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 aMarkdownFixReport(per-file changed flag + rule list).biome-runner/—runBiome({ cwd, mode, runner })— shells tobunx biome checkvia an injectableCommandRunnerseam (default =node:child_processspawn). Modes:"check","check-write","format-write". Returns aBiomeReportwith 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.mdpaths via injectableFsAdapter+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 aLintReport. Never callsprocess.exit— returns the report. The CLI is the only call site that translatesreport.successinto 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 callsrunCli(process.argv.slice(2))thenprocess.exit(code).error-codes/—LINTER_CODESviadefineCodes("LINTER", […])+LinterError.telemetry/— OTel counters + histogram for fixes applied, biome runs, and orchestrator durations.types/— sharedLintReport/MarkdownFixReport/BiomeReport/CommandRunner/FsAdapter/GlobAdaptertypes.
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-apibut the binary resolution behaviour differs across Bun / Node. We stay onbunx biome checkvia theCommandRunnerseam so the surface matches what consumers run by hand. Tests use a fakeCommandRunnerthat 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.exitfrom library code, which made testing impossible.runClireturns{ code }and only the binary wrapper exits. Verified by a spec scenario that callsrunCli(['--help'])and asserts the resolved code without the test harness exiting.