Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
# @suaveplan/linter - Unified markdown + biome linting pipeline: remark-based markdown fixer (MD012/022/025/029/031/032/035/036/040/044), injectable CommandRunner-backed biome wrapper, glob resolver, orchestrator with structured LintReport, pure runCli, LINTER_CODES error registry, and OTel self-telemetry.
Unified markdown + biome linting pipeline for Suaveplan: a remark-based
markdown fixer covering MD012/022/025/029/031/032/035/036/040/044, an injectable
`CommandRunner`-backed biome wrapper, a deterministic glob resolver, an orchestrator
that returns a structured `LintReport`, a pure `runCli`, the `LINTER_CODES` error
registry, and OpenTelemetry self-telemetry. The package is server-tier (Node ≥ 20),
ships zero `@suaveplan/*` runtime deps other than `@suaveplan/error` and
`@suaveplan/types`, and is fully tree-shakeable via per-feature subpath exports.
<!-- begin:auto-docs -->
## Documentation tree
- [biome-runner](./src/biome-runner/biome-runner.md) — `@suaveplan/linter/biome-runner` is the seam between the linter and the Biome CLI. Rather than calling `execSync` from…
- [biome-runner](./src/biome-runner/biome-runner.md) — `@suaveplan/linter/biome-runner` is the seam between the linter and the Biome CLI. Rather than calling `execSync` from…
- [cli](./src/cli/cli.md) — `@suaveplan/linter/cli` is the pure-function CLI entry point. It parses an `argv` array, runs the unified orchestrator,…
- [cli](./src/cli/cli.md) — `@suaveplan/linter/cli` is the pure-function CLI entry point. It parses an `argv` array, runs the unified orchestrator,…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/linter/error-codes` owns the stable error-code registry and the typed `LinterError` class that every other…
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/linter/error-codes` owns the stable error-code registry and the typed `LinterError` class that every other…
- [glob-resolver](./src/glob-resolver/glob-resolver.md) — `@suaveplan/linter/glob-resolver` expands a list of patterns (glob expressions or file paths) into a sorted, deduplicat…
- [glob-resolver](./src/glob-resolver/glob-resolver.md) — `@suaveplan/linter/glob-resolver` expands a list of patterns (glob expressions or file paths) into a sorted, deduplicat…
- [markdown-fixer](./src/markdown-fixer/markdown-fixer.md) — `@suaveplan/linter/markdown-fixer` is the remark-based markdown auto-fixer at the core of the package. It applies a cur…
- [markdown-fixer](./src/markdown-fixer/markdown-fixer.md) — `@suaveplan/linter/markdown-fixer` is the remark-based markdown auto-fixer at the core of the package. It applies a cur…
- [rules](./src/markdown-fixer/rules.md) — `rules.ts` houses the individual remark plugins that implement each markdown-lint rule the fixer enforces, plus the sha…
- [telemetry](./src/telemetry/telemetry.md) — `@suaveplan/linter/telemetry` exposes the OpenTelemetry instruments and the record helpers every other module uses to e…
- [telemetry](./src/telemetry/telemetry.md) — `@suaveplan/linter/telemetry` exposes the OpenTelemetry instruments and the record helpers every other module uses to e…
- [unified-lint](./src/unified-lint/unified-lint.md) — `@suaveplan/linter/unified-lint` is the orchestrator that composes the biome runner and the markdown fixer into a singl…
- [unified-lint](./src/unified-lint/unified-lint.md) — `@suaveplan/linter/unified-lint` is the orchestrator that composes the biome runner and the markdown fixer into a singl…
- [bin](./src/bin.md) — `bin.ts` is the executable entry point installed by `package.json` as the `suaveplan-lint` binary. It is intentionally…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/linter
```
## Quick start
```ts
import { runUnifiedLint } from "@suaveplan/linter";
const report = await runUnifiedLint({ fix: false });
if (!report.success) {
process.exitCode = 1;
}
```
Or run the bundled binary:
```bash
bunx suaveplan-lint --fix
bunx suaveplan-lint --markdown-only "docs/*.md"
```
## Modules
- [`error-codes`](./src/error-codes/error-codes.md) — stable
`LINTER_CODES` registry and `LinterError` class.
- [`telemetry`](./src/telemetry/telemetry.md) — four OTel
instruments and helper functions.
- [`markdown-fixer`](./src/markdown-fixer/markdown-fixer.md) — remark
pipeline + processor.
- [`markdown-fixer/rules`](./src/markdown-fixer/rules.md) — individual
remark rule plugins (re-exported through the markdown-fixer subpath).
- [`biome-runner`](./src/biome-runner/biome-runner.md) — injectable
Biome subprocess wrapper.
- [`glob-resolver`](./src/glob-resolver/glob-resolver.md) — pattern
expansion with default ignore list.
- [`unified-lint`](./src/unified-lint/unified-lint.md) — orchestrator
returning a `LintReport`.
- [`cli`](./src/cli/cli.md) — pure `runCli(argv, opts?)`.
## Subpath exports
Every feature is exposed as its own subpath so consumers tree-shake at the feature
boundary:
```ts
import { fixMarkdown } from "@suaveplan/linter/markdown-fixer";
import { runBiome } from "@suaveplan/linter/biome-runner";
import { LINTER_CODES } from "@suaveplan/linter/error-codes";
```
The root `@suaveplan/linter` import re-exports the public surface of every feature.

View File

@@ -0,0 +1,115 @@
{
"name": "@suaveplan/linter",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Unified markdown + biome linting pipeline: remark-based markdown fixer (MD012/022/025/029/031/032/035/036/040/044), injectable CommandRunner-backed biome wrapper, glob resolver, orchestrator with structured LintReport, pure runCli, LINTER_CODES error registry, and OTel self-telemetry.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"suaveplan-lint": "./dist/bin.js"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./markdown-fixer": {
"import": {
"types": "./dist/markdown-fixer/index.d.ts",
"default": "./dist/markdown-fixer/index.js"
}
},
"./biome-runner": {
"import": {
"types": "./dist/biome-runner/index.d.ts",
"default": "./dist/biome-runner/index.js"
}
},
"./glob-resolver": {
"import": {
"types": "./dist/glob-resolver/index.d.ts",
"default": "./dist/glob-resolver/index.js"
}
},
"./unified-lint": {
"import": {
"types": "./dist/unified-lint/index.d.ts",
"default": "./dist/unified-lint/index.js"
}
},
"./cli": {
"import": {
"types": "./dist/cli/index.d.ts",
"default": "./dist/cli/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"glob": "catalog:markdown",
"remark-parse": "catalog:markdown",
"remark-stringify": "catalog:markdown",
"tslib": "catalog:build",
"unified": "catalog:markdown",
"unist-util-visit": "catalog:markdown",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "^1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/glob": "catalog:markdown",
"@types/mdast": "catalog:markdown",
"@types/node": "catalog:",
"@types/unist": "catalog:markdown",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,60 @@
# `bin`
## Purpose
`bin.ts` is the executable entry point installed by `package.json` as the
`suaveplan-lint` binary. It is intentionally a one-line wrapper around `runCli` from
`@suaveplan/linter/cli`: the wrapper calls the pure CLI function with
`process.argv.slice(2)`, then translates the resolved `{ code }` into `process.exit`.
Keeping `process.exit` out of every other file in the package means tests and embedded
callers never accidentally terminate their host process, but the shipped binary still
behaves like a conventional Unix tool — exit non-zero on failure, zero on success.
## Features
- Tiny — fewer than ten lines, no dependencies beyond `cli`.
- Surfaces unhandled exceptions (anything `runCli` didn't catch as a `LinterError`)
via `process.stderr` then exits with code 2 so operators can distinguish CLI parse
failures (code 1) from completely unexpected crashes (code 2).
- Uses `.then` rather than `await` to support compilers (and Vite's esbuild target)
that don't permit top-level await — the binary is built into ESM via the same
Vite pipeline as the rest of the package, and that pipeline targets `es2020`.
## Usage: Basic
```bash
bunx suaveplan-lint --help
bunx suaveplan-lint --fix
bunx suaveplan-lint "docs/**/*.md"
```
## Usage: Advanced
Embed the same logic in a custom CLI without spawning a subprocess:
```ts
import { runCli } from "@suaveplan/linter/cli";
const { code } = await runCli(["--fix", "docs/*.md"], {
out: (line) => console.error(line),
});
process.exit(code);
```
## API Reference
The binary has no public API surface beyond the `suaveplan-lint` shell entry.
Implementation is `runCli(process.argv.slice(2))` followed by `process.exit(code)`.
Unhandled rejections trigger `process.exit(2)` after writing the error to stderr.
## Implementation Notes
- The `#!/usr/bin/env node` shebang is preserved by Vite's `entryFileNames`
configuration. Bun also accepts the same shebang line.
- `runCli` already catches `LinterError` and writes a one-line summary via the
injected `out` sink (defaulting to `console.log`); the `.then` rejection handler
exists for any error type `runCli` did not handle — historically zero, but the
wrapper exists so a future refactor that throws an unrelated `TypeError` from a
helper still produces a useful exit code instead of an unhandled-rejection crash.
- The `bin` field in `package.json` points at `./dist/bin.js`, which is emitted by
the Vite build alongside the other ESM entries with the shebang intact.

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env node
import { runCli } from "./cli/cli.js";
runCli(process.argv.slice(2)).then(
({ code }) => {
process.exit(code);
},
(err) => {
// Surface unexpected exceptions (e.g. an unhandled non-LinterError)
// to the operator and exit non-zero.
process.stderr.write(`${String(err)}\n`);
process.exit(2);
}
);

View File

