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

6.5 KiB

Proposal — Migrate @suaveplan/linter to genesis

  • Design: ./design.md
  • Spec delta: ./specs/linter/spec.md

Why

Upstream @suaveplan/linter (/mnt/s/web/suaveplan/packages/server/linter/) ships a thin markdown auto-fixer plus an execSync-based Biome / markdownlint orchestrator. The genesis enterprise/linter.md audit calls out three structural gaps: (a) no error-code registry — every failure path either console.errors or calls process.exit(1) — so consumers cannot discriminate, (b) no OpenTelemetry surface for what is the second-most invoked CI step in the monorepo (every package runs lint + format on every commit), and (c) the orchestrator is hard-wired to spawn subprocesses with no seam for tests or for programmatic embedding into pre-commit hooks. The fixed remark recipe is correct as far as it goes, but violations are written to disk silently with no machine-readable summary, so CI cannot gate on "files modified" vs "files clean".

Genesis cleans up the contract: every failure raises a typed LinterError with a stable LINTER.* code, every run emits structured LintReport results, the orchestrator and the shell-out path both take an injectable CommandRunner (defaults to node:child_process) so unit tests use a real fake runner and a single integration test exercises the real bunx biome --version probe, and every public surface is wrapped in a Zod schema.

What

Land @suaveplan/linter@0.1.0 at packages/devtools/tooling/linter/ with these feature modules (one folder per feature, sibling .md per implementation file):

  • markdown-fixer/ — fixMarkdown(filePath, opts?) / processMarkdownFiles(opts) — remark-based pipeline fixing MD012 / MD022 / MD025 / MD029 / MD031 / MD032 / MD035 / MD036 / MD040 / MD044. Returns a MarkdownFixReport (per-file changed flag + rule list).
  • biome-runner/ — runBiome({ cwd, mode, runner }) — shells to bunx biome check via an injectable CommandRunner seam (default = node:child_process spawn). Modes: "check", "check-write", "format-write". Returns a BiomeReport with parsed diagnostic count, exit code, and stdout/stderr.
  • glob-resolver/ — resolveMarkdownTargets({ patterns, fs, glob }) — resolves a list of patterns (or a single file path) to a sorted, deduplicated list of .md paths via injectable FsAdapter + GlobAdapter.
  • unified-lint/ — runUnifiedLint(opts) — orchestrator. Probes biome availability via the injectable runner, runs the markdown fixer + biome in fix or check mode, aggregates into a LintReport. Never calls process.exit — returns the report. The CLI is the only call site that translates report.success into an exit code.
  • cli/ — runCli(argv, opts?) — pure function over argv → Promise<{ code: number }>. Parses flags (--fix/--write, --unified, --markdown-only, --help/-h, --cwd), invokes the orchestrator, returns the exit code. The shipped binary (suaveplan-lint) is a one-line wrapper that calls runCli(process.argv.slice(2)) then process.exit(code).
  • error-codes/ — LINTER_CODES via defineCodes("LINTER", […]) + LinterError.
  • telemetry/ — OTel counters + histogram for fixes applied, biome runs, and orchestrator durations.
  • types/ — shared LintReport / MarkdownFixReport / BiomeReport / CommandRunner / FsAdapter / GlobAdapter types.

Parity Checklist

Capability Upstream Migrated Notes
fixMarkdown(filePath) (remark pipeline) ✅ ✅ Preserved; now returns MarkdownFixReport not boolean.
processMarkdownFiles(args) glob walker ✅ ✅ Split out into glob-resolver + markdown-fixer; injectable fs / glob.
unifiedLint(args) orchestrator ✅ ⬆️ Enhanced — injectable CommandRunner, returns LintReport, no process.exit.
MD025 (multiple H1s) fixer ✅ ✅
MD036 (emphasis-as-heading) fixer ✅ ✅ Plus extended NEVER/ALWAYS/CRITICAL/IMPORTANT/MANDATORY heuristics.
MD032 / MD022 / MD031 (blank lines) ✅ ✅
MD044 (proper-name capitalisation) ✅ ✅
MD029 (ordered list numbering) ✅ ✅
MD035 (horizontal-rule style) ✅ ✅
MD040 (code-block language inference) ✅ ✅
MD012 (multiple blank lines) ✅ ✅
Trailing-newline normalisation ✅ ✅
**text** → **text** post-process ✅ ✅
CLI binary suaveplan-lint ✅ ✅ Now a one-line wrapper over the pure runCli.
--help flag ✅ ✅
--fix / --write flag ✅ ✅
--unified / --markdown-only flag ✅ ✅
Error-code registry 🚫 ⬆️ Added — required by genesis.
OTel self-telemetry 🚫 ⬆️ Added — required by genesis.
Zod-validated options 🚫 ⬆️ Added — required by genesis.
Subpath exports per feature 🚫 ⬆️ Added — every feature is its own subpath.
execSync Biome / markdownlint shell-out ✅ ⬆️ Replaced with injectable CommandRunner (default = real spawn); markdownlint shell-out dropped (remark fixer covers the rule set + a real markdownlint-cli2 is already in devDependencies at the repo root for the bunx markdownlint-cli2 --fix lane in §13b — re-running it inside unifiedLint would double-walk the tree).

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/linter to enumerate. Net-new package on genesis — no current dependents. The repo-root bunx markdownlint-cli2 --fix lane referenced in openspec/AGENTS.md §13b is unchanged.

  • No dependents (verified by bunx turbo ls --filter=...@suaveplan/linter).

Risks

  • Biome programmatic vs subprocess. Biome v2 exposes @biomejs/js-api but the binary resolution behaviour differs across Bun / Node. We stay on bunx biome check via the CommandRunner seam so the surface matches what consumers run by hand. Tests use a fake CommandRunner that returns canned exit codes and stdout.
  • remark plugin order matters. The blank-line fixer must run after the emphasis-as- heading fixer (otherwise the rewritten heading isn't surrounded by blanks). Pinned by the existing pipeline order — preserved verbatim in migration.
  • CLI tests must not exit. The previous package called process.exit from library code, which made testing impossible. runCli returns { code } and only the binary wrapper exits. Verified by a spec scenario that calls runCli(['--help']) and asserts the resolved code without the test harness exiting.