pcbjam/tests/tools/corpus-lint.ts
Gergő Törcsvári fb2aa2759d
kicad-validity B3+C+S: revert toast, corpus lint, .kicad_mod tier
B3 (editor): kicad-binding observes kdoc_meta.revertNonce (deduped, like
seedNonce) → DOC_REVERTED_EVENT → WasmTool doc-reverted-toast (15 s,
data-testid for the deferred e2e check).
C: tests/tools/corpus-lint.ts (npm run corpus:lint) — kicad_tools --lint
over the fixture corpora raw + shared-codec round-trips
(docToFile(fileToDoc(x))); the E3/wrapInBoardEnvelope-class gate. Skips
when output/kicad_tools.js isn't built; CI step lands when kicad_tools
joins the CI app set. 7 fixtures / 7 round-trips green.
S: --lint + --resave gain the .kicad_mod tier (pcbToolsLintFootprint /
pcbToolsResaveFootprint; writer needs CTL_FOR_LIBRARY or the board flags
strip the (version) header). Verified: qa footprint round-trip lints
clean, resave stamps 20260206, garbage exits 1. Also bumps pcbjam-shared
(mergeYUpdates for the bisect pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLua64PCVwkQ1hpWdaf1Gm
2026-07-14 19:02:13 +02:00

115 lines
4.1 KiB
TypeScript

/**
* Corpus lint (kicad-validity 0001 C) — the E3 gate: our own s-expr PRODUCERS
* must agree with KiCad's parser. Runs `kicad_tools --lint` over:
*
* 1. the fixture corpus RAW (tests/fixtures + pcbjam-shared/test/fixtures)
* — guards fixtures themselves going stale vs the current parser;
* 2. every fixture ROUND-TRIPPED through the shared codec
* (docToFile(fileToDoc(text))) — the exact writer path the backend's
* ydoc materialization uses. A codec output KiCad rejects is the
* wrapInBoardEnvelope class of bug (pcbjam f07b997), caught pre-merge.
*
* Needs pcbjam/output/kicad_tools.js (docker/build.sh kicad_tools). When the
* artifact is absent the script SKIPS with exit 0 so it can sit in any CI leg;
* it becomes a hard gate once kicad_tools joins the CI build set (follow-up in
* docs/features/kicad-validity/0001 §5).
*
* Run: cd tests && npm run corpus:lint
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { docToFile, fileToDoc } from "../../web/pcbjam-shared/src/index.js";
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(here, "../..");
const cli = path.join(repo, "output/kicad_tools.js");
if (!existsSync(cli)) {
console.log("corpus-lint: SKIP — output/kicad_tools.js not built");
process.exit(0);
}
const CORPORA = [
path.join(repo, "tests/fixtures"),
path.join(repo, "web/pcbjam-shared/test/fixtures"),
];
const LINTABLE = /\.(kicad_pcb|kicad_sch|kicad_sym|kicad_mod)$/;
// The codec round-trips the doc formats it materializes (not symbol libs).
const CODEC = /\.(kicad_pcb|kicad_sch)$/;
function collect(dir: string): string[] {
if (!existsSync(dir)) return [];
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) {
const p = path.join(entry.parentPath ?? (entry as { path: string }).path, entry.name);
if (entry.isFile() && LINTABLE.test(entry.name)) out.push(p);
}
return out.sort();
}
function lint(file: string): { ok: boolean; detail: string } {
try {
execFileSync("node", [cli, "--lint", file], { stdio: ["ignore", "ignore", "pipe"] });
return { ok: true, detail: "" };
} catch (e) {
const err = e as { status?: number; stderr?: Buffer };
return { ok: false, detail: err.stderr?.toString().trim() ?? String(e) };
}
}
const files = CORPORA.flatMap(collect);
if (!files.length) {
console.error("corpus-lint: no fixtures found — corpus roots moved?");
process.exit(1);
}
const tmp = mkdtempSync(path.join(tmpdir(), "corpus-lint-"));
let failures = 0;
let roundTripped = 0;
try {
for (const file of files) {
const raw = lint(file);
if (!raw.ok) {
failures++;
console.error(`RAW FAIL ${path.relative(repo, file)}\n${raw.detail}`);
continue; // an invalid source can't meaningfully round-trip
}
if (!CODEC.test(file)) continue;
// Hierarchical sub-sheets don't parse standalone through the codec the
// same way; fileToDoc throwing marks "codec can't represent it" — that is
// a finding too, but a soft one: report, don't fail (multi-file sheets
// are exercised by the shared vitest suite with full projects).
let out: string;
try {
out = docToFile(fileToDoc(readFileSync(file, "utf8")));
} catch (e) {
console.warn(
`codec skip ${path.relative(repo, file)}: ${e instanceof Error ? e.message : e}`,
);
continue;
}
const rt = path.join(tmp, `rt-${roundTripped++}${path.extname(file)}`);
writeFileSync(rt, out, "utf8");
const verdict = lint(rt);
if (!verdict.ok) {
failures++;
console.error(
`ROUND-TRIP FAIL ${path.relative(repo, file)} — codec output rejected by KiCad:\n${verdict.detail}`,
);
}
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
console.log(
`corpus-lint: ${files.length} fixture(s), ${roundTripped} round-trip(s), ${failures} failure(s)`,
);
process.exit(failures ? 1 : 0);