diff --git a/skills/coderuler-new-rule/SKILL.md b/skills/coderuler-new-rule/SKILL.md index 3980ba8..cf5d806 100644 --- a/skills/coderuler-new-rule/SKILL.md +++ b/skills/coderuler-new-rule/SKILL.md @@ -1,6 +1,6 @@ --- name: coderuler-new-rule -description: Create CodeRuler rules, including custom selection, filters and checks, inline or companion tests, and inspection. +description: Create CodeRuler rules, including selection, checks, tests, inspection, and suppressing accepted violations. --- # Create a CodeRuler rule @@ -136,6 +136,12 @@ Finally exercise normal evaluation: coderuler check . --rules .coderuler/k8s-replica-floor.rule.ts --output-all ``` +## 5. Record a valid exception + +After the project check, suppress only a finding the rule correctly identifies but the project accepts. Find its exact rule name and location with `coderuler inspect . --json`, then add a narrow entry to `.coderuler/suppressions.yml` with a short reason. Prefer a scope ID; use inclusive lines when a scope is unavailable or too broad. + +Rerun `coderuler check . --output-all` and confirm only the intended finding is suppressed. Fix a rule or fixture when its policy is wrong; never add a suppression just to pass a test. The engine applies suppressions between a valid check result and its report; this does not change applicability or skip checks and AI calls. See [violation suppressions](reference.md#violation-suppressions) for the YAML shape and root rules. + ## Good and bad practices | Prefer | Avoid | diff --git a/skills/coderuler-new-rule/reference.md b/skills/coderuler-new-rule/reference.md index bcff934..b0ec37e 100644 --- a/skills/coderuler-new-rule/reference.md +++ b/skills/coderuler-new-rule/reference.md @@ -23,7 +23,7 @@ Default-export a constructible class extending `Rule` (`T = JsonValue`). Extr | `check: Evaluator` | Required | Function-valued property, such as `check = regex(...)` or `check: Evaluator = ...`, rather than a method. | -Execution: load and validate all rules → apply `check` tag filters → enabled → `when` → select → validate subjects → filter → group → check → report. `Awaitable` means `T | Promise`. +Execution: load and validate all rules → apply `check` tag filters → read root suppressions → enabled → `when` → select → validate subjects → filter → group → check → validate/normalize result → apply suppressions → report. Suppression leaves applicability and check execution unchanged, including AI calls. `Awaitable` means `T | Promise`. `files` and `exclude` must be dense arrays of nonempty strings, including on disabled rules. `tags`, when present, must be dense arrays of nonempty strings. @@ -344,6 +344,26 @@ Scalar AI classification supplies no offending-member attribution: scope AI can Results validate; supplied messages survive reporting. Violations without a message report rule/location without generated text. Return real probability/model/threshold metadata only; supplied probability/threshold must be finite in `[0, 1]`. Throw for execution errors. External libraries/providers may be called directly; no backend registry is required. +## Violation suppressions + +Rules return `CheckResult` with status `pass` or `violation`; only the engine creates `EvaluationResult` with status `suppressed`. The engine applies suppressions after validating and normalizing a check result, before reporting it. A partial match remains a violation with uncovered locations active; a full match has status `suppressed` and records each covered location and reason. Only active violations reach `Rule.comment`; fully suppressed results do not create PR comments. Suppression does not change applicability, skip checks, hide execution errors, or avoid AI costs. There is no bypass setting. + +Each run reads `.coderuler/suppressions.yml` from its project root: the invocation root for `check`, the selected folder for `inspect`, and each isolated fixture root for rule tests. Rule-test fixtures do not inherit the invocation project's configuration. Use `coderuler inspect . --json` to find the exact rule name, scope IDs, and line ranges. + +```yaml +suppressions: + field-naming: + - files: ["api/orders.proto"] + scope: "acme.Order.legacy_id" + reason: "Existing public contract" + no-console: + - files: ["src/legacy.js"] + lines: { start: 42, end: 45 } + reason: "Required diagnostic output" +``` + +`files` uses root-relative globs. `scope` is an exact selector ID; `lines` is an inclusive range that must contain the entire finding range. Use at most one selector; omitting both covers all findings in the matching files. Each entry needs a reason, and the first matching entry supplies it. Fully suppressed results and their reasons appear with `--output-all`; default check output omits them. Rules cannot return `suppressed`, and their applicability filters should not encode local exceptions. + ### Context helpers diff --git a/src/adapter/cli/inspect-json.ts b/src/adapter/cli/inspect-json.ts index db4d11e..6725901 100644 --- a/src/adapter/cli/inspect-json.ts +++ b/src/adapter/cli/inspect-json.ts @@ -1,7 +1,8 @@ import type { RuleReport, RunEvent } from '../../app/ports.js'; -import type { CheckResult, GroupSubject, JsonValue, ScopeSubject, Subject } from '../../app/rule.js'; -import type { InspectRenderer } from './reports.js'; +import type { EvaluationResult } from '../../app/suppressions.js'; +import type { GroupSubject, JsonValue, ScopeSubject, Subject } from '../../app/rule.js'; import type { TextRedactor, TextWriter } from './text.js'; +import type { InspectRenderer } from './reports.js'; import { createUsageTracker } from './inspect-usage.js'; interface InspectJsonOptions { @@ -60,12 +61,30 @@ function redactSubject(subject: Subject, redact: TextRedactor): Subject { return subject.kind === 'scope' ? redactScope(subject, redact) : redactGroup(subject, redact); } -function redactResult(result: CheckResult, redact: TextRedactor): CheckResult { +function redactResult(result: EvaluationResult, redact: TextRedactor): EvaluationResult { + const suppressedLocations = result.suppressedLocations?.map(suppressed => ({ + location: { + ...suppressed.location, + filepath: redact(suppressed.location.filepath), + ...(suppressed.location.scopeId === undefined ? {} : { scopeId: redact(suppressed.location.scopeId) }), + }, + reason: redact(suppressed.reason), + })); + if (result.status === 'suppressed') { + return { + ...result, + ...(suppressedLocations === undefined ? {} : { suppressedLocations }), + ...(result.message === undefined ? {} : { message: redact(result.message) }), + ...(result.model === undefined ? {} : { model: redact(result.model) }), + ...(result.usage === undefined ? {} : { usage: { ...result.usage } }), + }; + } return { ...result, ...(result.message === undefined ? {} : { message: redact(result.message) }), ...(result.model === undefined ? {} : { model: redact(result.model) }), ...(result.usage === undefined ? {} : { usage: { ...result.usage } }), + ...(suppressedLocations === undefined ? {} : { suppressedLocations }), }; } diff --git a/src/adapter/cli/reports.ts b/src/adapter/cli/reports.ts index e0e69b6..863a2d4 100644 --- a/src/adapter/cli/reports.ts +++ b/src/adapter/cli/reports.ts @@ -9,6 +9,7 @@ import type { RuleTestResult } from '../../app/rule-tests.js'; import type { AiUsage, JsonValue, ScopeSubject, Subject } from '../../app/rule.js'; import { addUsage } from '../../app/usage.js'; import type { ViolationComment } from '../../app/violation-comments.js'; +import type { SuppressedLocation } from '../../app/suppressions.js'; import { createUsageTracker } from './inspect-usage.js'; import { escapeText, type TextRedactor, type TextWriter } from './text.js'; @@ -432,14 +433,20 @@ export function createWalkthroughRenderer(options: WalkthroughOptions, inspectUs paint(' body:', 'gray', color), ...walkthroughPayload(event.body, redact, raw).map(line => paint(` ${line}`, 'gray', color)), ]); - case 'result': + case 'result': { + const result = event.result; return section(card, 'Result', [ - ` ${field('status', event.result.status, event.result.status === 'pass' ? 'green' : 'red')}`, - ...(event.result.message === undefined ? [] : [` ${field('message', event.result.message)}`]), - ...(event.result.probability === undefined ? [] : [` ${field('probability', String(event.result.probability))}`]), - ...(event.result.threshold === undefined ? [] : [` ${field('threshold', String(event.result.threshold))}`]), - ...(event.result.model === undefined ? [] : [` ${field('model', event.result.model)}`]), + ` ${field('status', result.status, result.status === 'pass' ? 'green' : result.status === 'suppressed' ? 'yellow' : 'red')}`, + ...(result.message === undefined ? [] : [` ${field('message', result.message)}`]), + ...(result.probability === undefined ? [] : [` ${field('probability', String(result.probability))}`]), + ...(result.threshold === undefined ? [] : [` ${field('threshold', String(result.threshold))}`]), + ...(result.model === undefined ? [] : [` ${field('model', result.model)}`]), + ...(result.suppressedLocations ?? []).flatMap(({ location, reason }) => [ + ` ${field('suppressed location', `${location.filepath}${location.lines === undefined ? '' : `:${location.lines.start}-${location.lines.end}`}${location.scopeId === undefined ? '' : ` ${location.scopeId}`}`, 'dim')}`, + ` ${field('reason', reason)}`, + ]), ]); + } case 'trace': return section(card, 'Trace', [ ` ${field('message', event.message)}`, @@ -794,6 +801,19 @@ function formatDuration(durationMs: number): string { return `${durationMs.toFixed(2)}ms`; } +function suppressionSummaryLines( + locations: readonly SuppressedLocation[] | undefined, + safe: (text: string) => string, + muted: (text: string) => string, +): string[] { + return (locations ?? []).flatMap(({ location, reason }) => { + const target = `${safe(location.filepath)}${ + location.lines === undefined ? '' : `:${location.lines.start}-${location.lines.end}` + }${location.scopeId === undefined ? '' : ` ${safe(location.scopeId)}`}`; + return [muted(` Suppressed: ${target}`), ` ${safe(reason)}`]; + }); +} + export function renderCheckSummary( reports: readonly RuleReport[], redact: TextRedactor, @@ -807,12 +827,17 @@ export function renderCheckSummary( const skipped = reports.filter(report => report.status === 'skipped'); const verifications = reports.reduce((total, report) => total + report.evaluations.length, 0); const violations = countViolations(reports); + const suppressed = reports.reduce( + (total, report) => total + report.evaluations.filter(evaluation => evaluation.result.status === 'suppressed').length, + 0, + ); const lines = [ [ `Rules: ${reports.length}`, paint(`Skipped: ${skipped.length}`, skipped.length > 0 ? 'yellow' : 'dim', color), `Verifications: ${verifications}`, paint(`Violations: ${violations}`, violations > 0 ? 'red' : 'green', color), + ...(suppressed > 0 ? [paint(`Suppressed: ${suppressed}`, 'yellow', color)] : []), `Time: ${formatDuration(options.durationMs)}`, ].join(' '), '', @@ -829,8 +854,7 @@ export function renderCheckSummary( const entries: { readonly name: string; readonly subject: Subject; - readonly message: string | undefined; - readonly status: 'pass' | 'violation'; + readonly result: RuleReport['evaluations'][number]['result']; }[] = []; for (const report of reports) { for (const evaluation of report.evaluations) { @@ -838,8 +862,7 @@ export function renderCheckSummary( entries.push({ name: report.name, subject: evaluation.subject, - message: evaluation.result.message, - status: evaluation.result.status, + result: evaluation.result, }); } } @@ -849,9 +872,14 @@ export function renderCheckSummary( const shown = outputAll ? entries : entries.slice(0, MAX_VIOLATIONS); for (const entry of shown) { const subject = entry.subject; - const prefix = entry.status === 'pass' ? `${paint('✓', 'green', color)} ` : ''; + const result = entry.result; + const prefix = result.status === 'pass' + ? `${paint('✓', 'green', color)} ` + : result.status === 'suppressed' + ? `${paint('[suppressed]', 'yellow', color)} ` + : ''; const ruleName = `${prefix}${paint( - name(entry.name), entry.status === 'violation' ? 'red' : 'green', color, + name(entry.name), result.status === 'violation' ? 'red' : result.status === 'suppressed' ? 'yellow' : 'green', color, )}`; if (subject.kind === 'group') { lines.push( @@ -859,7 +887,8 @@ export function renderCheckSummary( `group ${subject.group.type} ${safe(subject.group.path)} (${subject.members.length} members)`, )}`, ); - if (entry.message !== undefined) lines.push(` ${safe(entry.message)}`); + if (result.message !== undefined) lines.push(` ${safe(result.message)}`); + if (outputAll) lines.push(...suppressionSummaryLines(result.suppressedLocations, safe, muted)); const members = subject.members.slice(0, MAX_GROUP_MEMBERS); for (const member of members) { lines.push( @@ -885,7 +914,8 @@ export function renderCheckSummary( subject.lines === undefined ? '' : `:${subject.lines.start}-${subject.lines.end}` }`; lines.push(` ${ruleName}: ${muted(location)}`); - if (entry.message !== undefined) lines.push(` ${safe(entry.message)}`); + if (result.message !== undefined) lines.push(` ${safe(result.message)}`); + if (outputAll) lines.push(...suppressionSummaryLines(result.suppressedLocations, safe, muted)); lines.push(...excerptLines(subject, MAX_SOURCE_LINES, redact, color, outputAll)); } if (!outputAll && entries.length > MAX_VIOLATIONS) { diff --git a/src/adapter/filesystem/project.ts b/src/adapter/filesystem/project.ts index c8fd7a6..664c670 100644 --- a/src/adapter/filesystem/project.ts +++ b/src/adapter/filesystem/project.ts @@ -7,6 +7,7 @@ import type { Project } from '../../app/ports.js'; import { parseCsv } from '../scope/csv.js'; import { parseJson } from '../scope/json.js'; import { parseYaml } from '../scope/yaml.js'; +import { loadSuppressions } from './suppressions.js'; const SKIPPED_ANY_DEPTH: ReadonlySet = new Set([ '.git', @@ -259,6 +260,7 @@ export function createProject(root: string, options: ProjectOptions = {}): Proje return { root: resolvedRoot, + readSuppressions: () => loadSuppressions(resolvedRoot), glob: globFiles, diff --git a/src/adapter/filesystem/suppressions.ts b/src/adapter/filesystem/suppressions.ts new file mode 100644 index 0000000..17480e6 --- /dev/null +++ b/src/adapter/filesystem/suppressions.ts @@ -0,0 +1,62 @@ +import { constants } from 'node:fs'; +import { lstat, open, type FileHandle } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { SuppressionConfig } from '../../app/suppressions.js'; +import { parseSuppressionConfig } from '../../app/suppressions.js'; +import { parseYaml } from '../scope/yaml.js'; + +const CONFIG_PATH = '.coderuler/suppressions.yml'; + +function emptyConfig(): SuppressionConfig { + return new Map(); +} + +function errorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' + ? error.code + : undefined; +} + +async function readConfiguration(path: string): Promise { + let handle: FileHandle | undefined; + try { + const stat = await lstat(path); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('must be a regular, non-symlinked file'); + } + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + if (!(await handle.stat()).isFile()) throw new Error('must be a regular file'); + return await handle.readFile('utf8'); + } catch (error) { + if (errorCode(error) === 'ENOENT') return undefined; + throw error; + } finally { + await handle?.close(); + } +} + +export async function loadSuppressions(root: string): Promise { + try { + const directory = join(root, '.coderuler'); + let directoryStat; + try { + directoryStat = await lstat(directory); + } catch (error) { + if (errorCode(error) === 'ENOENT') return emptyConfig(); + throw error; + } + if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) { + throw new Error('.coderuler must be a regular directory'); + } + + const source = await readConfiguration(join(directory, 'suppressions.yml')); + if (source === undefined) return emptyConfig(); + const documents = parseYaml(source); + if (documents.length === 0) return emptyConfig(); + if (documents.length !== 1) throw new Error('must contain exactly one nonempty YAML document'); + return parseSuppressionConfig(documents[0]); + } catch (error) { + throw new Error(`${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); + } +} diff --git a/src/app/execute.ts b/src/app/execute.ts index 61dbd91..45571ca 100644 --- a/src/app/execute.ts +++ b/src/app/execute.ts @@ -25,6 +25,8 @@ import type { RunContext, RunEvent, } from './ports.js'; +import { applySuppressions } from './suppressions.js'; +import type { EvaluationResult, SuppressionConfig } from './suppressions.js'; import { createRunLsp } from './lsp.js'; import { mapConcurrent } from './concurrency.js'; import { evaluateAi } from './evaluators.js'; @@ -181,6 +183,12 @@ async function executeRun( }); return error instanceof Error ? error : new Error(message); }; + let suppressions: SuppressionConfig; + try { + suppressions = await context.project.readSuppressions(); + } catch (error) { + throw fail(undefined, 'suppression', error); + } const statCache = new Map>(); @@ -279,7 +287,7 @@ async function executeRun( subject: Subject, evaluationId: number, reportFailure: (error: unknown) => void, - ): Promise => { + ): Promise => { plan.evaluationStartedAt ??= performance.now(); const { name, rule } = plan; emit({ type: 'evaluation', evaluationId, name, subject }); @@ -378,12 +386,13 @@ async function executeRun( } const normalized: CheckResult = trackedUsage === undefined ? result : { ...result, usage: trackedUsage }; - emit({ type: 'result', evaluationId, name, subject, result: normalized }); + const effective = applySuppressions(suppressions, name, subject, normalized); + emit({ type: 'result', evaluationId, name, subject, result: effective }); context.emit?.({ type: 'progress', phase: 'evaluation', completed: ++completedEvaluations, total: tasks.length, }); plan.evaluationFinishedAt = performance.now(); - return normalized; + return effective; }; const plans: Plan[] = []; diff --git a/src/app/ports.ts b/src/app/ports.ts index 4b5971b..bce3b8c 100644 --- a/src/app/ports.ts +++ b/src/app/ports.ts @@ -1,7 +1,6 @@ import type { AiUsage, Awaitable, - CheckResult, GroupSubject, JsonValue, LineRange, @@ -13,6 +12,7 @@ import type { TokenPricing, TokenUsage, } from './rule.js'; +import type { EvaluationResult, SuppressionConfig } from './suppressions.js'; export interface DiffChanges { readonly changedFiles: ReadonlySet; @@ -28,6 +28,7 @@ export interface Project { readJson(path: string): Promise; readYaml(path: string): Promise; readCsv(path: string, options?: CsvOptions): Promise; + readSuppressions(): Promise; } export interface LspBackend extends LspTools { close(): Promise; @@ -109,7 +110,7 @@ export interface RunContext { export interface RuleEvaluation { readonly subject: Subject; - readonly result: CheckResult; + readonly result: EvaluationResult; } export interface RuleReport { @@ -180,7 +181,7 @@ export type RunEvent = readonly evaluationId: number; readonly name: string; readonly subject: Subject; - readonly result: CheckResult; + readonly result: EvaluationResult; } | { readonly type: 'trace'; diff --git a/src/app/suppressions.ts b/src/app/suppressions.ts new file mode 100644 index 0000000..194c63e --- /dev/null +++ b/src/app/suppressions.ts @@ -0,0 +1,199 @@ +import { matchesGlob } from 'node:path'; + +import type { CheckResult, LineRange, Subject, ViolationLocation } from './rule.js'; + +export interface SuppressionEntry { + readonly files: readonly string[]; + readonly scope?: string; + readonly lines?: LineRange; + readonly reason: string; +} + +export type SuppressionConfig = ReadonlyMap; + +export interface SuppressedLocation { + readonly location: ViolationLocation; + readonly reason: string; +} + +export type EvaluationResult = + | (CheckResult & { readonly suppressedLocations?: readonly SuppressedLocation[] }) + | (Omit & { + readonly status: 'suppressed'; + readonly locations?: never; + readonly suppressedLocations: readonly SuppressedLocation[]; + }); + +function isMapping(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function invalid(at: string, detail: string): never { + throw new Error(`${at} ${detail}`); +} + +function rejectUnknownFields(value: Record, allowed: readonly string[], at: string): void { + for (const field of Object.keys(value)) { + if (!allowed.includes(field)) invalid(`${at} field "${field}"`, 'is not allowed'); + } +} + +function parseLines(value: unknown, at: string): LineRange { + if (!isMapping(value)) invalid(`${at} field "lines"`, 'must be a mapping'); + rejectUnknownFields(value, ['start', 'end'], `${at} field "lines"`); + const start = value['start']; + const end = value['end']; + if (typeof start !== 'number' || !Number.isSafeInteger(start) || start < 1) { + invalid(`${at} field "lines.start"`, 'must be a positive safe integer'); + } + if (typeof end !== 'number' || !Number.isSafeInteger(end) || end < 1) { + invalid(`${at} field "lines.end"`, 'must be a positive safe integer'); + } + if (start > end) invalid(`${at} field "lines"`, 'must have start <= end'); + return { start, end }; +} + +function parseEntry(value: unknown, ruleName: string, index: number): SuppressionEntry { + const at = `rule "${ruleName}" entry ${index + 1}`; + if (!isMapping(value)) invalid(at, 'must be a mapping'); + rejectUnknownFields(value, ['files', 'scope', 'lines', 'reason'], at); + + const rawFiles = value['files']; + if (!Array.isArray(rawFiles) || rawFiles.length === 0) { + invalid(`${at} field "files"`, 'must be a nonempty array of nonblank strings'); + } + const files: string[] = []; + for (let fileIndex = 0; fileIndex < rawFiles.length; fileIndex += 1) { + const file = rawFiles[fileIndex]; + if (typeof file !== 'string' || file.trim() === '') { + invalid(`${at} field "files[${fileIndex}]"`, 'must be a nonblank string'); + } + files.push(file); + } + + const reason = value['reason']; + if (typeof reason !== 'string' || reason.trim() === '') { + invalid(`${at} field "reason"`, 'must be a nonblank string'); + } + + const hasScope = Object.hasOwn(value, 'scope'); + const hasLines = Object.hasOwn(value, 'lines'); + if (hasScope && hasLines) invalid(at, 'must not define both "scope" and "lines"'); + if (hasScope) { + const scope = value['scope']; + if (typeof scope !== 'string' || scope.length === 0) { + invalid(`${at} field "scope"`, 'must be a nonempty string'); + } + return { files, scope, reason }; + } + if (hasLines) return { files, lines: parseLines(value['lines'], at), reason }; + return { files, reason }; +} + +export function parseSuppressionConfig(value: unknown): SuppressionConfig { + if (!isMapping(value)) invalid('suppression configuration', 'must be a mapping'); + rejectUnknownFields(value, ['suppressions'], 'suppression configuration'); + const rawSuppressions = value['suppressions']; + if (!isMapping(rawSuppressions)) invalid('field "suppressions"', 'must be a mapping'); + + const config = new Map(); + for (const [ruleName, rawEntries] of Object.entries(rawSuppressions)) { + if (ruleName.trim() === '') invalid('rule name', 'must be nonblank'); + if (!Array.isArray(rawEntries)) invalid(`rule "${ruleName}"`, 'must contain an array of entries'); + const entries: SuppressionEntry[] = []; + for (let index = 0; index < rawEntries.length; index += 1) { + if (!Object.hasOwn(rawEntries, index)) invalid(`rule "${ruleName}" entry ${index + 1}`, 'must be a mapping'); + entries.push(parseEntry(rawEntries[index], ruleName, index)); + } + config.set(ruleName, entries); + } + return config; +} + +function contains(outer: LineRange, inner: LineRange): boolean { + return outer.start <= inner.start && outer.end >= inner.end; +} + +function scopeIdForLocation(location: ViolationLocation, subject: Subject): string | undefined { + if (location.scopeId !== undefined) return location.scopeId; + if (location.lines === undefined) return undefined; + + if (subject.kind === 'scope') { + return subject.filepath === location.filepath ? subject.id : undefined; + } + + const containingMembers = subject.members.filter(member => + member.filepath === location.filepath && member.lines !== undefined && contains(member.lines, location.lines!), + ); + return containingMembers.length === 1 ? containingMembers[0]!.id : undefined; +} + +function matchesEntry( + entry: SuppressionEntry, + location: ViolationLocation, + scopeId: string | undefined, +): boolean { + if (!entry.files.some(pattern => matchesGlob(location.filepath, pattern))) return false; + if (entry.scope !== undefined) return scopeId === entry.scope; + if (entry.lines !== undefined) return location.lines !== undefined && contains(entry.lines, location.lines); + return true; +} + +function reasonForLocation( + entries: readonly SuppressionEntry[], + location: ViolationLocation, + scopeId: string | undefined, +): string | undefined { + for (const entry of entries) { + if (matchesEntry(entry, location, scopeId)) return entry.reason; + } + return undefined; +} + +function subjectLocations(subject: Subject): readonly ViolationLocation[] { + const members = subject.kind === 'scope' ? [subject] : subject.members; + return members.map(({ filepath, id, lines }) => ({ + filepath, + scopeId: id, + ...(lines === undefined ? {} : { lines }), + })); +} + +export function applySuppressions( + config: SuppressionConfig, + ruleName: string, + subject: Subject, + result: CheckResult, +): EvaluationResult { + if (result.status !== 'violation') return result; + const entries = config.get(ruleName); + if (entries === undefined || entries.length === 0) return result; + + if (result.locations === undefined || result.locations.length === 0) { + const targets = subjectLocations(subject); + if (targets.length === 0) return result; + const suppressedLocations: SuppressedLocation[] = []; + for (const location of targets) { + const reason = reasonForLocation(entries, location, location.scopeId); + if (reason === undefined) return result; + suppressedLocations.push({ location, reason }); + } + const { locations: _locations, ...withoutLocations } = result; + return { ...withoutLocations, status: 'suppressed', suppressedLocations }; + } + + const active: ViolationLocation[] = []; + const suppressedLocations: SuppressedLocation[] = []; + for (const location of result.locations) { + const scopeId = scopeIdForLocation(location, subject); + const reason = reasonForLocation(entries, location, scopeId); + if (reason === undefined) active.push(location); + else suppressedLocations.push({ location, reason }); + } + if (suppressedLocations.length === 0) return result; + if (active.length === 0) { + const { locations: _locations, ...withoutLocations } = result; + return { ...withoutLocations, status: 'suppressed', suppressedLocations }; + } + return { ...result, locations: active, suppressedLocations }; +} diff --git a/src/index.ts b/src/index.ts index fd1b2ef..e37be19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,12 @@ export type { ViolationLocation, WorkspaceSymbol, } from './app/rule.js'; +export type { + EvaluationResult, + SuppressionConfig, + SuppressionEntry, + SuppressedLocation, +} from './app/suppressions.js'; export { ai, pass, regex, violation } from './app/evaluators.js'; export type { RegexOptions } from './app/evaluators.js'; diff --git a/tests/cli-reports.test.ts b/tests/cli-reports.test.ts index d2395e1..f735b01 100644 --- a/tests/cli-reports.test.ts +++ b/tests/cli-reports.test.ts @@ -450,6 +450,42 @@ test('passing output-all diagnostics preserve custom messages and redact before assert.ok(!plain.includes('🚓'), plain); }); +test('summary shows suppression count and details only with output-all', () => { + const report: RuleReport = { + ...violation(scope('bad();')), + evaluations: [ + { + subject: scope('bad();', 'src/active.ts'), + result: { + status: 'violation', + suppressedLocations: [{ location: { filepath: 'src/hidden.ts', scopeId: 'old_id', lines: { start: 4, end: 5 } }, reason: 'accepted\tsecret' }], + }, + }, + { + subject: scope('old();', 'src/hidden.ts'), + result: { + status: 'suppressed', + message: 'Existing finding', + suppressedLocations: [{ location: { filepath: 'src/hidden.ts', scopeId: 'old_id', lines: { start: 4, end: 5 } }, reason: 'accepted\tsecret' }], + }, + }, + ], + }; + const plain = renderCheckSummary([report], identity, options); + assert.ok(plain.startsWith('Rules: 1 Skipped: 0 Verifications: 2 Violations: 1 Suppressed: 1 Time: 12.50ms'), plain); + assert.ok(!plain.includes('accepted'), plain); + assert.ok(!plain.includes('[suppressed]'), plain); + + const all = renderCheckSummary([report], identity, { ...options, outputAll: true }); + assert.ok(all.includes('unicode: src/active.ts'), all); + assert.ok(all.includes('[suppressed] unicode: src/hidden.ts:9-11'), all); + assert.ok(all.includes('Existing finding'), all); + assert.ok(all.includes('src/hidden.ts:4-5 old_id'), all); + assert.ok(all.includes('accepted\\u0009secret'), all); + const redacted = renderCheckSummary([report], createRedactor(['secret']), { ...options, outputAll: true }); + assert.ok(redacted.includes('accepted\\u0009[REDACTED]'), redacted); +}); + test('empty summaries include duration without inventing details', () => { const output = renderCheckSummary([], identity, { durationMs: 0, outputAll: true }); assert.equal(output, 'Rules: 0 Skipped: 0 Verifications: 0 Violations: 0 Time: 0.00ms\n\nNo verifications were run.\n'); @@ -605,6 +641,46 @@ test('walkthrough status colors are opt-in and preserve plain card and setup con assert.ok(colored.includes('\u001b[2m'), colored); assert.ok(!colored.includes('\r'), colored); }); +test('walkthrough renders suppressed coverage and escaped reasons in yellow', () => { + const subject = scope('old();'); + const secret = 'private'; + const events: readonly RunEvent[] = [ + { type: 'evaluation', evaluationId: 9, name: 'rule', subject }, + { + type: 'result', + evaluationId: 9, + name: 'rule', + subject, + result: { + status: 'suppressed', + suppressedLocations: [{ + location: { filepath: `src/${secret}.ts`, scopeId: 'legacy_id', lines: { start: 9, end: 11 } }, + reason: `accepted\t${secret}`, + }], + }, + }, + ]; + const render = (color = false): string => { + const chunks: string[] = []; + const renderer = createWalkthroughRenderer({ + write: text => chunks.push(text), + headings: () => [], + redact: createRedactor([secret]), + color, + }, true); + events.forEach(renderer.emit); + renderer.finish(); + return chunks.join(''); + }; + const plain = render(); + const colored = render(true); + assert.ok(plain.includes('status: suppressed'), plain); + assert.ok(plain.includes('src/[REDACTED].ts:9-11 legacy_id'), plain); + assert.ok(plain.includes('accepted\\u0009[REDACTED]'), plain); + assert.ok(colored.includes('\u001b[33msuppressed\u001b[39m'), colored); + assert.equal(stripVTControlCharacters(colored), plain); +}); + test('wire requests, structured responses and correlated traces retain complete JSON payloads', () => { const subject = scope('safe();'); diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index 5bd6cfe..f14daa1 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -22,6 +22,7 @@ import { type ScopeSelector, type ScopeSubject, type SourceFile, + type SuppressionConfig, type Subject, } from '../dist/index.js'; import { createRunLsp } from '../dist/app/lsp.js'; @@ -75,6 +76,9 @@ class MemoryProject implements Project { async readYaml(): Promise { throw new Error('MemoryProject does not read YAML'); } + async readSuppressions(): Promise { + return new Map(); + } async readCsv(): Promise { throw new Error('MemoryProject does not read CSV'); diff --git a/tests/engine.test.ts b/tests/engine.test.ts index f746d4e..a74e45c 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -32,6 +32,7 @@ import type { ScopeSubject, SelectionContext, Subject, + SuppressionConfig, TokenUsage, } from '../dist/index.js'; import { executeRules } from '../dist/app/execute.js'; @@ -91,6 +92,9 @@ class MemoryProject implements Project { async readYaml(): Promise { throw new Error('MemoryProject does not read YAML'); } + async readSuppressions(): Promise { + return new Map(); + } async readCsv(): Promise { throw new Error('MemoryProject does not read CSV'); diff --git a/tests/golden.test.ts b/tests/golden.test.ts index 4da06a4..8e5423c 100644 --- a/tests/golden.test.ts +++ b/tests/golden.test.ts @@ -20,6 +20,7 @@ import type { ScopeSelector, ScopeSubject, Subject, + SuppressionConfig, } from '../dist/index.js'; import { createProject } from '../dist/adapter/filesystem/project.js'; import { createRuleLoader } from '../dist/adapter/filesystem/rules.js'; @@ -67,6 +68,9 @@ class GoldenProject implements Project { async readYaml(): Promise { throw new Error('GoldenProject does not read YAML'); } + async readSuppressions(): Promise { + return new Map(); + } async readCsv(): Promise { throw new Error('GoldenProject does not read CSV'); diff --git a/tests/inspect-json.test.ts b/tests/inspect-json.test.ts index 6c07698..373dbdf 100644 --- a/tests/inspect-json.test.ts +++ b/tests/inspect-json.test.ts @@ -243,6 +243,48 @@ for (const format of ['json', 'jsonl'] as const) { assert.equal(chunks.join(''), output); }); + test(`inspect ${format} redacts suppression metadata and preserves ranges`, () => { + const chunks: string[] = []; + const redact = createRedactor(['secret']); + const renderer = createInspectJsonRenderer({ write: text => chunks.push(text), redact, format }); + const subject: ScopeSubject = { + kind: 'scope', filepath: 'src/file.ts', id: 'scope', source: 'x', lines: { start: 3, end: 5 }, + }; + const result = { + status: 'suppressed' as const, + message: 'accepted', + suppressedLocations: [{ + location: { filepath: 'src/secret.ts', scopeId: 'secret.scope', lines: { start: 3, end: 5 } }, + reason: 'secret reason', + }], + }; + const event: RunEvent = { type: 'result', evaluationId: 1, name: 'rule', subject, result }; + const report: RuleReport = { + name: 'rule', ruleFile: 'rule.ts', status: 'pass', durationMs: 1, + evaluations: [{ subject, result }], + }; + renderer.emit(event); + renderer.finish([report]); + const expected = { + ...result, + suppressedLocations: [{ + location: { filepath: 'src/[REDACTED].ts', scopeId: '[REDACTED].scope', lines: { start: 3, end: 5 } }, + reason: '[REDACTED] reason', + }], + }; + if (format === 'json') { + const document = JSON.parse(chunks.join('')) as { events: RunEvent[]; reports: RuleReport[] }; + assert.deepEqual(document.events[0], { ...event, result: expected }); + assert.deepEqual(document.reports[0]!.evaluations[0]!.result, expected); + assert.equal(JSON.stringify(document).includes('secret'), false); + } else { + const records = chunks.map(chunk => JSON.parse(chunk) as { type: string; event?: RunEvent; report?: RuleReport }); + assert.deepEqual(records[0]!.event, { ...event, result: expected }); + assert.deepEqual(records[1]!.report!.evaluations[0]!.result, expected); + assert.equal(JSON.stringify(records).includes('secret'), false); + } + }); + test(`inspect ${format} handles preparation failure before any event and empty successful runs`, () => { for (const error of [undefined, '', 'selector unavailable']) { const chunks: string[] = []; diff --git a/tests/support.ts b/tests/support.ts index 852ab48..0dbe8f5 100644 --- a/tests/support.ts +++ b/tests/support.ts @@ -222,14 +222,20 @@ interface ViolationLocationShape { readonly lines?: { readonly start: number; readonly end: number }; } +interface SuppressedLocationShape { + readonly location: ViolationLocationShape; + readonly reason: string; +} + export interface CheckResultShape { - readonly status: 'pass' | 'violation'; + readonly status: 'pass' | 'violation' | 'suppressed'; readonly message?: string; readonly probability?: number; readonly threshold?: number; readonly model?: string; readonly usage?: AiUsage; readonly locations?: readonly ViolationLocationShape[]; + readonly suppressedLocations?: readonly SuppressedLocationShape[]; } export interface ScopeSubjectShape { @@ -264,7 +270,7 @@ export interface CheckReportShape { readonly evaluations: readonly CheckEvaluationShape[]; } -function toViolationLocation(value: unknown, where: string): ViolationLocationShape { +function toViolationLocation(value: unknown, where: string, allowFileOnly = false): ViolationLocationShape { const mapping = asMapping(value, where); const filepath = asString(mapping['filepath'], `${where}.filepath`); if (filepath.length === 0) throw new Error(`${where}.filepath must not be empty`); @@ -283,7 +289,7 @@ function toViolationLocation(value: unknown, where: string): ViolationLocationSh } location.lines = { start, end }; } - if (location.scopeId === undefined && location.lines === undefined) { + if (!allowFileOnly && location.scopeId === undefined && location.lines === undefined) { throw new Error(`${where} must include scopeId or lines`); } return location; @@ -292,10 +298,12 @@ function toViolationLocation(value: unknown, where: string): ViolationLocationSh function toCheckResult(value: unknown, where: string): CheckResultShape { const mapping = asMapping(value, where); const status = mapping['status']; - if (status !== 'pass' && status !== 'violation') throw new Error(`${where}.status must be pass or violation`); + if (status !== 'pass' && status !== 'violation' && status !== 'suppressed') { + throw new Error(`${where}.status must be pass, violation, or suppressed`); + } const result: { - status: 'pass' | 'violation'; message?: string; probability?: number; threshold?: number; model?: string; usage?: AiUsage; - locations?: readonly ViolationLocationShape[]; + status: 'pass' | 'violation' | 'suppressed'; message?: string; probability?: number; threshold?: number; model?: string; usage?: AiUsage; + locations?: readonly ViolationLocationShape[]; suppressedLocations?: readonly SuppressedLocationShape[]; } = { status }; const message = mapping['message']; if (message !== undefined) result.message = asString(message, `${where}.message`); @@ -307,6 +315,21 @@ function toCheckResult(value: unknown, where: string): CheckResultShape { toViolationLocation(location, `${where}.locations[${index}]`), ); } + const suppressedLocations = mapping['suppressedLocations']; + if (suppressedLocations !== undefined) { + if (status === 'pass') throw new Error(`${where}.suppressedLocations are only valid for violations or suppressed results`); + if (!Array.isArray(suppressedLocations)) throw new Error(`${where}.suppressedLocations must be an array`); + result.suppressedLocations = suppressedLocations.map((suppressed: unknown, index: number) => { + const entry = asMapping(suppressed, `${where}.suppressedLocations[${index}]`); + return { + location: toViolationLocation(entry['location'], `${where}.suppressedLocations[${index}].location`, true), + reason: asString(entry['reason'], `${where}.suppressedLocations[${index}].reason`), + }; + }); + } + if (status === 'suppressed' && result.suppressedLocations === undefined) { + throw new Error(`${where}.suppressedLocations are required for suppressed results`); + } const probability = mapping['probability']; if (probability !== undefined) result.probability = asNumber(probability, `${where}.probability`); const threshold = mapping['threshold']; diff --git a/tests/suppressions.test.ts b/tests/suppressions.test.ts new file mode 100644 index 0000000..92db250 --- /dev/null +++ b/tests/suppressions.test.ts @@ -0,0 +1,643 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; + +import { Rule, ai, scopes, violation as ruleViolation } from '../dist/index.js'; +import { applySuppressions, parseSuppressionConfig } from '../dist/app/suppressions.js'; +import type { + CheckResult, + DecisionBackend, + DecisionOutcome, + DecisionRuntime, + Evaluator, + GroupSubject, + Project, + RuleReport, + RunContext, + RunEvent, + ScopeSelector, + ScopeSubject, + Subject, +} from '../dist/index.js'; +import { executeRules } from '../dist/app/execute.js'; +import { createProject } from '../dist/adapter/filesystem/project.js'; +import { createSandbox, makeDirectory, makeSymlink, runCli, writeFile } from './support.ts'; + +const sandbox = createSandbox('coderuler-suppressions-'); +after(() => sandbox.cleanup()); + +function scope(id: string, filepath = 'src/legacy.js', lines?: { readonly start: number; readonly end: number }): ScopeSubject { + return { + kind: 'scope', + id, + filepath, + source: 'legacy source', + ...(lines === undefined ? {} : { lines }), + }; +} + +function group(members: readonly ScopeSubject[]): GroupSubject { + return { kind: 'group', group: { type: 'project', path: '.' }, members }; +} + +function violation(locations?: CheckResult['locations']): CheckResult { + return { status: 'violation', message: 'Policy failed', ...(locations === undefined ? {} : { locations }) }; +} +class LocalRule extends Rule { + check: Evaluator; + + constructor(check: Evaluator) { + super(); + this.check = check; + } +} + +function runContext(project: Project, events: RunEvent[], runtime?: DecisionRuntime): RunContext { + return { + project, + isTarget: () => true, + concurrency: 2, + resolveDecision: () => { + if (runtime === undefined) throw new Error('Unexpected AI decision'); + return runtime; + }, + emit: event => events.push(event), + }; +} + +async function runRule( + project: Project, + name: string, + rule: LocalRule, + runtime?: DecisionRuntime, +): Promise<{ readonly report: RuleReport; readonly events: readonly RunEvent[] }> { + const events: RunEvent[] = []; + const reports = await executeRules([{ + name, + ruleFile: `.coderuler/${name}.rule.ts`, + rule: rule as Rule, + }], runContext(project, events, runtime)); + assert.equal(reports.length, 1); + return { report: reports[0]!, events }; +} + +test('suppression configuration requires exact rule names and narrow entries', () => { + const config = parseSuppressionConfig({ + suppressions: { + 'field-naming': [ + { files: ['api/**'], scope: ' legacy_id ', reason: 'Existing contract' }, + { files: ['generated/**'], reason: 'Generated source' }, + ], + }, + }); + assert.equal(config.get('field-naming')?.[0]?.scope, ' legacy_id '); + assert.deepEqual(parseSuppressionConfig({ suppressions: {} }).size, 0); + assert.deepEqual(parseSuppressionConfig({ suppressions: { rule: [] } }).get('rule'), []); + + for (const [value, message] of [ + [{}, 'field "suppressions"'], + [{ suppressions: [], extra: true }, 'field "extra"'], + [{ suppressions: { ' ': [] } }, 'rule name'], + [{ suppressions: { rule: {} } }, 'rule "rule"'], + [{ suppressions: { rule: [{ files: [], reason: 'accepted' }] } }, 'entry 1 field "files"'], + [{ suppressions: { rule: [{ files: ['src/**'], scope: 'x', lines: { start: 1, end: 1 }, reason: 'accepted' }] } }, 'both "scope" and "lines"'], + [{ suppressions: { rule: [{ files: ['src/**'], reason: ' ' }] } }, 'field "reason"'], + [{ suppressions: { rule: [{ files: ['src/**'], lines: { start: 3, end: 2 }, reason: 'accepted' }] } }, 'start <= end'], + [{ suppressions: { rule: [{ files: ['src/**'], lines: { start: 1, end: 2, extra: 3 }, reason: 'accepted' }] } }, 'field "lines" field "extra"'], + ] as const) { + assert.throws(() => parseSuppressionConfig(value), new RegExp(message.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } + const sparseEntries: unknown[] = []; + sparseEntries.length = 1; + assert.throws( + () => parseSuppressionConfig({ suppressions: { policy: sparseEntries } }), + /rule "policy" entry 1 must be a mapping/, + ); +}); + +test('line suppression requires full inclusive containment and keeps the first reason', () => { + const config = parseSuppressionConfig({ + suppressions: { + policy: [ + { files: ['src/**'], lines: { start: 42, end: 45 }, reason: 'First exception' }, + { files: ['src/legacy.js'], lines: { start: 40, end: 50 }, reason: 'Later exception' }, + ], + }, + }); + const subject = scope('legacy', 'src/legacy.js', { start: 40, end: 50 }); + const covered = applySuppressions(config, 'policy', subject, violation([ + { filepath: 'src/legacy.js', lines: { start: 42, end: 45 } }, + ])); + assert.equal(covered.status, 'suppressed'); + assert.deepEqual(covered.suppressedLocations, [{ + location: { filepath: 'src/legacy.js', lines: { start: 42, end: 45 } }, + reason: 'First exception', + }]); + + const overlapping = violation([{ filepath: 'src/legacy.js', lines: { start: 39, end: 43 } }]); + assert.equal(applySuppressions(config, 'policy', subject, overlapping), overlapping); + const uncoveredEnd = violation([{ filepath: 'src/legacy.js', lines: { start: 42, end: 51 } }]); + assert.equal(applySuppressions(config, 'policy', subject, uncoveredEnd), uncoveredEnd); + const missingLines = violation([{ filepath: 'src/legacy.js', scopeId: 'legacy' }]); + assert.equal(applySuppressions(config, 'policy', subject, missingLines), missingLines); +}); + +test('scope suppression resolves line-only locations without guessing group members', () => { + const config = parseSuppressionConfig({ + suppressions: { policy: [{ files: ['src/**'], scope: 'legacy', reason: 'Named exception' }] }, + }); + const location = { filepath: 'src/legacy.js', lines: { start: 2, end: 2 } }; + const singleSubject = scope('legacy', 'src/legacy.js', { start: 1, end: 3 }); + assert.equal(applySuppressions(config, 'policy', singleSubject, violation([location])).status, 'suppressed'); + + const selectedGroup = group([ + scope('legacy', 'src/legacy.js', { start: 1, end: 3 }), + scope('other', 'src/legacy.js', { start: 4, end: 6 }), + ]); + assert.equal(applySuppressions(config, 'policy', selectedGroup, violation([location])).status, 'suppressed'); + const unrelatedGroup = group([scope('other', 'src/legacy.js', { start: 4, end: 6 })]); + assert.equal(applySuppressions(config, 'policy', unrelatedGroup, violation([location])).status, 'violation'); + + const unboundedGroup = group([scope('legacy')]); + const unboundedResult = violation([location]); + assert.equal(applySuppressions(config, 'policy', unboundedGroup, unboundedResult), unboundedResult); +}); + +test('unattributed group failures suppress only when every evaluated member is covered', () => { + const members = [ + scope('first', 'src/first.js', { start: 1, end: 2 }), + scope('second', 'src/second.js', { start: 3, end: 4 }), + ]; + const subject: Subject = group(members); + const original = violation([]); + const partial = parseSuppressionConfig({ + suppressions: { policy: [{ files: ['src/first.js'], reason: 'Accepted first member' }] }, + }); + assert.equal(applySuppressions(partial, 'policy', subject, original), original); + const omitted = violation(); + assert.equal(applySuppressions(partial, 'policy', subject, omitted), omitted); + + const complete = parseSuppressionConfig({ + suppressions: { + policy: [ + { files: ['src/first.js'], reason: 'Accepted first member' }, + { files: ['src/second.js'], reason: 'Accepted second member' }, + ], + }, + }); + const suppressed = applySuppressions(complete, 'policy', subject, original); + const fullyCoveredOmitted = applySuppressions(complete, 'policy', subject, omitted); + assert.equal(fullyCoveredOmitted.status, 'suppressed'); + assert.equal('locations' in fullyCoveredOmitted, false); + assert.equal(suppressed.status, 'suppressed'); + assert.equal('locations' in suppressed, false); + assert.deepEqual(suppressed.suppressedLocations?.map(({ location, reason }) => [location.scopeId, reason]), [ + ['first', 'Accepted first member'], + ['second', 'Accepted second member'], + ]); +}); + +test('explicit group locations are suppressed independently and preserve active order', () => { + const subject = group([ + scope('first', 'src/first.js', { start: 1, end: 2 }), + scope('second', 'src/second.js', { start: 3, end: 4 }), + ]); + const first = { filepath: 'src/first.js', scopeId: 'first', lines: { start: 1, end: 2 } }; + const second = { filepath: 'src/second.js', scopeId: 'second', lines: { start: 3, end: 4 } }; + const result = violation([first, second]); + const partial = parseSuppressionConfig({ + suppressions: { policy: [{ files: ['src/first.js'], scope: 'first', reason: 'Accepted first' }] }, + }); + const partiallySuppressed = applySuppressions(partial, 'policy', subject, result); + assert.equal(partiallySuppressed.status, 'violation'); + assert.deepEqual(partiallySuppressed.locations, [second]); + assert.deepEqual(partiallySuppressed.suppressedLocations, [{ location: first, reason: 'Accepted first' }]); + + const complete = parseSuppressionConfig({ + suppressions: { + policy: [ + { files: ['src/first.js'], scope: 'first', reason: 'Accepted first' }, + { files: ['src/second.js'], scope: 'second', reason: 'Accepted second' }, + ], + }, + }); + const fullySuppressed = applySuppressions(complete, 'policy', subject, result); + assert.equal(fullySuppressed.status, 'suppressed'); + assert.equal('locations' in fullySuppressed, false); + assert.deepEqual(fullySuppressed.suppressedLocations?.map(({ location }) => location), [first, second]); +}); + +test('filesystem configuration is optional, empty, and reloaded for each execution', async () => { + const { cwd } = sandbox.isolated('reload'); + const project = createProject(cwd); + const absent = await project.readSuppressions(); + assert.equal(absent.size, 0); + assert.notEqual(await project.readSuppressions(), absent); + makeDirectory(join(cwd, '.coderuler')); + assert.equal((await project.readSuppressions()).size, 0); + + const path = join(cwd, '.coderuler', 'suppressions.yml'); + writeFile(path, '# no active rules\n'); + assert.equal((await project.readSuppressions()).size, 0); + + writeFile(path, 'suppressions:\n policy:\n - files: [\"src/**\"]\n reason: First\n'); + assert.equal((await project.readSuppressions()).get('policy')?.[0]?.reason, 'First'); + writeFile(path, 'suppressions:\n policy:\n - files: [\"src/**\"]\n reason: Changed\n'); + assert.equal((await project.readSuppressions()).get('policy')?.[0]?.reason, 'Changed'); +}); + +test('filesystem configuration reports malformed YAML, documents, and entries', async () => { + const { cwd } = sandbox.isolated('invalid-config'); + const project = createProject(cwd); + const path = join(cwd, '.coderuler', 'suppressions.yml'); + + for (const [source, message] of [ + ['null\n', /suppression configuration must be a mapping/], + ['suppressions: {}\n---\nsuppressions: {}\n', /exactly one nonempty YAML document/], + ['suppressions:\n policy: []\n policy: []\n', /YAML source could not be parsed/], + ['suppressions:\n policy:\n - files: [\"src/**\"]\n scope: x\n lines: { start: 1, end: 2 }\n reason: invalid\n', /rule \"policy\" entry 1 must not define both/], + ['suppressions:\n policy:\n - files: ["src/**"]\n lines: { start: 0, end: 2 }\n reason: invalid range\n', /rule "policy" entry 1 field "lines.start"/], + ['suppressions:\n policy:\n - files: [\"src/**\"]\n reason: \" \"\n', /rule \"policy\" entry 1 field \"reason\"/], + ] as const) { + writeFile(path, source); + await assert.rejects(project.readSuppressions(), error => { + assert.ok((error as Error).message.startsWith('.coderuler/suppressions.yml:')); + assert.match((error as Error).message, message); + return true; + }); + } +}); + +test('filesystem loader rejects symlinks and nonregular configuration paths', async () => { + const symlinkedFile = sandbox.isolated('symlink-file'); + const filePath = join(symlinkedFile.cwd, '.coderuler', 'suppressions.yml'); + writeFile(join(symlinkedFile.cwd, 'outside.yml'), 'suppressions: {}\n'); + makeSymlink(filePath, join(symlinkedFile.cwd, 'outside.yml')); + await assert.rejects(createProject(symlinkedFile.cwd).readSuppressions(), error => + (error as Error).message.startsWith('.coderuler/suppressions.yml:')); + + const nonregularFile = sandbox.isolated('nonregular-file'); + makeDirectory(join(nonregularFile.cwd, '.coderuler', 'suppressions.yml')); + await assert.rejects(createProject(nonregularFile.cwd).readSuppressions(), error => + (error as Error).message.startsWith('.coderuler/suppressions.yml:')); + + const symlinkedDirectory = sandbox.isolated('symlink-directory'); + writeFile(join(symlinkedDirectory.cwd, 'outside', 'suppressions.yml'), 'suppressions: {}\n'); + makeSymlink(join(symlinkedDirectory.cwd, '.coderuler'), join(symlinkedDirectory.cwd, 'outside')); + await assert.rejects(createProject(symlinkedDirectory.cwd).readSuppressions(), error => + (error as Error).message.startsWith('.coderuler/suppressions.yml:')); +}); + +test('CLI applies line suppressions without hiding unrelated violations', async () => { + const target = sandbox.isolated('cli'); + writeFile(join(target.cwd, '.coderuler', 'no-console.rule.ts'), `import { Rule, violation } from '@alex-dbs/coderuler'; +export default class NoConsole extends Rule { + files = ['src/legacy.js']; + check = subject => violation('Legacy console use', [ + { filepath: subject.filepath, lines: { start: 1, end: 1 } }, + ]); +} +`); + writeFile(join(target.cwd, 'src', 'legacy.js'), "console.log('legacy');\n"); + + const initial = await runCli(['check', '--json'], target); + assert.equal(initial.status, 1, initial.stderr || initial.stdout); + assert.equal(JSON.parse(initial.stdout).reports.length, 1); + + const path = join(target.cwd, '.coderuler', 'suppressions.yml'); + writeFile(path, 'suppressions:\n no-console:\n - files: ["src/legacy.js"]\n lines: { start: 1, end: 1 }\n reason: Accepted legacy diagnostic\n'); + const suppressed = await runCli(['check', '--json'], target); + assert.equal(suppressed.status, 0, suppressed.stderr || suppressed.stdout); + assert.deepEqual(JSON.parse(suppressed.stdout).reports, []); + + const outputAll = await runCli(['check', '--json', '--output-all'], target); + assert.equal(outputAll.status, 0, outputAll.stderr || outputAll.stdout); + const allReports = JSON.parse(outputAll.stdout).reports; + const result = allReports[0].evaluations[0].result; + assert.equal(result.status, 'suppressed'); + assert.equal(result.message, 'Legacy console use'); + assert.equal(result.suppressedLocations[0].reason, 'Accepted legacy diagnostic'); + + const textOutput = await runCli(['check', '--output-all'], target); + assert.equal(textOutput.status, 0, textOutput.stderr || textOutput.stdout); + assert.ok(textOutput.stdout.includes('[suppressed] no-console'), textOutput.stdout); + assert.ok(textOutput.stdout.includes('Accepted legacy diagnostic'), textOutput.stdout); + + writeFile(path, 'suppressions:\n no-console:\n - files: ["src/legacy.js"]\n lines: { start: 2, end: 2 }\n reason: Wrong line\n'); + const uncovered = await runCli(['check', '--json'], target); + assert.equal(uncovered.status, 1, uncovered.stderr || uncovered.stdout); + + writeFile(join(target.cwd, '.coderuler', 'other-policy.rule.ts'), `import { Rule, violation } from '@alex-dbs/coderuler'; +export default class OtherPolicy extends Rule { + files = ['src/legacy.js']; + check = subject => violation('Other policy failure', [ + { filepath: subject.filepath, lines: { start: 1, end: 1 } }, + ]); +} +`); + writeFile(path, 'suppressions:\n no-console:\n - files: ["src/legacy.js"]\n lines: { start: 1, end: 1 }\n reason: Accepted legacy diagnostic\n'); + const unrelated = await runCli(['check', '--json'], target); + assert.equal(unrelated.status, 1, unrelated.stderr || unrelated.stdout); + assert.deepEqual(JSON.parse(unrelated.stdout).reports.map((report: { name: string }) => report.name), ['other-policy']); + + const mixedText = await runCli(['check', '--output-all'], target); + assert.equal(mixedText.status, 1, mixedText.stderr || mixedText.stdout); + assert.ok(mixedText.stdout.includes('[suppressed] no-console'), mixedText.stdout); + assert.ok(mixedText.stdout.includes('other-policy:'), mixedText.stdout); + assert.ok(mixedText.stdout.includes('Other policy failure'), mixedText.stdout); +}); + +test('file and line selectors match text files without format-specific parsing', () => { + const config = parseSuppressionConfig({ + suppressions: { policy: [{ files: ['src/**'], lines: { start: 1, end: 1 }, reason: 'Accepted text' }] }, + }); + for (const filepath of ['src/legacy.js', 'src/Legacy.cs', 'src/data.json', 'src/api.proto', 'src/order.avsc']) { + const subject = scope('$', filepath, { start: 1, end: 2 }); + const result = violation([{ filepath, lines: { start: 1, end: 1 } }]); + assert.equal(applySuppressions(config, 'policy', subject, result).status, 'suppressed', filepath); + } +}); + +test('empty scope locations still require full evaluated-scope coverage', () => { + const subject = scope('named-scope', 'src/legacy.js', { start: 1, end: 3 }); + const result = violation([]); + const unrelated = parseSuppressionConfig({ + suppressions: { policy: [{ files: ['src/other.js'], reason: 'Not this file' }] }, + }); + assert.equal(applySuppressions(unrelated, 'policy', subject, result), result); + + const covered = parseSuppressionConfig({ + suppressions: { policy: [{ files: ['src/legacy.js'], reason: 'Whole scope accepted' }] }, + }); + const suppressed = applySuppressions(covered, 'policy', subject, result); + assert.equal(suppressed.status, 'suppressed'); + assert.deepEqual(suppressed.suppressedLocations, [{ + location: { filepath: 'src/legacy.js', scopeId: 'named-scope', lines: { start: 1, end: 3 } }, + reason: 'Whole scope accepted', + }]); +}); + +test('execution uses fresh project suppressions before events and report aggregation', async () => { + const { cwd } = sandbox.isolated('direct-engine'); + writeFile(join(cwd, 'src', 'legacy.js'), 'console.log(1);\n'); + const configPath = join(cwd, '.coderuler', 'suppressions.yml'); + writeFile(configPath, 'suppressions:\n custom-policy:\n - files: ["src/legacy.js"]\n scope: named\n reason: Accepted by engine\n'); + + const rule = new LocalRule(subject => { + if (subject.kind !== 'scope') throw new Error('expected a scope'); + return ruleViolation('Custom selector finding', [{ filepath: subject.filepath, scopeId: subject.id }]); + }); + rule.files = ['src/legacy.js']; + const customScope: ScopeSelector = () => [{ + id: 'named', + source: 'console.log(1);', + lines: { start: 1, end: 1 }, + }]; + let selectionCalls = 0; + rule.select = context => { + selectionCalls += 1; + return context.select({ files: ['src/legacy.js'], scope: customScope }); + }; + + const project = createProject(cwd); + const first = await runRule(project, 'custom-policy', rule); + assert.equal(first.report.status, 'pass'); + assert.equal(first.report.evaluations[0]?.result.status, 'suppressed'); + const resultEvent = first.events.find(event => event.type === 'result'); + assert.ok(resultEvent?.type === 'result'); + assert.equal(resultEvent.result.status, 'suppressed'); + + writeFile(configPath, 'suppressions:\n custom-policy:\n - files: ["src/legacy.js"]\n scope: other\n reason: Different selector\n'); + const second = await runRule(project, 'custom-policy', rule); + assert.equal(second.report.status, 'violation'); + assert.equal(second.report.evaluations[0]?.result.status, 'violation'); + assert.equal(selectionCalls, 2); +}); + +test('execution loads suppressions once even for no rules and before any rule hooks', async () => { + const { cwd } = sandbox.isolated('empty-engine'); + const base = createProject(cwd); + const readSuppressions = base.readSuppressions.bind(base); + let loads = 0; + const project: Project = { + ...base, + readSuppressions: async () => { + loads += 1; + return readSuppressions(); + }, + }; + const events: RunEvent[] = []; + assert.deepEqual(await executeRules([], runContext(project, events)), []); + assert.equal(loads, 1); + + writeFile(join(cwd, '.coderuler', 'suppressions.yml'), 'not-suppressions: true\n'); + const failingProject = createProject(cwd); + const rule = new LocalRule(() => ruleViolation('Must not run')); + let hookCalls = 0; + rule.when = () => { + hookCalls += 1; + return true; + }; + const failureEvents: RunEvent[] = []; + await assert.rejects(executeRules([{ + name: 'policy', + ruleFile: '.coderuler/policy.rule.ts', + rule: rule as Rule, + }], runContext(failingProject, failureEvents)), /\.coderuler\/suppressions\.yml/); + assert.equal(hookCalls, 0); + assert.equal(failureEvents.some(event => event.type === 'rule'), false); + assert.equal(failureEvents.find(event => event.type === 'error')?.stage, 'suppression'); +}); + +test('suppression cannot hide a throwing check or a rule-authored suppressed status', async () => { + const { cwd } = sandbox.isolated('invalid-check'); + writeFile(join(cwd, 'src', 'legacy.js'), 'legacy\n'); + writeFile(join(cwd, '.coderuler', 'suppressions.yml'), 'suppressions:\n policy:\n - files: ["src/legacy.js"]\n reason: Accepted existing issue\n'); + const project = createProject(cwd); + + const thrownEvents: RunEvent[] = []; + const throwing = new LocalRule(() => { + throw new Error('check exploded'); + }); + throwing.files = ['src/legacy.js']; + await assert.rejects(executeRules([{ + name: 'policy', + ruleFile: '.coderuler/policy.rule.ts', + rule: throwing as Rule, + }], runContext(project, thrownEvents)), /check exploded/); + assert.equal(thrownEvents.find(event => event.type === 'error')?.stage, 'check'); + + const invalidEvents: RunEvent[] = []; + const authored = new LocalRule(() => ({ + status: 'suppressed', + suppressedLocations: [{ location: { filepath: 'src/legacy.js', lines: { start: 1, end: 1 } }, reason: 'Not trusted' }], + } as unknown as CheckResult)); + authored.files = ['src/legacy.js']; + await assert.rejects(executeRules([{ + name: 'policy', + ruleFile: '.coderuler/policy.rule.ts', + rule: authored as Rule, + }], runContext(project, invalidEvents)), /status must be "pass" or "violation"/); + assert.equal(invalidEvents.find(event => event.type === 'error')?.stage, 'check'); +}); + +test('tracked AI usage survives full suppression', async () => { + const { cwd } = sandbox.isolated('ai-usage'); + writeFile(join(cwd, 'src', 'legacy.js'), 'legacy\n'); + writeFile(join(cwd, '.coderuler', 'suppressions.yml'), 'suppressions:\n ai-policy:\n - files: ["src/legacy.js"]\n reason: Accepted AI finding\n'); + let backendCalls = 0; + const backend: DecisionBackend = { + async decide(): Promise { + backendCalls += 1; + return { + probability: 0.2, + model: 'test-model', + usage: { inputTokens: 10, outputTokens: 2 }, + }; + }, + }; + const runtime: DecisionRuntime = { + backend, + threshold: 0.5, + pricing: { inputCostPerMillion: 1, outputCostPerMillion: 2 }, + }; + const rule = new LocalRule(ai({ rule: 'Legacy code must be clean', violationMessage: 'AI finding' })); + rule.files = ['src/legacy.js']; + + const { report, events } = await runRule(createProject(cwd), 'ai-policy', rule, runtime); + const result = report.evaluations[0]?.result; + assert.equal(result?.status, 'suppressed'); + assert.deepEqual(result?.usage, { + calls: 1, + unreportedCalls: 0, + inputTokens: 10, + outputTokens: 2, + inputCost: 10 / 1_000_000, + outputCost: 4 / 1_000_000, + }); + assert.equal(events.find(event => event.type === 'result')?.type, 'result'); + assert.equal(backendCalls, 1); +}); + +test('Avro scope IDs remain tied to field names after earlier fields are inserted', async () => { + const { cwd } = sandbox.isolated('avro-scope'); + const sourcePath = join(cwd, 'schemas', 'order.avsc'); + const configPath = join(cwd, '.coderuler', 'suppressions.yml'); + writeFile(sourcePath, JSON.stringify({ + type: 'record', + name: 'Order', + fields: [{ name: 'legacy_id', type: 'string' }], + })); + writeFile(configPath, 'suppressions:\n field-naming:\n - files: ["schemas/order.avsc"]\n scope: legacy_id\n reason: Published field name\n'); + + const rule = new LocalRule(subject => { + if (subject.kind !== 'scope') throw new Error('expected a field scope'); + return ruleViolation('Field naming violation', [{ filepath: subject.filepath, scopeId: subject.id }]); + }); + rule.files = ['schemas/order.avsc']; + rule.scope = scopes.json({ path: '$.fields[*]', key: '$.name' }) as ScopeSelector; + + const first = await runRule(createProject(cwd), 'field-naming', rule); + assert.equal(first.report.evaluations[0]?.subject.kind, 'scope'); + assert.equal(first.report.evaluations[0]?.result.status, 'suppressed'); + + writeFile(sourcePath, JSON.stringify({ + type: 'record', + name: 'Order', + fields: [ + { name: 'created_at', type: 'string' }, + { name: 'legacy_id', type: 'string' }, + ], + })); + const afterInsert = await runRule(createProject(cwd), 'field-naming', rule); + const legacy = afterInsert.report.evaluations.find(({ subject }) => + subject.kind === 'scope' && subject.id === 'legacy_id'); + const inserted = afterInsert.report.evaluations.find(({ subject }) => + subject.kind === 'scope' && subject.id === 'created_at'); + assert.equal(legacy?.result.status, 'suppressed'); + assert.equal(inserted?.result.status, 'violation'); +}); + +test('Protobuf scope suppression uses qualified declaration IDs', async () => { + const { cwd } = sandbox.isolated('protobuf-scope'); + writeFile(join(cwd, 'schemas', 'order.proto'), 'syntax = "proto3";\npackage acme;\nmessage Order {\n string legacy_id = 1;\n}\n'); + writeFile(join(cwd, '.coderuler', 'suppressions.yml'), 'suppressions:\n field-naming:\n - files: ["schemas/order.proto"]\n scope: acme.Order.legacy_id\n reason: Published field name\n'); + + const rule = new LocalRule(subject => { + if (subject.kind !== 'scope') throw new Error('expected a field scope'); + return ruleViolation('Field naming violation', [{ filepath: subject.filepath, scopeId: subject.id }]); + }); + rule.files = ['schemas/order.proto']; + rule.scope = scopes.protobuf({ path: '$.fields[*]' }) as ScopeSelector; + const { report } = await runRule(createProject(cwd), 'field-naming', rule); + assert.equal(report.evaluations[0]?.subject.kind, 'scope'); + assert.equal(report.evaluations[0]?.subject.id, 'acme.Order.legacy_id'); + assert.equal(report.evaluations[0]?.result.status, 'suppressed'); +}); + +test('home rules use the execution root and inspect folder suppressions', async () => { + const target = sandbox.isolated('execution-root'); + const ruleSource = `import { Rule, violation } from '@alex-dbs/coderuler'; +export default class NoConsole extends Rule { + files = ['src/legacy.js']; + check = subject => violation('Legacy console use', [ + { filepath: subject.filepath, lines: { start: 1, end: 1 } }, + ]); +} +`; + writeFile(join(target.home, '.coderuler', 'no-console.rule.ts'), ruleSource); + writeFile(join(target.cwd, 'src', 'legacy.js'), 'console.log(1);\n'); + writeFile(join(target.cwd, '.coderuler', 'suppressions.yml'), 'suppressions:\n no-console:\n - files: ["src/legacy.js"]\n lines: { start: 1, end: 1 }\n reason: Execution project exception\n'); + + const check = await runCli(['check', '--json', '--output-all'], target); + assert.equal(check.status, 0, check.stderr || check.stdout); + const checkResult = JSON.parse(check.stdout).reports[0].evaluations[0].result; + assert.equal(checkResult.status, 'suppressed'); + assert.equal(checkResult.suppressedLocations[0].reason, 'Execution project exception'); + + const directRule = new LocalRule(subject => { + if (subject.kind !== 'scope') throw new Error('expected a scope'); + return ruleViolation('Legacy console use', [ + { filepath: subject.filepath, lines: { start: 1, end: 1 } }, + ]); + }); + directRule.files = ['src/legacy.js']; + const direct = await runRule(createProject(target.cwd), 'no-console', directRule); + assert.equal(direct.report.evaluations[0]?.result.status, 'suppressed'); + assert.equal(direct.report.evaluations[0]?.result.suppressedLocations?.[0]?.reason, 'Execution project exception'); + + const inspected = join(target.cwd, 'inspection'); + writeFile(join(inspected, 'src', 'legacy.js'), 'console.log(1);\n'); + writeFile(join(inspected, '.coderuler', 'suppressions.yml'), 'suppressions:\n no-console:\n - files: ["src/legacy.js"]\n lines: { start: 1, end: 1 }\n reason: Inspection folder exception\n'); + const inspect = await runCli(['inspect', 'no-console', 'inspection', '--json'], target); + assert.equal(inspect.status, 0, inspect.stderr || inspect.stdout); + const inspection = JSON.parse(inspect.stdout); + const inspectedResult = inspection.reports[0].evaluations[0].result; + assert.equal(inspectedResult.status, 'suppressed'); + assert.equal(inspectedResult.suppressedLocations[0].reason, 'Inspection folder exception'); +}); + +test('rule test cases load only suppression YAML from their fixture project', async () => { + const target = sandbox.isolated('test-fixture-root'); + writeFile(join(target.cwd, '.coderuler', 'suppressions.yml'), 'suppressions: { no-console: [{ files: ["src/legacy.js"], reason: "Invocation exception" }] }\n'); + writeFile(join(target.cwd, '.coderuler', 'no-console.rule.ts'), `import { Rule, test, violation } from '@alex-dbs/coderuler'; +export default class NoConsole extends Rule { + files = ['src/legacy.js']; + check = subject => violation('Legacy console use', [ + { filepath: subject.filepath, lines: { start: 1, end: 1 } }, + ]); +} +export const tests = [ + test('fixture suppression applies').withFiles({ + 'src/legacy.js': 'console.log(1)', + '.coderuler/suppressions.yml': 'suppressions: { no-console: [{ files: ["src/legacy.js"], lines: { start: 1, end: 1 }, reason: "Fixture exception" }] }', + }).passes(), + test('invocation suppression does not leak').withFiles({ + 'src/legacy.js': 'console.log(1)', + }).violates(), +]; +`); + const result = await runCli(['test', 'no-console'], target); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(result.stdout.includes('2 passed'), result.stdout); +}); diff --git a/tests/violation-locations.test.ts b/tests/violation-locations.test.ts index 3b07004..30d9cae 100644 --- a/tests/violation-locations.test.ts +++ b/tests/violation-locations.test.ts @@ -785,3 +785,49 @@ test('AI scalar results use only scope fallback and never invent grouped attribu ] : [[]]); } }); + +test('suppressed group locations do not reach comments for active violations', async () => { + const project = fixture(); + const configPath = join(project.root, '.coderuler', 'suppressions.yml'); + writeFile(configPath, 'suppressions:\n location-policy:\n - files: ["pkg/a.txt"]\n scope: head\n reason: Accepted head scope\n'); + const rule = scopedRule(subject => { + if (subject.kind !== 'group') throw new Error('Expected a project group'); + return violation('Group rejection', [ + { filepath: 'pkg/a.txt', scopeId: 'head' }, + { filepath: 'pkg/a.txt', scopeId: 'tail' }, + ]); + }, 'project'); + let callbackCalls = 0; + rule.comment = function (subject, result) { + callbackCalls += 1; + assert.equal(subject.kind, 'group'); + assert.deepEqual(result.locations, [{ + filepath: 'pkg/a.txt', scopeId: 'tail', lines: { start: 7, end: 9 }, + }]); + return 'Active finding only'; + }; + const selectedRule = { + name: 'location-policy', + ruleFile: '.coderuler/location-policy.rule.ts', + rule: rule as Rule, + }; + + const partial = await run(rule, project); + assert.equal(partial.report.status, 'violation'); + assert.equal(partial.report.evaluations[0]!.result.status, 'violation'); + assert.deepEqual((await renderViolationComments(partial.reports, redact, [selectedRule])).map(comment => ({ + body: comment.body, + locations: comment.locations, + })), [{ + body: 'Active finding only', + locations: [{ filepath: 'pkg/a.txt', lines: { start: 7, end: 9 } }], + }]); + assert.equal(callbackCalls, 1); + + writeFile(configPath, 'suppressions:\n location-policy:\n - files: ["pkg/a.txt"]\n scope: head\n reason: Accepted head scope\n - files: ["pkg/a.txt"]\n scope: tail\n reason: Accepted tail scope\n'); + const complete = await run(rule, project); + assert.equal(complete.report.status, 'pass'); + assert.equal(complete.report.evaluations[0]!.result.status, 'suppressed'); + assert.deepEqual(await renderViolationComments(complete.reports, redact, [selectedRule]), []); + assert.equal(callbackCalls, 1); +}); diff --git a/website/src/content/docs/cli.md b/website/src/content/docs/cli.md index dc62348..4d06092 100644 --- a/website/src/content/docs/cli.md +++ b/website/src/content/docs/cli.md @@ -103,6 +103,8 @@ coderuler check src --tags go security --exclude-tags experimental Concurrency must be a positive safe integer. Threshold must be a decimal between `0` and `1`. AI checks execute live during `check`, without a separate opt-in flag. See [CI and GitHub](/docs/ci/) before using PR checks or comments. +Fully suppressed evaluations are hidden by default, like passes. Use `--output-all` to show their `[suppressed]` status, covered locations, and reasons. With `--output-all`, partial violations also show suppression details. See [Configuration](/docs/configuration/#violation-suppressions) to manage accepted violations. + ## Inspect ```sh diff --git a/website/src/content/docs/configuration.md b/website/src/content/docs/configuration.md index a714a51..0da596e 100644 --- a/website/src/content/docs/configuration.md +++ b/website/src/content/docs/configuration.md @@ -55,6 +55,30 @@ An explicit target inside an excluded directory fails. Custom selectors and proj For `check` and `validate`, run the command from the intended project root. A `check` target narrows files without redefining that root. For `inspect`, the resolved folder argument becomes the inspected root. +## Violation suppressions + +Add accepted exceptions to `.coderuler/suppressions.yml` in the execution project's root: + +```yaml +suppressions: + field-naming: + - files: ["api/orders.proto"] + scope: "acme.Order.legacy_id" + reason: "Existing public contract" + no-console: + - files: ["src/legacy.js"] + lines: { start: 42, end: 45 } + reason: "Required diagnostic output" + - files: ["generated/**"] + reason: "Generated source" +``` + +Rule keys are exact filename stems. Each entry needs nonblank file globs and a reason. It can cover a whole matching file, one exact scope ID, or a line range. A line range must inclusively contain the full finding range. The first matching entry supplies the reason. + +Scope IDs stay stable when unrelated lines or array items move. Prefer a named ID over line numbers or array indexes. For Avro fields, use a JSON selector such as `scopes.json({ path: '$.fields[*]', key: '$.name' })` so each field ID comes from its name. + +The globs use the same project-relative `path.matchesGlob` rules as file selection. Suppressions apply after evaluation, so they do not avoid AI calls. + ## Grouping ```ts