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.
Overview
Section titled βOverviewβLinter rules enforce Azure API design guidelines on TypeSpec specifications. Rules live in one of these packages:
| Package path | Scope | npm name |
|---|---|---|
packages/typespec-client-generator-core | Client SDK generation rules | @azure-tools/typespec-client-generator-core |
packages/typespec-azure-core | Data-plane and shared Azure rules | @azure-tools/typespec-azure-core |
packages/typespec-azure-resource-manager | ARM-specific rules | @azure-tools/typespec-azure-resource-manager |
Where to put your rule
Section titled βWhere to put your ruleβtypespec-client-generator-coreβ Rules that pertain to decorators intypespec-client-generator-core, or are only about client SDK generationtypespec-azure-resource-managerβ Rules specific to ARM APIstypespec-azure-coreβ Rules that apply to both data-plane and ARM APIs, or only to data-plane APIs
Ruleset registration
Section titled βRuleset registrationβ- Rules that apply to ARM APIs β add to
resource-managerruleset - Rules that apply to data-plane APIs β add to
data-planeruleset - Rules can be in both rulesets if they apply to both
A complete rule usually touches 7+ files across 3+ packages:
- Rule implementation in
packages/<pkg>/src/rules/<rule-name>.ts - Linter registration in
packages/<pkg>/src/linter.ts - Tests in
packages/<pkg>/test/rules/<rule-name>.test.ts - Ruleset registration in
packages/typespec-azure-rulesets - Rule documentation in
packages/<pkg>/src/rules/<rule-name>.md(referenced from the rule viadocs) - Regenerated reference docs
- A changeset in
.chronus/changes/
Prerequisites
Section titled βPrerequisitesβBefore you start:
- Install Node.js 22+
- Enable pnpm via corepack
- Clone the repo with submodules
- Install dependencies with
pnpm install
corepack enablegit clone --recurse-submodules https://github.com/Azure/typespec-azure.gitcd typespec-azurepnpm installStep-by-Step Process
Section titled βStep-by-Step ProcessβStep 1: Plan the Rule
Section titled βStep 1: Plan the RuleβDecide the rule metadata before touching code:
| Decision | What to choose |
|---|---|
| Package | typespec-client-generator-core, typespec-azure-core, or typespec-azure-resource-manager |
| Rule name | Follow 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.
Rule Naming Guidelines
Section titled β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-enumoverno-enum-type-usage
Also review existing rules in packages/<pkg>/src/rules/ to match local
patterns for naming, visitors, diagnostics, and fixes.
Step 2: Scaffold the Files
Section titled βStep 2: Scaffold the FilesβUse the repo scaffold command:
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>.tspackages/<pkg>/test/rules/<rule-name>.test.tspackages/<pkg>/src/rules/<rule-name>.mdpackages/<pkg>/src/linter.ts
Step 3: Write Tests First (TDD)
Section titled βStep 3: Write Tests First (TDD)β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
ModelPropertyrules, include concrete examples such as simply defined properties, properties introduced through spread oris, and inherited properties - Boundary conditions that are specific to the rule logic
- At least one test verifying that library types in
Azure.CoreandAzure.ResourceManagerare not subject to the rule - Near-misses that should not trigger
Verify the tests fail before implementation:
pnpm --filter "@azure-tools/typespec-<pkg>..." buildpnpm --filter "@azure-tools/typespec-<pkg>..." testStep 4: Implement the Rule
Section titled βStep 4: Implement the Ruleβ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:
modelmodelPropertyoperationinterfaceenumenumMemberunionunionVariantnamespacescalar
Common implementation patterns:
- Use
context.reportDiagnostic({ target })to report on the exact symbol - Use
paramMessagefor interpolated messages - Use
defineCodeFixwhen 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.baseModelor 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)],});Step 5: Verify Tests Pass
Section titled βStep 5: Verify Tests PassβOnce the rule is implemented, rebuild and rerun tests:
pnpm --filter "@azure-tools/typespec-<pkg>..." buildpnpm --filter "@azure-tools/typespec-<pkg>..." testDo not move on until the new tests pass and existing tests for that package stay green.
Step 6: Register in Rulesets
Section titled βStep 6: Register in Rulesetsβ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-corerules generally go in both rulesetstypespec-azure-corerules 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:
pnpm --filter "@azure-tools/typespec-azure-rulesets..." buildpnpm --filter "@azure-tools/typespec-azure-rulesets..." testStep 7: Write Documentation
Section titled βStep 7: Write Documentationβ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
#### β Incorrectexamples#### β Correctexamples
Example docs markdown (packages/<pkg>/src/rules/my-new-rule.md):
Brief description of the rule.
#### β Incorrect
```tspmodel BadThing { badName: string;}```
#### β
Correct
```tspmodel GoodThing { goodName: string;}```After editing the rule docs, regenerate package docs:
pnpm --filter "@azure-tools/typespec-<pkg>..." buildpnpm --filter "@azure-tools/typespec-<pkg>" regen-docsStep 8: Create a Changeset
Section titled βStep 8: Create a ChangesetβCreate a changelog entry:
pnpm change addChoose:
featurefor a new rulefixfor a bug fix to an existing rule- Package:
@azure-tools/typespec-<pkg>
Step 9: Local Validation
Section titled βStep 9: Local ValidationβRun the repo validation flow:
pnpm validate:prThis checks:
- Branch sync
- Build
- Tests
- Lint
- Format
cspellspellingregen-docsfreshness- Changeset presence
- Diff cleanliness
Use --fix when you want auto-fixes for formatting and lint:
pnpm validate:pr --fixStep 10: External Integration Check
Section titled βStep 10: External Integration Checkβ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:
- Apply the
int:azure-specslabel. An agent can do this withgh pr edit --add-label "int:azure-specs". - If the agent cannot apply the label, instruct the user to apply it manually.
- Monitor the workflow with
gh run list --workflow=external-integration.ymland wait for the External Integration run. - Review any failures before requesting review.
The workflow:
- Builds and packs all
typespec-azurepackages from your PR - Checks out
azure-rest-api-specsmain - Patches the packages into that repo
- Runs
tsp-integration azure-specs --stage validate - 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:
- 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-specson the main branch. - Suppress the rule β If the spec cannot be fixed without changing API
behavior, add a
// suppresscomment. Suppressions can always go to the main branch. - 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.
- 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.
Reference
Section titled βReferenceβNaming Conventions
Section titled βNaming Conventionsβ| Item | Convention | Example |
|---|---|---|
| Rule name | kebab-case; omit package names; prefer no-* and use-* prefixes | no-nullable-key |
| Export variable | camelCase + Rule | noNullableKeyRule |
| Import extension | Always .js | from "./rules/no-nullable-key.js" |
| Diagnostic code | @azure-tools/typespec-<pkg>/<rule-name> | @azure-tools/typespec-azure-core/no-nullable-key |
| Rule URL | https://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-enumoverno-enum-type-usage
File Locations
Section titled βFile Locationsβ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.tswebsite/βββ src/content/docs/docs/libraries/ βββ <pkg>/rules/<rule-name>.md # generated by `regen-docs`Test Hosts
Section titled βTest Hostsβ| Package | Import | Notes |
|---|---|---|
typespec-client-generator-core | import { SimpleTester } from "../tester.js" | Client SDK generation context; other local testers are also available |
typespec-azure-core | import { TesterWithService } from "#test/test-host.js" | Wraps in @service namespace |
typespec-azure-resource-manager | import { Tester } from "#test/tester.js" | ARM context |
Common Compiler APIs
Section titled βCommon Compiler APIsβcreateRuleparamMessagedefineCodeFixgetServiceModelModelPropertyOperationNamespaceType
Useful Patterns from Existing Rules
Section titled βUseful Patterns from Existing Rulesβ- 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
Troubleshooting
Section titled βTroubleshootingβ| Problem | Cause | Fix |
|---|---|---|
validate-rules-defined fails | Rule not in ruleset | Add to data-plane.ts or resource-manager.ts |
| Tests canβt find rule | Import path wrong | Check the .js extension and linter.ts |
pnpm format fails | Prettier plugin not built | Run pnpm --filter "@typespec/prettier-plugin-typespec..." build |
| External Integration fails | Rule flags existing specs | Fix the spec, suppress the rule, or use typespec-next as needed |
regen-docs shows changes | Forgot regen-docs | Run it and commit the output |
| Changeset missing | pnpm change add not run | Run it and select the package |
Checklist
Section titled βChecklistβ- 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:prpasses -
int:azure-specsExternal Integration check passes - PR diff contains only expected files