diff --git a/web/scripts/drift/_bom.ts b/web/scripts/drift/_bom.ts new file mode 100644 index 00000000..629a1b2a --- /dev/null +++ b/web/scripts/drift/_bom.ts @@ -0,0 +1,56 @@ +/** + * The BOM as the drift checks read it. hardware/bom/bom_v3.9.csv is the + * source of truth (hard rule 2 in AGENTS.md): check:bom holds it against the + * build guide's parts tables, check:bom-schematic against the schematic PDF. + */ +import { read } from "./_lib"; + +export const BOM = "hardware/bom/bom_v3.9.csv"; + +export type Row = Record; + +// RFC 4180: a quoted cell may hold commas, line breaks and "" for a quote. +function parse(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ""; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '"' && text[i + 1] === '"') cell += text[++i]; + else if (c === '"') quoted = false; + else cell += c; + } else if (c === '"') quoted = true; + else if (c === "," || c === "\n") { + row.push(cell); + cell = ""; + if (c === "\n") { + rows.push(row); + row = []; + } + } else if (c !== "\r") cell += c; + } + if (cell || row.length) rows.push([...row, cell]); + return rows; +} + +/** The BOM's rows, each keyed by the CSV's header line. */ +export function bom(): Row[] { + const [head, ...rows] = parse(read(BOM)); + return rows.filter((r) => r.some(Boolean)).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]))); +} + +/** + * The designators a ref cell names. "J1" is one, and "SW1-SW4" is four, with + * a hyphen or an en dash. Any other cell names none: "-" off the board, the + * guide's "—", and "U1 (sockets)", whose notes say it is not a refdes. + */ +export function designators(ref: string): string[] { + const m = ref.trim().match(/^([A-Z]+)(\d+)(?:\s*[-–]\s*([A-Z]+)(\d+))?$/); + if (!m) return []; + const [, prefix, from, prefixTo, to] = m; + if (!to) return [prefix + from]; + if (prefixTo !== prefix || +to <= +from) throw new Error(`${ref}: a range keeps one prefix and counts up`); + return Array.from({ length: +to - +from + 1 }, (_, i) => `${prefix}${+from + i}`); +} diff --git a/web/scripts/drift/bom-schematic.ts b/web/scripts/drift/bom-schematic.ts new file mode 100644 index 00000000..0e75e6c5 --- /dev/null +++ b/web/scripts/drift/bom-schematic.ts @@ -0,0 +1,37 @@ +/** + * check:bom-schematic: the BOM's designators against the schematic's. + * hardware/pcb/schematic.pdf is KiCad output in stroke fonts, and pdftotext + * (poppler) reads every designator out of its text layer all the same, so + * this is strict from its first day. + * + * A token in the PDF is a designator when it has a designator's prefix: U, + * SW, J and C are on the board, R, D, Q, L and F are what a part added later + * would carry, and USB is what the v3.0 board's USB-C footprint was (hard + * rule 6). Pin names (S1, IO4) and ESP32 carry none of them. Without + * pdftotext this skips. + */ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +import { BOM, bom, designators } from "./_bom"; +import { ROOT, compare, done, skip } from "./_lib"; + +const PDF = "hardware/pcb/schematic.pdf"; +const PREFIXES = ["U", "SW", "J", "C", "R", "D", "Q", "L", "F", "USB"]; + +let text: string | undefined; +try { + text = execFileSync("pdftotext", ["-layout", path.join(ROOT, PDF), "-"], { encoding: "utf8" }); +} catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + skip("designators", "pdftotext is not installed (poppler-utils)"); +} +if (text !== undefined) { + const token = new RegExp(`\\b(?:${PREFIXES.join("|")})\\d+\\b`, "g"); + compare( + "designators", + { label: BOM, items: bom().flatMap((r) => designators(r.ref)) }, + { label: PDF, items: new Set(text.match(token)) }, + ); +} +done(); diff --git a/web/scripts/drift/bom.ts b/web/scripts/drift/bom.ts new file mode 100644 index 00000000..64d4e8af --- /dev/null +++ b/web/scripts/drift/bom.ts @@ -0,0 +1,78 @@ +/** + * check:bom: the BOM against the two parts tables in section 1 of + * BUILD_GUIDE.md. Hard rule 2 derives the guide's tables from the BOM, so a + * designator, a part or a quantity on one side only is drift. + * + * On the board a row is its designators, its quantity and its MPN. The names + * are worded apart ("Rotary encoder w/ switch" for "Rotary encoder with push + * switch") and are not compared. The sockets row has no designator: the BOM + * writes "U1 (sockets)" and the guide "—". Off the board there is neither, so + * the rows pair in order, and the guide's part keeps every word of the BOM's: + * "Sacrificial USB cable (it gets cut)" for "USB cable (sacrificial)". A + * hyphen and an en dash are the same in a quantity, so 6-12 is 6–12. + */ +import { BOM, bom, designators, type Row } from "./_bom"; +import { compare, done, read } from "./_lib"; + +const GUIDE = "BUILD_GUIDE.md"; + +// Bold, code, emphasis and link targets out; a link keeps its text. +const plain = (s: string) => + s + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*`]/g, "") + .trim(); +const dashes = (s: string) => s.replace(/[–—]/g, "-"); + +/** The first table under a heading of the guide, each row keyed by the table's header line. */ +function table(heading: string, columns: string[]): Row[] { + const lines = read(GUIDE).split("\n"); + const at = lines.findIndex((l) => l.trim() === heading); + if (at < 0) throw new Error(`${GUIDE} has no "${heading}"`); + const rows: string[][] = []; + for (const line of lines.slice(at + 1)) { + if (line.startsWith("|")) rows.push(line.trim().replace(/^\||\|$/g, "").split("|").map(plain)); + else if (rows.length || line.startsWith("#")) break; + } + const [head, ...body] = rows.filter((r) => !r.every((c) => /^:?-+:?$/.test(c))); + for (const c of columns) if (!head?.includes(c)) throw new Error(`${GUIDE}, ${heading}: no "${c}" column`); + return body.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]))); +} + +const rows = bom(); + +// On the board: designators, quantity, MPN. The guide's MPN cell ends in the maker, "(Bourns)". +const ref = (cell: string) => { + const d = designators(dashes(cell)); + return d.length > 1 ? `${d[0]}-${d[d.length - 1]}` : (d[0] ?? "—"); +}; +const part = (r: string, qty: string, mpn: string) => `${ref(r)}: ${dashes(qty)} × ${mpn}`; +compare( + "on the board", + { label: BOM, items: rows.filter((r) => r.category === "pcb").map((r) => part(r.ref, r.qty, r.mpn)) }, + { + label: GUIDE, + items: table("### On the board", ["Ref", "Qty", "MPN (Manufacturer)"]).map((g) => + part(g.Ref, g.Qty, g["MPN (Manufacturer)"].replace(/\s*\([^)]*\)$/, "")), + ), + }, +); + +// Off the board: quantity and part, in order. A guide row is named as the BOM's +// row in its place when it holds every word of that row's part. +const words = (s: string) => s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []; +const off = rows.filter((r) => r.category === "off-board"); +console.log("off the board, rows pair in order, and a guide row with every word of its BOM row's part takes that name"); +compare( + "off the board", + { label: BOM, items: off.map((r) => `${dashes(r.qty)} × ${r.part}`) }, + { + label: GUIDE, + items: table("### Off the board", ["Qty", "Part"]).map((g, i) => { + const has = new Set(words(g.Part)); + const same = off[i] && words(off[i].part).every((w) => has.has(w)); + return `${dashes(g.Qty)} × ${same ? off[i].part : g.Part}`; + }), + }, +); +done();