# Capability Spec Delta: linter **Package**: `@suaveplan/linter` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: LINTER_CODES registry MUST enumerate every typed failure path The package MUST export `LINTER_CODES = defineCodes("LINTER", [...])` exposing exactly the following codes: `INVALID_OPTIONS`, `FILE_NOT_FOUND`, `NO_TARGETS_FOUND`, `READ_FAILED`, `WRITE_FAILED`, `MARKDOWN_PARSE_FAILED`, `BIOME_UNAVAILABLE`, `BIOME_FAILED`, `RUNNER_FAILED`, `UNKNOWN_FLAG`. Every code MUST be reachable from a call site exercised by a test in `src/`. The exported `LinterError` MUST extend `SuaveplanError` and carry a `details: Record` field for structured diagnostics. #### Scenario: registry exposes the full code set - **WHEN** the consumer imports `LINTER_CODES` and reads `Object.values(LINTER_CODES)` - **THEN** the result contains exactly the ten declared codes (no more, no less) #### Scenario: LinterError carries code and details - **WHEN** `new LinterError(LINTER_CODES.INVALID_OPTIONS, "bad", { details: { field: "x" } })` - **THEN** the instance has `code === "LINTER.INVALID_OPTIONS"`, `details.field === "x"`, and `instanceof SuaveplanError === true` **Verified by:** `src/error-codes/error-codes.test.ts::"registry exposes the full code set"` AND `src/error-codes/error-codes.test.ts::"LinterError carries code and details payload"` --- ### Requirement: fixMarkdown applies the full remark rule set and returns a MarkdownFixReport `fixMarkdown(filePath: string, opts?: { fs?: FsAdapter }): Promise` MUST read the file via the injectable `FsAdapter` (defaulting to `nodeFsAdapter`), run the remark pipeline (MD025, MD036 + enhanced patterns, MD029, MD035, MD040, MD032 / MD022 / MD031, MD044, MD012, trailing-newline normalisation, bold-without-backticks post-process), write the result back only when content changed, and return `{ file, changed, rulesApplied }` where `rulesApplied` lists the rule IDs that altered the AST. A missing file MUST raise `LinterError(LINTER.FILE_NOT_FOUND)`. #### Scenario: collapses multiple H1s into H2s after the first - **WHEN** `fixMarkdown('test.md')` runs on content `# A\n\n# B\n\n# C` - **THEN** the written content contains `# A`, `## B`, `## C` AND the returned report has `changed === true` AND `"MD025"` ∈ `rulesApplied` #### Scenario: rewrites step-style strong paragraph to H3 - **WHEN** `fixMarkdown` runs on `**Step 1: PLAN**\n\nbody` - **THEN** the written content contains `### Step 1: PLAN` AND `"MD036"` ∈ `rulesApplied` #### Scenario: infers bash language for code blocks - **WHEN** `fixMarkdown` runs on a fenced block with no language whose body contains `npm install` - **THEN** the written content contains ` ```bash ` AND `"MD040"` ∈ `rulesApplied` #### Scenario: infers typescript language for code blocks - **WHEN** `fixMarkdown` runs on a fenced block containing `interface User { name: string }` - **THEN** the written content contains ` ```typescript ` #### Scenario: infers python language for code blocks - **WHEN** `fixMarkdown` runs on a fenced block containing `def main():\n pass` - **THEN** the written content contains ` ```python ` #### Scenario: infers markdown language for code blocks - **WHEN** `fixMarkdown` runs on a fenced block whose body is `# Title\n## Sub` - **THEN** the written content contains ` ```markdown ` #### Scenario: infers html language for code blocks - **WHEN** `fixMarkdown` runs on a fenced block whose body is `
x
` - **THEN** the written content contains ` ```html ` #### Scenario: infers json language for code blocks - **WHEN** `fixMarkdown` runs on a fenced block whose body is `{ "a": 1 }` - **THEN** the written content contains ` ```json ` #### Scenario: defaults to text language for unrecognised content - **WHEN** `fixMarkdown` runs on a fenced block whose body is opaque (`zzz qqq`) - **THEN** the written content contains ` ```text ` #### Scenario: collapses multiple blank lines - **WHEN** `fixMarkdown` runs on content with `\n\n\n\n` between paragraphs - **THEN** the written content contains no `\n\n\n` AND `"MD012"` ∈ `rulesApplied` #### Scenario: capitalises proper names - **WHEN** `fixMarkdown` runs on content `This is a json api over http.` - **THEN** the written content contains `JSON`, `API`, `HTTP` AND `"MD044"` ∈ `rulesApplied` #### Scenario: ensures trailing single newline - **WHEN** `fixMarkdown` runs on content with three trailing newlines - **THEN** the written content ends with exactly one `\n` #### Scenario: rewrites bold heading-style line with backticks - **WHEN** `fixMarkdown` runs on a paragraph line `**API**` - **THEN** the written content contains ` **`API`** ` #### Scenario: returns changed=false when content is already canonical - **WHEN** `fixMarkdown` runs on already-canonical content - **THEN** the report has `changed === false` AND no write occurs (verified via the injectable fs) #### Scenario: raises FILE_NOT_FOUND when the path does not exist - **WHEN** `fixMarkdown('missing.md')` is called with an in-memory `FsAdapter` whose `exists` returns `false` - **THEN** a `LinterError` is thrown with `code === "LINTER.FILE_NOT_FOUND"` #### Scenario: raises READ_FAILED when the FsAdapter throws on read - **WHEN** `fixMarkdown` is called with a `FsAdapter.readFile` that rejects - **THEN** a `LinterError` is thrown with `code === "LINTER.READ_FAILED"` AND the original error as `cause` #### Scenario: raises WRITE_FAILED when the FsAdapter throws on write - **WHEN** `fixMarkdown` is called with a `FsAdapter.writeFile` that rejects AND content changed - **THEN** a `LinterError` is thrown with `code === "LINTER.WRITE_FAILED"` #### Scenario: raises MARKDOWN_PARSE_FAILED when remark rejects the input - **WHEN** `fixMarkdown` is called with a `FsAdapter` whose `readFile` returns input that causes `unified().process` to throw (forced via a poisoned processor seam) - **THEN** a `LinterError` is thrown with `code === "LINTER.MARKDOWN_PARSE_FAILED"` **Verified by:** `src/markdown-fixer/markdown-fixer.test.ts::"collapses multiple H1s into H2s after the first"` AND `src/markdown-fixer/markdown-fixer.test.ts::"rewrites step-style strong paragraph to H3"` AND `src/markdown-fixer/markdown-fixer.test.ts::"infers bash language for code blocks"` AND `src/markdown-fixer/markdown-fixer.test.ts::"infers typescript language for code blocks"` AND `src/markdown-fixer/markdown-fixer.test.ts::"infers python language for code blocks"` AND `src/markdown-fixer/markdown-fixer.test.ts::"infers markdown language for code blocks"` AND `src/markdown-fixer/markdown-fixer.test.ts::"infers html language for code blocks"` AND `src/markdown-fixer/markdown-fixer.test.ts::"infers json language for code blocks"` AND `src/markdown-fixer/markdown-fixer.test.ts::"defaults to text language for unrecognised content"` AND `src/markdown-fixer/markdown-fixer.test.ts::"collapses multiple blank lines"` AND `src/markdown-fixer/markdown-fixer.test.ts::"capitalises proper names"` AND `src/markdown-fixer/markdown-fixer.test.ts::"ensures trailing single newline"` AND `src/markdown-fixer/markdown-fixer.test.ts::"rewrites bold heading-style line with backticks"` AND `src/markdown-fixer/markdown-fixer.test.ts::"returns changed=false when content is already canonical"` AND `src/markdown-fixer/markdown-fixer.test.ts::"raises FILE_NOT_FOUND when the path does not exist"` AND `src/markdown-fixer/markdown-fixer.test.ts::"raises READ_FAILED when the FsAdapter throws on read"` AND `src/markdown-fixer/markdown-fixer.test.ts::"raises WRITE_FAILED when the FsAdapter throws on write"` AND `src/markdown-fixer/markdown-fixer.test.ts::"raises MARKDOWN_PARSE_FAILED when remark rejects the input"` --- ### Requirement: processMarkdownFiles walks a pattern and applies fixMarkdown to each match `processMarkdownFiles({ patterns, fs?, glob? }): Promise` MUST resolve the patterns via `resolveMarkdownTargets`, apply `fixMarkdown` to each match in deterministic sorted order, and return one report per file. Empty pattern lists default to `["**/*.md"]`. An empty result set MUST raise `LinterError(LINTER.NO_TARGETS_FOUND)`. #### Scenario: walks a glob and returns per-file reports - **WHEN** `processMarkdownFiles({ patterns: ["**/*.md"], fs, glob })` runs over an in-memory tree with `a.md` (changed) and `b.md` (unchanged) - **THEN** the result has length 2 AND `result[0].file === 'a.md'` AND `result[1].file === 'b.md'` AND `result[0].changed === true` AND `result[1].changed === false` #### Scenario: defaults to **/*.md when patterns is empty - **WHEN** `processMarkdownFiles({ patterns: [], fs, glob })` is called - **THEN** the glob adapter is invoked with pattern `"**/*.md"` #### Scenario: raises NO_TARGETS_FOUND when nothing matches - **WHEN** `processMarkdownFiles({ patterns: ["nope/*.md"], fs, glob })` returns an empty list - **THEN** a `LinterError` is thrown with `code === "LINTER.NO_TARGETS_FOUND"` **Verified by:** `src/markdown-fixer/markdown-fixer.test.ts::"walks a glob and returns per-file reports"` AND `src/markdown-fixer/markdown-fixer.test.ts::"defaults to **/*.md when patterns is empty"` AND `src/markdown-fixer/markdown-fixer.test.ts::"raises NO_TARGETS_FOUND when nothing matches"` --- ### Requirement: runBiome invokes Biome via an injectable CommandRunner and returns a BiomeReport `runBiome({ cwd, mode, runner })` MUST shell to `bunx biome` with arguments derived from `mode` (`"check"` → `["biome", "check", "."]`, `"check-write"` → `["biome", "check", "--write", "--unsafe", "--max-diagnostics", "500", "."]`, `"format-write"` → `["biome", "format", "--write", "--max-diagnostics", "500", "."]`) via the injectable `CommandRunner` (default = `nodeCommandRunner`). It MUST measure duration via the injectable `Clock` (default: `Date.now`), populate `BiomeReport.success` as `exitCode === 0`, and return the full report. In `"check"` mode, an exit code other than 0 raises `LinterError(LINTER.BIOME_FAILED)` carrying the report under `details`. Invalid `mode` values raise `LinterError(LINTER.INVALID_OPTIONS)`. #### Scenario: passes the correct args for each mode - **WHEN** `runBiome({ cwd: '.', mode: 'check', runner })` is called with a recording fake runner - **THEN** the runner recorded `command === 'bunx'` AND `args` starts with `['biome', 'check', '.']` #### Scenario: check-write mode forwards --write and --unsafe - **WHEN** `runBiome({ cwd: '.', mode: 'check-write', runner })` is called - **THEN** the recorded args include `'--write'` AND `'--unsafe'` #### Scenario: format-write mode runs biome format - **WHEN** `runBiome({ cwd: '.', mode: 'format-write', runner })` is called - **THEN** the recorded args[1] equals `'format'` AND include `'--write'` #### Scenario: report carries exit code and stdout - **WHEN** the fake runner returns `{ exitCode: 0, stdout: 'ok', stderr: '' }` - **THEN** the report has `success === true`, `exitCode === 0`, `stdout === 'ok'`, AND `durationMs >= 0` #### Scenario: raises BIOME_FAILED in check mode on non-zero exit - **WHEN** `runBiome({ cwd: '.', mode: 'check', runner })` is called with a runner that returns `exitCode: 1` - **THEN** a `LinterError` is thrown with `code === "LINTER.BIOME_FAILED"` AND `error.details.report.exitCode === 1` #### Scenario: check-write mode returns non-zero report without throwing - **WHEN** `runBiome({ cwd: '.', mode: 'check-write', runner })` is called with `exitCode: 1` - **THEN** the call resolves to a report with `success === false` #### Scenario: raises RUNNER_FAILED when the runner rejects - **WHEN** the runner's `run` rejects with an error - **THEN** a `LinterError` is thrown with `code === "LINTER.RUNNER_FAILED"` AND the underlying error as `cause` #### Scenario: raises INVALID_OPTIONS on a bad mode - **WHEN** `runBiome({ cwd: '.', mode: 'nope' as 'check', runner })` is called - **THEN** a `LinterError` is thrown with `code === "LINTER.INVALID_OPTIONS"` **Verified by:** `src/biome-runner/biome-runner.test.ts::"passes the correct args for check mode"` AND `src/biome-runner/biome-runner.test.ts::"check-write mode forwards --write and --unsafe"` AND `src/biome-runner/biome-runner.test.ts::"format-write mode runs biome format"` AND `src/biome-runner/biome-runner.test.ts::"report carries exit code and stdout"` AND `src/biome-runner/biome-runner.test.ts::"raises BIOME_FAILED in check mode on non-zero exit"` AND `src/biome-runner/biome-runner.test.ts::"check-write mode returns non-zero report without throwing"` AND `src/biome-runner/biome-runner.test.ts::"raises RUNNER_FAILED when the runner rejects"` AND `src/biome-runner/biome-runner.test.ts::"raises INVALID_OPTIONS on a bad mode"` --- ### Requirement: probeBiomeAvailable verifies biome is installed via an injectable runner `probeBiomeAvailable({ runner }): Promise` MUST invoke `bunx biome --version` via the runner and return `true` iff `exitCode === 0`. A runner rejection MUST resolve to `false` (probe is non-fatal). The unified-lint orchestrator MUST use this probe and skip the Biome lane when it returns `false` (raising `LinterError(LINTER.BIOME_UNAVAILABLE)` is reserved for callers who opt into strict mode). #### Scenario: returns true when biome --version succeeds - **WHEN** `probeBiomeAvailable({ runner })` is called with a runner returning `exitCode: 0` - **THEN** the result is `true` #### Scenario: returns false on non-zero exit - **WHEN** the runner returns `exitCode: 127` - **THEN** the result is `false` #### Scenario: returns false when the runner rejects - **WHEN** the runner's `run` rejects - **THEN** the result is `false` **Verified by:** `src/biome-runner/biome-runner.test.ts::"probe returns true when biome --version succeeds"` AND `src/biome-runner/biome-runner.test.ts::"probe returns false on non-zero exit"` AND `src/biome-runner/biome-runner.test.ts::"probe returns false when the runner rejects"` --- ### Requirement: resolveMarkdownTargets expands patterns deterministically with ignore defaults `resolveMarkdownTargets({ patterns, fs, glob, cwd? }): Promise` MUST treat a pattern equal to an existing `.md` file as a single-file pass-through, MUST apply the default ignore list `["node_modules/**", "**/node_modules/**", "dist/**", "**/dist/**"]` on every glob invocation, MUST return the unique sorted list of matches across all patterns, and MUST raise `LinterError(LINTER.FILE_NOT_FOUND)` when a pattern looks like a file path (ends in `.md` AND has no glob magic chars `* ? [ {`) but does not exist. #### Scenario: single-file pattern bypasses the glob - **WHEN** `resolveMarkdownTargets({ patterns: ['README.md'], fs, glob })` is called and `fs.exists('README.md')` resolves true - **THEN** the result is `['README.md']` AND the glob adapter was NOT called #### Scenario: applies the default ignore list - **WHEN** `resolveMarkdownTargets({ patterns: ['**/*.md'], fs, glob })` is called with a recording glob - **THEN** the recorded `opts.ignore` contains `'node_modules/**'` AND `'dist/**'` #### Scenario: deduplicates across patterns - **WHEN** two patterns both match the same file - **THEN** the file appears exactly once in the sorted result #### Scenario: raises FILE_NOT_FOUND for a missing file path - **WHEN** `resolveMarkdownTargets({ patterns: ['missing.md'], fs, glob })` is called and `fs.exists('missing.md')` resolves false - **THEN** a `LinterError` is thrown with `code === "LINTER.FILE_NOT_FOUND"` **Verified by:** `src/glob-resolver/glob-resolver.test.ts::"single-file pattern bypasses the glob"` AND `src/glob-resolver/glob-resolver.test.ts::"applies the default ignore list"` AND `src/glob-resolver/glob-resolver.test.ts::"deduplicates across patterns"` AND `src/glob-resolver/glob-resolver.test.ts::"raises FILE_NOT_FOUND for a missing file path"` --- ### Requirement: runUnifiedLint orchestrates markdown + biome and returns a LintReport `runUnifiedLint(opts): Promise` MUST: (1) probe biome availability via the injectable runner; (2) when available, run `runBiome` in the mode derived from `opts.fix` (`"check-write"` when `fix === true`, otherwise `"check"`); (3) when `opts.markdownOnly !== true`, run `processMarkdownFiles`; (4) aggregate timings via the injectable `Clock`; (5) return `{ biome, markdown, success, durationMs }` where `success === (biome?.success ?? true)` AND every markdown report had a successful read/write. The function MUST NOT call `process.exit`. When biome is unavailable, the report has `biome === undefined`. #### Scenario: in fix mode runs biome check-write and markdown processor - **WHEN** `runUnifiedLint({ fix: true, cwd: '.', runner, fs, glob })` is called with a runner where the probe + check-write both succeed - **THEN** the result has `biome?.mode === 'check-write'` AND `markdown.length > 0` AND `success === true` #### Scenario: in check mode runs biome check and processes no writes - **WHEN** `runUnifiedLint({ fix: false, cwd: '.', runner, fs, glob })` is called - **THEN** `biome?.mode === 'check'` #### Scenario: markdownOnly skips biome entirely - **WHEN** `runUnifiedLint({ markdownOnly: true, runner, fs, glob })` is called - **THEN** the report has `biome === undefined` #### Scenario: biome unavailable yields biome:undefined and success based on markdown - **WHEN** the probe runner returns `exitCode: 127` - **THEN** the report has `biome === undefined` AND `success` reflects the markdown lane only #### Scenario: orchestrator never calls process.exit - **WHEN** any of the above scenarios run - **THEN** the function returns a resolved promise (the test asserts on the value, not on the process) **Verified by:** `src/unified-lint/unified-lint.test.ts::"in fix mode runs biome check-write and markdown processor"` AND `src/unified-lint/unified-lint.test.ts::"in check mode runs biome check and processes no writes"` AND `src/unified-lint/unified-lint.test.ts::"markdownOnly skips biome entirely"` AND `src/unified-lint/unified-lint.test.ts::"biome unavailable yields biome undefined and success based on markdown"` AND `src/unified-lint/unified-lint.test.ts::"orchestrator never calls process exit"` --- ### Requirement: runCli parses argv and resolves an exit code without exiting the process `runCli(argv: ReadonlyArray, opts?): Promise<{ code: number }>` MUST: (1) print help text and resolve `{ code: 0 }` for `--help` or `-h`; (2) treat any flag not in `--help`, `-h`, `--fix`, `--write`, `--unified`, `--markdown-only`, `--cwd` as an unknown flag and raise `LinterError(LINTER.UNKNOWN_FLAG)`; (3) translate `--fix` or `--write` to `fix: true`; (4) collect positional args as patterns; (5) invoke `runUnifiedLint` with the parsed options; (6) resolve `{ code: 0 }` on `report.success === true`, otherwise `{ code: 1 }`. The function MUST accept an injectable `out: (line: string) => void` for tests. #### Scenario: --help prints usage and resolves code 0 - **WHEN** `runCli(['--help'], { out, runner, fs, glob })` is called - **THEN** `out` was called with a string containing `"Usage:"` AND the result is `{ code: 0 }` #### Scenario: -h is an alias for --help - **WHEN** `runCli(['-h'], { out, runner, fs, glob })` is called - **THEN** the result is `{ code: 0 }` #### Scenario: --fix sets fix mode - **WHEN** `runCli(['--fix'], { out, runner, fs, glob })` is called with a runner whose probe + check-write succeed - **THEN** the recorded runner args include `'--write'` AND the result is `{ code: 0 }` #### Scenario: --write is an alias for --fix - **WHEN** `runCli(['--write'], { out, runner, fs, glob })` is called - **THEN** the recorded runner args include `'--write'` #### Scenario: --markdown-only skips biome - **WHEN** `runCli(['--markdown-only'], { out, runner, fs, glob })` is called - **THEN** the runner was NOT invoked #### Scenario: positional pattern is forwarded to the resolver - **WHEN** `runCli(['docs/*.md'], { out, runner, fs, glob })` is called with a glob adapter that captures `pattern` - **THEN** the glob adapter was invoked with pattern `'docs/*.md'` #### Scenario: unknown flag raises UNKNOWN_FLAG - **WHEN** `runCli(['--nope'], { out, runner, fs, glob })` is called - **THEN** a `LinterError` is thrown with `code === "LINTER.UNKNOWN_FLAG"` #### Scenario: failing report yields exit code 1 - **WHEN** `runCli([], { out, runner, fs, glob })` is called with a runner whose biome check returns `exitCode: 1` (i.e. `runBiome` throws BIOME_FAILED, the CLI catches it) - **THEN** the result is `{ code: 1 }` AND `out` was called with a string containing the failure summary **Verified by:** `src/cli/cli.test.ts::"--help prints usage and resolves code 0"` AND `src/cli/cli.test.ts::"-h is an alias for --help"` AND `src/cli/cli.test.ts::"--fix sets fix mode"` AND `src/cli/cli.test.ts::"--write is an alias for --fix"` AND `src/cli/cli.test.ts::"--markdown-only skips biome"` AND `src/cli/cli.test.ts::"positional pattern is forwarded to the resolver"` AND `src/cli/cli.test.ts::"unknown flag raises UNKNOWN_FLAG"` AND `src/cli/cli.test.ts::"failing report yields exit code 1"` --- ### Requirement: telemetry instruments use the @suaveplan/linter meter and record helpers The package MUST expose `filesFixedCounter`, `filesProcessedCounter`, `biomeRunsCounter`, and `runDurationHistogram` obtained via `metrics.getMeter("@suaveplan/linter")`, plus `recordFileFixed(rule)`, `recordFileProcessed(result)`, `recordBiomeRun(mode, outcome)`, and `recordRunDuration(phase, ms)` helpers. Calls to the helpers MUST be no-ops when no `MeterProvider` is registered. `fixMarkdown`, `runBiome`, and `runUnifiedLint` MUST emit the appropriate instruments during normal operation. #### Scenario: helpers do not throw with no provider registered - **WHEN** `recordFileFixed('MD025')`, `recordFileProcessed('changed')`, `recordBiomeRun('check', 'success')`, `recordRunDuration('total', 5)` are called - **THEN** no exception is thrown AND the calls return `undefined` #### Scenario: counter and histogram are wired and emit - **WHEN** an instrumented `MeterProvider` is registered AND `fixMarkdown` runs successfully on a changed file - **THEN** `linter_files_processed_total{result="changed"}` increments by 1 AND at least one `linter_files_fixed_total{rule=...}` series increments **Verified by:** `src/telemetry/telemetry.test.ts::"record helpers do not throw with no provider registered"` AND `src/telemetry/telemetry.test.ts::"counter and histogram are wired and emit through real MeterProvider"` --- ### Requirement: package surfaces subpath exports for every feature module `package.json` MUST declare subpath exports for each of `./markdown-fixer`, `./biome-runner`, `./glob-resolver`, `./unified-lint`, `./cli`, `./error-codes`, `./telemetry`, in addition to the root `"."` entry. Each subpath MUST point to a matching `dist//index.{js,d.ts}` bundle so consumers tree-shake at the feature boundary. The package MUST set `"sideEffects": false` and MUST register a `suaveplan-lint` bin pointing at `dist/bin.js`. #### Scenario: every feature folder has a matching subpath export - **WHEN** the test enumerates `Object.keys(packageJson.exports)` and the `src//` folders - **THEN** every feature folder name appears as a `./` key in `exports` #### Scenario: sideEffects is false - **WHEN** the test reads `packageJson.sideEffects` - **THEN** the value is `false` #### Scenario: bin entry is registered - **WHEN** the test reads `packageJson.bin['suaveplan-lint']` - **THEN** the value points at `./dist/bin.js` **Verified by:** `tests/package-shape.test.ts::"every feature folder has a matching subpath export"` AND `tests/package-shape.test.ts::"sideEffects is false"` AND `tests/package-shape.test.ts::"bin entry is registered"`