230 lines
7.1 KiB
Markdown
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.
|