Skip to content

Creating Linter Rules

This guide covers the full lifecycle of adding a TypeSpec linter rule for Azure libraries in typespec-azure. It is written for both human developers and AI coding agents that need a repeatable, end-to-end workflow.

Linter rules enforce Azure API design guidelines on TypeSpec specifications. Rules live in one of these packages:

Package pathScopenpm name
packages/typespec-client-generator-coreClient SDK generation rules@azure-tools/typespec-client-generator-core
packages/typespec-azure-coreData-plane and shared Azure rules@azure-tools/typespec-azure-core
packages/typespec-azure-resource-managerARM-specific rules@azure-tools/typespec-azure-resource-manager
  • typespec-client-generator-core β€” Rules that pertain to decorators in typespec-client-generator-core, or are only about client SDK generation
  • typespec-azure-resource-manager β€” Rules specific to ARM APIs
  • typespec-azure-core β€” Rules that apply to both data-plane and ARM APIs, or only to data-plane APIs
  • Rules that apply to ARM APIs β†’ add to resource-manager ruleset
  • Rules that apply to data-plane APIs β†’ add to data-plane ruleset
  • Rules can be in both rulesets if they apply to both

A complete rule usually touches 7+ files across 3+ packages:

  1. Rule implementation in packages/<pkg>/src/rules/<rule-name>.ts
  2. Linter registration in packages/<pkg>/src/linter.ts
  3. Tests in packages/<pkg>/test/rules/<rule-name>.test.ts
  4. Ruleset registration in packages/typespec-azure-rulesets
  5. Rule documentation in website/src/content/docs/docs/libraries/<pkg>/rules/
  6. Regenerated reference docs
  7. A changeset in .chronus/changes/

Before you start:

  • Install Node.js 22+
  • Enable pnpm via corepack
  • Clone the repo with submodules
  • Install dependencies with pnpm install
Terminal window
corepack enable
git clone --recurse-submodules https://github.com/Azure/typespec-azure.git
cd typespec-azure
pnpm install

Decide the rule metadata before touching code:

DecisionWhat to choose
Packagetypespec-client-generator-core, typespec-azure-core, or typespec-azure-resource-manager
Rule nameKebab-case, for example no-nullable-key
Target ruleset(s)Data-plane, resource-manager, or both
Enabled by default?true or false

All linter rules use warning severity. This is not configurable.

Also review existing rules in packages/<pkg>/src/rules/ to match local patterns for naming, visitors, diagnostics, and fixes.

Use the repo scaffold command:

Terminal window
pnpm create:linter-rule <rule-name> --package <azure-core|azure-resource-manager|client-generator-core> --description "What the rule enforces"

All linter rules use warning severity, so the scaffold command does not accept a severity flag.

It creates or updates the main rule artifacts:

  • Rule source
  • Rule test
  • Rule docs page
  • linter.ts

Concretely, expect changes like:

  • packages/<pkg>/src/rules/<rule-name>.ts
  • packages/<pkg>/test/rules/<rule-name>.test.ts
  • website/src/content/docs/docs/libraries/<pkg>/rules/<rule-name>.md
  • packages/<pkg>/src/linter.ts

Write or refine tests before implementing the rule logic.

For data-plane rules, start from the Azure Core test host:

import { TesterWithService } from "#test/test-host.js";
import { LinterRuleTester, createLinterRuleTester } from "@typespec/compiler/testing";
import { beforeEach, describe, it } from "vitest";
import { myNewRule } from "../../src/rules/my-new-rule.js";
let tester: LinterRuleTester;
beforeEach(async () => {
const runner = await TesterWithService.createInstance();
tester = createLinterRuleTester(runner, myNewRule, "@azure-tools/typespec-azure-core");
});

For ARM rules, use the ARM tester:

import { Tester } from "#test/tester.js";
import { LinterRuleTester, createLinterRuleTester } from "@typespec/compiler/testing";
import { beforeEach } from "vitest";
import { myArmRule } from "../../src/rules/my-arm-rule.js";
let tester: LinterRuleTester;
beforeEach(async () => {
const runner = await Tester.createInstance();
tester = createLinterRuleTester(
runner,
myArmRule,
"@azure-tools/typespec-azure-resource-manager",
);
});

