Files
2026-05-27 23:29:53 +01:00

230 lines
7.1 KiB
Markdown

# Design — @suaveplan/linter
## Tier
Server-tier — uses `node:fs` and `node:child_process`. Marked via `"engines.node"` and
the absence of a `browser` field. The package never imports from `@suaveplan/*` runtime
code other than `@suaveplan/error` and `@suaveplan/types`.
## Layout
```text
packages/devtools/tooling/linter/
package.json v0.1.0 — subpath exports per feature
tsconfig.lib.json
vite.config.ts ESM-first, dts plugin
vitest.config.ts forks pool, istanbul, 100/100/100/100
README.md
tests/setup/polyfills.ts
src/
index.ts Root barrel — explicit named re-exports
bin.ts One-line CLI binary entry: process.exit(await runCli(...))
error-codes/
error-codes.ts defineCodes("LINTER", ...) + LinterError
error-codes.md
error-codes.test.ts
index.ts
telemetry/
telemetry.ts OTel counters + histogram + record helpers
telemetry.md
telemetry.test.ts
index.ts
types/
linter.types.ts Shared interfaces (no .md required)
index.ts
markdown-fixer/
markdown-fixer.ts fixMarkdown + processMarkdownFiles
markdown-fixer.md
markdown-fixer.test.ts
index.ts
biome-runner/
biome-runner.ts runBiome via CommandRunner seam
biome-runner.md
biome-runner.test.ts
index.ts
glob-resolver/
glob-resolver.ts resolveMarkdownTargets({ patterns, fs, glob })
glob-resolver.md
glob-resolver.test.ts
index.ts
unified-lint/
unified-lint.ts runUnifiedLint orchestrator
unified-lint.md
unified-lint.test.ts
index.ts
cli/
cli.ts runCli(argv, opts?) — pure
cli.md
cli.test.ts
index.ts
```
## Public types
```ts
export interface CommandRunner {
run(input: CommandInput): Promise<CommandResult>;
}
export interface CommandInput {
command: string;
args: ReadonlyArray<string>;
cwd?: string;
env?: Readonly<Record<string, string>>;
}
export interface CommandResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface FsAdapter {
readFile(path: string): Promise<string>;
writeFile(path: string, data: string): Promise<void>;
exists(path: string): Promise<boolean>;
}
export interface GlobAdapter {
find(pattern: string, opts: GlobOptions): Promise<ReadonlyArray<string>>;
}
export interface GlobOptions {
ignore: ReadonlyArray<string>;
cwd: string;
}
export interface MarkdownFixReport {
file: string;
changed: boolean;
rulesApplied: ReadonlyArray<string>;
}
export interface BiomeReport {
mode: "check" | "check-write" | "format-write";
exitCode: number;
stdout: string;
stderr: string;
success: boolean;
durationMs: number;
}
export interface LintReport {
biome?: BiomeReport;
markdown: ReadonlyArray<MarkdownFixReport>;
success: boolean;
durationMs: number;
}
```
## Error codes
`defineCodes("LINTER", [...])`:
- `INVALID_OPTIONS` — Zod validation fails at the public surface.
- `FILE_NOT_FOUND` — single-file path passed to `fixMarkdown` / `resolveMarkdownTargets`
does not exist.
- `NO_TARGETS_FOUND` — glob expansion produced an empty list.
- `READ_FAILED` — `fs.readFile` threw (e.g. permissions, EISDIR).
- `WRITE_FAILED` — `fs.writeFile` threw.
- `MARKDOWN_PARSE_FAILED` — `unified().process(...)` threw (malformed input).
- `BIOME_UNAVAILABLE` — `biome --version` probe via `CommandRunner` returned non-zero.
- `BIOME_FAILED` — biome run returned a non-zero exit AND we are in `check` mode (a
non-zero exit in `check-write` mode is informational and surfaces via the report).
- `RUNNER_FAILED` — `CommandRunner.run` rejected (process spawn failed before exit).
- `UNKNOWN_FLAG` — CLI argv contains an unrecognised flag.
Every code has at least one Verified-by scenario in the spec delta.
## Telemetry
- Counter `linter_files_fixed_total{rule}` — incremented per rule application within
`fixMarkdown`.
- Counter `linter_files_processed_total{result}` — `result ∈ {"changed", "unchanged"}`.
- Counter `linter_biome_runs_total{mode, outcome}` — `outcome ∈ {"success", "failure"}`.
- Histogram `linter_run_duration_ms{phase}` — `phase ∈ {"markdown", "biome", "total"}`.
All instruments are obtained via `metrics.getMeter("@suaveplan/linter")` — when no
`MeterProvider` is registered the API returns no-op instruments so production callers
who do not opt into telemetry pay zero cost.
## CommandRunner seam
The default exported runner wraps `node:child_process.spawn` (NOT `execSync`) so it is
cancellation-friendly and async-native:
```ts
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", (d) => {
stdout += String(d);
});
child.stderr?.on("data", (d) => {
stderr += String(d);
});
child.on("error", reject);
child.on("close", (exitCode) => {
resolve({
exitCode: exitCode ?? 1,
stdout,
stderr,
});
});
});
},
};
```
Tests pass a `CommandRunner` whose `run` is a real async function returning canned
results — not a `vi.fn` mock. This honours rule 2 (no mocks) while keeping the unit-test
runtime hermetic.
## FsAdapter and GlobAdapter defaults
```ts
export const nodeFsAdapter: FsAdapter = {
async readFile(path) {
return fs.readFile(path, "utf-8");
},
async writeFile(path, data) {
await fs.writeFile(path, data, "utf-8");
},
async exists(path) {
try {
await fs.access(path);
return true;
} catch {
return false;
}
},
};
export const nodeGlobAdapter: GlobAdapter = {
async find(pattern, { ignore, cwd }) {
return glob(pattern, { ignore: [...ignore], cwd });
},
};
```
Tests pass in-memory adapters (a `Map<string, string>` for fs; a function returning a
canned list for glob) — again, real fakes, not `vi.mock`.
## CLI argv parsing
Trivial hand-rolled parser — no `commander` dependency for one binary with five flags.
Returns `{ mode, fix, patterns, cwd, showHelp }`. Unknown flags raise
`LinterError(LINTER.UNKNOWN_FLAG)` so the binary wrapper prints the message and exits
non-zero rather than silently treating the flag as a positional pattern.
## Subpath exports
Every feature folder gets its own subpath entry so consumers can import only what they
need (`@suaveplan/linter/markdown-fixer`, `/biome-runner`, etc.) and pay zero cost for the
features they don't use. The root barrel re-exports every public symbol so the default
`@suaveplan/linter` import still works.