diff --git a/.chronus/changes/add-single-word-model-name-rule-2026-7-7-19-30-0.md b/.chronus/changes/add-single-word-model-name-rule-2026-7-7-19-30-0.md new file mode 100644 index 0000000000..7b02337cb1 --- /dev/null +++ b/.chronus/changes/add-single-word-model-name-rule-2026-7-7-19-30-0.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-client-generator-core" + - "@azure-tools/typespec-azure-rulesets" +--- + +Add `csharp-no-single-word-model-name` linter rule for C# SDK model naming, with optional AI-powered quick fix suggestions in editors. diff --git a/core b/core index 6b421a5cbc..ed8c1ebdc4 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 6b421a5cbc59583cc9c52f3e180196816071bc1a +Subproject commit ed8c1ebdc4b95a1019b75688d31fbf87d82f36a0 diff --git a/packages/typespec-azure-rulesets/src/rulesets/client-sdk.ts b/packages/typespec-azure-rulesets/src/rulesets/client-sdk.ts index de55e0c3c4..e6c8b9cb9b 100644 --- a/packages/typespec-azure-rulesets/src/rulesets/client-sdk.ts +++ b/packages/typespec-azure-rulesets/src/rulesets/client-sdk.ts @@ -5,5 +5,6 @@ import type { LinterRuleSet } from "@typespec/compiler"; export default { enable: { "@azure-tools/typespec-client-generator-core/csharp-no-url-suffix": true, + "@azure-tools/typespec-client-generator-core/csharp-no-single-word-model-name": true, }, } satisfies LinterRuleSet; diff --git a/packages/typespec-azure-rulesets/test/validate-rules-defined.test.ts b/packages/typespec-azure-rulesets/test/validate-rules-defined.test.ts index 67f682e4e5..20d6f12d89 100644 --- a/packages/typespec-azure-rulesets/test/validate-rules-defined.test.ts +++ b/packages/typespec-azure-rulesets/test/validate-rules-defined.test.ts @@ -51,9 +51,14 @@ describe("expect all rules to be defined", () => { }); }); - it("client-sdk enables csharp-no-url-suffix", () => { + it("client-sdk enables client SDK rules", () => { const ruleset = $linter.ruleSets?.["client-sdk"]; ok(ruleset); ok(ruleset.enable?.["@azure-tools/typespec-client-generator-core/csharp-no-url-suffix"]); + ok( + ruleset.enable?.[ + "@azure-tools/typespec-client-generator-core/csharp-no-single-word-model-name" + ], + ); }); }); diff --git a/packages/typespec-client-generator-core/src/linter.ts b/packages/typespec-client-generator-core/src/linter.ts index 912283c4f8..8f5156ad7a 100644 --- a/packages/typespec-client-generator-core/src/linter.ts +++ b/packages/typespec-client-generator-core/src/linter.ts @@ -1,4 +1,5 @@ import { defineLinter } from "@typespec/compiler"; +import { csharpNoSingleWordModelNameRule } from "./rules/csharp-no-single-word-model-name.js"; import { csharpNoUrlSuffixRule } from "./rules/csharp-no-url-suffix.js"; import { noUnnamedTypesRule } from "./rules/no-unnamed-types.rule.js"; import { propertyNameConflictRule } from "./rules/property-name-conflict.rule.js"; @@ -9,9 +10,14 @@ const rules = [ propertyNameConflictRule, noUnnamedTypesRule, csharpNoUrlSuffixRule, + csharpNoSingleWordModelNameRule, ]; -const csharpRules = [propertyNameConflictRule, csharpNoUrlSuffixRule]; +const csharpRules = [ + propertyNameConflictRule, + csharpNoUrlSuffixRule, + csharpNoSingleWordModelNameRule, +]; export const $linter = defineLinter({ rules, diff --git a/packages/typespec-client-generator-core/src/rules/csharp-no-single-word-model-name.ts b/packages/typespec-client-generator-core/src/rules/csharp-no-single-word-model-name.ts new file mode 100644 index 0000000000..34424a8451 --- /dev/null +++ b/packages/typespec-client-generator-core/src/rules/csharp-no-single-word-model-name.ts @@ -0,0 +1,199 @@ +import { + CodeFix, + Model, + Program, + createRule, + getDoc, + getNamespaceFullName, + getSourceLocation, + paramMessage, +} from "@typespec/compiler"; +import { SyntaxKind } from "@typespec/compiler/ast"; +import { createTCGCContext } from "../context.js"; +import { getLibraryName } from "../public-utils.js"; +import { createClientTspAugmentDecoratorCodeFix } from "./codefix-helpers.js"; + +interface ChatCompletionConnection { + sendRequest( + requestName: "custom/chatCompletion", + params: { + messages: { role: "user"; message: string }[]; + modelFamily: string; + id: string; + }, + ): Promise; +} + +interface DynamicCodeFix extends CodeFix { + resolveCodefixes?: () => Promise; +} + +function splitPascalCase(name: string): string[] { + const words: string[] = []; + let current = ""; + + for (let i = 0; i < name.length; i++) { + const char = name[i]; + const isUpper = char >= "A" && char <= "Z"; + const nextIsLower = i + 1 < name.length && name[i + 1] >= "a" && name[i + 1] <= "z"; + + if (isUpper && current.length > 0) { + const prevIsLower = current[current.length - 1] >= "a" && current[current.length - 1] <= "z"; + if (prevIsLower || nextIsLower) { + words.push(current); + current = char; + } else { + current += char; + } + } else { + current += char; + } + } + if (current.length > 0) words.push(current); + return words; +} + +function isSingleWord(name: string): boolean { + if (name.length <= 1) return false; + if (!/^[A-Z]/.test(name)) return false; + return splitPascalCase(name).length <= 1; +} + +function getLspConnection(): ChatCompletionConnection | undefined { + const connection = (globalThis as { lspConnection?: unknown }).lspConnection; + if (typeof connection !== "object" || connection === null) return undefined; + const sendRequest = (connection as Partial).sendRequest; + if (typeof sendRequest !== "function") return undefined; + return connection as ChatCompletionConnection; +} + +function extractModelSource(model: Model): string { + if (model.node === undefined || model.node.kind !== SyntaxKind.ModelStatement) return ""; + const location = getSourceLocation(model.node); + return location.file.text.slice(model.node.pos, model.node.end).trim(); +} + +function getModelDocumentation(program: Program, model: Model): string { + return getDoc(program, model)?.trim() ?? ""; +} + +function parseSuggestions(response: string): string[] { + return [ + ...new Set( + response + .split("\n") + .map((line) => + line + .trim() + .replace(/^`+|`+$/g, "") + .replace(/^\d+\.\s*/, "") + .trim(), + ) + .filter((name) => name && /^[A-Z][a-zA-Z0-9]*$/.test(name) && !isSingleWord(name)), + ), + ].slice(0, 5); +} + +async function fetchAiNameSuggestions( + program: Program, + model: Model, + csharpName: string, +): Promise { + const connection = getLspConnection(); + if (connection === undefined) { + return []; + } + + const namespaceName = model.namespace ? getNamespaceFullName(model.namespace) : ""; + const modelDocumentation = getModelDocumentation(program, model); + const documentationSection = + modelDocumentation === "" ? "" : `\nModel documentation:\n${modelDocumentation}\n`; + const prompt = `You are a .NET naming expert. A TypeSpec model named "${csharpName}" in namespace "${namespaceName}" is a single word that may collide with .NET types. +${documentationSection} +Model definition: +${extractModelSource(model)} + +Suggest exactly 5 better multi-word PascalCase names. Reply with only the names, one per line.`; + + try { + const result = await connection.sendRequest("custom/chatCompletion", { + messages: [{ role: "user", message: prompt }], + modelFamily: "claude-opus-4.6", + id: `csharp-no-single-word-model-name-${namespaceName}.${csharpName}`, + }); + return typeof result === "string" ? parseSuggestions(result) : []; + } catch { + return []; + } +} + +function createClientNameCodeFix( + model: Model, + program: Program, + csharpName: string, + name: string, + index: number, +): CodeFix { + const fix = createClientTspAugmentDecoratorCodeFix(model, "clientName", program, [ + `"${name}"`, + `"csharp"`, + ]); + return { + ...fix, + id: `${fix.id}-${index}`, + label: `Rename '${csharpName}' to '${name}' in client.tsp`, + }; +} + +function createAiClientNameCodeFix(model: Model, program: Program, csharpName: string): CodeFix { + const fix: DynamicCodeFix = { + id: "ai-rename-single-word-model", + label: "Suggest multi-word C# names", + fix: () => [], + resolveCodefixes: async () => { + const suggestions = await fetchAiNameSuggestions(program, model, csharpName); + const names = + suggestions.length > 0 + ? suggestions + : [`${model.namespace?.name ?? "Service"}${csharpName}`].filter((x) => !isSingleWord(x)); + return names.map((name, index) => + createClientNameCodeFix(model, program, csharpName, name, index), + ); + }, + }; + return fix; +} + +export const csharpNoSingleWordModelNameRule = createRule({ + name: "csharp-no-single-word-model-name", + description: + "Model names should be multi-word to avoid naming collisions with .NET platform types.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/csharp-no-single-word-model-name", + messages: { + default: paramMessage`Model name '${"name"}' is a single word. Use a more descriptive multi-word name.`, + }, + create(context) { + const tcgcContext = createTCGCContext( + context.program, + "@azure-tools/typespec-client-generator-core", + { mutateNamespace: false }, + ); + + return { + model: (model: Model) => { + if (model.node === undefined || model.node.kind !== SyntaxKind.ModelStatement) return; + if (model.templateMapper !== undefined) return; + + const csharpName = getLibraryName(tcgcContext, model, "csharp"); + if (!isSingleWord(csharpName)) return; + + context.reportDiagnostic({ + format: { name: csharpName }, + target: model, + codefixes: [createAiClientNameCodeFix(model, context.program, csharpName)], + }); + }, + }; + }, +}); diff --git a/packages/typespec-client-generator-core/test/rules/csharp-no-single-word-model-name.test.ts b/packages/typespec-client-generator-core/test/rules/csharp-no-single-word-model-name.test.ts new file mode 100644 index 0000000000..0743ff3d9e --- /dev/null +++ b/packages/typespec-client-generator-core/test/rules/csharp-no-single-word-model-name.test.ts @@ -0,0 +1,54 @@ +import { LinterRuleTester, createLinterRuleTester } from "@typespec/compiler/testing"; +import { beforeEach, it } from "vitest"; +import { csharpNoSingleWordModelNameRule } from "../../src/rules/csharp-no-single-word-model-name.js"; +import { SimpleTester } from "../tester.js"; + +let tester: LinterRuleTester; + +beforeEach(async () => { + const runner = await SimpleTester.createInstance(); + tester = createLinterRuleTester( + runner, + csharpNoSingleWordModelNameRule, + "@azure-tools/typespec-client-generator-core", + ); +}); + +it("emits warning for single-word model name", async () => { + await tester.expect(`model Document { id: string; }`).toEmitDiagnostics({ + code: "@azure-tools/typespec-client-generator-core/csharp-no-single-word-model-name", + message: "Model name 'Document' is a single word. Use a more descriptive multi-word name.", + }); +}); + +it("is valid for multi-word model name", async () => { + await tester.expect(`model TableDocument { id: string; }`).toBeValid(); +}); + +it("is valid for acronym + word model name", async () => { + await tester.expect(`model HTTPClient { url: string; }`).toBeValid(); +}); + +it("does not flag single-char names", async () => { + await tester.expect(`model T { id: string; }`).toBeValid(); +}); + +it("is valid when @clientName provides multi-word name", async () => { + await tester + .expect( + `@clientName("StorageDocument", "csharp") + model Document { id: string; }`, + ) + .toBeValid(); +}); + +it("emits warning when @clientName is still single-word", async () => { + await tester + .expect( + `@clientName("Blob", "csharp") + model Document { id: string; }`, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-client-generator-core/csharp-no-single-word-model-name", + }); +}); diff --git a/website/src/content/docs/docs/libraries/typespec-client-generator-core/rules/csharp-no-single-word-model-name.md b/website/src/content/docs/docs/libraries/typespec-client-generator-core/rules/csharp-no-single-word-model-name.md new file mode 100644 index 0000000000..3004ca4eff --- /dev/null +++ b/website/src/content/docs/docs/libraries/typespec-client-generator-core/rules/csharp-no-single-word-model-name.md @@ -0,0 +1,32 @@ +--- +title: "csharp-no-single-word-model-name" +--- + +```text title="Full name" +@azure-tools/typespec-client-generator-core/csharp-no-single-word-model-name +``` + +Model names should be multi-word to avoid naming collisions with .NET platform or third-party types. The codefix can suggest C# names and write `@@clientName` to `client.tsp`. + +#### ❌ Incorrect + +```tsp +model Document { + id: string; +} +``` + +#### ✅ Correct + +```tsp +model TableDocument { + id: string; +} +``` + +```tsp +@clientName("StorageDocument", "csharp") +model Document { + id: string; +} +```