diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 58ef3531..3a3448e2 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2993,6 +2993,89 @@ ARCH-006/no-unapproved-deps 7. **Handle missing files gracefully.** If your rule reads a specific file like `package.json`, wrap the read in a try/catch and return early if the file does not exist. +## Opt-out directives + +There are two ways to handle exceptions in Archgate: **engine-level suppression** (works with any rule automatically) and **custom rule-level directives** (implemented by the rule author for domain-specific opt-outs). + +### Engine-level suppression + +Archgate supports inline `archgate-ignore` comments that suppress violations without modifying the rule itself. The engine parses these comments and filters matching violations before reporting. + +**Next-line suppression** — suppresses the violation on the immediately following line: + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy dep, migration planned for Q3 + +``` + +**File-level suppression** — suppresses all matching violations anywhere in the file: + +```typescript +// archgate-ignore-file ARCH-005/test-mirrors-src generated file, no manual test +``` + +**Multiple rules** — stack comments to suppress more than one rule on the same line: + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy dep +// archgate-ignore ARCH-003/use-style-text third-party lib handles colors + +``` + +Consecutive suppression comments all target the first non-suppression line that follows. + +The format is `ADR-ID/rule-id` followed by a reason. The reason is **required** — a suppression without a reason is ignored and produces a warning: + +``` +[suppression] Suppression for ARCH-006/no-unapproved-deps is missing a reason src/foo.ts:1 +``` + +Both `//` and `#` comment styles are supported, so suppressions work in TypeScript, JavaScript, YAML, Python, shell scripts, and other file types your rules may scan. + +:::tip +Unused suppression comments (comments that don't match any violation) also produce warnings, helping you clean up stale exceptions as code evolves. + +### Custom rule-level directives + +For domain-specific opt-outs, rule authors can implement their own comment-based directives inside the `check` function. This pattern gives the rule full control over the directive syntax, placement, and validation. + +```typescript +// In your .rules.ts file: +async check(ctx) { + const files = await ctx.glob("src/components/**/*Connected.tsx"); + + for (const file of files) { + const content = await ctx.readFile(file); + + // Support opt-out directive at the top of the file + if (/^\/\/\s*@no-presentational:/u.test(content.trimStart())) continue; + + // ... rule logic that may report a violation ... + ctx.report.violation({ + message: "Missing presentational component", + file, + fix: 'Add "// @no-presentational: " at the top of the file to opt out', + }); + } +} +``` + +The developer opts out by adding the directive to their file: + +```typescript +// @no-presentational: this component only redirects, no UI to render + +``` + +### When to use which + +| Approach | Best for | Who controls it | +| ----------------- | ------------------------------------------------ | ------------------------ | +| `archgate-ignore` | Ad-hoc exceptions for any rule | Developer using the rule | +| Custom directive | Domain-specific opt-outs with structured reasons | Rule author | + +Use `archgate-ignore` when a developer needs to suppress a one-off violation. Use custom directives when the opt-out is a first-class concept in your rule's domain — for example, marking a component as intentionally unpaired, or a file as auto-generated. + ## Next steps - [Common Rule Patterns](/examples/common-rule-patterns/) — Copy-pasteable patterns organized by category: dependency management, import restrictions, file structure, code quality, database schema, and architecture boundaries. @@ -4975,6 +5058,19 @@ interface ViolationDetail { --- +## Inline suppression + +Violations can be suppressed in source code using `archgate-ignore` comments. The engine handles this automatically — rules do not need any special logic. + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy dep, migration planned + +``` + +A reason is required. File-level suppression uses `archgate-ignore-file`. Stack multiple comments to suppress more than one rule on the same line. See [Opt-out directives](/guides/writing-rules/#opt-out-directives) for full details and custom directive patterns. + +--- + ## RuleSet The type used with `satisfies` to type-check your rules object. Export a plain object that conforms to this shape. diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx index cf2a8af0..18582316 100644 --- a/docs/src/content/docs/guides/writing-rules.mdx +++ b/docs/src/content/docs/guides/writing-rules.mdx @@ -301,6 +301,90 @@ ARCH-006/no-unapproved-deps 7. **Handle missing files gracefully.** If your rule reads a specific file like `package.json`, wrap the read in a try/catch and return early if the file does not exist. +## Opt-out directives + +There are two ways to handle exceptions in Archgate: **engine-level suppression** (works with any rule automatically) and **custom rule-level directives** (implemented by the rule author for domain-specific opt-outs). + +### Engine-level suppression + +Archgate supports inline `archgate-ignore` comments that suppress violations without modifying the rule itself. The engine parses these comments and filters matching violations before reporting. + +**Next-line suppression** — suppresses the violation on the immediately following line: + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy dep, migration planned for Q3 +import chalk from "chalk"; +``` + +**File-level suppression** — suppresses all matching violations anywhere in the file: + +```typescript +// archgate-ignore-file ARCH-005/test-mirrors-src generated file, no manual test +``` + +**Multiple rules** — stack comments to suppress more than one rule on the same line: + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy dep +// archgate-ignore ARCH-003/use-style-text third-party lib handles colors +import chalk from "chalk"; +``` + +Consecutive suppression comments all target the first non-suppression line that follows. + +The format is `ADR-ID/rule-id` followed by a reason. The reason is **required** — a suppression without a reason is ignored and produces a warning: + +``` +[suppression] Suppression for ARCH-006/no-unapproved-deps is missing a reason src/foo.ts:1 +``` + +Both `//` and `#` comment styles are supported, so suppressions work in TypeScript, JavaScript, YAML, Python, shell scripts, and other file types your rules may scan. + +:::tip +Unused suppression comments (comments that don't match any violation) also produce warnings, helping you clean up stale exceptions as code evolves. +::: + +### Custom rule-level directives + +For domain-specific opt-outs, rule authors can implement their own comment-based directives inside the `check` function. This pattern gives the rule full control over the directive syntax, placement, and validation. + +```typescript +// In your .rules.ts file: +async check(ctx) { + const files = await ctx.glob("src/components/**/*Connected.tsx"); + + for (const file of files) { + const content = await ctx.readFile(file); + + // Support opt-out directive at the top of the file + if (/^\/\/\s*@no-presentational:/u.test(content.trimStart())) continue; + + // ... rule logic that may report a violation ... + ctx.report.violation({ + message: "Missing presentational component", + file, + fix: 'Add "// @no-presentational: " at the top of the file to opt out', + }); + } +} +``` + +The developer opts out by adding the directive to their file: + +```typescript +// @no-presentational: this component only redirects, no UI to render +import { useNavigate } from "react-router"; +``` + +### When to use which + +| Approach | Best for | Who controls it | +| ----------------- | ------------------------------------------------ | ------------------------ | +| `archgate-ignore` | Ad-hoc exceptions for any rule | Developer using the rule | +| Custom directive | Domain-specific opt-outs with structured reasons | Rule author | + +Use `archgate-ignore` when a developer needs to suppress a one-off violation. Use custom directives when the opt-out is a first-class concept in your rule's domain — for example, marking a component as intentionally unpaired, or a file as auto-generated. + ## Next steps - [Common Rule Patterns](/examples/common-rule-patterns/) — Copy-pasteable patterns organized by category: dependency management, import restrictions, file structure, code quality, database schema, and architecture boundaries. diff --git a/docs/src/content/docs/nb/guides/writing-rules.mdx b/docs/src/content/docs/nb/guides/writing-rules.mdx index 72fc3a61..4e18568f 100644 --- a/docs/src/content/docs/nb/guides/writing-rules.mdx +++ b/docs/src/content/docs/nb/guides/writing-rules.mdx @@ -301,6 +301,90 @@ ARCH-006/no-unapproved-deps 7. **Håndter manglende filer elegant.** Hvis regelen din leser en spesifikk fil som `package.json`, pakk lesingen i en try/catch og returner tidlig hvis filen ikke eksisterer. +## Opt-out-direktiver + +Det finnes to måter å håndtere unntak i Archgate: **undertrykkelse på motornivå** (fungerer med alle regler automatisk) og **egendefinerte direktiver på regelnivå** (implementert av regelforfatteren for domenespesifikke opt-outs). + +### Undertrykkelse på motornivå + +Archgate støtter inline `archgate-ignore`-kommentarer som undertrykker brudd uten å endre selve regelen. Motoren analyserer disse kommentarene og filtrerer matchende brudd før rapportering. + +**Neste-linje-undertrykkelse** — undertrykker bruddet på den umiddelbart følgende linjen: + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy-avhengighet, migrering planlagt for Q3 +import chalk from "chalk"; +``` + +**Filnivå-undertrykkelse** — undertrykker alle matchende brudd hvor som helst i filen: + +```typescript +// archgate-ignore-file ARCH-005/test-mirrors-src generert fil, ingen manuell test +``` + +**Flere regler** — stable kommentarer for å undertrykke mer enn én regel på samme linje: + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy-avhengighet +// archgate-ignore ARCH-003/use-style-text tredjepartsbibliotek håndterer farger +import chalk from "chalk"; +``` + +Sammenhengende undertrykkelseskommentarer sikter alle mot den første ikke-undertrykkelseslinjen etter blokken. + +Formatet er `ADR-ID/rule-id` etterfulgt av en begrunnelse. Begrunnelsen er **påkrevd** — en undertrykkelse uten begrunnelse ignoreres og gir en advarsel: + +``` +[suppression] Suppression for ARCH-006/no-unapproved-deps is missing a reason src/foo.ts:1 +``` + +Både `//` og `#` kommentarstiler støttes, slik at undertrykkelser fungerer i TypeScript, JavaScript, YAML, Python, shell-skript og andre filtyper reglene dine kan skanne. + +:::tip +Ubrukte undertrykkelseskommentarer (kommentarer som ikke matcher noen brudd) gir også advarsler, som hjelper deg med å rydde opp i foreldede unntak etter hvert som koden utvikler seg. +::: + +### Egendefinerte direktiver på regelnivå + +For domenespesifikke opt-outs kan regelforfattere implementere sine egne kommentarbaserte direktiver inne i `check`-funksjonen. Dette mønsteret gir regelen full kontroll over direktivsyntaksen, plassering og validering. + +```typescript +// I din .rules.ts-fil: +async check(ctx) { + const files = await ctx.glob("src/components/**/*Connected.tsx"); + + for (const file of files) { + const content = await ctx.readFile(file); + + // Støtt opt-out-direktiv øverst i filen + if (/^\/\/\s*@no-presentational:/u.test(content.trimStart())) continue; + + // ... regellogikk som kan rapportere et brudd ... + ctx.report.violation({ + message: "Manglende presentasjonskomponent", + file, + fix: 'Legg til "// @no-presentational: " øverst i filen for å gjøre opt-out', + }); + } +} +``` + +Utvikleren gjør opt-out ved å legge til direktivet i filen sin: + +```typescript +// @no-presentational: denne komponenten bare omdirigerer, ingen UI å rendre +import { useNavigate } from "react-router"; +``` + +### Når bruke hva + +| Tilnærming | Best for | Hvem kontrollerer | +| --------------------- | ------------------------------------------------------- | ----------------------------- | +| `archgate-ignore` | Ad-hoc-unntak for alle regler | Utvikleren som bruker regelen | +| Egendefinert direktiv | Domenespesifikke opt-outs med strukturerte begrunnelser | Regelforfatteren | + +Bruk `archgate-ignore` når en utvikler trenger å undertrykke et engangsbrudd. Bruk egendefinerte direktiver når opt-outen er et førsteklasses konsept i regelens domene — for eksempel å markere en komponent som med vilje uparet, eller en fil som automatisk generert. + ## Neste steg - [Vanlige regelmønstre](/examples/common-rule-patterns/) -- Kopier-og-lim-inn-mønstre organisert etter kategori: avhengighetshåndtering, importrestriksjoner, filstruktur, kodekvalitet, databaseskjema og arkitekturgrenser. diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index 5ff88ace..f5373232 100644 --- a/docs/src/content/docs/nb/reference/rule-api.mdx +++ b/docs/src/content/docs/nb/reference/rule-api.mdx @@ -301,6 +301,19 @@ interface ViolationDetail { --- +## Inline undertrykkelse + +Brudd kan undertrykkes i kildekoden ved hjelp av `archgate-ignore`-kommentarer. Motoren håndterer dette automatisk — regler trenger ingen spesiell logikk. + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy-avhengighet, migrering planlagt +import chalk from "chalk"; +``` + +En begrunnelse er påkrevd. Filnivå-undertrykkelse bruker `archgate-ignore-file`. Stable flere kommentarer for å undertrykke mer enn én regel på samme linje. Se [Opt-out-direktiver](/guides/writing-rules/#opt-out-direktiver) for fullstendige detaljer og egendefinerte direktivmønstre. + +--- + ## RuleSet Typen brukt med `satisfies` for a typekontrollere regelobjektet ditt. Eksporter et vanlig objekt som samsvarer med denne formen. diff --git a/docs/src/content/docs/pt-br/guides/writing-rules.mdx b/docs/src/content/docs/pt-br/guides/writing-rules.mdx index 122a8ccb..e0823ae7 100644 --- a/docs/src/content/docs/pt-br/guides/writing-rules.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-rules.mdx @@ -301,6 +301,90 @@ ARCH-006/no-unapproved-deps 7. **Trate arquivos ausentes com elegância.** Se sua regra lê um arquivo específico como `package.json`, envolva a leitura em um try/catch e retorne antecipadamente se o arquivo não existir. +## Diretivas de opt-out + +Existem duas formas de lidar com exceções no Archgate: **supressão no nível do engine** (funciona com qualquer regra automaticamente) e **diretivas customizadas no nível da regra** (implementadas pelo autor da regra para opt-outs específicos do domínio). + +### Supressão no nível do engine + +O Archgate suporta comentários inline `archgate-ignore` que suprimem violações sem modificar a regra em si. O engine analisa esses comentários e filtra as violações correspondentes antes de reportar. + +**Supressão da próxima linha** — suprime a violação na linha imediatamente seguinte: + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps dep legada, migração planejada para Q3 +import chalk from "chalk"; +``` + +**Supressão no nível do arquivo** — suprime todas as violações correspondentes em qualquer lugar do arquivo: + +```typescript +// archgate-ignore-file ARCH-005/test-mirrors-src arquivo gerado, sem teste manual +``` + +**Múltiplas regras** — empilhe comentários para suprimir mais de uma regra na mesma linha: + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps dep legada +// archgate-ignore ARCH-003/use-style-text lib de terceiros lida com cores +import chalk from "chalk"; +``` + +Comentários de supressão consecutivos todos visam a primeira linha que não é uma supressão após o bloco. + +O formato é `ADR-ID/rule-id` seguido de um motivo. O motivo é **obrigatório** — uma supressão sem motivo é ignorada e produz um aviso: + +``` +[suppression] Suppression for ARCH-006/no-unapproved-deps is missing a reason src/foo.ts:1 +``` + +Ambos os estilos de comentário `//` e `#` são suportados, então as supressões funcionam em TypeScript, JavaScript, YAML, Python, shell scripts e outros tipos de arquivo que suas regras podem analisar. + +:::tip +Comentários de supressão não utilizados (comentários que não correspondem a nenhuma violação) também produzem avisos, ajudando a limpar exceções obsoletas à medida que o código evolui. +::: + +### Diretivas customizadas no nível da regra + +Para opt-outs específicos do domínio, autores de regras podem implementar suas próprias diretivas baseadas em comentários dentro da função `check`. Esse padrão dá à regra controle total sobre a sintaxe, posicionamento e validação da diretiva. + +```typescript +// No seu arquivo .rules.ts: +async check(ctx) { + const files = await ctx.glob("src/components/**/*Connected.tsx"); + + for (const file of files) { + const content = await ctx.readFile(file); + + // Suportar diretiva de opt-out no topo do arquivo + if (/^\/\/\s*@no-presentational:/u.test(content.trimStart())) continue; + + // ... lógica da regra que pode reportar uma violação ... + ctx.report.violation({ + message: "Componente presentational ausente", + file, + fix: 'Adicione "// @no-presentational: " no topo do arquivo para fazer opt-out', + }); + } +} +``` + +O desenvolvedor faz opt-out adicionando a diretiva ao seu arquivo: + +```typescript +// @no-presentational: este componente apenas redireciona, sem UI para renderizar +import { useNavigate } from "react-router"; +``` + +### Quando usar qual + +| Abordagem | Melhor para | Quem controla | +| -------------------- | -------------------------------------------------------- | ---------------------------- | +| `archgate-ignore` | Exceções ad-hoc para qualquer regra | Desenvolvedor usando a regra | +| Diretiva customizada | Opt-outs específicos do domínio com motivos estruturados | Autor da regra | + +Use `archgate-ignore` quando um desenvolvedor precisa suprimir uma violação pontual. Use diretivas customizadas quando o opt-out é um conceito de primeira classe no domínio da sua regra — por exemplo, marcar um componente como intencionalmente sem par, ou um arquivo como auto-gerado. + ## Próximos passos - [Padrões Comuns de Regras](/examples/common-rule-patterns/) — Padrões prontos para copiar e colar, organizados por categoria: gerenciamento de dependências, restrições de import, estrutura de arquivos, qualidade de código, esquema de banco de dados e limites de arquitetura. diff --git a/docs/src/content/docs/pt-br/reference/rule-api.mdx b/docs/src/content/docs/pt-br/reference/rule-api.mdx index 48499268..e06bff21 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -301,6 +301,19 @@ interface ViolationDetail { --- +## Supressão inline + +Violações podem ser suprimidas no código-fonte usando comentários `archgate-ignore`. O engine lida com isso automaticamente — as regras não precisam de nenhuma lógica especial. + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps dep legada, migração planejada +import chalk from "chalk"; +``` + +Um motivo é obrigatório. A supressão no nível do arquivo usa `archgate-ignore-file`. Empilhe múltiplos comentários para suprimir mais de uma regra na mesma linha. Veja [Diretivas de opt-out](/guides/writing-rules/#diretivas-de-opt-out) para detalhes completos e padrões de diretivas customizadas. + +--- + ## RuleSet O tipo usado com `satisfies` para verificar a tipagem do seu objeto de regras. Exporte um objeto simples que esteja em conformidade com esta forma. diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index f2f142d0..a9280f2b 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -301,6 +301,19 @@ interface ViolationDetail { --- +## Inline suppression + +Violations can be suppressed in source code using `archgate-ignore` comments. The engine handles this automatically — rules do not need any special logic. + +```typescript +// archgate-ignore ARCH-006/no-unapproved-deps legacy dep, migration planned +import chalk from "chalk"; +``` + +A reason is required. File-level suppression uses `archgate-ignore-file`. Stack multiple comments to suppress more than one rule on the same line. See [Opt-out directives](/guides/writing-rules/#opt-out-directives) for full details and custom directive patterns. + +--- + ## RuleSet The type used with `satisfies` to type-check your rules object. Export a plain object that conforms to this shape. diff --git a/src/engine/context.ts b/src/engine/context.ts index 0b8d10dc..881a7a0b 100644 --- a/src/engine/context.ts +++ b/src/engine/context.ts @@ -193,6 +193,8 @@ const EMPTY_SUMMARY: ReportSummary = { ruleErrors: 0, warningsExceeded: false, truncated: false, + suppressed: 0, + suppressionWarnings: [], results: [], durationMs: 0, }; diff --git a/src/engine/reporter.ts b/src/engine/reporter.ts index 747e96b7..0a657981 100644 --- a/src/engine/reporter.ts +++ b/src/engine/reporter.ts @@ -18,6 +18,10 @@ export interface ReportSummary { /** True when a `maxWarnings` threshold was set and the warning count exceeded it. */ warningsExceeded: boolean; truncated: boolean; + /** Number of violations suppressed by archgate-ignore comments. */ + suppressed: number; + /** Warnings from the suppression system (missing reason, unused suppression). */ + suppressionWarnings: Array<{ message: string; file: string; line: number }>; results: Array<{ adrId: string; ruleId: string; @@ -127,6 +131,12 @@ export function buildSummary( ruleErrors, warningsExceeded, truncated: anyTruncated, + suppressed: result.suppressedCount ?? 0, + suppressionWarnings: (result.suppressionWarnings ?? []).map((w) => ({ + message: w.message, + file: w.file, + line: w.line, + })), results, durationMs: result.totalDurationMs, }; @@ -190,6 +200,14 @@ export function reportConsole( } } + // Print suppression warnings + for (const w of summary.suppressionWarnings) { + const loc = w.line ? `${w.file}:${w.line}` : w.file; + console.log( + ` ${styleText("yellow", "[suppression]")} ${w.message} ${styleText("dim", loc)}` + ); + } + // Summary line console.log(); const parts: string[] = []; @@ -201,6 +219,8 @@ export function reportConsole( parts.push(styleText("red", `${summary.ruleErrors} errors`)); if (summary.warnings > 0) parts.push(styleText("yellow", `${summary.warnings} warnings`)); + if (summary.suppressed > 0) + parts.push(styleText("dim", `${summary.suppressed} suppressed`)); const durationStr = styleText("dim", `(${summary.durationMs.toFixed(0)}ms)`); const status = summary.pass @@ -268,6 +288,15 @@ export function reportCI( } } + // Suppression warnings + for (const w of summary.suppressionWarnings) { + const filePart = w.file ? ` file=${w.file}` : ""; + const linePart = w.line ? `,line=${w.line}` : ""; + console.log( + `::warning${filePart}${linePart} title=suppression::${w.message}` + ); + } + // Also output summary const status = summary.pass ? "check passed" : "check failed"; console.log( diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 4b59fca8..d2f623ed 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -17,6 +17,7 @@ import { getGitTrackedFiles, } from "./git-files"; import { type LoadResult, blockedToRuleResult } from "./loader"; +import { applySuppressions, type SuppressionWarning } from "./suppressions"; /** * Resolve a user-supplied path and ensure it stays within projectRoot. @@ -63,7 +64,7 @@ function safeGlob(pattern: string): void { } const RULE_TIMEOUT_MS = 30_000; -interface RuleResult { +export interface RuleResult { ruleId: string; adrId: string; description: string; @@ -75,6 +76,8 @@ interface RuleResult { export interface CheckResult { results: RuleResult[]; totalDurationMs: number; + suppressedCount?: number; + suppressionWarnings?: SuppressionWarning[]; } /** @@ -351,5 +354,22 @@ export async function runChecks( } } - return { results, totalDurationMs: performance.now() - startTime }; + // Apply inline suppressions (archgate-ignore / archgate-ignore-file comments) + const suppression = await applySuppressions(projectRoot, results); + + // Filter suppressed violations from each rule result + if (suppression.suppressedCount > 0) { + for (const r of results) { + r.violations = r.violations.filter((v) => + suppression.activeViolations.has(v) + ); + } + } + + return { + results, + totalDurationMs: performance.now() - startTime, + suppressedCount: suppression.suppressedCount, + suppressionWarnings: suppression.warnings, + }; } diff --git a/src/engine/suppressions.ts b/src/engine/suppressions.ts new file mode 100644 index 00000000..6fc1966c --- /dev/null +++ b/src/engine/suppressions.ts @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { resolve } from "node:path"; + +import type { ViolationDetail } from "../formats/rules"; +import { logDebug } from "../helpers/log"; +import type { RuleResult } from "./runner"; + +// --- Types --- + +export interface SuppressionComment { + type: "next-line" | "file"; + adrId: string; + ruleId: string; + reason: string | null; + /** 1-based line number of the comment itself. */ + line: number; + /** + * 1-based line this next-line suppression targets. + * For stacked comments, all suppressions in a consecutive block share the + * same target: the first non-suppression line after the block. + * Undefined for file-level suppressions. + */ + targetLine?: number; + file: string; + /** Mutable — set to true when a violation matches this suppression. */ + matched: boolean; +} + +export interface SuppressionWarning { + message: string; + file: string; + line: number; +} + +export interface SuppressionResult { + /** Set of violations that were NOT suppressed (remain active). */ + activeViolations: Set; + suppressedCount: number; + warnings: SuppressionWarning[]; +} + +// --- Parsing --- + +/** + * Matches both `//` and `#` style comments: + * // archgate-ignore ARCH-006/no-unapproved-deps legacy dep, migration planned + * // archgate-ignore-file ARCH-005/test-mirrors-src generated file + * # archgate-ignore GEN-003/scripts-only Makefile target + * + * Capture groups: + * 1: "-file" or undefined (scope) + * 2: ADR ID (e.g. "ARCH-006") + * 3: rule ID (e.g. "no-unapproved-deps") + * 4: reason text or undefined + */ +const SUPPRESSION_RE = + /^[ \t]*(?:\/\/|#)\s*archgate-ignore(-file)?\s+([\w-]+)\/([\w-]+)(?:\s+(.+))?$/u; + +/** Regex to detect fenced code block delimiters in markdown (``` or ~~~). */ +const FENCE_RE = /^[ \t]*(`{3,}|~{3,})/u; + +/** + * Parse suppression comments from file content. + * Returns one entry per matching comment line. + * + * In markdown files (.md, .mdx), lines inside fenced code blocks are skipped + * so that documented examples of `archgate-ignore` are not treated as real + * suppression directives. + */ +export function parseSuppressions( + content: string, + filePath: string +): SuppressionComment[] { + const lines = content.split("\n"); + const results: SuppressionComment[] = []; + const isMarkdown = filePath.endsWith(".md") || filePath.endsWith(".mdx"); + let insideCodeBlock = false; + + for (let i = 0; i < lines.length; i++) { + // Track fenced code blocks in markdown so examples are not parsed + if (isMarkdown && FENCE_RE.test(lines[i])) { + insideCodeBlock = !insideCodeBlock; + continue; + } + if (insideCodeBlock) continue; + + const match = lines[i].match(SUPPRESSION_RE); + if (!match) continue; + + results.push({ + type: match[1] === "-file" ? "file" : "next-line", + adrId: match[2], + ruleId: match[3], + reason: match[4]?.trim() ?? null, + line: i + 1, + file: filePath, + matched: false, + }); + } + + // Compute target lines: consecutive next-line suppressions all target the + // first non-suppression line after the block, so stacking comments works. + const suppressionLineSet = new Set( + results.filter((s) => s.type === "next-line").map((s) => s.line) + ); + for (const s of results) { + if (s.type !== "next-line") continue; + let target = s.line + 1; + while (suppressionLineSet.has(target)) target++; + s.targetLine = target; + } + + return results; +} + +// --- Filtering --- + +/** + * Apply inline suppressions to rule results. + * + * For each violation with a `file` and `line`, checks whether the source file + * contains an `archgate-ignore` comment on the preceding line (next-line scope) + * or an `archgate-ignore-file` comment anywhere in the file (file scope). + * + * Suppressions without a reason are ignored — a warning is emitted instead. + * Unused suppressions also produce warnings. + */ +export async function applySuppressions( + projectRoot: string, + results: RuleResult[] +): Promise { + // Collect unique file paths referenced by violations + const filePathsNeeded = new Set(); + for (const r of results) { + for (const v of r.violations) { + if (v.file) filePathsNeeded.add(v.file); + } + } + + if (filePathsNeeded.size === 0) { + const allViolations = new Set(); + for (const r of results) { + for (const v of r.violations) allViolations.add(v); + } + return { + activeViolations: allViolations, + suppressedCount: 0, + warnings: [], + }; + } + + // Read files in parallel and parse suppressions + const fileSuppressions = new Map(); + const readPromises = [...filePathsNeeded].map(async (relPath) => { + try { + const absPath = resolve(projectRoot, relPath); + const content = await Bun.file(absPath).text(); + const suppressions = parseSuppressions(content, relPath); + if (suppressions.length > 0) { + fileSuppressions.set(relPath, suppressions); + } + } catch { + // File unreadable — skip, no suppressions for this file + logDebug(`Suppression scan: could not read ${relPath}`); + } + }); + await Promise.all(readPromises); + + // Filter violations + const activeViolations = new Set(); + const warnings: SuppressionWarning[] = []; + let suppressedCount = 0; + + for (const r of results) { + for (const v of r.violations) { + const suppressed = checkSuppression(v, fileSuppressions, warnings); + if (suppressed) { + suppressedCount++; + } else { + activeViolations.add(v); + } + } + } + + // Detect unused suppressions + for (const [, suppressions] of fileSuppressions) { + for (const s of suppressions) { + if (s.reason === null) continue; // already warned about missing reason + if (!s.matched) { + warnings.push({ + message: `Unused suppression: ${s.adrId}/${s.ruleId}`, + file: s.file, + line: s.line, + }); + } + } + } + + logDebug( + `Suppressions: ${suppressedCount} suppressed, ${warnings.length} warnings` + ); + + return { activeViolations, suppressedCount, warnings }; +} + +/** + * Check whether a single violation is suppressed by any comment in its file. + * Returns true if suppressed. + */ +function checkSuppression( + violation: ViolationDetail, + fileSuppressions: Map, + warnings: SuppressionWarning[] +): boolean { + if (!violation.file) return false; + + const suppressions = fileSuppressions.get(violation.file); + if (!suppressions) return false; + + const qualifiedId = `${violation.adrId}/${violation.ruleId}`; + + for (const s of suppressions) { + if (s.adrId !== violation.adrId || s.ruleId !== violation.ruleId) continue; + + // Check scope match — targetLine accounts for stacked suppression blocks + const scopeMatches = + s.type === "file" || + (s.type === "next-line" && + violation.line !== undefined && + s.targetLine === violation.line); + + if (!scopeMatches) continue; + + // Reason is required — missing reason means the suppression is ignored + if (s.reason === null) { + warnings.push({ + message: `Suppression for ${qualifiedId} is missing a reason`, + file: s.file, + line: s.line, + }); + return false; + } + + s.matched = true; + return true; + } + + return false; +} diff --git a/tests/commands/check-action.test.ts b/tests/commands/check-action.test.ts index 6bbef236..f2247d16 100644 --- a/tests/commands/check-action.test.ts +++ b/tests/commands/check-action.test.ts @@ -49,6 +49,8 @@ const MOCK_SUMMARY: ReportSummary = { ruleErrors: 0, warningsExceeded: false, truncated: false, + suppressed: 0, + suppressionWarnings: [], results: [ { adrId: "TEST-001", diff --git a/tests/engine/reporter.test.ts b/tests/engine/reporter.test.ts index 2a0c9caf..d18cafec 100644 --- a/tests/engine/reporter.test.ts +++ b/tests/engine/reporter.test.ts @@ -333,5 +333,31 @@ describe("reporter", () => { expect(summary.warningsExceeded).toBe(false); expect(summary.pass).toBe(true); }); + + test("includes suppressed count from CheckResult", () => { + const result: CheckResult = { + ...makeResult(), + suppressedCount: 3, + suppressionWarnings: [ + { + message: "Unused suppression: ARCH-002/no-console", + file: "src/foo.ts", + line: 1, + }, + ], + }; + const summary = buildSummary(result); + expect(summary.suppressed).toBe(3); + expect(summary.suppressionWarnings).toHaveLength(1); + expect(summary.suppressionWarnings[0].message).toContain( + "Unused suppression" + ); + }); + + test("defaults suppressed to 0 when not present", () => { + const summary = buildSummary(makeResult()); + expect(summary.suppressed).toBe(0); + expect(summary.suppressionWarnings).toHaveLength(0); + }); }); }); diff --git a/tests/engine/runner.test.ts b/tests/engine/runner.test.ts index c14afd93..33ed9ae0 100644 --- a/tests/engine/runner.test.ts +++ b/tests/engine/runner.test.ts @@ -328,4 +328,133 @@ describe("runChecks", () => { expect(result.totalDurationMs).toBeGreaterThan(0); expect(result.results[0].durationMs).toBeGreaterThanOrEqual(0); }); + + // --- Inline suppression integration tests --- + + test("archgate-ignore comment suppresses matching violation", async () => { + writeFileSync( + join(tempDir, "src", "suppressed.ts"), + [ + "// archgate-ignore TEST-001/no-console legacy helper", + 'console.log("suppressed");', + "", + ].join("\n") + ); + + const loaded = makeLoadedAdr( + { files: ["src/**/*.ts"] }, + { + rules: { + "no-console": { + description: "No console.log", + async check(ctx) { + const results = await Promise.all( + ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log/u)) + ); + for (const matches of results) { + for (const m of matches) { + ctx.report.violation({ + message: "Found console.log", + file: m.file, + line: m.line, + }); + } + } + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].violations).toHaveLength(0); + expect(result.suppressedCount).toBe(1); + }); + + test("archgate-ignore-file suppresses all matching violations in file", async () => { + writeFileSync( + join(tempDir, "src", "exempt.ts"), + [ + "// archgate-ignore-file TEST-001/no-console debug file", + 'console.log("a");', + 'console.log("b");', + "", + ].join("\n") + ); + + const loaded = makeLoadedAdr( + { files: ["src/**/*.ts"] }, + { + rules: { + "no-console": { + description: "No console.log", + async check(ctx) { + const results = await Promise.all( + ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log/u)) + ); + for (const matches of results) { + for (const m of matches) { + ctx.report.violation({ + message: "Found console.log", + file: m.file, + line: m.line, + }); + } + } + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].violations).toHaveLength(0); + expect(result.suppressedCount).toBe(2); + }); + + test("suppression warnings populate CheckResult", async () => { + writeFileSync( + join(tempDir, "src", "warn.ts"), + [ + "// archgate-ignore TEST-001/no-console", + 'console.log("missing reason");', + "", + ].join("\n") + ); + + const loaded = makeLoadedAdr( + { files: ["src/**/*.ts"] }, + { + rules: { + "no-console": { + description: "No console.log", + async check(ctx) { + const results = await Promise.all( + ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log/u)) + ); + for (const matches of results) { + for (const m of matches) { + ctx.report.violation({ + message: "Found console.log", + file: m.file, + line: m.line, + }); + } + } + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + // Violation NOT suppressed because reason is missing + expect(result.results[0].violations).toHaveLength(1); + expect(result.suppressedCount).toBe(0); + expect(result.suppressionWarnings).toBeDefined(); + expect(result.suppressionWarnings!.length).toBeGreaterThan(0); + expect(result.suppressionWarnings![0].message).toContain( + "missing a reason" + ); + }); }); diff --git a/tests/engine/suppressions.test.ts b/tests/engine/suppressions.test.ts new file mode 100644 index 00000000..16090b6a --- /dev/null +++ b/tests/engine/suppressions.test.ts @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { RuleResult } from "../../src/engine/runner"; +import { + parseSuppressions, + applySuppressions, +} from "../../src/engine/suppressions"; +import type { ViolationDetail } from "../../src/formats/rules"; + +// --------------------------------------------------------------------------- +// parseSuppressions +// --------------------------------------------------------------------------- + +describe("parseSuppressions", () => { + test("parses a next-line suppression with reason", () => { + const content = + '// archgate-ignore ARCH-006/no-unapproved-deps legacy dep\nimport chalk from "chalk";\n'; + const result = parseSuppressions(content, "src/foo.ts"); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "next-line", + adrId: "ARCH-006", + ruleId: "no-unapproved-deps", + reason: "legacy dep", + line: 1, + file: "src/foo.ts", + matched: false, + }); + }); + + test("parses a file-level suppression with reason", () => { + const content = + "// archgate-ignore-file ARCH-005/test-mirrors-src generated file\n\nexport default {};\n"; + const result = parseSuppressions(content, "src/gen.ts"); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "file", + adrId: "ARCH-005", + ruleId: "test-mirrors-src", + reason: "generated file", + line: 1, + file: "src/gen.ts", + }); + }); + + test("parses hash-style comments", () => { + const content = + "# archgate-ignore GEN-003/scripts-only Makefile target\nfoo: bar\n"; + const result = parseSuppressions(content, "Makefile"); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "next-line", + adrId: "GEN-003", + ruleId: "scripts-only", + reason: "Makefile target", + }); + }); + + test("records null reason when reason text is missing", () => { + const content = + "// archgate-ignore ARCH-006/no-unapproved-deps\nimport chalk;\n"; + const result = parseSuppressions(content, "src/foo.ts"); + + expect(result).toHaveLength(1); + expect(result[0].reason).toBeNull(); + }); + + test("parses multiple suppression comments in one file", () => { + const content = [ + "// archgate-ignore ARCH-001/cmd-export legacy", + 'import { foo } from "./bar";', + "// archgate-ignore ARCH-002/no-console debug helper", + "console.log(foo);", + ].join("\n"); + const result = parseSuppressions(content, "src/main.ts"); + + expect(result).toHaveLength(2); + expect(result[0].adrId).toBe("ARCH-001"); + expect(result[0].line).toBe(1); + expect(result[1].adrId).toBe("ARCH-002"); + expect(result[1].line).toBe(3); + }); + + test("ignores non-matching lines", () => { + const content = [ + "// This is a normal comment", + "const x = 1; // archgate-ignore is not at line start", + "/* archgate-ignore ARCH-001/foo -- block comments not supported */", + "// archgate-ignoreFOO ARCH-001/foo -- no space", + ].join("\n"); + const result = parseSuppressions(content, "src/foo.ts"); + + expect(result).toHaveLength(0); + }); + + test("handles leading whitespace before comment marker", () => { + const content = + " // archgate-ignore ARCH-006/no-deps indented\n import x;\n"; + const result = parseSuppressions(content, "src/foo.ts"); + + expect(result).toHaveLength(1); + expect(result[0].reason).toBe("indented"); + }); + + test("handles ADR IDs with numbers and hyphens", () => { + const content = + "// archgate-ignore CI-001/pin-sha exempted\nuses: actions/checkout@v4\n"; + const result = parseSuppressions(content, ".github/workflows/ci.yml"); + + expect(result).toHaveLength(1); + expect(result[0].adrId).toBe("CI-001"); + expect(result[0].ruleId).toBe("pin-sha"); + }); + + test("returns empty array for empty content", () => { + expect(parseSuppressions("", "empty.ts")).toHaveLength(0); + }); + + test("skips suppression comments inside markdown code blocks", () => { + const content = [ + "# Example", + "", + "```typescript", + "// archgate-ignore ARCH-006/no-unapproved-deps example in docs", + 'import chalk from "chalk";', + "```", + "", + "Real suppression outside code block:", + "// archgate-ignore ARCH-001/cmd-export real one", + "some code", + ].join("\n"); + const result = parseSuppressions(content, "docs/guide.mdx"); + + expect(result).toHaveLength(1); + expect(result[0].adrId).toBe("ARCH-001"); + expect(result[0].ruleId).toBe("cmd-export"); + }); + + test("stacked suppressions share the same targetLine", () => { + const content = [ + "// archgate-ignore ARCH-006/no-unapproved-deps legacy dep", + "// archgate-ignore ARCH-002/no-console debug helper", + "// archgate-ignore ARCH-003/use-style-text third-party lib", + "// archgate-ignore ARCH-004/no-barrel barrel needed here", + 'console.log(chalk.red("error"));', + ].join("\n"); + const result = parseSuppressions(content, "src/foo.ts"); + + expect(result).toHaveLength(4); + // All four target line 5 (the first non-suppression line) + expect(result[0].targetLine).toBe(5); + expect(result[1].targetLine).toBe(5); + expect(result[2].targetLine).toBe(5); + expect(result[3].targetLine).toBe(5); + }); + + test("single suppression targets the next line", () => { + const content = [ + "// archgate-ignore ARCH-006/no-unapproved-deps legacy dep", + 'import chalk from "chalk";', + ].join("\n"); + const result = parseSuppressions(content, "src/foo.ts"); + + expect(result).toHaveLength(1); + expect(result[0].targetLine).toBe(2); + }); + + test("does not skip code blocks in non-markdown files", () => { + const content = [ + "```", + "// archgate-ignore ARCH-001/foo inside backticks in .ts", + "```", + ].join("\n"); + const result = parseSuppressions(content, "src/foo.ts"); + + // In a .ts file, ``` is not a code fence — the comment should be parsed + expect(result).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// applySuppressions +// --------------------------------------------------------------------------- + +describe("applySuppressions", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "archgate-suppress-"))); + mkdirSync(join(tempDir, "src"), { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + /* temp dir cleanup may fail on Windows */ + } + }); + + function makeViolation( + overrides: Partial = {} + ): ViolationDetail { + return { + ruleId: "no-console", + adrId: "ARCH-002", + message: "Found console.log", + file: "src/foo.ts", + line: 2, + severity: "error", + ...overrides, + }; + } + + function makeRuleResult(violations: ViolationDetail[]): RuleResult { + return { + ruleId: violations[0]?.ruleId ?? "test-rule", + adrId: violations[0]?.adrId ?? "TEST-001", + description: "Test rule", + violations, + durationMs: 10, + }; + } + + test("suppresses next-line violation with matching comment", async () => { + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore ARCH-002/no-console debug helper", + 'console.log("hello");', + "", + ].join("\n") + ); + + const v = makeViolation({ file: "src/foo.ts", line: 2 }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(1); + expect(result.activeViolations.has(v)).toBe(false); + expect(result.warnings).toHaveLength(0); + }); + + test("stacked suppressions suppress multiple rules on same line", async () => { + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore ARCH-006/no-deps legacy dep", + "// archgate-ignore ARCH-002/no-console debug helper", + 'console.log(chalk.red("error"));', + "", + ].join("\n") + ); + + const v1 = makeViolation({ + file: "src/foo.ts", + line: 3, + adrId: "ARCH-006", + ruleId: "no-deps", + }); + const v2 = makeViolation({ + file: "src/foo.ts", + line: 3, + adrId: "ARCH-002", + ruleId: "no-console", + }); + const result = await applySuppressions(tempDir, [ + makeRuleResult([v1]), + makeRuleResult([v2]), + ]); + + expect(result.suppressedCount).toBe(2); + expect(result.activeViolations.has(v1)).toBe(false); + expect(result.activeViolations.has(v2)).toBe(false); + expect(result.warnings).toHaveLength(0); + }); + + test("does not suppress when comment is not on preceding line", async () => { + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore ARCH-002/no-console debug helper", + "const x = 1;", + 'console.log("hello");', + "", + ].join("\n") + ); + + const v = makeViolation({ file: "src/foo.ts", line: 3 }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(0); + expect(result.activeViolations.has(v)).toBe(true); + }); + + test("file-level suppression suppresses all matching violations", async () => { + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore-file ARCH-002/no-console entire file exempt", + 'console.log("a");', + 'console.log("b");', + "", + ].join("\n") + ); + + const v1 = makeViolation({ file: "src/foo.ts", line: 2 }); + const v2 = makeViolation({ file: "src/foo.ts", line: 3 }); + const result = await applySuppressions(tempDir, [makeRuleResult([v1, v2])]); + + expect(result.suppressedCount).toBe(2); + expect(result.activeViolations.has(v1)).toBe(false); + expect(result.activeViolations.has(v2)).toBe(false); + }); + + test("missing reason leaves violation active and emits warning", async () => { + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore ARCH-002/no-console", + 'console.log("hello");', + "", + ].join("\n") + ); + + const v = makeViolation({ file: "src/foo.ts", line: 2 }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(0); + expect(result.activeViolations.has(v)).toBe(true); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].message).toContain("missing a reason"); + }); + + test("violations without file pass through unsuppressed", async () => { + const v = makeViolation({ file: undefined, line: undefined }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(0); + expect(result.activeViolations.has(v)).toBe(true); + }); + + test("violations without line pass through unsuppressed", async () => { + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore ARCH-002/no-console debug", + 'console.log("hello");', + "", + ].join("\n") + ); + + const v = makeViolation({ file: "src/foo.ts", line: undefined }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(0); + expect(result.activeViolations.has(v)).toBe(true); + }); + + test("file-level suppresses violations without line number", async () => { + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore-file ARCH-002/no-console exempt", + 'console.log("hello");', + "", + ].join("\n") + ); + + const v = makeViolation({ file: "src/foo.ts", line: undefined }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(1); + expect(result.activeViolations.has(v)).toBe(false); + }); + + test("mismatched adrId/ruleId does not suppress", async () => { + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore ARCH-006/no-deps wrong rule", + 'console.log("hello");', + "", + ].join("\n") + ); + + const v = makeViolation({ + file: "src/foo.ts", + line: 2, + adrId: "ARCH-002", + ruleId: "no-console", + }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(0); + expect(result.activeViolations.has(v)).toBe(true); + }); + + test("reports unused suppression warning", async () => { + // Suppression targets ARCH-002/no-console on line 1 (next-line = line 2), + // but the violation is on line 3 — so the suppression is unused. + writeFileSync( + join(tempDir, "src", "foo.ts"), + [ + "// archgate-ignore ARCH-002/no-console debug helper", + "const x = 1;", + 'console.log("hello");', + "", + ].join("\n") + ); + + const v = makeViolation({ file: "src/foo.ts", line: 3 }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + const unusedWarnings = result.warnings.filter((w) => + w.message.includes("Unused suppression") + ); + expect(unusedWarnings).toHaveLength(1); + expect(unusedWarnings[0].file).toBe("src/foo.ts"); + }); + + test("handles file read failure gracefully", async () => { + // Violation references a file that doesn't exist + const v = makeViolation({ file: "src/nonexistent.ts", line: 2 }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(0); + expect(result.activeViolations.has(v)).toBe(true); + expect(result.warnings).toHaveLength(0); + }); + + test("returns all violations active when no suppressions exist", async () => { + writeFileSync(join(tempDir, "src", "foo.ts"), 'console.log("hello");\n'); + + const v = makeViolation({ file: "src/foo.ts", line: 1 }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(0); + expect(result.activeViolations.has(v)).toBe(true); + expect(result.warnings).toHaveLength(0); + }); + + test("handles multiple rule results across different files", async () => { + writeFileSync( + join(tempDir, "src", "a.ts"), + [ + "// archgate-ignore ARCH-002/no-console ok in a", + 'console.log("a");', + "", + ].join("\n") + ); + writeFileSync(join(tempDir, "src", "b.ts"), 'console.log("b");\n'); + + const v1 = makeViolation({ file: "src/a.ts", line: 2 }); + const v2 = makeViolation({ file: "src/b.ts", line: 1 }); + const result = await applySuppressions(tempDir, [ + makeRuleResult([v1]), + makeRuleResult([v2]), + ]); + + expect(result.suppressedCount).toBe(1); + expect(result.activeViolations.has(v1)).toBe(false); + expect(result.activeViolations.has(v2)).toBe(true); + }); + + test("returns early with no work when violations have no file paths", async () => { + const v = makeViolation({ file: undefined }); + const result = await applySuppressions(tempDir, [makeRuleResult([v])]); + + expect(result.suppressedCount).toBe(0); + expect(result.activeViolations.size).toBe(1); + }); +});