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 packages/<pkg>/src/rules/<rule-name>.md (referenced from the rule via docs)
  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 nameFollow the rule naming guidelines below
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.

  • Use kebab-case (lowercase letters, numbers, hyphens only)
  • DO NOT include the package name in the rule ID β€” the package is already part of the fully-qualified diagnostic code (e.g., @azure-tools/typespec-azure-core/no-nullable, not @azure-tools/typespec-azure-core/azure-core-no-nullable)
  • Use no-<thing> when the rule bans a construct or usage (e.g., no-nullable, no-enum, no-format)
  • Use use-<preferred-thing> when the rule points users toward a standard or preferred pattern (e.g., use-standard-operations, use-extensible-enum)
  • Keep names short and concise β€” prefer no-enum over no-enum-type-usage

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
  • packages/<pkg>/src/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,
fileRef,
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",
docs: fileRef.fromPackageRoot("src/rules/my-new-rule.md"),
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’s extended documentation lives in a markdown file next to the rule implementation and is referenced from the rule via the docs field (docs: fileRef.fromPackageRoot("src/rules/<rule-name>.md")):

  • packages/<pkg>/src/rules/<rule-name>.md

tspd renders one reference page per rule at website/src/content/docs/docs/libraries/<pkg>/rules/<rule-name>.md. The generated page starts with the rule id and its description, followed by the content of the docs markdown file. The docs file therefore contains only the extended documentation (no frontmatter and no Full name code block β€” those are generated):

  • A plain-language explanation
  • #### ❌ Incorrect examples
  • #### βœ… Correct examples

Example docs markdown (packages/<pkg>/src/rules/my-new-rule.md):

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-case; omit package names; prefer no-* and use-* prefixesno-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

Rule Naming Guidelines:

  • Use kebab-case (lowercase letters, numbers, hyphens only)
  • DO NOT include the package name in the rule ID β€” the package is already part of the fully-qualified diagnostic code (e.g., @azure-tools/typespec-azure-core/no-nullable, not @azure-tools/typespec-azure-core/azure-core-no-nullable)
  • Use no-<thing> when the rule bans a construct or usage (e.g., no-nullable, no-enum, no-format)
  • Use use-<preferred-thing> when the rule points users toward a standard or preferred pattern (e.g., use-standard-operations, use-extensible-enum)
  • Keep names short and concise β€” prefer no-enum over no-enum-type-usage
packages/
β”œβ”€β”€ typespec-client-generator-core/
β”‚ β”œβ”€β”€ src/
β”‚ β”‚ β”œβ”€β”€ linter.ts
β”‚ β”‚ └── rules/
β”‚ β”‚ β”œβ”€β”€ <rule-name>.ts
β”‚ β”‚ └── <rule-name>.md
β”‚ └── test/
β”‚ β”œβ”€β”€ tester.ts
β”‚ └── rules/
β”‚ └── <rule-name>.test.ts
β”œβ”€β”€ typespec-azure-core/
β”‚ β”œβ”€β”€ src/
β”‚ β”‚ β”œβ”€β”€ linter.ts
β”‚ β”‚ └── rules/
β”‚ β”‚ β”œβ”€β”€ <rule-name>.ts
β”‚ β”‚ └── <rule-name>.md
β”‚ └── test/
β”‚ β”œβ”€β”€ test-host.ts
β”‚ └── rules/
β”‚ └── <rule-name>.test.ts
β”œβ”€β”€ typespec-azure-resource-manager/
β”‚ β”œβ”€β”€ src/
β”‚ β”‚ β”œβ”€β”€ linter.ts
β”‚ β”‚ └── rules/
β”‚ β”‚ β”œβ”€β”€ <rule-name>.ts
β”‚ β”‚ └── <rule-name>.md
β”‚ └── 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/
└── <pkg>/rules/<rule-name>.md # generated by `regen-docs`
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