23 KiB
linter — Capability Spec
Package: @suaveplan/linter
Status: SHIPPED (2026-05-25)
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
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<LinterCode>
and carry a details: Record<string, unknown> field for structured diagnostics.
Scenario: registry exposes the full code set
- WHEN the consumer imports
LINTER_CODESand readsObject.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", andinstanceof 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<MarkdownFixReport>
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,## CAND the returned report haschanged === trueAND"MD025"∈rulesApplied
Scenario: rewrites step-style strong paragraph to H3
- WHEN
fixMarkdownruns on**Step 1: PLAN**\n\nbody - THEN the written content contains
### Step 1: PLANAND"MD036"∈rulesApplied
Scenario: infers bash language for code blocks
- WHEN
fixMarkdownruns on a fenced block with no language whose body containsnpm install - THEN the written content contains
```bashAND"MD040"∈rulesApplied
Scenario: infers typescript language for code blocks
- WHEN
fixMarkdownruns on a fenced block containinginterface User { name: string } - THEN the written content contains
```typescript
Scenario: infers python language for code blocks
- WHEN
fixMarkdownruns on a fenced block containingdef main():\n pass - THEN the written content contains
```python
Scenario: infers markdown language for code blocks
- WHEN
fixMarkdownruns on a fenced block whose body is# Title\n## Sub - THEN the written content contains
```markdown
Scenario: infers html language for code blocks
- WHEN
fixMarkdownruns on a fenced block whose body is<div>x</div> - THEN the written content contains
```html
Scenario: infers json language for code blocks
- WHEN
fixMarkdownruns on a fenced block whose body is{ "a": 1 } - THEN the written content contains
```json
Scenario: defaults to text language for unrecognised content
- WHEN
fixMarkdownruns on a fenced block whose body is opaque (zzz qqq) - THEN the written content contains
```text
Scenario: collapses multiple blank lines
- WHEN
fixMarkdownruns on content with\n\n\n\nbetween paragraphs - THEN the written content contains no
\n\n\nAND"MD012"∈rulesApplied
Scenario: capitalises proper names
- WHEN
fixMarkdownruns on contentThis is a json api over http. - THEN the written content contains
JSON,API,HTTPAND"MD044"∈rulesApplied
Scenario: ensures trailing single newline
- WHEN
fixMarkdownruns on content with three trailing newlines - THEN the written content ends with exactly one
\n
Scenario: rewrites bold heading-style line with backticks
- WHEN
fixMarkdownruns on a paragraph line**API** - THEN the written content contains
**API**
Scenario: returns changed=false when content is already canonical
- WHEN
fixMarkdownruns on already-canonical content - THEN the report has
changed === falseAND 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-memoryFsAdapterwhoseexistsreturnsfalse - THEN a
LinterErroris thrown withcode === "LINTER.FILE_NOT_FOUND"
Scenario: raises READ_FAILED when the FsAdapter throws on read
- WHEN
fixMarkdownis called with aFsAdapter.readFilethat rejects - THEN a
LinterErroris thrown withcode === "LINTER.READ_FAILED"AND the original error ascause
Scenario: raises WRITE_FAILED when the FsAdapter throws on write
- WHEN
fixMarkdownis called with aFsAdapter.writeFilethat rejects AND content changed - THEN a
LinterErroris thrown withcode === "LINTER.WRITE_FAILED"
Scenario: raises MARKDOWN_PARSE_FAILED when remark rejects the input
- WHEN
fixMarkdownis called with aFsAdapterwhosereadFilereturns input that causesunified().processto throw (forced via a poisoned processor seam) - THEN a
LinterErroris thrown withcode === "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<MarkdownFixReport[]> 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 witha.md(changed) andb.md(unchanged) - THEN the result has length 2 AND
result[0].file === 'a.md'ANDresult[1].file === 'b.md'ANDresult[0].changed === trueANDresult[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
LinterErroris thrown withcode === "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'ANDargsstarts 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', ANDdurationMs >= 0
Scenario: raises BIOME_FAILED in check mode on non-zero exit
- WHEN
runBiome({ cwd: '.', mode: 'check', runner })is called with a runner that returnsexitCode: 1 - THEN a
LinterErroris thrown withcode === "LINTER.BIOME_FAILED"ANDerror.details.report.exitCode === 1
Scenario: check-write mode returns non-zero report without throwing
- WHEN
runBiome({ cwd: '.', mode: 'check-write', runner })is called withexitCode: 1 - THEN the call resolves to a report with
success === false
Scenario: raises RUNNER_FAILED when the runner rejects
- WHEN the runner's
runrejects with an error - THEN a
LinterErroris thrown withcode === "LINTER.RUNNER_FAILED"AND the underlying error ascause
Scenario: raises INVALID_OPTIONS on a bad mode
- WHEN
runBiome({ cwd: '.', mode: 'nope' as 'check', runner })is called - THEN a
LinterErroris thrown withcode === "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<boolean> 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 returningexitCode: 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
runrejects - 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<string[]> 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 andfs.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.ignorecontains'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 andfs.exists('missing.md')resolves false - THEN a
LinterErroris thrown withcode === "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<LintReport> 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'ANDmarkdown.length > 0ANDsuccess === 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 === undefinedANDsuccessreflects 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<string>, 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
outwas 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 capturespattern - 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
LinterErroris thrown withcode === "LINTER.UNKNOWN_FLAG"
Scenario: failing report yields exit code 1
- WHEN
runCli([], { out, runner, fs, glob })is called with a runner whose biome check returnsexitCode: 1(i.e.runBiomethrows BIOME_FAILED, the CLI catches it) - THEN the result is
{ code: 1 }ANDoutwas 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
MeterProvideris registered ANDfixMarkdownruns successfully on a changed file - THEN
linter_files_processed_total{result="changed"}increments by 1 AND at least onelinter_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/<feature>/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 thesrc/<feature>/folders - THEN every feature folder name appears as a
./<feature>key inexports
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"