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
12 changes: 12 additions & 0 deletions .chronus/changes/unused-suppression-diagnostics-2026-05-26.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Dim unused `#suppress` directives for available compiler and library diagnostics in editor scenarios.

```typespec
#suppress "deprecated" "old suppression"
model Widget {}
```
1 change: 1 addition & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,4 +1131,5 @@ const diagnostics = {
} as const;

export type CompilerDiagnostics = TypeOfDiagnostics<typeof diagnostics>;
export const compilerDiagnosticCodes = new Set(Object.keys(diagnostics));
export const { createDiagnostic, reportDiagnostic } = createDiagnosticCreator(diagnostics);
73 changes: 25 additions & 48 deletions packages/compiler/src/core/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ import {
} from "./source-loader.js";
import { createStateAccessors } from "./state-accessors.js";
import { ComplexityStats, RuntimeStats, Stats } from "./stats.js";
import {
createSuppressionTracker,
findDirectiveSuppressingOnNode,
} from "./suppression-tracking.js";
import {
CompilerHost,
Diagnostic,
Directive,
DirectiveExpressionNode,
EmitContext,
EmitterFunc,
Entity,
Expand All @@ -64,7 +66,6 @@ import {
Sym,
SymbolFlags,
SymbolTable,
SyntaxKind,
TemplateInstanceTarget,
Tracer,
Type,
Expand Down Expand Up @@ -118,6 +119,8 @@ export interface Program {

/** @internal */
reportDuplicateSymbols(symbols: SymbolTable | undefined): void;
/** @internal */
reportUnusedSuppressions(): void;

getGlobalNamespaceType(): Namespace;

Expand Down Expand Up @@ -200,6 +203,7 @@ export async function compile(
}
emitStats.total = timer.end();
program.stats.runtime.emit = emitStats;
program.reportUnusedSuppressions();
return program;
}

Expand All @@ -218,9 +222,12 @@ async function createProgram(
const emitters: EmitterRef[] = [];
const requireImports = new Map<string, string>();
const complexityStats: ComplexityStats = {} as any;
let sourceResolution: SourceResolution;
let sourceResolution: SourceResolution = undefined!;
let error = false;
let continueToNextStage = true;
const suppressionTracker: {
current?: ReturnType<typeof createSuppressionTracker>;
} = {};

const logger = createLogger({ sink: host.logSink });
const tracer = createTracer(logger, { filter: options.trace });
Expand Down Expand Up @@ -250,6 +257,7 @@ async function createProgram(
reportDiagnostic,
reportDiagnostics,
reportDuplicateSymbols,
reportUnusedSuppressions,
hasError() {
return error;
},
Expand Down Expand Up @@ -295,6 +303,11 @@ async function createProgram(
// let GC reclaim old program, we do not reuse it beyond this point.
oldProgram = undefined;

suppressionTracker.current = createSuppressionTracker({
addDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
sourceResolution,
});

const resolver = (program.resolver = createResolver(program));
runtimeStats.resolver = perf.time(() => resolver.resolveProgram());

Expand Down Expand Up @@ -330,6 +343,10 @@ async function createProgram(
runtimeStats.linter = lintResult.stats.runtime;
program.reportDiagnostics(lintResult.diagnostics);

if (emit.length === 0) {
reportUnusedSuppressions();
}

return { program, shouldAbort: false };

/**
Expand Down Expand Up @@ -830,56 +847,16 @@ async function createProgram(

return false;
} else {
suppressionTracker.current?.markUsed(suppressing.node);
return true;
}
}
return false;
}

function findDirectiveSuppressingOnNode(code: string, node: Node): Directive | undefined {
let current: Node | undefined = node;
do {
if (current.directives) {
const directive = findDirectiveSuppressingCode(code, current.directives);
if (directive) {
return directive;
}
}
} while ((current = current.parent));
return undefined;
}

/**
* Returns the directive node that is suppressing this code.
* @param code Code to check for suppression.
* @param directives List of directives.
* @returns Directive suppressing this code if found, `undefined` otherwise
*/
function findDirectiveSuppressingCode(
code: string,
directives: readonly DirectiveExpressionNode[],
): Directive | undefined {
for (const directive of directives.map((x) => parseDirective(x))) {
if (directive.name === "suppress") {
if (directive.code === code) {
return directive;
}
}
}
return undefined;
}

function parseDirective(node: DirectiveExpressionNode): Directive {
const args = node.arguments.map((x) => {
return x.kind === SyntaxKind.Identifier ? x.sv : x.value;
});
switch (node.target.sv) {
case "suppress":
return { name: "suppress", code: args[0], message: args[1], node };
case "deprecated":
return { name: "deprecated", message: args[0], node };
default:
throw new Error("Unexpected directive name.");
function reportUnusedSuppressions(): void {
if (program.compilerOptions.designTimeBuild) {
suppressionTracker.current?.reportUnusedSuppressions();
}
}

Expand Down
205 changes: 205 additions & 0 deletions packages/compiler/src/core/suppression-tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { defineCodeFix, getSourceLocation } from "./diagnostics.js";
import { builtInLinterLibraryName } from "./linter.js";
import { compilerDiagnosticCodes } from "./messages.js";
import { visitChildren } from "./parser.js";
import { SourceResolution } from "./source-loader.js";
import {
CodeFix,
Diagnostic,
Directive,
DirectiveExpressionNode,
Node,
SuppressDirective,
SyntaxKind,
} from "./types.js";

interface SuppressionRecord {
directive: SuppressDirective;
used: boolean;
}

interface SuppressionTracker {
markUsed(directiveNode: DirectiveExpressionNode): void;
reportUnusedSuppressions(): void;
}

export function createSuppressionTracker({
addDiagnostic,
sourceResolution,
}: {
addDiagnostic: (diagnostic: Diagnostic) => void;
sourceResolution: SourceResolution;
}): SuppressionTracker {
const suppressions = collectSuppressions(sourceResolution);
let unusedSuppressionsReported = false;

return {
markUsed(directiveNode) {
const suppression = suppressions.get(directiveNode);
if (suppression) {
suppression.used = true;
}
},
reportUnusedSuppressions() {
if (unusedSuppressionsReported) {
return;
}
unusedSuppressionsReported = true;

for (const suppression of suppressions.values()) {
if (suppression.used) {
continue;
}

const availability = getSuppressionSourceAvailability(
suppression.directive.code,
sourceResolution,
);
if (availability === "unavailable") {
continue;
}

addDiagnostic({
code: "unused-suppression",
severity: "warning",
message: `Suppression for "${suppression.directive.code}" is unused.`,
target: suppression.directive.node,
codefixes: [createRemoveUnusedSuppressionCodeFix(suppression.directive.node)],
});
}
},
};
}

function collectSuppressions(
sourceResolution: SourceResolution,
): Map<DirectiveExpressionNode, SuppressionRecord> {
const suppressions = new Map<DirectiveExpressionNode, SuppressionRecord>();
for (const script of sourceResolution.sourceFiles.values()) {
if (sourceResolution.locationContexts.get(script.file)?.type !== "project") {
continue;
}

visit(script);
}

return suppressions;

function visit(node: Node) {
for (const directiveNode of node.directives ?? []) {
const directive = parseDirective(directiveNode);
if (directive?.name === "suppress") {
suppressions.set(directive.node, { directive, used: false });
}
}

visitChildren(node, visit);
}
}

function getSuppressionSourceAvailability(
code: string,
sourceResolution: SourceResolution,
): "available" | "unavailable" {
if (
compilerDiagnosticCodes.has(code) ||
matchesDiagnosticSource(code, builtInLinterLibraryName)
) {
return "available";
}

for (const libraryName of sourceResolution.loadedLibraries.keys()) {
if (matchesDiagnosticSource(code, libraryName)) {
return "available";
}
}

return "unavailable";
}

function matchesDiagnosticSource(code: string, source: string): boolean {
return code.startsWith(`${source}/`);
}

export function findDirectiveSuppressingOnNode(code: string, node: Node): Directive | undefined {
let current: Node | undefined = node;
do {
if (current.directives) {
const directive = findDirectiveSuppressingCode(code, current.directives);
if (directive) {
return directive;
}
}
} while ((current = current.parent));
return undefined;
}

/**
* Returns the directive node that is suppressing this code.
* @param code Code to check for suppression.
* @param directives List of directives.
* @returns Directive suppressing this code if found, `undefined` otherwise
*/
export function findDirectiveSuppressingCode(
code: string,
directives: readonly DirectiveExpressionNode[],
): Directive | undefined {
for (const directiveNode of directives) {
const directive = parseDirective(directiveNode);
if (directive?.name === "suppress") {
if (directive.code === code) {
return directive;
}
}
}
return undefined;
}

export function parseDirective(node: DirectiveExpressionNode): Directive | undefined {
const args = node.arguments.map((x) => {
return x.kind === SyntaxKind.Identifier ? x.sv : x.value;
});
switch (node.target.sv) {
case "suppress":
if (typeof args[0] !== "string") {
return undefined;
}
return { name: "suppress", code: args[0], message: args[1] ?? "", node };
case "deprecated":
if (typeof args[0] !== "string") {
return undefined;
}
return { name: "deprecated", message: args[0], node };
default:
return undefined;
}
}

export function createRemoveUnusedSuppressionCodeFix(node: DirectiveExpressionNode): CodeFix {
return defineCodeFix({
id: "remove-unused-suppression",
label: "Remove unused suppression",
fix: (context) => {
const location = getSourceLocation(node);
const text = location.file.text;
const lineStart = text.lastIndexOf("\n", location.pos - 1) + 1;
const textBeforeDirective = text.slice(lineStart, location.pos);

if (textBeforeDirective.trim() !== "") {
return context.replaceText(location, "");
}

let end = location.end;
while (end < text.length && (text[end] === " " || text[end] === "\t")) {
end++;
}
if (text[end] === "\r" && text[end + 1] === "\n") {
end += 2;
} else if (text[end] === "\n" || text[end] === "\r") {
end++;
}

return context.replaceText({ ...location, pos: lineStart, end }, "");
},
});
}
3 changes: 3 additions & 0 deletions packages/compiler/src/server/serverlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,9 @@ export function createServer(
// if the unused template parameter is not configured by user explicitly, report it as hint by default
diagnostic.severity = DiagnosticSeverity.Hint;
}
} else if (each.code === "unused-suppression") {
diagnostic.tags = [DiagnosticTag.Unnecessary];
diagnostic.severity = DiagnosticSeverity.Hint;
}
diagnostic.data = {
id: diagnosticIdCounter++,
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/test/checker/values/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function compileAndDiagnoseValueOrType(
import "./collect.js";
extern dec collect(target, value: ${constraint});

${disableDeprecatedSuppression ? "" : `#suppress "deprecated" "for testing"`}
${disableDeprecatedSuppression === false ? `#suppress "deprecated" "for testing"` : ""}
@collect(${code})
model ${t.model("Test")} {}
${other ?? ""}
Expand Down
Loading
Loading