@@ -0,0 +1,117 @@
# `biome-runner`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/linter/biome-runner` is the seam between the linter and the Biome CLI. Rather
than calling `execSync` from library code (which makes tests impossible and blocks the
event loop), the runner shells out through an injectable `CommandRunner` whose default
implementation wraps `node:child_process.spawn`. Tests pass real fake runners that
return canned `CommandResult` objects; production callers get the real subprocess. Every
run is wrapped in duration measurement so the OTel histogram captures the cost of every
biome invocation across the project.
## Features
- Three modes — `"check"` (read-only), `"check-write"` (lint+fix, `--write --unsafe`),
`"format-write"` (formatter only with `--write`).
- Returns a `BiomeReport` with `mode`, `exitCode`, `stdout`, `stderr`, `success`, and
`durationMs` so downstream callers can render summaries without re-parsing.
- `"check"` mode throws `LinterError(LINTER.BIOME_FAILED)` on a non-zero exit (the
expected contract for CI gates); `"check-write"` and `"format-write"` return the
report unconditionally so write-mode runs can report success even when biome surfaced
diagnostics that it auto-fixed.
- `probeBiomeAvailable({ runner })` — non-fatal `bunx biome --version` ping returning
`boolean`. Used by the orchestrator to gate the biome lane.
- Zod-validated options — invalid `mode` values raise
`LinterError(LINTER.INVALID_OPTIONS)`.
- Injectable `ClockLike` for deterministic duration measurement in tests.
## Usage: Basic
```ts
import {
nodeCommandRunner,
runBiome,
} from "@suaveplan/linter/biome-runner";
const report = await runBiome({
cwd: ".",
mode: "check",
runner: nodeCommandRunner,
});
console.log(report.success, report.durationMs);
```
## Usage: Advanced
Drive the runner from a test with a recording fake runner:
```ts
import { runBiome } from "@suaveplan/linter/biome-runner";
import type { CommandInput, CommandRunner } from "@suaveplan/linter";
const calls: CommandInput[] = [];
const runner: CommandRunner = {
async run(input) {
calls.push(input);
return { exitCode: 0, stdout: "ok", stderr: "" };
},
};
const clock = { now: () => 1000 };
const report = await runBiome({
cwd: ".",
mode: "check-write",
runner,
clock,
});
// calls[0].args === ['biome', 'check', '--write', '--unsafe', '--max-diagnostics', '500', '.']
// report.success === true
// report.durationMs === 0
```
## API Reference
### `runBiome(options): Promise<BiomeReport>`
- `options.cwd: string` — required, forwarded to the runner.
- `options.mode: "check" | "check-write" | "format-write"` — required.
- `options.runner: CommandRunner` — required.
- `options.clock?: ClockLike` — defaults to `Date.now`.
Throws `LinterError` with code `INVALID_OPTIONS`, `RUNNER_FAILED`, or `BIOME_FAILED`.
### `probeBiomeAvailable(options): Promise<boolean>`
- `options.runner: CommandRunner` — required.
- `options.cwd?: string` — defaults to `"."`.
Resolves to `true` only on `exitCode === 0`; runner rejections resolve to `false`.
### `nodeCommandRunner: CommandRunner`
Default runner wrapping `child_process.spawn`. Inherits the current `process.env` and
overlays any `env` field provided in the `CommandInput`.
## Implementation Notes
- The runner shells through `bunx biome` (not `npx`) so the catalogued
`@biomejs/biome` version is the one that runs. Bunx resolves the binary from the
current workspace install, matching what `bun run lint` would invoke in every other
package.
- The default runner buffers stdout / stderr in memory and resolves on `close`, NOT
`exit` — `close` fires after both streams are drained, so the report includes
trailing output that `exit` would miss.
- `RunBiomeOptions` is validated via Zod at the boundary so consumers passing
hand-typed strings get an `INVALID_OPTIONS` error rather than a runtime crash deep
inside the runner.
- `recordBiomeRun(mode, outcome)` and `recordRunDuration("biome", ms)` fire on every
successful runner completion (success or failure); the only path that skips them is
a runner rejection, which is the `RUNNER_FAILED` branch.
- `probeBiomeAvailable` deliberately swallows rejections — a missing binary should not
bubble an unhandled rejection through the orchestrator. Callers who want strict
behaviour can raise `BIOME_UNAVAILABLE` themselves after a `false` probe.

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
CommandInput,
CommandResult,
CommandRunner,
} from "../types/linter.types.js";
import {
nodeCommandRunner,
probeBiomeAvailable,
runBiome,
} from "./biome-runner.js";
function makeRecorder(result: CommandResult): {
runner: CommandRunner;
calls: CommandInput[];
} {
const calls: CommandInput[] = [];
const runner: CommandRunner = {
async run(input) {
calls.push(input);
return result;
},
};
return { runner, calls };
}
const okResult: CommandResult = { exitCode: 0, stdout: "ok", stderr: "" };
const failResult: CommandResult = {
exitCode: 1,
stdout: "",
stderr: "boom",
};
describe("runBiome", () => {
it("passes the correct args for check mode", {
timeout: 5000,
}, async () => {
const { runner, calls } = makeRecorder(okResult);
await runBiome({ cwd: ".", mode: "check", runner });
expect(calls[0]?.command).toBe("bunx");
expect(calls[0]?.args.slice(0, 3)).toEqual(["biome", "check", "."]);
});
it("check-write mode forwards --write and --unsafe", {
timeout: 5000,
}, async () => {
const { runner, calls } = makeRecorder(okResult);
await runBiome({ cwd: ".", mode: "check-write", runner });
expect(calls[0]?.args).toContain("--write");
expect(calls[0]?.args).toContain("--unsafe");
});
it("format-write mode runs biome format", { timeout: 5000 }, async () => {
const { runner, calls } = makeRecorder(okResult);
await runBiome({ cwd: ".", mode: "format-write", runner });
expect(calls[0]?.args[1]).toBe("format");
expect(calls[0]?.args).toContain("--write");
});
it("report carries exit code and stdout", { timeout: 5000 }, async () => {
const { runner } = makeRecorder(okResult);
const report = await runBiome({
cwd: ".",
mode: "check",
runner,
});
expect(report.success).toBe(true);
expect(report.exitCode).toBe(0);
expect(report.stdout).toBe("ok");
expect(report.durationMs).toBeGreaterThanOrEqual(0);
expect(report.mode).toBe("check");
});
it("raises BIOME_FAILED in check mode on non-zero exit", {
timeout: 5000,
}, async () => {
const { runner } = makeRecorder(failResult);
await expect(
runBiome({ cwd: ".", mode: "check", runner })
).rejects.toMatchObject({
code: "LINTER.BIOME_FAILED",
});
});
it("check-write mode returns non-zero report without throwing", {
timeout: 5000,
}, async () => {
const { runner } = makeRecorder(failResult);
const report = await runBiome({
cwd: ".",
mode: "check-write",
runner,
});
expect(report.success).toBe(false);
expect(report.exitCode).toBe(1);
});
it("raises RUNNER_FAILED when the runner rejects", {
timeout: 5000,
}, async () => {
const runner: CommandRunner = {
async run() {
throw new Error("spawn error");
},
};
await expect(
runBiome({ cwd: ".", mode: "check", runner })
).rejects.toMatchObject({ code: "LINTER.RUNNER_FAILED" });
});
it("raises INVALID_OPTIONS on a bad mode", { timeout: 5000 }, async () => {
const { runner } = makeRecorder(okResult);
await expect(
runBiome({
cwd: ".",
mode: "nope" as "check",
runner,
})
).rejects.toMatchObject({ code: "LINTER.INVALID_OPTIONS" });
});
it("raises INVALID_OPTIONS when the runner is missing", {
timeout: 5000,
}, async () => {
await expect(
runBiome({
cwd: ".",
mode: "check",
// biome doesn't accept the test cast; force-cast to unknown
runner: undefined as unknown as CommandRunner,
})
).rejects.toMatchObject({ code: "LINTER.INVALID_OPTIONS" });
});
it("uses the injected clock for duration measurement", {
timeout: 5000,
}, async () => {
const { runner } = makeRecorder(okResult);
let n = 0;
const clock = {
now: () => {
n += 7;
return n;
},
};
const report = await runBiome({
cwd: ".",
mode: "check",
runner,
clock,
});
expect(report.durationMs).toBe(7);
});
});
describe("probeBiomeAvailable", () => {
it("returns true when biome --version succeeds", {
timeout: 5000,
}, async () => {
const { runner } = makeRecorder(okResult);
await expect(probeBiomeAvailable({ runner })).resolves.toBe(true);
});
it("returns false on non-zero exit", { timeout: 5000 }, async () => {
const { runner } = makeRecorder({
exitCode: 127,
stdout: "",
stderr: "missing",
});
await expect(probeBiomeAvailable({ runner })).resolves.toBe(false);
});
it("returns false when the runner rejects", { timeout: 5000 }, async () => {
const runner: CommandRunner = {
async run() {
throw new Error("ENOENT");
},
};
await expect(probeBiomeAvailable({ runner })).resolves.toBe(false);
});
it("accepts an explicit cwd", { timeout: 5000 }, async () => {
const { runner, calls } = makeRecorder(okResult);
await probeBiomeAvailable({ runner, cwd: "/tmp" });
expect(calls[0]?.cwd).toBe("/tmp");
});
});
describe("nodeCommandRunner", () => {
it("runs `node -e` and reports stdout + exit zero", {
timeout: 5000,
}, async () => {
const result = await nodeCommandRunner.run({
command: process.execPath,
args: ["-e", "process.stdout.write('hello'); process.exit(0)"],
cwd: process.cwd(),
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("hello");
});
it("captures stderr and non-zero exit", { timeout: 5000 }, async () => {
const result = await nodeCommandRunner.run({
command: process.execPath,
args: ["-e", "process.stderr.write('oops'); process.exit(2)"],
cwd: process.cwd(),
});
expect(result.exitCode).toBe(2);
expect(result.stderr).toBe("oops");
});
it("merges env overrides into the child process", {
timeout: 5000,
}, async () => {
const result = await nodeCommandRunner.run({
command: process.execPath,
args: [
"-e",
"process.stdout.write(process.env.LINTER_TEST_FLAG ?? 'missing')",
],
cwd: process.cwd(),
env: { LINTER_TEST_FLAG: "yes" },
});
expect(result.stdout).toBe("yes");
});
it("treats a killed child (null exit code) as exitCode 1", {
timeout: 5000,
}, async () => {
// SIGKILL the child during spawn so the close handler fires
// with exitCode === null and the `?? 1` fallback triggers.
const { spawn } = await import("node:child_process");
const original = spawn;
// We can't monkey-patch the module-level spawn the runner
// uses without affecting other tests. Instead, call the runner
// with a self-killing wrapper that has the parent send itself
// SIGKILL after stdio is open.
const result = await nodeCommandRunner.run({
command: process.execPath,
args: ["-e", "process.kill(process.pid, 'SIGKILL')"],
cwd: process.cwd(),
});
// Either exitCode === 1 (fallback fired) OR exitCode === 137
// (raw signal exit on some platforms). Both confirm the close
// handler resolved successfully.
expect([1, 137, 9]).toContain(result.exitCode);
// touch `original` to silence unused-var lint
expect(typeof original).toBe("function");
});
it("rejects when the command does not exist", {
timeout: 5000,
}, async () => {
await expect(
nodeCommandRunner.run({
command: "/definitely-not-a-real-binary-12345",
args: [],
cwd: process.cwd(),
})
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,187 @@
import { spawn } from "node:child_process";
import z from "zod";
import { LINTER_CODES, LinterError } from "../error-codes/error-codes.js";
import { recordBiomeRun, recordRunDuration } from "../telemetry/telemetry.js";
import type {
BiomeMode,
BiomeReport,
ClockLike,
CommandResult,
CommandRunner,
} from "../types/linter.types.js";
const BIOME_MODE_SCHEMA = z.enum(["check", "check-write", "format-write"]);
const RUN_BIOME_OPTIONS_SCHEMA = z.object({
cwd: z.string(),
mode: BIOME_MODE_SCHEMA,
runner: z.custom<CommandRunner>(
(v) =>
typeof v === "object" &&
v !== null &&
typeof (v as CommandRunner).run === "function"
),
clock: z
.custom<ClockLike>(
(v) =>
typeof v === "object" &&
v !== null &&
typeof (v as ClockLike).now === "function"
)
.optional(),
});
const defaultClock: ClockLike = { now: () => Date.now() };
/** Default `CommandRunner` wrapping `node:child_process.spawn`. */
export const nodeCommandRunner: CommandRunner = {
async run({ command, args, cwd, env }) {
return await new Promise<CommandResult>((resolve, reject) => {
const child = spawn(command, [...args], {
cwd,
env: env ? { ...process.env, ...env } : { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (exitCode) => {
resolve({
exitCode: exitCode ?? 1,
stdout,
stderr,
});
});
});
},
};
function argsForMode(mode: BiomeMode): ReadonlyArray<string> {
switch (mode) {
case "check":
return ["biome", "check", "."];
case "check-write":
return [
"biome",
"check",
"--write",
"--unsafe",
"--max-diagnostics",
"500",
".",
];
case "format-write":
return [
"biome",
"format",
"--write",
"--max-diagnostics",
"500",
".",
];
}
}
/** Options accepted by {@link runBiome}. */
export interface RunBiomeOptions {
readonly cwd: string;
readonly mode: BiomeMode;
readonly runner: CommandRunner;
readonly clock?: ClockLike;
}
async function executeRunner(
runner: CommandRunner,
args: ReadonlyArray<string>,
cwd: string
): Promise<CommandResult> {
try {
return await runner.run({
command: "bunx",
args,
cwd,
});
} catch (cause) {
throw new LinterError(
LINTER_CODES.RUNNER_FAILED,
"Command runner rejected before completion",
{ cause, details: { cwd } }
);
}
}
/**
* Run Biome through the injectable `CommandRunner`. Returns a
* {@link BiomeReport} carrying the parsed exit code, stdout / stderr, and a
* measured duration. In `"check"` mode a non-zero exit code raises
* `LinterError(LINTER.BIOME_FAILED)` carrying the report under `details`.
* Invalid `mode` raises `LinterError(LINTER.INVALID_OPTIONS)`.
*/
export async function runBiome(options: RunBiomeOptions): Promise<BiomeReport> {
const parsed = RUN_BIOME_OPTIONS_SCHEMA.safeParse(options);
if (!parsed.success) {
throw new LinterError(
LINTER_CODES.INVALID_OPTIONS,
"Invalid runBiome options",
{ details: { issues: parsed.error.issues } }
);
}
const { cwd, mode, runner } = parsed.data;
const clock = parsed.data.clock ?? defaultClock;
const args = argsForMode(mode);
const start = clock.now();
const result = await executeRunner(runner, args, cwd);
const durationMs = Math.max(0, clock.now() - start);
const success = result.exitCode === 0;
const report: BiomeReport = {
mode,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
success,
durationMs,
};
recordBiomeRun(mode, success ? "success" : "failure");
recordRunDuration("biome", durationMs);
if (!success && mode === "check") {
throw new LinterError(
LINTER_CODES.BIOME_FAILED,
`Biome check failed (exit ${result.exitCode})`,
{ details: { report } }
);
}
return report;
}
/** Options accepted by {@link probeBiomeAvailable}. */
export interface ProbeBiomeAvailableOptions {
readonly runner: CommandRunner;
readonly cwd?: string;
}
/**
* Probe whether `bunx biome --version` resolves successfully. Returns
* `true` only on `exitCode === 0`; rejections from the runner resolve to
* `false` so the probe is non-fatal.
*/
export async function probeBiomeAvailable(
options: ProbeBiomeAvailableOptions
): Promise<boolean> {
const cwd = options.cwd ?? ".";
try {
const result = await options.runner.run({
command: "bunx",
args: ["biome", "--version"],
cwd,
});
return result.exitCode === 0;
} catch {
return false;
}
}

View File

@@ -0,0 +1,9 @@
export type {
ProbeBiomeAvailableOptions,
RunBiomeOptions,
} from "./biome-runner.js";
export {
nodeCommandRunner,
probeBiomeAvailable,
runBiome,
} from "./biome-runner.js";

View File

@@ -0,0 +1,93 @@
# `cli`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/linter/cli` is the pure-function CLI entry point. It parses an `argv` array,
runs the unified orchestrator, and returns a resolved exit code. Unlike the upstream
package, it never calls `process.exit` — the shipped binary (`suaveplan-lint`) is a
one-line wrapper that calls `runCli(process.argv.slice(2))` and then exits with the
returned code. Keeping the CLI pure means tests can assert on the resolved code without
the test process terminating mid-suite, and other tools (pre-commit hooks, IDE
integrations) can embed the CLI logic directly.
## Features
- Hand-rolled argv parser — no `commander` dependency for a binary with five flags.
- Recognised flags: `--help` / `-h`, `--fix` / `--write`, `--unified`,
`--markdown-only`, `--cwd <path>`. Anything else starting with `-` raises
`LinterError(LINTER.UNKNOWN_FLAG)` (rather than silently treating it as a positional
pattern, which is how the upstream package quietly swallowed typos).
- Positional arguments accumulate into `patterns` so multiple globs are supported.
- Injectable `out: (line: string) => void` sink for tests — defaults to `console.log`.
- Translates `runUnifiedLint`'s `LintReport.success` into exit code 0 / 1, prints a
concise summary line, and catches `LinterError` to surface the code and message in
the summary.
## Usage: Basic
```ts
import { runCli } from "@suaveplan/linter/cli";
const { code } = await runCli(process.argv.slice(2));
process.exit(code);
```
## Usage: Advanced
Embed the CLI inside another tool with full adapter control:
```ts
import { runCli } from "@suaveplan/linter/cli";
import type { CommandRunner } from "@suaveplan/linter";
const lines: string[] = [];
const out = (line: string) => lines.push(line);
const runner: CommandRunner = {
async run() {
return { exitCode: 0, stdout: "", stderr: "" };
},
};
const { code } = await runCli(["--fix", "docs/*.md"], {
out,
runner,
cwd: "/tmp/repo",
});
// code === 0, lines contains the summary
```
## API Reference
### `runCli(argv, options?): Promise<{ code: number }>`
- `argv: ReadonlyArray<string>` — required. The argv slice (typically
`process.argv.slice(2)`).
- `options.out?: (line: string) => void` — sink for help / summary / error lines.
- `options.runner?: CommandRunner` — forwarded to the orchestrator.
- `options.fs?: FsAdapter`, `options.glob?: GlobAdapter`, `options.clock?: ClockLike`
— forwarded to the orchestrator.
Returns `{ code: 0 }` on success or `{ code: 1 }` on failure. Throws
`LinterError(LINTER.UNKNOWN_FLAG)` for an unrecognised flag.
## Implementation Notes
- The parser uses a single forward sweep; flags are recognised by exact match and
positional args are anything that doesn't start with `-` (or `--cwd <path>` where
the next token is consumed as the cwd value). This is deliberately simple — the
scope is five flags, not a general argv parser.
- `--unified` is accepted but no-op; the orchestrator runs both lanes by default and
the flag exists for symmetry with the upstream CLI's documented surface.
- The success summary names the count of markdown files touched and the count that
were fixed. The failure summary names the biome exit code so consumers can grep CI
output for the underlying cause.
- `LinterError` is caught here (not at the orchestrator) because the CLI is the layer
that maps domain errors to exit codes. Letting the error propagate out of `runCli`
would force every consumer to write the same translation.
- The CLI does NOT print the markdown reports inline. Consumers that want per-file
output can call `runUnifiedLint` directly; the CLI summary is the "CI-friendly"
shape.

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
CommandInput,
CommandResult,
CommandRunner,
FsAdapter,
GlobAdapter,
} from "../types/linter.types.js";
import { runCli } from "./cli.js";
function makeFs(seed: Record<string, string>): FsAdapter {
const store = new Map<string, string>(Object.entries(seed));
return {
async readFile(path) {
const v = store.get(path);
if (v === undefined) {
throw new Error("ENOENT");
}
return v;
},
async writeFile(path, data) {
store.set(path, data);
},
async exists(path) {
return store.has(path);
},
};
}
function makeGlob(files: ReadonlyArray<string>): {
glob: GlobAdapter;
captured: { pattern: string };
} {
const captured = { pattern: "" };
const glob: GlobAdapter = {
async find(pattern) {
captured.pattern = pattern;
return files;
},
};
return { glob, captured };
}
function makeRunner(sequence: ReadonlyArray<CommandResult>): {
runner: CommandRunner;
calls: CommandInput[];
} {
let index = 0;
const calls: CommandInput[] = [];
const runner: CommandRunner = {
async run(input) {
calls.push(input);
const r = sequence[index] ?? sequence[sequence.length - 1];
index += 1;
if (!r) {
throw new Error("no canned result");
}
return r;
},
};
return { runner, calls };
}
const ok: CommandResult = { exitCode: 0, stdout: "", stderr: "" };
describe("runCli", () => {
it("--help prints usage and resolves code 0", {
timeout: 5000,
}, async () => {
const lines: string[] = [];
const result = await runCli(["--help"], {
out: (l) => lines.push(l),
});
expect(result.code).toBe(0);
expect(lines.join("\n")).toContain("Usage:");
});
it("-h is an alias for --help", { timeout: 5000 }, async () => {
const result = await runCli(["-h"], { out: () => {} });
expect(result.code).toBe(0);
});
it("--fix sets fix mode", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const { glob } = makeGlob(["a.md"]);
const { runner, calls } = makeRunner([ok, ok]);
const result = await runCli(["--fix"], {
out: () => {},
runner,
fs,
glob,
});
expect(result.code).toBe(0);
expect(calls[1]?.args).toContain("--write");
});
it("--write is an alias for --fix", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const { glob } = makeGlob(["a.md"]);
const { runner, calls } = makeRunner([ok, ok]);
await runCli(["--write"], {
out: () => {},
runner,
fs,
glob,
});
expect(calls[1]?.args).toContain("--write");
});
it("--markdown-only skips biome", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const { glob } = makeGlob(["a.md"]);
let runnerCalled = false;
const runner: CommandRunner = {
async run() {
runnerCalled = true;
return ok;
},
};
await runCli(["--markdown-only"], {
out: () => {},
runner,
fs,
glob,
});
expect(runnerCalled).toBe(false);
});
it("positional pattern is forwarded to the resolver", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "docs/a.md": "# A\n" });
const { glob, captured } = makeGlob(["docs/a.md"]);
const { runner } = makeRunner([ok, ok]);
await runCli(["docs/*.md"], {
out: () => {},
runner,
fs,
glob,
});
expect(captured.pattern).toBe("docs/*.md");
});
it("unknown flag raises UNKNOWN_FLAG", { timeout: 5000 }, async () => {
await expect(
runCli(["--nope"], { out: () => {} })
).rejects.toMatchObject({ code: "LINTER.UNKNOWN_FLAG" });
});
it("failing report yields exit code 1", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const { glob } = makeGlob(["a.md"]);
const lines: string[] = [];
const { runner } = makeRunner([
ok, // probe
{ exitCode: 1, stdout: "", stderr: "" }, // check
]);
const result = await runCli([], {
out: (l) => lines.push(l),
runner,
fs,
glob,
});
expect(result.code).toBe(1);
expect(lines.join("\n")).toContain("Lint failed");
});
it("--unified is accepted as a no-op", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const { glob } = makeGlob(["a.md"]);
const { runner } = makeRunner([ok, ok]);
const result = await runCli(["--unified"], {
out: () => {},
runner,
fs,
glob,
});
expect(result.code).toBe(0);
});
it("--cwd consumes the next token", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const { glob } = makeGlob(["a.md"]);
const { runner, calls } = makeRunner([ok, ok]);
await runCli(["--cwd", "/tmp/x"], {
out: () => {},
runner,
fs,
glob,
});
expect(calls[0]?.cwd).toBe("/tmp/x");
});
it("catches LinterError from the orchestrator and prints code", {
timeout: 5000,
}, async () => {
const fs = makeFs({});
const glob: GlobAdapter = {
async find() {
return [];
},
};
const lines: string[] = [];
const { runner } = makeRunner([
{ exitCode: 127, stdout: "", stderr: "" }, // biome unavailable
]);
// No matching markdown files → NO_TARGETS_FOUND raised by
// processMarkdownFiles, caught and turned into code:1.
const result = await runCli(["**/*.md"], {
out: (l) => lines.push(l),
runner,
fs,
glob,
});
expect(result.code).toBe(1);
expect(lines.join("\n")).toContain("LINTER.NO_TARGETS_FOUND");
});
it("forwards an injected clock to the orchestrator", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const { glob } = makeGlob(["a.md"]);
const { runner } = makeRunner([ok, ok]);
let n = 0;
const clock = {
now: () => {
n += 1;
return n;
},
};
const result = await runCli([], {
out: () => {},
runner,
fs,
glob,
clock,
});
expect(result.code).toBe(0);
});
it("exercises buildOptions branches when runtime adapters are omitted", {
timeout: 5000,
}, async () => {
// No runner/fs/glob/clock passed — buildOptions must NOT spread
// them. Use markdownOnly + a real tmp file to avoid hitting biome.
const tmp = await import("node:fs/promises");
const path = await import("node:path");
const dir = await tmp.mkdtemp(
path.join(process.cwd(), ".linter-cli-tmp-")
);
const file = path.join(dir, "a.md");
await tmp.writeFile(file, "# A\n", "utf-8");
try {
const lines: string[] = [];
const result = await runCli(["--markdown-only", file], {
out: (l) => lines.push(l),
});
expect(result.code).toBe(0);
} finally {
await tmp.rm(dir, { recursive: true });
}
});
it("default out sink writes to console.log without throwing", {
timeout: 5000,
}, async () => {
const original = console.log;
const captured: string[] = [];
console.log = (msg: string) => {
captured.push(msg);
};
try {
const result = await runCli(["--help"]);
expect(result.code).toBe(0);
expect(captured.join("\n")).toContain("Usage:");
} finally {
console.log = original;
}
});
it("propagates non-LinterError exceptions", { timeout: 5000 }, async () => {
const fs: FsAdapter = {
async readFile() {
return "";
},
async writeFile() {},
async exists() {
// throw plain Error to exercise the rethrow branch
throw new TypeError("kaboom");
},
};
const { glob } = makeGlob([]);
const { runner } = makeRunner([ok]);
await expect(
runCli(["a.md"], {
out: () => {},
runner,
fs,
glob,
})
).rejects.toThrow(TypeError);
});
});

