Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .chronus/changes/linter-report-template-instantiation-2026-9-4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
changeKind: fix
packages:
- "@typespec/compiler"
---

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<T>` when the project writes `Wrapper<uuid>` — 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.

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.
16 changes: 16 additions & 0 deletions .chronus/changes/suppress-template-instantiation-2026-9-4.md
Original file line number Diff line number Diff line change
@@ -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<WidgetItem>;
}
```

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.
107 changes: 99 additions & 8 deletions packages/compiler/src/core/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,21 @@ import { EventEmitter, mapEventEmitterToNodeListener, navigateProgram } from "./
import type {
Diagnostic,
DiagnosticMessages,
DiagnosticTarget,
LinterDefinition,
LinterResolvedDefinition,
LinterRule,
LinterRuleContext,
LinterRuleDiagnosticReport,
LinterRuleEnableValue,
LinterRuleSet,
Node,
RuleRef,
SemanticNodeListener,
TemplateParameter,
TypeMapper,
} from "./types.js";
import { NoTarget } from "./types.js";
import { NoTarget, SyntaxKind } from "./types.js";

type LinterLibraryInstance = { linter: LinterResolvedDefinition };

Expand Down Expand Up @@ -427,17 +431,104 @@ export function createLinterRuleContext<

function reportDiagnostic<M extends keyof DM>(diag: LinterRuleDiagnosticReport<DM, M>): 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) return;

const target = resolveUserOwnedTarget(program, diagnostic.target);
if (target !== undefined) {
diagnosticCollector.add({ ...diagnostic, target });
}
}
}

/**
* Linter rules should only report on code the user is able to act on.
*
* 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<T> { value: T }`, `Wrapper<uuid>.value` exists in that shape only because
* the user chose `uuid`, while everything else `Wrapper<T>` 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 resolveUserOwnedTarget(
program: Program,
target: DiagnosticTarget,
): DiagnosticTarget | undefined {
if (getLocationContext(program, target).type === "project") {
return target;
}
return findUserSuppliedArgumentNode(program, target);
}

/**
* 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,
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 = findDeclaredTemplateParameter(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 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<Item>`, 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 findDeclaredTemplateParameter(
program: Program,
node: Node,
): TemplateParameter | undefined {
const declared = program.checker.getTypeForNode(node);
if (declared.kind !== "ModelProperty" && declared.kind !== "UnionVariant") 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. */
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`;
export function createBuiltInLinterLibrary(): LinterLibraryInstance {
const builtInLinter: LinterResolvedDefinition = resolveLinterDefinition(
Expand Down
29 changes: 23 additions & 6 deletions packages/compiler/src/core/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -54,6 +54,7 @@ import {
import type {
CompilerHost,
Diagnostic,
DiagnosticTarget,
EmitContext,
EmitterFunc,
Entity,
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
121 changes: 121 additions & 0 deletions packages/compiler/test/core/linter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down Expand Up @@ -274,6 +294,107 @@ describe("diagnostic location", () => {
message: `Cannot call model 'Foo'`,
});
});

describe("library template instantiated from the user project", () => {
async function lintLibrary(
lib: string,
main: string,
rules: LinterDefinition["rules"] = [noPropertyValue],
) {
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 },
);
return (await linter.lint()).diagnostics;
}

it("reports on the argument the user supplied", async () => {
const diagnostics = await lintLibrary(
`model Wrapper<T> { value: T; }`,
`model Bar { wrapped: Wrapper<string>; }`,
);
expectDiagnostics(diagnostics, {
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<T extends string = string> { value: T; }`,
`model Bar { wrapped: Wrapper<T = string>; }`,
);
expectDiagnostics(diagnostics, {
code: "@typespec/test-linter/no-property-value",
file: "main.tsp",
});
});

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<T> { value: T[]; }`,
`model Bar { wrapped: Wrapper<string>; }`,
),
);
});

it("doesn't emit diagnostic for a non templated library model", async () => {
expectDiagnosticEmpty(
await lintLibrary(
`model NotATemplate { value: string; }`,
`model Bar { plain: NotATemplate; }`,
),
);
});

it("doesn't emit diagnostic when the library instantiated the template itself", async () => {
expectDiagnosticEmpty(
await lintLibrary(
`model Wrapper<T> { value: T; }
model LibOwnedUsage { ...Wrapper<string>; }`,
`model Bar { plain: LibOwnedUsage; }`,
),
);
});

it("doesn't emit diagnostic for a property the library declared itself", async () => {
expectDiagnosticEmpty(
await lintLibrary(
`model Wrapper<T> { wrapped: T; value: string; }`,
`model Bar { wrapped: Wrapper<string>; }`,
),
);
});

it("doesn't emit diagnostic when the argument was left to its default", async () => {
expectDiagnosticEmpty(
await lintLibrary(
`model Wrapper<T extends string = string> { value: T; }`,
`model Bar { wrapped: Wrapper; }`,
),
);
});

it("doesn't emit diagnostic on the instantiated model itself", async () => {
expectDiagnosticEmpty(
await lintLibrary(`model Foo<T> { value: T; }`, `model Bar { wrapped: Foo<string>; }`, [
noModelFoo,
]),
);
});
});
});

describe("when enabling a rule", () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/compiler/test/suppression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
wrapped: T;
inline: {
name: 123;
};
}

model Foo {
#suppress "no-inline-model" "This is needed"
prop: Wrapper<string>;
}
`);
expectDiagnosticEmpty(diagnostics);
});

it("error diagnostics cannot be suppressed and emit another error", async () => {
const diagnostics = await run(`
model Foo {
Expand Down
9 changes: 9 additions & 0 deletions website/src/content/docs/docs/language-basics/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<WidgetItem>;
}
```

### 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:
Expand Down
Loading