# 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.error`s 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.