# `unified-lint` _(no submodules)_ ## Purpose `@suaveplan/linter/unified-lint` is the orchestrator that composes the biome runner and the markdown fixer into a single pass. It probes biome availability via the injectable `CommandRunner`, decides whether to invoke biome in check or check-write mode (driven by the `fix` flag), runs the markdown fixer over the configured patterns, aggregates both lanes into a `LintReport`, and returns. The orchestrator NEVER calls `process.exit`; that translation is the CLI's responsibility, which keeps the orchestrator unit-testable and embeddable inside other tools (pre-commit hooks, IDE integrations, language servers). ## Features - Bi-lane orchestration — Biome for JS/TS/JSON, markdown fixer for `.md`. Either lane can be skipped (`markdownOnly: true` skips biome; an unavailable biome binary skips the biome lane and leaves `biome: undefined` in the report). - `LintReport` aggregation — `{ biome, markdown, success, durationMs }`. `success` reflects the biome lane's outcome (the markdown lane is structurally always a success once it returns; per-file failures throw a `LinterError` and short-circuit). - Catches `BIOME_FAILED` from `runBiome` in `check-write` mode and reflects it in the report rather than propagating, so write-mode is non-fatal but informational. In `check` mode a `BIOME_FAILED` error is propagated to the caller (matching the upstream "fail CI on lint errors" semantic). - OTel emission — `linter_run_duration_ms{phase="markdown"}` and `{phase="total"}` recorded per run. ## Usage: Basic ```ts import { runUnifiedLint } from "@suaveplan/linter/unified-lint"; const report = await runUnifiedLint({ fix: false }); if (!report.success) { process.exitCode = 1; } ``` ## Usage: Advanced Run a markdown-only pass with custom adapters from a test: ```ts import { runUnifiedLint } from "@suaveplan/linter/unified-lint"; import type { CommandRunner, FsAdapter, GlobAdapter } from "@suaveplan/linter"; const store = new Map([["a.md", "# A\n# B\n"]]); const fs: FsAdapter = { async readFile(p) { const v = store.get(p); if (v === undefined) throw new Error("ENOENT"); return v; }, async writeFile(p, data) { store.set(p, data); }, async exists(p) { return store.has(p); }, }; const glob: GlobAdapter = { async find() { return [...store.keys()]; }, }; const runner: CommandRunner = { async run() { return { exitCode: 0, stdout: "", stderr: "" }; }, }; const report = await runUnifiedLint({ markdownOnly: true, patterns: ["*.md"], runner, fs, glob, }); // report.biome === undefined // report.markdown[0].changed === true ``` ## API Reference ### `runUnifiedLint(options?): Promise` Options (all optional): - `cwd?: string` — defaults to `"."`. - `fix?: boolean` — when `true`, biome runs in `"check-write"` mode and the markdown fixer writes back changes. - `markdownOnly?: boolean` — skips the biome lane entirely. - `patterns?: ReadonlyArray` — defaults to `["**/*.md"]`. - `runner?: CommandRunner` — defaults to `nodeCommandRunner`. - `fs?: FsAdapter` — defaults to `nodeFsAdapter`. - `glob?: GlobAdapter` — defaults to `nodeGlobAdapter`. - `clock?: ClockLike` — defaults to `Date.now`. Returns a `LintReport`. Throws `LinterError` only when the markdown lane fails (any `FILE_NOT_FOUND` / `READ_FAILED` / `WRITE_FAILED` / `MARKDOWN_PARSE_FAILED` / `NO_TARGETS_FOUND` is propagated) or when `runBiome` raises in `check` mode. ## Implementation Notes - The biome lane catches `LinterError(LINTER.BIOME_FAILED)` to surface the embedded report instead of bubbling. This makes the orchestrator's contract simpler — it always returns a `LintReport` unless the markdown lane fails (which is genuinely unrecoverable: a corrupt file or a missing target). - `markdownOnly` short-circuits BEFORE the probe so a user opting out of biome doesn't pay the cost of even probing for the binary. Conversely, an unavailable binary just yields `biome: undefined` — quiet skip, not an error. - The orchestrator's `durationMs` measures wall-clock from entry to exit, which includes both lanes' overhead. The histogram is double-tagged: per-lane phases via `recordRunDuration("markdown", ...)` and `recordRunDuration("biome", ...)` (the latter fired by `runBiome`) and the total via `recordRunDuration("total", ...)`. - The injected `Clock` defaults to `{ now: () => Date.now() }`. Tests pass a counter that increments deterministically so durations are stable.