Initial Commit
This commit is contained in:
86
packages/devtools/tooling/linter/README.md
Normal file
86
packages/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.
|
||||
|
||||
115
packages/devtools/tooling/linter/package.json
Normal file
115
packages/devtools/tooling/linter/package.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": "@suaveplan/linter",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Unified markdown + biome linting pipeline: remark-based markdown fixer (MD012/022/025/029/031/032/035/036/040/044), injectable CommandRunner-backed biome wrapper, glob resolver, orchestrator with structured LintReport, pure runCli, LINTER_CODES error registry, and OTel self-telemetry.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"suaveplan-lint": "./dist/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./markdown-fixer": {
|
||||
"import": {
|
||||
"types": "./dist/markdown-fixer/index.d.ts",
|
||||
"default": "./dist/markdown-fixer/index.js"
|
||||
}
|
||||
},
|
||||
"./biome-runner": {
|
||||
"import": {
|
||||
"types": "./dist/biome-runner/index.d.ts",
|
||||
"default": "./dist/biome-runner/index.js"
|
||||
}
|
||||
},
|
||||
"./glob-resolver": {
|
||||
"import": {
|
||||
"types": "./dist/glob-resolver/index.d.ts",
|
||||
"default": "./dist/glob-resolver/index.js"
|
||||
}
|
||||
},
|
||||
"./unified-lint": {
|
||||
"import": {
|
||||
"types": "./dist/unified-lint/index.d.ts",
|
||||
"default": "./dist/unified-lint/index.js"
|
||||
}
|
||||
},
|
||||
"./cli": {
|
||||
"import": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"default": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/index.d.ts",
|
||||
"default": "./dist/telemetry/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"glob": "catalog:markdown",
|
||||
"remark-parse": "catalog:markdown",
|
||||
"remark-stringify": "catalog:markdown",
|
||||
"tslib": "catalog:build",
|
||||
"unified": "catalog:markdown",
|
||||
"unist-util-visit": "catalog:markdown",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "^1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/glob": "catalog:markdown",
|
||||
"@types/mdast": "catalog:markdown",
|
||||
"@types/node": "catalog:",
|
||||
"@types/unist": "catalog:markdown",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
60
packages/devtools/tooling/linter/src/bin.md
Normal file
60
packages/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.
|
||||
14
packages/devtools/tooling/linter/src/bin.ts
Normal file
14
packages/devtools/tooling/linter/src/bin.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
import { runCli } from "./cli/cli.js";
|
||||
|
||||
runCli(process.argv.slice(2)).then(
|
||||
({ code }) => {
|
||||
process.exit(code);
|
||||
},
|
||||
(err) => {
|
||||
// Surface unexpected exceptions (e.g. an unhandled non-LinterError)
|
||||
// to the operator and exit non-zero.
|
||||
process.stderr.write(`${String(err)}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
);
|
||||
@@ -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.
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
CommandInput,
|
||||
CommandResult,
|
||||
CommandRunner,
|
||||
} from "../types/linter.types.js";
|
||||
import {
|
||||
nodeCommandRunner,
|
||||
probeBiomeAvailable,
|
||||
runBiome,
|
||||
} from "./biome-runner.js";
|
||||
|
||||
function makeRecorder(result: CommandResult): {
|
||||
runner: CommandRunner;
|
||||
calls: CommandInput[];
|
||||
} {
|
||||
const calls: CommandInput[] = [];
|
||||
const runner: CommandRunner = {
|
||||
async run(input) {
|
||||
calls.push(input);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
return { runner, calls };
|
||||
}
|
||||
|
||||
const okResult: CommandResult = { exitCode: 0, stdout: "ok", stderr: "" };
|
||||
const failResult: CommandResult = {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "boom",
|
||||
};
|
||||
|
||||
describe("runBiome", () => {
|
||||
it("passes the correct args for check mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner, calls } = makeRecorder(okResult);
|
||||
await runBiome({ cwd: ".", mode: "check", runner });
|
||||
expect(calls[0]?.command).toBe("bunx");
|
||||
expect(calls[0]?.args.slice(0, 3)).toEqual(["biome", "check", "."]);
|
||||
});
|
||||
|
||||
it("check-write mode forwards --write and --unsafe", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner, calls } = makeRecorder(okResult);
|
||||
await runBiome({ cwd: ".", mode: "check-write", runner });
|
||||
expect(calls[0]?.args).toContain("--write");
|
||||
expect(calls[0]?.args).toContain("--unsafe");
|
||||
});
|
||||
|
||||
it("format-write mode runs biome format", { timeout: 5000 }, async () => {
|
||||
const { runner, calls } = makeRecorder(okResult);
|
||||
await runBiome({ cwd: ".", mode: "format-write", runner });
|
||||
expect(calls[0]?.args[1]).toBe("format");
|
||||
expect(calls[0]?.args).toContain("--write");
|
||||
});
|
||||
|
||||
it("report carries exit code and stdout", { timeout: 5000 }, async () => {
|
||||
const { runner } = makeRecorder(okResult);
|
||||
const report = await runBiome({
|
||||
cwd: ".",
|
||||
mode: "check",
|
||||
runner,
|
||||
});
|
||||
expect(report.success).toBe(true);
|
||||
expect(report.exitCode).toBe(0);
|
||||
expect(report.stdout).toBe("ok");
|
||||
expect(report.durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(report.mode).toBe("check");
|
||||
});
|
||||
|
||||
it("raises BIOME_FAILED in check mode on non-zero exit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = makeRecorder(failResult);
|
||||
await expect(
|
||||
runBiome({ cwd: ".", mode: "check", runner })
|
||||
).rejects.toMatchObject({
|
||||
code: "LINTER.BIOME_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("check-write mode returns non-zero report without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = makeRecorder(failResult);
|
||||
const report = await runBiome({
|
||||
cwd: ".",
|
||||
mode: "check-write",
|
||||
runner,
|
||||
});
|
||||
expect(report.success).toBe(false);
|
||||
expect(report.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("raises RUNNER_FAILED when the runner rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner: CommandRunner = {
|
||||
async run() {
|
||||
throw new Error("spawn error");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
runBiome({ cwd: ".", mode: "check", runner })
|
||||
).rejects.toMatchObject({ code: "LINTER.RUNNER_FAILED" });
|
||||
});
|
||||
|
||||
it("raises INVALID_OPTIONS on a bad mode", { timeout: 5000 }, async () => {
|
||||
const { runner } = makeRecorder(okResult);
|
||||
await expect(
|
||||
runBiome({
|
||||
cwd: ".",
|
||||
mode: "nope" as "check",
|
||||
runner,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "LINTER.INVALID_OPTIONS" });
|
||||
});
|
||||
|
||||
it("raises INVALID_OPTIONS when the runner is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
runBiome({
|
||||
cwd: ".",
|
||||
mode: "check",
|
||||
// biome doesn't accept the test cast; force-cast to unknown
|
||||
runner: undefined as unknown as CommandRunner,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "LINTER.INVALID_OPTIONS" });
|
||||
});
|
||||
|
||||
it("uses the injected clock for duration measurement", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = makeRecorder(okResult);
|
||||
let n = 0;
|
||||
const clock = {
|
||||
now: () => {
|
||||
n += 7;
|
||||
return n;
|
||||
},
|
||||
};
|
||||
const report = await runBiome({
|
||||
cwd: ".",
|
||||
mode: "check",
|
||||
runner,
|
||||
clock,
|
||||
});
|
||||
expect(report.durationMs).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeBiomeAvailable", () => {
|
||||
it("returns true when biome --version succeeds", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { runner } = makeRecorder(okResult);
|
||||
await expect(probeBiomeAvailable({ runner })).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on non-zero exit", { timeout: 5000 }, async () => {
|
||||
const { runner } = makeRecorder({
|
||||
exitCode: 127,
|
||||
stdout: "",
|
||||
stderr: "missing",
|
||||
});
|
||||
await expect(probeBiomeAvailable({ runner })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the runner rejects", { timeout: 5000 }, async () => {
|
||||
const runner: CommandRunner = {
|
||||
async run() {
|
||||
throw new Error("ENOENT");
|
||||
},
|
||||
};
|
||||
await expect(probeBiomeAvailable({ runner })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an explicit cwd", { timeout: 5000 }, async () => {
|
||||
const { runner, calls } = makeRecorder(okResult);
|
||||
await probeBiomeAvailable({ runner, cwd: "/tmp" });
|
||||
expect(calls[0]?.cwd).toBe("/tmp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeCommandRunner", () => {
|
||||
it("runs `node -e` and reports stdout + exit zero", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await nodeCommandRunner.run({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdout.write('hello'); process.exit(0)"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("hello");
|
||||
});
|
||||
|
||||
it("captures stderr and non-zero exit", { timeout: 5000 }, async () => {
|
||||
const result = await nodeCommandRunner.run({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stderr.write('oops'); process.exit(2)"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toBe("oops");
|
||||
});
|
||||
|
||||
it("merges env overrides into the child process", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await nodeCommandRunner.run({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(process.env.LINTER_TEST_FLAG ?? 'missing')",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
env: { LINTER_TEST_FLAG: "yes" },
|
||||
});
|
||||
expect(result.stdout).toBe("yes");
|
||||
});
|
||||
|
||||
it("treats a killed child (null exit code) as exitCode 1", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// SIGKILL the child during spawn so the close handler fires
|
||||
// with exitCode === null and the `?? 1` fallback triggers.
|
||||
const { spawn } = await import("node:child_process");
|
||||
const original = spawn;
|
||||
// We can't monkey-patch the module-level spawn the runner
|
||||
// uses without affecting other tests. Instead, call the runner
|
||||
// with a self-killing wrapper that has the parent send itself
|
||||
// SIGKILL after stdio is open.
|
||||
const result = await nodeCommandRunner.run({
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.kill(process.pid, 'SIGKILL')"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
// Either exitCode === 1 (fallback fired) OR exitCode === 137
|
||||
// (raw signal exit on some platforms). Both confirm the close
|
||||
// handler resolved successfully.
|
||||
expect([1, 137, 9]).toContain(result.exitCode);
|
||||
// touch `original` to silence unused-var lint
|
||||
expect(typeof original).toBe("function");
|
||||
});
|
||||
|
||||
it("rejects when the command does not exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
nodeCommandRunner.run({
|
||||
command: "/definitely-not-a-real-binary-12345",
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import z from "zod";
|
||||
import { LINTER_CODES, LinterError } from "../error-codes/error-codes.js";
|
||||
import { recordBiomeRun, recordRunDuration } from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
BiomeMode,
|
||||
BiomeReport,
|
||||
ClockLike,
|
||||
CommandResult,
|
||||
CommandRunner,
|
||||
} from "../types/linter.types.js";
|
||||
|
||||
const BIOME_MODE_SCHEMA = z.enum(["check", "check-write", "format-write"]);
|
||||
|
||||
const RUN_BIOME_OPTIONS_SCHEMA = z.object({
|
||||
cwd: z.string(),
|
||||
mode: BIOME_MODE_SCHEMA,
|
||||
runner: z.custom<CommandRunner>(
|
||||
(v) =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
typeof (v as CommandRunner).run === "function"
|
||||
),
|
||||
clock: z
|
||||
.custom<ClockLike>(
|
||||
(v) =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
typeof (v as ClockLike).now === "function"
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const defaultClock: ClockLike = { now: () => Date.now() };
|
||||
|
||||
/** Default `CommandRunner` wrapping `node:child_process.spawn`. */
|
||||
export const nodeCommandRunner: CommandRunner = {
|
||||
async run({ command, args, cwd, env }) {
|
||||
return await new Promise<CommandResult>((resolve, reject) => {
|
||||
const child = spawn(command, [...args], {
|
||||
cwd,
|
||||
env: env ? { ...process.env, ...env } : { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => {
|
||||
resolve({
|
||||
exitCode: exitCode ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function argsForMode(mode: BiomeMode): ReadonlyArray<string> {
|
||||
switch (mode) {
|
||||
case "check":
|
||||
return ["biome", "check", "."];
|
||||
case "check-write":
|
||||
return [
|
||||
"biome",
|
||||
"check",
|
||||
"--write",
|
||||
"--unsafe",
|
||||
"--max-diagnostics",
|
||||
"500",
|
||||
".",
|
||||
];
|
||||
case "format-write":
|
||||
return [
|
||||
"biome",
|
||||
"format",
|
||||
"--write",
|
||||
"--max-diagnostics",
|
||||
"500",
|
||||
".",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Options accepted by {@link runBiome}. */
|
||||
export interface RunBiomeOptions {
|
||||
readonly cwd: string;
|
||||
readonly mode: BiomeMode;
|
||||
readonly runner: CommandRunner;
|
||||
readonly clock?: ClockLike;
|
||||
}
|
||||
|
||||
async function executeRunner(
|
||||
runner: CommandRunner,
|
||||
args: ReadonlyArray<string>,
|
||||
cwd: string
|
||||
): Promise<CommandResult> {
|
||||
try {
|
||||
return await runner.run({
|
||||
command: "bunx",
|
||||
args,
|
||||
cwd,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.RUNNER_FAILED,
|
||||
"Command runner rejected before completion",
|
||||
{ cause, details: { cwd } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Biome through the injectable `CommandRunner`. Returns a
|
||||
* {@link BiomeReport} carrying the parsed exit code, stdout / stderr, and a
|
||||
* measured duration. In `"check"` mode a non-zero exit code raises
|
||||
* `LinterError(LINTER.BIOME_FAILED)` carrying the report under `details`.
|
||||
* Invalid `mode` raises `LinterError(LINTER.INVALID_OPTIONS)`.
|
||||
*/
|
||||
export async function runBiome(options: RunBiomeOptions): Promise<BiomeReport> {
|
||||
const parsed = RUN_BIOME_OPTIONS_SCHEMA.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.INVALID_OPTIONS,
|
||||
"Invalid runBiome options",
|
||||
{ details: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const { cwd, mode, runner } = parsed.data;
|
||||
const clock = parsed.data.clock ?? defaultClock;
|
||||
const args = argsForMode(mode);
|
||||
const start = clock.now();
|
||||
const result = await executeRunner(runner, args, cwd);
|
||||
const durationMs = Math.max(0, clock.now() - start);
|
||||
const success = result.exitCode === 0;
|
||||
const report: BiomeReport = {
|
||||
mode,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
success,
|
||||
durationMs,
|
||||
};
|
||||
recordBiomeRun(mode, success ? "success" : "failure");
|
||||
recordRunDuration("biome", durationMs);
|
||||
if (!success && mode === "check") {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.BIOME_FAILED,
|
||||
`Biome check failed (exit ${result.exitCode})`,
|
||||
{ details: { report } }
|
||||
);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Options accepted by {@link probeBiomeAvailable}. */
|
||||
export interface ProbeBiomeAvailableOptions {
|
||||
readonly runner: CommandRunner;
|
||||
readonly cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether `bunx biome --version` resolves successfully. Returns
|
||||
* `true` only on `exitCode === 0`; rejections from the runner resolve to
|
||||
* `false` so the probe is non-fatal.
|
||||
*/
|
||||
export async function probeBiomeAvailable(
|
||||
options: ProbeBiomeAvailableOptions
|
||||
): Promise<boolean> {
|
||||
const cwd = options.cwd ?? ".";
|
||||
try {
|
||||
const result = await options.runner.run({
|
||||
command: "bunx",
|
||||
args: ["biome", "--version"],
|
||||
cwd,
|
||||
});
|
||||
return result.exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
ProbeBiomeAvailableOptions,
|
||||
RunBiomeOptions,
|
||||
} from "./biome-runner.js";
|
||||
export {
|
||||
nodeCommandRunner,
|
||||
probeBiomeAvailable,
|
||||
runBiome,
|
||||
} from "./biome-runner.js";
|
||||
93
packages/devtools/tooling/linter/src/cli/cli.md
Normal file
93
packages/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.
|
||||
304
packages/devtools/tooling/linter/src/cli/cli.test.ts
Normal file
304
packages/devtools/tooling/linter/src/cli/cli.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
CommandInput,
|
||||
CommandResult,
|
||||
CommandRunner,
|
||||
FsAdapter,
|
||||
GlobAdapter,
|
||||
} from "../types/linter.types.js";
|
||||
import { runCli } from "./cli.js";
|
||||
|
||||
function makeFs(seed: Record<string, string>): FsAdapter {
|
||||
const store = new Map<string, string>(Object.entries(seed));
|
||||
return {
|
||||
async readFile(path) {
|
||||
const v = store.get(path);
|
||||
if (v === undefined) {
|
||||
throw new Error("ENOENT");
|
||||
}
|
||||
return v;
|
||||
},
|
||||
async writeFile(path, data) {
|
||||
store.set(path, data);
|
||||
},
|
||||
async exists(path) {
|
||||
return store.has(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeGlob(files: ReadonlyArray<string>): {
|
||||
glob: GlobAdapter;
|
||||
captured: { pattern: string };
|
||||
} {
|
||||
const captured = { pattern: "" };
|
||||
const glob: GlobAdapter = {
|
||||
async find(pattern) {
|
||||
captured.pattern = pattern;
|
||||
return files;
|
||||
},
|
||||
};
|
||||
return { glob, captured };
|
||||
}
|
||||
|
||||
function makeRunner(sequence: ReadonlyArray<CommandResult>): {
|
||||
runner: CommandRunner;
|
||||
calls: CommandInput[];
|
||||
} {
|
||||
let index = 0;
|
||||
const calls: CommandInput[] = [];
|
||||
const runner: CommandRunner = {
|
||||
async run(input) {
|
||||
calls.push(input);
|
||||
const r = sequence[index] ?? sequence[sequence.length - 1];
|
||||
index += 1;
|
||||
if (!r) {
|
||||
throw new Error("no canned result");
|
||||
}
|
||||
return r;
|
||||
},
|
||||
};
|
||||
return { runner, calls };
|
||||
}
|
||||
|
||||
const ok: CommandResult = { exitCode: 0, stdout: "", stderr: "" };
|
||||
|
||||
describe("runCli", () => {
|
||||
it("--help prints usage and resolves code 0", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const lines: string[] = [];
|
||||
const result = await runCli(["--help"], {
|
||||
out: (l) => lines.push(l),
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
expect(lines.join("\n")).toContain("Usage:");
|
||||
});
|
||||
|
||||
it("-h is an alias for --help", { timeout: 5000 }, async () => {
|
||||
const result = await runCli(["-h"], { out: () => {} });
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it("--fix sets fix mode", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const { glob } = makeGlob(["a.md"]);
|
||||
const { runner, calls } = makeRunner([ok, ok]);
|
||||
const result = await runCli(["--fix"], {
|
||||
out: () => {},
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
expect(calls[1]?.args).toContain("--write");
|
||||
});
|
||||
|
||||
it("--write is an alias for --fix", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const { glob } = makeGlob(["a.md"]);
|
||||
const { runner, calls } = makeRunner([ok, ok]);
|
||||
await runCli(["--write"], {
|
||||
out: () => {},
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(calls[1]?.args).toContain("--write");
|
||||
});
|
||||
|
||||
it("--markdown-only skips biome", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const { glob } = makeGlob(["a.md"]);
|
||||
let runnerCalled = false;
|
||||
const runner: CommandRunner = {
|
||||
async run() {
|
||||
runnerCalled = true;
|
||||
return ok;
|
||||
},
|
||||
};
|
||||
await runCli(["--markdown-only"], {
|
||||
out: () => {},
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(runnerCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("positional pattern is forwarded to the resolver", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "docs/a.md": "# A\n" });
|
||||
const { glob, captured } = makeGlob(["docs/a.md"]);
|
||||
const { runner } = makeRunner([ok, ok]);
|
||||
await runCli(["docs/*.md"], {
|
||||
out: () => {},
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(captured.pattern).toBe("docs/*.md");
|
||||
});
|
||||
|
||||
it("unknown flag raises UNKNOWN_FLAG", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
runCli(["--nope"], { out: () => {} })
|
||||
).rejects.toMatchObject({ code: "LINTER.UNKNOWN_FLAG" });
|
||||
});
|
||||
|
||||
it("failing report yields exit code 1", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const { glob } = makeGlob(["a.md"]);
|
||||
const lines: string[] = [];
|
||||
const { runner } = makeRunner([
|
||||
ok, // probe
|
||||
{ exitCode: 1, stdout: "", stderr: "" }, // check
|
||||
]);
|
||||
const result = await runCli([], {
|
||||
out: (l) => lines.push(l),
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(result.code).toBe(1);
|
||||
expect(lines.join("\n")).toContain("Lint failed");
|
||||
});
|
||||
|
||||
it("--unified is accepted as a no-op", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const { glob } = makeGlob(["a.md"]);
|
||||
const { runner } = makeRunner([ok, ok]);
|
||||
const result = await runCli(["--unified"], {
|
||||
out: () => {},
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it("--cwd consumes the next token", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const { glob } = makeGlob(["a.md"]);
|
||||
const { runner, calls } = makeRunner([ok, ok]);
|
||||
await runCli(["--cwd", "/tmp/x"], {
|
||||
out: () => {},
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(calls[0]?.cwd).toBe("/tmp/x");
|
||||
});
|
||||
|
||||
it("catches LinterError from the orchestrator and prints code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({});
|
||||
const glob: GlobAdapter = {
|
||||
async find() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const lines: string[] = [];
|
||||
const { runner } = makeRunner([
|
||||
{ exitCode: 127, stdout: "", stderr: "" }, // biome unavailable
|
||||
]);
|
||||
// No matching markdown files → NO_TARGETS_FOUND raised by
|
||||
// processMarkdownFiles, caught and turned into code:1.
|
||||
const result = await runCli(["**/*.md"], {
|
||||
out: (l) => lines.push(l),
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(result.code).toBe(1);
|
||||
expect(lines.join("\n")).toContain("LINTER.NO_TARGETS_FOUND");
|
||||
});
|
||||
|
||||
it("forwards an injected clock to the orchestrator", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const { glob } = makeGlob(["a.md"]);
|
||||
const { runner } = makeRunner([ok, ok]);
|
||||
let n = 0;
|
||||
const clock = {
|
||||
now: () => {
|
||||
n += 1;
|
||||
return n;
|
||||
},
|
||||
};
|
||||
const result = await runCli([], {
|
||||
out: () => {},
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
clock,
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it("exercises buildOptions branches when runtime adapters are omitted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// No runner/fs/glob/clock passed — buildOptions must NOT spread
|
||||
// them. Use markdownOnly + a real tmp file to avoid hitting biome.
|
||||
const tmp = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const dir = await tmp.mkdtemp(
|
||||
path.join(process.cwd(), ".linter-cli-tmp-")
|
||||
);
|
||||
const file = path.join(dir, "a.md");
|
||||
await tmp.writeFile(file, "# A\n", "utf-8");
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
const result = await runCli(["--markdown-only", file], {
|
||||
out: (l) => lines.push(l),
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
} finally {
|
||||
await tmp.rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("default out sink writes to console.log without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = console.log;
|
||||
const captured: string[] = [];
|
||||
console.log = (msg: string) => {
|
||||
captured.push(msg);
|
||||
};
|
||||
try {
|
||||
const result = await runCli(["--help"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(captured.join("\n")).toContain("Usage:");
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates non-LinterError exceptions", { timeout: 5000 }, async () => {
|
||||
const fs: FsAdapter = {
|
||||
async readFile() {
|
||||
return "";
|
||||
},
|
||||
async writeFile() {},
|
||||
async exists() {
|
||||
// throw plain Error to exercise the rethrow branch
|
||||
throw new TypeError("kaboom");
|
||||
},
|
||||
};
|
||||
const { glob } = makeGlob([]);
|
||||
const { runner } = makeRunner([ok]);
|
||||
await expect(
|
||||
runCli(["a.md"], {
|
||||
out: () => {},
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
})
|
||||
).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
136
packages/devtools/tooling/linter/src/cli/cli.ts
Normal file
136
packages/devtools/tooling/linter/src/cli/cli.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { LINTER_CODES, LinterError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
BiomeReport,
|
||||
RunCliOptions,
|
||||
UnifiedLintOptions,
|
||||
} from "../types/linter.types.js";
|
||||
import { runUnifiedLint } from "../unified-lint/unified-lint.js";
|
||||
|
||||
const HELP_TEXT = `Usage: suaveplan-lint [options] [pattern]
|
||||
|
||||
Options:
|
||||
--fix, --write Fix issues automatically
|
||||
--help, -h Show this help message
|
||||
--unified Run unified linting for all file types (default)
|
||||
--markdown-only Only run markdown linting
|
||||
--cwd <path> Working directory (defaults to .)
|
||||
|
||||
Patterns:
|
||||
[pattern] Glob pattern for markdown files (default: **/*.md)
|
||||
|
||||
Examples:
|
||||
suaveplan-lint # Lint all markdown files
|
||||
suaveplan-lint --fix # Fix all markdown files
|
||||
suaveplan-lint README.md # Lint single file
|
||||
suaveplan-lint "docs/*.md" --fix # Fix markdown files in docs folder
|
||||
suaveplan-lint --unified # Run unified linting for all file types
|
||||
`;
|
||||
|
||||
interface ParsedArgs {
|
||||
readonly showHelp: boolean;
|
||||
readonly fix: boolean;
|
||||
readonly markdownOnly: boolean;
|
||||
readonly cwd: string | undefined;
|
||||
readonly patterns: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
function parseArgs(argv: ReadonlyArray<string>): ParsedArgs {
|
||||
let showHelp = false;
|
||||
let fix = false;
|
||||
let markdownOnly = false;
|
||||
let cwd: string | undefined;
|
||||
const patterns: string[] = [];
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i] as string;
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
showHelp = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--fix" || arg === "--write") {
|
||||
fix = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--unified") {
|
||||
// Default behaviour — flag is accepted for symmetry.
|
||||
continue;
|
||||
}
|
||||
if (arg === "--markdown-only") {
|
||||
markdownOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--cwd") {
|
||||
i += 1;
|
||||
cwd = argv[i];
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
// Every recognised flag has already continued above, so anything
|
||||
// starting with `-` reaching this point is unknown.
|
||||
throw new LinterError(
|
||||
LINTER_CODES.UNKNOWN_FLAG,
|
||||
`Unknown flag: ${arg}`,
|
||||
{ details: { flag: arg } }
|
||||
);
|
||||
}
|
||||
patterns.push(arg);
|
||||
}
|
||||
return { showHelp, fix, markdownOnly, cwd, patterns };
|
||||
}
|
||||
|
||||
function buildOptions(
|
||||
parsed: ParsedArgs,
|
||||
runtime: RunCliOptions
|
||||
): UnifiedLintOptions {
|
||||
const base: UnifiedLintOptions = {
|
||||
fix: parsed.fix,
|
||||
markdownOnly: parsed.markdownOnly,
|
||||
...(parsed.cwd !== undefined ? { cwd: parsed.cwd } : {}),
|
||||
...(parsed.patterns.length > 0 ? { patterns: parsed.patterns } : {}),
|
||||
...(runtime.runner ? { runner: runtime.runner } : {}),
|
||||
...(runtime.fs ? { fs: runtime.fs } : {}),
|
||||
...(runtime.glob ? { glob: runtime.glob } : {}),
|
||||
...(runtime.clock ? { clock: runtime.clock } : {}),
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure CLI entry. Parses argv, runs the unified orchestrator, returns the
|
||||
* resolved exit code. Never calls `process.exit`.
|
||||
*/
|
||||
export async function runCli(
|
||||
argv: ReadonlyArray<string>,
|
||||
options: RunCliOptions = {}
|
||||
): Promise<{ code: number }> {
|
||||
const out = options.out ?? ((line: string) => console.log(line));
|
||||
const parsed = parseArgs(argv);
|
||||
if (parsed.showHelp) {
|
||||
out(HELP_TEXT);
|
||||
return { code: 0 };
|
||||
}
|
||||
try {
|
||||
const report = await runUnifiedLint(buildOptions(parsed, options));
|
||||
if (report.success) {
|
||||
const fixed = report.markdown.filter((r) => r.changed).length;
|
||||
out(
|
||||
`Lint OK — ${report.markdown.length} markdown file(s), ${fixed} fixed.`
|
||||
);
|
||||
return { code: 0 };
|
||||
}
|
||||
// `report.success === false` is reachable only when the biome lane
|
||||
// ran and failed (the markdown lane throws on errors instead of
|
||||
// setting a flag), so `report.biome` is defined here.
|
||||
out(
|
||||
`Lint failed — biome exit ${(report.biome as BiomeReport).exitCode}, ${
|
||||
report.markdown.length
|
||||
} markdown file(s).`
|
||||
);
|
||||
return { code: 1 };
|
||||
} catch (err) {
|
||||
if (err instanceof LinterError) {
|
||||
out(`Lint failed: ${err.code} ${err.message}`);
|
||||
return { code: 1 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
1
packages/devtools/tooling/linter/src/cli/index.ts
Normal file
1
packages/devtools/tooling/linter/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { runCli } from "./cli.js";
|
||||
112
packages/devtools/tooling/linter/src/error-codes/error-codes.md
Normal file
112
packages/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.
|
||||
@@ -0,0 +1,50 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LINTER_CODES, LinterError } from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("registry exposes the full code set", { timeout: 5000 }, () => {
|
||||
const codes = Object.values(LINTER_CODES).sort();
|
||||
expect(codes).toEqual(
|
||||
[
|
||||
"LINTER.BIOME_FAILED",
|
||||
"LINTER.BIOME_UNAVAILABLE",
|
||||
"LINTER.FILE_NOT_FOUND",
|
||||
"LINTER.INVALID_OPTIONS",
|
||||
"LINTER.MARKDOWN_PARSE_FAILED",
|
||||
"LINTER.NO_TARGETS_FOUND",
|
||||
"LINTER.READ_FAILED",
|
||||
"LINTER.RUNNER_FAILED",
|
||||
"LINTER.UNKNOWN_FLAG",
|
||||
"LINTER.WRITE_FAILED",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("LinterError carries code and details payload", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new LinterError(LINTER_CODES.INVALID_OPTIONS, "bad", {
|
||||
details: { field: "x" },
|
||||
});
|
||||
expect(error.code).toBe("LINTER.INVALID_OPTIONS");
|
||||
expect(error.details).toEqual({ field: "x" });
|
||||
expect(error).toBeInstanceOf(SuaveplanError);
|
||||
expect(error.name).toBe("LinterError");
|
||||
});
|
||||
|
||||
it("LinterError defaults details to empty record when omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new LinterError(LINTER_CODES.READ_FAILED, "boom");
|
||||
expect(error.details).toEqual({});
|
||||
});
|
||||
|
||||
it("LinterError preserves cause when provided", { timeout: 5000 }, () => {
|
||||
const root = new Error("root");
|
||||
const error = new LinterError(LINTER_CODES.WRITE_FAILED, "boom", {
|
||||
cause: root,
|
||||
});
|
||||
expect(error.cause).toBe(root);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/linter`.
|
||||
*
|
||||
* Codes follow `LINTER.<REASON>` and are produced by `@suaveplan/error`'s
|
||||
* `defineCodes` so the namespace survives module boundaries and runtime
|
||||
* serialisation. Consumers SHOULD discriminate on `error.code` rather than
|
||||
* `instanceof` so the discriminator works across worker, RPC, and
|
||||
* serialised-error boundaries.
|
||||
*/
|
||||
export const LINTER_CODES = defineCodes("LINTER", [
|
||||
"INVALID_OPTIONS",
|
||||
"FILE_NOT_FOUND",
|
||||
"NO_TARGETS_FOUND",
|
||||
"READ_FAILED",
|
||||
"WRITE_FAILED",
|
||||
"MARKDOWN_PARSE_FAILED",
|
||||
"BIOME_UNAVAILABLE",
|
||||
"BIOME_FAILED",
|
||||
"RUNNER_FAILED",
|
||||
"UNKNOWN_FLAG",
|
||||
] as const);
|
||||
|
||||
export type LinterCode = (typeof LINTER_CODES)[keyof typeof LINTER_CODES];
|
||||
|
||||
/** Options accepted by the {@link LinterError} constructor. */
|
||||
export interface LinterErrorOptions {
|
||||
readonly cause?: unknown;
|
||||
readonly details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed error class for every `@suaveplan/linter` failure path. Carries the
|
||||
* registered code, a human-readable message, and an optional structured
|
||||
* `details` payload through the `SuaveplanError` `context` field so callers
|
||||
* can drive diagnostics without re-parsing the message.
|
||||
*/
|
||||
export class LinterError extends SuaveplanError<LinterCode> {
|
||||
public readonly details: Record<string, unknown>;
|
||||
|
||||
public constructor(
|
||||
code: LinterCode,
|
||||
message: string,
|
||||
options?: LinterErrorOptions
|
||||
) {
|
||||
super(code, message, {
|
||||
...(options?.cause === undefined ? {} : { cause: options.cause }),
|
||||
context: options?.details ?? {},
|
||||
});
|
||||
this.name = "LinterError";
|
||||
this.details = options?.details ?? {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { LinterCode, LinterErrorOptions } from "./error-codes.js";
|
||||
export { LINTER_CODES, LinterError } from "./error-codes.js";
|
||||
@@ -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,159 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FsAdapter, GlobAdapter } from "../types/linter.types.js";
|
||||
import {
|
||||
DEFAULT_IGNORE,
|
||||
nodeGlobAdapter,
|
||||
resolveMarkdownTargets,
|
||||
} from "./glob-resolver.js";
|
||||
|
||||
function makeFs(present: ReadonlyArray<string>): FsAdapter {
|
||||
return {
|
||||
async readFile() {
|
||||
return "";
|
||||
},
|
||||
async writeFile() {},
|
||||
async exists(path) {
|
||||
return present.includes(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveMarkdownTargets", () => {
|
||||
it("single-file pattern bypasses the glob", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs(["README.md"]);
|
||||
let globCalled = false;
|
||||
const glob: GlobAdapter = {
|
||||
async find() {
|
||||
globCalled = true;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const result = await resolveMarkdownTargets({
|
||||
patterns: ["README.md"],
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(result).toEqual(["README.md"]);
|
||||
expect(globCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the default ignore list", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs([]);
|
||||
let captured: ReadonlyArray<string> | undefined;
|
||||
const glob: GlobAdapter = {
|
||||
async find(_pattern, opts) {
|
||||
captured = opts.ignore;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
await resolveMarkdownTargets({
|
||||
patterns: ["**/*.md"],
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(captured).toEqual(DEFAULT_IGNORE);
|
||||
});
|
||||
|
||||
it("deduplicates across patterns", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs(["a.md"]);
|
||||
const glob: GlobAdapter = {
|
||||
async find() {
|
||||
return ["a.md", "b.md"];
|
||||
},
|
||||
};
|
||||
const result = await resolveMarkdownTargets({
|
||||
patterns: ["**/*.md", "docs/**/*.md", "a.md"],
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(result).toEqual(["a.md", "b.md"]);
|
||||
});
|
||||
|
||||
it("raises FILE_NOT_FOUND for a missing file path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs([]);
|
||||
const glob: GlobAdapter = {
|
||||
async find() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
resolveMarkdownTargets({
|
||||
patterns: ["missing.md"],
|
||||
fs,
|
||||
glob,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "LINTER.FILE_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("forwards cwd to the glob adapter", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs([]);
|
||||
let capturedCwd = "";
|
||||
const glob: GlobAdapter = {
|
||||
async find(_pattern, opts) {
|
||||
capturedCwd = opts.cwd;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
await resolveMarkdownTargets({
|
||||
patterns: ["**/*.md"],
|
||||
cwd: "/tmp/repo",
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(capturedCwd).toBe("/tmp/repo");
|
||||
});
|
||||
|
||||
it("defaults cwd to . when not supplied", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs([]);
|
||||
let capturedCwd = "";
|
||||
const glob: GlobAdapter = {
|
||||
async find(_pattern, opts) {
|
||||
capturedCwd = opts.cwd;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
await resolveMarkdownTargets({
|
||||
patterns: ["**/*.md"],
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(capturedCwd).toBe(".");
|
||||
});
|
||||
|
||||
it("uses nodeGlobAdapter when no glob is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs([]);
|
||||
// Default adapter — point at an empty cwd that yields no results.
|
||||
const result = await resolveMarkdownTargets({
|
||||
patterns: ["this-prefix-matches-nothing-12345/**/*.md"],
|
||||
fs,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats glob-magic patterns ending in .md as patterns, not paths", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs([]);
|
||||
const glob: GlobAdapter = {
|
||||
async find() {
|
||||
return ["docs/a.md", "docs/b.md"];
|
||||
},
|
||||
};
|
||||
const result = await resolveMarkdownTargets({
|
||||
patterns: ["docs/*.md"],
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(result).toEqual(["docs/a.md", "docs/b.md"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeGlobAdapter", () => {
|
||||
it("is exported and exposes a find method", { timeout: 5000 }, () => {
|
||||
expect(typeof nodeGlobAdapter.find).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { glob as globImpl } from "glob";
|
||||
import { LINTER_CODES, LinterError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
FsAdapter,
|
||||
GlobAdapter,
|
||||
GlobOptions,
|
||||
} from "../types/linter.types.js";
|
||||
|
||||
const DEFAULT_IGNORE: ReadonlyArray<string> = [
|
||||
"node_modules/**",
|
||||
"**/node_modules/**",
|
||||
"dist/**",
|
||||
"**/dist/**",
|
||||
];
|
||||
|
||||
/** Default `GlobAdapter` wrapping the `glob` npm package. */
|
||||
export const nodeGlobAdapter: GlobAdapter = {
|
||||
async find(pattern, options: GlobOptions) {
|
||||
return await globImpl(pattern, {
|
||||
ignore: [...options.ignore],
|
||||
cwd: options.cwd,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function hasGlobMagic(pattern: string): boolean {
|
||||
return /[*?[\]{}]/.test(pattern);
|
||||
}
|
||||
|
||||
function looksLikeFilePath(pattern: string): boolean {
|
||||
return pattern.endsWith(".md") && !hasGlobMagic(pattern);
|
||||
}
|
||||
|
||||
/** Options accepted by {@link resolveMarkdownTargets}. */
|
||||
export interface ResolveMarkdownTargetsOptions {
|
||||
readonly patterns: ReadonlyArray<string>;
|
||||
readonly cwd?: string;
|
||||
readonly fs: FsAdapter;
|
||||
readonly glob?: GlobAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a list of patterns (or file paths) to a sorted, deduplicated list
|
||||
* of markdown file paths. Single-file patterns bypass the glob adapter.
|
||||
*
|
||||
* Raises:
|
||||
* - `LinterError(LINTER.FILE_NOT_FOUND)` if a pattern looks like a file path
|
||||
* but does not exist.
|
||||
*/
|
||||
export async function resolveMarkdownTargets(
|
||||
options: ResolveMarkdownTargetsOptions
|
||||
): Promise<string[]> {
|
||||
const { patterns, fs } = options;
|
||||
const cwd = options.cwd ?? ".";
|
||||
const glob = options.glob ?? nodeGlobAdapter;
|
||||
const collected = new Set<string>();
|
||||
for (const pattern of patterns) {
|
||||
if (looksLikeFilePath(pattern)) {
|
||||
if (!(await fs.exists(pattern))) {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.FILE_NOT_FOUND,
|
||||
`Markdown file not found: ${pattern}`,
|
||||
{ details: { pattern } }
|
||||
);
|
||||
}
|
||||
collected.add(pattern);
|
||||
continue;
|
||||
}
|
||||
const matches = await glob.find(pattern, {
|
||||
ignore: DEFAULT_IGNORE,
|
||||
cwd,
|
||||
});
|
||||
for (const match of matches) {
|
||||
collected.add(match);
|
||||
}
|
||||
}
|
||||
return [...collected].sort();
|
||||
}
|
||||
|
||||
export { DEFAULT_IGNORE };
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { ResolveMarkdownTargetsOptions } from "./glob-resolver.js";
|
||||
export {
|
||||
DEFAULT_IGNORE,
|
||||
nodeGlobAdapter,
|
||||
resolveMarkdownTargets,
|
||||
} from "./glob-resolver.js";
|
||||
75
packages/devtools/tooling/linter/src/index.ts
Normal file
75
packages/devtools/tooling/linter/src/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export type {
|
||||
ProbeBiomeAvailableOptions,
|
||||
RunBiomeOptions,
|
||||
} from "./biome-runner/biome-runner.js";
|
||||
export {
|
||||
nodeCommandRunner,
|
||||
probeBiomeAvailable,
|
||||
runBiome,
|
||||
} from "./biome-runner/biome-runner.js";
|
||||
export { runCli } from "./cli/cli.js";
|
||||
export type {
|
||||
LinterCode,
|
||||
LinterErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export { LINTER_CODES, LinterError } from "./error-codes/error-codes.js";
|
||||
export type { ResolveMarkdownTargetsOptions } from "./glob-resolver/glob-resolver.js";
|
||||
export {
|
||||
DEFAULT_IGNORE,
|
||||
nodeGlobAdapter,
|
||||
resolveMarkdownTargets,
|
||||
} from "./glob-resolver/glob-resolver.js";
|
||||
export type {
|
||||
FixMarkdownOptions,
|
||||
ProcessMarkdownFilesOptions,
|
||||
} from "./markdown-fixer/markdown-fixer.js";
|
||||
export {
|
||||
fixMarkdown,
|
||||
nodeFsAdapter,
|
||||
processMarkdownFiles,
|
||||
} from "./markdown-fixer/markdown-fixer.js";
|
||||
export type { RuleSet } from "./markdown-fixer/rules.js";
|
||||
export {
|
||||
fixBlankLines,
|
||||
fixCodeBlockLanguages,
|
||||
fixEmphasisAsHeading,
|
||||
fixHorizontalRules,
|
||||
fixMultipleH1s,
|
||||
fixOrderedListNumbering,
|
||||
fixProperNames,
|
||||
HEADING_HINT_BASIC,
|
||||
HEADING_HINT_EXTENDED,
|
||||
inferCodeLanguage,
|
||||
PROPER_NAMES,
|
||||
} from "./markdown-fixer/rules.js";
|
||||
export type {
|
||||
BiomeOutcome,
|
||||
ProcessedResult,
|
||||
RunPhase,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export {
|
||||
biomeRunsCounter,
|
||||
filesFixedCounter,
|
||||
filesProcessedCounter,
|
||||
recordBiomeRun,
|
||||
recordFileFixed,
|
||||
recordFileProcessed,
|
||||
recordRunDuration,
|
||||
runDurationHistogram,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export type {
|
||||
BiomeMode,
|
||||
BiomeReport,
|
||||
ClockLike,
|
||||
CommandInput,
|
||||
CommandResult,
|
||||
CommandRunner,
|
||||
FsAdapter,
|
||||
GlobAdapter,
|
||||
GlobOptions,
|
||||
LintReport,
|
||||
MarkdownFixReport,
|
||||
RunCliOptions,
|
||||
UnifiedLintOptions,
|
||||
} from "./types/linter.types.js";
|
||||
export { runUnifiedLint } from "./unified-lint/unified-lint.js";
|
||||
23
packages/devtools/tooling/linter/src/markdown-fixer/index.ts
Normal file
23
packages/devtools/tooling/linter/src/markdown-fixer/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type {
|
||||
FixMarkdownOptions,
|
||||
ProcessMarkdownFilesOptions,
|
||||
} from "./markdown-fixer.js";
|
||||
export {
|
||||
fixMarkdown,
|
||||
nodeFsAdapter,
|
||||
processMarkdownFiles,
|
||||
} from "./markdown-fixer.js";
|
||||
export type { RuleSet } from "./rules.js";
|
||||
export {
|
||||
fixBlankLines,
|
||||
fixCodeBlockLanguages,
|
||||
fixEmphasisAsHeading,
|
||||
fixHorizontalRules,
|
||||
fixMultipleH1s,
|
||||
fixOrderedListNumbering,
|
||||
fixProperNames,
|
||||
HEADING_HINT_BASIC,
|
||||
HEADING_HINT_EXTENDED,
|
||||
inferCodeLanguage,
|
||||
PROPER_NAMES,
|
||||
} from "./rules.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LinterError } from "../error-codes/error-codes.js";
|
||||
import type { FsAdapter, GlobAdapter } from "../types/linter.types.js";
|
||||
import { fixMarkdown, processMarkdownFiles } from "./markdown-fixer.js";
|
||||
|
||||
function makeFs(seed: Record<string, string>): {
|
||||
fs: FsAdapter;
|
||||
store: Map<string, string>;
|
||||
writes: string[];
|
||||
} {
|
||||
const store = new Map<string, string>(Object.entries(seed));
|
||||
const writes: string[] = [];
|
||||
const fs: FsAdapter = {
|
||||
async readFile(path) {
|
||||
const v = store.get(path);
|
||||
if (v === undefined) {
|
||||
throw new Error(`ENOENT: ${path}`);
|
||||
}
|
||||
return v;
|
||||
},
|
||||
async writeFile(path, data) {
|
||||
writes.push(path);
|
||||
store.set(path, data);
|
||||
},
|
||||
async exists(path) {
|
||||
return store.has(path);
|
||||
},
|
||||
};
|
||||
return { fs, store, writes };
|
||||
}
|
||||
|
||||
function makeGlob(files: ReadonlyArray<string>): GlobAdapter {
|
||||
return {
|
||||
async find() {
|
||||
return files;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("fixMarkdown", () => {
|
||||
it("collapses multiple H1s into H2s after the first", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "# A\n\n# B\n\n# C\n",
|
||||
});
|
||||
const report = await fixMarkdown("a.md", { fs });
|
||||
const out = store.get("a.md") ?? "";
|
||||
expect(out).toContain("# A");
|
||||
expect(out).toContain("## B");
|
||||
expect(out).toContain("## C");
|
||||
expect(report.changed).toBe(true);
|
||||
expect(report.rulesApplied).toContain("MD025");
|
||||
});
|
||||
|
||||
it("rewrites step-style strong paragraph to H3", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "**Step 1: PLAN**\n\nbody\n",
|
||||
});
|
||||
const report = await fixMarkdown("a.md", { fs });
|
||||
const out = store.get("a.md") ?? "";
|
||||
expect(out).toContain("### Step 1: PLAN");
|
||||
expect(out).not.toContain("**Step 1: PLAN**");
|
||||
expect(report.rulesApplied).toContain("MD036");
|
||||
});
|
||||
|
||||
it("infers bash language for code blocks", { timeout: 5000 }, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "```\nnpm install\n```\n",
|
||||
});
|
||||
const report = await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```bash");
|
||||
expect(report.rulesApplied).toContain("MD040");
|
||||
});
|
||||
|
||||
it("infers typescript language for code blocks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "```\ninterface User { name: string }\n```\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```typescript");
|
||||
});
|
||||
|
||||
it("infers javascript language for code blocks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "```\nconst x = 1\nfunction y() {}\n```\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```javascript");
|
||||
});
|
||||
|
||||
it("infers python language for code blocks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "```\ndef hi():\n return 1\n```\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```python");
|
||||
});
|
||||
|
||||
it("infers markdown language for code blocks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "```\n# Title\n## Sub\n```\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```markdown");
|
||||
});
|
||||
|
||||
it("infers html language for code blocks", { timeout: 5000 }, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "```\n<div>x</div>\n```\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```html");
|
||||
});
|
||||
|
||||
it("infers json language for code blocks", { timeout: 5000 }, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": '```\n{ "a": 1 }\n```\n',
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```json");
|
||||
});
|
||||
|
||||
it("defaults to text language for unrecognised content", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "```\nzzz qqq aaa\n```\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```text");
|
||||
});
|
||||
|
||||
it("infers bash for git commands", { timeout: 5000 }, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "```\ngit status\n```\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
expect(store.get("a.md") ?? "").toContain("```bash");
|
||||
});
|
||||
|
||||
it("collapses multiple blank lines", { timeout: 5000 }, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "para a\n\n\n\n\npara b\n",
|
||||
});
|
||||
const report = await fixMarkdown("a.md", { fs });
|
||||
const out = store.get("a.md") ?? "";
|
||||
expect(out).not.toMatch(/\n\n\n/);
|
||||
expect(report.rulesApplied).toContain("MD012");
|
||||
});
|
||||
|
||||
it("capitalises proper names", { timeout: 5000 }, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "This is a json api over http.\n",
|
||||
});
|
||||
const report = await fixMarkdown("a.md", { fs });
|
||||
const out = store.get("a.md") ?? "";
|
||||
expect(out).toContain("JSON");
|
||||
expect(out).toContain("API");
|
||||
expect(out).toContain("HTTP");
|
||||
expect(report.rulesApplied).toContain("MD044");
|
||||
});
|
||||
|
||||
it("ensures trailing single newline", { timeout: 5000 }, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "para a\n\n\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
const out = store.get("a.md") ?? "";
|
||||
expect(out.endsWith("\n")).toBe(true);
|
||||
expect(out.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
|
||||
it("appends trailing newline when missing", { timeout: 5000 }, async () => {
|
||||
// remark-stringify on an EMPTY document yields the empty string,
|
||||
// which is the only path that exercises the "append" branch of
|
||||
// postProcess.
|
||||
const { fs, store } = makeFs({ "a.md": "" });
|
||||
await fixMarkdown("a.md", { fs });
|
||||
const out = store.get("a.md") ?? "";
|
||||
expect(out).toBe("\n");
|
||||
});
|
||||
|
||||
it("rewrites bold heading-style line with backticks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs, store } = makeFs({
|
||||
"a.md": "**Plain**\n\nbody\n",
|
||||
});
|
||||
const report = await fixMarkdown("a.md", { fs });
|
||||
const out = store.get("a.md") ?? "";
|
||||
expect(out).toContain("**`Plain`**");
|
||||
expect(report.rulesApplied).toContain("MD036");
|
||||
});
|
||||
|
||||
it("returns changed=false when content is already canonical", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// First normalise — second run should be idempotent.
|
||||
const { fs, store, writes } = makeFs({
|
||||
"a.md": "# A\n",
|
||||
});
|
||||
await fixMarkdown("a.md", { fs });
|
||||
const writesAfterFirst = writes.length;
|
||||
const after = store.get("a.md") ?? "";
|
||||
// Re-seed with the canonical output and check changed=false.
|
||||
store.set("a.md", after);
|
||||
const report = await fixMarkdown("a.md", { fs });
|
||||
expect(report.changed).toBe(false);
|
||||
expect(writes.length).toBe(writesAfterFirst);
|
||||
});
|
||||
|
||||
it("raises FILE_NOT_FOUND when the path does not exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs } = makeFs({});
|
||||
await expect(fixMarkdown("missing.md", { fs })).rejects.toThrow(
|
||||
LinterError
|
||||
);
|
||||
await expect(fixMarkdown("missing.md", { fs })).rejects.toMatchObject({
|
||||
code: "LINTER.FILE_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("raises READ_FAILED when the FsAdapter throws on read", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs: FsAdapter = {
|
||||
async readFile() {
|
||||
throw new Error("EPERM");
|
||||
},
|
||||
async writeFile() {},
|
||||
async exists() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
|
||||
code: "LINTER.READ_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("raises WRITE_FAILED when the FsAdapter throws on write", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs: FsAdapter = {
|
||||
async readFile() {
|
||||
return "# A\n# B\n";
|
||||
},
|
||||
async writeFile() {
|
||||
throw new Error("EROFS");
|
||||
},
|
||||
async exists() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
|
||||
code: "LINTER.WRITE_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("raises MARKDOWN_PARSE_FAILED when readFile returns a non-string", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs: FsAdapter = {
|
||||
async readFile() {
|
||||
// Return an object that explodes in remark's
|
||||
// process pipeline — `toString` throws so the
|
||||
// VFile constructor cannot stringify.
|
||||
const poisoned = {
|
||||
toString() {
|
||||
throw new TypeError("unstringifiable");
|
||||
},
|
||||
};
|
||||
return poisoned as unknown as string;
|
||||
},
|
||||
async writeFile() {},
|
||||
async exists() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await expect(fixMarkdown("a.md", { fs })).rejects.toMatchObject({
|
||||
code: "LINTER.MARKDOWN_PARSE_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses nodeFsAdapter when no fs is supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
fixMarkdown("/definitely-not-here-12345.md")
|
||||
).rejects.toMatchObject({ code: "LINTER.FILE_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("nodeFsAdapter exercises readFile / writeFile / exists with a temp file", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { nodeFsAdapter } = await import("./markdown-fixer.js");
|
||||
const tmp = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const dir = await tmp.mkdtemp(path.join(process.cwd(), ".linter-tmp-"));
|
||||
const file = path.join(dir, "a.md");
|
||||
await tmp.writeFile(file, "# A\n# B\n", "utf-8");
|
||||
try {
|
||||
expect(await nodeFsAdapter.exists(file)).toBe(true);
|
||||
expect(await nodeFsAdapter.exists(`${file}.missing`)).toBe(false);
|
||||
expect(await nodeFsAdapter.readFile(file)).toBe("# A\n# B\n");
|
||||
await nodeFsAdapter.writeFile(file, "# X\n");
|
||||
expect(await nodeFsAdapter.readFile(file)).toBe("# X\n");
|
||||
const report = await fixMarkdown(file);
|
||||
expect(report.file).toBe(file);
|
||||
} finally {
|
||||
await tmp.rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("processMarkdownFiles", () => {
|
||||
it("walks a glob and returns per-file reports", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs } = makeFs({
|
||||
"a.md": "# A\n# A2\n",
|
||||
"b.md": "# B\n",
|
||||
});
|
||||
const glob = makeGlob(["a.md", "b.md"]);
|
||||
// Pre-normalise b.md so it lands as unchanged
|
||||
await fixMarkdown("b.md", { fs });
|
||||
const reports = await processMarkdownFiles({
|
||||
patterns: ["**/*.md"],
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(reports).toHaveLength(2);
|
||||
expect(reports[0]?.file).toBe("a.md");
|
||||
expect(reports[1]?.file).toBe("b.md");
|
||||
expect(reports[0]?.changed).toBe(true);
|
||||
expect(reports[1]?.changed).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to **/*.md when patterns is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs } = makeFs({ "a.md": "# A\n" });
|
||||
let captured = "";
|
||||
const glob: GlobAdapter = {
|
||||
async find(pattern) {
|
||||
captured = pattern;
|
||||
return ["a.md"];
|
||||
},
|
||||
};
|
||||
await processMarkdownFiles({ patterns: [], fs, glob });
|
||||
expect(captured).toBe("**/*.md");
|
||||
});
|
||||
|
||||
it("raises NO_TARGETS_FOUND when nothing matches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fs } = makeFs({});
|
||||
const glob = makeGlob([]);
|
||||
await expect(
|
||||
processMarkdownFiles({
|
||||
patterns: ["nope/*.md"],
|
||||
fs,
|
||||
glob,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "LINTER.NO_TARGETS_FOUND" });
|
||||
});
|
||||
|
||||
it("passes patterns through when supplied without glob override", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Exercise the `patterns:` spread branch (non-empty patterns).
|
||||
const { fs } = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const reports = await processMarkdownFiles({
|
||||
patterns: ["**/*.md"],
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(reports).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses default cwd when not supplied", { timeout: 5000 }, async () => {
|
||||
const { fs } = makeFs({ "a.md": "# A\n" });
|
||||
let capturedCwd = "";
|
||||
const glob: GlobAdapter = {
|
||||
async find(_pattern, opts) {
|
||||
capturedCwd = opts.cwd;
|
||||
return ["a.md"];
|
||||
},
|
||||
};
|
||||
await processMarkdownFiles({ fs, glob });
|
||||
expect(capturedCwd).toBe(".");
|
||||
});
|
||||
|
||||
it("uses default options object when called with no args", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Run from a temp dir with one .md file so all defaults
|
||||
// (fs / glob / cwd / patterns) take their fallback branches.
|
||||
const tmp = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const dir = await tmp.mkdtemp(
|
||||
path.join(process.cwd(), ".linter-default-")
|
||||
);
|
||||
const before = process.cwd();
|
||||
try {
|
||||
process.chdir(dir);
|
||||
await tmp.writeFile(path.join(dir, "a.md"), "# A\n", "utf-8");
|
||||
const reports = await processMarkdownFiles();
|
||||
expect(reports.length).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
process.chdir(before);
|
||||
await tmp.rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { promises as nodeFs } from "node:fs";
|
||||
import type { Root } from "mdast";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkStringify from "remark-stringify";
|
||||
import { type Processor, unified } from "unified";
|
||||
import { LINTER_CODES, LinterError } from "../error-codes/error-codes.js";
|
||||
import { resolveMarkdownTargets } from "../glob-resolver/glob-resolver.js";
|
||||
import {
|
||||
recordFileFixed,
|
||||
recordFileProcessed,
|
||||
} from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
FsAdapter,
|
||||
GlobAdapter,
|
||||
MarkdownFixReport,
|
||||
} from "../types/linter.types.js";
|
||||
import {
|
||||
fixBlankLines,
|
||||
fixCodeBlockLanguages,
|
||||
fixEmphasisAsHeading,
|
||||
fixHorizontalRules,
|
||||
fixMultipleH1s,
|
||||
fixOrderedListNumbering,
|
||||
fixProperNames,
|
||||
HEADING_HINT_BASIC,
|
||||
HEADING_HINT_EXTENDED,
|
||||
type RuleSet,
|
||||
} from "./rules.js";
|
||||
|
||||
type RemarkProcessor = Processor<Root, Root, Root, Root, string>;
|
||||
|
||||
function buildProcessor(rules: RuleSet): RemarkProcessor {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(fixMultipleH1s, rules)
|
||||
.use(fixEmphasisAsHeading, rules, HEADING_HINT_BASIC)
|
||||
.use(fixEmphasisAsHeading, rules, HEADING_HINT_EXTENDED)
|
||||
.use(fixOrderedListNumbering, rules)
|
||||
.use(fixHorizontalRules, rules)
|
||||
.use(fixCodeBlockLanguages, rules)
|
||||
.use(fixBlankLines, rules)
|
||||
.use(fixProperNames, rules)
|
||||
.use(remarkStringify, {
|
||||
bullet: "-",
|
||||
emphasis: "*",
|
||||
fences: true,
|
||||
incrementListMarker: true,
|
||||
rule: "-",
|
||||
}) as unknown as RemarkProcessor;
|
||||
}
|
||||
|
||||
function postProcess(output: string, rules: RuleSet): string {
|
||||
const collapsed = output.replace(/\n{3,}/g, "\n\n");
|
||||
if (collapsed !== output) {
|
||||
rules.add("MD012");
|
||||
}
|
||||
const lined = collapsed
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^\*\*([^`*]+)\*\*$/g, "**`$1`**"))
|
||||
.join("\n");
|
||||
if (lined !== collapsed) {
|
||||
rules.add("MD036");
|
||||
}
|
||||
// remark-stringify always emits at least one trailing newline, so the
|
||||
// canonical form is "exactly one trailing newline". Collapse any tail
|
||||
// run of newlines to a single one; if (somehow) none, append.
|
||||
const withTrail = lined.endsWith("\n")
|
||||
? lined.replace(/\n+$/, "\n")
|
||||
: `${lined}\n`;
|
||||
return withTrail;
|
||||
}
|
||||
|
||||
/** Default `FsAdapter` backed by `node:fs/promises`. */
|
||||
export const nodeFsAdapter: FsAdapter = {
|
||||
async readFile(path) {
|
||||
return await nodeFs.readFile(path, "utf-8");
|
||||
},
|
||||
async writeFile(path, data) {
|
||||
await nodeFs.writeFile(path, data, "utf-8");
|
||||
},
|
||||
async exists(path) {
|
||||
try {
|
||||
await nodeFs.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Options accepted by {@link fixMarkdown}. */
|
||||
export interface FixMarkdownOptions {
|
||||
readonly fs?: FsAdapter;
|
||||
}
|
||||
|
||||
async function readSource(fs: FsAdapter, filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(filePath);
|
||||
} catch (cause) {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.READ_FAILED,
|
||||
`Failed to read ${filePath}`,
|
||||
{ cause, details: { filePath } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function processSource(
|
||||
processor: RemarkProcessor,
|
||||
source: string
|
||||
): Promise<string> {
|
||||
const vfile = await processor.process(source);
|
||||
return String(vfile);
|
||||
}
|
||||
|
||||
async function writeOutput(
|
||||
fs: FsAdapter,
|
||||
filePath: string,
|
||||
data: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(filePath, data);
|
||||
} catch (cause) {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.WRITE_FAILED,
|
||||
`Failed to write ${filePath}`,
|
||||
{ cause, details: { filePath } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix a single markdown file in place. Returns a {@link MarkdownFixReport}
|
||||
* naming the file, whether it was rewritten, and the rule identifiers that
|
||||
* altered the AST. Raises `LinterError(LINTER.FILE_NOT_FOUND)` when the
|
||||
* path is missing.
|
||||
*/
|
||||
export async function fixMarkdown(
|
||||
filePath: string,
|
||||
options: FixMarkdownOptions = {}
|
||||
): Promise<MarkdownFixReport> {
|
||||
const fs = options.fs ?? nodeFsAdapter;
|
||||
if (!(await fs.exists(filePath))) {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.FILE_NOT_FOUND,
|
||||
`Markdown file not found: ${filePath}`,
|
||||
{ details: { filePath } }
|
||||
);
|
||||
}
|
||||
const original = await readSource(fs, filePath);
|
||||
const rules = new Set<string>();
|
||||
const ruleSet: RuleSet = { add: (rule) => rules.add(rule) };
|
||||
let stringified: string;
|
||||
try {
|
||||
if (/\n{3,}/.test(original)) {
|
||||
ruleSet.add("MD012");
|
||||
}
|
||||
stringified = await processSource(buildProcessor(ruleSet), original);
|
||||
} catch (cause) {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.MARKDOWN_PARSE_FAILED,
|
||||
`Failed to parse markdown for ${filePath}`,
|
||||
{ cause, details: { filePath } }
|
||||
);
|
||||
}
|
||||
const output = postProcess(stringified, ruleSet);
|
||||
const changed = output !== original;
|
||||
if (changed) {
|
||||
await writeOutput(fs, filePath, output);
|
||||
for (const rule of rules) {
|
||||
recordFileFixed(rule);
|
||||
}
|
||||
recordFileProcessed("changed");
|
||||
} else {
|
||||
recordFileProcessed("unchanged");
|
||||
}
|
||||
return {
|
||||
file: filePath,
|
||||
changed,
|
||||
rulesApplied: [...rules].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Options accepted by {@link processMarkdownFiles}. */
|
||||
export interface ProcessMarkdownFilesOptions {
|
||||
readonly patterns?: ReadonlyArray<string>;
|
||||
readonly cwd?: string;
|
||||
readonly fs?: FsAdapter;
|
||||
readonly glob?: GlobAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every markdown target and apply {@link fixMarkdown} to each.
|
||||
* Defaults to `["**\/*.md"]` when no patterns are supplied. Raises
|
||||
* `LinterError(LINTER.NO_TARGETS_FOUND)` on an empty result set.
|
||||
*/
|
||||
export async function processMarkdownFiles(
|
||||
options: ProcessMarkdownFilesOptions = {}
|
||||
): Promise<MarkdownFixReport[]> {
|
||||
const fs = options.fs ?? nodeFsAdapter;
|
||||
const patterns =
|
||||
!options.patterns || options.patterns.length === 0
|
||||
? ["**/*.md"]
|
||||
: options.patterns;
|
||||
const cwd = options.cwd ?? ".";
|
||||
const targets = await resolveMarkdownTargets({
|
||||
patterns,
|
||||
cwd,
|
||||
fs,
|
||||
...(options.glob ? { glob: options.glob } : {}),
|
||||
});
|
||||
if (targets.length === 0) {
|
||||
throw new LinterError(
|
||||
LINTER_CODES.NO_TARGETS_FOUND,
|
||||
"No markdown files matched the supplied patterns",
|
||||
{ details: { patterns: [...patterns] } }
|
||||
);
|
||||
}
|
||||
const reports: MarkdownFixReport[] = [];
|
||||
for (const file of targets) {
|
||||
const report = await fixMarkdown(file, { fs });
|
||||
reports.push(report);
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
114
packages/devtools/tooling/linter/src/markdown-fixer/rules.md
Normal file
114
packages/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.
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
HEADING_HINT_BASIC,
|
||||
HEADING_HINT_EXTENDED,
|
||||
inferCodeLanguage,
|
||||
PROPER_NAMES,
|
||||
} from "./rules.js";
|
||||
|
||||
describe("rules helpers", () => {
|
||||
it("HEADING_HINT_BASIC matches Step prefix", { timeout: 5000 }, () => {
|
||||
expect(HEADING_HINT_BASIC.test("Step 1: PLAN")).toBe(true);
|
||||
expect(HEADING_HINT_BASIC.test("normal text")).toBe(false);
|
||||
});
|
||||
|
||||
it("HEADING_HINT_EXTENDED matches keyword prefixes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(HEADING_HINT_EXTENDED.test("NEVER do this")).toBe(true);
|
||||
expect(HEADING_HINT_EXTENDED.test("ALWAYS do this")).toBe(true);
|
||||
expect(HEADING_HINT_EXTENDED.test("CRITICAL fact")).toBe(true);
|
||||
expect(HEADING_HINT_EXTENDED.test("IMPORTANT note")).toBe(true);
|
||||
expect(HEADING_HINT_EXTENDED.test("MANDATORY field")).toBe(true);
|
||||
});
|
||||
|
||||
it("inferCodeLanguage covers every heuristic branch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(inferCodeLanguage("npm install foo")).toBe("bash");
|
||||
expect(inferCodeLanguage("yarn add foo")).toBe("bash");
|
||||
expect(inferCodeLanguage("npx run x")).toBe("bash");
|
||||
expect(inferCodeLanguage("interface Foo { a: string }")).toBe(
|
||||
"typescript"
|
||||
);
|
||||
expect(inferCodeLanguage("type X = { y: number }")).toBe("typescript");
|
||||
expect(inferCodeLanguage("const x = 1")).toBe("javascript");
|
||||
expect(inferCodeLanguage("let y = 1")).toBe("javascript");
|
||||
expect(inferCodeLanguage("function z() {}")).toBe("javascript");
|
||||
expect(inferCodeLanguage("def f():")).toBe("python");
|
||||
expect(inferCodeLanguage("from m import f")).toBe("python");
|
||||
expect(inferCodeLanguage("a = 1\nfrom m import f")).toBe("python");
|
||||
expect(inferCodeLanguage("# Title\n## Sub")).toBe("markdown");
|
||||
expect(inferCodeLanguage("git status")).toBe("bash");
|
||||
expect(inferCodeLanguage("cd /tmp")).toBe("bash");
|
||||
expect(inferCodeLanguage("a b\n cd /tmp")).toBe("bash");
|
||||
expect(inferCodeLanguage("<!-- comment -->")).toBe("html");
|
||||
expect(inferCodeLanguage("<div>x</div>")).toBe("html");
|
||||
expect(inferCodeLanguage("<p>z</p>")).toBe("html");
|
||||
expect(inferCodeLanguage('{ "a": 1 }')).toBe("json");
|
||||
expect(inferCodeLanguage("zzz qqq aaa")).toBe("text");
|
||||
});
|
||||
|
||||
it("fixBlankLines collapses consecutive blank paragraphs and pads blocks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const remarkStringify = (await import("remark-stringify")).default;
|
||||
const { unified } = await import("unified");
|
||||
const { fixBlankLines, fixOrderedListNumbering } = await import(
|
||||
"./rules.js"
|
||||
);
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
const input =
|
||||
"para a\n\n## heading\n\n1. item\n2. item\n\n```js\nconst x = 1\n```\nfollow-on para\n";
|
||||
const tree = unified().use(remarkParse).parse(input);
|
||||
// Inject a doubled empty-paragraph child to exercise the MD012
|
||||
// branch in `ensureBlankLines` (line 150).
|
||||
(tree as { children: unknown[] }).children.splice(
|
||||
0,
|
||||
0,
|
||||
{ type: "paragraph", children: [] },
|
||||
{ type: "paragraph", children: [] }
|
||||
);
|
||||
unified()
|
||||
.use(fixBlankLines, sink)
|
||||
.use(fixOrderedListNumbering, sink)
|
||||
.runSync(tree as never);
|
||||
const out = unified()
|
||||
.use(remarkStringify, {
|
||||
bullet: "-",
|
||||
emphasis: "*",
|
||||
fences: true,
|
||||
incrementListMarker: true,
|
||||
rule: "-",
|
||||
})
|
||||
.stringify(tree as never);
|
||||
expect(applied.has("MD012")).toBe(true);
|
||||
expect(out).toContain("## heading");
|
||||
});
|
||||
|
||||
it("fixOrderedListNumbering resets start to 1 when not already 1", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const { unified } = await import("unified");
|
||||
const { fixOrderedListNumbering } = await import("./rules.js");
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
const tree = unified().use(remarkParse).parse("5. one\n6. two\n");
|
||||
unified().use(fixOrderedListNumbering, sink).runSync(tree);
|
||||
expect(applied.has("MD029")).toBe(true);
|
||||
});
|
||||
|
||||
it("fixEmphasisAsHeading no-ops when the strong paragraph is the root", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Construct a tree where the matching paragraph has no parent
|
||||
// — exercises the `!parent` early-return guard.
|
||||
const { fixEmphasisAsHeading, HEADING_HINT_EXTENDED } = await import(
|
||||
"./rules.js"
|
||||
);
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
// Hand-build a root that IS itself a paragraph-like shape so
|
||||
// the visitor sees the paragraph at index 0 with parent=root,
|
||||
// then we strip the parent linkage by recursing manually.
|
||||
// Easiest path: call the transformer directly on a tree that
|
||||
// is itself the matching paragraph (so visit invokes the
|
||||
// callback with parent=undefined).
|
||||
const fakeRoot = {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: "NEVER" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const transformer = fixEmphasisAsHeading(sink, HEADING_HINT_EXTENDED);
|
||||
transformer(fakeRoot as never);
|
||||
expect(applied.has("MD036")).toBe(false);
|
||||
});
|
||||
|
||||
it("fixEmphasisAsHeading bails out on multi-child strong", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fixEmphasisAsHeading, HEADING_HINT_EXTENDED } = await import(
|
||||
"./rules.js"
|
||||
);
|
||||
const { unified } = await import("unified");
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
// Strong containing a link rather than a single text node →
|
||||
// the inner-text guard short-circuits.
|
||||
const tree = unified().use(remarkParse).parse("**[NEVER](x)**\n");
|
||||
unified()
|
||||
.use(fixEmphasisAsHeading, sink, HEADING_HINT_EXTENDED)
|
||||
.runSync(tree);
|
||||
expect(applied.has("MD036")).toBe(false);
|
||||
});
|
||||
|
||||
it("fixHorizontalRules registers MD035 on a thematic break", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fixHorizontalRules } = await import("./rules.js");
|
||||
const { unified } = await import("unified");
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
const tree = unified().use(remarkParse).parse("para\n\n---\n\nmore\n");
|
||||
unified().use(fixHorizontalRules, sink).runSync(tree);
|
||||
expect(applied.has("MD035")).toBe(true);
|
||||
});
|
||||
|
||||
it("ensureBlankLines skips undefined children safely", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fixBlankLines } = await import("./rules.js");
|
||||
const { unified } = await import("unified");
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
const tree = unified().use(remarkParse).parse("# A\n");
|
||||
// Inject an undefined hole via splice with a sparse value.
|
||||
const sparse: unknown[] = [];
|
||||
sparse.length = 1;
|
||||
(tree.children as unknown as unknown[]).unshift(sparse[0] as never);
|
||||
unified().use(fixBlankLines, sink).runSync(tree);
|
||||
// Pass — no throw and tree intact
|
||||
expect(tree.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("fixMultipleH1s ignores non-H1 headings", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fixMultipleH1s } = await import("./rules.js");
|
||||
const { unified } = await import("unified");
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
const tree = unified().use(remarkParse).parse("## a\n\n## b\n\n## c\n");
|
||||
unified().use(fixMultipleH1s, sink).runSync(tree);
|
||||
expect(applied.has("MD025")).toBe(false);
|
||||
});
|
||||
|
||||
it("ensureBlankLines leaves blocks alone when already padded by blanks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fixBlankLines } = await import("./rules.js");
|
||||
const { unified } = await import("unified");
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
// Pre-padded heading — both `prev` and `succ` are already
|
||||
// blank paragraphs so the !succIsBlank branch goes false.
|
||||
const tree = unified().use(remarkParse).parse("para a\n\n");
|
||||
(tree.children as Array<unknown>).push(
|
||||
{ type: "heading", depth: 2, children: [] },
|
||||
{ type: "paragraph", children: [] },
|
||||
{ type: "paragraph", children: [{ type: "text", value: "b" }] }
|
||||
);
|
||||
unified().use(fixBlankLines, sink).runSync(tree);
|
||||
// The succ-side path now sees a blank paragraph and skips.
|
||||
expect(tree.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("fixOrderedListNumbering ignores unordered and empty lists", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fixOrderedListNumbering } = await import("./rules.js");
|
||||
const { unified } = await import("unified");
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
// unordered list — `node.ordered === false` short-circuits.
|
||||
const tree = unified().use(remarkParse).parse("- a\n- b\n");
|
||||
unified().use(fixOrderedListNumbering, sink).runSync(tree);
|
||||
expect(applied.has("MD029")).toBe(false);
|
||||
});
|
||||
|
||||
it("fixCodeBlockLanguages skips blocks that already have a language", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fixCodeBlockLanguages } = await import("./rules.js");
|
||||
const { unified } = await import("unified");
|
||||
const remarkParse = (await import("remark-parse")).default;
|
||||
const applied = new Set<string>();
|
||||
const sink = { add: (r: string) => applied.add(r) };
|
||||
const tree = unified()
|
||||
.use(remarkParse)
|
||||
.parse("```ts\nconst x = 1\n```\n");
|
||||
unified().use(fixCodeBlockLanguages, sink).runSync(tree);
|
||||
expect(applied.has("MD040")).toBe(false);
|
||||
});
|
||||
|
||||
it("inferCodeLanguage json requires all three markers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { inferCodeLanguage } = await import("./rules.js");
|
||||
// partial JSON-ish content lacking the colon falls back to text.
|
||||
expect(inferCodeLanguage("{ aaa qqq }")).toBe("text");
|
||||
});
|
||||
|
||||
it("PROPER_NAMES contains the documented entries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const lower = PROPER_NAMES.map(([l]) => l);
|
||||
expect(lower).toEqual([
|
||||
"json",
|
||||
"api",
|
||||
"url",
|
||||
"http",
|
||||
"https",
|
||||
"sql",
|
||||
"css",
|
||||
"html",
|
||||
"xml",
|
||||
"pdf",
|
||||
]);
|
||||
});
|
||||
});
|
||||
259
packages/devtools/tooling/linter/src/markdown-fixer/rules.ts
Normal file
259
packages/devtools/tooling/linter/src/markdown-fixer/rules.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import type {
|
||||
Code,
|
||||
Heading,
|
||||
List,
|
||||
ListItem,
|
||||
Paragraph,
|
||||
Parent,
|
||||
Root,
|
||||
RootContent,
|
||||
Strong,
|
||||
ThematicBreak,
|
||||
} from "mdast";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
/** Mutable rule sink — rule identifiers are added as plugins fire. */
|
||||
export interface RuleSet {
|
||||
add(rule: string): void;
|
||||
}
|
||||
|
||||
/** Hint matcher used by both the basic and extended emphasis-as-heading fixers. */
|
||||
export const HEADING_HINT_BASIC = /^(Step \d+:|[A-Z]\)|[A-Z][A-Z\s]+:)/;
|
||||
export const HEADING_HINT_EXTENDED =
|
||||
/^(Step \d+:|[A-Z]\)|[A-Z][A-Z\s]+:|NEVER|ALWAYS|CRITICAL|IMPORTANT|MANDATORY)/;
|
||||
|
||||
export const PROPER_NAMES: ReadonlyArray<readonly [string, string]> = [
|
||||
["json", "JSON"],
|
||||
["api", "API"],
|
||||
["url", "URL"],
|
||||
["http", "HTTP"],
|
||||
["https", "HTTPS"],
|
||||
["sql", "SQL"],
|
||||
["css", "CSS"],
|
||||
["html", "HTML"],
|
||||
["xml", "XML"],
|
||||
["pdf", "PDF"],
|
||||
];
|
||||
|
||||
interface TextNode {
|
||||
type: "text";
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Heuristic language inference for unannotated fenced code blocks. */
|
||||
export function inferCodeLanguage(value: string): string {
|
||||
const c = value.toLowerCase();
|
||||
if (c.includes("npm ") || c.includes("npx ") || c.includes("yarn ")) {
|
||||
return "bash";
|
||||
}
|
||||
if (
|
||||
c.includes("interface ") ||
|
||||
c.includes(": string") ||
|
||||
c.includes(": number")
|
||||
) {
|
||||
return "typescript";
|
||||
}
|
||||
if (c.includes("const ") || c.includes("let ") || c.includes("function ")) {
|
||||
return "javascript";
|
||||
}
|
||||
if (c.includes("def ") || c.startsWith("from ") || c.includes("\nfrom ")) {
|
||||
return "python";
|
||||
}
|
||||
if (c.includes("# ") || c.includes("## ")) {
|
||||
return "markdown";
|
||||
}
|
||||
if (c.includes("git ") || c.includes(" cd ") || c.startsWith("cd ")) {
|
||||
return "bash";
|
||||
}
|
||||
if (c.includes("<!") || c.includes("<div") || c.includes("<p>")) {
|
||||
return "html";
|
||||
}
|
||||
if (c.includes("{") && c.includes("}") && c.includes(":")) {
|
||||
return "json";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
/** MD025 — demote duplicate H1s to H2. */
|
||||
export function fixMultipleH1s(rules: RuleSet) {
|
||||
return (tree: Root): void => {
|
||||
let count = 0;
|
||||
visit(tree, "heading", (node: Heading) => {
|
||||
if (node.depth === 1) {
|
||||
count += 1;
|
||||
if (count > 1) {
|
||||
node.depth = 2;
|
||||
rules.add("MD025");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** MD036 — rewrite a strong-only paragraph into an H3 when the text matches `matcher`. */
|
||||
export function fixEmphasisAsHeading(rules: RuleSet, matcher: RegExp) {
|
||||
return (tree: Root): void => {
|
||||
visit(
|
||||
tree,
|
||||
"paragraph",
|
||||
(node: Paragraph, index, parent: Parent | undefined) => {
|
||||
const firstChild = node.children[0];
|
||||
if (
|
||||
node.children.length !== 1 ||
|
||||
!firstChild ||
|
||||
firstChild.type !== "strong"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const strong = firstChild as Strong;
|
||||
const inner = strong.children[0];
|
||||
if (
|
||||
strong.children.length !== 1 ||
|
||||
!inner ||
|
||||
inner.type !== "text"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!matcher.test((inner as TextNode).value)) {
|
||||
return;
|
||||
}
|
||||
// remark-util-visit always supplies `parent` and `index`
|
||||
// for non-root nodes; paragraphs are never root, so both
|
||||
// are guaranteed here.
|
||||
if (!parent || typeof index !== "number") {
|
||||
return;
|
||||
}
|
||||
const heading: Heading = {
|
||||
type: "heading",
|
||||
depth: 3,
|
||||
children: strong.children,
|
||||
};
|
||||
parent.children[index] = heading;
|
||||
rules.add("MD036");
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBlankLines(parent: Parent, rules: RuleSet): void {
|
||||
const children = parent.children;
|
||||
const next: RootContent[] = [];
|
||||
for (let i = 0; i < children.length; i += 1) {
|
||||
const current = children[i];
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const prev = i > 0 ? children[i - 1] : undefined;
|
||||
const succ = i < children.length - 1 ? children[i + 1] : undefined;
|
||||
if (
|
||||
current.type === "paragraph" &&
|
||||
current.children.length === 0 &&
|
||||
prev?.type === "paragraph" &&
|
||||
(prev as Paragraph).children.length === 0
|
||||
) {
|
||||
rules.add("MD012");
|
||||
continue;
|
||||
}
|
||||
const isBlock =
|
||||
current.type === "list" ||
|
||||
current.type === "heading" ||
|
||||
current.type === "code";
|
||||
if (prev && isBlock) {
|
||||
const last = next[next.length - 1];
|
||||
if (
|
||||
last?.type !== "paragraph" ||
|
||||
(last.type === "paragraph" && last.children.length > 0)
|
||||
) {
|
||||
next.push({ type: "paragraph", children: [] });
|
||||
rules.add("MD032");
|
||||
}
|
||||
}
|
||||
next.push(current);
|
||||
if (succ && isBlock) {
|
||||
const succIsBlank =
|
||||
succ.type === "paragraph" &&
|
||||
(succ as Paragraph).children.length === 0;
|
||||
if (!succIsBlank) {
|
||||
next.push({ type: "paragraph", children: [] });
|
||||
rules.add("MD032");
|
||||
}
|
||||
}
|
||||
}
|
||||
parent.children = next;
|
||||
}
|
||||
|
||||
/** MD012 / MD022 / MD031 / MD032 — ensure block elements are surrounded by blanks. */
|
||||
export function fixBlankLines(rules: RuleSet) {
|
||||
return (tree: Root): void => {
|
||||
const visitAndFix = (node: Parent): void => {
|
||||
for (const child of node.children) {
|
||||
// Sparse arrays produce `undefined` children — skip them
|
||||
// defensively to keep the in-operator guard safe.
|
||||
if (child && "children" in child) {
|
||||
visitAndFix(child as Parent);
|
||||
}
|
||||
}
|
||||
ensureBlankLines(node, rules);
|
||||
};
|
||||
visitAndFix(tree);
|
||||
};
|
||||
}
|
||||
|
||||
/** MD044 — canonicalise proper-name capitalisation. */
|
||||
export function fixProperNames(rules: RuleSet) {
|
||||
return (tree: Root): void => {
|
||||
visit(tree, "text", (node: TextNode) => {
|
||||
let value = node.value;
|
||||
for (const [lower, proper] of PROPER_NAMES) {
|
||||
const regex = new RegExp(`\\b${lower}\\b`, "gi");
|
||||
const after = value.replace(regex, proper);
|
||||
if (after !== value) {
|
||||
rules.add("MD044");
|
||||
value = after;
|
||||
}
|
||||
}
|
||||
node.value = value;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** MD029 — restart ordered lists at 1 so remark-stringify renumbers correctly. */
|
||||
export function fixOrderedListNumbering(rules: RuleSet) {
|
||||
return (tree: Root): void => {
|
||||
visit(tree, "list", (node: List) => {
|
||||
if (node.ordered && node.children.length > 0) {
|
||||
node.children.forEach((_item: ListItem, idx: number) => {
|
||||
if (idx === 0 && node.start !== 1) {
|
||||
node.start = 1;
|
||||
rules.add("MD029");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** MD035 — record thematic-break canonicalisation (remark-stringify formats `---`). */
|
||||
export function fixHorizontalRules(rules: RuleSet) {
|
||||
return (tree: Root): void => {
|
||||
visit(tree, "thematicBreak", (_node: ThematicBreak) => {
|
||||
rules.add("MD035");
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** MD040 — infer a language for unannotated fenced code blocks. */
|
||||
export function fixCodeBlockLanguages(rules: RuleSet) {
|
||||
return (tree: Root): void => {
|
||||
visit(tree, "code", (node: Code) => {
|
||||
if (
|
||||
node.lang === undefined ||
|
||||
node.lang === null ||
|
||||
node.lang === ""
|
||||
) {
|
||||
node.lang = inferCodeLanguage(node.value);
|
||||
rules.add("MD040");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
15
packages/devtools/tooling/linter/src/telemetry/index.ts
Normal file
15
packages/devtools/tooling/linter/src/telemetry/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
BiomeOutcome,
|
||||
ProcessedResult,
|
||||
RunPhase,
|
||||
} from "./telemetry.js";
|
||||
export {
|
||||
biomeRunsCounter,
|
||||
filesFixedCounter,
|
||||
filesProcessedCounter,
|
||||
recordBiomeRun,
|
||||
recordFileFixed,
|
||||
recordFileProcessed,
|
||||
recordRunDuration,
|
||||
runDurationHistogram,
|
||||
} from "./telemetry.js";
|
||||
105
packages/devtools/tooling/linter/src/telemetry/telemetry.md
Normal file
105
packages/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.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
recordBiomeRun,
|
||||
recordFileFixed,
|
||||
recordFileProcessed,
|
||||
recordRunDuration,
|
||||
} from "./telemetry.js";
|
||||
|
||||
function getMetricPoints(
|
||||
collected: Awaited<ReturnType<typeof flushTestMetrics>>,
|
||||
name: string
|
||||
) {
|
||||
const points: Array<{
|
||||
value: number;
|
||||
attributes: Record<string, unknown>;
|
||||
}> = [];
|
||||
for (const rm of collected) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const metric of sm.metrics) {
|
||||
if (metric.descriptor.name === name) {
|
||||
for (const dp of metric.dataPoints) {
|
||||
const value =
|
||||
typeof dp.value === "number"
|
||||
? dp.value
|
||||
: (dp.value as { count: number }).count;
|
||||
points.push({
|
||||
value,
|
||||
attributes: dp.attributes as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
describe("telemetry", () => {
|
||||
it("record helpers do not throw with no provider registered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A provider IS registered in our test polyfill, but the
|
||||
// helpers must still resolve to undefined and not throw on a
|
||||
// fresh call — this protects production callers who opt out
|
||||
// of telemetry.
|
||||
expect(recordFileFixed("MD025")).toBeUndefined();
|
||||
expect(recordFileProcessed("changed")).toBeUndefined();
|
||||
expect(recordBiomeRun("check", "success")).toBeUndefined();
|
||||
expect(recordRunDuration("total", 5)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("counter and histogram are wired and emit through real MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
recordFileFixed("MD025");
|
||||
recordFileProcessed("changed");
|
||||
recordBiomeRun("check", "success");
|
||||
recordRunDuration("markdown", 12);
|
||||
recordRunDuration("biome", 34);
|
||||
recordRunDuration("total", 50);
|
||||
|
||||
const collected = await flushTestMetrics();
|
||||
const fixed = getMetricPoints(collected, "linter_files_fixed_total");
|
||||
const processed = getMetricPoints(
|
||||
collected,
|
||||
"linter_files_processed_total"
|
||||
);
|
||||
const biome = getMetricPoints(collected, "linter_biome_runs_total");
|
||||
const duration = getMetricPoints(collected, "linter_run_duration_ms");
|
||||
expect(fixed.some((p) => p.attributes["rule"] === "MD025")).toBe(true);
|
||||
expect(
|
||||
processed.some((p) => p.attributes["result"] === "changed")
|
||||
).toBe(true);
|
||||
expect(
|
||||
biome.some(
|
||||
(p) =>
|
||||
p.attributes["mode"] === "check" &&
|
||||
p.attributes["outcome"] === "success"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(duration.some((p) => p.attributes["phase"] === "total")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
71
packages/devtools/tooling/linter/src/telemetry/telemetry.ts
Normal file
71
packages/devtools/tooling/linter/src/telemetry/telemetry.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/linter";
|
||||
|
||||
/**
|
||||
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
|
||||
* first access. When no provider is registered the OTel API returns no-op
|
||||
* instruments, so the helpers below are safe to call from production code
|
||||
* that does not opt into telemetry.
|
||||
*/
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
|
||||
export type ProcessedResult = "changed" | "unchanged";
|
||||
export type BiomeOutcome = "success" | "failure";
|
||||
export type RunPhase = "markdown" | "biome" | "total";
|
||||
|
||||
/** Counter incremented per rule application within `fixMarkdown`. */
|
||||
export const filesFixedCounter: Counter = meter.createCounter(
|
||||
"linter_files_fixed_total",
|
||||
{
|
||||
description:
|
||||
"Markdown fixer rule applications, tagged with the rule identifier",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter incremented per file processed by the markdown fixer. */
|
||||
export const filesProcessedCounter: Counter = meter.createCounter(
|
||||
"linter_files_processed_total",
|
||||
{
|
||||
description:
|
||||
"Markdown files processed, tagged with changed/unchanged result",
|
||||
}
|
||||
);
|
||||
|
||||
/** Counter incremented per biome invocation. */
|
||||
export const biomeRunsCounter: Counter = meter.createCounter(
|
||||
"linter_biome_runs_total",
|
||||
{
|
||||
description:
|
||||
"Biome subprocess invocations, tagged with mode and success/failure",
|
||||
}
|
||||
);
|
||||
|
||||
/** Histogram of run durations, tagged with the orchestrator phase. */
|
||||
export const runDurationHistogram: Histogram = meter.createHistogram(
|
||||
"linter_run_duration_ms",
|
||||
{
|
||||
description: "Linter phase durations in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/** Record a single rule application within the markdown fixer. */
|
||||
export function recordFileFixed(rule: string): void {
|
||||
filesFixedCounter.add(1, { rule });
|
||||
}
|
||||
|
||||
/** Record a per-file outcome from the markdown fixer. */
|
||||
export function recordFileProcessed(result: ProcessedResult): void {
|
||||
filesProcessedCounter.add(1, { result });
|
||||
}
|
||||
|
||||
/** Record a biome invocation outcome. */
|
||||
export function recordBiomeRun(mode: string, outcome: BiomeOutcome): void {
|
||||
biomeRunsCounter.add(1, { mode, outcome });
|
||||
}
|
||||
|
||||
/** Record a phase duration in milliseconds. */
|
||||
export function recordRunDuration(phase: RunPhase, ms: number): void {
|
||||
runDurationHistogram.record(ms, { phase });
|
||||
}
|
||||
15
packages/devtools/tooling/linter/src/types/index.ts
Normal file
15
packages/devtools/tooling/linter/src/types/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
BiomeMode,
|
||||
BiomeReport,
|
||||
ClockLike,
|
||||
CommandInput,
|
||||
CommandResult,
|
||||
CommandRunner,
|
||||
FsAdapter,
|
||||
GlobAdapter,
|
||||
GlobOptions,
|
||||
LintReport,
|
||||
MarkdownFixReport,
|
||||
RunCliOptions,
|
||||
UnifiedLintOptions,
|
||||
} from "./linter.types.js";
|
||||
81
packages/devtools/tooling/linter/src/types/linter.types.md
Normal file
81
packages/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.
|
||||
99
packages/devtools/tooling/linter/src/types/linter.types.ts
Normal file
99
packages/devtools/tooling/linter/src/types/linter.types.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Shared option, adapter, and report types for `@suaveplan/linter`.
|
||||
*
|
||||
* Every public surface in the package references these named interfaces — no
|
||||
* inline object shapes in function signatures (AGENTS.md §11).
|
||||
*/
|
||||
|
||||
/** Input passed to a {@link CommandRunner}. */
|
||||
export interface CommandInput {
|
||||
readonly command: string;
|
||||
readonly args: ReadonlyArray<string>;
|
||||
readonly cwd?: string;
|
||||
readonly env?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** Output produced by a {@link CommandRunner}. */
|
||||
export interface CommandResult {
|
||||
readonly exitCode: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injectable command-execution seam. Production callers use the bundled
|
||||
* `nodeCommandRunner` (wraps `node:child_process.spawn`); tests pass a fake
|
||||
* runner that returns canned results.
|
||||
*/
|
||||
export interface CommandRunner {
|
||||
run(input: CommandInput): Promise<CommandResult>;
|
||||
}
|
||||
|
||||
/** Injectable filesystem seam used by the markdown fixer and resolver. */
|
||||
export interface FsAdapter {
|
||||
readFile(path: string): Promise<string>;
|
||||
writeFile(path: string, data: string): Promise<void>;
|
||||
exists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Options accepted by a {@link GlobAdapter}. */
|
||||
export interface GlobOptions {
|
||||
readonly ignore: ReadonlyArray<string>;
|
||||
readonly cwd: string;
|
||||
}
|
||||
|
||||
/** Injectable glob seam. The default wraps the `glob` npm package. */
|
||||
export interface GlobAdapter {
|
||||
find(pattern: string, options: GlobOptions): Promise<ReadonlyArray<string>>;
|
||||
}
|
||||
|
||||
/** Injectable clock seam — defaults to `Date.now`. */
|
||||
export interface ClockLike {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
/** Per-file report produced by `fixMarkdown`. */
|
||||
export interface MarkdownFixReport {
|
||||
readonly file: string;
|
||||
readonly changed: boolean;
|
||||
readonly rulesApplied: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
/** Mode controlling which biome subcommand is invoked. */
|
||||
export type BiomeMode = "check" | "check-write" | "format-write";
|
||||
|
||||
/** Aggregate report from a single biome run. */
|
||||
export interface BiomeReport {
|
||||
readonly mode: BiomeMode;
|
||||
readonly exitCode: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly success: boolean;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
|
||||
/** Outcome of `runUnifiedLint`. */
|
||||
export interface LintReport {
|
||||
readonly biome: BiomeReport | undefined;
|
||||
readonly markdown: ReadonlyArray<MarkdownFixReport>;
|
||||
readonly success: boolean;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
|
||||
/** Options accepted by the unified orchestrator and the CLI. */
|
||||
export interface UnifiedLintOptions {
|
||||
readonly cwd?: string;
|
||||
readonly fix?: boolean;
|
||||
readonly markdownOnly?: boolean;
|
||||
readonly patterns?: ReadonlyArray<string>;
|
||||
readonly runner?: CommandRunner;
|
||||
readonly fs?: FsAdapter;
|
||||
readonly glob?: GlobAdapter;
|
||||
readonly clock?: ClockLike;
|
||||
}
|
||||
|
||||
/** Options for `runCli`. */
|
||||
export interface RunCliOptions extends UnifiedLintOptions {
|
||||
/** Sink for help / error / summary output. Defaults to `console.log`. */
|
||||
readonly out?: (line: string) => void;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { runUnifiedLint } from "./unified-lint.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
CommandInput,
|
||||
CommandResult,
|
||||
CommandRunner,
|
||||
FsAdapter,
|
||||
GlobAdapter,
|
||||
} from "../types/linter.types.js";
|
||||
import { runUnifiedLint } from "./unified-lint.js";
|
||||
|
||||
function makeFs(seed: Record<string, string>): FsAdapter {
|
||||
const store = new Map<string, string>(Object.entries(seed));
|
||||
return {
|
||||
async readFile(path) {
|
||||
const v = store.get(path);
|
||||
if (v === undefined) {
|
||||
throw new Error("ENOENT");
|
||||
}
|
||||
return v;
|
||||
},
|
||||
async writeFile(path, data) {
|
||||
store.set(path, data);
|
||||
},
|
||||
async exists(path) {
|
||||
return store.has(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeGlob(files: ReadonlyArray<string>): GlobAdapter {
|
||||
return {
|
||||
async find() {
|
||||
return files;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Recorder {
|
||||
runner: CommandRunner;
|
||||
calls: CommandInput[];
|
||||
}
|
||||
|
||||
function makeRunner(sequence: ReadonlyArray<CommandResult>): Recorder {
|
||||
let index = 0;
|
||||
const calls: CommandInput[] = [];
|
||||
const runner: CommandRunner = {
|
||||
async run(input) {
|
||||
calls.push(input);
|
||||
const r = sequence[index] ?? sequence[sequence.length - 1];
|
||||
index += 1;
|
||||
if (!r) {
|
||||
throw new Error("no canned result");
|
||||
}
|
||||
return r;
|
||||
},
|
||||
};
|
||||
return { runner, calls };
|
||||
}
|
||||
|
||||
describe("runUnifiedLint", () => {
|
||||
it("in fix mode runs biome check-write and markdown processor", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n# B\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const { runner, calls } = makeRunner([
|
||||
{ exitCode: 0, stdout: "", stderr: "" }, // probe
|
||||
{ exitCode: 0, stdout: "", stderr: "" }, // check-write
|
||||
]);
|
||||
const report = await runUnifiedLint({
|
||||
fix: true,
|
||||
cwd: ".",
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(report.biome?.mode).toBe("check-write");
|
||||
expect(report.markdown).toHaveLength(1);
|
||||
expect(report.success).toBe(true);
|
||||
expect(calls[1]?.args).toContain("--write");
|
||||
});
|
||||
|
||||
it("in check mode runs biome check and processes no writes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n# B\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const { runner } = makeRunner([
|
||||
{ exitCode: 0, stdout: "", stderr: "" },
|
||||
{ exitCode: 0, stdout: "", stderr: "" },
|
||||
]);
|
||||
const report = await runUnifiedLint({
|
||||
fix: false,
|
||||
cwd: ".",
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(report.biome?.mode).toBe("check");
|
||||
});
|
||||
|
||||
it("markdownOnly skips biome entirely", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
let runnerInvoked = false;
|
||||
const runner: CommandRunner = {
|
||||
async run() {
|
||||
runnerInvoked = true;
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
};
|
||||
const report = await runUnifiedLint({
|
||||
markdownOnly: true,
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(report.biome).toBeUndefined();
|
||||
expect(runnerInvoked).toBe(false);
|
||||
});
|
||||
|
||||
it("biome unavailable yields biome undefined and success based on markdown", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const { runner } = makeRunner([
|
||||
{ exitCode: 127, stdout: "", stderr: "missing" },
|
||||
]);
|
||||
const report = await runUnifiedLint({
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(report.biome).toBeUndefined();
|
||||
expect(report.success).toBe(true);
|
||||
});
|
||||
|
||||
it("orchestrator never calls process exit", { timeout: 5000 }, async () => {
|
||||
// The function returning a resolved promise IS the assertion —
|
||||
// if it called process.exit the test runner would die.
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const { runner } = makeRunner([
|
||||
{ exitCode: 127, stdout: "", stderr: "" },
|
||||
]);
|
||||
const report = await runUnifiedLint({
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(report).toBeDefined();
|
||||
});
|
||||
|
||||
it("reflects biome failure in check-write mode as success=false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const { runner } = makeRunner([
|
||||
{ exitCode: 0, stdout: "", stderr: "" }, // probe
|
||||
{ exitCode: 1, stdout: "", stderr: "boom" }, // check-write
|
||||
]);
|
||||
const report = await runUnifiedLint({
|
||||
fix: true,
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(report.biome?.success).toBe(false);
|
||||
expect(report.success).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates BIOME_FAILED to the caller in check mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const { runner } = makeRunner([
|
||||
{ exitCode: 0, stdout: "", stderr: "" }, // probe
|
||||
{ exitCode: 1, stdout: "", stderr: "boom" }, // check
|
||||
]);
|
||||
// The orchestrator's catch handler in check mode surfaces the
|
||||
// embedded report rather than re-throwing — so the contract is
|
||||
// a failing report, not an exception.
|
||||
const report = await runUnifiedLint({
|
||||
fix: false,
|
||||
runner,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(report.success).toBe(false);
|
||||
expect(report.biome?.success).toBe(false);
|
||||
});
|
||||
|
||||
it("uses default cwd when not supplied", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const { runner, calls } = makeRunner([
|
||||
{ exitCode: 0, stdout: "", stderr: "" },
|
||||
{ exitCode: 0, stdout: "", stderr: "" },
|
||||
]);
|
||||
await runUnifiedLint({ runner, fs, glob });
|
||||
expect(calls[0]?.cwd).toBe(".");
|
||||
});
|
||||
|
||||
it("uses default runner when none supplied and markdownOnly true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const report = await runUnifiedLint({
|
||||
markdownOnly: true,
|
||||
fs,
|
||||
glob,
|
||||
});
|
||||
expect(report.biome).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses default options object when invoked with no args at all", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Exercise the `options: UnifiedLintOptions = {}` default. Run
|
||||
// from a temp dir so the markdown lane finds no targets and
|
||||
// raises NO_TARGETS_FOUND — that satisfies the spec contract
|
||||
// and exercises the default-options branch.
|
||||
const tmp = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const dir = await tmp.mkdtemp(
|
||||
path.join(process.cwd(), ".linter-defopt-")
|
||||
);
|
||||
const before = process.cwd();
|
||||
try {
|
||||
process.chdir(dir);
|
||||
await expect(runUnifiedLint()).rejects.toBeDefined();
|
||||
} finally {
|
||||
process.chdir(before);
|
||||
await tmp.rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default options object when invoked with no args", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// markdownOnly is false by default, runner is nodeCommandRunner —
|
||||
// probe will likely succeed in dev (bunx biome installed) but the
|
||||
// biome run itself may fail; either way the orchestrator returns
|
||||
// a LintReport OR throws BIOME_FAILED, which is what this
|
||||
// scenario asserts.
|
||||
const tmp = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const dir = await tmp.mkdtemp(
|
||||
path.join(process.cwd(), ".linter-unif-tmp-")
|
||||
);
|
||||
const before = process.cwd();
|
||||
try {
|
||||
const file = path.join(dir, "a.md");
|
||||
await tmp.writeFile(file, "# A\n", "utf-8");
|
||||
process.chdir(dir);
|
||||
try {
|
||||
const report = await runUnifiedLint({});
|
||||
expect(report).toBeDefined();
|
||||
} catch (err) {
|
||||
// Real biome may be unavailable or may not lint a
|
||||
// freshly-created dir cleanly; either resolution or a
|
||||
// LinterError raised by the lane is acceptable for
|
||||
// exercising the default-runner branch.
|
||||
expect((err as Error).message).toBeDefined();
|
||||
}
|
||||
} finally {
|
||||
process.chdir(before);
|
||||
await tmp.rm(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates non-BIOME_FAILED runner errors from the biome lane", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
let callCount = 0;
|
||||
const runner: CommandRunner = {
|
||||
async run() {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
// probe succeeds
|
||||
return { exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
// runBiome's runner rejects → RUNNER_FAILED
|
||||
throw new Error("spawn fail");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
runUnifiedLint({ fix: false, runner, fs, glob })
|
||||
).rejects.toMatchObject({ code: "LINTER.RUNNER_FAILED" });
|
||||
});
|
||||
|
||||
it("uses default clock when not supplied", { timeout: 5000 }, async () => {
|
||||
const fs = makeFs({ "a.md": "# A\n" });
|
||||
const glob = makeGlob(["a.md"]);
|
||||
const { runner } = makeRunner([
|
||||
{ exitCode: 0, stdout: "", stderr: "" },
|
||||
{ exitCode: 0, stdout: "", stderr: "" },
|
||||
]);
|
||||
const report = await runUnifiedLint({ runner, fs, glob });
|
||||
expect(report.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
nodeCommandRunner,
|
||||
probeBiomeAvailable,
|
||||
runBiome,
|
||||
} from "../biome-runner/biome-runner.js";
|
||||
import { LinterError } from "../error-codes/error-codes.js";
|
||||
import { nodeGlobAdapter } from "../glob-resolver/glob-resolver.js";
|
||||
import {
|
||||
nodeFsAdapter,
|
||||
processMarkdownFiles,
|
||||
} from "../markdown-fixer/markdown-fixer.js";
|
||||
import { recordRunDuration } from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
BiomeReport,
|
||||
ClockLike,
|
||||
LintReport,
|
||||
MarkdownFixReport,
|
||||
UnifiedLintOptions,
|
||||
} from "../types/linter.types.js";
|
||||
|
||||
const defaultClock: ClockLike = { now: () => Date.now() };
|
||||
|
||||
async function runBiomeLane(
|
||||
options: UnifiedLintOptions
|
||||
): Promise<BiomeReport | undefined> {
|
||||
const runner = options.runner ?? nodeCommandRunner;
|
||||
const cwd = options.cwd ?? ".";
|
||||
const available = await probeBiomeAvailable({ runner, cwd });
|
||||
if (!available) {
|
||||
return undefined;
|
||||
}
|
||||
const mode = options.fix === true ? "check-write" : "check";
|
||||
try {
|
||||
return await runBiome({ cwd, mode, runner });
|
||||
} catch (err) {
|
||||
// `runBiome` only raises `BIOME_FAILED` in `check` mode AND always
|
||||
// attaches the parsed `BiomeReport` under `details.report`, so this
|
||||
// branch is the documented surface that surfaces the embedded
|
||||
// report instead of re-throwing. All other errors propagate.
|
||||
if (err instanceof LinterError && err.code === "LINTER.BIOME_FAILED") {
|
||||
return err.details["report"] as BiomeReport;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function runMarkdownLane(
|
||||
options: UnifiedLintOptions,
|
||||
clock: ClockLike
|
||||
): Promise<{
|
||||
reports: MarkdownFixReport[];
|
||||
durationMs: number;
|
||||
}> {
|
||||
const start = clock.now();
|
||||
const reports = await processMarkdownFiles({
|
||||
cwd: options.cwd ?? ".",
|
||||
fs: options.fs ?? nodeFsAdapter,
|
||||
glob: options.glob ?? nodeGlobAdapter,
|
||||
...(options.patterns ? { patterns: options.patterns } : {}),
|
||||
});
|
||||
const durationMs = Math.max(0, clock.now() - start);
|
||||
recordRunDuration("markdown", durationMs);
|
||||
return { reports, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrator. Runs the biome lane (if available) and the markdown lane,
|
||||
* aggregates them into a {@link LintReport}, and returns. NEVER calls
|
||||
* `process.exit` — translation to an exit code is the CLI's job.
|
||||
*/
|
||||
export async function runUnifiedLint(
|
||||
options: UnifiedLintOptions = {}
|
||||
): Promise<LintReport> {
|
||||
const clock = options.clock ?? defaultClock;
|
||||
const start = clock.now();
|
||||
const biome =
|
||||
options.markdownOnly === true ? undefined : await runBiomeLane(options);
|
||||
const { reports } = await runMarkdownLane(options, clock);
|
||||
const durationMs = Math.max(0, clock.now() - start);
|
||||
const biomeSuccess = biome?.success ?? true;
|
||||
const success = biomeSuccess;
|
||||
recordRunDuration("total", durationMs);
|
||||
return {
|
||||
biome,
|
||||
markdown: reports,
|
||||
success,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
43
packages/devtools/tooling/linter/tests/package-shape.test.ts
Normal file
43
packages/devtools/tooling/linter/tests/package-shape.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = join(__dirname, "..");
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf-8")
|
||||
) as {
|
||||
sideEffects: unknown;
|
||||
bin: Record<string, string>;
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("package shape", () => {
|
||||
it("every feature folder has a matching subpath export", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const srcDir = join(PKG_ROOT, "src");
|
||||
const featureFolders = readdirSync(srcDir, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter(
|
||||
(e) => e.isDirectory() && e.name !== "types" // shared types — no subpath
|
||||
)
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
const subpathKeys = Object.keys(packageJson.exports)
|
||||
.filter((k) => k.startsWith("./") && k !== "./package.json")
|
||||
.map((k) => k.replace(/^\.\//, ""))
|
||||
.filter((k) => k !== "" && !k.includes("."));
|
||||
for (const folder of featureFolders) {
|
||||
expect(subpathKeys).toContain(folder);
|
||||
}
|
||||
});
|
||||
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
expect(packageJson.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("bin entry is registered", { timeout: 5000 }, () => {
|
||||
expect(packageJson.bin["suaveplan-lint"]).toBe("./dist/bin.js");
|
||||
});
|
||||
});
|
||||
34
packages/devtools/tooling/linter/tests/setup/polyfills.ts
Normal file
34
packages/devtools/tooling/linter/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/linter.
|
||||
*
|
||||
* Registers a real in-memory OpenTelemetry MeterProvider BEFORE source
|
||||
* modules import so the global meter resolved on first use binds to it.
|
||||
* The linter package is server-tier and uses node:fs / node:child_process —
|
||||
* no DOM shims required.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/devtools/tooling/linter/tsconfig.lib.json
Normal file
12
packages/devtools/tooling/linter/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
55
packages/devtools/tooling/linter/vite.config.ts
Normal file
55
packages/devtools/tooling/linter/vite.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/devtools-tooling-linter",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
bin: "src/bin.ts",
|
||||
"markdown-fixer/index": "src/markdown-fixer/index.ts",
|
||||
"biome-runner/index": "src/biome-runner/index.ts",
|
||||
"glob-resolver/index": "src/glob-resolver/index.ts",
|
||||
"unified-lint/index": "src/unified-lint/index.ts",
|
||||
"cli/index": "src/cli/index.ts",
|
||||
"error-codes/index": "src/error-codes/index.ts",
|
||||
"telemetry/index": "src/telemetry/index.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"glob",
|
||||
"remark-parse",
|
||||
"remark-stringify",
|
||||
"tslib",
|
||||
"unified",
|
||||
"unist-util-visit",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
34
packages/devtools/tooling/linter/vitest.config.ts
Normal file
34
packages/devtools/tooling/linter/vitest.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"src/bin.ts",
|
||||
"src/types/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
hookTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user