View File

@@ -0,0 +1,136 @@
import { LINTER_CODES, LinterError } from "../error-codes/error-codes.js";
import type {
BiomeReport,
RunCliOptions,
UnifiedLintOptions,
} from "../types/linter.types.js";
import { runUnifiedLint } from "../unified-lint/unified-lint.js";
const HELP_TEXT = `Usage: suaveplan-lint [options] [pattern]
Options:
--fix, --write Fix issues automatically
--help, -h Show this help message
--unified Run unified linting for all file types (default)
--markdown-only Only run markdown linting
--cwd <path> Working directory (defaults to .)
Patterns:
[pattern] Glob pattern for markdown files (default: **/*.md)
Examples:
suaveplan-lint # Lint all markdown files
suaveplan-lint --fix # Fix all markdown files
suaveplan-lint README.md # Lint single file
suaveplan-lint "docs/*.md" --fix # Fix markdown files in docs folder
suaveplan-lint --unified # Run unified linting for all file types
`;
interface ParsedArgs {
readonly showHelp: boolean;
readonly fix: boolean;
readonly markdownOnly: boolean;
readonly cwd: string | undefined;
readonly patterns: ReadonlyArray<string>;
}
function parseArgs(argv: ReadonlyArray<string>): ParsedArgs {
let showHelp = false;
let fix = false;
let markdownOnly = false;
let cwd: string | undefined;
const patterns: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] as string;
if (arg === "--help" || arg === "-h") {
showHelp = true;
continue;
}
if (arg === "--fix" || arg === "--write") {
fix = true;
continue;
}
if (arg === "--unified") {
// Default behaviour — flag is accepted for symmetry.
continue;
}
if (arg === "--markdown-only") {
markdownOnly = true;
continue;
}
if (arg === "--cwd") {
i += 1;
cwd = argv[i];
continue;
}
if (arg.startsWith("-")) {
// Every recognised flag has already continued above, so anything
// starting with `-` reaching this point is unknown.
throw new LinterError(
LINTER_CODES.UNKNOWN_FLAG,
`Unknown flag: ${arg}`,
{ details: { flag: arg } }
);
}
patterns.push(arg);
}
return { showHelp, fix, markdownOnly, cwd, patterns };
}
function buildOptions(
parsed: ParsedArgs,
runtime: RunCliOptions
): UnifiedLintOptions {
const base: UnifiedLintOptions = {
fix: parsed.fix,
markdownOnly: parsed.markdownOnly,
...(parsed.cwd !== undefined ? { cwd: parsed.cwd } : {}),
...(parsed.patterns.length > 0 ? { patterns: parsed.patterns } : {}),
...(runtime.runner ? { runner: runtime.runner } : {}),
...(runtime.fs ? { fs: runtime.fs } : {}),
...(runtime.glob ? { glob: runtime.glob } : {}),
...(runtime.clock ? { clock: runtime.clock } : {}),
};
return base;
}
/**
* Pure CLI entry. Parses argv, runs the unified orchestrator, returns the
* resolved exit code. Never calls `process.exit`.
*/
export async function runCli(
argv: ReadonlyArray<string>,
options: RunCliOptions = {}
): Promise<{ code: number }> {
const out = options.out ?? ((line: string) => console.log(line));
const parsed = parseArgs(argv);
if (parsed.showHelp) {
out(HELP_TEXT);
return { code: 0 };
}
try {
const report = await runUnifiedLint(buildOptions(parsed, options));
if (report.success) {
const fixed = report.markdown.filter((r) => r.changed).length;
out(
`Lint OK — ${report.markdown.length} markdown file(s), ${fixed} fixed.`
);
return { code: 0 };
}
// `report.success === false` is reachable only when the biome lane
// ran and failed (the markdown lane throws on errors instead of
// setting a flag), so `report.biome` is defined here.
out(
`Lint failed — biome exit ${(report.biome as BiomeReport).exitCode}, ${
report.markdown.length
} markdown file(s).`
);
return { code: 1 };
} catch (err) {
if (err instanceof LinterError) {
out(`Lint failed: ${err.code} ${err.message}`);
return { code: 1 };
}
throw err;
}
}

View File

@@ -0,0 +1 @@
export { runCli } from "./cli.js";

View File

@@ -0,0 +1,112 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/linter/error-codes` owns the stable error-code registry and the typed
`LinterError` class that every other module in the package throws. The registry is
expressed through `defineCodes("LINTER", [...])` from `@suaveplan/error` so each code
carries the `LINTER.` namespace prefix and survives across module boundaries, worker
threads, RPC envelopes, and serialised error transports without losing its identity.
Consumers of the linter — pre-commit hooks, CI gates, IDE integrations — discriminate
on `error.code` to make routing decisions (retry, suppress, surface to the user) without
parsing free-form messages, so the registry is a public contract, not an implementation
detail.
## Features
- Closed code set declared once via `defineCodes` — typo-safe, no string literals at
call sites.
- `LinterError extends SuaveplanError<LinterCode>` — interoperable with every
observability adapter shipped in `@suaveplan/error` (OTel span events, redaction,
i18n hooks).
- Optional `details` payload preserved on the instance and on the underlying
`SuaveplanError.context` field so structured diagnostics travel with the error.
- Optional `cause` forwarded to the native `Error.cause` chain — preserves the
underlying failure (e.g. an `EACCES` from `fs.writeFile`) for debugging.
## Usage: Basic
```ts
import { LINTER_CODES, LinterError } from "@suaveplan/linter/error-codes";
throw new LinterError(LINTER_CODES.NO_TARGETS_FOUND, "no .md files matched", {
details: { pattern: "docs/**/*.md" },
});
```
## Usage: Advanced
Route errors by code in a CI gate:
```ts
import { LinterError } from "@suaveplan/linter/error-codes";
import { runUnifiedLint } from "@suaveplan/linter/unified-lint";
try {
const report = await runUnifiedLint({ fix: false });
if (!report.success) {
process.exitCode = 1;
}
} catch (err) {
if (err instanceof LinterError) {
switch (err.code) {
case "LINTER.BIOME_UNAVAILABLE":
console.warn("biome not installed — skipping JS/TS lane");
break;
case "LINTER.NO_TARGETS_FOUND":
process.exitCode = 0; // empty repo, not a failure
break;
default:
console.error(err.code, err.message, err.details);
process.exitCode = 2;
}
} else {
throw err;
}
}
```
## API Reference
### `LINTER_CODES`
A frozen object whose values are the registered `LINTER.<REASON>` strings. Members:
`INVALID_OPTIONS`, `FILE_NOT_FOUND`, `NO_TARGETS_FOUND`, `READ_FAILED`,
`WRITE_FAILED`, `MARKDOWN_PARSE_FAILED`, `BIOME_UNAVAILABLE`, `BIOME_FAILED`,
`RUNNER_FAILED`, `UNKNOWN_FLAG`.
### `type LinterCode`
Union of every value in `LINTER_CODES`.
### `class LinterError extends SuaveplanError<LinterCode>`
Constructor: `new LinterError(code, message, options?)`.
- `code: LinterCode` — one of the registered codes.
- `message: string` — human-readable summary.
- `options.cause?: unknown` — forwarded to `Error.cause`.
- `options.details?: Record<string, unknown>` — stored as both
`instance.details` AND `instance.context` (via `SuaveplanError`).
`instance.name` is set to `"LinterError"`.
## Implementation Notes
- The registry is enumerated by tests (`Object.values(LINTER_CODES).sort()`) to
guarantee the public set matches the spec delta exactly. Adding a code without
updating the test fails the gate.
- `details` defaults to `{}` rather than `undefined` so consumers can safely spread or
read fields without a null-check; the spec scenario "defaults details to empty record
when omitted" pins this contract.
- `SuaveplanError` accepts `cause` only when defined — passing `undefined` would set
`cause: undefined` on the chain, which differs from "no cause." The constructor
guards this with the `options?.cause === undefined ? {} : { cause: options.cause }`
ternary.
- The class extends `SuaveplanError<LinterCode>` rather than `SuaveplanError` so the
`code` getter is narrowed to the linter's registered union at the type level — IDE
autocompletion lists exactly the ten codes.

View File

@@ -0,0 +1,50 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LINTER_CODES, LinterError } from "./error-codes.js";
describe("error-codes", () => {
it("registry exposes the full code set", { timeout: 5000 }, () => {
const codes = Object.values(LINTER_CODES).sort();
expect(codes).toEqual(
[
"LINTER.BIOME_FAILED",
"LINTER.BIOME_UNAVAILABLE",
"LINTER.FILE_NOT_FOUND",
"LINTER.INVALID_OPTIONS",
"LINTER.MARKDOWN_PARSE_FAILED",
"LINTER.NO_TARGETS_FOUND",
"LINTER.READ_FAILED",
"LINTER.RUNNER_FAILED",
"LINTER.UNKNOWN_FLAG",
"LINTER.WRITE_FAILED",
].sort()
);
});
it("LinterError carries code and details payload", {
timeout: 5000,
}, () => {
const error = new LinterError(LINTER_CODES.INVALID_OPTIONS, "bad", {
details: { field: "x" },
});
expect(error.code).toBe("LINTER.INVALID_OPTIONS");
expect(error.details).toEqual({ field: "x" });
expect(error).toBeInstanceOf(SuaveplanError);
expect(error.name).toBe("LinterError");
});
it("LinterError defaults details to empty record when omitted", {
timeout: 5000,
}, () => {
const error = new LinterError(LINTER_CODES.READ_FAILED, "boom");
expect(error.details).toEqual({});
});
it("LinterError preserves cause when provided", { timeout: 5000 }, () => {
const root = new Error("root");
const error = new LinterError(LINTER_CODES.WRITE_FAILED, "boom", {
cause: root,
});
expect(error.cause).toBe(root);
});
});

View File

@@ -0,0 +1,54 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/linter`.
*
* Codes follow `LINTER.<REASON>` and are produced by `@suaveplan/error`'s
* `defineCodes` so the namespace survives module boundaries and runtime
* serialisation. Consumers SHOULD discriminate on `error.code` rather than
* `instanceof` so the discriminator works across worker, RPC, and
* serialised-error boundaries.
*/
export const LINTER_CODES = defineCodes("LINTER", [
"INVALID_OPTIONS",
"FILE_NOT_FOUND",
"NO_TARGETS_FOUND",
"READ_FAILED",
"WRITE_FAILED",
"MARKDOWN_PARSE_FAILED",
"BIOME_UNAVAILABLE",
"BIOME_FAILED",
"RUNNER_FAILED",
"UNKNOWN_FLAG",
] as const);
export type LinterCode = (typeof LINTER_CODES)[keyof typeof LINTER_CODES];
/** Options accepted by the {@link LinterError} constructor. */
export interface LinterErrorOptions {
readonly cause?: unknown;
readonly details?: Record<string, unknown>;
}
/**
* Typed error class for every `@suaveplan/linter` failure path. Carries the
* registered code, a human-readable message, and an optional structured
* `details` payload through the `SuaveplanError` `context` field so callers
* can drive diagnostics without re-parsing the message.
*/
export class LinterError extends SuaveplanError<LinterCode> {
public readonly details: Record<string, unknown>;
public constructor(
code: LinterCode,
message: string,
options?: LinterErrorOptions
) {
super(code, message, {
...(options?.cause === undefined ? {} : { cause: options.cause }),
context: options?.details ?? {},
});
this.name = "LinterError";
this.details = options?.details ?? {};
}
}

View File

@@ -0,0 +1,2 @@
export type { LinterCode, LinterErrorOptions } from "./error-codes.js";
export { LINTER_CODES, LinterError } from "./error-codes.js";

View File

