first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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,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.