first commit
This commit is contained in:
86
docs/devtools/tooling/linter/README.md
Normal file
86
docs/devtools/tooling/linter/README.md
Normal 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.
|
||||
|
||||
60
docs/devtools/tooling/linter/src/bin.md
Normal file
60
docs/devtools/tooling/linter/src/bin.md
Normal 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.
|
||||
117
docs/devtools/tooling/linter/src/biome-runner/biome-runner.md
Normal file
117
docs/devtools/tooling/linter/src/biome-runner/biome-runner.md
Normal 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.
|
||||
93
docs/devtools/tooling/linter/src/cli/cli.md
Normal file
93
docs/devtools/tooling/linter/src/cli/cli.md
Normal 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.
|
||||
112
docs/devtools/tooling/linter/src/error-codes/error-codes.md
Normal file
112
docs/devtools/tooling/linter/src/error-codes/error-codes.md
Normal 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.
|
||||
117
docs/devtools/tooling/linter/src/glob-resolver/glob-resolver.md
Normal file
117
docs/devtools/tooling/linter/src/glob-resolver/glob-resolver.md
Normal 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).
|
||||
@@ -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.
|
||||
114
docs/devtools/tooling/linter/src/markdown-fixer/rules.md
Normal file
114
docs/devtools/tooling/linter/src/markdown-fixer/rules.md
Normal 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.
|
||||
105
docs/devtools/tooling/linter/src/telemetry/telemetry.md
Normal file
105
docs/devtools/tooling/linter/src/telemetry/telemetry.md
Normal 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.
|
||||
81
docs/devtools/tooling/linter/src/types/linter.types.md
Normal file
81
docs/devtools/tooling/linter/src/types/linter.types.md
Normal 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.
|
||||
122
docs/devtools/tooling/linter/src/unified-lint/unified-lint.md
Normal file
122
docs/devtools/tooling/linter/src/unified-lint/unified-lint.md
Normal 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.
|
||||
Reference in New Issue
Block a user