Files
suaveplan/openspec/archive/2026-05-25-enterprise-linter/proposal.md
2026-05-27 23:29:53 +01:00

105 lines
6.5 KiB
Markdown

# 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.