Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Shared markdown-table parsing for BOM.md and PINOUT.md (design D9's
* fixed-column contract). Originally lived inline in drift.ts; pulled out so
* the supplier BOM export work (add-supplier-bom-export) can parse BOM.md
* once, the same way, instead of duplicating this.
*/
export interface TableRow {
cells: string[];
}
/**
* Split one row into trimmed cells. The outer pipes are optional, because they
* are optional in GitHub-flavored markdown: `Refdes | Pin | Net` renders as a
* table exactly like `| Refdes | Pin | Net |` does, and a hand- or LLM-authored
* doc may legitimately be written either way.
*/
function splitRow(line: string): string[] {
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((c) => c.trim());
}
/** `|---|:--:|` and friends: the row separating a header from its data. */
function isSeparatorRow(cells: string[]): boolean {
return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c));
}
/**
* Parses every markdown pipe-table row out of a document, across however
* many tables the file contains, skipping separator rows (e.g. `|---|---|`).
* Malformed lines (stray `|` outside a real table) just become a row with
* whatever cells they split into — this function never throws.
*/
export function parseMarkdownTables(md: string): TableRow[] {
return (
scanTableGroups(md)
// A group opened by an un-piped line with no delimiter row is not a table:
// GFM requires the delimiter row for one to exist at all. Without this,
// ordinary prose such as `Second-source options: Yageo | Vishay | KOA`
// under a BOM table becomes a part in `export bom`, which `check` cannot
// catch because `parseCanonicalTables` drops the header-less group.
.filter((g) => g[0]!.piped || g.some((l) => l.separator))
.flat()
.filter((l) => !l.separator)
.map((l) => ({ cells: l.cells }))
);
}
interface ScannedLine {
cells: string[];
separator: boolean;
/** Whether the source line carried a leading pipe. */
piped: boolean;
}
/**
* Group a document's table lines, one group per table. Both readers below go
* through this so they cannot disagree about which documents are readable:
* `check` accepting a BOM.md that `export bom` reads as empty is how a clean
* check turns into a header-only ordering file.
*
* Lines inside a fenced code block are skipped. Docs legitimately show the
* table format as an example, and an example row is not a part or a pin.
*/
function scanTableGroups(md: string): ScannedLine[][] {
const groups: ScannedLine[][] = [];
let current: ScannedLine[] | null = null;
let width = 0; // column count of the open group, set by its first line
// The open fence's delimiter, or null outside one. A fence closes only on the
// same character, at least as long (CommonMark). Toggling on any fence-like
// line would let a shorter or different delimiter *inside* a block end it
// early, and the example rows after it would be read as parts or pins.
let fence: { char: string; length: number } | null = null;
// Split on either line ending: a CRLF document would otherwise leave a
// trailing \r on every line, and \r is a line terminator that `.` does not
// match, so the fence patterns below would never fire on a CRLF file.
for (const line of md.split(/\r?\n/)) {
const t = line.trim();
// Matched against the raw line: CommonMark allows a fence up to three
// spaces of indentation, and four or more makes it content rather than a
// fence. Trimming first would let an indented delimiter inside a block
// read as a closer.
const delim = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
if (delim) {
const marker = delim[1]!;
const char = marker[0]!;
if (!fence) {
fence = { char, length: marker.length };
current = null;
continue;
}
// A closing fence carries no info string; anything else stays content.
if (char === fence.char && marker.length >= fence.length && !delim[2]!.trim()) {
fence = null;
current = null;
continue;
}
}
if (fence || !t.includes('|')) {
current = null; // a blank, prose, or fenced line terminates the table
continue;
}
const cells = splitRow(t);
// Outer pipes make a line unambiguously a table row. Without them, a
// pipe-bearing prose line (`Legend: A | B`) is indistinguishable from a
// row by shape alone, so the column count decides: matching the open
// table's width keeps it as a row, a mismatch ends the table there rather
// than reading the prose as a part/pin. The line still opens a new group,
// in case it is itself the header of an un-piped table.
if (current && !t.startsWith('|') && cells.length !== width) current = null;
if (!current) {
current = [];
width = cells.length;
groups.push(current);
}
// The separator row stays in the group so the header can be located
// relative to it; it is dropped by the readers above and below.
current.push({ cells, separator: isSeparatorRow(cells), piped: t.startsWith('|') });
}
return groups;
}
/** True for a table's header row. BOM.md and PINOUT.md both lead with a
* Refdes or Pin column, so one check covers both doc types. */
export const isHeader = (row: TableRow): boolean =>
row.cells.some((c) => /^(refdes|pin)$/i.test(c));
/**
* Data rows of the CANONICAL table(s) only — those introduced by a Refdes/Pin
* header row (`isHeader`). BOM.md and PINOUT.md legitimately carry supporting
* tables (a quiescent-current roll-up, a net-meaning legend); their rows are
* NOT parts/pins and must never be compared against the schematic. The flat
* `parseMarkdownTables(md).filter(!isHeader)` does exactly that — it merges
* every table's rows — so a second table's first cell gets read as a refdes and
* flagged "not in schematic", which pushes the agent to degrade good docs into
* bullet lists just to appease the drift gate.
*
* This groups lines into tables (a run of pipe-rows, ended by any non-pipe
* line), keeps only the groups whose first row is a Refdes/Pin header, and
* returns those groups' data rows (header dropped). A table with no recognized
* header — including a bare data-only block — is ignored, preserving the
* fixed-column contract (design D9) while letting docs hold extra tables.
*/
export function parseCanonicalRows(md: string): TableRow[] {
return parseCanonicalTables(md).flatMap((t) => t.rows);
}
/**
* Like parseCanonicalRows, but keeps each kept table's header row so a caller
* can resolve columns by *name* instead of a fixed position. PINOUT.md's
* column count is not fixed in practice: the scaffold writes
* `Refdes | Pin | Name | Net | Notes`, but a hand- or LLM-authored table may
* legitimately drop the optional Name/Notes columns and write
* `Refdes | Pin | Net`. A fixed positional net index then reads the wrong cell
* and reports every pin as net "NC" against a doc that is in fact correct —
* a false drift the agent cannot diagnose (the doc plainly shows the net), so
* it loops on finish forever. Resolving by header name fixes that.
*/
export function parseCanonicalTables(md: string): Array<{ header: TableRow; rows: TableRow[] }> {
const tables: Array<{ header: TableRow; rows: TableRow[] }> = [];
for (const g of scanTableGroups(md)) {
// The header is the row directly above the separator. Falling back to the
// first row keeps a table that omits the separator working, and anchoring
// on the separator means a stray pipe-bearing prose line immediately above
// a table no longer hides it.
const sep = g.findIndex((l) => l.separator);
const headerIdx = sep > 0 ? sep - 1 : 0;
const header = g[headerIdx];
if (!header || header.separator || !isHeader({ cells: header.cells })) continue;
tables.push({
header: { cells: header.cells },
rows: g
.slice(headerIdx + 1)
.filter((l) => !l.separator)
.map((l) => ({ cells: l.cells })),
});
}
return tables;
}
/**
* PINOUT.md pin assignments, resolved by column *name* and tolerant of the
* optional Name/Notes columns (see parseCanonicalTables). Only the canonical
* table that carries both a Pin and a Net header is read; a supporting table
* (e.g. a `Net | Role` legend) is ignored. Net names are compared bare, so the
* common `` `VBUS` `` markdown-backtick styling is stripped — the schematic
* stores plain net names, and a backtick-only difference is not real drift.
*/
export function parsePinoutRows(md: string): Array<{ ref: string; pin: string; net: string }> {
const out: Array<{ ref: string; pin: string; net: string }> = [];
const strip = (s: string | undefined): string => (s ?? '').replace(/`/g, '').trim();
for (const { header, rows } of parseCanonicalTables(md)) {
const col = (re: RegExp): number => header.cells.findIndex((c) => re.test(c));
const refI = col(/^refdes$/i);
const pinI = col(/^pin$/i);
const netI = col(/^net$/i);
if (pinI < 0 || netI < 0) continue; // not the pin-assignment table
for (const row of rows) {
out.push({
ref: refI >= 0 ? strip(row.cells[refI]) : '',
pin: strip(row.cells[pinI]),
net: strip(row.cells[netI]),
});
}
}
return out;
}
/**
* Fold the semantically-identical encodings that the model and KiCad render
* differently, so a value that differs only in *encoding* is not flagged as
* drift (#I11). A design that reached ERC-clean once churned for turns on
* `Ihold≥3A` vs `Ihold>=3A` and `0.1"` vs `0.1in` — byte differences with zero
* electrical meaning. Folded here: ≥/>=, ≤/<=, Ω/ohm(s), µ/μ/u, smart quotes,
* and the inch mark (`"` / `″` / a trailing `in`/`inch` after a number). NFKC
* first collapses width/compatibility variants; the explicit rules cover the
* cases NFKC leaves alone (≥, smart quotes, the ohm/inch words).
*/
export function foldEncodings(s: string | undefined): string {
if (!s) return '';
return s
.normalize('NFKC')
.replace(/≥/g, '>=')
.replace(/≤/g, '<=')
.replace(/[ΩΩ]/g, 'ohm') // ohm sign U+2126 / greek capital omega U+03A9
.replace(/\bohms\b/gi, 'ohm')
.replace(/[µμ]/g, 'u') // micro sign U+00B5 / greek small mu U+03BC
.replace(/[“”″]/g, '"') // smart double quotes and double-prime → "
.replace(/[‘’′]/g, "'") // smart single quotes and prime → '
.replace(/(?<=[\d.])\s*(?:inches|inch|in)\b/gi, '"') // 0.1in / 0.1 inch → 0.1"
.trim();
}
/**
* Value-cell equality key: `foldEncodings` plus case- and whitespace-folding.
* Used to compare BOM.md value/footprint cells against schematic symbol values
* so an encoding/case/spacing-only difference is not reported as drift.
*/
export function normalizeValue(s: string | undefined): string {
return foldEncodings(s).replace(/\s+/g, '').toLowerCase();
}
/**
* Footprint equality key: like `normalizeValue` but WITHOUT case-folding (F6).
* A footprint is a KiCad library reference (`Resistor_SMD:R_0603_1608Metric`)
* whose casing is significant — `R_0603` and `r_0603` are not the same library
* id — so lowercasing it would hide a real footprint difference. Encoding and
* spacing are still folded (a stray space or unicode variant is not real drift).
*/
export function normalizeFootprint(s: string | undefined): string {
return foldEncodings(s).replace(/\s+/g, '');
}
/**
* Which of the canonical pin-assignment columns PINOUT.md actually provides.
* `checkDrift` uses this to emit ONE explicit "no Net column" message when the
* doc omits the column entirely, instead of silently checking nothing (a
* correct doc then looks unverified) or — the old positional bug (#I12) —
* reading the wrong cell and reporting every pin as a false `NC` mismatch.
* `hasTable` is false when the doc has no Refdes/Pin-headed table at all.
*/
export function pinoutColumnReport(md: string): { hasTable: boolean; pin: boolean; net: boolean; refdes: boolean } {
let hasTable = false;
let pin = false;
let net = false;
let refdes = false;
for (const { header } of parseCanonicalTables(md)) {
hasTable = true;
const has = (re: RegExp): boolean => header.cells.some((c) => re.test(c));
if (has(/^pin$/i)) pin = true;
if (has(/^net$/i)) net = true;
if (has(/^refdes$/i)) refdes = true;
}
return { hasTable, pin, net, refdes };
}
/**
* A typed BOM.md data row, per the fixed column contract that `init` writes
* (Refdes | Value | Footprint | MPN | Rationale — see scaffold.ts's
* `bomTable`). `flags` currently only ever contains `UNVERIFIED` (the MPN
* column literally says so) or `MISSING_MPN` (no MPN column value at all);
* more may be added as the export/fab-gate work grows.
*/
export interface BomRow {
refdes: string;
value?: string;
footprint?: string;
mpn?: string;
flags: string[];
}
/**
* Parses BOM.md's data rows into typed rows. Columns are resolved by header
* *name* (Refdes/Value/Footprint/MPN), falling back to the canonical position
* when a header is absent — the same header-name discipline `parsePinoutRows`
* uses (#I12), so a doc that reorders or drops an optional column is still read
* correctly instead of silently shifting every cell. Rows without a refdes are
* dropped rather than thrown on: a hand-edited doc with a ragged or partial
* table shouldn't crash `check` or `export bom`, it should just be skipped
* (drift/export callers report the gaps that matter against the schematic).
*/
export function parseBomTable(md: string): BomRow[] {
const out: BomRow[] = [];
for (const { header, rows } of parseCanonicalTables(md)) {
// Resolve by header name; -1 means "not found", so fall back to the
// canonical index for that column (Refdes 0, Value 1, Footprint 2, MPN 3).
const col = (re: RegExp, fallback: number): number => {
const i = header.cells.findIndex((c) => re.test(c));
return i >= 0 ? i : fallback;
};
const refI = col(/^refdes$/i, 0);
const valI = col(/^value$/i, 1);
const fpI = col(/^footprint$/i, 2);
const mpnI = col(/^mpn$/i, 3);
for (const row of rows) {
const refdes = row.cells[refI];
if (!refdes) continue;
const value = row.cells[valI];
const footprint = row.cells[fpI];
const mpn = row.cells[mpnI];
const flags: string[] = [];
if (mpn === 'UNVERIFIED') flags.push('UNVERIFIED');
else if (!mpn) flags.push('MISSING_MPN');
out.push({
refdes,
value: value || undefined,
footprint: footprint || undefined,
mpn: mpn || undefined,
flags,
});
}
}
return out;
}8ecc6ed96This file is pinned to the selected release. Download the original to open it in your design tool.