From f9d7f4d5b4619cc62abba4ffb32e61d72b66c129 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 4 Sep 2026 11:55:41 -0400 Subject: [PATCH 1/3] Report linter diagnostics on library templates instantiated by the user project Fixes https://github.com/microsoft/typespec/issues/11861 --- ...-report-template-instantiation-2026-9-4.md | 9 +++ ...uppress-template-instantiation-2026-9-4.md | 16 +++++ packages/compiler/src/core/linter.ts | 35 +++++++--- packages/compiler/src/core/program.ts | 29 ++++++-- packages/compiler/test/core/linter.test.ts | 68 +++++++++++++++++++ packages/compiler/test/suppression.test.ts | 17 +++++ .../docs/docs/language-basics/directives.md | 9 +++ 7 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 .chronus/changes/linter-report-template-instantiation-2026-9-4.md create mode 100644 .chronus/changes/suppress-template-instantiation-2026-9-4.md diff --git a/.chronus/changes/linter-report-template-instantiation-2026-9-4.md b/.chronus/changes/linter-report-template-instantiation-2026-9-4.md new file mode 100644 index 00000000000..ea9beb0eebd --- /dev/null +++ b/.chronus/changes/linter-report-template-instantiation-2026-9-4.md @@ -0,0 +1,9 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Report linter diagnostics on library templates that the user project instantiated + +A rule reporting on a type declared inside a library template — for example the `value` property of `Wrapper` when the project writes `Wrapper` — was silently dropped, because the target's source location resolves to the template declaration in the library. Such a type only exists because of the template arguments the user passed, so the diagnostic is now reported with the instantiation trace pointing back at the user's own code, matching how non-linter diagnostics already behave. diff --git a/.chronus/changes/suppress-template-instantiation-2026-9-4.md b/.chronus/changes/suppress-template-instantiation-2026-9-4.md new file mode 100644 index 00000000000..dd41255bbf4 --- /dev/null +++ b/.chronus/changes/suppress-template-instantiation-2026-9-4.md @@ -0,0 +1,16 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Suppress a diagnostic reported inside a template where the template was instantiated + +```tsp +model Widget { + #suppress "some-rule" "Not applicable here" + page: Page; +} +``` + +Previously the `#suppress` directive was only looked up on the target and its parents, so a diagnostic coming from a template declaration could not be suppressed from the code that instantiated it. diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 810887ae6a1..f6d64de8703 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -2,7 +2,11 @@ import { isPromise } from "../utils/misc.js"; import type { DiagnosticCodeResolver } from "./diagnostic-code.js"; import { formatShortNameCandidates } from "./diagnostic-code.js"; import type { DiagnosticCollector } from "./diagnostics.js"; -import { compilerAssert, createDiagnosticCollector } from "./diagnostics.js"; +import { + compilerAssert, + createDiagnosticCollector, + getDiagnosticTemplateInstantitationTrace, +} from "./diagnostics.js"; import { getLocationContext } from "./helpers/location-context.js"; import { defineLinter } from "./library.js"; import { createUnusedTemplateParameterLinterRule } from "./linter-rules/unused-template-parameter.rule.js"; @@ -15,6 +19,7 @@ import { EventEmitter, mapEventEmitterToNodeListener, navigateProgram } from "./ import type { Diagnostic, DiagnosticMessages, + DiagnosticTarget, LinterDefinition, LinterResolvedDefinition, LinterRule, @@ -427,17 +432,31 @@ export function createLinterRuleContext< function reportDiagnostic(diag: LinterRuleDiagnosticReport): void { const diagnostic = createDiagnostic(diag); - if (diagnostic.target !== NoTarget) { - const context = getLocationContext(program, diagnostic.target); - // Only report diagnostic in the user project. - // See for showing diagnostic in library at point of usage https://github.com/microsoft/typespec/issues/1997 - if (context.type === "project") { - diagnosticCollector.add(diagnostic); - } + if (diagnostic.target !== NoTarget && isUserOwnedTarget(program, diagnostic.target)) { + diagnosticCollector.add(diagnostic); } } } +/** + * Linter rules should only report on code the user is able to act on. + * + * A target is user owned if it is declared in the user project, or if it belongs to a + * library template that the user project instantiated: in that case the type only exists + * because of the template arguments the user passed, and the diagnostic is rendered with + * the instantiation trace pointing back at the user's own code. + * + * See https://github.com/microsoft/typespec/issues/11861 + */ +function isUserOwnedTarget(program: Program, target: DiagnosticTarget): boolean { + if (getLocationContext(program, target).type === "project") { + return true; + } + return getDiagnosticTemplateInstantitationTrace(target).some( + (node) => getLocationContext(program, node).type === "project", + ); +} + export const builtInLinterLibraryName = `@typespec/compiler`; export function createBuiltInLinterLibrary(): LinterLibraryInstance { const builtInLinter: LinterResolvedDefinition = resolveLinterDefinition( diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index ac8d28a7d9f..b8b0129afae 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -14,7 +14,7 @@ import { createChecker } from "./checker.js"; import { createSuppressCodeFix } from "./compiler-code-fixes/suppress.codefix.js"; import type { DiagnosticCodeResolver, LibraryNameInfo } from "./diagnostic-code.js"; import { createDiagnosticCodeResolver, formatShortNameCandidates } from "./diagnostic-code.js"; -import { compilerAssert } from "./diagnostics.js"; +import { compilerAssert, getDiagnosticTemplateInstantitationTrace } from "./diagnostics.js"; import { getEmittedFilesForProgram } from "./emitter-utils.js"; import { resolveTypeSpecEntrypoint } from "./entrypoint-resolution.js"; import { ExternalError } from "./external-error.js"; @@ -54,6 +54,7 @@ import { import type { CompilerHost, Diagnostic, + DiagnosticTarget, EmitContext, EmitterFunc, Entity, @@ -979,11 +980,7 @@ async function createProgram( return false; // Can't find target cannot be suppressed. } - const suppressing = findDirectiveSuppressingOnNode( - diagnostic.code, - node, - diagnosticCodeResolver, - ); + const suppressing = findSuppressingDirective(target, node); if (suppressing) { if (diagnostic.severity === "error") { // Cannot suppress errors. @@ -1002,6 +999,26 @@ async function createProgram( } } return false; + + /** + * Look for a `#suppress` directive on the target itself, then on each template + * instantiation that produced it, so a diagnostic reported inside a template can be + * suppressed where the template was instantiated. + */ + function findSuppressingDirective(target: DiagnosticTarget, node: Node) { + const direct = findDirectiveSuppressingOnNode(diagnostic.code, node, diagnosticCodeResolver); + if (direct) return direct; + + for (const instantiation of getDiagnosticTemplateInstantitationTrace(target)) { + const suppressing = findDirectiveSuppressingOnNode( + diagnostic.code, + instantiation, + diagnosticCodeResolver, + ); + if (suppressing) return suppressing; + } + return undefined; + } } function getNode(target: Node | Entity | Sym | TemplateInstanceTarget): Node | undefined { diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 0858e91f5ef..db475d69bf0 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -34,6 +34,26 @@ const noModelFoo = createLinterRule({ }, }); +const noPropertyValue = createLinterRule({ + name: "no-property-value", + description: "", + severity: "warning", + messages: { + default: "Cannot call property 'value'", + }, + create(context) { + return { + modelProperty: (target) => { + if (target.name === "value") { + context.reportDiagnostic({ + target, + }); + } + }, + }; + }, +}); + const exitLintRuleSync = createLinterRule({ name: "exit-lint-rule-sync", description: "", @@ -274,6 +294,54 @@ describe("diagnostic location", () => { message: `Cannot call model 'Foo'`, }); }); + + describe("library template instantiated from the user project", () => { + async function lintPropertyValue(lib: string, main: string) { + const linter = await createTestLinterAndEnableRules( + { + "main.tsp": `import "my-lib";\n${main}`, + "node_modules/my-lib/package.json": JSON.stringify({ + name: "my-lib", + tspMain: "main.tsp", + }), + "node_modules/my-lib/main.tsp": lib, + }, + { rules: [noPropertyValue] }, + ); + return (await linter.lint()).diagnostics; + } + + it("emit diagnostic when the user project instantiated the template", async () => { + const diagnostics = await lintPropertyValue( + `model Wrapper { value: T; }`, + `model Bar { wrapped: Wrapper; }`, + ); + expectDiagnostics(diagnostics, { + severity: "warning", + code: "@typespec/test-linter/no-property-value", + message: `Cannot call property 'value'`, + }); + }); + + it("doesn't emit diagnostic for a non templated library model", async () => { + expectDiagnosticEmpty( + await lintPropertyValue( + `model NotATemplate { value: string; }`, + `model Bar { plain: NotATemplate; }`, + ), + ); + }); + + it("doesn't emit diagnostic when the library instantiated the template itself", async () => { + expectDiagnosticEmpty( + await lintPropertyValue( + `model Wrapper { value: T; } + model LibOwnedUsage { ...Wrapper; }`, + `model Bar { plain: LibOwnedUsage; }`, + ), + ); + }); + }); }); describe("when enabling a rule", () => { diff --git a/packages/compiler/test/suppression.test.ts b/packages/compiler/test/suppression.test.ts index 270d7c5317c..133f2b9dcca 100644 --- a/packages/compiler/test/suppression.test.ts +++ b/packages/compiler/test/suppression.test.ts @@ -75,6 +75,23 @@ it("suppress warning diagnostic on parent node", async () => { expectDiagnosticEmpty(diagnostics); }); +it("suppress warning diagnostic where the template was instantiated", async () => { + const diagnostics = await run(` + model Wrapper { + wrapped: T; + inline: { + name: 123; + }; + } + + model Foo { + #suppress "no-inline-model" "This is needed" + prop: Wrapper; + } + `); + expectDiagnosticEmpty(diagnostics); +}); + it("error diagnostics cannot be suppressed and emit another error", async () => { const diagnostics = await run(` model Foo { diff --git a/website/src/content/docs/docs/language-basics/directives.md b/website/src/content/docs/docs/language-basics/directives.md index 7d825630e50..a541e33e048 100644 --- a/website/src/content/docs/docs/language-basics/directives.md +++ b/website/src/content/docs/docs/language-basics/directives.md @@ -87,6 +87,15 @@ namespace Lib { } ``` +A diagnostic reported inside a template can also be suppressed where the template was instantiated: + +```tsp +model Widget { + #suppress "some-rule" "Not applicable here" + page: Page; +} +``` + ### Short diagnostic codes Diagnostic codes from a library are prefixed with the package name (e.g. `@typespec/http/no-service-found`), which can get verbose. You can also reference a diagnostic using its **short name**, where the package scope is stripped: From 1bbb702a15d6f4b13d26140bea0a059a30755798 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 4 Sep 2026 14:07:41 -0400 Subject: [PATCH 2/3] Report only the template argument the user supplied, on their own file Narrow the fix so a library-declared member is reported only when its declared type depends on a template parameter the user passed an argument for, and retarget the diagnostic to that argument node in the user's project. --- ...-report-template-instantiation-2026-9-4.md | 6 +- packages/compiler/src/core/linter.ts | 120 +++++++++++++++--- packages/compiler/test/core/linter.test.ts | 65 +++++++++- 3 files changed, 166 insertions(+), 25 deletions(-) diff --git a/.chronus/changes/linter-report-template-instantiation-2026-9-4.md b/.chronus/changes/linter-report-template-instantiation-2026-9-4.md index ea9beb0eebd..184f53fdb9d 100644 --- a/.chronus/changes/linter-report-template-instantiation-2026-9-4.md +++ b/.chronus/changes/linter-report-template-instantiation-2026-9-4.md @@ -4,6 +4,8 @@ packages: - "@typespec/compiler" --- -Report linter diagnostics on library templates that the user project instantiated +Report linter diagnostics on library template members whose type the user supplied -A rule reporting on a type declared inside a library template — for example the `value` property of `Wrapper` when the project writes `Wrapper` — was silently dropped, because the target's source location resolves to the template declaration in the library. Such a type only exists because of the template arguments the user passed, so the diagnostic is now reported with the instantiation trace pointing back at the user's own code, matching how non-linter diagnostics already behave. +A rule reporting on a member declared inside a library template — for example the `value` property of `Wrapper` when the project writes `Wrapper` — was silently dropped, because the member's source location resolves to the template declaration in the library. That member only has the type it has because of the argument the user passed, so it is now reported, on the argument in the user's own file. + +Members whose type the library declared itself are still not reported, so a rule never blames the user for code they cannot change. diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index f6d64de8703..6f456f0b54e 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -2,11 +2,7 @@ import { isPromise } from "../utils/misc.js"; import type { DiagnosticCodeResolver } from "./diagnostic-code.js"; import { formatShortNameCandidates } from "./diagnostic-code.js"; import type { DiagnosticCollector } from "./diagnostics.js"; -import { - compilerAssert, - createDiagnosticCollector, - getDiagnosticTemplateInstantitationTrace, -} from "./diagnostics.js"; +import { compilerAssert, createDiagnosticCollector } from "./diagnostics.js"; import { getLocationContext } from "./helpers/location-context.js"; import { defineLinter } from "./library.js"; import { createUnusedTemplateParameterLinterRule } from "./linter-rules/unused-template-parameter.rule.js"; @@ -27,10 +23,14 @@ import type { LinterRuleDiagnosticReport, LinterRuleEnableValue, LinterRuleSet, + Node, RuleRef, SemanticNodeListener, + TemplateParameter, + Type, + TypeMapper, } from "./types.js"; -import { NoTarget } from "./types.js"; +import { NoTarget, SyntaxKind } from "./types.js"; type LinterLibraryInstance = { linter: LinterResolvedDefinition }; @@ -432,8 +432,11 @@ export function createLinterRuleContext< function reportDiagnostic(diag: LinterRuleDiagnosticReport): void { const diagnostic = createDiagnostic(diag); - if (diagnostic.target !== NoTarget && isUserOwnedTarget(program, diagnostic.target)) { - diagnosticCollector.add(diagnostic); + if (diagnostic.target === NoTarget) return; + + const target = resolveUserOwnedTarget(program, diagnostic.target); + if (target !== undefined) { + diagnosticCollector.add({ ...diagnostic, target }); } } } @@ -441,20 +444,103 @@ export function createLinterRuleContext< /** * Linter rules should only report on code the user is able to act on. * - * A target is user owned if it is declared in the user project, or if it belongs to a - * library template that the user project instantiated: in that case the type only exists - * because of the template arguments the user passed, and the diagnostic is rendered with - * the instantiation trace pointing back at the user's own code. + * A target declared in the user project is reported as is. A target declared in a library + * is reported only when its type is a template argument the user supplied: given + * `model Wrapper { value: T }`, `Wrapper.value` exists in that shape only because + * the user chose `uuid`, while everything else `Wrapper` declares is authored by the + * library and cannot be changed by them. + * + * The diagnostic is then reported on the argument in the user's own file, which is the + * code they can actually change. * * See https://github.com/microsoft/typespec/issues/11861 */ -function isUserOwnedTarget(program: Program, target: DiagnosticTarget): boolean { +function resolveUserOwnedTarget( + program: Program, + target: DiagnosticTarget, +): DiagnosticTarget | undefined { if (getLocationContext(program, target).type === "project") { - return true; + return target; } - return getDiagnosticTemplateInstantitationTrace(target).some( - (node) => getLocationContext(program, node).type === "project", - ); + return findUserSuppliedArgumentNode(program, target); +} + +/** + * Resolve the node of the template argument the given target's type was built from, as + * written in the user project. Returns `undefined` when the target isn't attributable to + * an argument the user wrote, which includes arguments left to their default value. + */ +function findUserSuppliedArgumentNode( + program: Program, + target: DiagnosticTarget, +): Node | undefined { + if (typeof target !== "object" || !("kind" in target)) return undefined; + // Only members carry a type the user could have supplied. A whole instantiated model or + // operation is a library declaration, and reporting on it would duplicate the diagnostic + // already reported on the user's own `is`/`extends`/property declaration. + if (target.kind !== "ModelProperty" && target.kind !== "UnionVariant") return undefined; + if (target.node === undefined) return undefined; + + // Members are linked to the mapper of the template they were instantiated with, even + // though `TemplatedTypeBase` is not part of their public type. + const mapper = (target as { templateMapper?: TypeMapper }).templateMapper; + if (mapper === undefined) return undefined; + + const parameter = findTemplateParameterInDeclaredType(program, target.node); + if (parameter === undefined) return undefined; + + const node = getTemplateArgumentNode(mapper.source.node, parameter); + return node && getLocationContext(program, node).type === "project" ? node : undefined; +} + +/** + * Resolve the member as declared, with its template parameters unsubstituted, and find the + * parameter its type is built from. Looking at the declaration rather than comparing the + * instantiated type to the arguments avoids matching a type the library declared itself + * that merely happens to be the same as an argument, such as `string`. + */ +function findTemplateParameterInDeclaredType( + program: Program, + node: Node, +): TemplateParameter | undefined { + const declared = program.checker.getTypeForNode(node); + if (declared.kind !== "ModelProperty" && declared.kind !== "UnionVariant") return undefined; + return findTemplateParameter(declared.type); +} + +/** Find the template parameter a type is built from, looking through instantiations so `T[]` matches `T`. */ +function findTemplateParameter( + type: Type, + visited = new Set(), +): TemplateParameter | undefined { + if (visited.has(type)) return undefined; + visited.add(type); + + if (type.kind === "TemplateParameter") return type; + + const mapper = (type as { templateMapper?: TypeMapper }).templateMapper; + for (const argument of mapper?.args ?? []) { + if (typeof argument === "object" && "kind" in argument) { + const found = findTemplateParameter(argument as Type, visited); + if (found) return found; + } + } + return undefined; +} + +/** Resolve the argument passed for `parameter` in a template reference, by name or by position. */ +function getTemplateArgumentNode(source: Node, parameter: TemplateParameter): Node | undefined { + if (source.kind !== SyntaxKind.TypeReference) return undefined; + const args = source.arguments; + + const name = parameter.node.id.sv; + const named = args.find((arg) => arg.name?.sv === name); + if (named) return named.argument; + + const declaration = parameter.node.parent; + const index = declaration?.templateParameters?.indexOf(parameter.node) ?? -1; + const positional = index === -1 ? undefined : args[index]; + return positional?.name === undefined ? positional?.argument : undefined; } export const builtInLinterLibraryName = `@typespec/compiler`; diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index db475d69bf0..c38ad81454b 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -296,7 +296,11 @@ describe("diagnostic location", () => { }); describe("library template instantiated from the user project", () => { - async function lintPropertyValue(lib: string, main: string) { + async function lintLibrary( + lib: string, + main: string, + rules: LinterDefinition["rules"] = [noPropertyValue], + ) { const linter = await createTestLinterAndEnableRules( { "main.tsp": `import "my-lib";\n${main}`, @@ -306,13 +310,13 @@ describe("diagnostic location", () => { }), "node_modules/my-lib/main.tsp": lib, }, - { rules: [noPropertyValue] }, + { rules }, ); return (await linter.lint()).diagnostics; } - it("emit diagnostic when the user project instantiated the template", async () => { - const diagnostics = await lintPropertyValue( + it("reports on the argument the user supplied", async () => { + const diagnostics = await lintLibrary( `model Wrapper { value: T; }`, `model Bar { wrapped: Wrapper; }`, ); @@ -320,12 +324,35 @@ describe("diagnostic location", () => { severity: "warning", code: "@typespec/test-linter/no-property-value", message: `Cannot call property 'value'`, + file: "main.tsp", + }); + }); + + it("reports on a named argument", async () => { + const diagnostics = await lintLibrary( + `model Wrapper { value: T; }`, + `model Bar { wrapped: Wrapper; }`, + ); + expectDiagnostics(diagnostics, { + code: "@typespec/test-linter/no-property-value", + file: "main.tsp", + }); + }); + + it("reports when the argument is nested in the property type", async () => { + const diagnostics = await lintLibrary( + `model Wrapper { value: T[]; }`, + `model Bar { wrapped: Wrapper; }`, + ); + expectDiagnostics(diagnostics, { + code: "@typespec/test-linter/no-property-value", + file: "main.tsp", }); }); it("doesn't emit diagnostic for a non templated library model", async () => { expectDiagnosticEmpty( - await lintPropertyValue( + await lintLibrary( `model NotATemplate { value: string; }`, `model Bar { plain: NotATemplate; }`, ), @@ -334,13 +361,39 @@ describe("diagnostic location", () => { it("doesn't emit diagnostic when the library instantiated the template itself", async () => { expectDiagnosticEmpty( - await lintPropertyValue( + await lintLibrary( `model Wrapper { value: T; } model LibOwnedUsage { ...Wrapper; }`, `model Bar { plain: LibOwnedUsage; }`, ), ); }); + + it("doesn't emit diagnostic for a property the library declared itself", async () => { + expectDiagnosticEmpty( + await lintLibrary( + `model Wrapper { wrapped: T; value: string; }`, + `model Bar { wrapped: Wrapper; }`, + ), + ); + }); + + it("doesn't emit diagnostic when the argument was left to its default", async () => { + expectDiagnosticEmpty( + await lintLibrary( + `model Wrapper { value: T; }`, + `model Bar { wrapped: Wrapper; }`, + ), + ); + }); + + it("doesn't emit diagnostic on the instantiated model itself", async () => { + expectDiagnosticEmpty( + await lintLibrary(`model Foo { value: T; }`, `model Bar { wrapped: Foo; }`, [ + noModelFoo, + ]), + ); + }); }); }); From 9c40289e4539c7c726faef9bd605c71ccca95960 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 4 Sep 2026 15:00:11 -0400 Subject: [PATCH 3/3] Only attribute a member declared as the template parameter itself A member the parameter merely appears inside, such as `value: T[]` in `Page`, is the library's own declaration: a diagnostic about it is the library's to fix no matter which item type the user passed. Reported against azure-rest-api-specs this removes the `missing-x-ms-identifiers` findings on `Page` while keeping `no-unknown` on `ArmResponse` and `missing-x-ms-identifiers` where the user passed the array itself. --- ...-report-template-instantiation-2026-9-4.md | 4 +- packages/compiler/src/core/linter.ts | 48 +++++++------------ packages/compiler/test/core/linter.test.ts | 16 +++---- 3 files changed, 27 insertions(+), 41 deletions(-) diff --git a/.chronus/changes/linter-report-template-instantiation-2026-9-4.md b/.chronus/changes/linter-report-template-instantiation-2026-9-4.md index 184f53fdb9d..6d794a50f24 100644 --- a/.chronus/changes/linter-report-template-instantiation-2026-9-4.md +++ b/.chronus/changes/linter-report-template-instantiation-2026-9-4.md @@ -4,8 +4,8 @@ packages: - "@typespec/compiler" --- -Report linter diagnostics on library template members whose type the user supplied +Report linter diagnostics on library template members the user gave a type to A rule reporting on a member declared inside a library template — for example the `value` property of `Wrapper` when the project writes `Wrapper` — was silently dropped, because the member's source location resolves to the template declaration in the library. That member only has the type it has because of the argument the user passed, so it is now reported, on the argument in the user's own file. -Members whose type the library declared itself are still not reported, so a rule never blames the user for code they cannot change. +Only a member declared *as* the parameter, such as `value: T`, counts. A member the parameter merely appears inside, such as `value: T[]`, is still left alone: the array is the library's own declaration, so a diagnostic about it is the library's to fix no matter which item type the user passed. diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 6f456f0b54e..7741508dced 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -27,7 +27,6 @@ import type { RuleRef, SemanticNodeListener, TemplateParameter, - Type, TypeMapper, } from "./types.js"; import { NoTarget, SyntaxKind } from "./types.js"; @@ -466,9 +465,9 @@ function resolveUserOwnedTarget( } /** - * Resolve the node of the template argument the given target's type was built from, as - * written in the user project. Returns `undefined` when the target isn't attributable to - * an argument the user wrote, which includes arguments left to their default value. + * Resolve the node of the template argument the given target was declared as, as written in + * the user project. Returns `undefined` when the target isn't attributable to an argument the + * user wrote, which includes arguments left to their default value. */ function findUserSuppliedArgumentNode( program: Program, @@ -486,7 +485,7 @@ function findUserSuppliedArgumentNode( const mapper = (target as { templateMapper?: TypeMapper }).templateMapper; if (mapper === undefined) return undefined; - const parameter = findTemplateParameterInDeclaredType(program, target.node); + const parameter = findDeclaredTemplateParameter(program, target.node); if (parameter === undefined) return undefined; const node = getTemplateArgumentNode(mapper.source.node, parameter); @@ -494,38 +493,25 @@ function findUserSuppliedArgumentNode( } /** - * Resolve the member as declared, with its template parameters unsubstituted, and find the - * parameter its type is built from. Looking at the declaration rather than comparing the - * instantiated type to the arguments avoids matching a type the library declared itself - * that merely happens to be the same as an argument, such as `string`. + * Resolve the member as declared, with its template parameters unsubstituted, and return the + * parameter it was declared as, if any. + * + * Only a member declared *as* the parameter, such as `body: Request`, is considered. A member + * the parameter merely appears inside, such as `value: Item[]` in `Page`, is left alone: + * the array is the library's own declaration, so a diagnostic about it is the library's to fix + * no matter which item type the user passed. + * + * Looking at the declaration rather than comparing the instantiated type to the arguments + * avoids matching a type the library declared itself that merely happens to be the same as an + * argument, such as `string`. */ -function findTemplateParameterInDeclaredType( +function findDeclaredTemplateParameter( program: Program, node: Node, ): TemplateParameter | undefined { const declared = program.checker.getTypeForNode(node); if (declared.kind !== "ModelProperty" && declared.kind !== "UnionVariant") return undefined; - return findTemplateParameter(declared.type); -} - -/** Find the template parameter a type is built from, looking through instantiations so `T[]` matches `T`. */ -function findTemplateParameter( - type: Type, - visited = new Set(), -): TemplateParameter | undefined { - if (visited.has(type)) return undefined; - visited.add(type); - - if (type.kind === "TemplateParameter") return type; - - const mapper = (type as { templateMapper?: TypeMapper }).templateMapper; - for (const argument of mapper?.args ?? []) { - if (typeof argument === "object" && "kind" in argument) { - const found = findTemplateParameter(argument as Type, visited); - if (found) return found; - } - } - return undefined; + return declared.type.kind === "TemplateParameter" ? declared.type : undefined; } /** Resolve the argument passed for `parameter` in a template reference, by name or by position. */ diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index c38ad81454b..d366b1eaaca 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -339,15 +339,15 @@ describe("diagnostic location", () => { }); }); - it("reports when the argument is nested in the property type", async () => { - const diagnostics = await lintLibrary( - `model Wrapper { value: T[]; }`, - `model Bar { wrapped: Wrapper; }`, + it("doesn't emit diagnostic when the argument is only nested in the property type", async () => { + // `value: T[]` is the library's own array declaration, so a diagnostic about it is the + // library's to fix no matter which item type the user passed. + expectDiagnosticEmpty( + await lintLibrary( + `model Wrapper { value: T[]; }`, + `model Bar { wrapped: Wrapper; }`, + ), ); - expectDiagnostics(diagnostics, { - code: "@typespec/test-linter/no-property-value", - file: "main.tsp", - }); }); it("doesn't emit diagnostic for a non templated library model", async () => {