4.6 KiB
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: trueskips biome; an unavailable biome binary skips the biome lane and leavesbiome: undefinedin the report). LintReportaggregation —{ biome, markdown, success, durationMs }.successreflects the biome lane's outcome (the markdown lane is structurally always a success once it returns; per-file failures throw aLinterErrorand short-circuit).- Catches
BIOME_FAILEDfromrunBiomeincheck-writemode and reflects it in the report rather than propagating, so write-mode is non-fatal but informational. Incheckmode aBIOME_FAILEDerror 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
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:
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<LintReport>
Options (all optional):
cwd?: string— defaults to".".fix?: boolean— whentrue, biome runs in"check-write"mode and the markdown fixer writes back changes.markdownOnly?: boolean— skips the biome lane entirely.patterns?: ReadonlyArray<string>— defaults to["**/*.md"].runner?: CommandRunner— defaults tonodeCommandRunner.fs?: FsAdapter— defaults tonodeFsAdapter.glob?: GlobAdapter— defaults tonodeGlobAdapter.clock?: ClockLike— defaults toDate.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 aLintReportunless the markdown lane fails (which is genuinely unrecoverable: a corrupt file or a missing target). markdownOnlyshort-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 yieldsbiome: undefined— quiet skip, not an error.- The orchestrator's
durationMsmeasures wall-clock from entry to exit, which includes both lanes' overhead. The histogram is double-tagged: per-lane phases viarecordRunDuration("markdown", ...)andrecordRunDuration("biome", ...)(the latter fired byrunBiome) and the total viarecordRunDuration("total", ...). - The injected
Clockdefaults to{ now: () => Date.now() }. Tests pass a counter that increments deterministically so durations are stable.