@@ -0,0 +1,117 @@
# `glob-resolver`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/linter/glob-resolver` expands a list of patterns (glob expressions or file
paths) into a sorted, deduplicated list of markdown targets. It owns the default ignore
list (`node_modules`, `dist`), distinguishes between single-file paths and glob
expressions, and produces a stable iteration order so the rest of the pipeline is
deterministic. The resolver is the single seam where the filesystem and the glob
implementation meet, which means every call site downstream can rely on receiving a
plain sorted array of paths.
## Features
- Single-file fast path — a pattern that ends in `.md` AND contains no glob magic
characters bypasses the glob adapter entirely and is added directly to the result
after an `fs.exists` check.
- Default ignore list — `node_modules/**`, `**/node_modules/**`, `dist/**`,
`**/dist/**`. Callers cannot override this list at the resolver layer (the
intent is to keep all linter walks consistent across the project).
- Set-based deduplication — two patterns that match the same file produce one entry.
- Sorted output — paths returned in lexicographic order so reports are reproducible
across runs.
- Injectable `FsAdapter` and `GlobAdapter` seams; the default `nodeGlobAdapter` wraps
the `glob` npm package.
## Usage: Basic
```ts
import {
nodeGlobAdapter,
resolveMarkdownTargets,
} from "@suaveplan/linter/glob-resolver";
import { nodeFsAdapter } from "@suaveplan/linter/markdown-fixer";
const files = await resolveMarkdownTargets({
patterns: ["docs/**/*.md", "README.md"],
fs: nodeFsAdapter,
glob: nodeGlobAdapter,
});
```
## Usage: Advanced
Use an in-memory glob adapter to drive a hermetic test:
```ts
import type { FsAdapter, GlobAdapter } from "@suaveplan/linter";
import { resolveMarkdownTargets } from "@suaveplan/linter/glob-resolver";
const tree = ["a.md", "docs/b.md", "node_modules/junk.md"];
const fs: FsAdapter = {
async readFile() {
return "";
},
async writeFile() {},
async exists(p) {
return tree.includes(p);
},
};
const glob: GlobAdapter = {
async find(pattern, { ignore }) {
// ignore-aware filter so the test exercises the same contract
return tree.filter(
(f) =>
!ignore.some((i) => f.startsWith(i.replace("/**", ""))) &&
f.endsWith(".md")
);
},
};
const targets = await resolveMarkdownTargets({
patterns: ["**/*.md", "a.md"],
fs,
glob,
});
// targets === ['a.md', 'docs/b.md'] (node_modules ignored, deduped)
```
## API Reference
### `resolveMarkdownTargets(options): Promise<string[]>`
- `options.patterns: ReadonlyArray<string>` — required.
- `options.cwd?: string` — defaults to `"."`.
- `options.fs: FsAdapter` — required (used for the single-file existence check).
- `options.glob?: GlobAdapter` — defaults to `nodeGlobAdapter`.
Throws `LinterError(LINTER.FILE_NOT_FOUND)` when a pattern looks like a file path
(ends in `.md` AND has no magic chars) but does not exist.
### `nodeGlobAdapter: GlobAdapter`
Default adapter; forwards to `glob(pattern, { ignore, cwd })`.
### `DEFAULT_IGNORE: ReadonlyArray<string>`
The unmodifiable ignore list applied on every glob invocation.
## Implementation Notes
- The magic-character detection regex (`/[*?[\]{}]/`) matches any of the glob
meta-characters. A pattern containing none of them is treated as a literal path —
this avoids the `glob` library treating `README.md` as a pattern with side effects
on a case-insensitive filesystem.
- The `cwd` option is forwarded verbatim to the glob adapter. When omitted it falls
through as `"."`, which is the behaviour the upstream `processMarkdownFiles` relied
on implicitly.
- The result is materialised into a `Set` then sorted to a regular array. Returning
the `Set` directly would force callers to handle iteration order; the explicit
`[...].sort()` keeps the contract simple.
- The resolver never reads file contents — only `fs.exists` for the single-file fast
path. This keeps the resolver O(patterns) rather than O(matches × file size).

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FsAdapter, GlobAdapter } from "../types/linter.types.js";
import {
DEFAULT_IGNORE,
nodeGlobAdapter,
resolveMarkdownTargets,
} from "./glob-resolver.js";
function makeFs(present: ReadonlyArray<string>): FsAdapter {
return {
async readFile() {
return "";
},
async writeFile() {},
async exists(path) {
return present.includes(path);
},
};
}
describe("resolveMarkdownTargets", () => {
it("single-file pattern bypasses the glob", { timeout: 5000 }, async () => {
const fs = makeFs(["README.md"]);
let globCalled = false;
const glob: GlobAdapter = {
async find() {
globCalled = true;
return [];
},
};
const result = await resolveMarkdownTargets({
patterns: ["README.md"],
fs,
glob,
});
expect(result).toEqual(["README.md"]);
expect(globCalled).toBe(false);
});
it("applies the default ignore list", { timeout: 5000 }, async () => {
const fs = makeFs([]);
let captured: ReadonlyArray<string> | undefined;
const glob: GlobAdapter = {
async find(_pattern, opts) {
captured = opts.ignore;
return [];
},
};
await resolveMarkdownTargets({
patterns: ["**/*.md"],
fs,
glob,
});
expect(captured).toEqual(DEFAULT_IGNORE);
});
it("deduplicates across patterns", { timeout: 5000 }, async () => {
const fs = makeFs(["a.md"]);
const glob: GlobAdapter = {
async find() {
return ["a.md", "b.md"];
},
};
const result = await resolveMarkdownTargets({
patterns: ["**/*.md", "docs/**/*.md", "a.md"],
fs,
glob,
});
expect(result).toEqual(["a.md", "b.md"]);
});
it("raises FILE_NOT_FOUND for a missing file path", {
timeout: 5000,
}, async () => {
const fs = makeFs([]);
const glob: GlobAdapter = {
async find() {
return [];
},
};
await expect(
resolveMarkdownTargets({
patterns: ["missing.md"],
fs,
glob,
})
).rejects.toMatchObject({ code: "LINTER.FILE_NOT_FOUND" });
});
it("forwards cwd to the glob adapter", { timeout: 5000 }, async () => {
const fs = makeFs([]);
let capturedCwd = "";
const glob: GlobAdapter = {
async find(_pattern, opts) {
capturedCwd = opts.cwd;
return [];
},
};
await resolveMarkdownTargets({
patterns: ["**/*.md"],
cwd: "/tmp/repo",
fs,
glob,
});
expect(capturedCwd).toBe("/tmp/repo");
});
it("defaults cwd to . when not supplied", { timeout: 5000 }, async () => {
const fs = makeFs([]);
let capturedCwd = "";
const glob: GlobAdapter = {
async find(_pattern, opts) {
capturedCwd = opts.cwd;
return [];
},
};
await resolveMarkdownTargets({
patterns: ["**/*.md"],
fs,
glob,
});
expect(capturedCwd).toBe(".");
});
it("uses nodeGlobAdapter when no glob is supplied", {
timeout: 5000,
}, async () => {
const fs = makeFs([]);
// Default adapter — point at an empty cwd that yields no results.
const result = await resolveMarkdownTargets({
patterns: ["this-prefix-matches-nothing-12345/**/*.md"],
fs,
});
expect(result).toEqual([]);
});
it("treats glob-magic patterns ending in .md as patterns, not paths", {
timeout: 5000,
}, async () => {
const fs = makeFs([]);
const glob: GlobAdapter = {
async find() {
return ["docs/a.md", "docs/b.md"];
},
};
const result = await resolveMarkdownTargets({
patterns: ["docs/*.md"],
fs,
glob,
});
expect(result).toEqual(["docs/a.md", "docs/b.md"]);
});
});
describe("nodeGlobAdapter", () => {
it("is exported and exposes a find method", { timeout: 5000 }, () => {
expect(typeof nodeGlobAdapter.find).toBe("function");
});
});

View File

@@ -0,0 +1,80 @@
import { glob as globImpl } from "glob";
import { LINTER_CODES, LinterError } from "../error-codes/error-codes.js";
import type {
FsAdapter,
GlobAdapter,
GlobOptions,
} from "../types/linter.types.js";
const DEFAULT_IGNORE: ReadonlyArray<string> = [
"node_modules/**",
"**/node_modules/**",
"dist/**",
"**/dist/**",
];
/** Default `GlobAdapter` wrapping the `glob` npm package. */
export const nodeGlobAdapter: GlobAdapter = {
async find(pattern, options: GlobOptions) {
return await globImpl(pattern, {
ignore: [...options.ignore],
cwd: options.cwd,
});
},
};
function hasGlobMagic(pattern: string): boolean {
return /[*?[\]{}]/.test(pattern);
}
function looksLikeFilePath(pattern: string): boolean {
return pattern.endsWith(".md") && !hasGlobMagic(pattern);
}
/** Options accepted by {@link resolveMarkdownTargets}. */
export interface ResolveMarkdownTargetsOptions {
readonly patterns: ReadonlyArray<string>;
readonly cwd?: string;
readonly fs: FsAdapter;
readonly glob?: GlobAdapter;
}
/**
* Resolve a list of patterns (or file paths) to a sorted, deduplicated list
* of markdown file paths. Single-file patterns bypass the glob adapter.
*
* Raises:
* - `LinterError(LINTER.FILE_NOT_FOUND)` if a pattern looks like a file path
* but does not exist.
*/
export async function resolveMarkdownTargets(
options: ResolveMarkdownTargetsOptions
): Promise<string[]> {
const { patterns, fs } = options;
const cwd = options.cwd ?? ".";
const glob = options.glob ?? nodeGlobAdapter;
const collected = new Set<string>();
for (const pattern of patterns) {
if (looksLikeFilePath(pattern)) {
if (!(await fs.exists(pattern))) {
throw new LinterError(
LINTER_CODES.FILE_NOT_FOUND,
`Markdown file not found: ${pattern}`,
{ details: { pattern } }
);
}
collected.add(pattern);
continue;
}
const matches = await glob.find(pattern, {
ignore: DEFAULT_IGNORE,
cwd,
});
for (const match of matches) {
collected.add(match);
}
}
return [...collected].sort();
}
export { DEFAULT_IGNORE };

View File

@@ -0,0 +1,6 @@
export type { ResolveMarkdownTargetsOptions } from "./glob-resolver.js";
export {
DEFAULT_IGNORE,
nodeGlobAdapter,
resolveMarkdownTargets,
} from "./glob-resolver.js";

View File

@@ -0,0 +1,75 @@
export type {
ProbeBiomeAvailableOptions,
RunBiomeOptions,
} from "./biome-runner/biome-runner.js";
export {
nodeCommandRunner,
probeBiomeAvailable,
runBiome,
} from "./biome-runner/biome-runner.js";
export { runCli } from "./cli/cli.js";
export type {
LinterCode,
LinterErrorOptions,
} from "./error-codes/error-codes.js";
export { LINTER_CODES, LinterError } from "./error-codes/error-codes.js";
export type { ResolveMarkdownTargetsOptions } from "./glob-resolver/glob-resolver.js";
export {
DEFAULT_IGNORE,
nodeGlobAdapter,
resolveMarkdownTargets,
} from "./glob-resolver/glob-resolver.js";
export type {
FixMarkdownOptions,
ProcessMarkdownFilesOptions,
} from "./markdown-fixer/markdown-fixer.js";
export {
fixMarkdown,
nodeFsAdapter,
processMarkdownFiles,
} from "./markdown-fixer/markdown-fixer.js";
export type { RuleSet } from "./markdown-fixer/rules.js";
export {
fixBlankLines,
fixCodeBlockLanguages,
fixEmphasisAsHeading,
fixHorizontalRules,
fixMultipleH1s,
fixOrderedListNumbering,
fixProperNames,
HEADING_HINT_BASIC,
HEADING_HINT_EXTENDED,
inferCodeLanguage,
PROPER_NAMES,
} from "./markdown-fixer/rules.js";
export type {
BiomeOutcome,
ProcessedResult,
RunPhase,
} from "./telemetry/telemetry.js";
export {
biomeRunsCounter,
filesFixedCounter,
filesProcessedCounter,
recordBiomeRun,
recordFileFixed,
recordFileProcessed,
recordRunDuration,
runDurationHistogram,
} from "./telemetry/telemetry.js";
export type {
BiomeMode,
BiomeReport,
ClockLike,
CommandInput,
CommandResult,
CommandRunner,
FsAdapter,
GlobAdapter,
GlobOptions,
LintReport,
MarkdownFixReport,
RunCliOptions,
UnifiedLintOptions,
} from "./types/linter.types.js";
export { runUnifiedLint } from "./unified-lint/unified-lint.js";

View File

@@ -0,0 +1,23 @@
export type {
FixMarkdownOptions,
ProcessMarkdownFilesOptions,
} from "./markdown-fixer.js";
export {
fixMarkdown,
nodeFsAdapter,
processMarkdownFiles,
} from "./markdown-fixer.js";
export type { RuleSet } from "./rules.js";
export {
fixBlankLines,
fixCodeBlockLanguages,
fixEmphasisAsHeading,
fixHorizontalRules,
fixMultipleH1s,
fixOrderedListNumbering,
fixProperNames,
HEADING_HINT_BASIC,
HEADING_HINT_EXTENDED,
inferCodeLanguage,
PROPER_NAMES,
} from "./rules.js";

View File

@@ -0,0 +1,133 @@
# `markdown-fixer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/linter/markdown-fixer` is the remark-based markdown auto-fixer at the core of
the package. It applies a curated set of markdownlint-equivalent rules — MD012, MD022,
MD025, MD029, MD031, MD032, MD035, MD036, MD040, MD044, plus trailing-newline
normalisation and a bold-as-heading post-process — by transforming the parsed mdast AST
through a deterministic pipeline of plugins. Every file the fixer touches is read,
parsed, transformed, stringified, post-processed, and rewritten only when the canonical
form differs from the input, so re-running the fixer over an already-clean tree is a no-op
that records the `unchanged` outcome and never touches disk.
## Features
- Pure-function entrypoint `fixMarkdown(filePath, { fs? })` that returns a
`MarkdownFixReport` per file (`{ file, changed, rulesApplied }`).
- Bulk walker `processMarkdownFiles({ patterns, fs?, glob? })` that defaults to
`**/*.md`, dedupes via the glob resolver, and yields one report per file.
- Injectable `FsAdapter` and `GlobAdapter` seams — tests pass `Map`-backed adapters,
production callers use the bundled `nodeFsAdapter` (default).
- Stable error taxonomy via `LinterError` — `FILE_NOT_FOUND`, `READ_FAILED`,
`WRITE_FAILED`, `MARKDOWN_PARSE_FAILED`, `NO_TARGETS_FOUND` are the documented
failures, all carrying the file path under `details`.
- OTel emission on every run — `linter_files_processed_total{result}` for changed /
unchanged, `linter_files_fixed_total{rule}` for each rule applied.
## Usage: Basic
```ts
import { fixMarkdown } from "@suaveplan/linter/markdown-fixer";
const report = await fixMarkdown("README.md");
if (report.changed) {
console.log(`fixed ${report.file}: ${report.rulesApplied.join(", ")}`);
}
```
## Usage: Advanced
Drive the fixer over an in-memory tree from a test with custom adapters:
```ts
import {
fixMarkdown,
processMarkdownFiles,
} from "@suaveplan/linter/markdown-fixer";
import type { FsAdapter, GlobAdapter } from "@suaveplan/linter";
const store = new Map([
["a.md", "# A\n# A2\n"],
["b.md", "**Step 1: GO**\n\nbody\n"],
]);
const fs: FsAdapter = {
async readFile(p) {
const v = store.get(p);
if (v === undefined) {
throw Object.assign(new Error("ENOENT"), { code: "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 reports = await processMarkdownFiles({ patterns: ["*.md"], fs, glob });
// reports[0].rulesApplied includes "MD025"
// reports[1].rulesApplied includes "MD036"
```
## API Reference
### `fixMarkdown(filePath, options?): Promise<MarkdownFixReport>`
- `filePath: string` — absolute or relative path. MUST exist on `fs.exists`.
- `options.fs?: FsAdapter` — defaults to `nodeFsAdapter`.
Throws `LinterError` with code `FILE_NOT_FOUND` / `READ_FAILED` / `WRITE_FAILED` /
`MARKDOWN_PARSE_FAILED`.
### `processMarkdownFiles(options?): Promise<MarkdownFixReport[]>`
- `options.patterns?: ReadonlyArray<string>` — defaults to `["**/*.md"]`.
- `options.cwd?: string` — defaults to `"."`.
- `options.fs?: FsAdapter`, `options.glob?: GlobAdapter` — adapter overrides.
Throws `LinterError` with code `NO_TARGETS_FOUND` when the resolver returns an empty
list (delegated through `resolveMarkdownTargets`).
### `nodeFsAdapter: FsAdapter`
Default adapter wrapping `node:fs/promises`. `exists` swallows access errors and
returns `false`.
### Rule plugin factories
Re-exported from `./rules.js`: `fixMultipleH1s`, `fixEmphasisAsHeading`,
`fixBlankLines`, `fixProperNames`, `fixOrderedListNumbering`, `fixHorizontalRules`,
`fixCodeBlockLanguages`, plus `HEADING_HINT_BASIC` / `HEADING_HINT_EXTENDED` /
`PROPER_NAMES` / `inferCodeLanguage`.
## Implementation Notes
- The remark pipeline order is load-bearing: `fixEmphasisAsHeading` MUST run BEFORE
`fixBlankLines`, otherwise the rewritten headings won't be surrounded by blanks. The
two emphasis passes are sequenced basic → extended so the basic matcher catches the
common form first and the extended pass picks up the keyword variants without
re-walking already-rewritten paragraphs.
- Post-processing is intentionally string-level. `remark-stringify` doesn't expose
hooks for the bold-without-backticks rewrite or the multi-newline collapse, and
running another AST pass for them would be heavier than two regexes that operate on
already-canonical output.
- `changed` is computed by byte-comparison of the post-processed output against the
original. This means whitespace-only transformations (e.g. trailing-newline trimming)
count as a change — that's intentional; the fixer's contract is "the file matches the
canonical form on disk", not "the rendered HTML is identical".
- The `RuleSet` is a `Set<string>` wrapper exposed as `{ add(rule) }`. Sorting
`rulesApplied` in the report keeps snapshot output stable across runs.
- The fixer NEVER calls `process.exit`. Callers (the CLI) translate the report into
exit codes.