For client generator rules, start from one of the package-local testers:

import { LinterRuleTester, createLinterRuleTester } from "@typespec/compiler/testing";
import { beforeEach } from "vitest";
import { myClientRule } from "../../src/rules/my-client-rule.js";
import { SimpleTester } from "../tester.js";
let tester: LinterRuleTester;
beforeEach(async () => {
const runner = await SimpleTester.createInstance();
tester = createLinterRuleTester(
runner,
myClientRule,
"@azure-tools/typespec-client-generator-core",
);
});

Core assertion patterns:

await tester.expect(`model Pet { name: string; }`).toBeValid();
await tester.expect(`model BadPet { owner: string | null; }`).toEmitDiagnostics([
{
code: "@azure-tools/typespec-azure-core/my-new-rule",
severity: "warning",
message: "Expected diagnostic message",
},
]);
await tester.expect(`enum Color { red }`).applyCodeFix("enum-to-extensible-union").toEqual(`
union Color {
string,
red: "red",
}
`);

What to test:

  • Valid code that must stay silent
  • Invalid code that must emit the diagnostic
  • Equivalence classes: group inputs by how the rule handles them
  • For ModelProperty rules, include concrete examples such as simply defined properties, properties introduced through spread or is, and inherited properties
  • Boundary conditions that are specific to the rule logic
  • At least one test verifying that library types in Azure.Core and Azure.ResourceManager are not subject to the rule
  • Near-misses that should not trigger

Verify the tests fail before implementation:

Terminal window
pnpm --filter "@azure-tools/typespec-<pkg>..." build
pnpm --filter "@azure-tools/typespec-<pkg>..." test

A typical rule is built with createRule() and one or more semantic visitor hooks:

import {
ModelProperty,
Namespace,
createRule,
defineCodeFix,
getService,
getSourceLocation,
paramMessage,
} from "@typespec/compiler";
function createReadOnlyFix(property: ModelProperty) {
return defineCodeFix({
id: "add-readonly",
label: "Add @visibility(Lifecycle.Read)",
fix: (fixContext) => {
if (property.node === undefined) return [];
return fixContext.prependText(
getSourceLocation(property.node),
"@visibility(Lifecycle.Read)\n",
);
},
});
}
export const myNewRule = createRule({
name: "my-new-rule",
description: "Require a specific Azure shape.",
severity: "warning",
url: "https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/my-new-rule",
messages: {
default: "This type violates the rule.",
withName: paramMessage`Property '${"propertyName"}' must meet the Azure guideline.`,
},
create(context) {
return {
modelProperty(property: ModelProperty) {
if (property.node === undefined) return;
if (property.model?.namespace) {
const service = getService(context.program, property.model.namespace as Namespace);
if (service === undefined) return;
}
if (property.name === "badName") {
context.reportDiagnostic({
target: property,
messageId: "withName",
format: { propertyName: property.name },
codefixes: [createReadOnlyFix(property)],
});
}
},
};
},
});

Common visitor hooks:

  • model
  • modelProperty
  • operation
  • interface
  • enum
  • enumMember
  • union
  • unionVariant
  • namespace
  • scalar

Common implementation patterns:

  • Use context.reportDiagnostic({ target }) to report on the exact symbol
  • Use paramMessage for interpolated messages
  • Use defineCodeFix when a deterministic fix exists
  • Skip template declarations or template signatures when the declaration itself is not the real target
  • Check whether a type belongs to a service namespace before reporting
  • Walk hierarchy when needed, such as model.baseModel or derived types

A minimal diagnostic looks like this:

context.reportDiagnostic({
target: property,
});

A parameterized diagnostic looks like this:

context.reportDiagnostic({
target: property,
messageId: "withName",
format: { propertyName: property.name },
});

A code-fix-enabled diagnostic looks like this:

