From e353b816d6f7e058dbf4bc80237f51e7dec33450 Mon Sep 17 00:00:00 2001 From: Haiyuan Zhang Date: Wed, 20 May 2026 00:37:01 +0800 Subject: [PATCH] Add model-name-request-suffix linter rule New lint rule that flags model names ending with Request and suggests using Content suffix instead, per Azure SDK .NET naming conventions. Includes auto-fix codefix that inserts @clientName with Content suffix. Registered in TCGC best-practices:csharp ruleset and resource-manager mega-ruleset. Closes #4448 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...e-request-suffix-rule-2026-5-19-16-30-0.md | 8 ++ .../src/rulesets/resource-manager.ts | 1 + .../src/linter.ts | 10 +- .../rules/model-name-request-suffix.rule.ts | 69 ++++++++++ .../rules/model-name-request-suffix.test.ts | 121 ++++++++++++++++++ .../rules/model-name-request-suffix.md | 60 +++++++++ 6 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/add-model-name-request-suffix-rule-2026-5-19-16-30-0.md create mode 100644 packages/typespec-client-generator-core/src/rules/model-name-request-suffix.rule.ts create mode 100644 packages/typespec-client-generator-core/test/rules/model-name-request-suffix.test.ts create mode 100644 website/src/content/docs/docs/libraries/typespec-client-generator-core/rules/model-name-request-suffix.md diff --git a/.chronus/changes/add-model-name-request-suffix-rule-2026-5-19-16-30-0.md b/.chronus/changes/add-model-name-request-suffix-rule-2026-5-19-16-30-0.md new file mode 100644 index 0000000000..767c2c131a --- /dev/null +++ b/.chronus/changes/add-model-name-request-suffix-rule-2026-5-19-16-30-0.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-client-generator-core" + - "@azure-tools/typespec-azure-rulesets" +--- + +Add `model-name-request-suffix` linter rule that flags model names ending with `Request` and suggests using `Content` suffix instead, per Azure SDK .NET naming conventions. Includes auto-fix via `@clientName`. diff --git a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts index ba2b21abe0..9412c2b82c 100644 --- a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts +++ b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts @@ -98,5 +98,6 @@ export default { "@azure-tools/typespec-client-generator-core/require-client-suffix": true, "@azure-tools/typespec-client-generator-core/property-name-conflict": true, "@azure-tools/typespec-client-generator-core/no-unnamed-types": false, // Too bad performance https://github.com/Azure/typespec-azure/issues/2803 + "@azure-tools/typespec-client-generator-core/model-name-request-suffix": true, }, } satisfies LinterRuleSet; diff --git a/packages/typespec-client-generator-core/src/linter.ts b/packages/typespec-client-generator-core/src/linter.ts index 737455f880..56ce67d4df 100644 --- a/packages/typespec-client-generator-core/src/linter.ts +++ b/packages/typespec-client-generator-core/src/linter.ts @@ -1,11 +1,17 @@ import { defineLinter } from "@typespec/compiler"; +import { modelNameRequestSuffixRule } from "./rules/model-name-request-suffix.rule.js"; import { noUnnamedTypesRule } from "./rules/no-unnamed-types.rule.js"; import { propertyNameConflictRule } from "./rules/property-name-conflict.rule.js"; import { requireClientSuffixRule } from "./rules/require-client-suffix.rule.js"; -const rules = [requireClientSuffixRule, propertyNameConflictRule, noUnnamedTypesRule]; +const rules = [ + requireClientSuffixRule, + propertyNameConflictRule, + noUnnamedTypesRule, + modelNameRequestSuffixRule, +]; -const csharpRules = [propertyNameConflictRule]; +const csharpRules = [propertyNameConflictRule, modelNameRequestSuffixRule]; export const $linter = defineLinter({ rules, diff --git a/packages/typespec-client-generator-core/src/rules/model-name-request-suffix.rule.ts b/packages/typespec-client-generator-core/src/rules/model-name-request-suffix.rule.ts new file mode 100644 index 0000000000..1d18341924 --- /dev/null +++ b/packages/typespec-client-generator-core/src/rules/model-name-request-suffix.rule.ts @@ -0,0 +1,69 @@ +import { + createRule, + defineCodeFix, + getSourceLocation, + Model, + paramMessage, +} from "@typespec/compiler"; +import { createTCGCContext } from "../context.js"; +import { getLibraryName } from "../public-utils.js"; + +function createReplaceRequestWithContentCodeFix(target: Model, csharpName: string) { + const newName = csharpName.replace(/Request$/, "Content"); + return defineCodeFix({ + id: "replace-request-with-content", + label: `Add @clientName("${newName}", "csharp")`, + fix: (fixContext) => { + const location = getSourceLocation(target); + const text = location.file.text; + let lineStart = location.pos; + while (lineStart > 0 && text[lineStart - 1] !== "\n") { + lineStart--; + } + let indentEnd = lineStart; + while (indentEnd < text.length && (text[indentEnd] === " " || text[indentEnd] === "\t")) { + indentEnd++; + } + const indent = text.slice(lineStart, indentEnd); + const updatedLocation = { ...location, pos: lineStart }; + return fixContext.prependText( + updatedLocation, + `${indent}@clientName("${newName}", "csharp")\n`, + ); + }, + }); +} + +export const modelNameRequestSuffixRule = createRule({ + name: "model-name-request-suffix", + description: "Model names should not end with 'Request'. Use 'Content' suffix instead.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/model-name-request-suffix", + messages: { + default: paramMessage`Model name '${"name"}' ends with 'Request'. Consider renaming it to '${"suggestion"}' or use @clientName("${"suggestion"}", "csharp") to rename it for C#.`, + }, + create(context) { + const tcgcContext = createTCGCContext( + context.program, + "@azure-tools/typespec-client-generator-core", + { + mutateNamespace: false, + }, + ); + return { + model: (model: Model) => { + const csharpName = getLibraryName(tcgcContext, model, "csharp"); + if (!csharpName.endsWith("Request")) return; + const suggestion = csharpName.replace(/Request$/, "Content"); + context.reportDiagnostic({ + format: { + name: csharpName, + suggestion, + }, + target: model, + codefixes: [createReplaceRequestWithContentCodeFix(model, csharpName)], + }); + }, + }; + }, +}); diff --git a/packages/typespec-client-generator-core/test/rules/model-name-request-suffix.test.ts b/packages/typespec-client-generator-core/test/rules/model-name-request-suffix.test.ts new file mode 100644 index 0000000000..a561025974 --- /dev/null +++ b/packages/typespec-client-generator-core/test/rules/model-name-request-suffix.test.ts @@ -0,0 +1,121 @@ +import { + createLinterRuleTester, + LinterRuleTester, + TesterInstance, +} from "@typespec/compiler/testing"; +import { beforeEach, describe, it } from "vitest"; +import { modelNameRequestSuffixRule } from "../../src/rules/model-name-request-suffix.rule.js"; +import { SimpleTester } from "../tester.js"; + +let runner: TesterInstance; +let tester: LinterRuleTester; + +beforeEach(async () => { + runner = await SimpleTester.createInstance(); + tester = createLinterRuleTester( + runner, + modelNameRequestSuffixRule, + "@azure-tools/typespec-client-generator-core", + ); +}); + +it("emits warning when model name ends with Request", async () => { + await tester + .expect( + `model PredictionRequest { + value: string; + }`, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-client-generator-core/model-name-request-suffix", + message: `Model name 'PredictionRequest' ends with 'Request'. Consider renaming it to 'PredictionContent' or use @clientName("PredictionContent", "csharp") to rename it for C#.`, + }); +}); + +it("is valid when model name does not end with Request", async () => { + await tester + .expect( + `model PredictionContent { + value: string; + }`, + ) + .toBeValid(); +}); + +it("is valid when model name ends with Requests (plural)", async () => { + await tester + .expect( + `model BatchRequests { + items: string[]; + }`, + ) + .toBeValid(); +}); + +it("is valid when @clientName overrides to Content suffix", async () => { + await tester + .expect( + `@clientName("PredictionContent", "csharp") + model PredictionRequest { + value: string; + }`, + ) + .toBeValid(); +}); + +it("emits warning when @clientName still ends with Request", async () => { + await tester + .expect( + `@clientName("MyPredictionRequest", "csharp") + model SomePrediction { + value: string; + }`, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-client-generator-core/model-name-request-suffix", + message: `Model name 'MyPredictionRequest' ends with 'Request'. Consider renaming it to 'MyPredictionContent' or use @clientName("MyPredictionContent", "csharp") to rename it for C#.`, + }); +}); + +it("does not flag model named exactly Request", async () => { + await tester + .expect( + `model Request { + value: string; + }`, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-client-generator-core/model-name-request-suffix", + }); +}); + +it("is case-sensitive - does not flag lowercase request", async () => { + await tester + .expect( + `model Myrequest { + value: string; + }`, + ) + .toBeValid(); +}); + +it("does not flag non-model types", async () => { + await tester.expect(`scalar RequestId extends string;`).toBeValid(); +}); + +describe("codefix", () => { + it("offers replace Request with Content codefix", async () => { + await tester + .expect( + ` + model PredictionRequest { + value: string; + }`, + ) + .applyCodeFix("replace-request-with-content").toEqual(` + @clientName("PredictionContent", "csharp") + model PredictionRequest { + value: string; + }`); + }); +}); diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/rules/model-name-request-suffix.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/rules/model-name-request-suffix.md new file mode 100644 index 0000000000..d1881bcd9e --- /dev/null +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/rules/model-name-request-suffix.md @@ -0,0 +1,60 @@ +--- +title: model-name-request-suffix +--- + +```text title="Full name" +@azure-tools/typespec-client-generator-core/model-name-request-suffix +``` + +Model names should not end with `Request`. The +[Azure SDK for .NET management plane naming convention](https://github.com/Azure/azure-sdk-for-net/blob/main/doc/dev/Mgmt-Naming-Conventions.md) +requires using the `Content` suffix instead. This rule is part of the +`best-practices:csharp` ruleset. + +The rule inspects the C#-scoped name of each model (the value of +`@clientName(..., "csharp")` if provided, otherwise the TypeSpec name). +If it ends with `Request`, a warning is emitted suggesting the `Content` +suffix instead. + +To fix violations, either rename the model in TypeSpec when the +convention applies cross-language, or use +`@clientName("Content", "csharp")` to provide a C#-only override +while keeping the original TypeSpec name. + +#### ❌ Incorrect + +```tsp +model PredictionRequest { + value: string; +} + +model CheckNameAvailabilityRequest { + name: string; +} +``` + +#### ✅ Correct (rename in TypeSpec) + +```tsp +model PredictionContent { + value: string; +} + +model CheckNameAvailabilityContent { + name: string; +} +``` + +#### ✅ Correct (rename for C# only) + +```tsp +@clientName("PredictionContent", "csharp") +model PredictionRequest { + value: string; +} + +@clientName("CheckNameAvailabilityContent", "csharp") +model CheckNameAvailabilityRequest { + name: string; +} +```