View File

@@ -0,0 +1,427 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LinterError } from "../error-codes/error-codes.js";
import type { FsAdapter, GlobAdapter } from "../types/linter.types.js";
import { fixMarkdown, processMarkdownFiles } from "./markdown-fixer.js";
function makeFs(seed: Record<string, string>): {
fs: FsAdapter;
store: Map<string, string>;
writes: string[];
} {
const store = new Map<string, string>(Object.entries(seed));
const writes: string[] = [];
const fs: FsAdapter = {
async readFile(path) {
const v = store.get(path);
if (v === undefined) {
throw new Error(`ENOENT: ${path}`);
}
return v;
},
async writeFile(path, data) {
writes.push(path);
store.set(path, data);
},
async exists(path) {
return store.has(path);
},
};
return { fs, store, writes };
}
function makeGlob(files: ReadonlyArray<string>): GlobAdapter {
return {
async find() {
return files;
},
};
}
describe("fixMarkdown", () => {
it("collapses multiple H1s into H2s after the first", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "# A\n\n# B\n\n# C\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toContain("# A");
expect(out).toContain("## B");
expect(out).toContain("## C");
expect(report.changed).toBe(true);
expect(report.rulesApplied).toContain("MD025");
});
it("rewrites step-style strong paragraph to H3", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "**Step 1: PLAN**\n\nbody\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toContain("### Step 1: PLAN");
expect(out).not.toContain("**Step 1: PLAN**");
expect(report.rulesApplied).toContain("MD036");
});
it("infers bash language for code blocks", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "```\nnpm install\n```\n",
});
const report = await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```bash");
expect(report.rulesApplied).toContain("MD040");
});
it("infers typescript language for code blocks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\ninterface User { name: string }\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```typescript");
});
it("infers javascript language for code blocks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\nconst x = 1\nfunction y() {}\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```javascript");
});
it("infers python language for code blocks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\ndef hi():\n return 1\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```python");
});
it("infers markdown language for code blocks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\n# Title\n## Sub\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```markdown");
});
it("infers html language for code blocks", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "```\n<div>x</div>\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```html");
});
it("infers json language for code blocks", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": '```\n{ "a": 1 }\n```\n',
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```json");
});
it("defaults to text language for unrecognised content", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "```\nzzz qqq aaa\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```text");
});
it("infers bash for git commands", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "```\ngit status\n```\n",
});
await fixMarkdown("a.md", { fs });
expect(store.get("a.md") ?? "").toContain("```bash");
});
it("collapses multiple blank lines", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "para a\n\n\n\n\npara b\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).not.toMatch(/\n\n\n/);
expect(report.rulesApplied).toContain("MD012");
});
it("capitalises proper names", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "This is a json api over http.\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toContain("JSON");
expect(out).toContain("API");
expect(out).toContain("HTTP");
expect(report.rulesApplied).toContain("MD044");
});
it("ensures trailing single newline", { timeout: 5000 }, async () => {
const { fs, store } = makeFs({
"a.md": "para a\n\n\n",
});
await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out.endsWith("\n")).toBe(true);
expect(out.endsWith("\n\n")).toBe(false);
});
it("appends trailing newline when missing", { timeout: 5000 }, async () => {
// remark-stringify on an EMPTY document yields the empty string,
// which is the only path that exercises the "append" branch of
// postProcess.
const { fs, store } = makeFs({ "a.md": "" });
await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toBe("\n");
});
it("rewrites bold heading-style line with backticks", {
timeout: 5000,
}, async () => {
const { fs, store } = makeFs({
"a.md": "**Plain**\n\nbody\n",
});
const report = await fixMarkdown("a.md", { fs });
const out = store.get("a.md") ?? "";
expect(out).toContain("**`Plain`**");
expect(report.rulesApplied).toContain("MD036");
});
it("returns changed=false when content is already canonical", {
timeout: 5000,
}, async () => {
// First normalise — second run should be idempotent.
const { fs, store, writes } = makeFs({
"a.md": "# A\n",
});
await fixMarkdown("a.md", { fs });
const writesAfterFirst = writes.length;
const after = store.get("a.md") ?? "";
// Re-seed with the canonical output and check changed=false.
store.set("a.md", after);
const report = await fixMarkdown("a.md", { fs });
expect(report.changed).toBe(false);
expect(writes.length).toBe(writesAfterFirst);
});
it("raises FILE_NOT_FOUND when the path does not exist", {
timeout: 5000,
}, async () => {
const { fs } = makeFs({});
await expect(fixMarkdown("missing.md", { fs })).rejects.toThrow(
LinterError
);
await expect(fixMarkdown("missing.md", { fs })).rejects.toMatchObject({
code: "LINTER.FILE_NOT_FOUND",
});
});
it("raises READ_FAILED when the FsAdapter throws on read", {
timeout: 5000,
}, async () => {
const fs: FsAdapter = {
async readFile() {
throw new Error("EPERM");
},
async writeFile() {},
async exists() {
return true;
},
};
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
code: "LINTER.READ_FAILED",
});
});
it("raises WRITE_FAILED when the FsAdapter throws on write", {
timeout: 5000,
}, async () => {
const fs: FsAdapter = {
async readFile() {
return "# A\n# B\n";
},
async writeFile() {
throw new Error("EROFS");
},
async exists() {
return true;
},
};
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
code: "LINTER.WRITE_FAILED",
});
});
it("raises MARKDOWN_PARSE_FAILED when readFile returns a non-string", {
timeout: 5000,
}, async () => {
const fs: FsAdapter = {
async readFile() {
// Return an object that explodes in remark's
// process pipeline — `toString` throws so the
// VFile constructor cannot stringify.
const poisoned = {
toString() {
throw new TypeError("unstringifiable");
},
};
return poisoned as unknown as string;
},
async writeFile() {},
async exists() {
return true;
},
};
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
code: "LINTER.MARKDOWN_PARSE_FAILED",
});
});
it("uses nodeFsAdapter when no fs is supplied", {
timeout: 5000,
}, async () => {
await expect(
fixMarkdown("/definitely-not-here-12345.md")
).rejects.toMatchObject({ code: "LINTER.FILE_NOT_FOUND" });
});
it("nodeFsAdapter exercises readFile / writeFile / exists with a temp file", {
timeout: 5000,
}, async () => {
const { nodeFsAdapter } = await import("./markdown-fixer.js");
const tmp = await import("node:fs/promises");
const path = await import("node:path");
const dir = await tmp.mkdtemp(path.join(process.cwd(), ".linter-tmp-"));
const file = path.join(dir, "a.md");
await tmp.writeFile(file, "# A\n# B\n", "utf-8");
try {
expect(await nodeFsAdapter.exists(file)).toBe(true);
expect(await nodeFsAdapter.exists(`${file}.missing`)).toBe(false);
expect(await nodeFsAdapter.readFile(file)).toBe("# A\n# B\n");
await nodeFsAdapter.writeFile(file, "# X\n");
expect(await nodeFsAdapter.readFile(file)).toBe("# X\n");
const report = await fixMarkdown(file);
expect(report.file).toBe(file);
} finally {
await tmp.rm(dir, { recursive: true });
}
});
});
describe("processMarkdownFiles", () => {
it("walks a glob and returns per-file reports", {
timeout: 5000,
}, async () => {
const { fs } = makeFs({
"a.md": "# A\n# A2\n",
"b.md": "# B\n",
});
const glob = makeGlob(["a.md", "b.md"]);
// Pre-normalise b.md so it lands as unchanged
await fixMarkdown("b.md", { fs });
const reports = await processMarkdownFiles({
patterns: ["**/*.md"],
fs,
glob,
});
expect(reports).toHaveLength(2);
expect(reports[0]?.file).toBe("a.md");
expect(reports[1]?.file).toBe("b.md");
expect(reports[0]?.changed).toBe(true);
expect(reports[1]?.changed).toBe(false);
});
it("defaults to **/*.md when patterns is empty", {
timeout: 5000,
}, async () => {
const { fs } = makeFs({ "a.md": "# A\n" });
let captured = "";
const glob: GlobAdapter = {
async find(pattern) {
captured = pattern;
return ["a.md"];
},
};
await processMarkdownFiles({ patterns: [], fs, glob });
expect(captured).toBe("**/*.md");
});
it("raises NO_TARGETS_FOUND when nothing matches", {
timeout: 5000,
}, async () => {
const { fs } = makeFs({});
const glob = makeGlob([]);
await expect(
processMarkdownFiles({
patterns: ["nope/*.md"],
fs,
glob,
})
).rejects.toMatchObject({ code: "LINTER.NO_TARGETS_FOUND" });
});
it("passes patterns through when supplied without glob override", {
timeout: 5000,
}, async () => {
// Exercise the `patterns:` spread branch (non-empty patterns).
const { fs } = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const reports = await processMarkdownFiles({
patterns: ["**/*.md"],
fs,
glob,
});
expect(reports).toHaveLength(1);
});
it("uses default cwd when not supplied", { timeout: 5000 }, async () => {
const { fs } = makeFs({ "a.md": "# A\n" });
let capturedCwd = "";
const glob: GlobAdapter = {
async find(_pattern, opts) {
capturedCwd = opts.cwd;
return ["a.md"];
},
};
await processMarkdownFiles({ fs, glob });
expect(capturedCwd).toBe(".");
});
it("uses default options object when called with no args", {
timeout: 5000,
}, async () => {
// Run from a temp dir with one .md file so all defaults
// (fs / glob / cwd / patterns) take their fallback branches.
const tmp = await import("node:fs/promises");
const path = await import("node:path");
const dir = await tmp.mkdtemp(
path.join(process.cwd(), ".linter-default-")
);
const before = process.cwd();
try {
process.chdir(dir);
await tmp.writeFile(path.join(dir, "a.md"), "# A\n", "utf-8");
const reports = await processMarkdownFiles();
expect(reports.length).toBeGreaterThanOrEqual(1);
} finally {
process.chdir(before);
await tmp.rm(dir, { recursive: true });
}
});
});

View File