context.reportDiagnostic({
target: property,
codefixes: [createReadOnlyFix(property)],
});

Once the rule is implemented, rebuild and rerun tests:

Terminal window
pnpm --filter "@azure-tools/typespec-<pkg>..." build
pnpm --filter "@azure-tools/typespec-<pkg>..." test

Do not move on until the new tests pass and existing tests for that package stay green.

Add the rule to the appropriate ruleset file or files:

  • Data-plane: packages/typespec-azure-rulesets/src/rulesets/data-plane.ts
  • ARM: packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts

Ruleset guidance:

  • typespec-client-generator-core rules generally go in both rulesets
  • typespec-azure-core rules intended for both ARM and data-plane also go in both rulesets
  • Only rules that are truly ARM-specific go exclusively in the resource-manager ruleset
  • Data-plane-only rules go only in the data-plane ruleset

Entry format:

"@azure-tools/typespec-<pkg>/<rule-name>": true,

Use false instead of true when the rule should ship but stay disabled by default.

The test validate-rules-defined.test.ts catches missing ruleset entries.

Verify ruleset registration:

Terminal window
pnpm --filter "@azure-tools/typespec-azure-rulesets..." build
pnpm --filter "@azure-tools/typespec-azure-rulesets..." test

Each rule needs its own rule page:

  • website/src/content/docs/docs/libraries/<pkg>/rules/<rule-name>.md

That page should include:

  • Frontmatter with a title
  • A Full name code block
  • A plain-language description
  • #### ❌ Incorrect examples
  • #### βœ… Correct examples

Example rule doc shape:

---
title: "my-new-rule"
---
```text title="Full name"
@azure-tools/typespec-azure-core/my-new-rule
```
Brief description of the rule.
#### ❌ Incorrect
```tsp
model BadThing {
badName: string;
}
```
#### βœ… Correct
```tsp
model GoodThing {
goodName: string;
}
```

After editing the rule docs, regenerate package docs:

Terminal window
pnpm --filter "@azure-tools/typespec-<pkg>..." build
pnpm --filter "@azure-tools/typespec-<pkg>" regen-docs

Create a changelog entry:

Terminal window
pnpm change add

Choose:

  • feature for a new rule
  • fix for a bug fix to an existing rule
  • Package: @azure-tools/typespec-<pkg>

Run the repo validation flow:

Terminal window
pnpm validate:pr

This checks:

  • Branch sync
  • Build
  • Tests
  • Lint
  • Format
  • cspell spelling
  • regen-docs freshness
  • Changeset presence
  • Diff cleanliness

Use --fix when you want auto-fixes for formatting and lint:

Terminal window
pnpm validate:pr --fix

This step is critical for linter rules. New diagnostics must not break existing Azure service specs in Azure/azure-rest-api-specs.

After opening the PR:

  1. Apply the int:azure-specs label. An agent can do this with gh pr edit --add-label "int:azure-specs".
  2. If the agent cannot apply the label, instruct the user to apply it manually.
  3. Monitor the workflow with gh run list --workflow=external-integration.yml and wait for the External Integration run.
  4. Review any failures before requesting review.

The workflow:

  1. Builds and packs all typespec-azure packages from your PR
  2. Checks out azure-rest-api-specs main
  3. Patches the packages into that repo
  4. Runs tsp-integration azure-specs --stage validate
  5. Checks for unexpected changes

If the External Integration check fails, your rule produces diagnostics on existing specs in Azure/azure-rest-api-specs. To resolve this:

  1. Apply an API-neutral fix to the spec (preferred) β€” If the fix doesn’t change API behavior (for example, adding a decorator or renaming a type to follow conventions), submit a PR to Azure/azure-rest-api-specs on the main branch.
  2. Suppress the rule β€” If the spec cannot be fixed without changing API behavior, add a // suppress comment. Suppressions can always go to the main branch.
  3. Fix on typespec-next branch β€” If the fix requires unreleased TypeSpec APIs, types, or behavior that aren’t yet available in the published packages, submit the fix to the typespec-next branch, which uses nightly TypeSpec builds.
  4. Link your spec fix PR β€” Always link the PR that fixes the spec failures in your linter rule PR description. Reviewers need to see both together.

