Files
2026-05-27 23:29:53 +01:00

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_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<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, ## 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 <div>x</div>
  • 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<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 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<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 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<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 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<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' 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<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 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/<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 the src/<feature>/ folders
  • THEN every feature folder name appears as a ./<feature> 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"