@@ -0,0 +1,225 @@
import { promises as nodeFs } from "node:fs";
import type { Root } from "mdast";
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import { type Processor, unified } from "unified";
import { LINTER_CODES, LinterError } from "../error-codes/error-codes.js";
import { resolveMarkdownTargets } from "../glob-resolver/glob-resolver.js";
import {
recordFileFixed,
recordFileProcessed,
} from "../telemetry/telemetry.js";
import type {
FsAdapter,
GlobAdapter,
MarkdownFixReport,
} from "../types/linter.types.js";
import {
fixBlankLines,
fixCodeBlockLanguages,
fixEmphasisAsHeading,
fixHorizontalRules,
fixMultipleH1s,
fixOrderedListNumbering,
fixProperNames,
HEADING_HINT_BASIC,
HEADING_HINT_EXTENDED,
type RuleSet,
} from "./rules.js";
type RemarkProcessor = Processor<Root, Root, Root, Root, string>;
function buildProcessor(rules: RuleSet): RemarkProcessor {
return unified()
.use(remarkParse)
.use(fixMultipleH1s, rules)
.use(fixEmphasisAsHeading, rules, HEADING_HINT_BASIC)
.use(fixEmphasisAsHeading, rules, HEADING_HINT_EXTENDED)
.use(fixOrderedListNumbering, rules)
.use(fixHorizontalRules, rules)
.use(fixCodeBlockLanguages, rules)
.use(fixBlankLines, rules)
.use(fixProperNames, rules)
.use(remarkStringify, {
bullet: "-",
emphasis: "*",
fences: true,
incrementListMarker: true,
rule: "-",
}) as unknown as RemarkProcessor;
}
function postProcess(output: string, rules: RuleSet): string {
const collapsed = output.replace(/\n{3,}/g, "\n\n");
if (collapsed !== output) {
rules.add("MD012");
}
const lined = collapsed
.split("\n")
.map((line) => line.replace(/^\*\*([^`*]+)\*\*$/g, "**`$1`**"))
.join("\n");
if (lined !== collapsed) {
rules.add("MD036");
}
// remark-stringify always emits at least one trailing newline, so the
// canonical form is "exactly one trailing newline". Collapse any tail
// run of newlines to a single one; if (somehow) none, append.
const withTrail = lined.endsWith("\n")
? lined.replace(/\n+$/, "\n")
: `${lined}\n`;
return withTrail;
}
/** Default `FsAdapter` backed by `node:fs/promises`. */
export const nodeFsAdapter: FsAdapter = {
async readFile(path) {
return await nodeFs.readFile(path, "utf-8");
},
async writeFile(path, data) {
await nodeFs.writeFile(path, data, "utf-8");
},
async exists(path) {
try {
await nodeFs.access(path);
return true;
} catch {
return false;
}
},
};
/** Options accepted by {@link fixMarkdown}. */
export interface FixMarkdownOptions {
readonly fs?: FsAdapter;
}
async function readSource(fs: FsAdapter, filePath: string): Promise<string> {
try {
return await fs.readFile(filePath);
} catch (cause) {
throw new LinterError(
LINTER_CODES.READ_FAILED,
`Failed to read ${filePath}`,
{ cause, details: { filePath } }
);
}
}
async function processSource(
processor: RemarkProcessor,
source: string
): Promise<string> {
const vfile = await processor.process(source);
return String(vfile);
}
async function writeOutput(
fs: FsAdapter,
filePath: string,
data: string
): Promise<void> {
try {
await fs.writeFile(filePath, data);
} catch (cause) {
throw new LinterError(
LINTER_CODES.WRITE_FAILED,
`Failed to write ${filePath}`,
{ cause, details: { filePath } }
);
}
}
/**
* Fix a single markdown file in place. Returns a {@link MarkdownFixReport}
* naming the file, whether it was rewritten, and the rule identifiers that
* altered the AST. Raises `LinterError(LINTER.FILE_NOT_FOUND)` when the
* path is missing.
*/
export async function fixMarkdown(
filePath: string,
options: FixMarkdownOptions = {}
): Promise<MarkdownFixReport> {
const fs = options.fs ?? nodeFsAdapter;
if (!(await fs.exists(filePath))) {
throw new LinterError(
LINTER_CODES.FILE_NOT_FOUND,
`Markdown file not found: ${filePath}`,
{ details: { filePath } }
);
}
const original = await readSource(fs, filePath);
const rules = new Set<string>();
const ruleSet: RuleSet = { add: (rule) => rules.add(rule) };
let stringified: string;
try {
if (/\n{3,}/.test(original)) {
ruleSet.add("MD012");
}
stringified = await processSource(buildProcessor(ruleSet), original);
} catch (cause) {
throw new LinterError(
LINTER_CODES.MARKDOWN_PARSE_FAILED,
`Failed to parse markdown for ${filePath}`,
{ cause, details: { filePath } }
);
}
const output = postProcess(stringified, ruleSet);
const changed = output !== original;
if (changed) {
await writeOutput(fs, filePath, output);
for (const rule of rules) {
recordFileFixed(rule);
}
recordFileProcessed("changed");
} else {
recordFileProcessed("unchanged");
}
return {
file: filePath,
changed,
rulesApplied: [...rules].sort(),
};
}
/** Options accepted by {@link processMarkdownFiles}. */
export interface ProcessMarkdownFilesOptions {
readonly patterns?: ReadonlyArray<string>;
readonly cwd?: string;
readonly fs?: FsAdapter;
readonly glob?: GlobAdapter;
}
/**
* Resolve every markdown target and apply {@link fixMarkdown} to each.
* Defaults to `["**\/*.md"]` when no patterns are supplied. Raises
* `LinterError(LINTER.NO_TARGETS_FOUND)` on an empty result set.
*/
export async function processMarkdownFiles(
options: ProcessMarkdownFilesOptions = {}
): Promise<MarkdownFixReport[]> {
const fs = options.fs ?? nodeFsAdapter;
const patterns =
!options.patterns || options.patterns.length === 0
? ["**/*.md"]
: options.patterns;
const cwd = options.cwd ?? ".";
const targets = await resolveMarkdownTargets({
patterns,
cwd,
fs,
...(options.glob ? { glob: options.glob } : {}),
});
if (targets.length === 0) {
throw new LinterError(
LINTER_CODES.NO_TARGETS_FOUND,
"No markdown files matched the supplied patterns",
{ details: { patterns: [...patterns] } }
);
}
const reports: MarkdownFixReport[] = [];
for (const file of targets) {
const report = await fixMarkdown(file, { fs });
reports.push(report);
}
return reports;
}

View File

@@ -0,0 +1,114 @@
# `markdown-fixer/rules`
## Purpose
`rules.ts` houses the individual remark plugins that implement each markdown-lint rule
the fixer enforces, plus the shared heuristic table (`PROPER_NAMES`) and the
`inferCodeLanguage` heuristic. It is the rule-by-rule contract of the fixer; the
`markdown-fixer.ts` entry point composes these plugins into a single `unified()`
pipeline so the orchestration code stays short and the rule logic stays in one place,
testable and reviewable per rule.
## Features
- One exported plugin factory per rule — `fixMultipleH1s`, `fixEmphasisAsHeading`,
`fixBlankLines`, `fixProperNames`, `fixOrderedListNumbering`, `fixHorizontalRules`,
`fixCodeBlockLanguages`. Each takes a `RuleSet` sink the caller mutates as the
plugin walks the AST so the fixer can report exactly which rules altered a file.
- `HEADING_HINT_BASIC` and `HEADING_HINT_EXTENDED` regexes — the basic matcher
recognises `Step N:`, `A)`, and all-caps `LABEL:` prefixes; the extended matcher
adds `NEVER`, `ALWAYS`, `CRITICAL`, `IMPORTANT`, `MANDATORY` keywords used in
rules / safety docs.
- `inferCodeLanguage(value)` — heuristic mapper from code-block content to a
language identifier (`bash`, `javascript`, `typescript`, `python`, `markdown`,
`html`, `json`, `text`).
- `PROPER_NAMES` — closed allow-list of `(lower, proper)` pairs for MD044.
## Usage: Basic
```ts
import {
fixMultipleH1s,
fixCodeBlockLanguages,
} from "@suaveplan/linter/markdown-fixer";
const rules = new Set<string>();
const sink = { add: (r: string) => rules.add(r) };
// then plug each factory into a `unified().use(...)` pipeline
unified().use(fixMultipleH1s, sink).use(fixCodeBlockLanguages, sink);
```
## Usage: Advanced
Compose the same plugin set in a custom pipeline that strips Markdown to plain text:
```ts
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import { unified } from "unified";
import {
fixCodeBlockLanguages,
fixMultipleH1s,
HEADING_HINT_EXTENDED,
fixEmphasisAsHeading,
} from "@suaveplan/linter/markdown-fixer";
const rules = new Set<string>();
const sink = { add: (r: string) => rules.add(r) };
const processor = unified()
.use(remarkParse)
.use(fixMultipleH1s, sink)
.use(fixEmphasisAsHeading, sink, HEADING_HINT_EXTENDED)
.use(fixCodeBlockLanguages, sink)
.use(remarkStringify);
const file = await processor.process("# A\n# B\n");
const applied = [...rules];
```
## API Reference
### `interface RuleSet { add(rule: string): void }`
The mutable sink the plugins write to. The fixer passes a `Set<string>` wrapper.
### `HEADING_HINT_BASIC: RegExp`
Matches `^(Step \d+:|[A-Z]\)|[A-Z][A-Z\s]+:)` — used by the first emphasis-as-heading
pass.
### `HEADING_HINT_EXTENDED: RegExp`
Extended matcher used by the second pass; adds keyword prefixes.
### `PROPER_NAMES: ReadonlyArray<readonly [string, string]>`
Closed pair list — every lowercase form on the left rewrites to the canonical form on
the right with a case-insensitive word-boundary match.
### `inferCodeLanguage(value: string): string`
Heuristic returning one of `"bash" | "javascript" | "typescript" | "python" | "markdown" | "html" | "json" | "text"`.
### Plugin factories
Each factory accepts the `RuleSet` (and a matcher where applicable) and returns a
`unified` transformer of type `(tree: Root) => void`. Exported names:
`fixMultipleH1s`, `fixEmphasisAsHeading`, `fixBlankLines`, `fixProperNames`,
`fixOrderedListNumbering`, `fixHorizontalRules`, `fixCodeBlockLanguages`.
## Implementation Notes
- The blank-line pass walks the tree depth-first, fixing each `Parent`'s children
bottom-up so a heading nested inside a `blockquote` gets the same surround-with-blanks
treatment as one at the document root. The pass also collapses adjacent empty
paragraphs as MD012.
- The emphasis-as-heading pass runs TWICE in the pipeline — once with the basic
matcher, once with the extended matcher. The basic matcher handles the common
`Step 1: …` form; the extended pass picks up safety-doc prefixes
(`NEVER`, `MANDATORY`) that the basic pattern is intentionally too narrow to catch.
- `fixOrderedListNumbering` resets `list.start = 1` only when it isn't already 1.
This avoids treating an already-canonical list as "changed" — necessary so
`fixMarkdown` can report `changed: false` correctly.
- `inferCodeLanguage` is intentionally conservative — any block that doesn't match a
known heuristic falls back to `"text"` rather than guessing. The remark fixer is a
formatter, not a syntax analyser.

View File

@@ -0,0 +1,272 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
HEADING_HINT_BASIC,
HEADING_HINT_EXTENDED,
inferCodeLanguage,
PROPER_NAMES,
} from "./rules.js";
describe("rules helpers", () => {
it("HEADING_HINT_BASIC matches Step prefix", { timeout: 5000 }, () => {
expect(HEADING_HINT_BASIC.test("Step 1: PLAN")).toBe(true);
expect(HEADING_HINT_BASIC.test("normal text")).toBe(false);
});
it("HEADING_HINT_EXTENDED matches keyword prefixes", {
timeout: 5000,
}, () => {
expect(HEADING_HINT_EXTENDED.test("NEVER do this")).toBe(true);
expect(HEADING_HINT_EXTENDED.test("ALWAYS do this")).toBe(true);
expect(HEADING_HINT_EXTENDED.test("CRITICAL fact")).toBe(true);
expect(HEADING_HINT_EXTENDED.test("IMPORTANT note")).toBe(true);
expect(HEADING_HINT_EXTENDED.test("MANDATORY field")).toBe(true);
});
it("inferCodeLanguage covers every heuristic branch", {
timeout: 5000,
}, () => {
expect(inferCodeLanguage("npm install foo")).toBe("bash");
expect(inferCodeLanguage("yarn add foo")).toBe("bash");
expect(inferCodeLanguage("npx run x")).toBe("bash");
expect(inferCodeLanguage("interface Foo { a: string }")).toBe(
"typescript"
);
expect(inferCodeLanguage("type X = { y: number }")).toBe("typescript");
expect(inferCodeLanguage("const x = 1")).toBe("javascript");
expect(inferCodeLanguage("let y = 1")).toBe("javascript");
expect(inferCodeLanguage("function z() {}")).toBe("javascript");
expect(inferCodeLanguage("def f():")).toBe("python");
expect(inferCodeLanguage("from m import f")).toBe("python");
expect(inferCodeLanguage("a = 1\nfrom m import f")).toBe("python");
expect(inferCodeLanguage("# Title\n## Sub")).toBe("markdown");
expect(inferCodeLanguage("git status")).toBe("bash");
expect(inferCodeLanguage("cd /tmp")).toBe("bash");
expect(inferCodeLanguage("a b\n cd /tmp")).toBe("bash");
expect(inferCodeLanguage("<!-- comment -->")).toBe("html");
expect(inferCodeLanguage("<div>x</div>")).toBe("html");
expect(inferCodeLanguage("<p>z</p>")).toBe("html");
expect(inferCodeLanguage('{ "a": 1 }')).toBe("json");
expect(inferCodeLanguage("zzz qqq aaa")).toBe("text");
});
it("fixBlankLines collapses consecutive blank paragraphs and pads blocks", {
timeout: 5000,
}, async () => {
const remarkParse = (await import("remark-parse")).default;
const remarkStringify = (await import("remark-stringify")).default;
const { unified } = await import("unified");
const { fixBlankLines, fixOrderedListNumbering } = await import(
"./rules.js"
);
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
const input =
"para a\n\n## heading\n\n1. item\n2. item\n\n```js\nconst x = 1\n```\nfollow-on para\n";
const tree = unified().use(remarkParse).parse(input);
// Inject a doubled empty-paragraph child to exercise the MD012
// branch in `ensureBlankLines` (line 150).
(tree as { children: unknown[] }).children.splice(
0,
0,
{ type: "paragraph", children: [] },
{ type: "paragraph", children: [] }
);
unified()
.use(fixBlankLines, sink)
.use(fixOrderedListNumbering, sink)
.runSync(tree as never);
const out = unified()
.use(remarkStringify, {
bullet: "-",
emphasis: "*",
fences: true,
incrementListMarker: true,
rule: "-",
})
.stringify(tree as never);
expect(applied.has("MD012")).toBe(true);
expect(out).toContain("## heading");
});
it("fixOrderedListNumbering resets start to 1 when not already 1", {
timeout: 5000,
}, async () => {
const remarkParse = (await import("remark-parse")).default;
const { unified } = await import("unified");
const { fixOrderedListNumbering } = await import("./rules.js");
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
const tree = unified().use(remarkParse).parse("5. one\n6. two\n");
unified().use(fixOrderedListNumbering, sink).runSync(tree);
expect(applied.has("MD029")).toBe(true);
});
it("fixEmphasisAsHeading no-ops when the strong paragraph is the root", {
timeout: 5000,
}, async () => {
// Construct a tree where the matching paragraph has no parent
// — exercises the `!parent` early-return guard.
const { fixEmphasisAsHeading, HEADING_HINT_EXTENDED } = await import(
"./rules.js"
);
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
// Hand-build a root that IS itself a paragraph-like shape so
// the visitor sees the paragraph at index 0 with parent=root,
// then we strip the parent linkage by recursing manually.
// Easiest path: call the transformer directly on a tree that
// is itself the matching paragraph (so visit invokes the
// callback with parent=undefined).
const fakeRoot = {
type: "paragraph",
children: [
{
type: "strong",
children: [{ type: "text", value: "NEVER" }],
},
],
};
const transformer = fixEmphasisAsHeading(sink, HEADING_HINT_EXTENDED);
transformer(fakeRoot as never);
expect(applied.has("MD036")).toBe(false);
});
it("fixEmphasisAsHeading bails out on multi-child strong", {
timeout: 5000,
}, async () => {
const { fixEmphasisAsHeading, HEADING_HINT_EXTENDED } = await import(
"./rules.js"
);
const { unified } = await import("unified");
const remarkParse = (await import("remark-parse")).default;
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
// Strong containing a link rather than a single text node →
// the inner-text guard short-circuits.
const tree = unified().use(remarkParse).parse("**[NEVER](x)**\n");
unified()
.use(fixEmphasisAsHeading, sink, HEADING_HINT_EXTENDED)
.runSync(tree);
expect(applied.has("MD036")).toBe(false);
});
it("fixHorizontalRules registers MD035 on a thematic break", {
timeout: 5000,
}, async () => {
const { fixHorizontalRules } = await import("./rules.js");
const { unified } = await import("unified");
const remarkParse = (await import("remark-parse")).default;
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
const tree = unified().use(remarkParse).parse("para\n\n---\n\nmore\n");
unified().use(fixHorizontalRules, sink).runSync(tree);
expect(applied.has("MD035")).toBe(true);
});
it("ensureBlankLines skips undefined children safely", {
timeout: 5000,
}, async () => {
const { fixBlankLines } = await import("./rules.js");
const { unified } = await import("unified");
const remarkParse = (await import("remark-parse")).default;
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
const tree = unified().use(remarkParse).parse("# A\n");
// Inject an undefined hole via splice with a sparse value.
const sparse: unknown[] = [];
sparse.length = 1;
(tree.children as unknown as unknown[]).unshift(sparse[0] as never);
unified().use(fixBlankLines, sink).runSync(tree);
// Pass — no throw and tree intact
expect(tree.children.length).toBeGreaterThan(0);
});
it("fixMultipleH1s ignores non-H1 headings", {
timeout: 5000,
}, async () => {
const { fixMultipleH1s } = await import("./rules.js");
const { unified } = await import("unified");
const remarkParse = (await import("remark-parse")).default;
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
const tree = unified().use(remarkParse).parse("## a\n\n## b\n\n## c\n");
unified().use(fixMultipleH1s, sink).runSync(tree);
expect(applied.has("MD025")).toBe(false);
});
it("ensureBlankLines leaves blocks alone when already padded by blanks", {
timeout: 5000,
}, async () => {
const { fixBlankLines } = await import("./rules.js");
const { unified } = await import("unified");
const remarkParse = (await import("remark-parse")).default;
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
// Pre-padded heading — both `prev` and `succ` are already
// blank paragraphs so the !succIsBlank branch goes false.
const tree = unified().use(remarkParse).parse("para a\n\n");
(tree.children as Array<unknown>).push(
{ type: "heading", depth: 2, children: [] },
{ type: "paragraph", children: [] },
{ type: "paragraph", children: [{ type: "text", value: "b" }] }
);
unified().use(fixBlankLines, sink).runSync(tree);
// The succ-side path now sees a blank paragraph and skips.
expect(tree.children.length).toBeGreaterThan(0);
});
it("fixOrderedListNumbering ignores unordered and empty lists", {
timeout: 5000,
}, async () => {
const { fixOrderedListNumbering } = await import("./rules.js");
const { unified } = await import("unified");
const remarkParse = (await import("remark-parse")).default;
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
// unordered list — `node.ordered === false` short-circuits.
const tree = unified().use(remarkParse).parse("- a\n- b\n");
unified().use(fixOrderedListNumbering, sink).runSync(tree);
expect(applied.has("MD029")).toBe(false);
});
it("fixCodeBlockLanguages skips blocks that already have a language", {
timeout: 5000,
}, async () => {
const { fixCodeBlockLanguages } = await import("./rules.js");
const { unified } = await import("unified");
const remarkParse = (await import("remark-parse")).default;
const applied = new Set<string>();
const sink = { add: (r: string) => applied.add(r) };
const tree = unified()
.use(remarkParse)
.parse("```ts\nconst x = 1\n```\n");
unified().use(fixCodeBlockLanguages, sink).runSync(tree);
expect(applied.has("MD040")).toBe(false);
});
it("inferCodeLanguage json requires all three markers", {
timeout: 5000,
}, async () => {
const { inferCodeLanguage } = await import("./rules.js");
// partial JSON-ish content lacking the colon falls back to text.
expect(inferCodeLanguage("{ aaa qqq }")).toBe("text");
});
it("PROPER_NAMES contains the documented entries", {
timeout: 5000,
}, () => {
const lower = PROPER_NAMES.map(([l]) => l);
expect(lower).toEqual([
"json",
"api",
"url",
"http",
"https",
"sql",
"css",
"html",
"xml",
"pdf",
]);
});
});

View File

@@ -0,0 +1,259 @@
import type {
Code,
Heading,
List,
ListItem,
Paragraph,
Parent,
Root,
RootContent,
Strong,
ThematicBreak,
} from "mdast";
import { visit } from "unist-util-visit";
/** Mutable rule sink — rule identifiers are added as plugins fire. */
export interface RuleSet {
add(rule: string): void;
}
/** Hint matcher used by both the basic and extended emphasis-as-heading fixers. */
export const HEADING_HINT_BASIC = /^(Step \d+:|[A-Z]\)|[A-Z][A-Z\s]+:)/;
export const HEADING_HINT_EXTENDED =
/^(Step \d+:|[A-Z]\)|[A-Z][A-Z\s]+:|NEVER|ALWAYS|CRITICAL|IMPORTANT|MANDATORY)/;
export const PROPER_NAMES: ReadonlyArray<readonly [string, string]> = [
["json", "JSON"],
["api", "API"],
["url", "URL"],
["http", "HTTP"],
["https", "HTTPS"],
["sql", "SQL"],
["css", "CSS"],
["html", "HTML"],
["xml", "XML"],
["pdf", "PDF"],
];
interface TextNode {
type: "text";
value: string;
}
/** Heuristic language inference for unannotated fenced code blocks. */
export function inferCodeLanguage(value: string): string {
const c = value.toLowerCase();
if (c.includes("npm ") || c.includes("npx ") || c.includes("yarn ")) {
return "bash";
}
if (
c.includes("interface ") ||
c.includes(": string") ||
c.includes(": number")
) {
return "typescript";
}
if (c.includes("const ") || c.includes("let ") || c.includes("function ")) {
return "javascript";
}
if (c.includes("def ") || c.startsWith("from ") || c.includes("\nfrom ")) {
return "python";
}
if (c.includes("# ") || c.includes("## ")) {
return "markdown";
}
if (c.includes("git ") || c.includes(" cd ") || c.startsWith("cd ")) {
return "bash";
}
if (c.includes("<!") || c.includes("<div") || c.includes("<p>")) {
return "html";
}
if (c.includes("{") && c.includes("}") && c.includes(":")) {
return "json";
}
return "text";
}
/** MD025 — demote duplicate H1s to H2. */
export function fixMultipleH1s(rules: RuleSet) {
return (tree: Root): void => {
let count = 0;
visit(tree, "heading", (node: Heading) => {
if (node.depth === 1) {
count += 1;
if (count > 1) {
node.depth = 2;
rules.add("MD025");
}
}
});
};
}
/** MD036 — rewrite a strong-only paragraph into an H3 when the text matches `matcher`. */
export function fixEmphasisAsHeading(rules: RuleSet, matcher: RegExp) {
return (tree: Root): void => {
visit(
tree,
"paragraph",
(node: Paragraph, index, parent: Parent | undefined) => {
const firstChild = node.children[0];
if (
node.children.length !== 1 ||
!firstChild ||
firstChild.type !== "strong"
) {
return;
}
const strong = firstChild as Strong;
const inner = strong.children[0];
if (
strong.children.length !== 1 ||
!inner ||
inner.type !== "text"
) {
return;
}
if (!matcher.test((inner as TextNode).value)) {
return;
}
// remark-util-visit always supplies `parent` and `index`
// for non-root nodes; paragraphs are never root, so both
// are guaranteed here.
if (!parent || typeof index !== "number") {
return;
}
const heading: Heading = {
type: "heading",
depth: 3,
children: strong.children,
};
parent.children[index] = heading;
rules.add("MD036");
}
);
};
}
function ensureBlankLines(parent: Parent, rules: RuleSet): void {
const children = parent.children;
const next: RootContent[] = [];
for (let i = 0; i < children.length; i += 1) {
const current = children[i];
if (!current) {
continue;
}
const prev = i > 0 ? children[i - 1] : undefined;
const succ = i < children.length - 1 ? children[i + 1] : undefined;
if (
current.type === "paragraph" &&
current.children.length === 0 &&
prev?.type === "paragraph" &&
(prev as Paragraph).children.length === 0
) {
rules.add("MD012");
continue;
}
const isBlock =
current.type === "list" ||
current.type === "heading" ||
current.type === "code";
if (prev && isBlock) {
const last = next[next.length - 1];
if (
last?.type !== "paragraph" ||
(last.type === "paragraph" && last.children.length > 0)
) {
next.push({ type: "paragraph", children: [] });
rules.add("MD032");
}
}
next.push(current);
if (succ && isBlock) {
const succIsBlank =
succ.type === "paragraph" &&
(succ as Paragraph).children.length === 0;
if (!succIsBlank) {
next.push({ type: "paragraph", children: [] });
rules.add("MD032");
}
}
}
parent.children = next;
}
/** MD012 / MD022 / MD031 / MD032 — ensure block elements are surrounded by blanks. */
export function fixBlankLines(rules: RuleSet) {
return (tree: Root): void => {
const visitAndFix = (node: Parent): void => {
for (const child of node.children) {
// Sparse arrays produce `undefined` children — skip them
// defensively to keep the in-operator guard safe.
if (child && "children" in child) {
visitAndFix(child as Parent);
}
}
ensureBlankLines(node, rules);
};
visitAndFix(tree);
};
}
/** MD044 — canonicalise proper-name capitalisation. */
export function fixProperNames(rules: RuleSet) {
return (tree: Root): void => {
visit(tree, "text", (node: TextNode) => {
let value = node.value;
for (const [lower, proper] of PROPER_NAMES) {
const regex = new RegExp(`\\b${lower}\\b`, "gi");
const after = value.replace(regex, proper);
if (after !== value) {
rules.add("MD044");
value = after;
}
}
node.value = value;
});
};
}
/** MD029 — restart ordered lists at 1 so remark-stringify renumbers correctly. */
export function fixOrderedListNumbering(rules: RuleSet) {
return (tree: Root): void => {
visit(tree, "list", (node: List) => {
if (node.ordered && node.children.length > 0) {
node.children.forEach((_item: ListItem, idx: number) => {
if (idx === 0 && node.start !== 1) {
node.start = 1;
rules.add("MD029");
}
});
}
});
};
}
/** MD035 — record thematic-break canonicalisation (remark-stringify formats `---`). */
export function fixHorizontalRules(rules: RuleSet) {
return (tree: Root): void => {
visit(tree, "thematicBreak", (_node: ThematicBreak) => {
rules.add("MD035");
});
};
}
/** MD040 — infer a language for unannotated fenced code blocks. */
export function fixCodeBlockLanguages(rules: RuleSet) {
return (tree: Root): void => {
visit(tree, "code", (node: Code) => {
if (
node.lang === undefined ||
node.lang === null ||
node.lang === ""
) {
node.lang = inferCodeLanguage(node.value);
rules.add("MD040");
}
});
};
}

View File

@@ -0,0 +1,15 @@
export type {
BiomeOutcome,
ProcessedResult,
RunPhase,
} from "./telemetry.js";
export {
biomeRunsCounter,
filesFixedCounter,
filesProcessedCounter,
recordBiomeRun,
recordFileFixed,
recordFileProcessed,
recordRunDuration,
runDurationHistogram,
} from "./telemetry.js";

View File

@@ -0,0 +1,105 @@
# `telemetry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/linter/telemetry` exposes the OpenTelemetry instruments and the record helpers
every other module uses to emit metrics during a lint run. The instruments are bound to
the `@suaveplan/linter` meter, which resolves lazily on first access — when no
`MeterProvider` is registered globally, OTel returns no-op instruments, so production
callers who don't opt into telemetry pay zero cost and never see a side effect. When a
provider is registered (e.g. via `@opentelemetry/sdk-metrics`), every fix application,
biome invocation, and phase duration is captured automatically.
## Features
- Four canonical instruments — `linter_files_fixed_total` (counter, tagged by rule),
`linter_files_processed_total` (counter, tagged by changed/unchanged result),
`linter_biome_runs_total` (counter, tagged by mode and outcome), and
`linter_run_duration_ms` (histogram, tagged by phase).
- Record helpers (`recordFileFixed`, `recordFileProcessed`, `recordBiomeRun`,
`recordRunDuration`) that callers prefer over reaching into the counters directly
— they encapsulate the attribute keys so the metric schema is the helper signature,
not a free-form bag of strings.
- Safe no-op behaviour when no `MeterProvider` is registered — `metrics.getMeter` returns
a no-op meter and every `counter.add` / `histogram.record` is a function call that
returns immediately.
## Usage: Basic
```ts
import { recordFileFixed } from "@suaveplan/linter/telemetry";
recordFileFixed("MD025");
```
## Usage: Advanced
Wire an in-memory exporter and assert on the emitted metrics:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { recordBiomeRun } from "@suaveplan/linter/telemetry";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
recordBiomeRun("check", "success");
await provider.forceFlush();
const collected = exporter.getMetrics();
// inspect `collected[*].scopeMetrics[*].metrics[*]`
```
## API Reference
### Counters
- `filesFixedCounter: Counter` — `linter_files_fixed_total{rule}`.
- `filesProcessedCounter: Counter` — `linter_files_processed_total{result}`.
- `biomeRunsCounter: Counter` — `linter_biome_runs_total{mode, outcome}`.
### Histogram
- `runDurationHistogram: Histogram` — `linter_run_duration_ms{phase}` (unit: `ms`).
### Helpers
- `recordFileFixed(rule: string): void`.
- `recordFileProcessed(result: "changed" | "unchanged"): void`.
- `recordBiomeRun(mode: string, outcome: "success" | "failure"): void`.
- `recordRunDuration(phase: "markdown" | "biome" | "total", ms: number): void`.
### Types
- `ProcessedResult = "changed" | "unchanged"`.
- `BiomeOutcome = "success" | "failure"`.
- `RunPhase = "markdown" | "biome" | "total"`.
## Implementation Notes
- The meter is resolved at module-evaluation time via `metrics.getMeter("@suaveplan/linter")`.
The OTel SDK contract states that subsequent `setGlobalMeterProvider` calls update the
underlying delegate, so callers can register a provider AFTER the module loads and the
instruments will still emit through it. The test polyfill demonstrates the pattern.
- The histogram is unit-tagged with `ms` so backend aggregators (Prometheus, OTLP) can
render durations in milliseconds without unit conversion.
- Rule strings emitted by `recordFileFixed` are the markdown-lint identifiers
(`MD012`, `MD022`, etc.). Keeping them as opaque strings rather than an enum lets
downstream dashboards group by rule without the linter owning the full markdownlint
catalogue.
- Phase keys (`markdown`, `biome`, `total`) are deliberately coarse. Finer-grained
instrumentation (per-file biome timings) would explode cardinality on large repos.

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
recordBiomeRun,
recordFileFixed,
recordFileProcessed,
recordRunDuration,
} from "./telemetry.js";
function getMetricPoints(
collected: Awaited<ReturnType<typeof flushTestMetrics>>,
name: string
) {
const points: Array<{
value: number;
attributes: Record<string, unknown>;
}> = [];
for (const rm of collected) {
for (const sm of rm.scopeMetrics) {
for (const metric of sm.metrics) {
if (metric.descriptor.name === name) {
for (const dp of metric.dataPoints) {
const value =
typeof dp.value === "number"
? dp.value
: (dp.value as { count: number }).count;
points.push({
value,
attributes: dp.attributes as Record<
string,
unknown
>,
});
}
}
}
}
}
return points;
}
describe("telemetry", () => {
it("record helpers do not throw with no provider registered", {
timeout: 5000,
}, () => {
// A provider IS registered in our test polyfill, but the
// helpers must still resolve to undefined and not throw on a
// fresh call — this protects production callers who opt out
// of telemetry.
expect(recordFileFixed("MD025")).toBeUndefined();
expect(recordFileProcessed("changed")).toBeUndefined();
expect(recordBiomeRun("check", "success")).toBeUndefined();
expect(recordRunDuration("total", 5)).toBeUndefined();
});
it("counter and histogram are wired and emit through real MeterProvider", {
timeout: 5000,
}, async () => {
recordFileFixed("MD025");
recordFileProcessed("changed");
recordBiomeRun("check", "success");
recordRunDuration("markdown", 12);
recordRunDuration("biome", 34);
recordRunDuration("total", 50);
const collected = await flushTestMetrics();
const fixed = getMetricPoints(collected, "linter_files_fixed_total");
const processed = getMetricPoints(
collected,
"linter_files_processed_total"
);
const biome = getMetricPoints(collected, "linter_biome_runs_total");
const duration = getMetricPoints(collected, "linter_run_duration_ms");
expect(fixed.some((p) => p.attributes["rule"] === "MD025")).toBe(true);
expect(
processed.some((p) => p.attributes["result"] === "changed")
).toBe(true);
expect(
biome.some(
(p) =>
p.attributes["mode"] === "check" &&
p.attributes["outcome"] === "success"
)
).toBe(true);
expect(duration.some((p) => p.attributes["phase"] === "total")).toBe(
true
);
});
});

View File

@@ -0,0 +1,71 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/linter";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns no-op
* instruments, so the helpers below are safe to call from production code
* that does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
export type ProcessedResult = "changed" | "unchanged";
export type BiomeOutcome = "success" | "failure";
export type RunPhase = "markdown" | "biome" | "total";
/** Counter incremented per rule application within `fixMarkdown`. */
export const filesFixedCounter: Counter = meter.createCounter(
"linter_files_fixed_total",
{
description:
"Markdown fixer rule applications, tagged with the rule identifier",
}
);
/** Counter incremented per file processed by the markdown fixer. */
export const filesProcessedCounter: Counter = meter.createCounter(
"linter_files_processed_total",
{
description:
"Markdown files processed, tagged with changed/unchanged result",
}
);
/** Counter incremented per biome invocation. */
export const biomeRunsCounter: Counter = meter.createCounter(
"linter_biome_runs_total",
{
description:
"Biome subprocess invocations, tagged with mode and success/failure",
}
);
/** Histogram of run durations, tagged with the orchestrator phase. */
export const runDurationHistogram: Histogram = meter.createHistogram(
"linter_run_duration_ms",
{
description: "Linter phase durations in milliseconds",
unit: "ms",
}
);
/** Record a single rule application within the markdown fixer. */
export function recordFileFixed(rule: string): void {
filesFixedCounter.add(1, { rule });
}
/** Record a per-file outcome from the markdown fixer. */
export function recordFileProcessed(result: ProcessedResult): void {
filesProcessedCounter.add(1, { result });
}
/** Record a biome invocation outcome. */
export function recordBiomeRun(mode: string, outcome: BiomeOutcome): void {
biomeRunsCounter.add(1, { mode, outcome });
}
/** Record a phase duration in milliseconds. */
export function recordRunDuration(phase: RunPhase, ms: number): void {
runDurationHistogram.record(ms, { phase });
}

View File

@@ -0,0 +1,15 @@
export type {
BiomeMode,
BiomeReport,
ClockLike,
CommandInput,
CommandResult,
CommandRunner,
FsAdapter,
GlobAdapter,
GlobOptions,
LintReport,
MarkdownFixReport,
RunCliOptions,
UnifiedLintOptions,
} from "./linter.types.js";

View File

@@ -0,0 +1,81 @@
# `types/linter.types`
## Purpose
`linter.types.ts` holds every public option, adapter, and report interface used across
the `@suaveplan/linter` package. Centralising them here means every feature module
references named, reviewable types rather than inlining object shapes in function
signatures (AGENTS.md §11), and consumers can import the canonical contract from a
single subpath. The file is types-only — it contains no runtime code — so it carries
zero bundle cost and is safely importable from any tier (the bundler erases it).
## Features
- `CommandRunner` / `CommandInput` / `CommandResult` — the injectable subprocess seam
the biome runner and orchestrator consume.
- `FsAdapter`, `GlobAdapter`, `GlobOptions` — filesystem and glob seams the markdown
fixer and resolver consume.
- `ClockLike` — `now(): number` seam for deterministic duration measurement.
- `MarkdownFixReport`, `BiomeReport`, `LintReport`, `BiomeMode` — every report shape
the package emits.
- `UnifiedLintOptions`, `RunCliOptions` — the orchestrator and CLI option bags.
## Usage: Basic
```ts
import type { CommandRunner, LintReport } from "@suaveplan/linter";
```
## Usage: Advanced
Implement a custom `CommandRunner` that records every invocation for use in a test
harness:
```ts
import type {
CommandInput,
CommandResult,
CommandRunner,
} from "@suaveplan/linter";
class RecordingRunner implements CommandRunner {
public readonly calls: CommandInput[] = [];
public async run(input: CommandInput): Promise<CommandResult> {
this.calls.push(input);
return { exitCode: 0, stdout: "", stderr: "" };
}
}
```
## API Reference
Every exported interface is `readonly` where mutability would compromise the contract:
- `CommandRunner.run(input): Promise<CommandResult>`
- `FsAdapter.{readFile, writeFile, exists}`
- `GlobAdapter.find(pattern, options): Promise<ReadonlyArray<string>>`
- `MarkdownFixReport.{file, changed, rulesApplied}`
- `BiomeReport.{mode, exitCode, stdout, stderr, success, durationMs}`
- `LintReport.{biome, markdown, success, durationMs}` — `biome` is `BiomeReport | undefined`.
- `BiomeMode = "check" | "check-write" | "format-write"`.
- `UnifiedLintOptions.{cwd, fix, markdownOnly, patterns, runner, fs, glob, clock}` —
all fields optional.
- `RunCliOptions extends UnifiedLintOptions` with an additional `out?: (line) => void`
sink.
## Implementation Notes
- Every nested shape is its own named interface (AGENTS.md §11) so a `CommandRunner`
signature is `(input: CommandInput) => Promise<CommandResult>` rather than
`({ command, args, ... }) => Promise<{ exitCode, ... }>`. Code review and IDE
navigation both work better against names than against literal shapes.
- The file deliberately stays at zero runtime cost — only `interface` / `type`
declarations. The bundler erases the file in the built output; the only way the
symbols surface is via `.d.ts`.
- The optional-field style (`runner?: CommandRunner`) interacts with the package's
`tsconfig.base.json`'s `exactOptionalPropertyTypes: true` — callers cannot pass
`runner: undefined` explicitly; they must omit the field. That keeps adapter
defaults pinned to the runtime `??` fallbacks in each feature module.
- The reports are deliberately flat (no nested unions) so they serialise cleanly via
`JSON.stringify` for use in CI summary files.

View File

@@ -0,0 +1,99 @@
/**
* Shared option, adapter, and report types for `@suaveplan/linter`.
*
* Every public surface in the package references these named interfaces — no
* inline object shapes in function signatures (AGENTS.md §11).
*/
/** Input passed to a {@link CommandRunner}. */
export interface CommandInput {
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly cwd?: string;
readonly env?: Readonly<Record<string, string>>;
}
/** Output produced by a {@link CommandRunner}. */
export interface CommandResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
/**
* Injectable command-execution seam. Production callers use the bundled
* `nodeCommandRunner` (wraps `node:child_process.spawn`); tests pass a fake
* runner that returns canned results.
*/
export interface CommandRunner {
run(input: CommandInput): Promise<CommandResult>;
}
/** Injectable filesystem seam used by the markdown fixer and resolver. */
export interface FsAdapter {
readFile(path: string): Promise<string>;
writeFile(path: string, data: string): Promise<void>;
exists(path: string): Promise<boolean>;
}
/** Options accepted by a {@link GlobAdapter}. */
export interface GlobOptions {
readonly ignore: ReadonlyArray<string>;
readonly cwd: string;
}
/** Injectable glob seam. The default wraps the `glob` npm package. */
export interface GlobAdapter {
find(pattern: string, options: GlobOptions): Promise<ReadonlyArray<string>>;
}
/** Injectable clock seam — defaults to `Date.now`. */
export interface ClockLike {
now(): number;
}
/** Per-file report produced by `fixMarkdown`. */
export interface MarkdownFixReport {
readonly file: string;
readonly changed: boolean;
readonly rulesApplied: ReadonlyArray<string>;
}
/** Mode controlling which biome subcommand is invoked. */
export type BiomeMode = "check" | "check-write" | "format-write";
/** Aggregate report from a single biome run. */
export interface BiomeReport {
readonly mode: BiomeMode;
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
readonly success: boolean;
readonly durationMs: number;
}
/** Outcome of `runUnifiedLint`. */
export interface LintReport {
readonly biome: BiomeReport | undefined;
readonly markdown: ReadonlyArray<MarkdownFixReport>;
readonly success: boolean;
readonly durationMs: number;
}
/** Options accepted by the unified orchestrator and the CLI. */
export interface UnifiedLintOptions {
readonly cwd?: string;
readonly fix?: boolean;
readonly markdownOnly?: boolean;
readonly patterns?: ReadonlyArray<string>;
readonly runner?: CommandRunner;
readonly fs?: FsAdapter;
readonly glob?: GlobAdapter;
readonly clock?: ClockLike;
}
/** Options for `runCli`. */
export interface RunCliOptions extends UnifiedLintOptions {
/** Sink for help / error / summary output. Defaults to `console.log`. */
readonly out?: (line: string) => void;
}

View File

@@ -0,0 +1 @@
export { runUnifiedLint } from "./unified-lint.js";

View File

@@ -0,0 +1,122 @@
# `unified-lint`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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<LintReport>`
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<string>` — 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.

View File

@@ -0,0 +1,308 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
CommandInput,
CommandResult,
CommandRunner,
FsAdapter,
GlobAdapter,
} from "../types/linter.types.js";
import { runUnifiedLint } from "./unified-lint.js";
function makeFs(seed: Record<string, string>): FsAdapter {
const store = new Map<string, string>(Object.entries(seed));
return {
async readFile(path) {
const v = store.get(path);
if (v === undefined) {
throw new Error("ENOENT");
}
return v;
},
async writeFile(path, data) {
store.set(path, data);
},
async exists(path) {
return store.has(path);
},
};
}
function makeGlob(files: ReadonlyArray<string>): GlobAdapter {
return {
async find() {
return files;
},
};
}
interface Recorder {
runner: CommandRunner;
calls: CommandInput[];
}
function makeRunner(sequence: ReadonlyArray<CommandResult>): Recorder {
let index = 0;
const calls: CommandInput[] = [];
const runner: CommandRunner = {
async run(input) {
calls.push(input);
const r = sequence[index] ?? sequence[sequence.length - 1];
index += 1;
if (!r) {
throw new Error("no canned result");
}
return r;
},
};
return { runner, calls };
}
describe("runUnifiedLint", () => {
it("in fix mode runs biome check-write and markdown processor", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "a.md": "# A\n# B\n" });
const glob = makeGlob(["a.md"]);
const { runner, calls } = makeRunner([
{ exitCode: 0, stdout: "", stderr: "" }, // probe
{ exitCode: 0, stdout: "", stderr: "" }, // check-write
]);
const report = await runUnifiedLint({
fix: true,
cwd: ".",
runner,
fs,
glob,
});
expect(report.biome?.mode).toBe("check-write");
expect(report.markdown).toHaveLength(1);
expect(report.success).toBe(true);
expect(calls[1]?.args).toContain("--write");
});
it("in check mode runs biome check and processes no writes", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "a.md": "# A\n# B\n" });
const glob = makeGlob(["a.md"]);
const { runner } = makeRunner([
{ exitCode: 0, stdout: "", stderr: "" },
{ exitCode: 0, stdout: "", stderr: "" },
]);
const report = await runUnifiedLint({
fix: false,
cwd: ".",
runner,
fs,
glob,
});
expect(report.biome?.mode).toBe("check");
});
it("markdownOnly skips biome entirely", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
let runnerInvoked = false;
const runner: CommandRunner = {
async run() {
runnerInvoked = true;
return { exitCode: 0, stdout: "", stderr: "" };
},
};
const report = await runUnifiedLint({
markdownOnly: true,
runner,
fs,
glob,
});
expect(report.biome).toBeUndefined();
expect(runnerInvoked).toBe(false);
});
it("biome unavailable yields biome undefined and success based on markdown", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const { runner } = makeRunner([
{ exitCode: 127, stdout: "", stderr: "missing" },
]);
const report = await runUnifiedLint({
runner,
fs,
glob,
});
expect(report.biome).toBeUndefined();
expect(report.success).toBe(true);
});
it("orchestrator never calls process exit", { timeout: 5000 }, async () => {
// The function returning a resolved promise IS the assertion —
// if it called process.exit the test runner would die.
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const { runner } = makeRunner([
{ exitCode: 127, stdout: "", stderr: "" },
]);
const report = await runUnifiedLint({
runner,
fs,
glob,
});
expect(report).toBeDefined();
});
it("reflects biome failure in check-write mode as success=false", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const { runner } = makeRunner([
{ exitCode: 0, stdout: "", stderr: "" }, // probe
{ exitCode: 1, stdout: "", stderr: "boom" }, // check-write
]);
const report = await runUnifiedLint({
fix: true,
runner,
fs,
glob,
});
expect(report.biome?.success).toBe(false);
expect(report.success).toBe(false);
});
it("propagates BIOME_FAILED to the caller in check mode", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const { runner } = makeRunner([
{ exitCode: 0, stdout: "", stderr: "" }, // probe
{ exitCode: 1, stdout: "", stderr: "boom" }, // check
]);
// The orchestrator's catch handler in check mode surfaces the
// embedded report rather than re-throwing — so the contract is
// a failing report, not an exception.
const report = await runUnifiedLint({
fix: false,
runner,
fs,
glob,
});
expect(report.success).toBe(false);
expect(report.biome?.success).toBe(false);
});
it("uses default cwd when not supplied", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const { runner, calls } = makeRunner([
{ exitCode: 0, stdout: "", stderr: "" },
{ exitCode: 0, stdout: "", stderr: "" },
]);
await runUnifiedLint({ runner, fs, glob });
expect(calls[0]?.cwd).toBe(".");
});
it("uses default runner when none supplied and markdownOnly true", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const report = await runUnifiedLint({
markdownOnly: true,
fs,
glob,
});
expect(report.biome).toBeUndefined();
});
it("uses default options object when invoked with no args at all", {
timeout: 30000,
}, async () => {
// Exercise the `options: UnifiedLintOptions = {}` default. Run
// from a temp dir so the markdown lane finds no targets and
// raises NO_TARGETS_FOUND — that satisfies the spec contract
// and exercises the default-options branch.
const tmp = await import("node:fs/promises");
const path = await import("node:path");
const dir = await tmp.mkdtemp(
path.join(process.cwd(), ".linter-defopt-")
);
const before = process.cwd();
try {
process.chdir(dir);
await expect(runUnifiedLint()).rejects.toBeDefined();
} finally {
process.chdir(before);
await tmp.rm(dir, { recursive: true });
}
});
it("uses default options object when invoked with no args", {
timeout: 30000,
}, async () => {
// markdownOnly is false by default, runner is nodeCommandRunner —
// probe will likely succeed in dev (bunx biome installed) but the
// biome run itself may fail; either way the orchestrator returns
// a LintReport OR throws BIOME_FAILED, which is what this
// scenario asserts.
const tmp = await import("node:fs/promises");
const path = await import("node:path");
const dir = await tmp.mkdtemp(
path.join(process.cwd(), ".linter-unif-tmp-")
);
const before = process.cwd();
try {
const file = path.join(dir, "a.md");
await tmp.writeFile(file, "# A\n", "utf-8");
process.chdir(dir);
try {
const report = await runUnifiedLint({});
expect(report).toBeDefined();
} catch (err) {
// Real biome may be unavailable or may not lint a
// freshly-created dir cleanly; either resolution or a
// LinterError raised by the lane is acceptable for
// exercising the default-runner branch.
expect((err as Error).message).toBeDefined();
}
} finally {
process.chdir(before);
await tmp.rm(dir, { recursive: true });
}
});
it("propagates non-BIOME_FAILED runner errors from the biome lane", {
timeout: 5000,
}, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
let callCount = 0;
const runner: CommandRunner = {
async run() {
callCount += 1;
if (callCount === 1) {
// probe succeeds
return { exitCode: 0, stdout: "", stderr: "" };
}
// runBiome's runner rejects → RUNNER_FAILED
throw new Error("spawn fail");
},
};
await expect(
runUnifiedLint({ fix: false, runner, fs, glob })
).rejects.toMatchObject({ code: "LINTER.RUNNER_FAILED" });
});
it("uses default clock when not supplied", { timeout: 5000 }, async () => {
const fs = makeFs({ "a.md": "# A\n" });
const glob = makeGlob(["a.md"]);
const { runner } = makeRunner([
{ exitCode: 0, stdout: "", stderr: "" },
{ exitCode: 0, stdout: "", stderr: "" },
]);
const report = await runUnifiedLint({ runner, fs, glob });
expect(report.durationMs).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,89 @@
import {
nodeCommandRunner,
probeBiomeAvailable,
runBiome,
} from "../biome-runner/biome-runner.js";
import { LinterError } from "../error-codes/error-codes.js";
import { nodeGlobAdapter } from "../glob-resolver/glob-resolver.js";
import {
nodeFsAdapter,
processMarkdownFiles,
} from "../markdown-fixer/markdown-fixer.js";
import { recordRunDuration } from "../telemetry/telemetry.js";
import type {
BiomeReport,
ClockLike,
LintReport,
MarkdownFixReport,
UnifiedLintOptions,
} from "../types/linter.types.js";
const defaultClock: ClockLike = { now: () => Date.now() };
async function runBiomeLane(
options: UnifiedLintOptions
): Promise<BiomeReport | undefined> {
const runner = options.runner ?? nodeCommandRunner;
const cwd = options.cwd ?? ".";
const available = await probeBiomeAvailable({ runner, cwd });
if (!available) {
return undefined;
}
const mode = options.fix === true ? "check-write" : "check";
try {
return await runBiome({ cwd, mode, runner });
} catch (err) {
// `runBiome` only raises `BIOME_FAILED` in `check` mode AND always
// attaches the parsed `BiomeReport` under `details.report`, so this
// branch is the documented surface that surfaces the embedded
// report instead of re-throwing. All other errors propagate.
if (err instanceof LinterError && err.code === "LINTER.BIOME_FAILED") {
return err.details["report"] as BiomeReport;
}
throw err;
}
}
async function runMarkdownLane(
options: UnifiedLintOptions,
clock: ClockLike
): Promise<{
reports: MarkdownFixReport[];
durationMs: number;
}> {
const start = clock.now();
const reports = await processMarkdownFiles({
cwd: options.cwd ?? ".",
fs: options.fs ?? nodeFsAdapter,
glob: options.glob ?? nodeGlobAdapter,
...(options.patterns ? { patterns: options.patterns } : {}),
});
const durationMs = Math.max(0, clock.now() - start);
recordRunDuration("markdown", durationMs);
return { reports, durationMs };
}
/**
* Orchestrator. Runs the biome lane (if available) and the markdown lane,
* aggregates them into a {@link LintReport}, and returns. NEVER calls
* `process.exit` — translation to an exit code is the CLI's job.
*/
export async function runUnifiedLint(
options: UnifiedLintOptions = {}
): Promise<LintReport> {
const clock = options.clock ?? defaultClock;
const start = clock.now();
const biome =
options.markdownOnly === true ? undefined : await runBiomeLane(options);
const { reports } = await runMarkdownLane(options, clock);
const durationMs = Math.max(0, clock.now() - start);
const biomeSuccess = biome?.success ?? true;
const success = biomeSuccess;
recordRunDuration("total", durationMs);
return {
biome,
markdown: reports,
success,
durationMs,
};
}

View File

@@ -0,0 +1,43 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(__dirname, "..");
const packageJson = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
) as {
sideEffects: unknown;
bin: Record<string, string>;
exports: Record<string, unknown>;
};
describe("package shape", () => {
it("every feature folder has a matching subpath export", {
timeout: 5000,
}, () => {
const srcDir = join(PKG_ROOT, "src");
const featureFolders = readdirSync(srcDir, {
withFileTypes: true,
})
.filter(
(e) => e.isDirectory() && e.name !== "types" // shared types — no subpath
)
.map((e) => e.name)
.sort();
const subpathKeys = Object.keys(packageJson.exports)
.filter((k) => k.startsWith("./") && k !== "./package.json")
.map((k) => k.replace(/^\.\//, ""))
.filter((k) => k !== "" && !k.includes("."));
for (const folder of featureFolders) {
expect(subpathKeys).toContain(folder);
}
});
it("sideEffects is false", { timeout: 5000 }, () => {
expect(packageJson.sideEffects).toBe(false);
});
it("bin entry is registered", { timeout: 5000 }, () => {
expect(packageJson.bin["suaveplan-lint"]).toBe("./dist/bin.js");
});
});

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/linter.
*
* Registers a real in-memory OpenTelemetry MeterProvider BEFORE source
* modules import so the global meter resolved on first use binds to it.
* The linter package is server-tier and uses node:fs / node:child_process —
* no DOM shims required.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/devtools-tooling-linter",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
bin: "src/bin.ts",
"markdown-fixer/index": "src/markdown-fixer/index.ts",
"biome-runner/index": "src/biome-runner/index.ts",
"glob-resolver/index": "src/glob-resolver/index.ts",
"unified-lint/index": "src/unified-lint/index.ts",
"cli/index": "src/cli/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"glob",
"remark-parse",
"remark-stringify",
"tslib",
"unified",
"unist-util-visit",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,34 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/bin.ts",
"src/types/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
hookTimeout: 5000,
},
});