For local experimentation, see Testing a change in azure-rest-api-specs.

ItemConventionExample
Rule namekebab-caseno-nullable-key
Export variablecamelCase + RulenoNullableKeyRule
Import extensionAlways .jsfrom "./rules/no-nullable-key.js"
Diagnostic code@azure-tools/typespec-<pkg>/<rule-name>@azure-tools/typespec-azure-core/no-nullable-key
Rule URLhttps://azure.github.io/typespec-azure/docs/libraries/<pkg>/rules/<rule-name>https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/no-nullable-key
packages/
β”œβ”€β”€ typespec-client-generator-core/
β”‚ β”œβ”€β”€ src/
β”‚ β”‚ β”œβ”€β”€ linter.ts
β”‚ β”‚ └── rules/
β”‚ β”‚ └── <rule-name>.ts
β”‚ └── test/
β”‚ β”œβ”€β”€ tester.ts
β”‚ └── rules/
β”‚ └── <rule-name>.test.ts
β”œβ”€β”€ typespec-azure-core/
β”‚ β”œβ”€β”€ src/
β”‚ β”‚ β”œβ”€β”€ linter.ts
β”‚ β”‚ └── rules/
β”‚ β”‚ └── <rule-name>.ts
β”‚ └── test/
β”‚ β”œβ”€β”€ test-host.ts
β”‚ └── rules/
β”‚ └── <rule-name>.test.ts
β”œβ”€β”€ typespec-azure-resource-manager/
β”‚ β”œβ”€β”€ src/
β”‚ β”‚ β”œβ”€β”€ linter.ts
β”‚ β”‚ └── rules/
β”‚ β”‚ └── <rule-name>.ts
β”‚ └── test/
β”‚ β”œβ”€β”€ tester.ts
β”‚ └── rules/
β”‚ └── <rule-name>.test.ts
β”œβ”€β”€ typespec-azure-rulesets/
β”‚ β”œβ”€β”€ src/rulesets/data-plane.ts
β”‚ └── src/rulesets/resource-manager.ts
website/
└── src/content/docs/docs/libraries/
β”œβ”€β”€ azure-core/rules/<rule-name>.md
β”œβ”€β”€ azure-resource-manager/rules/<rule-name>.md
└── typespec-client-generator-core/rules/<rule-name>.md
PackageImportNotes
typespec-client-generator-coreimport { SimpleTester } from "../tester.js"Client SDK generation context; other local testers are also available
typespec-azure-coreimport { TesterWithService } from "#test/test-host.js"Wraps in @service namespace
typespec-azure-resource-managerimport { Tester } from "#test/tester.js"ARM context
  • createRule
  • paramMessage
  • defineCodeFix
  • getService
  • Model
  • ModelProperty
  • Operation
  • Namespace
  • Type
  • Check decorators
  • Walk model hierarchy
  • Check whether the symbol is in a service namespace
  • Inspect property type details
  • Skip template declarations or uninstantiated template signatures
ProblemCauseFix
validate-rules-defined failsRule not in rulesetAdd to data-plane.ts or resource-manager.ts
Tests can’t find ruleImport path wrongCheck the .js extension and linter.ts
pnpm format failsPrettier plugin not builtRun pnpm --filter "@typespec/prettier-plugin-typespec..." build
External Integration failsRule flags existing specsFix the spec, suppress the rule, or use typespec-next as needed
regen-docs shows changesForgot regen-docsRun it and commit the output
Changeset missingpnpm change add not runRun it and select the package
  • Rule implementation complete, handles edge cases
  • Tests cover valid, invalid, edge cases (β‰₯95% branch coverage)
  • Rule registered in linter.ts
  • Rule listed in appropriate ruleset(s)
  • Documentation has realistic examples
  • Reference docs regenerated
  • Changeset created
  • pnpm validate:pr passes
  • int:azure-specs External Integration check passes
  • PR diff contains only expected files