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
website/src/content/docs/docs/libraries/<pkg>/rules/ - 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 | Kebab-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.
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.tswebsite/src/content/docs/docs/libraries/<pkg>/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, 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:
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 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 namecode block - A plain-language description
#### β Incorrectexamples#### β Correctexamples
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
```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 | 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 |
File Locations
Section titled βFile Locationsβ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.tswebsite/βββ 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>.mdTest 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