Skip to content
Open
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
19 changes: 19 additions & 0 deletions .chronus/changes/compiler-suppression-inspection.md
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.
8 changes: 8 additions & 0 deletions packages/compiler/src/ast/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
*/

export { NodeFlags, SyntaxKind } from "../core/types.js";
export type { SuppressDirective } from "../core/types.js";
export {
collectSuppressions,
getSuppressions,
type ProgramSuppression,
type Suppression,
type SuppressionScope,
} from "./suppressions.js";

export { getNodeForTarget } from "../core/diagnostics.js";
export { printTypeSpecNode } from "../core/formatter.js";
Expand Down
148 changes: 148 additions & 0 deletions packages/compiler/src/ast/suppressions.ts
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;
Comment thread
timotheeguerin marked this conversation as resolved.
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);
}
});
}
Comment thread
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;
}
}
103 changes: 103 additions & 0 deletions packages/compiler/src/config/linter-disables.ts
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;
}
}
22 changes: 22 additions & 0 deletions packages/compiler/src/core/directives.ts
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;
}
}
Loading
Loading