Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
c62f54059This file is pinned to the selected release. Download the original to open it in your design tool.
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { listSymbols, pinNets, type SchematicSymbol } from '../kicad/sexp.js';
import {
parseCanonicalRows,
parseBomTable,
parsePinoutRows,
pinoutColumnReport,
normalizeValue,
normalizeFootprint,
} from './bom-table.js';
/**
* Doc-vs-schematic drift check (AC-2.3). BOM.md and PINOUT.md use fixed table
* columns (the parseable contract, design D9); free-prose docs are not checked.
*/
export interface DriftMismatch {
doc: string;
claim: string;
actual: string;
}
/**
* The zero-symbol carve-out in checkDrift is right for the create pipeline,
* but it would let `check` silently pass an established repo whose schematic
* was emptied by accident while BOM.md still lists parts. `check` calls this
* alongside checkDrift and reports the result as a warning, not a failure:
* an empty sheet with a populated BOM is either bootstrap (fine) or an
* accident (worth a human look), and only a human can tell which.
*/
export async function emptySchematicWarning(
repoRoot: string,
docsDir: string,
schematic: string,
): Promise<string | null> {
const symbols = await listSymbols(path.join(repoRoot, schematic));
if (symbols.length) return null;
const bomPath = path.join(repoRoot, docsDir, 'BOM.md');
if (!existsSync(bomPath)) return null;
const refs = parseCanonicalRows(await readFile(bomPath, 'utf8'))
.map((r) => r.cells[0])
.filter(Boolean);
if (!refs.length) return null;
return `schematic has zero symbols but BOM.md lists ${refs.length} refdes; if this repo is not mid-bootstrap, the schematic may have been emptied accidentally`;
}
export async function checkDrift(repoRoot: string, docsDir: string, schematic: string): Promise<DriftMismatch[]> {
const mismatches: DriftMismatch[] = [];
const schPath = path.join(repoRoot, schematic);
const symbols = await listSymbols(schPath);
// A schematic with zero symbols is the bootstrap state: during the create
// pipeline the docs legitimately lead the schematic (part-selection writes
// BOM.md before any symbol exists), so comparing against an empty sheet
// deadlocks every docs-touching stage — or worse, teaches the agent to strip
// refdes from BOM.md to appease the gate (#21). Same reasoning as the
// "no schematic configured" carve-out in the check_drift tool.
if (!symbols.length) return mismatches;
const byRef = new Map<string, SchematicSymbol>(symbols.map((s) => [s.ref, s]));
const bomPath = path.join(repoRoot, docsDir, 'BOM.md');
if (existsSync(bomPath)) {
// Resolve BOM columns by header name via the shared parseBomTable (F5), so
// the drift reader and `export bom` never disagree on a reordered table.
const rows = parseBomTable(await readFile(bomPath, 'utf8'));
const seen = new Set<string>();
for (const { refdes: ref, value, footprint } of rows) {
if (!ref) continue;
seen.add(ref);
const sym = byRef.get(ref);
if (!sym) {
mismatches.push({ doc: 'BOM.md', claim: `${ref} exists`, actual: `${ref} not in schematic` });
continue;
}
// Compare on the semantic value, not the byte-exact string: `Ihold≥3A` and
// `Ihold>=3A` are the same value written two ways (#I11). Raw `!==` flagged
// them as drift and whack-a-moled the agent across finish attempts.
if (value !== undefined && normalizeValue(value) !== normalizeValue(sym.value)) {
mismatches.push({ doc: 'BOM.md', claim: `${ref} value ${value}`, actual: `${ref} value ${sym.value}` });
}
// Footprint compare folds encoding/spacing but keeps case (F6): a footprint
// library id is case-sensitive, so lowercasing would hide a real mismatch.
if (
footprint !== undefined &&
normalizeFootprint(footprint) !== normalizeFootprint(sym.footprint)
) {
mismatches.push({
doc: 'BOM.md',
claim: `${ref} footprint ${footprint}`,
actual: `${ref} footprint ${sym.footprint}`,
});
}
}
for (const sym of symbols) {
if (!seen.has(sym.ref)) {
mismatches.push({ doc: 'BOM.md', claim: `${sym.ref} absent`, actual: `${sym.ref} (${sym.value}) in schematic` });
}
}
}
const pinoutPath = path.join(repoRoot, docsDir, 'PINOUT.md');
if (existsSync(pinoutPath)) {
const pinoutMd = await readFile(pinoutPath, 'utf8');
const nets = await pinNets(schPath);
const netOf = new Map(nets.map((p) => [`${p.ref}:${p.pinNumber}`, p.net]));
// If the doc has a Refdes/Pin table but no Net column, say so once and
// explicitly, rather than silently checking nothing (a correct doc then
// reads as unverified) — the counterpart to the old positional bug that
// reported every pin as a false NC (#I12). This tells the model what to fix
// (add the column) instead of leaving it guessing why nets aren't verified.
const cols = pinoutColumnReport(pinoutMd);
if (cols.hasTable && !cols.net) {
mismatches.push({
doc: 'PINOUT.md',
claim: 'the pin table has a Net column (expected header: Refdes | Pin | Net)',
actual: 'no Net column in the pin table, so pin-to-net assignments cannot be checked; add a Net column',
});
}
// Resolve columns by header name, not position: the PINOUT table may be
// `Refdes | Pin | Net` or the scaffold's `Refdes | Pin | Name | Net | Notes`.
// A fixed net index read every pin as "NC" on the 3-column form (#I12).
const rows = parsePinoutRows(pinoutMd);
for (const { ref, pin: pinNumber, net } of rows) {
if (!ref || !pinNumber) continue;
const k = `${ref}:${pinNumber}`;
if (!netOf.has(k)) {
mismatches.push({ doc: 'PINOUT.md', claim: `${k} exists`, actual: `${k} not in schematic` });
continue;
}
const actual = netOf.get(k) ?? 'NC';
const claimed = net === undefined || net === '' ? 'NC' : net;
if (claimed !== actual) {
mismatches.push({ doc: 'PINOUT.md', claim: `${k} net ${claimed}`, actual: `${k} net ${actual}` });
}
}
}
return mismatches;
}