Repository navigation
feat(compiler): expose suppression inspection APIs #12095
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Timothee Guerin (timotheeguerin)
wants to merge
4
commits into
microsoft:main
Choose a base branch
from
timotheeguerin:feat/suppression-inspection
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
e7a1c61
feat(compiler): expose suppression inspection APIs
timotheeguerin 1c27226
chore(compiler): trim suppression inspection tests and docs
timotheeguerin 563968d
docs: remove tooling API guidance from language reference
timotheeguerin c849b28
feat(compiler): expose observed suppression usage
timotheeguerin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| --- | ||
| changeKind: feature | ||
| packages: | ||
| - "@typespec/compiler" | ||
| --- | ||
|
|
||
| Inspect inline suppressions and local linter disables without compiling a project. Inline results include declaration context and source locations; config results include rule-key locations and diagnostics. For compiled programs, inspect whether each project suppression matched a diagnostic using `getSuppressions`. | ||
|
|
||
| ```ts | ||
| import { collectLinterDisables } from "@typespec/compiler"; | ||
| import { collectSuppressions, getSuppressions, parse } from "@typespec/compiler/ast"; | ||
|
|
||
| const script = parse(source); | ||
| const suppressions = collectSuppressions(script); | ||
| const [disables, diagnostics] = collectLinterDisables(configText); | ||
| const observedSuppressions = getSuppressions(program); // Includes a `used` flag. | ||
| ``` | ||
|
|
||
| Usage is a snapshot of diagnostics matched so far in that compilation, including rejected attempts to suppress errors. An unmatched suppression may be needed under other settings: its rule could be disabled, its diagnostic source unavailable, its emitter skipped, or compilation could stop before the relevant stage runs. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| import { parseDirective } from "../core/directives.js"; | ||
| import { visitChildren } from "../core/parser.js"; | ||
| import type { Program } from "../core/program.js"; | ||
| import type { | ||
| DirectiveExpressionNode, | ||
| Node, | ||
| SourceLocation, | ||
| SuppressDirective, | ||
| TypeSpecScriptNode, | ||
| } from "../core/types.js"; | ||
| import { SyntaxKind } from "../core/types.js"; | ||
| import { isArray } from "../utils/misc.js"; | ||
|
|
||
| /** A declaration or member in a suppression's structural context. */ | ||
| export interface SuppressionScope { | ||
| /** The declaration, member, or anonymous container node. */ | ||
| readonly node: Node; | ||
| /** Decoded declaration name, if named. */ | ||
| readonly name?: string; | ||
| } | ||
|
|
||
| /** A suppress directive and its location and declaration context in one source file. */ | ||
| export interface Suppression { | ||
| /** Parsed directive, preserving its written code and justification. */ | ||
| readonly directive: SuppressDirective; | ||
| /** The syntax node carrying the directive. */ | ||
| readonly target: Node; | ||
| /** Directive range in the source file, including any trailing trivia in its AST range. */ | ||
| readonly location: SourceLocation; | ||
| /** | ||
| * Structural context, outermost first, including the target if it is a declaration or member. | ||
| * Includes file-scoped namespaces and anonymous containers, but not incidental syntax nodes. | ||
| * This is not a semantic scope or a guarantee of a unique declaration identity. | ||
| */ | ||
| readonly scope: readonly SuppressionScope[]; | ||
| } | ||
|
|
||
| /** A project suppression with its observed usage in a compilation. */ | ||
| export interface ProgramSuppression extends Suppression { | ||
| /** | ||
| * Whether the directive matched a diagnostic so far in this compilation. | ||
| * Includes rejected attempts to suppress errors; `true` does not guarantee a diagnostic was hidden. | ||
| * `false` means no match was observed, including when a rule is disabled, a diagnostic source | ||
| * is unavailable, an emitter is skipped, or errors prevent later compilation stages from running. | ||
| * It does not establish that the suppression can be removed under other configurations. | ||
| */ | ||
| readonly used: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Get project suppressions and their usage from the compiler's existing tracker. | ||
| * | ||
| * Returns a snapshot of usage at the time of the call, preserving written codes and | ||
| * including unmatched directives even when their diagnostic source is unavailable. | ||
| * Libraries' suppressions are excluded. Results may change as validators, linter rules, | ||
| * emitters, or other callers report diagnostics; query after compilation to include all | ||
| * stages that ran. If compilation stopped before tracking was initialized, returns an empty list. | ||
| * | ||
| * @param program Program whose suppressions are being inspected. | ||
| */ | ||
| export function getSuppressions(program: Program): readonly ProgramSuppression[] { | ||
| return program.suppressionTracker?.getSuppressions() ?? []; | ||
| } | ||
|
|
||
| /** | ||
| * Collect suppress directives from a parsed file without binding or compiling it. | ||
| * | ||
| * Returns directives in source order, including duplicates and codes from unavailable libraries. | ||
| * Multiple AST references to the same directive are returned once, with the deepest attachment. | ||
| * Codes are not resolved, and neither usage nor effectiveness is checked. Inspect | ||
| * `script.parseDiagnostics` before treating the inventory as complete. | ||
| * | ||
| * @param script Parsed source file. Its nodes are not modified. | ||
| */ | ||
| export function collectSuppressions(script: TypeSpecScriptNode): readonly Suppression[] { | ||
| const suppressions = new Map<DirectiveExpressionNode, Suppression>(); | ||
| let fileScope: readonly SuppressionScope[] = []; | ||
| for (const statement of script.statements) { | ||
| visit(statement, fileScope); | ||
| let namespace = statement; | ||
| const namespaceScope: SuppressionScope[] = []; | ||
| while (namespace.kind === SyntaxKind.NamespaceStatement) { | ||
| namespaceScope.push({ node: namespace, name: namespace.id.sv }); | ||
| if (namespace.statements === undefined) { | ||
| fileScope = [...fileScope, ...namespaceScope]; | ||
| break; | ||
| } | ||
| if (isArray(namespace.statements)) { | ||
| break; | ||
| } | ||
| namespace = namespace.statements; | ||
| } | ||
| } | ||
| return [...suppressions.values()].sort((a, b) => a.location.pos - b.location.pos); | ||
|
|
||
| function visit(node: Node, parentScope: readonly SuppressionScope[]) { | ||
| const entry = getScope(node); | ||
| const scope = entry ? [...parentScope, entry] : parentScope; | ||
| for (const directiveNode of node.directives ?? []) { | ||
| const directive = parseDirective(directiveNode); | ||
| if (directive?.name === "suppress") { | ||
| suppressions.set(directive.node, { | ||
| directive, | ||
| target: node, | ||
| location: { file: script.file, pos: directive.node.pos, end: directive.node.end }, | ||
| scope, | ||
| }); | ||
| } | ||
| } | ||
| visitChildren(node, (child) => { | ||
| if (node.kind === SyntaxKind.OperationSignatureDeclaration && child === node.parameters) { | ||
| visitChildren(child, (parameter) => visit(parameter, scope)); | ||
| } else { | ||
| visit(child, scope); | ||
| } | ||
| }); | ||
| } | ||
|
timotheeguerin marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function getScope(node: Node): SuppressionScope | undefined { | ||
| switch (node.kind) { | ||
| case SyntaxKind.NamespaceStatement: | ||
| case SyntaxKind.ModelStatement: | ||
| case SyntaxKind.ModelDeclarationExpression: | ||
| case SyntaxKind.ModelProperty: | ||
| case SyntaxKind.InterfaceStatement: | ||
| case SyntaxKind.OperationStatement: | ||
| case SyntaxKind.UnionStatement: | ||
| case SyntaxKind.UnionDeclarationExpression: | ||
| case SyntaxKind.UnionVariant: | ||
| case SyntaxKind.EnumStatement: | ||
| case SyntaxKind.EnumDeclarationExpression: | ||
| case SyntaxKind.EnumMember: | ||
| case SyntaxKind.ScalarStatement: | ||
| case SyntaxKind.ScalarDeclarationExpression: | ||
| case SyntaxKind.ScalarConstructor: | ||
| case SyntaxKind.AliasStatement: | ||
| case SyntaxKind.ConstStatement: | ||
| case SyntaxKind.DecoratorDeclarationStatement: | ||
| case SyntaxKind.FunctionDeclarationStatement: | ||
| case SyntaxKind.FunctionParameter: | ||
| return { node, name: node.id?.sv }; | ||
| case SyntaxKind.ModelExpression: | ||
| return { node }; | ||
| default: | ||
| return undefined; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { isAlias, isMap, isNode, isScalar, type Node, type YAMLMap } from "yaml"; | ||
| import type { Diagnostic, DiagnosticResult, SourceFile, SourceLocation } from "../core/types.js"; | ||
| import { parseYamlDocument } from "../yaml/parser.js"; | ||
|
|
||
| /** A rule explicitly disabled in a configuration file's `linter.disable` mapping. */ | ||
| export interface LinterDisable { | ||
| /** Rule code as written, without resolving short names or library aliases. */ | ||
| readonly code: string; | ||
| /** Decoded justification string. */ | ||
| readonly message: string; | ||
| /** Range of the rule key in the supplied file. */ | ||
| readonly location: SourceLocation; | ||
| } | ||
|
|
||
| /** | ||
| * Collect local `linter.disable` entries without loading or compiling a project. | ||
| * | ||
| * Reads only the relevant configuration structure; unrelated settings are not validated. | ||
| * Does not follow `extends`, read rulesets, or interpret `enable: false`. | ||
| * Same-file YAML aliases are supported, with locations at the original rule keys. | ||
| * | ||
| * @param source Configuration text or a source file retaining its path. | ||
| * @returns Entries in source order and diagnostics. On error, the entries are `undefined`; | ||
| * missing or empty optional sections produce an empty list. | ||
| */ | ||
| export function collectLinterDisables( | ||
| source: string | SourceFile, | ||
| ): DiagnosticResult<readonly LinterDisable[] | undefined> { | ||
| const [{ file, doc }, parseDiagnostics] = parseYamlDocument(source); | ||
| const diagnostics: Diagnostic[] = [...parseDiagnostics]; | ||
| if (diagnostics.some((d) => d.severity === "error")) { | ||
| return [undefined, diagnostics]; | ||
| } | ||
|
|
||
| const root = mapping(doc.contents, "/"); | ||
| const linter = mapping(root?.get("linter", true), "/linter"); | ||
| const disable = mapping(linter?.get("disable", true), "/linter/disable"); | ||
| const disables: LinterDisable[] = []; | ||
| for (const pair of disable?.items ?? []) { | ||
| const key = resolve(pair.key); | ||
| const value = resolve(pair.value); | ||
| if (key === undefined && isAlias(pair.key)) { | ||
| continue; | ||
| } | ||
| if (!isScalar(key) || typeof key.value !== "string") { | ||
| invalid(pair.key, "Rule codes must be strings."); | ||
| continue; | ||
| } | ||
| if (!isScalar(value) || typeof value.value !== "string") { | ||
| if (value !== undefined || !isAlias(pair.value)) { | ||
| invalid(pair.value ?? pair.key, `Justification for "${key.value}" must be a string.`); | ||
| } | ||
| continue; | ||
| } | ||
| disables.push({ code: key.value, message: value.value, location: location(pair.key) }); | ||
| } | ||
| return [diagnostics.some((d) => d.severity === "error") ? undefined : disables, diagnostics]; | ||
|
|
||
| function location(node: unknown): SourceLocation { | ||
| return { | ||
| file, | ||
| pos: isNode(node) ? (node.range?.[0] ?? 0) : 0, | ||
| end: isNode(node) ? (node.range?.[1] ?? 0) : 0, | ||
| }; | ||
| } | ||
|
|
||
| function invalid(node: unknown, message: string) { | ||
| diagnostics.push({ | ||
| code: "invalid-schema", | ||
| severity: "error", | ||
| message, | ||
| target: location(node), | ||
| }); | ||
| } | ||
|
|
||
| function resolve(node: unknown): Node | undefined { | ||
| if (isAlias(node)) { | ||
| const resolved = node.resolve(doc); | ||
| if (resolved === undefined) { | ||
| diagnostics.push({ | ||
| code: "yaml-alias-not-found", | ||
| severity: "error", | ||
| message: `Unresolved alias "${node.source}".`, | ||
| target: location(node), | ||
| }); | ||
| } | ||
| return resolved; | ||
| } | ||
| return isNode(node) ? node : undefined; | ||
| } | ||
|
|
||
| function mapping(node: unknown, path: string): YAMLMap | undefined { | ||
| const resolved = resolve(node); | ||
| if (resolved === undefined || (isScalar(resolved) && resolved.value === null)) { | ||
| return undefined; | ||
| } | ||
| if (isMap(resolved)) { | ||
| return resolved; | ||
| } | ||
| invalid(node, `Expected a mapping at "${path}".`); | ||
| return undefined; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { Directive, DirectiveExpressionNode } from "./types.js"; | ||
| import { SyntaxKind } from "./types.js"; | ||
|
|
||
| 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; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.