From e6938d5bbbd23f35d1bf9b4864afe9530b1bab7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Thu, 15 Oct 2026 08:11:19 +0200 Subject: [PATCH 1/3] feat(library): qualify IPC generators by pad fingerprint, guard generated geometry IPC families are offered only when the installed rendering-core manifest marks them qualified AND OpenPCB's accepted-fingerprint table holds that revision (empty on pinned v0.1.3, so all five families are quarantined). The server re-checks IPC output against its accepted fingerprint, rejects overlapping/degenerate IPC and template output on commit and materialize, and records ipcRevision + padFingerprint provenance. Adds the frozen v0.1.3 legacy deny-list and scripts/gen-ipc-fingerprints.ts. Generated-footprint positives in tests move to the pin-header template. Refs OPENPCB-63. Slice O1 of OPENPCB-63.md (H15). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019f4xrAzSno98LLAfrwv6XQ --- scripts/gen-ipc-fingerprints.ts | 355 ++++++++++++++++ .../designer-refresh-part-footprint.test.ts | 16 +- .../tests/helpers/template-footprint.ts | 43 ++ ...library-component-footprint-assign.test.ts | 61 ++- .../tests/library-generated-geometry.test.ts | 250 ++++++++++++ .../tests/library-generator-import.test.ts | 89 +++- .../tests/library-generator-policy.test.ts | 383 ++++++++++++++++++ .../library-generator-quarantine.test.ts | 127 ------ .../tests/library-pad-fingerprint.test.ts | 169 ++++++++ src/modules/library/backend/import/README.md | 13 +- .../backend/import/generated-geometry.ts | 286 +++++++++++++ .../import/resolve-generated-footprint.ts | 84 +++- .../import/unqualified-generator-error.ts | 17 + .../backend/qualification/pad-fingerprint.ts | 120 ++++++ src/modules/library/backend/routes.ts | 3 + .../import-wizard/preset-quarantine.test.tsx | 78 +++- .../library/shared/generator-policy.ts | 11 +- .../library/shared/generator-qualification.ts | 163 ++++++++ .../legacy-ipc-fingerprints.generated.ts | 101 +++++ .../qualified-ipc-fingerprints.generated.ts | 13 + 20 files changed, 2183 insertions(+), 199 deletions(-) create mode 100644 scripts/gen-ipc-fingerprints.ts create mode 100644 src/core/backend/tests/helpers/template-footprint.ts create mode 100644 src/core/backend/tests/library-generated-geometry.test.ts create mode 100644 src/core/backend/tests/library-generator-policy.test.ts delete mode 100644 src/core/backend/tests/library-generator-quarantine.test.ts create mode 100644 src/core/backend/tests/library-pad-fingerprint.test.ts create mode 100644 src/modules/library/backend/import/generated-geometry.ts create mode 100644 src/modules/library/backend/import/unqualified-generator-error.ts create mode 100644 src/modules/library/backend/qualification/pad-fingerprint.ts create mode 100644 src/modules/library/shared/generator-qualification.ts create mode 100644 src/modules/library/shared/legacy-ipc-fingerprints.generated.ts create mode 100644 src/modules/library/shared/qualified-ipc-fingerprints.generated.ts diff --git a/scripts/gen-ipc-fingerprints.ts b/scripts/gen-ipc-fingerprints.ts new file mode 100644 index 00000000..dfe8ab3b --- /dev/null +++ b/scripts/gen-ipc-fingerprints.ts @@ -0,0 +1,355 @@ +#!/usr/bin/env bun +// IPC-7351B pad-fingerprint tables (H15 §3.2) — the data behind generator +// availability and footprint classification. +// +// bun scripts/gen-ipc-fingerprints.ts --legacy # regenerate the legacy deny-list +// bun scripts/gen-ipc-fingerprints.ts --legacy --check # drift check (exit 1 on drift) +// bun scripts/gen-ipc-fingerprints.ts --qualified # append the installed revision blocks +// bun scripts/gen-ipc-fingerprints.ts --qualified --check # parity + disjointness check +// +// --legacy runs only while the installed rendering-core is 0.1.3, the ONE +// legacy IPC generator that ever shipped; afterwards the file is frozen and the +// check reports a skip. Run --qualified only AFTER the O6 DRC qualification +// (library-generator-qualification.test.ts) passes on the installed revision: +// it appends that evidence to an append-only table. Revoke a revision by +// deleting its block by hand. +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import renderingPackage from "../node_modules/@openpcb/rendering-core/package.json"; +import { padGeometryFingerprint } from "../src/modules/library/backend/qualification/pad-fingerprint"; +import { + installedIpcQualification, + ipcFingerprintKey, +} from "../src/modules/library/shared/generator-qualification"; +import { + LEGACY_IPC_FINGERPRINT_PRESETS, + LEGACY_IPC_GENERATOR, + LEGACY_IPC_REMOVED, +} from "../src/modules/library/shared/legacy-ipc-fingerprints.generated"; +import { + QUALIFIED_IPC_FINGERPRINTS, + type QualifiedIpcFingerprintTable, +} from "../src/modules/library/shared/qualified-ipc-fingerprints.generated"; +import { buildFootprintRenderModel } from "../src/shared/rendering/footprint-preview-builder"; +import { + ALL_FAMILIES, + generateFootprint, + type DensityLevel, + type PackageFamily, +} from "../src/shared/rendering/ipc7351b"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const LEGACY_FILE = path.join( + ROOT, + "src/modules/library/shared/legacy-ipc-fingerprints.generated.ts", +); +const QUALIFIED_FILE = path.join( + ROOT, + "src/modules/library/shared/qualified-ipc-fingerprints.generated.ts", +); +const LEGACY_VERSION = "0.1.3"; +const DENSITIES: readonly DensityLevel[] = ["most", "nominal", "least"]; + +interface RemovedEntry { + readonly fingerprint: string; + readonly presets: readonly string[]; + readonly note: string; +} + +type MutableTable = Record>>; + +function presetKey(family: string, size: string, density: DensityLevel): string { + return `${family}|${size}|${density}`; +} + +/** Every preset of the installed generator, both projections the library stores. */ +function installedOutputs(): { + family: PackageFamily; + size: string; + density: DensityLevel; + source: string; + preview: string; +}[] { + const out = []; + for (const family of ALL_FAMILIES) { + for (const size of family.sizes) { + for (const density of DENSITIES) { + const { source } = generateFootprint(family.id, size.label, density); + out.push({ + family: family.id, + size: size.label, + density, + source: padGeometryFingerprint(source.pads), + preview: padGeometryFingerprint(buildFootprintRenderModel(source).pads), + }); + } + } + } + return out; +} + +/** fingerprint → where the accepted table holds it. */ +function acceptedLocations(table: QualifiedIpcFingerprintTable): Map { + const out = new Map(); + for (const family of Object.keys(table).sort()) { + const revisions = table[family] ?? {}; + for (const revision of Object.keys(revisions).map(Number).sort((a, b) => a - b)) { + const block = revisions[revision] ?? {}; + for (const key of Object.keys(block).sort()) { + const fingerprint = block[key]; + if (fingerprint) out.set(fingerprint, `${family} revision ${revision} ${key}`); + } + } + } + return out; +} + +/** Drop accepted fingerprints from the deny-list, recording why (§5: the legacy pads were correct). */ +function subtractAccepted( + entries: Map, + removed: RemovedEntry[], + table: QualifiedIpcFingerprintTable, +): void { + for (const [fingerprint, where] of acceptedLocations(table)) { + const presets = entries.get(fingerprint); + if (!presets) continue; + entries.delete(fingerprint); + const note = `accepted as ${where}`; + removed.push({ fingerprint, presets, note }); + console.log(`legacy deny-list: removed ${fingerprint} (${presets.join(", ")}): ${note}`); + } + removed.sort((a, b) => (a.fingerprint < b.fingerprint ? -1 : 1)); +} + +function legacyEntriesFromInstalled(): { entries: Map; combinations: number } { + const entries = new Map(); + const outputs = installedOutputs(); + for (const output of outputs) { + const key = presetKey(output.family, output.size, output.density); + for (const fingerprint of new Set([output.source, output.preview])) { + const presets = entries.get(fingerprint) ?? []; + if (!presets.includes(key)) presets.push(key); + entries.set(fingerprint, presets); + } + } + return { entries, combinations: outputs.length }; +} + +function quote(value: string): string { + return JSON.stringify(value); +} + +function emitLegacy( + entries: Map, + removed: readonly RemovedEntry[], + combinations: number, +): string { + const rows = [...entries.entries()] + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map( + ([fingerprint, presets]) => + ` ${quote(fingerprint)}: [${[...presets].sort().map(quote).join(", ")}],`, + ); + const removedRows = removed.map( + (entry) => + ` { fingerprint: ${quote(entry.fingerprint)}, presets: [${entry.presets.map(quote).join(", ")}], note: ${quote(entry.note)} },`, + ); + return [ + `// GENERATED by scripts/gen-ipc-fingerprints.ts --legacy from @openpcb/rendering-core ${LEGACY_VERSION}. Do not edit.`, + "// FROZEN: that generator (== the in-tree generator dda0d07…81c3ccd) is the only legacy IPC-7351B", + "// generator that ever shipped, so this deny-list is complete and final.", + "", + "/** Identity of the generator this deny-list was produced from. */", + "export const LEGACY_IPC_GENERATOR = {", + ' package: "@openpcb/rendering-core",', + ` version: ${quote(LEGACY_VERSION)},`, + ` combinations: ${combinations},`, + ' projections: ["source.pads", "buildFootprintRenderModel(source).pads"],', + "} as const;", + "", + "/** fingerprint → presets (\"family|size|density\") that emit it: the label map for reasons. */", + "export const LEGACY_IPC_FINGERPRINT_PRESETS: Readonly> = {", + ...rows, + "};", + "", + "/** Every pad fingerprint the only legacy IPC generator (rendering-core v0.1.3 == in-tree dda0d07…81c3ccd)", + " * can emit: ALL_FAMILIES × sizes × densities, fingerprinted over BOTH source.pads and", + " * buildFootprintRenderModel(source).pads (deduplicated). Frozen; LEGACY_IPC_GENERATOR records the generator identity. */", + "export const LEGACY_IPC_FINGERPRINTS: ReadonlySet = new Set(", + " Object.keys(LEGACY_IPC_FINGERPRINT_PRESETS),", + ");", + "", + "/** Fingerprints dropped because an accepted revision emits them: those legacy pads were already correct. */", + "export const LEGACY_IPC_REMOVED: readonly {", + " readonly fingerprint: string;", + " readonly presets: readonly string[];", + " readonly note: string;", + removedRows.length === 0 ? "}[] = [];" : "}[] = [", + ...(removedRows.length === 0 ? [] : [...removedRows, "];"]), + "", + ].join("\n"); +} + +function emitQualified(table: QualifiedIpcFingerprintTable): string { + const families = Object.keys(table).sort(); + const body: string[] = []; + for (const family of families) { + const revisions = table[family] ?? {}; + body.push(` ${family}: {`); + for (const revision of Object.keys(revisions).map(Number).sort((a, b) => a - b)) { + const block = revisions[revision] ?? {}; + body.push(` ${revision}: {`); + for (const key of Object.keys(block).sort()) { + body.push(` ${quote(key)}: ${quote(block[key] ?? "")},`); + } + body.push(" },"); + } + body.push(" },"); + } + return [ + "// GENERATED by scripts/gen-ipc-fingerprints.ts --qualified. Append-only: never edit a block.", + "// Revoke a revision by deleting its block; affected parts re-flag at read time (H15 §3.2).", + "", + "/** family → accepted revision → \"size|density\" → pad fingerprint. */", + "export type QualifiedIpcFingerprintTable = Readonly<", + " Record>>>>", + ">;", + "", + "/**", + " * family → accepted revision → \"size|density\" → fingerprint. Append-only; delete a revision block to revoke it.", + " * Generated by scripts/gen-ipc-fingerprints.ts --qualified AFTER O6 passes on that revision.", + " */", + body.length === 0 + ? "export const QUALIFIED_IPC_FINGERPRINTS: QualifiedIpcFingerprintTable = {};" + : "export const QUALIFIED_IPC_FINGERPRINTS: QualifiedIpcFingerprintTable = {", + ...(body.length === 0 ? [] : [...body, "};"]), + "", + ].join("\n"); +} + +/** The installed generator's block for one family: "size|density" → preview fingerprint. */ +function installedBlock(family: PackageFamily): Record { + const block: Record = {}; + for (const output of installedOutputs()) { + if (output.family === family) { + block[ipcFingerprintKey(output.size, output.density)] = output.preview; + } + } + return block; +} + +function sameBlock(a: Readonly>, b: Readonly>): boolean { + const keys = Object.keys(a).sort(); + return ( + keys.join("\n") === Object.keys(b).sort().join("\n") && + keys.every((key) => a[key] === b[key]) + ); +} + +function fail(message: string): never { + console.error(`gen-ipc-fingerprints: ${message}`); + process.exit(1); +} + +async function writeOrCheck(file: string, text: string, check: boolean): Promise { + const current = await fs.readFile(file, "utf8").catch(() => null); + if (current === text) return true; + if (check) { + console.error(`drift: ${path.relative(ROOT, file)} differs from the generator output`); + return false; + } + await fs.writeFile(file, text); + console.log(`wrote ${path.relative(ROOT, file)}`); + return true; +} + +async function runLegacy(check: boolean): Promise { + if (renderingPackage.version !== LEGACY_VERSION) { + if (check) { + console.log( + `legacy deny-list is frozen (installed rendering-core ${renderingPackage.version}); check skipped`, + ); + return; + } + fail( + `--legacy needs the legacy generator ${LEGACY_VERSION}; installed is ${renderingPackage.version}. The table is frozen.`, + ); + } + const { entries, combinations } = legacyEntriesFromInstalled(); + const removed: RemovedEntry[] = []; + subtractAccepted(entries, removed, QUALIFIED_IPC_FINGERPRINTS); + const ok = await writeOrCheck( + LEGACY_FILE, + emitLegacy(entries, removed, combinations), + check, + ); + if (!ok) fail("run `bun scripts/gen-ipc-fingerprints.ts --legacy` and commit the result"); + console.log( + `legacy deny-list: ${entries.size} fingerprints over ${combinations} presets${check ? " (up to date)" : ""}`, + ); +} + +async function runQualified(check: boolean): Promise { + const installed = installedIpcQualification(); + const table: MutableTable = {}; + for (const [family, revisions] of Object.entries(QUALIFIED_IPC_FINGERPRINTS)) { + table[family] = {}; + for (const [revision, block] of Object.entries(revisions)) { + table[family]![Number(revision)] = { ...block }; + } + } + if (installed === null) { + if (!check) fail("the installed rendering-core has no IPC7351B_QUALIFICATION manifest; nothing to qualify"); + console.log(`no qualification manifest in rendering-core ${renderingPackage.version}; parity check skipped`); + } else { + for (const family of ALL_FAMILIES) { + const entry = installed[family.id]; + if (!entry?.qualified) continue; + const block = installedBlock(family.id); + const existing = table[family.id]?.[entry.revision]; + if (existing) { + if (!sameBlock(existing, block)) { + fail( + `${family.id} revision ${entry.revision}: installed output differs from the accepted block. Accepted blocks are never edited; the generator must bump its revision.`, + ); + } + } else if (check) { + console.log(`${family.id} revision ${entry.revision}: not accepted (family stays unavailable)`); + } else { + (table[family.id] ??= {})[entry.revision] = block; + console.log(`${family.id} revision ${entry.revision}: accepted ${Object.keys(block).length} presets`); + } + } + } + + const entries = new Map( + Object.entries(LEGACY_IPC_FINGERPRINT_PRESETS).map(([fingerprint, presets]) => [ + fingerprint, + [...presets], + ]), + ); + const removed: RemovedEntry[] = [...LEGACY_IPC_REMOVED]; + subtractAccepted(entries, removed, table); + if (check && removed.length !== LEGACY_IPC_REMOVED.length) { + fail("accepted and legacy fingerprint sets overlap; run --qualified to record the removal"); + } + const qualifiedOk = await writeOrCheck(QUALIFIED_FILE, emitQualified(table), check); + const legacyOk = + removed.length === LEGACY_IPC_REMOVED.length || + (await writeOrCheck( + LEGACY_FILE, + // The frozen file's own count: the installed generator is no longer the legacy one. + emitLegacy(entries, removed, LEGACY_IPC_GENERATOR.combinations), + check, + )); + if (!qualifiedOk || !legacyOk) fail("fingerprint tables drifted"); +} + +const args = new Set(process.argv.slice(2)); +const check = args.has("--check"); +if (args.has("--legacy") === args.has("--qualified")) { + fail("pass exactly one of --legacy or --qualified (optionally with --check)"); +} +if (args.has("--legacy")) await runLegacy(check); +else await runQualified(check); diff --git a/src/core/backend/tests/designer-refresh-part-footprint.test.ts b/src/core/backend/tests/designer-refresh-part-footprint.test.ts index 34886ecd..9ef21a1c 100644 --- a/src/core/backend/tests/designer-refresh-part-footprint.test.ts +++ b/src/core/backend/tests/designer-refresh-part-footprint.test.ts @@ -15,6 +15,7 @@ import { createHttpServer } from "../http/create-http-server"; import { ModuleRuntime } from "../modules/module-loader"; import { ModuleRouterRegistry } from "../router/module-registry"; import { MentionRegistry } from "../mentions"; +import { pinHeaderGenerator, templatePayload } from "./helpers/template-footprint"; const PLACEHOLDER = "fp-no-footprint-yet"; const SESSION = "refresh-fp-session"; @@ -143,18 +144,13 @@ describe("designer: footprintStale + refresh_part_footprint", () => { positionMm: { x: 7.5, y: -3.25 }, }); - // Library assigns a real footprint → both parts stale. + // Library assigns a real footprint → both parts stale. (Every IPC family is + // quarantined on the pinned rendering-core, H15 D1: use an 8-pad template.) + const generator = pinHeaderGenerator(2, 4); const added = await lib(env, "POST", `/components/${comp}/footprints`, { mode: "generated", - source: (({ source, metadata }) => ({ source, metadata }))( - generateFootprint("soic", "SOIC-8", "nominal"), - ), - generator: { - kind: "ipc7351b", - family: "soic", - size: "SOIC-8", - density: "nominal", - }, + source: templatePayload(generator), + generator, }); expect(added.status).toBe(201); const newFp = added.json.data.footprintId as string; diff --git a/src/core/backend/tests/helpers/template-footprint.ts b/src/core/backend/tests/helpers/template-footprint.ts new file mode 100644 index 00000000..ab2838b5 --- /dev/null +++ b/src/core/backend/tests/helpers/template-footprint.ts @@ -0,0 +1,43 @@ +/** + * Generated-footprint fixtures that do not depend on an IPC family being + * available: every IPC family is quarantined on the pinned rendering-core + * (H15 D1), so positive generated-footprint paths use a parametric template. + */ +import { resolveGeneratedFootprint } from "../../../../modules/library/backend/import/resolve-generated-footprint"; +import type { GeneratedFootprintMetadata } from "../../../../shared/rendering/ipc7351b"; +import type { FootprintRenderSource } from "../../../../shared/rendering/types"; + +export interface TemplateGenerator { + kind: "template"; + templateId: string; + params: Record; +} + +/** A THT pin header (2.54 mm, vertical): `rows × pinsPerRow` pads numbered 1…n. */ +export function pinHeaderGenerator( + rows: number, + pinsPerRow: number, +): TemplateGenerator { + return { + kind: "template", + templateId: "pin-header", + params: { + rows, + pinsPerRow, + pitchMm: 2.54, + mount: "tht", + orientation: "vertical", + }, + }; +} + +/** What the client posts beside the generator: the server's own output, as JSON. */ +export function templatePayload(generator: TemplateGenerator): { + source: FootprintRenderSource; + metadata: GeneratedFootprintMetadata; +} { + const resolved = resolveGeneratedFootprint({ generator }); + return JSON.parse( + JSON.stringify({ source: resolved.source, metadata: resolved.metadata }), + ); +} diff --git a/src/core/backend/tests/library-component-footprint-assign.test.ts b/src/core/backend/tests/library-component-footprint-assign.test.ts index 17b4823d..852a88a7 100644 --- a/src/core/backend/tests/library-component-footprint-assign.test.ts +++ b/src/core/backend/tests/library-component-footprint-assign.test.ts @@ -4,12 +4,14 @@ import path from "node:path"; import type { LibrarySDK } from "../../../sdks"; import { MODULE_SDK_TOKENS } from "../../../sdks"; import { generateFootprint } from "../../../shared/rendering/ipc7351b"; +import { ipcFamilyAvailability } from "../../../modules/library/shared/generator-qualification"; import { resetSharedSqliteForTesting } from "../db/sqlite-client"; import { DiagnosticsStore } from "../diagnostics/diagnostics-store"; import { createHttpServer } from "../http/create-http-server"; import { ModuleRuntime } from "../modules/module-loader"; import { ModuleRouterRegistry } from "../router/module-registry"; import { MentionRegistry } from "../mentions"; +import { pinHeaderGenerator, templatePayload } from "./helpers/template-footprint"; const PLACEHOLDER = "fp-no-footprint-yet"; @@ -92,6 +94,14 @@ async function importNone( } // Generated footprints are re-derived server-side from these parameters. +// Every IPC family is quarantined on the pinned rendering-core (H15 D1), so the +// generated positives use an 8-pad template. +const HEADER8_GENERATOR = pinHeaderGenerator(2, 4); + +function header8() { + return templatePayload(HEADER8_GENERATOR); +} + const SOIC8_GENERATOR = { kind: "ipc7351b", family: "soic", @@ -104,8 +114,10 @@ function soic8() { return { source: result.source, metadata: result.metadata }; } +const SOIC_AVAILABLE = ipcFamilyAvailability("soic").available; + describe("library: assign / replace component default footprint", () => { - test("POST /components/:id/footprints (generated SOIC-8) links + sets default", async () => { + test("POST /components/:id/footprints (generated pin header) links + sets default", async () => { const { server, sdk } = await boot(); const id = await importNone(server, "FpAddA", ["1", "2"]); const before = await sdk.resolveComponentForPlacement(id); @@ -113,8 +125,8 @@ describe("library: assign / replace component default footprint", () => { const res = await call(server, "POST", `/components/${id}/footprints`, { mode: "generated", - source: soic8(), - generator: SOIC8_GENERATOR, + source: header8(), + generator: HEADER8_GENERATOR, }); expect(res.status).toBe(201); const footprintId = res.json.data.footprintId as string; @@ -131,6 +143,35 @@ describe("library: assign / replace component default footprint", () => { ]); }); + test.if(SOIC_AVAILABLE)("POST /components/:id/footprints (generated SOIC-8) links + sets default", async () => { + const { server, sdk } = await boot(); + const id = await importNone(server, "FpAddIpc", ["1", "2"]); + const res = await call(server, "POST", `/components/${id}/footprints`, { + mode: "generated", + source: soic8(), + generator: SOIC8_GENERATOR, + }); + expect(res.status).toBe(201); + const after = await sdk.resolveComponentForPlacement(id); + expect(after?.footprint.footprintId).toBe(res.json.data.footprintId); + expect(after?.footprint.preview?.pads.length).toBe(8); + }); + + test.if(!SOIC_AVAILABLE)("POST footprints with a quarantined IPC family is 422; nothing persisted", async () => { + const { server, sdk } = await boot(); + const id = await importNone(server, "FpAddQuarantined", ["1", "2"]); + const res = await call(server, "POST", `/components/${id}/footprints`, { + mode: "generated", + source: soic8(), + generator: SOIC8_GENERATOR, + }); + expect(res.status).toBe(422); + expect(res.json.code).toBe("UNQUALIFIED_GENERATOR"); + const after = await sdk.resolveComponentForPlacement(id); + expect(after?.footprint.footprintId).toBe(PLACEHOLDER); + expect(after?.footprintVariants.length).toBe(1); + }); + test("POST footprints with setDefault=false keeps the old default", async () => { const { server, sdk } = await boot(); const id = await importNone(server, "FpAddB", ["1", "2"]); @@ -155,8 +196,8 @@ describe("library: assign / replace component default footprint", () => { const id = await importNone(server, "FpAddC", ["1", "99"]); const res = await call(server, "POST", `/components/${id}/footprints`, { mode: "generated", - source: soic8(), - generator: SOIC8_GENERATOR, + source: header8(), + generator: HEADER8_GENERATOR, }); expect(res.status).toBe(400); const after = await sdk.resolveComponentForPlacement(id); @@ -170,8 +211,8 @@ describe("library: assign / replace component default footprint", () => { const other = await importNone(server, "FpPatchSrc", ["1"]); const created = await call(server, "POST", `/components/${other}/footprints`, { mode: "generated", - source: soic8(), - generator: SOIC8_GENERATOR, + source: header8(), + generator: HEADER8_GENERATOR, }); const fpId = created.json.data.footprintId as string; @@ -200,8 +241,8 @@ describe("library: assign / replace component default footprint", () => { const src = await importNone(server, "FpMmSrc", ["1"]); const created = await call(server, "POST", `/components/${src}/footprints`, { mode: "generated", - source: soic8(), - generator: SOIC8_GENERATOR, + source: header8(), + generator: HEADER8_GENERATOR, }); const fpId = created.json.data.footprintId as string; const id = await importNone(server, "FpMm", ["1", "42"]); @@ -235,7 +276,7 @@ describe("library: assign / replace component default footprint", () => { server, "POST", `/components/${builtin!.id}/footprints`, - { mode: "generated", source: soic8(), generator: SOIC8_GENERATOR }, + { mode: "generated", source: header8(), generator: HEADER8_GENERATOR }, ); expect(post.status).toBe(400); }); diff --git a/src/core/backend/tests/library-generated-geometry.test.ts b/src/core/backend/tests/library-generated-geometry.test.ts new file mode 100644 index 00000000..a62d9abd --- /dev/null +++ b/src/core/backend/tests/library-generated-geometry.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from "bun:test"; +import { + ALL_FAMILIES, + generateFootprint, +} from "../../../shared/rendering/ipc7351b"; +import { + defaultValues, + getTemplate, + PARAMETRIC_TEMPLATES, + type ParamValues, +} from "../../../shared/rendering/parametric"; +import type { FootprintRenderSourcePad } from "../../../shared/rendering/types"; +import { + assertGeneratedGeometry, + generatedGeometryDefects, +} from "../../../modules/library/backend/import/generated-geometry"; +import { + resolveGeneratedFootprint, + UnqualifiedGeneratorError, +} from "../../../modules/library/backend/import/resolve-generated-footprint"; +import { installedIpcQualification } from "../../../modules/library/shared/generator-qualification"; + +const LEGACY_INSTALLED = installedIpcQualification() === null; + +function pad( + id: string, + number: string, + x: number, + y: number, + extra: Partial & Record = {}, +): FootprintRenderSourcePad { + return { + id, + number, + shape: "rect", + centerMm: { x, y }, + widthMm: 1, + heightMm: 1, + rotationDeg: 0, + layer: "F.Cu", + ...extra, + } as FootprintRenderSourcePad; +} + +function kinds(pads: readonly FootprintRenderSourcePad[]): string[] { + return generatedGeometryDefects(pads).map((defect) => defect.kind); +} + +// The four schema-valid overlapping sets reproduced in rendering-core's +// parametric-geometry.test.ts (the all-"" mounting array among them). +const OVERLAPPING: readonly { template: string; values: ParamValues }[] = [ + { template: "pin-header", values: { rows: 1, pinsPerRow: 4, pitchMm: 1.27, mount: "tht", orientation: "vertical" } }, + { template: "pin-header", values: { rows: 1, pinsPerRow: 4, pitchMm: 0.5, mount: "smd", orientation: "vertical" } }, + { template: "screw-terminal", values: { poles: 2, pitchMm: 2.54, drillMm: 2.5 } }, + { template: "mounting-array", values: { rows: 1, cols: 2, pitchXMm: 1, pitchYMm: 1, drillMm: 8, plated: true } }, +]; + +const VALID_MOUNTING_ARRAYS: readonly ParamValues[] = [ + { rows: 2, cols: 2, pitchXMm: 20, pitchYMm: 20, drillMm: 3.2, plated: false }, + { rows: 2, cols: 2, pitchXMm: 20, pitchYMm: 20, drillMm: 3.2, plated: true }, + { rows: 3, cols: 2, pitchXMm: 10, pitchYMm: 15, drillMm: 2.2, plated: true }, +]; + +describe("template reproductions", () => { + for (const { template, values } of OVERLAPPING) { + test(`${template} ${JSON.stringify(values)} is rejected`, () => { + const source = getTemplate(template)!.generate(values).source; + const defects = generatedGeometryDefects(source.pads); + expect(defects.some((defect) => defect.kind === "pad-overlap")).toBe(true); + expect(() => assertGeneratedGeometry(source)).toThrow( + UnqualifiedGeneratorError, + ); + expect(() => + resolveGeneratedFootprint({ + generator: { kind: "template", templateId: template, params: values }, + }), + ).toThrow("overlap"); + }); + } + + test("the all-\"\" mounting array overlap is caught on copper AND on the holes", () => { + const source = getTemplate("mounting-array")!.generate(OVERLAPPING[3]!.values) + .source; + const defects = generatedGeometryDefects(source.pads); + expect(defects.map((defect) => defect.kind).sort()).toEqual([ + "drill-overlap", + "pad-overlap", + ]); + expect(defects[0]!.padIds).toEqual(["hole-1", "hole-2"]); + expect(defects[0]!.padNumbers).toEqual(["", ""]); + }); + + for (const values of VALID_MOUNTING_ARRAYS) { + test(`valid all-"" mounting array ${JSON.stringify(values)} is accepted`, () => { + const source = getTemplate("mounting-array")!.generate(values).source; + expect(source.pads.every((p) => p.number === "")).toBe(true); + expect(generatedGeometryDefects(source.pads)).toEqual([]); + expect(() => + resolveGeneratedFootprint({ + generator: { kind: "template", templateId: "mounting-array", params: values }, + }), + ).not.toThrow(); + }); + } + + test("every template's defaults generate clean geometry", () => { + for (const template of PARAMETRIC_TEMPLATES) { + const values = defaultValues(template.schema); + expect(generatedGeometryDefects(template.generate(values).source.pads)).toEqual([]); + } + }); + + test("the message names at most five defects", () => { + const source = getTemplate("pin-header")!.generate({ + rows: 1, + pinsPerRow: 20, + pitchMm: 0.5, + mount: "tht", + orientation: "vertical", + }).source; + let message = ""; + try { + assertGeneratedGeometry(source); + } catch (error) { + message = (error as Error).message; + } + expect(message).toMatch(/; and \d+ more\. Regenerate it/); + expect(message.split("; ").length).toBe(6); + }); +}); + +describe("pad numbering", () => { + test("a repeated non-empty number on separate copper is a defect", () => { + const defects = generatedGeometryDefects([ + pad("a", "1", 0, 0), + pad("b", "1", 5, 0), + pad("c", "2", 10, 0), + ]); + expect(defects).toEqual([ + { kind: "duplicate-pad-number", padIds: ["a", "b"], padNumbers: ["1", "1"] }, + ]); + }); + + test("a same-number stack (EP with in-pad vias, both sides) is allowed", () => { + expect( + kinds([ + pad("ep", "5", 0, 0, { widthMm: 3, heightMm: 3 }), + pad("via-1", "5", -0.8, 0, { + shape: "circle", + widthMm: 0.6, + heightMm: 0.6, + drillDiameterMm: 0.3, + }), + pad("via-2", "5", 0.8, 0, { + shape: "circle", + widthMm: 0.6, + heightMm: 0.6, + drillDiameterMm: 0.3, + }), + pad("ep-back", "5", 0, 0, { widthMm: 2, heightMm: 2, layer: "B.Cu" }), + pad("pin", "1", 3, 0), + ]), + ).toEqual([]); + }); + + test("empty-number pads are exempt from duplicates but not from overlap", () => { + expect(kinds([pad("h1", "", 0, 0), pad("h2", "", 10, 0)])).toEqual([]); + expect(kinds([pad("h1", "", 0, 0), pad("h2", "", 0.5, 0)])).toEqual([ + "pad-overlap", + ]); + }); + + test("non-positive sizes are defects and skip the pair checks", () => { + expect( + kinds([ + pad("zero", "1", 0, 0, { widthMm: 0 }), + pad("nan", "2", 0, 0, { heightMm: Number.NaN }), + pad("ok", "3", 0, 0), + ]), + ).toEqual(["non-positive-size", "non-positive-size"]); + }); +}); + +describe("exact copper and drill gaps", () => { + test("pads on different copper layers do not overlap each other", () => { + expect( + kinds([pad("top", "1", 0, 0), pad("bottom", "2", 0, 0, { layer: "B.Cu" })]), + ).toEqual([]); + }); + + test("rounded corners are measured exactly, not by their bounds", () => { + const round = { shape: "roundrect" as const, roundrectRatio: 0.5 }; + // Bounds overlap by 0.2 on both axes; the discs are 0.131 apart. + expect(kinds([pad("a", "1", 0, 0, round), pad("b", "2", 0.8, 0.8, round)])).toEqual( + [], + ); + expect(kinds([pad("a", "1", 0, 0), pad("b", "2", 0.8, 0.8)])).toEqual([ + "pad-overlap", + ]); + }); + + test("touching copper counts as overlap", () => { + expect(kinds([pad("a", "1", 0, 0), pad("b", "2", 1, 0)])).toEqual([ + "pad-overlap", + ]); + }); + + test("structural drill offset: holes overlap while the copper does not", () => { + // Fields the pinned pad type lacks are read structurally. + expect( + kinds([ + pad("a", "1", 0, 0, { drillDiameterMm: 0.5, drillOffsetMm: { x: 2, y: 0 } }), + pad("b", "2", 3, 0, { drillDiameterMm: 0.5, drillOffsetMm: { x: -1, y: 0 } }), + ]), + ).toEqual(["drill-overlap"]); + }); + + test("structural drill slot: the routed stadium is measured, not its tool disc", () => { + const slotted = (id: string, number: string, x: number) => + pad(id, number, x, 0, { + shape: "oval", + widthMm: 1, + heightMm: 3, + drillDiameterMm: 0.5, + drillSlotMm: { widthMm: 0.5, heightMm: 2.5 }, + rotationDeg: 90, + }); + // Rotated 90°: the slots run along X; centres 2.2 apart, slot length 2.5. + expect(kinds([slotted("a", "1", 0), slotted("b", "2", 2.2)])).toEqual([ + "pad-overlap", + "drill-overlap", + ]); + }); +}); + +test.if(LEGACY_INSTALLED)( + "legacy IPC outputs: only QFN carries a geometric defect (the rest need the fingerprint policy)", + () => { + const families = new Set(); + for (const family of ALL_FAMILIES) { + for (const size of family.sizes) { + for (const density of ["most", "nominal", "least"] as const) { + const pads = generateFootprint(family.id, size.label, density).source.pads; + if (generatedGeometryDefects(pads).length > 0) families.add(family.id); + } + } + } + expect([...families]).toEqual(["qfn"]); + }, +); diff --git a/src/core/backend/tests/library-generator-import.test.ts b/src/core/backend/tests/library-generator-import.test.ts index 989eb9a3..1f10108f 100644 --- a/src/core/backend/tests/library-generator-import.test.ts +++ b/src/core/backend/tests/library-generator-import.test.ts @@ -5,6 +5,7 @@ import { ALL_FAMILIES, generateFootprint, } from "../../../shared/rendering/ipc7351b"; +import { getTemplate } from "../../../shared/rendering/parametric"; import type { SymbolRenderSource } from "../../../shared/rendering/types"; import { getSharedSqlite, @@ -14,7 +15,9 @@ import { DiagnosticsStore } from "../diagnostics/diagnostics-store"; import { createHttpServer } from "../http/create-http-server"; import { ModuleRuntime } from "../modules/module-loader"; import { ModuleRouterRegistry } from "../router/module-registry"; +import { ipcFamilyAvailability } from "../../../modules/library/shared/generator-qualification"; import { getKicadFixturePath } from "./helpers/kicad-fixtures"; +import { pinHeaderGenerator, templatePayload } from "./helpers/template-footprint"; let server: ReturnType; let symbolId: string; @@ -50,8 +53,12 @@ const drawnSymbol: { source: SymbolRenderSource; referencePrefix: string } = { }; async function post(endpoint: string, body: unknown): Promise { + return postLibrary(`imports/${endpoint}`, body); +} + +async function postLibrary(subpath: string, body: unknown): Promise { return server.fetch( - new Request(`http://localhost/api/modules/library/imports/${endpoint}`, { + new Request(`http://localhost/api/modules/library/${subpath}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), @@ -122,8 +129,8 @@ afterAll(() => { for (const endpoint of ["generated", "drawn"]) { test(`${endpoint}: every quarantined size/density returns actionable 422 with no library writes`, async () => { const before = snapshot(); - for (const family of ALL_FAMILIES.filter((family) => - ["sot", "qfn"].includes(family.id), + for (const family of ALL_FAMILIES.filter( + (family) => !ipcFamilyAvailability(family.id).available, )) { for (const size of family.sizes) { for (const density of ["most", "nominal", "least"] as const) { @@ -191,7 +198,56 @@ for (const endpoint of ["generated", "drawn"]) { } }); - test(`${endpoint}: allowed tuple persists exact server geometry and generator provenance`, async () => { + test(`${endpoint}: template generator persists exact server geometry and provenance`, async () => { + const generator = pinHeaderGenerator(1, 2); + const preview = templatePayload(generator); + const response = await post( + endpoint, + request(endpoint, { generator, ...preview }), + ); + expect(response.status).toBe(201); + const payload = (await response.json()) as { + data: { componentId: string }; + }; + const row = getSharedSqlite() + .query<{ data_json: string }, [string]>( + "SELECT f.data_json FROM library_footprints f JOIN library_components c ON c.footprint_id=f.id WHERE c.id=?", + ) + .get(payload.data.componentId)!; + const data = JSON.parse(row.data_json); + expect(data.raw.source).toEqual(preview.source); + expect(data.normalized.name).toBe(preview.metadata.name); + expect(data.provenance.generator).toEqual({ + ...generator, + params: expect.objectContaining(generator.params), + }); + expect(data.provenance.generatorVersion).toBe( + getTemplate("pin-header")!.generatorVersion, + ); + }); + + test(`${endpoint}: overlapping template output returns 422 with no library writes`, async () => { + const before = snapshot(); + const response = await post( + endpoint, + request(endpoint, { + generator: { + kind: "template", + templateId: "screw-terminal", + params: { poles: 2, pitchMm: 2.54, drillMm: 2.5 }, + }, + }), + ); + expect(response.status).toBe(422); + const problem = (await response.json()) as { code: string; detail: string }; + expect(problem.code).toBe("UNQUALIFIED_GENERATOR"); + expect(problem.detail).toContain("pads 1 and 2 overlap"); + expect(snapshot()).toBe(before); + }); + + // Every family is quarantined on the pinned rendering-core (H15 D1); this + // runs once a qualified, accepted generator is installed. + test.if(ipcFamilyAvailability("chip").available)(`${endpoint}: allowed tuple persists exact server geometry and generator provenance`, async () => { const generator = { kind: "ipc7351b", family: "chip", @@ -226,9 +282,34 @@ for (const endpoint of ["generated", "drawn"]) { expect(data.provenance.presetParameters).toEqual( ALL_FAMILIES[0].sizes.find((size) => size.label === "0603")!.params, ); + expect(data.provenance.padFingerprint).toMatch(/^[a-f0-9]{64}$/); }); } +test("template materialize refuses overlapping output and serves clean output", async () => { + const rejected = await postLibrary("templates/pin-header/materialize", { + params: { rows: 1, pinsPerRow: 4, pitchMm: 1.27, mount: "tht" }, + }); + expect(rejected.status).toBe(422); + const problem = (await rejected.json()) as { code: string; detail: string }; + expect(problem.code).toBe("UNQUALIFIED_GENERATOR"); + expect(problem.detail).toContain("overlap"); + + const served = await postLibrary("templates/mounting-array/materialize", { + params: {}, + }); + expect(served.status).toBe(200); + const body = (await served.json()) as { + data: { source: { pads: { number: string }[] } }; + }; + expect(body.data.source.pads.map((pad) => pad.number)).toEqual([ + "", + "", + "", + "", + ]); +}); + test("provenance='drawn' cannot bypass generated endpoint validation", async () => { const preview = generateFootprint("sot", "SOT-23", "nominal"); const before = snapshot(); diff --git a/src/core/backend/tests/library-generator-policy.test.ts b/src/core/backend/tests/library-generator-policy.test.ts new file mode 100644 index 00000000..29481e23 --- /dev/null +++ b/src/core/backend/tests/library-generator-policy.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, test } from "bun:test"; +import renderingPackage from "../../../../node_modules/@openpcb/rendering-core/package.json"; +import { buildFootprintRenderModel } from "../../../shared/rendering/footprint-preview-builder"; +import { + ALL_FAMILIES, + generateFootprint, + type PackageFamily, +} from "../../../shared/rendering/ipc7351b"; +import { + generatorUnavailableReason, + type IpcGeneratorIdentity, +} from "../../../modules/library/shared/generator-policy"; +import { + acceptedIpcFingerprint, + installedIpcQualification, + ipcFamilyAvailability, + ipcFingerprintKey, + type InstalledIpcQualification, +} from "../../../modules/library/shared/generator-qualification"; +import type { QualifiedIpcFingerprintTable } from "../../../modules/library/shared/qualified-ipc-fingerprints.generated"; +import { padGeometryFingerprint } from "../../../modules/library/backend/qualification/pad-fingerprint"; +import { + resolveGeneratedFootprint, + UnqualifiedGeneratorError, +} from "../../../modules/library/backend/import/resolve-generated-footprint"; + +const densities = ["most", "nominal", "least"] as const; +const REMEDY = "Import a verified footprint or draw one instead."; +/** rendering-core ≤ v0.1.3 ships no qualification manifest: the legacy generator. */ +const LEGACY_INSTALLED = installedIpcQualification() === null; +const FAMILIES: readonly PackageFamily[] = ALL_FAMILIES.map((family) => family.id); + +const CHIP_0603: IpcGeneratorIdentity = { + kind: "ipc7351b", + family: "chip", + size: "0603", + density: "nominal", +}; + +/** A synthetic installed manifest + accepted table that accepts chip 0603 as the generator emits it now. */ +function acceptChip0603(revision = 7) { + const fingerprint = padGeometryFingerprint( + buildFootprintRenderModel(generateFootprint("chip", "0603", "nominal").source) + .pads, + ); + return { + fingerprint, + installed: { chip: { revision, qualified: true } } as InstalledIpcQualification, + accepted: { + chip: { [revision]: { [ipcFingerprintKey("0603", "nominal")]: fingerprint } }, + } as QualifiedIpcFingerprintTable, + }; +} + +describe("installed generator (pinned rendering-core)", () => { + test.if(renderingPackage.version === "0.1.3")( + "rendering-core 0.1.3 ships no qualification manifest", + () => { + expect(installedIpcQualification()).toBeNull(); + }, + ); + + test.if(LEGACY_INSTALLED)( + "every IPC family is quarantined with an actionable reason (D1)", + () => { + for (const family of FAMILIES) { + const reason = generatorUnavailableReason(family); + expect(reason).not.toBeNull(); + expect(reason).toContain("unavailable"); + expect(reason!.endsWith(REMEDY)).toBe(true); + } + // The pre-H15 SOT / QFN reasons are kept verbatim. + expect(generatorUnavailableReason("sot")).toBe( + "SOT presets are unavailable because generated pad counts and tab layouts are incorrect. Import a verified footprint or draw one instead.", + ); + expect(generatorUnavailableReason("qfn")).toBe( + "QFN presets are unavailable because the exposed pad overlaps signal pads. Import a verified footprint or draw one instead.", + ); + }, + ); + + test.if(LEGACY_INSTALLED)( + "legacy SOT generator reproduces the quarantined counts and missing SOT-223 tab", + () => { + const counts = { "SOT-23": 4, "SOT-23-5": 6, "SOT-323": 4, "SOT-223": 4 }; + for (const density of densities) { + for (const [size, count] of Object.entries(counts)) { + const source = generateFootprint("sot", size, density).source; + expect(source.pads).toHaveLength(count); + if (size === "SOT-223") { + expect( + new Set(source.pads.map((pad) => `${pad.widthMm}:${pad.heightMm}`)) + .size, + ).toBe(1); + } + } + } + }, + ); + + test.if(LEGACY_INSTALLED)( + "every legacy exposed QFN preset overlaps its perimeter pads at every density", + () => { + const family = ALL_FAMILIES.find((family) => family.id === "qfn")!; + for (const size of family.sizes) { + for (const density of densities) { + const pads = generateFootprint("qfn", size.label, density).source.pads; + const ep = pads[pads.length - 1]; + if (!ep) throw new Error("QFN fixture has no exposed pad"); + const overlap = pads + .slice(0, -1) + .filter( + (pad) => + Math.abs(pad.centerMm.x - ep.centerMm.x) < + (pad.widthMm + ep.widthMm) / 2 && + Math.abs(pad.centerMm.y - ep.centerMm.y) < + (pad.heightMm + ep.heightMm) / 2, + ); + expect(overlap.length).toBe(pads.length - 1); + } + } + }, + ); + + test.if(LEGACY_INSTALLED)( + "legacy chip and SOIC lands sit outside the KiCad oracle positions (D1 evidence)", + () => { + // KiCad R_0603_1608Metric centre |x| 0.825; SOIC-8_3.9x4.9mm_P1.27mm 2.475. + const chip = generateFootprint("chip", "0603", "nominal").source.pads; + expect(chip.map((pad) => Math.abs(pad.centerMm.x))).toEqual([1.15, 1.15]); + const soic = generateFootprint("soic", "SOIC-8", "nominal").source.pads; + expect(soic.every((pad) => Math.abs(pad.centerMm.x) === 3)).toBe(true); + }, + ); +}); + +describe("installedIpcQualification feature detection", () => { + test("absent or non-object manifests read as null (quarantine)", () => { + for (const namespace of [ + null, + {}, + { IPC7351B_QUALIFICATION: null }, + { IPC7351B_QUALIFICATION: [] }, + { IPC7351B_QUALIFICATION: "qualified" }, + ]) { + expect(installedIpcQualification(namespace)).toBeNull(); + } + }); + + test("any malformed family entry voids the whole manifest", () => { + for (const entry of [ + null, + { revision: 0, qualified: true }, + { revision: -1, qualified: true }, + { revision: 1.5, qualified: true }, + { revision: "2", qualified: true }, + { revision: 2, qualified: "yes" }, + { revision: 2 }, + ]) { + expect( + installedIpcQualification({ + IPC7351B_QUALIFICATION: { + chip: { revision: 2, qualified: true }, + soic: entry, + }, + }), + ).toBeNull(); + } + }); + + test("a well-formed manifest keeps known families and ignores unknown keys", () => { + expect( + installedIpcQualification({ + IPC7351B_QUALIFICATION: { + chip: { revision: 2, qualified: true, sources: ["IPC-SM-782"] }, + qfn: { revision: 3, qualified: false }, + bga: { revision: 1, qualified: true }, + }, + }), + ).toEqual({ + chip: { revision: 2, qualified: true }, + qfn: { revision: 3, qualified: false }, + }); + }); +}); + +describe("ipcFamilyAvailability", () => { + const accepted: QualifiedIpcFingerprintTable = { + chip: { 2: { "0603|nominal": "f".repeat(64) } }, + }; + + test("no manifest ⇒ every family unavailable with its legacy reason", () => { + for (const family of FAMILIES) { + const availability = ipcFamilyAvailability(family, null, accepted); + expect(availability.available).toBe(false); + if (availability.available) continue; + expect(availability.reason.endsWith(REMEDY)).toBe(true); + } + }); + + test("family missing from the manifest ⇒ unavailable", () => { + const availability = ipcFamilyAvailability("soic", {}, accepted); + expect(availability).toEqual({ + available: false, + reason: `SOIC presets are unavailable because the installed generator declares no qualification for them. ${REMEDY}`, + }); + }); + + test("manifest says unqualified ⇒ unavailable even with an accepted block", () => { + const availability = ipcFamilyAvailability( + "chip", + { chip: { revision: 2, qualified: false } }, + accepted, + ); + expect(availability).toEqual({ + available: false, + reason: `Chip presets from generator revision 2 are not marked qualified by the installed generator. ${REMEDY}`, + }); + }); + + test("qualified upstream but no accepted block for that revision ⇒ generic reason", () => { + const tables: QualifiedIpcFingerprintTable[] = [ + {}, + { chip: { 1: { "0603|nominal": "a" } } }, + ]; + for (const table of tables) { + expect( + ipcFamilyAvailability( + "chip", + { chip: { revision: 2, qualified: true } }, + table, + ), + ).toEqual({ + available: false, + reason: `Chip presets from generator revision 2 have not passed OpenPCB's land-pattern qualification. ${REMEDY}`, + }); + } + }); + + test("qualified upstream with an accepted block ⇒ available at that revision", () => { + expect( + ipcFamilyAvailability( + "chip", + { chip: { revision: 2, qualified: true } }, + accepted, + ), + ).toEqual({ available: true, revision: 2 }); + }); + + test("generatorUnavailableReason delegates to the installed availability", () => { + for (const family of FAMILIES) { + const availability = ipcFamilyAvailability(family); + expect(generatorUnavailableReason(family)).toBe( + availability.available ? null : availability.reason, + ); + } + }); +}); + +for (const family of ALL_FAMILIES) { + for (const size of family.sizes) { + for (const density of densities) { + test(`${family.id}/${size.label}/${density}: policy matches server generator resolution`, () => { + const generator: IpcGeneratorIdentity = { + kind: "ipc7351b", + family: family.id, + size: size.label, + density, + }; + const availability = ipcFamilyAvailability(family.id); + if (!availability.available) { + expect(() => resolveGeneratedFootprint({ generator })).toThrow( + new UnqualifiedGeneratorError(availability.reason), + ); + return; + } + const expected = generateFootprint(family.id, size.label, density); + const result = resolveGeneratedFootprint({ + generator, + source: expected.source, + metadata: expected.metadata, + }); + expect(result.source).toEqual(expected.source); + expect(result.metadata).toEqual(expected.metadata); + expect(result.provenance.generator).toEqual(generator); + if (!("presetParameters" in result.provenance)) + throw new Error("expected IPC provenance"); + expect(result.provenance.presetParameters).toEqual(size.params); + expect(result.provenance.generatorPackage.name).toBe( + "@openpcb/rendering-core", + ); + expect(result.provenance.ipcRevision).toBe(availability.revision); + const accepted = acceptedIpcFingerprint( + family.id, + availability.revision, + size.label, + density, + ); + expect(accepted).not.toBeNull(); + expect(result.provenance.padFingerprint).toBe(accepted!); + }); + } + } +} + +describe("server fingerprint check", () => { + test("accepted output resolves with diagnostic revision and pad fingerprint", () => { + const { fingerprint, installed, accepted } = acceptChip0603(); + const expected = generateFootprint("chip", "0603", "nominal"); + const result = resolveGeneratedFootprint( + { generator: CHIP_0603, source: expected.source, metadata: expected.metadata }, + { installed, accepted }, + ); + if (!("presetParameters" in result.provenance)) + throw new Error("expected IPC provenance"); + expect(result.provenance.ipcRevision).toBe(7); + expect(result.provenance.padFingerprint).toBe(fingerprint); + expect(result.provenance.generatedSourceSha256).toMatch(/^[a-f0-9]{64}$/); + }); + + test("installed output that differs from its accepted evidence is refused, family stays offered", () => { + const { installed } = acceptChip0603(); + const tables: QualifiedIpcFingerprintTable[] = [ + { chip: { 7: { "0603|nominal": "0".repeat(64) } } }, + // Block exists, preset missing from it. + { chip: { 7: { "0402|nominal": "0".repeat(64) } } }, + ]; + for (const accepted of tables) { + expect(ipcFamilyAvailability("chip", installed, accepted).available).toBe( + true, + ); + expect(() => + resolveGeneratedFootprint({ generator: CHIP_0603 }, { installed, accepted }), + ).toThrow( + new UnqualifiedGeneratorError( + "The installed generator's output differs from its qualification evidence.", + ), + ); + } + }); + + test("a revision without an accepted block is refused with the generic reason", () => { + const { accepted } = acceptChip0603(7); + expect(() => + resolveGeneratedFootprint( + { generator: CHIP_0603 }, + { installed: { chip: { revision: 8, qualified: true } }, accepted }, + ), + ).toThrow("have not passed OpenPCB's land-pattern qualification"); + }); +}); + +test("identity rejects unknown kinds, sizes, densities, omitted parameters and spoofed metadata", () => { + const generator = { ...CHIP_0603 }; + for (const value of [ + undefined, + null, + {}, + { ...generator, kind: "parametric" }, + { ...generator, size: "invented" }, + { ...generator, density: "invented" }, + { ...generator, provenance: "drawn" }, + ]) { + expect(() => resolveGeneratedFootprint({ generator: value })).toThrow( + UnqualifiedGeneratorError, + ); + } + // Reach the preview comparison past the availability gate. + const inputs = acceptChip0603(); + const preview = generateFootprint("chip", "0603", "nominal"); + expect(() => + resolveGeneratedFootprint( + { generator, source: { ...preview.source, name: "spoofed" } }, + inputs, + ), + ).toThrow("preview does not match"); + expect(() => + resolveGeneratedFootprint( + { generator, metadata: { ...preview.metadata, name: "spoofed" } }, + inputs, + ), + ).toThrow("preview does not match"); +}); diff --git a/src/core/backend/tests/library-generator-quarantine.test.ts b/src/core/backend/tests/library-generator-quarantine.test.ts deleted file mode 100644 index b16932ab..00000000 --- a/src/core/backend/tests/library-generator-quarantine.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { expect, test } from "bun:test"; -import { - ALL_FAMILIES, - generateFootprint, -} from "../../../shared/rendering/ipc7351b"; -import { generatorUnavailableReason } from "../../../modules/library/shared/generator-policy"; -import { - resolveGeneratedFootprint, - UnqualifiedGeneratorError, -} from "../../../modules/library/backend/import/resolve-generated-footprint"; -import type { IpcGeneratorIdentity } from "../../../modules/library/shared/generator-policy"; - -const densities = ["most", "nominal", "least"] as const; - -test("installed SOT generator reproduces the quarantined counts and missing SOT-223 tab", () => { - const counts = { "SOT-23": 4, "SOT-23-5": 6, "SOT-323": 4, "SOT-223": 4 }; - for (const density of densities) { - for (const [size, count] of Object.entries(counts)) { - const source = generateFootprint("sot", size, density).source; - expect(source.pads).toHaveLength(count); - if (size === "SOT-223") { - expect( - new Set(source.pads.map((pad) => `${pad.widthMm}:${pad.heightMm}`)) - .size, - ).toBe(1); - } - } - } -}); - -test("every exposed QFN preset reproduces positive exposed-pad/perimeter overlap at every density", () => { - const family = ALL_FAMILIES.find((family) => family.id === "qfn")!; - for (const size of family.sizes) { - for (const density of densities) { - const pads = generateFootprint("qfn", size.label, density).source.pads; - const ep = pads[pads.length - 1]; - if (!ep) throw new Error("QFN fixture has no exposed pad"); - const overlap = pads - .slice(0, -1) - .filter( - (pad) => - Math.abs(pad.centerMm.x - ep.centerMm.x) < - (pad.widthMm + ep.widthMm) / 2 && - Math.abs(pad.centerMm.y - ep.centerMm.y) < - (pad.heightMm + ep.heightMm) / 2, - ); - expect(overlap.length).toBe(pads.length - 1); - } - } -}); - -for (const family of ALL_FAMILIES) { - for (const size of family.sizes) { - for (const density of densities) { - test(`${family.id}/${size.label}/${density}: policy matches server generator resolution`, () => { - const generator: IpcGeneratorIdentity = { - kind: "ipc7351b", - family: family.id, - size: size.label, - density, - }; - if (generatorUnavailableReason(family.id)) { - expect(() => resolveGeneratedFootprint({ generator })).toThrow( - UnqualifiedGeneratorError, - ); - return; - } - const expected = generateFootprint(family.id, size.label, density); - const result = resolveGeneratedFootprint({ - generator, - source: expected.source, - metadata: expected.metadata, - }); - expect(result.source).toEqual(expected.source); - expect(result.metadata).toEqual(expected.metadata); - expect(result.provenance.generator).toEqual(generator); - if (!("presetParameters" in result.provenance)) - throw new Error("expected IPC provenance"); - expect(result.provenance.presetParameters).toEqual(size.params); - expect(result.provenance.generatorPackage.name).toBe( - "@openpcb/rendering-core", - ); - expect(result.provenance.generatorPackage.version).toMatch( - /^\d+\.\d+\.\d+/, - ); - expect(result.provenance.generatedSourceSha256).toMatch( - /^[a-f0-9]{64}$/, - ); - }); - } - } -} - -test("identity rejects unknown kinds, sizes, densities, omitted parameters and spoofed metadata", () => { - const generator = { - kind: "ipc7351b", - family: "chip", - size: "0603", - density: "nominal", - }; - for (const value of [ - undefined, - null, - {}, - { ...generator, kind: "parametric" }, - { ...generator, size: "invented" }, - { ...generator, density: "invented" }, - { ...generator, provenance: "drawn" }, - ]) { - expect(() => resolveGeneratedFootprint({ generator: value })).toThrow( - UnqualifiedGeneratorError, - ); - } - const preview = generateFootprint("chip", "0603", "nominal"); - expect(() => - resolveGeneratedFootprint({ - generator, - source: { ...preview.source, name: "spoofed" }, - }), - ).toThrow("preview does not match"); - expect(() => - resolveGeneratedFootprint({ - generator, - metadata: { ...preview.metadata, name: "spoofed" }, - }), - ).toThrow("preview does not match"); -}); diff --git a/src/core/backend/tests/library-pad-fingerprint.test.ts b/src/core/backend/tests/library-pad-fingerprint.test.ts new file mode 100644 index 00000000..64d537d0 --- /dev/null +++ b/src/core/backend/tests/library-pad-fingerprint.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; +import renderingPackage from "../../../../node_modules/@openpcb/rendering-core/package.json"; +import { buildFootprintRenderModel } from "../../../shared/rendering/footprint-preview-builder"; +import { + ALL_FAMILIES, + generateFootprint, +} from "../../../shared/rendering/ipc7351b"; +import { padGeometryFingerprint } from "../../../modules/library/backend/qualification/pad-fingerprint"; +import { + LEGACY_IPC_FINGERPRINT_PRESETS, + LEGACY_IPC_FINGERPRINTS, + LEGACY_IPC_GENERATOR, +} from "../../../modules/library/shared/legacy-ipc-fingerprints.generated"; + +const PADS = [ + { + id: "pad-1", + number: "1", + shape: "roundrect", + centerMm: { x: -0.825, y: 0 }, + widthMm: 0.8, + heightMm: 0.95, + rotationDeg: 0, + roundrectRatio: 0.25, + layer: "F.Cu", + }, + { + id: "pad-2", + number: "2", + shape: "roundrect", + centerMm: { x: 0.825, y: 0 }, + widthMm: 0.8, + heightMm: 0.95, + rotationDeg: 0, + roundrectRatio: 0.25, + layer: "F.Cu", + }, + { + id: "mh", + number: "", + shape: "circle", + centerMm: { x: 0, y: 3 }, + widthMm: 3.6, + heightMm: 3.6, + rotationDeg: 0, + drillDiameterMm: 3.2, + layer: "Edge.Cuts", + }, +]; + +const BASE = padGeometryFingerprint(PADS); + +function edited(index: number, patch: Record): unknown[] { + return PADS.map((pad, i) => (i === index ? { ...pad, ...patch } : pad)); +} + +describe("padGeometryFingerprint invariance", () => { + test("is a sha256 hex digest", () => { + expect(BASE).toMatch(/^[a-f0-9]{64}$/); + }); + + test("pad order does not matter", () => { + expect(padGeometryFingerprint([...PADS].reverse())).toBe(BASE); + expect(padGeometryFingerprint([PADS[2], PADS[0], PADS[1]])).toBe(BASE); + }); + + test("key order and a JSON round trip do not matter", () => { + const reordered = PADS.map((pad) => + Object.fromEntries(Object.entries(pad).reverse()), + ); + expect(padGeometryFingerprint(reordered)).toBe(BASE); + expect(padGeometryFingerprint(JSON.parse(JSON.stringify(PADS)))).toBe(BASE); + }); + + test("float noise below 1e-6 mm and -0 do not matter", () => { + const noisy = PADS.map((pad) => ({ + ...pad, + centerMm: { x: pad.centerMm.x + 1e-10, y: pad.centerMm.y - 1e-10 }, + widthMm: pad.widthMm * (1 + 1e-12), + })); + expect(padGeometryFingerprint(noisy)).toBe(BASE); + expect(padGeometryFingerprint(edited(0, { centerMm: { x: -0.825, y: -0 } }))).toBe( + BASE, + ); + }); + + test("pad ids never reach the fab and are ignored", () => { + expect(padGeometryFingerprint(edited(0, { id: "renamed" }))).toBe(BASE); + }); +}); + +describe("padGeometryFingerprint sensitivity", () => { + test("any geometric edit of 1e-6 mm or more changes it", () => { + expect(padGeometryFingerprint(edited(0, { centerMm: { x: -0.825002, y: 0 } }))).not.toBe( + BASE, + ); + expect(padGeometryFingerprint(edited(1, { heightMm: 0.951 }))).not.toBe(BASE); + }); + + test("every projected field participates", () => { + const patches: Record[] = [ + { number: "3" }, + { shape: "rect" }, + { widthMm: 0.81 }, + { rotationDeg: 90 }, + { roundrectRatio: 0.2 }, + { drillDiameterMm: 0.3 }, + { layer: "B.Cu" }, + ]; + for (const patch of patches) { + expect(padGeometryFingerprint(edited(0, patch))).not.toBe(BASE); + } + }); + + test("structural fields the pinned pad type lacks are read", () => { + for (const patch of [ + { plated: false }, + { drillSlotMm: { widthMm: 3.2, heightMm: 4 } }, + { drillOffsetMm: { x: 0.1, y: 0 } }, + { layers: ["*.Cu", "*.Mask"] }, + ]) { + expect(padGeometryFingerprint(edited(2, patch))).not.toBe(BASE); + } + // Absent `plated` means plated on a drilled pad. + expect(padGeometryFingerprint(edited(2, { plated: true }))).toBe(BASE); + // `plated` is meaningless without a drill. + expect(padGeometryFingerprint(edited(0, { plated: false }))).toBe(BASE); + // `layers` is a set. + expect(padGeometryFingerprint(edited(2, { layers: ["*.Mask", "*.Cu"] }))).toBe( + padGeometryFingerprint(edited(2, { layers: ["*.Cu", "*.Mask"] })), + ); + }); + + test("malformed entries are projected, never thrown on", () => { + expect(() => + padGeometryFingerprint([null, 7, "pad", {}, { centerMm: "x", widthMm: "1" }]), + ).not.toThrow(); + expect(padGeometryFingerprint([{}])).not.toBe(padGeometryFingerprint([])); + }); + + test("the projection is pinned: a change invalidates every fingerprint table", () => { + // Regenerate scripts/gen-ipc-fingerprints.ts outputs before updating this. + expect(BASE).toBe( + "165a720fe2e56d02377be94c50de2eb7222c4d923d4b4d3a91c789362d795d40", + ); + }); +}); + +test.if(renderingPackage.version === LEGACY_IPC_GENERATOR.version)( + "the legacy deny-list reproduces from the installed legacy generator", + () => { + let combinations = 0; + for (const family of ALL_FAMILIES) { + for (const size of family.sizes) { + for (const density of ["most", "nominal", "least"] as const) { + combinations += 1; + const { source } = generateFootprint(family.id, size.label, density); + const key = `${family.id}|${size.label}|${density}`; + for (const pads of [source.pads, buildFootprintRenderModel(source).pads]) { + const fingerprint = padGeometryFingerprint(pads); + expect(LEGACY_IPC_FINGERPRINTS.has(fingerprint)).toBe(true); + expect(LEGACY_IPC_FINGERPRINT_PRESETS[fingerprint]).toContain(key); + } + } + } + } + expect(LEGACY_IPC_GENERATOR.combinations as number).toBe(combinations); + }, +); diff --git a/src/modules/library/backend/import/README.md b/src/modules/library/backend/import/README.md index 756e99fc..dc35d1ed 100644 --- a/src/modules/library/backend/import/README.md +++ b/src/modules/library/backend/import/README.md @@ -4,9 +4,14 @@ New KiCad footprint commits return HTTP 422 `UNQUALIFIED_IMPORT` until the sourc coordinate transform is independently qualified. Direct KiCad import, KiCad ZIP import and drawn-symbol plus imported-footprint commits reject before SQLite or model asset writes. Inspection and preview remain available. Symbol-only imports, -editor-drawn footprints and IPC presets allowed by `generator-policy.ts` remain -available. Arbitrary editor geometry cannot prove its original authoring source; -the drawn payload is an explicit workflow boundary, not electrical certification. +editor-drawn footprints, parametric templates and IPC presets allowed by +`generator-qualification.ts` remain available. Generated output must also pass +`generated-geometry.ts` (no overlapping pads or holes), and an IPC preset's pad +fingerprint must equal its accepted entry in `qualified-ipc-fingerprints.generated.ts`; +with no accepted block on the pinned rendering-core, every IPC family is +unavailable. Arbitrary editor geometry cannot prove its original authoring +source; the drawn payload is an explicit workflow boundary, not electrical +certification. All footprint-bearing `.opclib` installations are also blocked, including bundled CoreLibrary bootstrap, updates, developer watching, manual installs and cloud @@ -27,7 +32,7 @@ permit a pack. There is no environment or payload override. Focused verification: ```sh -bun test src/core/backend/tests/library-import-quarantine.test.ts src/core/backend/tests/library-generator-quarantine.test.ts src/core/backend/tests/library-generator-import.test.ts +bun test src/core/backend/tests/library-import-quarantine.test.ts src/core/backend/tests/library-generator-policy.test.ts src/core/backend/tests/library-generated-geometry.test.ts src/core/backend/tests/library-pad-fingerprint.test.ts src/core/backend/tests/library-generator-import.test.ts npm run test:react -- src/modules/library/frontend/import-wizard/import-quarantine.test.tsx src/modules/library/frontend/import-wizard/preset-quarantine.test.tsx ``` diff --git a/src/modules/library/backend/import/generated-geometry.ts b/src/modules/library/backend/import/generated-geometry.ts new file mode 100644 index 00000000..c5acd31c --- /dev/null +++ b/src/modules/library/backend/import/generated-geometry.ts @@ -0,0 +1,286 @@ +/** + * Server-side output check for generated footprints (H15 §3.3): IPC presets + * and parametric templates must not emit pads that overlap, holes that + * overlap, degenerate pads, or one pad number on separate copper. + * + * Gaps are measured with the DRC's own exact kernel at identity placement + * (`padOutlineWorldMm` → `padRoundedWorldMm` → `roundedGap`), and copper + * layers come from THE pad→copper resolver, so a footprint that passes here + * cannot short against itself in the DRC for a geometric reason this check + * ignored. + */ +import type { + PcbCopperLayerId, + PcbLayerCount, + PcbPlacedPart, +} from "../../../../sdks/designer"; +import { + copperLayersForCount, + MAX_PCB_LAYER_COUNT, +} from "../../../../sdks/designer/stackup"; +import { + padOutlineWorldMm, + padRoundedWorldMm, + ringBounds, + type RingBounds, +} from "../../../../shared/pcb-geometry/pad-outline"; +import { + roundedGap, + roundedPoint, + roundedSegment, +} from "../../../../shared/pcb-geometry/rounded-shape"; +import type { RoundedShape } from "../../../../shared/pcb-geometry/rounded-shape-types"; +import { GEOM_EPS_MM } from "../../../../shared/pcb-geometry/tolerance"; +import { resolvePadCopperLayers } from "../../../../shared/rendering/pad-copper-layers"; +import { footprintPadDrill } from "../../../../shared/rendering/pcb/pcb-drills"; +import type { + FootprintRenderSource, + FootprintRenderSourcePad, +} from "../../../../shared/rendering/types"; +import { UnqualifiedGeneratorError } from "./unqualified-generator-error"; + +export interface GeneratedGeometryDefect { + readonly kind: + | "non-positive-size" + | "duplicate-pad-number" + | "pad-overlap" + | "drill-overlap"; + readonly padIds: readonly string[]; // ids, not numbers: numbers may be "" or repeated + readonly padNumbers: readonly string[]; + readonly gapMm?: number; +} + +/** Footprint-local frame: the pads exactly as the generator emitted them. */ +const IDENTITY: PcbPlacedPart = { + id: "generated-geometry", + partId: "", + componentId: "", + reference: "", + positionMm: { x: 0, y: 0 }, + rotationDeg: 0, + mirrored: false, + layer: "F.Cu", + footprint: { + footprintId: "", + name: "", + mountType: null, + sourceHash: null, + preview: null, + }, +}; + +const ALL_COPPER: ReadonlySet = new Set( + copperLayersForCount(MAX_PCB_LAYER_COUNT as PcbLayerCount), +); + +/** At most this many defects are spelled out in the error message. */ +const MESSAGE_DEFECT_LIMIT = 5; + +interface PadGeometry { + readonly id: string; + readonly number: string; + /** null when the pad has a non-positive or non-finite size. */ + readonly copper: { + readonly layers: ReadonlySet; + readonly shape: RoundedShape; + readonly bounds: RingBounds; + } | null; + readonly drill: { readonly shape: RoundedShape; readonly bounds: RingBounds } | null; +} + +function positiveSize(pad: FootprintRenderSourcePad): boolean { + return pad.widthMm > 0 && pad.heightMm > 0; +} + +function drillShape(pad: FootprintRenderSourcePad): PadGeometry["drill"] { + const drill = footprintPadDrill(pad, IDENTITY); + if (!drill) return null; + const r = drill.drillMm / 2; + const shape = drill.slot + ? roundedSegment(drill.slot.a, drill.slot.b, drill.slot.widthMm / 2) + : roundedPoint(drill.centerMm, r); + const xs = shape.core.map((p) => p.x); + const ys = shape.core.map((p) => p.y); + return { + shape, + bounds: { + minX: Math.min(...xs) - shape.radiusMm, + minY: Math.min(...ys) - shape.radiusMm, + maxX: Math.max(...xs) + shape.radiusMm, + maxY: Math.max(...ys) + shape.radiusMm, + }, + }; +} + +function analyse(pad: FootprintRenderSourcePad): PadGeometry { + const number = typeof pad.number === "string" ? pad.number : ""; + const id = typeof pad.id === "string" ? pad.id : ""; + if (!positiveSize(pad)) return { id, number, copper: null, drill: null }; + const ring = padOutlineWorldMm(IDENTITY, pad); + return { + id, + number, + copper: { + layers: resolvePadCopperLayers(pad, IDENTITY, ALL_COPPER), + shape: padRoundedWorldMm(IDENTITY, pad, ring), + // The ring circumscribes the exact shape, so disjoint ring bounds prove + // a positive gap. + bounds: ringBounds(ring), + }, + drill: drillShape(pad), + }; +} + +/** Bounds farther apart than the tolerance cannot hold a defect. */ +function apart(a: RingBounds, b: RingBounds): boolean { + return ( + a.maxX + GEOM_EPS_MM < b.minX || + b.maxX + GEOM_EPS_MM < a.minX || + a.maxY + GEOM_EPS_MM < b.minY || + b.maxY + GEOM_EPS_MM < a.minY + ); +} + +function sharesLayer( + a: ReadonlySet, + b: ReadonlySet, +): boolean { + for (const layer of a) if (b.has(layer)) return true; + return false; +} + +/** Copper gap, or null when the pads share no copper layer. NaN fails closed. */ +function copperGap(a: PadGeometry, b: PadGeometry): number | null { + if (!a.copper || !b.copper) return null; + if (!sharesLayer(a.copper.layers, b.copper.layers)) return null; + if (apart(a.copper.bounds, b.copper.bounds)) return Infinity; + return roundedGap(a.copper.shape, b.copper.shape); +} + +function drillGap(a: PadGeometry, b: PadGeometry): number | null { + if (!a.drill || !b.drill) return null; + if (apart(a.drill.bounds, b.drill.bounds)) return Infinity; + return roundedGap(a.drill.shape, b.drill.shape); +} + +/** A defect is a gap ≤ GEOM_EPS_MM; `!(gap > eps)` also catches NaN. */ +function touching(gap: number | null): gap is number { + return gap !== null && !(gap > GEOM_EPS_MM); +} + +/** Same non-empty number on copper that does not connect: two pads, one pin. */ +function duplicateNumberDefects( + geometry: readonly PadGeometry[], +): GeneratedGeometryDefect[] { + const groups = new Map(); + geometry.forEach((pad, index) => { + if (pad.number === "") return; + const group = groups.get(pad.number); + if (group) group.push(index); + else groups.set(pad.number, [index]); + }); + const defects: GeneratedGeometryDefect[] = []; + for (const [number, members] of groups) { + if (members.length < 2) continue; + // A stack (KiCad EP sub-pads) is allowed: same-number pads whose copper + // touches form ONE pad. Union-find over touching members. + const parent = members.map((_, i) => i); + const find = (i: number): number => { + while (parent[i] !== i) i = parent[i] = parent[parent[i]!]!; + return i; + }; + for (let i = 0; i < members.length; i += 1) { + for (let j = i + 1; j < members.length; j += 1) { + const gap = copperGap(geometry[members[i]!]!, geometry[members[j]!]!); + if (touching(gap)) parent[find(i)] = find(j); + } + } + if (new Set(members.map((_, i) => find(i))).size < 2) continue; + defects.push({ + kind: "duplicate-pad-number", + padIds: members.map((index) => geometry[index]!.id), + padNumbers: members.map(() => number), + }); + } + return defects; +} + +/** + * Every unordered pair EXCEPT two pads with the same non-empty number (a + * stack). Empty numbers mean "no electrical pad" (mounting arrays): they are + * exempt from the duplicate check but not from the overlap and drill checks. + */ +export function generatedGeometryDefects( + pads: readonly FootprintRenderSourcePad[], +): GeneratedGeometryDefect[] { + const geometry = pads.map(analyse); + const defects: GeneratedGeometryDefect[] = geometry + .filter((pad) => !pad.copper) + .map((pad) => ({ + kind: "non-positive-size", + padIds: [pad.id], + padNumbers: [pad.number], + })); + defects.push(...duplicateNumberDefects(geometry)); + for (let i = 0; i < geometry.length; i += 1) { + const a = geometry[i]!; + for (let j = i + 1; j < geometry.length; j += 1) { + const b = geometry[j]!; + if (a.number !== "" && a.number === b.number) continue; + const pair = { padIds: [a.id, b.id], padNumbers: [a.number, b.number] }; + const copper = copperGap(a, b); + if (touching(copper)) { + defects.push({ kind: "pad-overlap", ...pair, gapMm: copper }); + } + const drill = drillGap(a, b); + if (touching(drill)) { + defects.push({ kind: "drill-overlap", ...pair, gapMm: drill }); + } + } + } + return defects; +} + +function padLabel(id: string, number: string): string { + return number !== "" ? number : `"${id}"`; +} + +function describeDefect(defect: GeneratedGeometryDefect): string { + const labels = defect.padIds.map((id, i) => + padLabel(id, defect.padNumbers[i] ?? ""), + ); + // A negative gap is a lower bound on the overlap; 0 can also mean overlap + // (two r = 0 polygons that intersect), so only a negative value is printed. + const gap = + defect.gapMm !== undefined && defect.gapMm < 0 + ? ` (gap ${defect.gapMm.toFixed(3)} mm)` + : ""; + switch (defect.kind) { + case "non-positive-size": + return `pad ${labels[0]} has a non-positive size`; + case "duplicate-pad-number": + return `pad number ${defect.padNumbers[0]} is used by ${labels.length} pads that do not connect`; + case "pad-overlap": + return `pads ${labels[0]} and ${labels[1]} overlap${gap}`; + case "drill-overlap": + return `the holes of pads ${labels[0]} and ${labels[1]} overlap${gap}`; + } +} + +/** One sentence naming at most five defects. */ +export function describeGeneratedGeometryDefects( + defects: readonly GeneratedGeometryDefect[], +): string { + const named = defects.slice(0, MESSAGE_DEFECT_LIMIT).map(describeDefect); + const more = defects.length - named.length; + return `${named.join("; ")}${more > 0 ? `; and ${more} more` : ""}`; +} + +/** Throws UnqualifiedGeneratorError (422) when the generated pads are unsafe. */ +export function assertGeneratedGeometry(source: FootprintRenderSource): void { + const defects = generatedGeometryDefects(source.pads); + if (defects.length === 0) return; + throw new UnqualifiedGeneratorError( + `The generated footprint is not manufacturable: ${describeGeneratedGeometryDefects(defects)}. Regenerate it with non-overlapping parameters.`, + ); +} diff --git a/src/modules/library/backend/import/resolve-generated-footprint.ts b/src/modules/library/backend/import/resolve-generated-footprint.ts index b036c03b..a9c48805 100644 --- a/src/modules/library/backend/import/resolve-generated-footprint.ts +++ b/src/modules/library/backend/import/resolve-generated-footprint.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import renderingPackage from "../../../../../node_modules/@openpcb/rendering-core/package.json"; -import { AppError } from "../../../../core/contracts/errors"; +import { buildFootprintRenderModel } from "../../../../shared/rendering/footprint-preview-builder"; import { ALL_FAMILIES, generateFootprint, @@ -13,11 +13,21 @@ import { hashParams, validateParams, } from "../../../../shared/rendering/parametric"; -import { - generatorUnavailableReason, - type IpcGeneratorIdentity, - type TemplateGeneratorIdentity, +import type { + IpcGeneratorIdentity, + TemplateGeneratorIdentity, } from "../../shared/generator-policy"; +import { + acceptedIpcFingerprint, + ipcFamilyAvailability, + type InstalledIpcQualification, +} from "../../shared/generator-qualification"; +import type { QualifiedIpcFingerprintTable } from "../../shared/qualified-ipc-fingerprints.generated"; +import { padGeometryFingerprint } from "../qualification/pad-fingerprint"; +import { assertGeneratedGeometry } from "./generated-geometry"; +import { UnqualifiedGeneratorError } from "./unqualified-generator-error"; + +export { UnqualifiedGeneratorError }; export interface FootprintPayload { source: FootprintRenderSource; @@ -30,16 +40,14 @@ export interface GeneratedFootprintInput { metadata?: GeneratedFootprintMetadata; } -export class UnqualifiedGeneratorError extends AppError { - constructor(message: string) { - super( - message, - 422, - "Footprint generator unavailable", - "https://openpcb.dev/problems/unqualified-generator", - { code: "UNQUALIFIED_GENERATOR" }, - ); - } +/** + * Test seam for the qualification inputs: `installed` overrides the installed + * rendering-core manifest (null = none), `accepted` the accepted-fingerprint + * table. Production callers pass nothing. + */ +export interface GeneratorQualificationInputs { + installed?: InstalledIpcQualification | null; + accepted?: QualifiedIpcFingerprintTable; } function record(value: unknown): value is Record { @@ -52,7 +60,7 @@ const packageIdentity = { version: renderingPackage.version, }; -function resolvePreset(value: unknown) { +function resolvePreset(value: unknown, inputs: GeneratorQualificationInputs) { if ( !record(value) || value.kind !== "ipc7351b" || @@ -72,15 +80,21 @@ function resolvePreset(value: unknown) { "Choose an available IPC preset and density, then regenerate the footprint.", ); } - const reason = generatorUnavailableReason(family.id); - if (reason) throw new UnqualifiedGeneratorError(reason); + const availability = ipcFamilyAvailability( + family.id, + inputs.installed, + inputs.accepted, + ); + if (!availability.available) { + throw new UnqualifiedGeneratorError(availability.reason); + } const generator: IpcGeneratorIdentity = { kind: "ipc7351b", family: family.id, size: size.label, density, }; - return { generator, parameters: size.params }; + return { generator, parameters: size.params, revision: availability.revision }; } /** Parametric templates are re-materialized server-side, like IPC presets. */ @@ -107,6 +121,7 @@ function resolveTemplate(value: Record) { ); } const generated = template.generate(validated.values); + assertGeneratedGeometry(generated.source); const generator: TemplateGeneratorIdentity = { kind: "template", templateId: template.id, @@ -129,13 +144,34 @@ function resolveTemplate(value: Record) { }; } -function resolveIpc(value: unknown) { - const { generator, parameters } = resolvePreset(value); +function resolveIpc(value: unknown, inputs: GeneratorQualificationInputs) { + const { generator, parameters, revision } = resolvePreset(value, inputs); const result = generateFootprint( generator.family, generator.size, generator.density, ); + assertGeneratedGeometry(result.source); + // The preview pads are what a placement snapshots and the Gerber writes; a + // re-pinned or linked generator whose output moved without re-qualification + // stops here instead of persisting unqualified geometry. + const padFingerprint = padGeometryFingerprint( + buildFootprintRenderModel(result.source).pads, + ); + if ( + padFingerprint !== + acceptedIpcFingerprint( + generator.family, + revision, + generator.size, + generator.density, + inputs.accepted, + ) + ) { + throw new UnqualifiedGeneratorError( + "The installed generator's output differs from its qualification evidence.", + ); + } return { ...result, provenance: { @@ -145,6 +181,9 @@ function resolveIpc(value: unknown) { generatedSourceSha256: createHash("sha256") .update(JSON.stringify(result.source)) .digest("hex"), + // Diagnostic only: qualification is re-derived from geometry, never read from here. + ipcRevision: revision, + padFingerprint, }, }; } @@ -155,12 +194,13 @@ function asJson(value: unknown): unknown { export function resolveGeneratedFootprint( input: GeneratedFootprintInput | undefined, + inputs: GeneratorQualificationInputs = {}, ) { const value = input?.generator; const result = record(value) && value.kind === "template" ? resolveTemplate(value) - : resolveIpc(value); + : resolveIpc(value, inputs); // Client payloads crossed JSON; compare against the same normalization. if ( (input?.source !== undefined && diff --git a/src/modules/library/backend/import/unqualified-generator-error.ts b/src/modules/library/backend/import/unqualified-generator-error.ts new file mode 100644 index 00000000..676dd726 --- /dev/null +++ b/src/modules/library/backend/import/unqualified-generator-error.ts @@ -0,0 +1,17 @@ +import { AppError } from "../../../../core/contracts/errors"; + +/** + * 422 `UNQUALIFIED_GENERATOR`. Its own module so the generator resolver and + * the generated-geometry guard can both throw it without an import cycle. + */ +export class UnqualifiedGeneratorError extends AppError { + constructor(message: string) { + super( + message, + 422, + "Footprint generator unavailable", + "https://openpcb.dev/problems/unqualified-generator", + { code: "UNQUALIFIED_GENERATOR" }, + ); + } +} diff --git a/src/modules/library/backend/qualification/pad-fingerprint.ts b/src/modules/library/backend/qualification/pad-fingerprint.ts new file mode 100644 index 00000000..258cbcb9 --- /dev/null +++ b/src/modules/library/backend/qualification/pad-fingerprint.ts @@ -0,0 +1,120 @@ +/** + * Geometric identity of a footprint's pads (H15 §3.2). Qualification compares + * the pads actually stored or placed against the accepted / legacy fingerprint + * tables, so neither `raw.source`, `sourceKind` nor any generator claim can buy + * a verdict — only the geometry the Gerber writes can. + * + * Every field is read structurally: the pinned `@openpcb/rendering-core` pad + * type lacks `plated`, `drillSlotMm`, `drillOffsetMm` and `layers`, and stored + * JSON may carry anything. + */ +import { createHash } from "node:crypto"; + +/** Coordinates are quantised to 1e-6 mm, i.e. emitted as integer nanometres. */ +const QUANTUM_PER_MM = 1e6; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function num(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const quantised = Math.round(value * QUANTUM_PER_MM); + // -0 and 0 must hash identically. + return quantised === 0 ? 0 : quantised; +} + +function str(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function pair( + value: unknown, + a: string, + b: string, +): { [key: string]: number | null } | null { + const r = record(value); + return r ? { [a]: num(r[a]), [b]: num(r[b]) } : null; +} + +interface PadProjection { + n: string | null; + s: string | null; + x: number | null; + y: number | null; + w: number | null; + h: number | null; + r: number | null; + rr: number | null; + d: number | null; + ds: { [key: string]: number | null } | null; + do: { [key: string]: number | null } | null; + p: boolean | null; + l: string | null; + ls: string[] | null; +} + +function project(pad: unknown): PadProjection | null { + const p = record(pad); + if (!p) return null; + const center = record(p.centerMm); + const drill = num(p.drillDiameterMm); + const drilled = drill !== null && drill > 0; + const layers = Array.isArray(p.layers) + ? p.layers.filter((layer): layer is string => typeof layer === "string").sort() + : null; + // Key order is fixed by this literal; JSON.stringify preserves it. + return { + n: str(p.number), + s: str(p.shape), + x: num(center?.x), + y: num(center?.y), + w: num(p.widthMm), + h: num(p.heightMm), + r: num(p.rotationDeg), + rr: num(p.roundrectRatio), + d: drill, + ds: pair(p.drillSlotMm, "widthMm", "heightMm"), + do: pair(p.drillOffsetMm, "x", "y"), + p: drilled ? p.plated !== false : null, + l: str(p.layer), + ls: layers, + }; +} + +function compareNullable(a: T | null, b: T | null): number { + if (a === b) return 0; + if (a === null) return -1; + if (b === null) return 1; + return a < b ? -1 : 1; +} + +/** + * sha256 over the canonical projection of the pads the Gerber writes: per pad + * `{n, s, x, y, w, h, r, rr, d, ds, do, p, l, ls}` (`p` only for drilled pads, + * `ls` sorted), numbers quantised to 1e-6 mm, pads sorted by (n, x, y) with + * the full projection as the final tie-break, keys in fixed order. Pad `id` + * is excluded: it never reaches the fab. + */ +export function padGeometryFingerprint(pads: readonly unknown[]): string { + const rows = pads.map((pad) => { + const projection = project(pad); + return { projection, json: JSON.stringify(projection) }; + }); + rows.sort((a, b) => { + const pa = a.projection; + const pb = b.projection; + if (!pa || !pb) return pa === pb ? 0 : pa ? 1 : -1; + return ( + compareNullable(pa.n, pb.n) || + compareNullable(pa.x, pb.x) || + compareNullable(pa.y, pb.y) || + compareNullable(a.json, b.json) + ); + }); + return createHash("sha256") + .update(`[${rows.map((row) => row.json).join(",")}]`) + .digest("hex"); +} diff --git a/src/modules/library/backend/routes.ts b/src/modules/library/backend/routes.ts index 7ed8b6fe..c000d519 100644 --- a/src/modules/library/backend/routes.ts +++ b/src/modules/library/backend/routes.ts @@ -82,6 +82,7 @@ import { type CommitDrawnRequest, } from "./import/commit-drawn"; import { buildInspectResponse } from "./import/inspect-kicad"; +import { assertGeneratedGeometry } from "./import/generated-geometry"; import type { CommitKicadRequest, InspectKicadRequest } from "./import/types"; import { getOrRenderSymbolPreview, @@ -1356,6 +1357,8 @@ export function registerRoutes( ); } const result = template.generate(validated.values); + // Same output check as the commit path: never preview what cannot be committed. + assertGeneratedGeometry(result.source); return success({ templateId: template.id, generatorVersion: template.generatorVersion, diff --git a/src/modules/library/frontend/import-wizard/preset-quarantine.test.tsx b/src/modules/library/frontend/import-wizard/preset-quarantine.test.tsx index 63270d60..8822750d 100644 --- a/src/modules/library/frontend/import-wizard/preset-quarantine.test.tsx +++ b/src/modules/library/frontend/import-wizard/preset-quarantine.test.tsx @@ -2,7 +2,12 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { generateFootprint } from "../../../../shared/rendering/ipc7351b"; +import { + ALL_FAMILIES, + generateFootprint, +} from "../../../../shared/rendering/ipc7351b"; +import { generatorUnavailableReason } from "../../shared/generator-policy"; +import { installedIpcQualification } from "../../shared/generator-qualification"; import { IpcPresetTab } from "../footprint-generator/IpcPresetTab"; import { useImportWizardStore, hasCurrentPreset } from "./useImportWizardStore"; import { ImportWizardPage } from "./ImportWizardPage"; @@ -11,6 +16,23 @@ import { commitGeneratedImportRequest, } from "./import-api"; +// Every IPC family is quarantined on the pinned rendering-core (H15 D1). The +// positive wizard paths below mark chosen families available; the rest keep +// the real policy. +const qualification = vi.hoisted(() => ({ available: new Set() })); +vi.mock("../../shared/generator-qualification", async (importOriginal) => { + const actual = + await importOriginal(); + const ipcFamilyAvailability: typeof actual.ipcFamilyAvailability = ( + family, + ...rest + ) => + qualification.available.has(family) + ? { available: true, revision: 2 } + : actual.ipcFamilyAvailability(family, ...rest); + return { ...actual, ipcFamilyAvailability }; +}); + vi.mock("./steps/SymbolStep", () => ({ SymbolStep: () => null })); vi.mock("./steps/FootprintStep", () => ({ FootprintStep: () => null })); vi.mock("./steps/ModelStep", () => ({ ModelStep: () => null })); @@ -91,6 +113,7 @@ async function renderWizard(): Promise { beforeEach(() => { vi.clearAllMocks(); + qualification.available = new Set(["chip"]); useImportWizardStore.getState().reset(); useImportWizardStore.setState({ symbolSource: "draw", @@ -163,7 +186,7 @@ test("unsafe generator output cannot be injected into current wizard state", () expect(useImportWizardStore.getState().generatedFootprint).toBeNull(); }); -test("picker disables SOT/QFN with visible reasons, keeps other families available", async () => { +async function renderPicker(): Promise { await act(async () => root.render( , ), ); - const buttons = [...host.querySelectorAll("button")]; - for (const family of ["SOT", "QFN"]) { - const button = buttons.find((button) => - button.textContent?.startsWith(family), - )!; - expect(button.disabled).toBe(true); - expect(button.title).toContain("unavailable"); - expect(button.textContent).toContain( - "Import a verified footprint or draw one", - ); + return [...host.querySelectorAll("button")]; +} + +function familyButton( + buttons: readonly HTMLButtonElement[], + label: string, +): HTMLButtonElement { + const button = buttons.find((candidate) => + candidate.textContent?.startsWith(label), + ); + if (!button) throw new Error(`${label} family button missing`); + return button; +} + +test.runIf(installedIpcQualification() === null)( + "picker disables all five IPC families with visible reasons on the pinned generator", + async () => { + qualification.available = new Set(); + const buttons = await renderPicker(); + for (const family of ["Chip", "SOT", "SOIC", "QFP", "QFN"]) { + const button = familyButton(buttons, family); + expect(button.disabled).toBe(true); + expect(button.title).toContain("unavailable"); + expect(button.textContent).toContain( + "Import a verified footprint or draw one", + ); + } + }, +); + +test("picker offers exactly the families the qualification policy makes available", async () => { + const buttons = await renderPicker(); + expect(familyButton(buttons, "Chip").disabled).toBe(false); + for (const family of ALL_FAMILIES) { + const reason = generatorUnavailableReason(family.id); + const button = familyButton(buttons, family.label); + expect(button.disabled).toBe(reason !== null); + expect(button.title).toBe(reason ?? ""); } - expect( - buttons.find((button) => button.textContent?.startsWith("Chip"))?.disabled, - ).toBe(false); }); for (const symbolSource of ["draw", "import"] as const) { diff --git a/src/modules/library/shared/generator-policy.ts b/src/modules/library/shared/generator-policy.ts index da56f3f6..ef5e2d12 100644 --- a/src/modules/library/shared/generator-policy.ts +++ b/src/modules/library/shared/generator-policy.ts @@ -2,6 +2,7 @@ import type { DensityLevel, PackageFamily, } from "../../../shared/rendering/ipc7351b"; +import { ipcFamilyAvailability } from "./generator-qualification"; export interface IpcGeneratorIdentity { kind: "ipc7351b"; @@ -20,16 +21,12 @@ export type FootprintGeneratorIdentity = | IpcGeneratorIdentity | TemplateGeneratorIdentity; +/** Null when the family may be generated; otherwise the reason it is quarantined. */ export function generatorUnavailableReason( family: PackageFamily, ): string | null { - if (family === "sot") { - return "SOT presets are unavailable because generated pad counts and tab layouts are incorrect. Import a verified footprint or draw one instead."; - } - if (family === "qfn") { - return "QFN presets are unavailable because the exposed pad overlaps signal pads. Import a verified footprint or draw one instead."; - } - return null; + const availability = ipcFamilyAvailability(family); + return availability.available ? null : availability.reason; } export function sameGeneratorIdentity( diff --git a/src/modules/library/shared/generator-qualification.ts b/src/modules/library/shared/generator-qualification.ts new file mode 100644 index 00000000..fa1f0589 --- /dev/null +++ b/src/modules/library/shared/generator-qualification.ts @@ -0,0 +1,163 @@ +/** + * IPC-7351B generator availability (H15 §3.2). A family is offered only when + * the installed rendering-core declares it qualified AND OpenPCB has accepted + * that exact revision's output (`QUALIFIED_IPC_FINGERPRINTS`). The upstream + * manifest is a self-check; acceptance is OpenPCB's own, after its DRC + * qualification, which is why the table lives here and not in the package. + * + * Plain data + pure functions: imported by the frontend picker and the server + * resolver alike, so it must stay free of node-only modules. + */ +import * as ipcNamespace from "../../../shared/rendering/ipc7351b"; +import { + ALL_FAMILIES, + type DensityLevel, + type PackageFamily, +} from "../../../shared/rendering/ipc7351b"; +import { + QUALIFIED_IPC_FINGERPRINTS, + type QualifiedIpcFingerprintTable, +} from "./qualified-ipc-fingerprints.generated"; + +/** The rendering-core export this module feature-detects (absent ≤ v0.1.3). */ +const MANIFEST_EXPORT = "IPC7351B_QUALIFICATION"; + +const REMEDY = "Import a verified footprint or draw one instead."; + +/** + * Why each family of the only legacy generator (rendering-core ≤ v0.1.3, no + * manifest) is quarantined. The SOT and QFN strings predate H15 and are kept + * verbatim. + */ +const LEGACY_REASONS: Readonly> = { + chip: `Chip presets are unavailable because the installed generator places the lands outside the component terminals. ${REMEDY}`, + sot: `SOT presets are unavailable because generated pad counts and tab layouts are incorrect. ${REMEDY}`, + soic: `SOIC presets are unavailable because the installed generator measures the lead span tip-to-tip as pad centres. ${REMEDY}`, + qfp: `QFP presets are unavailable because the installed generator's body sizes and lead spans are not taken from package drawings. ${REMEDY}`, + qfn: `QFN presets are unavailable because the exposed pad overlaps signal pads. ${REMEDY}`, +}; + +export interface InstalledIpcFamily { + readonly revision: number; + readonly qualified: boolean; +} + +export type InstalledIpcQualification = Readonly< + Partial> +>; + +export type IpcFamilyAvailability = + | { readonly available: true; readonly revision: number } + | { readonly available: false; readonly reason: string }; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Feature-detects IPC7351B_QUALIFICATION through the namespace; null on ≤ v0.1.3 or malformed shape. + * + * `source` is the module namespace to inspect (tests pass a synthetic one). + * The namespace is passed as a value so bundlers keep the whole object rather + * than resolving a missing named export statically. Any malformed family entry + * voids the whole manifest: fail closed, never partially. + */ +export function installedIpcQualification( + source: unknown = ipcNamespace, +): InstalledIpcQualification | null { + const manifest = record(record(source)?.[MANIFEST_EXPORT]); + if (!manifest) return null; + const out: Partial> = {}; + for (const family of ALL_FAMILIES) { + if (!Object.hasOwn(manifest, family.id)) continue; + const entry = record(manifest[family.id]); + const revision = entry?.revision; + if ( + !entry || + typeof revision !== "number" || + !Number.isSafeInteger(revision) || + revision < 1 || + typeof entry.qualified !== "boolean" + ) { + return null; + } + out[family.id] = { revision, qualified: entry.qualified }; + } + return out; +} + +// The installed package cannot change at runtime; read its manifest once. +let installedDefault: InstalledIpcQualification | null | undefined; + +function installedDefaultQualification(): InstalledIpcQualification | null { + if (installedDefault === undefined) { + installedDefault = installedIpcQualification(); + } + return installedDefault; +} + +function familyLabel(family: PackageFamily): string { + return ALL_FAMILIES.find((def) => def.id === family)?.label ?? family; +} + +/** Key of one preset inside an accepted revision block. */ +export function ipcFingerprintKey(size: string, density: DensityLevel): string { + return `${size}|${density}`; +} + +/** The accepted fingerprint for one preset of one revision, or null. */ +export function acceptedIpcFingerprint( + family: PackageFamily, + revision: number, + size: string, + density: DensityLevel, + accepted: QualifiedIpcFingerprintTable = QUALIFIED_IPC_FINGERPRINTS, +): string | null { + const block = Object.hasOwn(accepted, family) + ? accepted[family]?.[revision] + : undefined; + const key = ipcFingerprintKey(size, density); + return block && Object.hasOwn(block, key) ? (block[key] ?? null) : null; +} + +/** + * available ⇔ manifest present ∧ entry.qualified ∧ QUALIFIED_IPC_FINGERPRINTS[family][entry.revision] exists. + * + * `installed` undefined reads the installed package; `null` means "no + * manifest". `accepted` is injectable for tests only. + */ +export function ipcFamilyAvailability( + family: PackageFamily, + installed: InstalledIpcQualification | null = installedDefaultQualification(), + accepted: QualifiedIpcFingerprintTable = QUALIFIED_IPC_FINGERPRINTS, +): IpcFamilyAvailability { + if (installed === null) { + return { available: false, reason: LEGACY_REASONS[family] }; + } + const label = familyLabel(family); + const entry = installed[family]; + if (!entry) { + return { + available: false, + reason: `${label} presets are unavailable because the installed generator declares no qualification for them. ${REMEDY}`, + }; + } + if (!entry.qualified) { + return { + available: false, + reason: `${label} presets from generator revision ${entry.revision} are not marked qualified by the installed generator. ${REMEDY}`, + }; + } + const block = Object.hasOwn(accepted, family) + ? accepted[family]?.[entry.revision] + : undefined; + if (!block) { + return { + available: false, + reason: `${label} presets from generator revision ${entry.revision} have not passed OpenPCB's land-pattern qualification. ${REMEDY}`, + }; + } + return { available: true, revision: entry.revision }; +} diff --git a/src/modules/library/shared/legacy-ipc-fingerprints.generated.ts b/src/modules/library/shared/legacy-ipc-fingerprints.generated.ts new file mode 100644 index 00000000..bcbfd2b5 --- /dev/null +++ b/src/modules/library/shared/legacy-ipc-fingerprints.generated.ts @@ -0,0 +1,101 @@ +// GENERATED by scripts/gen-ipc-fingerprints.ts --legacy from @openpcb/rendering-core 0.1.3. Do not edit. +// FROZEN: that generator (== the in-tree generator dda0d07…81c3ccd) is the only legacy IPC-7351B +// generator that ever shipped, so this deny-list is complete and final. + +/** Identity of the generator this deny-list was produced from. */ +export const LEGACY_IPC_GENERATOR = { + package: "@openpcb/rendering-core", + version: "0.1.3", + combinations: 72, + projections: ["source.pads", "buildFootprintRenderModel(source).pads"], +} as const; + +/** fingerprint → presets ("family|size|density") that emit it: the label map for reasons. */ +export const LEGACY_IPC_FINGERPRINT_PRESETS: Readonly> = { + "0336eca6179c791fcdfb708ef42d8ae44da0784ac47e7d0c71e20d801d687195": ["qfp|LQFP-100|most"], + "04dcb50f2f1926a8cbae84163ecbc84d48b6f9c9f84c317f0f8ab1f123c605df": ["qfn|QFN-24|least"], + "05d4219afc7e4aa56b2cfc1f3fcf3f797385e4e8a1cb110230e3de7ed070f8fc": ["qfn|QFN-16|least"], + "0b18564828d2348640c981b67df509cbd8da184a19bb09f8804822edf71b3214": ["qfn|QFN-24|most"], + "0c8deb1110b950111ab8302ed62239058deef3cebbf853e54c3d7126feb7070e": ["qfp|LQFP-64|least"], + "0df73974db91638814fed693d69f504e26a85e8df86e380d6f2402507335048f": ["qfp|TQFP-48|most"], + "1053b22f87e1cbc99c301e5a40281a162ac086fb049bd6c74fe46e791f9959ce": ["chip|0603|most"], + "1816b9f46b87d9eabdce94abfab1826116797b7fcc0684b20f699ace58b7090a": ["soic|SOIC-28W|nominal"], + "190dac35d75785b335f512cd8a6ff8e424667b0e84c305437216a16e41bd4250": ["qfn|QFN-20|least"], + "194455a59b533fa04c970dda320061887a56ce91aed0d9923f725cb89e592f8b": ["sot|SOT-323|least"], + "1d37ba82b4b1b3c860f878323617f451a289b9dc3f61145a6ebf00f12ee64a8e": ["soic|SOIC-16|most"], + "20e827bf3af69e9bdb75963bb5abf0d78f8eab28530cae8ad4ab85553657a22a": ["chip|0402|least"], + "26aaf7c65023bdb31ea367ac4146b6a7e146a5b3a8657f699d5869453d95a4ad": ["qfn|QFN-20|most"], + "2778e020bc37c2f25f44044d4e82e60c59b6b2c1748b0d6ff49e1fbbc4d5548a": ["chip|2512|most"], + "2a8e235b264fdd78b26eeea0e69e774d264f1736de73e5d23efa7d53aae28d96": ["sot|SOT-23|most"], + "2fdc08bbe8ce3e68bd55933463d1b8570fa7b20e239d1313c745811cca2e446d": ["soic|SOIC-28W|least"], + "30af7be6d3ffd2feddd45e26a1f21347d2e55b97e3584b94d171cf86fcbed8a4": ["soic|SOIC-16|least"], + "315d1548a113ba873f9bc353abbead5d4efcf11b123e1b79174eb84f17f684a0": ["qfp|LQFP-64|most"], + "361565f744107d30686c3286b654a7018166d87d33b16b21bc2f9a30d30031bd": ["chip|1206|most"], + "38815d9b93ad5e933c42b50b77832d7fbd6e8833de03d28c3d35e65c4a8c4464": ["qfp|TQFP-32|most"], + "3a401cc01d519f9c0f35f9743223378c6ff7ebb421b38e755a69127b5fba48b8": ["qfp|LQFP-100|nominal"], + "457394896314ba44513a85c0a6425d7a3b059df2edfe6a215741b483ffe38cb2": ["sot|SOT-23-5|nominal"], + "4c2cc5ffe6cebcb2f47f74bf7902d8f8a721d0a76dcc14d11e1f717febdef646": ["sot|SOT-23-5|least"], + "5168dc80c4dd414acba00045cb9f980d56eb250d658fe4452dd491e1270eb623": ["chip|1210|least"], + "5a8197d3ac4f2d4c45ca5324aab13e4ff67bf7f895d5089b3944c9e9e169d118": ["sot|SOT-323|most"], + "5ce9da8dc664bba986461824f4d6d845a1be2b21307e2c3c5ef460a5b67a5467": ["sot|SOT-23|nominal"], + "600781bef2d01eb5d09527e03ddbcd1413527efe1e10c714b2ad7abe5fb47032": ["qfn|QFN-32|most"], + "644340cb4edf55f7c42fcd23bca94cc55bd5d61db6222331dbbedddf9126bdbb": ["sot|SOT-223|least"], + "65089189781739c13b74a7872ca896a1be9fbd8507a0ea26a86b1c36d619bc94": ["sot|SOT-323|nominal"], + "6793b3a21663b89b1b84a0ca6a34151cc9198f9aef069309bb89c32ded0e8e8d": ["chip|0603|least"], + "6a1289c852f388ee5b9d723a4dc385c290ea9536e5049cf1f6d169f427712de2": ["qfn|QFN-16|most"], + "6a9c0808f86ec3b46863251ec4d8233639d35660b8ec2c3bcd99b7cef338f056": ["chip|0402|nominal"], + "6e91a507517f134939b2761f91c9fea8662947cd60da4529af0d80fb5fc3669e": ["qfn|QFN-24|nominal"], + "6fd2f1b5cb7c576699568ff78d73afeb7f6c06a93929de619e53b2bc8ef891a5": ["chip|1210|nominal"], + "7106d903a79b70a54ce0d643e573c94d7554524ce10cfe6ac278ce96f1035f32": ["qfn|QFN-32|nominal"], + "7946bec191e829e297f84e6472ba9755ecca31877b6e7e39d568ff83f58e830d": ["sot|SOT-23|least"], + "809dafa1b7ffb2865585266f251d261274672cfc4736b5004bf503960be0ce0e": ["chip|0201|least"], + "8a22f8ac77aae6dc51595e6c0d179287bce1995c6c6d6911e5a607346610d502": ["chip|0402|most"], + "8b6309f0df7cb489e2e938d347f955fa48aebb5344d67f16caddd676b7ff0a5a": ["chip|0201|most"], + "93da9d6ae34bec775c8ae2832971ecb8aab15b281995afddb8d3dda050f64bed": ["soic|SOIC-14|most"], + "9662618db564079432f12c08c06d96f93661452a0cb688109f7d801e5f9a17d8": ["chip|2010|most"], + "985689248afd37d939b1367d0b810e93efd00de8c0fa4ef846fdf67fc6d9661a": ["soic|SOIC-8|least"], + "9aac474ddec5e236c3dd78bcd062dcfde824061505690449356dcfa7bb16d8d6": ["chip|2010|least"], + "9adcb9cb9635c9beed7891e13d1c74db1f55487e22b76158d2288b4bd856b33e": ["qfp|TQFP-48|least"], + "a07ae05f43766e8d4902c566dbfa59faafc908f5fa9dd32a2b79831c196d7efe": ["chip|0201|nominal"], + "a64a96a2ede1a9e740736eb655cc2b4898c6bcabb751b55e79978a8718021d9c": ["chip|2512|least"], + "a8025d1dbd0d82cb8f44262e2ba814e9b043316b6d15a0b08758d3715d3f25f0": ["soic|SOIC-8|most"], + "a8cd96d50aa769395ec71155b023e77a14bfbbfd22e657b52285a2cb12d7b2fa": ["chip|1210|most"], + "acd656edfe32f77edc863e7613ed3fbf88204aec497e96b947191512d5e75096": ["chip|2010|nominal"], + "af9d60c0c6fbfe6b9b651599c47ea779333c3caabe86d2c2b0cc0f8502692b89": ["qfn|QFN-20|nominal"], + "b106780927670bbc36bc0d5916717ed2c7c578ebac7118449c7094cbcf06c779": ["soic|SOIC-14|least"], + "b476bd16fc54ec877c5f701faa4be7c2f6e105c4e66bf40f804283d4fb69b0c2": ["qfp|LQFP-100|least"], + "c38cd397f3545be9821c18930539a6b24dd220c2f6afb1eef80915ed9db81424": ["chip|1206|least"], + "c73d250373428aae44ee5e599aa317f4fcd26c9e7b70babff0e44d0fa40ea69a": ["soic|SOIC-8|nominal"], + "c75a266085397c6c4d2281bad6f76bbf6492761faa6ea44f583a50d6de10e4f2": ["chip|1206|nominal"], + "cade19acea84432687cee26a8a68aee2ad79bbac8fb65bef27c2580e85971636": ["soic|SOIC-16|nominal"], + "cd377bdb0fbcd43c4ccddb1c868399919994d7f3dd1ce14748461261158be89c": ["qfn|QFN-16|nominal"], + "d27211c12bcc6a24b048c5995cb11c58a0dae76486274ff08c02a6fee0ec4a5f": ["sot|SOT-23-5|most"], + "da958a373510ec897274cdf597d7734ebe60bd5078588032ca97b84c707bd60e": ["qfp|TQFP-32|least"], + "dbca22d840e8914ddae234ccb05be9d597895a95eccab986fbb2186e17ec49c9": ["soic|SOIC-28W|most"], + "dd543dd9d323b097cd8f8781148fb431d82f1d225f8b1a97426ddf247f64c2ba": ["qfp|TQFP-48|nominal"], + "dfa900e23f234eb13d8b623c6524851ae57b5706e7f29d5715c8c919a90095bd": ["chip|2512|nominal"], + "e33ca3e818caa4e679dee8473086e0bc5fe939916941d9dcd525d6a2ee1686f4": ["qfp|TQFP-32|nominal"], + "e3f8eb1f4a6130ed3036f7b2cc43e313b47bd0c1e8903dda9d487cb032a9211f": ["chip|0805|nominal"], + "e5e0311646dd5d88b1b1c8054e5d2513758a0685e4648234623f89199fb24d87": ["qfp|LQFP-64|nominal"], + "ea39d87804deac08a7d68bd7c4b299464160b61be5f38be5e37409f4d039b98d": ["chip|0805|least"], + "ecea55ec9d68011f16a1a434cadc05acad15d0f889d15f2428333b04990128c0": ["sot|SOT-223|nominal"], + "f33faa830ca77cbf281feb8dc9d1fe920e6ab0af938ac2da10db4c75c8adc76e": ["chip|0603|nominal"], + "f3ea6fe23e86b923e5f368a1aaee3610b15ba48ef01209a5a217766efb511a1c": ["qfn|QFN-32|least"], + "f535ad2658a06ced889d6d258b07c4c3c84bb9d98e4149cd0fd5ece9870eb926": ["soic|SOIC-14|nominal"], + "f7890fd34cc38d7f1daa77514d7da75ca24772b6ff1b4792012d9301b7cae2e7": ["sot|SOT-223|most"], + "f9f3def1821a2f6c477bc10904a8a5de7925ec1151aee60eff3717d146bb3e05": ["chip|0805|most"], +}; + +/** Every pad fingerprint the only legacy IPC generator (rendering-core v0.1.3 == in-tree dda0d07…81c3ccd) + * can emit: ALL_FAMILIES × sizes × densities, fingerprinted over BOTH source.pads and + * buildFootprintRenderModel(source).pads (deduplicated). Frozen; LEGACY_IPC_GENERATOR records the generator identity. */ +export const LEGACY_IPC_FINGERPRINTS: ReadonlySet = new Set( + Object.keys(LEGACY_IPC_FINGERPRINT_PRESETS), +); + +/** Fingerprints dropped because an accepted revision emits them: those legacy pads were already correct. */ +export const LEGACY_IPC_REMOVED: readonly { + readonly fingerprint: string; + readonly presets: readonly string[]; + readonly note: string; +}[] = []; diff --git a/src/modules/library/shared/qualified-ipc-fingerprints.generated.ts b/src/modules/library/shared/qualified-ipc-fingerprints.generated.ts new file mode 100644 index 00000000..720b5ad8 --- /dev/null +++ b/src/modules/library/shared/qualified-ipc-fingerprints.generated.ts @@ -0,0 +1,13 @@ +// GENERATED by scripts/gen-ipc-fingerprints.ts --qualified. Append-only: never edit a block. +// Revoke a revision by deleting its block; affected parts re-flag at read time (H15 §3.2). + +/** family → accepted revision → "size|density" → pad fingerprint. */ +export type QualifiedIpcFingerprintTable = Readonly< + Record>>>> +>; + +/** + * family → accepted revision → "size|density" → fingerprint. Append-only; delete a revision block to revoke it. + * Generated by scripts/gen-ipc-fingerprints.ts --qualified AFTER O6 passes on that revision. + */ +export const QUALIFIED_IPC_FINGERPRINTS: QualifiedIpcFingerprintTable = {}; From 35e6d47e1d1a6d7a2e282d92305f5d755a47f5f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Thu, 15 Oct 2026 08:36:21 +0200 Subject: [PATCH 2/3] feat(library): classify footprint qualification from stored and placed pads Read-time classifier (accepted table, legacy deny-list, then IPC/template/ legacy-generated claims) over the pads actually stored or placed. LibrarySDK gains getFootprintQualifications + classifyPlacedFootprints; placement snapshots carry a generation stamp + derivationHash, default refs carry derivationHash. updateComponent refuses a flagged footprint as a new default (422 UNQUALIFIED_GENERATOR). Library detail page and ChangeFootprintDialog show the unqualified badge and disable "Use selected footprint". Refs OPENPCB-63. Slice O3 of OPENPCB-63.md (H15). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019f4xrAzSno98LLAfrwv6XQ --- .../tests/helpers/footprint-qualification.ts | 140 ++++++ .../library-default-footprint-guard.test.ts | 176 ++++++++ .../library-footprint-qualification.test.ts | 424 ++++++++++++++++++ .../library/backend/qualification/classify.ts | 343 ++++++++++++++ .../qualification/footprint-qualifications.ts | 230 ++++++++++ src/modules/library/backend/queries.ts | 33 ++ .../frontend/ChangeFootprintDialog.tsx | 25 +- .../frontend/ComponentDetailPage.test.ts | 123 ++++- .../library/frontend/ComponentDetailPage.tsx | 4 + .../components/FootprintUnqualifiedBadge.tsx | 33 ++ src/modules/library/frontend/types.ts | 8 +- src/sdks/library/index.ts | 19 + src/sdks/library/types.ts | 36 ++ 13 files changed, 1589 insertions(+), 5 deletions(-) create mode 100644 src/core/backend/tests/helpers/footprint-qualification.ts create mode 100644 src/core/backend/tests/library-default-footprint-guard.test.ts create mode 100644 src/core/backend/tests/library-footprint-qualification.test.ts create mode 100644 src/modules/library/backend/qualification/classify.ts create mode 100644 src/modules/library/backend/qualification/footprint-qualifications.ts create mode 100644 src/modules/library/frontend/components/FootprintUnqualifiedBadge.tsx diff --git a/src/core/backend/tests/helpers/footprint-qualification.ts b/src/core/backend/tests/helpers/footprint-qualification.ts new file mode 100644 index 00000000..649fa0cc --- /dev/null +++ b/src/core/backend/tests/helpers/footprint-qualification.ts @@ -0,0 +1,140 @@ +/** + * Library footprint rows for H15 qualification tests (§3.4): stored pads with + * arbitrary provenance claims, written straight into `library_footprints` the + * way an `.opclib` intake, an old build or a forged pack could leave them. + */ +import { getSharedSqlite } from "../../db/sqlite-client"; +import { buildFootprintRenderModel } from "../../../../shared/rendering/footprint-preview-builder"; +import { generateFootprint } from "../../../../shared/rendering/ipc7351b"; +import type { + FootprintRenderModel, + FootprintRenderSource, + FootprintRenderSourcePad, +} from "../../../../shared/rendering/types"; +import { getTemplate } from "../../../../shared/rendering/parametric"; +import { installedIpcQualification } from "../../../../modules/library/shared/generator-qualification"; + +/** True while the installed rendering-core is the legacy (≤ v0.1.3, manifest-less) generator. */ +export const LEGACY_GENERATOR_INSTALLED = installedIpcQualification() === null; + +export function rectPad( + number: string, + x: number, + y: number, + size = 1, +): FootprintRenderSourcePad { + return { + id: `${number}:${x}:${y}`, + number, + shape: "rect", + centerMm: { x, y }, + widthMm: size, + heightMm: size, + rotationDeg: 0, + layer: "F.Cu", + }; +} + +export function previewOf( + pads: readonly FootprintRenderSourcePad[], + name = "Test footprint", +): FootprintRenderModel { + const source: FootprintRenderSource = { + name, + pads, + graphics: [], + labels: [], + warnings: [], + }; + return buildFootprintRenderModel(source); +} + +/** Preview of a legacy IPC preset: only meaningful while the legacy generator is installed. */ +export function legacyIpcPreview( + family: "chip" | "soic" | "sot" | "qfp" | "qfn", + size: string, + density: "most" | "nominal" | "least", +): FootprintRenderModel { + return buildFootprintRenderModel(generateFootprint(family, size, density).source); +} + +export function templatePreview( + templateId: string, + params: Record, +): FootprintRenderModel { + const template = getTemplate(templateId); + if (!template) throw new Error(`unknown template ${templateId}`); + return buildFootprintRenderModel(template.generate(params as never).source); +} + +/** Two numbered pads on top of each other: what an overlapping template emits. */ +export function overlappingPreview(): FootprintRenderModel { + return previewOf([rectPad("1", 0, 0), rectPad("2", 0.2, 0)]); +} + +/** Two well-separated pads numbered 1 and 2. */ +export function plainPreview(): FootprintRenderModel { + return previewOf([rectPad("1", -2, 0), rectPad("2", 2, 0)]); +} + +export const PIN_HEADER_1X2 = { + rows: 1, + pinsPerRow: 2, + pitchMm: 2.54, + mount: "tht", + orientation: "vertical", +} as const; + +export function footprintDataJson( + provenance: Record, + preview: FootprintRenderModel | null, +): string { + return JSON.stringify({ + provenance, + normalized: { name: preview?.name ?? "fp", preview }, + }); +} + +export function insertFootprintRow( + id: string, + dataJson: string, + name = "Qualification fixture", +): string { + getSharedSqlite() + .query( + "INSERT INTO library_footprints (id, name, data_json, created_at) VALUES (?, ?, ?, ?)", + ) + .run(id, name, dataJson, new Date().toISOString()); + return id; +} + +export function updateFootprintRow(id: string, dataJson: string): void { + getSharedSqlite() + .query("UPDATE library_footprints SET data_json = ? WHERE id = ?") + .run(dataJson, id); +} + +export function deleteFootprintRow(id: string): void { + getSharedSqlite().query("DELETE FROM library_footprints WHERE id = ?").run(id); +} + +/** Provenance claims, as each writer leaves them. */ +export const PROVENANCE = { + /** Generated before d11ce92: no generator identity. */ + legacyGenerated: { sourceKind: "generated", sourceFormat: "ipc-7351b" }, + ipc: (family: string, size: string, density: string, extra: Record = {}) => ({ + sourceKind: "generated", + sourceFormat: "ipc-7351b", + generator: { kind: "ipc7351b", family, size, density }, + ...extra, + }), + template: (templateId: string, params: Record) => ({ + sourceKind: "generated", + sourceFormat: "ipc-7351b", + generator: { kind: "template", templateId, params }, + generatorVersion: 1, + }), + drawn: { sourceKind: "drawn", sourceFormat: "openpcb-editor" }, + kicad: { sourceKind: "imported", sourceFormat: "kicad_mod" }, + coreLibrary: { source: "kicad-derived", sourceHash: "core-hash" }, +} as const; diff --git a/src/core/backend/tests/library-default-footprint-guard.test.ts b/src/core/backend/tests/library-default-footprint-guard.test.ts new file mode 100644 index 00000000..e8e8c223 --- /dev/null +++ b/src/core/backend/tests/library-default-footprint-guard.test.ts @@ -0,0 +1,176 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import os from "node:os"; +import path from "node:path"; +import type { LibrarySDK } from "../../../sdks"; +import { MODULE_SDK_TOKENS } from "../../../sdks"; +import { getSharedSqlite, resetSharedSqliteForTesting } from "../db/sqlite-client"; +import { DiagnosticsStore } from "../diagnostics/diagnostics-store"; +import { createHttpServer } from "../http/create-http-server"; +import { MentionRegistry } from "../mentions"; +import { ModuleRuntime } from "../modules/module-loader"; +import { ModuleRouterRegistry } from "../router/module-registry"; +import { + footprintDataJson, + insertFootprintRow, + overlappingPreview, + plainPreview, + PROVENANCE, + PIN_HEADER_1X2, +} from "./helpers/footprint-qualification"; +import { pinHeaderGenerator, templatePayload } from "./helpers/template-footprint"; + +// PATCH /components/:id {defaultFootprintId} refuses a flagged generated +// footprint as a NEW default (H15 §3.4) from every client; an unchanged +// flagged default (e.g. set by .opclib intake) stays editable. + +let server: ReturnType; +let sdk: LibrarySDK; + +beforeAll(async () => { + resetSharedSqliteForTesting(); + process.env.OPENPCB_DB_PATH = path.join( + os.tmpdir(), + `fp-default-guard-${Date.now()}-${crypto.randomUUID()}.sqlite`, + ); + MentionRegistry.init(); + const moduleRegistry = new ModuleRouterRegistry(); + const moduleRuntime = new ModuleRuntime({ + moduleRegistry, + workspaceRoot: path.resolve(import.meta.dir, "../../.."), + }); + await moduleRuntime.bootstrap(); + server = createHttpServer({ + diagnosticsStore: new DiagnosticsStore(), + moduleRegistry, + moduleRuntime, + }); + sdk = moduleRuntime.getSdkRegistry().resolve(MODULE_SDK_TOKENS.LIBRARY); +}); + +async function call(method: string, url: string, body?: unknown): Promise<{ status: number; json: any }> { + const res = await server.fetch( + new Request(`http://localhost/api/modules/library${url}`, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); + return { status: res.status, json: await res.json() }; +} + +/** Drawn two-pin component (pins 1, 2) on the placeholder footprint. */ +async function importComponent(name: string): Promise { + const pins = ["1", "2"].map((n, i) => ({ + id: `p${n}`, + name: n, + number: n, + electricalType: "passive", + positionMm: { x: i * 2.54, y: 0 }, + lengthMm: 1, + rotationDeg: 0, + unit: 1, + hidden: false, + })); + const { status, json } = await call("POST", "/imports/drawn", { + drawnSymbol: { + source: { name, unitCount: 1, referenceText: "U?", valueText: name, pins, graphics: [], warnings: [] }, + referencePrefix: "U", + }, + footprintMode: "none", + component: { name, description: "default guard" }, + }); + expect(status).toBe(201); + return json.data.componentId as string; +} + +/** Link a stored footprint row to the component as a (non-default) variant. */ +function linkVariant(componentId: string, footprintId: string, isDefault = false): void { + const db = getSharedSqlite(); + db.query( + "INSERT INTO library_component_footprints (component_id, footprint_id, is_default, variant_label, sort_order) VALUES (?, ?, ?, ?, ?)", + ).run(componentId, footprintId, isDefault ? 1 : 0, footprintId, 10); + if (isDefault) { + db.query("UPDATE library_component_footprints SET is_default = 0 WHERE component_id = ? AND footprint_id != ?").run( + componentId, + footprintId, + ); + db.query("UPDATE library_components SET footprint_id = ? WHERE id = ?").run(footprintId, componentId); + } +} + +function flaggedRows(): { label: string; dataJson: string }[] { + return [ + { label: "legacy generated", dataJson: footprintDataJson(PROVENANCE.legacyGenerated, plainPreview()) }, + { + label: "overlapping template", + dataJson: footprintDataJson(PROVENANCE.template("pin-header", PIN_HEADER_1X2), overlappingPreview()), + }, + ]; +} + +describe("library: default-footprint qualification guard", () => { + for (const { label, dataJson } of flaggedRows()) { + test(`PATCH to a flagged ${label} footprint → 422, default unchanged`, async () => { + const componentId = await importComponent(`Guard ${label}`); + const flagged = insertFootprintRow(crypto.randomUUID(), dataJson); + linkVariant(componentId, flagged); + + const detail = await sdk.getComponentDetail(componentId); + const variant = detail?.footprintVariants.find((v) => v.footprintId === flagged); + expect(variant?.qualification?.status).toBe("unqualified-generator"); + + const res = await call("PATCH", `/components/${componentId}`, { defaultFootprintId: flagged }); + expect(res.status).toBe(422); + expect(res.json.code).toBe("UNQUALIFIED_GENERATOR"); + expect(res.json.type).toBe("https://openpcb.dev/problems/unqualified-generator"); + expect(res.json.detail).toBe(variant?.qualification?.reason); + + const after = await sdk.resolveComponent(componentId); + expect(after?.footprintId).toBe("fp-no-footprint-yet"); + }); + } + + test("the SDK path is guarded too", async () => { + const componentId = await importComponent("Guard SDK"); + const flagged = insertFootprintRow(crypto.randomUUID(), flaggedRows()[0]!.dataJson); + linkVariant(componentId, flagged); + await expect(sdk.updateComponent(componentId, { defaultFootprintId: flagged })).rejects.toThrow( + "Generated before generator provenance was recorded", + ); + }); + + test("an unchanged flagged default → 200", async () => { + const componentId = await importComponent("Guard unchanged"); + const flagged = insertFootprintRow(crypto.randomUUID(), flaggedRows()[0]!.dataJson); + linkVariant(componentId, flagged, true); + + const res = await call("PATCH", `/components/${componentId}`, { + defaultFootprintId: flagged, + description: "still editable", + }); + expect(res.status).toBe(200); + expect(res.json.data.component.footprintId).toBe(flagged); + expect(res.json.data.component.description).toBe("still editable"); + }); + + test("PATCH to a qualified template or drawn footprint → 200", async () => { + const componentId = await importComponent("Guard ok"); + const generator = pinHeaderGenerator(1, 2); + const added = await call("POST", `/components/${componentId}/footprints`, { + mode: "generated", + source: templatePayload(generator), + generator, + setDefault: false, + }); + expect(added.status).toBe(201); + const template = added.json.data.footprintId as string; + const drawn = insertFootprintRow(crypto.randomUUID(), footprintDataJson(PROVENANCE.drawn, plainPreview())); + linkVariant(componentId, drawn); + + for (const footprintId of [template, drawn]) { + const res = await call("PATCH", `/components/${componentId}`, { defaultFootprintId: footprintId }); + expect(res.status).toBe(200); + expect(res.json.data.component.footprintId).toBe(footprintId); + } + }); +}); diff --git a/src/core/backend/tests/library-footprint-qualification.test.ts b/src/core/backend/tests/library-footprint-qualification.test.ts new file mode 100644 index 00000000..fe3cf69a --- /dev/null +++ b/src/core/backend/tests/library-footprint-qualification.test.ts @@ -0,0 +1,424 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import os from "node:os"; +import path from "node:path"; +import type { + LibraryFootprintPlacementSnapshot, + LibraryFootprintQualification, + LibrarySDK, +} from "../../../sdks"; +import { MODULE_SDK_TOKENS } from "../../../sdks"; +import { classifyFootprintGeometry } from "../../../modules/library/backend/qualification/classify"; +import { + classifyPlacedFootprints, + getFootprintQualifications, +} from "../../../modules/library/backend/qualification/footprint-qualifications"; +import { padGeometryFingerprint } from "../../../modules/library/backend/qualification/pad-fingerprint"; +import type { QualificationInputs } from "../../../modules/library/backend/qualification/classify"; +import { LEGACY_IPC_FINGERPRINTS } from "../../../modules/library/shared/legacy-ipc-fingerprints.generated"; +import type { FootprintRenderModel } from "../../../shared/rendering/types"; +import { getSharedDb, getSharedSqlite, resetSharedSqliteForTesting } from "../db/sqlite-client"; +import { DiagnosticsStore } from "../diagnostics/diagnostics-store"; +import { createHttpServer } from "../http/create-http-server"; +import { MentionRegistry } from "../mentions"; +import { ModuleRuntime } from "../modules/module-loader"; +import { ModuleRouterRegistry } from "../router/module-registry"; +import { + deleteFootprintRow, + footprintDataJson, + insertFootprintRow, + LEGACY_GENERATOR_INSTALLED, + legacyIpcPreview, + overlappingPreview, + PIN_HEADER_1X2, + plainPreview, + PROVENANCE, + previewOf, + rectPad, + templatePreview, + updateFootprintRow, +} from "./helpers/footprint-qualification"; +import { pinHeaderGenerator, templatePayload } from "./helpers/template-footprint"; + +// Legacy pads only exist while the legacy generator (pinned v0.1.3) is installed. +const legacyTest = test.if(LEGACY_GENERATOR_INSTALLED); + +let sdk: LibrarySDK; +let server: ReturnType; + +beforeAll(async () => { + resetSharedSqliteForTesting(); + process.env.OPENPCB_DB_PATH = path.join( + os.tmpdir(), + `fp-qualification-${Date.now()}-${crypto.randomUUID()}.sqlite`, + ); + MentionRegistry.init(); + const moduleRegistry = new ModuleRouterRegistry(); + const moduleRuntime = new ModuleRuntime({ + moduleRegistry, + workspaceRoot: path.resolve(import.meta.dir, "../../.."), + }); + await moduleRuntime.bootstrap(); + server = createHttpServer({ + diagnosticsStore: new DiagnosticsStore(), + moduleRegistry, + moduleRuntime, + }); + sdk = moduleRuntime.getSdkRegistry().resolve(MODULE_SDK_TOKENS.LIBRARY); +}); + +function fp(preview: FootprintRenderModel): string { + return padGeometryFingerprint(preview.pads); +} + +/** An accepted SOT-23/least block whose only fingerprint is `preview`'s pads. */ +function acceptedFor(preview: FootprintRenderModel): QualificationInputs { + return { accepted: { sot: { 2: { "SOT-23|least": fp(preview) } } } }; +} + +function seedRow(provenance: Record, preview: FootprintRenderModel | null): string { + return insertFootprintRow(crypto.randomUUID(), footprintDataJson(provenance, preview)); +} + +function rowQualification(id: string, inputs: QualificationInputs = {}): LibraryFootprintQualification { + const out = getFootprintQualifications(getSharedDb(), [id], inputs); + expect(Object.keys(out)).toEqual([id]); + return out[id]!; +} + +function snapshot( + footprintId: string, + preview: FootprintRenderModel, + generation?: unknown, +): LibraryFootprintPlacementSnapshot { + const snap: Record = { + footprintId, + name: preview.name, + mountType: null, + sourceHash: null, + preview, + }; + if (generation !== undefined) snap.generation = generation; + return snap as unknown as LibraryFootprintPlacementSnapshot; +} + +function placedQualification( + snap: LibraryFootprintPlacementSnapshot, + inputs: QualificationInputs = {}, +): LibraryFootprintQualification { + return classifyPlacedFootprints(getSharedDb(), [{ key: "p", snapshot: snap }], inputs).p!; +} + +describe("legacy deny-list fixtures", () => { + legacyTest("the pinned generator's chip 0603 preview is on the legacy deny-list", () => { + expect(LEGACY_IPC_FINGERPRINTS.has(fp(legacyIpcPreview("chip", "0603", "nominal")))).toBe(true); + }); +}); + +describe("row classification (stored pads + provenance claim)", () => { + test("legacy generated row (no generator identity) is flagged", () => { + const q = rowQualification(seedRow(PROVENANCE.legacyGenerated, plainPreview())); + expect(q.status).toBe("unqualified-generator"); + expect(q.generatorKind).toBe("legacy-generated"); + expect(q.generatorLabel).toBeNull(); + expect(q.reason).toContain("Generated before generator provenance was recorded"); + expect(q.reason).toContain("No qualified IPC generator is installed yet"); + }); + + legacyTest("post-d11ce92 IPC row carrying legacy pads is flagged with its preset label", () => { + const q = rowQualification( + seedRow(PROVENANCE.ipc("chip", "0603", "nominal", { ipcRevision: 2 }), legacyIpcPreview("chip", "0603", "nominal")), + ); + expect(q.status).toBe("unqualified-generator"); + expect(q.generatorKind).toBe("ipc7351b"); + expect(q.generatorLabel).toBe("0603 · nominal"); + expect(q.reason).toContain("legacy IPC-7351B Chip generator"); + expect(q.reason).toContain("No qualified generator for Chip is installed yet"); + }); + + legacyTest("legacy pads get the replace-with-IPC remedy once the family is available", () => { + const id = seedRow(PROVENANCE.ipc("chip", "0603", "nominal"), legacyIpcPreview("chip", "0603", "nominal")); + const q = rowQualification(id, { + installed: { chip: { revision: 2, qualified: true } }, + accepted: { chip: { 2: { "0603|nominal": "not-these-pads" } } }, + }); + expect(q.status).toBe("unqualified-generator"); + expect(q.reason).toContain("Replace it in the Library (Change footprint → IPC package)"); + }); + + test("pads in an accepted block qualify; the same row without the block does not", () => { + const preview = plainPreview(); + const id = seedRow(PROVENANCE.ipc("sot", "SOT-23", "least"), preview); + expect(rowQualification(id, acceptedFor(preview))).toEqual({ + status: "qualified", + reason: null, + generatorKind: "ipc7351b", + generatorLabel: "SOT-23 · least", + }); + const unaccepted = rowQualification(id); + expect(unaccepted.status).toBe("unqualified-generator"); + expect(unaccepted.generatorKind).toBe("ipc7351b"); + expect(unaccepted.reason).toContain("has not passed qualification"); + }); + + test("a clean template row qualifies", () => { + const q = rowQualification( + seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), templatePreview("pin-header", PIN_HEADER_1X2)), + ); + expect(q).toEqual({ + status: "qualified", + reason: null, + generatorKind: "template", + generatorLabel: "pin-header", + }); + }); + + test("an overlapping template row is flagged with the pads that overlap", () => { + const q = rowQualification(seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), overlappingPreview())); + expect(q.status).toBe("unqualified-generator"); + expect(q.generatorKind).toBe("template"); + expect(q.reason).toContain("pads 1 and 2 overlap"); + expect(q.reason).toContain("Regenerate it with non-overlapping parameters."); + }); + + test("a template row with unreadable pads is flagged, not trusted", () => { + const preview = { ...plainPreview(), pads: [{ number: "1" }] } as unknown as FootprintRenderModel; + const q = rowQualification(seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), preview)); + expect(q.status).toBe("unqualified-generator"); + expect(q.reason).toContain("its pads cannot be read"); + }); + + for (const [label, provenance] of [ + ["drawn", PROVENANCE.drawn], + ["KiCad import", PROVENANCE.kicad], + ["CoreLibrary", PROVENANCE.coreLibrary], + ] as const) { + test(`${label} row is not generated`, () => { + expect(rowQualification(seedRow(provenance, plainPreview()))).toEqual({ + status: "not-generated", + reason: null, + generatorKind: null, + generatorLabel: null, + }); + }); + } + + test("malformed data_json reads as not generated", () => { + const id = insertFootprintRow(crypto.randomUUID(), "{not json"); + expect(rowQualification(id).status).toBe("not-generated"); + }); + + legacyTest("a relabelled drawn row carrying legacy pads is flagged (rule 2 ignores claims)", () => { + const q = rowQualification(seedRow(PROVENANCE.drawn, legacyIpcPreview("chip", "0603", "nominal"))); + expect(q.status).toBe("unqualified-generator"); + expect(q.generatorKind).toBe("ipc7351b"); + expect(q.generatorLabel).toBe("0603 · nominal"); + }); + + test("unknown ids are absent and duplicate ids collapse", () => { + const id = seedRow(PROVENANCE.drawn, plainPreview()); + const out = getFootprintQualifications(getSharedDb(), [id, id, "missing-footprint"]); + expect(Object.keys(out)).toEqual([id]); + }); + + test("the SDK classifies rows with the shipped tables", async () => { + const legacy = seedRow(PROVENANCE.legacyGenerated, plainPreview()); + const drawn = seedRow(PROVENANCE.drawn, plainPreview()); + const out = await sdk.getFootprintQualifications([legacy, drawn]); + expect(out[legacy]?.status).toBe("unqualified-generator"); + expect(out[drawn]?.status).toBe("not-generated"); + }); +}); + +describe("snapshot classification (placed pads)", () => { + test("stamped overlapping template is flagged after its row is deleted", () => { + const id = seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), overlappingPreview()); + deleteFootprintRow(id); + const q = placedQualification(snapshot(id, overlappingPreview(), { kind: "template", label: "pin-header" })); + expect(q.status).toBe("unqualified-generator"); + expect(q.generatorKind).toBe("template"); + expect(q.reason).toContain("overlap"); + }); + + test("stamped clean template qualifies without any row", () => { + const preview = templatePreview("pin-header", PIN_HEADER_1X2); + const q = placedQualification(snapshot("no-such-row", preview, { kind: "template", label: "pin-header" })); + expect(q.status).toBe("qualified"); + expect(q.generatorLabel).toBe("pin-header"); + }); + + test("a stamp cannot buy qualification: IPC stamp on arbitrary pads is flagged", () => { + const q = placedQualification(snapshot("no-such-row", plainPreview(), { kind: "ipc7351b", label: "SOT-23 · least" })); + expect(q.status).toBe("unqualified-generator"); + expect(q.reason).toContain("has not passed qualification"); + expect(q.reason).toContain("No qualified generator for SOT is installed yet"); + }); + + legacyTest("unstamped legacy pads are flagged after the row is deleted", () => { + const preview = legacyIpcPreview("chip", "0603", "nominal"); + const id = seedRow(PROVENANCE.ipc("chip", "0603", "nominal"), preview); + deleteFootprintRow(id); + const q = placedQualification(snapshot(id, preview)); + expect(q.status).toBe("unqualified-generator"); + expect(q.generatorLabel).toBe("0603 · nominal"); + }); + + test("unstamped snapshot whose generated row was deleted is the documented residual", () => { + const id = seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), overlappingPreview()); + deleteFootprintRow(id); + expect(placedQualification(snapshot(id, overlappingPreview())).status).toBe("not-generated"); + }); + + test("unstamped snapshot adopts its unchanged generated row's claim", () => { + const clean = templatePreview("pin-header", PIN_HEADER_1X2); + const cleanId = seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), clean); + expect(placedQualification(snapshot(cleanId, clean))).toMatchObject({ + status: "qualified", + generatorKind: "template", + }); + const badId = seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), overlappingPreview()); + const bad = placedQualification(snapshot(badId, overlappingPreview())); + expect(bad.status).toBe("unqualified-generator"); + expect(bad.reason).toContain("overlap"); + }); + + test("unstamped snapshot of a row updated in place no longer matches", () => { + const placed = templatePreview("pin-header", PIN_HEADER_1X2); + const id = seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), placed); + updateFootprintRow( + id, + footprintDataJson(PROVENANCE.template("pin-header", PIN_HEADER_1X2), previewOf([rectPad("1", -3, 0), rectPad("2", 3, 0)])), + ); + const q = placedQualification(snapshot(id, placed)); + expect(q.status).toBe("unqualified-generator"); + expect(q.generatorKind).toBe("template"); + expect(q.reason).toContain("no longer matches its generated library footprint"); + }); + + test("unstamped snapshot of a non-generated row is not generated", () => { + const id = seedRow(PROVENANCE.drawn, overlappingPreview()); + expect(placedQualification(snapshot(id, overlappingPreview())).status).toBe("not-generated"); + }); + + test("a malformed stamp counts as absent, so the row lookup still applies", () => { + const id = seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), overlappingPreview()); + const q = placedQualification(snapshot(id, overlappingPreview(), { kind: "drawn", label: 7 })); + expect(q.status).toBe("unqualified-generator"); + expect(q.reason).toContain("overlap"); + }); + + test("revoking an accepted block re-flags placed parts (stamped and unstamped)", () => { + const preview = plainPreview(); + const id = seedRow(PROVENANCE.ipc("sot", "SOT-23", "least"), preview); + const stamped = snapshot(id, preview, { kind: "ipc7351b", label: "SOT-23 · least" }); + const unstamped = snapshot(id, preview); + for (const snap of [stamped, unstamped]) { + expect(placedQualification(snap, acceptedFor(preview)).status).toBe("qualified"); + const revoked = placedQualification(snap, { accepted: {} }); + expect(revoked.status).toBe("unqualified-generator"); + expect(revoked.generatorKind).toBe("ipc7351b"); + } + }); + + test("one call classifies many keys and only looks up stampless leftovers", () => { + const clean = templatePreview("pin-header", PIN_HEADER_1X2); + const rowId = seedRow(PROVENANCE.template("pin-header", PIN_HEADER_1X2), clean); + const out = classifyPlacedFootprints(getSharedDb(), [ + { key: "a", snapshot: snapshot(rowId, clean) }, + { key: "b", snapshot: snapshot(rowId, clean, { kind: "template", label: "pin-header" }) }, + { key: "c", snapshot: snapshot("gone", plainPreview()) }, + { key: "d", snapshot: null as unknown as LibraryFootprintPlacementSnapshot }, + ]); + expect(Object.fromEntries(Object.entries(out).map(([k, v]) => [k, v.status]))).toEqual({ + a: "qualified", + b: "qualified", + c: "not-generated", + d: "not-generated", + }); + }); + + test("the classifier entry point applies the rules to bare pads", () => { + expect(classifyFootprintGeometry({ pads: null, generation: null }).status).toBe("not-generated"); + expect( + classifyFootprintGeometry({ pads: null, generation: { kind: "template", label: "pin-header" } }).status, + ).toBe("unqualified-generator"); + }); +}); + +describe("snapshot stamp writer", () => { + async function call(method: string, url: string, body?: unknown) { + const res = await server.fetch( + new Request(`http://localhost/api/modules/library${url}`, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); + return { status: res.status, json: (await res.json()) as any }; + } + + /** A drawn two-pin component on the placeholder footprint. */ + async function placeholderComponent(name: string): Promise { + const pins = ["1", "2"].map((n, i) => ({ + id: `p${n}`, + name: n, + number: n, + electricalType: "passive", + positionMm: { x: i * 2.54, y: 0 }, + lengthMm: 1, + rotationDeg: 0, + unit: 1, + hidden: false, + })); + const created = await call("POST", "/imports/drawn", { + drawnSymbol: { + source: { name, unitCount: 1, referenceText: "J?", valueText: name, pins, graphics: [], warnings: [] }, + referencePrefix: "J", + }, + footprintMode: "none", + component: { name, description: "stamp writer" }, + }); + expect(created.status).toBe(201); + return created.json.data.componentId as string; + } + + async function headerComponent(): Promise<{ componentId: string; footprintId: string }> { + const componentId = await placeholderComponent("QualHdr"); + const generator = pinHeaderGenerator(1, 2); + const added = await call("POST", `/components/${componentId}/footprints`, { + mode: "generated", + source: templatePayload(generator), + generator, + }); + expect(added.status).toBe(201); + return { componentId, footprintId: added.json.data.footprintId as string }; + } + + test("snapshots of generated rows carry the generation stamp and derivationHash", async () => { + const { componentId, footprintId } = await headerComponent(); + const placement = await sdk.resolveComponentForPlacement(componentId); + expect(placement?.footprint.generation).toEqual({ kind: "template", label: "pin-header" }); + expect(placement?.footprint.derivationHash).toBeNull(); + const variant = placement?.footprintVariants.find((v) => v.footprintId === footprintId); + expect(variant?.qualification?.status).toBe("qualified"); + + const row = getSharedSqlite() + .query<{ data_json: string }, [string]>("SELECT data_json FROM library_footprints WHERE id = ?") + .get(footprintId)!; + const data = JSON.parse(row.data_json); + data.provenance.derivationHash = "derived-1"; + updateFootprintRow(footprintId, JSON.stringify(data)); + const after = await sdk.resolveComponentForPlacement(componentId); + expect(after?.footprint.derivationHash).toBe("derived-1"); + const refs = await sdk.getDefaultFootprintRefs([componentId]); + expect(refs[componentId]).toMatchObject({ footprintId, derivationHash: "derived-1" }); + }); + + test("snapshots of non-generated rows (the placeholder) carry no stamp", async () => { + const componentId = await placeholderComponent("QualPlain"); + const placement = await sdk.resolveComponentForPlacement(componentId); + expect(placement?.footprint.footprintId).toBe("fp-no-footprint-yet"); + expect(placement?.footprint).not.toHaveProperty("generation"); + const refs = await sdk.getDefaultFootprintRefs([componentId]); + expect(refs[componentId]?.derivationHash).toBeNull(); + }); +}); diff --git a/src/modules/library/backend/qualification/classify.ts b/src/modules/library/backend/qualification/classify.ts new file mode 100644 index 00000000..a35807ec --- /dev/null +++ b/src/modules/library/backend/qualification/classify.ts @@ -0,0 +1,343 @@ +/** + * Footprint generator qualification (H15 §3.4), derived at read time from the + * pads actually stored (library row) or placed (snapshot). Rules 1–2 are + * purely geometric, so no claim — `sourceKind`, a generation stamp, a forged + * `ipcRevision` — can make legacy or arbitrary geometry qualify. The stamp or + * row provenance only selects the claim rules 3–5. + * + * Pure apart from the template-verdict LRU. + */ +import type { + FootprintGenerationStamp, + LibraryFootprintQualification, +} from "../../../../sdks/library"; +import { + ALL_FAMILIES, + type PackageFamily, +} from "../../../../shared/rendering/ipc7351b"; +import type { FootprintRenderSourcePad } from "../../../../shared/rendering/types"; +import { + ipcFamilyAvailability, + type InstalledIpcQualification, +} from "../../shared/generator-qualification"; +import { LEGACY_IPC_FINGERPRINT_PRESETS } from "../../shared/legacy-ipc-fingerprints.generated"; +import { + QUALIFIED_IPC_FINGERPRINTS, + type QualifiedIpcFingerprintTable, +} from "../../shared/qualified-ipc-fingerprints.generated"; +import { + describeGeneratedGeometryDefects, + generatedGeometryDefects, +} from "../import/generated-geometry"; +import { padGeometryFingerprint } from "./pad-fingerprint"; + +/** + * Test seam: `accepted` overrides the accepted-fingerprint table, `installed` + * the installed rendering-core manifest (null = none). Production passes + * nothing. + */ +export interface QualificationInputs { + readonly accepted?: QualifiedIpcFingerprintTable; + readonly installed?: InstalledIpcQualification | null; +} + +export const NOT_GENERATED: LibraryFootprintQualification = Object.freeze({ + status: "not-generated", + reason: null, + generatorKind: null, + generatorLabel: null, +}); + +const TEMPLATE_REMEDY = "Regenerate it with non-overlapping parameters."; +const LEGACY_GENERATED_REASON = + "Generated before generator provenance was recorded; its geometry cannot be verified."; +export const NO_LONGER_MATCHES_REASON = + "The placed footprint no longer matches its generated library footprint and cannot be verified. Update the part from the Library."; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function presetLabel(size: string, density: string): string { + return `${size} · ${density}`; +} + +function familyLabel(family: PackageFamily): string { + return ALL_FAMILIES.find((def) => def.id === family)?.label ?? family; +} + +function familyOf(id: string): PackageFamily | null { + return ALL_FAMILIES.find((def) => def.id === id)?.id ?? null; +} + +/** The family whose presets include this size label (labels are family-unique). */ +function familyOfSize(size: string): PackageFamily | null { + return ( + ALL_FAMILIES.find((def) => def.sizes.some((s) => s.label === size))?.id ?? + null + ); +} + +function familyAvailable( + family: PackageFamily, + inputs: QualificationInputs, +): boolean { + return ipcFamilyAvailability(family, inputs.installed, inputs.accepted) + .available; +} + +const REPLACE_WITH_IPC = + "Replace it in the Library (Change footprint → IPC package), then update the part."; + +/** Pin-aware remedy for an IPC-generated footprint (review finding 23). */ +function ipcRemedy( + family: PackageFamily | null, + inputs: QualificationInputs, +): string { + if (family === null) { + return "No qualified IPC generator for this package is installed yet; replace it with a drawn or verified footprint."; + } + return familyAvailable(family, inputs) + ? REPLACE_WITH_IPC + : `No qualified generator for ${familyLabel(family)} is installed yet; replace it with a drawn or verified footprint.`; +} + +/** A generator with unknown family (legacy-generated): the remedy cannot name one. */ +function genericRemedy(inputs: QualificationInputs): string { + return ALL_FAMILIES.some((def) => familyAvailable(def.id, inputs)) + ? "Replace it in the Library (Change footprint → IPC package, or a drawn or verified footprint), then update the part." + : "No qualified IPC generator is installed yet; replace it with a drawn or verified footprint."; +} + +function unqualified( + generatorKind: LibraryFootprintQualification["generatorKind"], + generatorLabel: string | null, + reason: string, +): LibraryFootprintQualification { + return { + status: "unqualified-generator", + reason, + generatorKind, + generatorLabel, + }; +} + +interface AcceptedEntry { + readonly label: string; +} + +// fingerprint → accepted preset, per table object (tests inject their own). +const acceptedIndexes = new WeakMap< + QualifiedIpcFingerprintTable, + ReadonlyMap +>(); + +function acceptedIndex( + table: QualifiedIpcFingerprintTable, +): ReadonlyMap { + const cached = acceptedIndexes.get(table); + if (cached) return cached; + const index = new Map(); + for (const revisions of Object.values(table)) { + for (const block of Object.values(revisions ?? {})) { + for (const [key, fingerprint] of Object.entries(block ?? {})) { + const [size = key, density = ""] = key.split("|"); + index.set(fingerprint, { + label: density ? presetLabel(size, density) : size, + }); + } + } + } + acceptedIndexes.set(table, index); + return index; +} + +/** Rules 1–2: the verdict geometry alone decides, or null when neither table knows `fp`. */ +export function geometricQualification( + fingerprint: string | null, + inputs: QualificationInputs = {}, +): LibraryFootprintQualification | null { + if (fingerprint === null) return null; + const accepted = acceptedIndex(inputs.accepted ?? QUALIFIED_IPC_FINGERPRINTS); + const hit = accepted.get(fingerprint); + if (hit) { + return { + status: "qualified", + reason: null, + generatorKind: "ipc7351b", + generatorLabel: hit.label, + }; + } + if (!Object.hasOwn(LEGACY_IPC_FINGERPRINT_PRESETS, fingerprint)) return null; + const preset = LEGACY_IPC_FINGERPRINT_PRESETS[fingerprint]?.[0] ?? ""; + const [familyId = "", size = "", density = ""] = preset.split("|"); + const family = familyOf(familyId); + const label = size ? presetLabel(size, density) : null; + const what = family ? `${familyLabel(family)} ` : ""; + return unqualified( + "ipc7351b", + label, + `Its pads match the legacy IPC-7351B ${what}generator output${label ? ` for ${label}` : ""}, which failed land-pattern qualification. ${ipcRemedy(family, inputs)}`, + ); +} + +function finite(value: unknown): boolean { + return typeof value === "number" && Number.isFinite(value); +} + +/** Enough structure for the geometry kernel not to read garbage. */ +function readablePads( + pads: readonly unknown[], +): pads is readonly FootprintRenderSourcePad[] { + return pads.every((pad) => { + const p = record(pad); + const center = record(p?.centerMm); + return ( + p !== null && + center !== null && + finite(center.x) && + finite(center.y) && + typeof p.number === "string" && + typeof p.shape === "string" && + finite(p.widthMm) && + finite(p.heightMm) && + (p.rotationDeg === undefined || finite(p.rotationDeg)) + ); + }); +} + +const UNREADABLE = "its pads cannot be read"; +const TEMPLATE_CACHE_LIMIT = 1024; +// fingerprint → defect sentence ("" = clean), for readable pads only: there +// the key covers exactly the pad fields the geometry check reads. LRU by Map +// insertion order. +const templateVerdicts = new Map(); + +function templateDefects( + pads: readonly unknown[] | null, + fingerprint: string | null, +): string { + if (pads === null || pads.length === 0 || !readablePads(pads)) { + return UNREADABLE; + } + if (fingerprint !== null) { + const cached = templateVerdicts.get(fingerprint); + if (cached !== undefined) { + templateVerdicts.delete(fingerprint); + templateVerdicts.set(fingerprint, cached); + return cached; + } + } + let verdict: string; + try { + const defects = generatedGeometryDefects(pads); + verdict = defects.length ? describeGeneratedGeometryDefects(defects) : ""; + } catch { + return UNREADABLE; + } + if (fingerprint !== null) { + templateVerdicts.set(fingerprint, verdict); + if (templateVerdicts.size > TEMPLATE_CACHE_LIMIT) { + const oldest = templateVerdicts.keys().next().value; + if (oldest !== undefined) templateVerdicts.delete(oldest); + } + } + return verdict; +} + +/** Rules 3–5: the claim selects the check once geometry alone did not decide. */ +function claimQualification( + pads: readonly unknown[] | null, + fingerprint: string | null, + generation: FootprintGenerationStamp | null, + inputs: QualificationInputs, +): LibraryFootprintQualification { + switch (generation?.kind) { + case "ipc7351b": { + const size = generation.label?.split(" · ")[0] ?? ""; + return unqualified( + "ipc7351b", + generation.label, + `Generated by an IPC preset revision that has not passed qualification. ${ipcRemedy(familyOfSize(size), inputs)}`, + ); + } + case "template": { + const defects = templateDefects(pads, fingerprint); + if (defects === "") { + return { + status: "qualified", + reason: null, + generatorKind: "template", + generatorLabel: generation.label, + }; + } + return unqualified( + "template", + generation.label, + `Its template output is not manufacturable: ${defects}. ${TEMPLATE_REMEDY}`, + ); + } + case "legacy-generated": + return unqualified( + "legacy-generated", + generation.label, + `${LEGACY_GENERATED_REASON} ${genericRemedy(inputs)}`, + ); + default: + return NOT_GENERATED; + } +} + +export function fingerprintPads( + pads: readonly unknown[] | null, +): string | null { + return pads === null ? null : padGeometryFingerprint(pads); +} + +/** Rules 1–6 (H15 §3.4) on one footprint. Exported for H13's intake qualifier. */ +export function classifyFootprintGeometry( + input: { + pads: readonly unknown[] | null; // preview pads actually placed / stored + generation: FootprintGenerationStamp | null; // from snapshot stamp or row provenance + }, + inputs: QualificationInputs = {}, + fingerprint: string | null = fingerprintPads(input.pads), +): LibraryFootprintQualification { + return ( + geometricQualification(fingerprint, inputs) ?? + claimQualification(input.pads, fingerprint, input.generation, inputs) + ); +} + +/** + * The generation a row's provenance claims: `sourceKind: "generated"` with an + * IPC tuple ⇒ ipc7351b, with a template identity ⇒ template, with neither ⇒ + * legacy-generated (pre-`d11ce92`); anything else ⇒ null. + */ +export function generationFromProvenance( + provenance: unknown, +): FootprintGenerationStamp | null { + const prov = record(provenance); + if (prov?.sourceKind !== "generated") return null; + const generator = record(prov.generator); + if ( + generator?.kind === "ipc7351b" && + typeof generator.family === "string" && + typeof generator.size === "string" && + typeof generator.density === "string" + ) { + return { + kind: "ipc7351b", + label: presetLabel(generator.size, generator.density), + }; + } + if ( + generator?.kind === "template" && + typeof generator.templateId === "string" + ) { + return { kind: "template", label: generator.templateId }; + } + return { kind: "legacy-generated", label: null }; +} diff --git a/src/modules/library/backend/qualification/footprint-qualifications.ts b/src/modules/library/backend/qualification/footprint-qualifications.ts new file mode 100644 index 00000000..204d1b01 --- /dev/null +++ b/src/modules/library/backend/qualification/footprint-qualifications.ts @@ -0,0 +1,230 @@ +/** + * Library-row and placed-snapshot classification (H15 §3.4) over the shared + * SQLite database. Rows are read through `json_extract` (provenance + preview + * pads only), never a full `data_json` parse; malformed JSON reads as + * not-generated. + */ +import { and, inArray, sql } from "drizzle-orm"; +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import type { + FootprintGenerationStamp, + LibraryFootprintPlacementSnapshot, + LibraryFootprintQualification, +} from "../../../../sdks/library"; +import { footprints } from "../schema"; +import { + classifyFootprintGeometry, + fingerprintPads, + generationFromProvenance, + geometricQualification, + NO_LONGER_MATCHES_REASON, + NOT_GENERATED, + type QualificationInputs, +} from "./classify"; + +type Db = BetterSQLite3Database>; + +const CHUNK = 500; + +function parseJson(text: unknown): unknown { + if (typeof text !== "string") return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function padArray(value: unknown): readonly unknown[] | null { + return Array.isArray(value) ? value : null; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** One stored row: its provenance claim + its stored preview pads. */ +export function classifyStoredFootprint( + provenance: unknown, + pads: unknown, + inputs: QualificationInputs = {}, +): LibraryFootprintQualification { + return classifyFootprintGeometry( + { pads: padArray(pads), generation: generationFromProvenance(provenance) }, + inputs, + ); +} + +/** A full `data_json` already in hand (variant lists, the default-footprint guard). */ +export function classifyFootprintDataJson( + dataJson: string, + inputs: QualificationInputs = {}, +): LibraryFootprintQualification { + const data = record(parseJson(dataJson)); + if (!data) return NOT_GENERATED; + const preview = record(record(data.normalized)?.preview); + return classifyStoredFootprint(data.provenance, preview?.pads, inputs); +} + +const PROVENANCE = sql< + string | null +>`CASE WHEN json_valid(${footprints.dataJson}) THEN json_extract(${footprints.dataJson}, '$.provenance') END`; +const PADS = sql< + string | null +>`CASE WHEN json_valid(${footprints.dataJson}) THEN json_extract(${footprints.dataJson}, '$.normalized.preview.pads') END`; + +interface StoredRow { + id: string; + prov: string | null; + pads: string | null; +} + +function loadRows( + db: Db, + ids: readonly string[], + generatedOnly: boolean, +): StoredRow[] { + const rows: StoredRow[] = []; + for (let i = 0; i < ids.length; i += CHUNK) { + const chunk = ids.slice(i, i + CHUNK); + const where = generatedOnly + ? and( + inArray(footprints.id, chunk), + sql`json_valid(${footprints.dataJson}) AND json_extract(${footprints.dataJson}, '$.provenance.sourceKind') = 'generated'`, + ) + : inArray(footprints.id, chunk); + rows.push( + ...db + .select({ id: footprints.id, prov: PROVENANCE, pads: PADS }) + .from(footprints) + .where(where) + .all(), + ); + } + return rows; +} + +/** Stored rows by id; unknown ids are absent. */ +export function getFootprintQualifications( + db: Db, + footprintIds: readonly string[], + inputs: QualificationInputs = {}, +): Record { + const ids = [...new Set(footprintIds)]; + const out: Record = {}; + for (const row of loadRows(db, ids, false)) { + out[row.id] = classifyStoredFootprint( + parseJson(row.prov), + parseJson(row.pads), + inputs, + ); + } + return out; +} + +interface PendingSnapshot { + readonly key: string; + readonly footprintId: string; + readonly pads: readonly unknown[] | null; + readonly fingerprint: string | null; +} + +/** + * Placed footprints, from each snapshot's own pads. A stamped snapshot is + * classified on its own geometry + stamp. A stampless one (not generated, or + * built before the stamp) is decided by rules 1–2 when geometry alone can; otherwise one + * batched lookup of its GENERATED library rows decides: same pads ⇒ that + * row's claim applies, different pads ⇒ "no longer matches", no generated row + * ⇒ not-generated (the documented residual, §3.4). + */ +export function classifyPlacedFootprints( + db: Db, + items: readonly { + key: string; + snapshot: LibraryFootprintPlacementSnapshot; + }[], + inputs: QualificationInputs = {}, +): Record { + const out: Record = {}; + const pending: PendingSnapshot[] = []; + for (const { key, snapshot } of items) { + const snap = record(snapshot); + if (!snap) { + out[key] = NOT_GENERATED; + continue; + } + const pads = padArray(record(snap.preview)?.pads); + const fingerprint = fingerprintPads(pads); + // A stamp can only add checks: a malformed one counts as absent. + const stamp = stampOf(snap.generation); + if (stamp) { + out[key] = classifyFootprintGeometry( + { pads, generation: stamp }, + inputs, + fingerprint, + ); + continue; + } + const geometric = geometricQualification(fingerprint, inputs); + if (geometric) { + out[key] = geometric; + } else if (typeof snap.footprintId === "string") { + pending.push({ key, footprintId: snap.footprintId, pads, fingerprint }); + } else { + out[key] = NOT_GENERATED; + } + } + if (pending.length === 0) return out; + + const rows = new Map( + loadRows(db, [...new Set(pending.map((p) => p.footprintId))], true).map( + (row) => [row.id, row], + ), + ); + const rowFingerprints = new Map(); + for (const item of pending) { + const row = rows.get(item.footprintId); + if (!row) { + out[item.key] = NOT_GENERATED; + continue; + } + let rowFingerprint = rowFingerprints.get(row.id); + if (rowFingerprint === undefined) { + rowFingerprint = fingerprintPads(padArray(parseJson(row.pads))); + rowFingerprints.set(row.id, rowFingerprint); + } + const generation = generationFromProvenance(parseJson(row.prov)); + if (rowFingerprint === null || rowFingerprint !== item.fingerprint) { + out[item.key] = { + status: "unqualified-generator", + reason: NO_LONGER_MATCHES_REASON, + generatorKind: generation?.kind ?? "legacy-generated", + generatorLabel: generation?.label ?? null, + }; + continue; + } + out[item.key] = classifyFootprintGeometry( + { pads: item.pads, generation }, + inputs, + item.fingerprint, + ); + } + return out; +} + +/** A stamp read from persisted JSON; null when absent or malformed. */ +function stampOf(value: unknown): FootprintGenerationStamp | null { + const stamp = record(value); + if ( + stamp && + (stamp.kind === "ipc7351b" || + stamp.kind === "template" || + stamp.kind === "legacy-generated") && + (stamp.label === null || typeof stamp.label === "string") + ) { + return { kind: stamp.kind, label: stamp.label }; + } + return null; +} diff --git a/src/modules/library/backend/queries.ts b/src/modules/library/backend/queries.ts index 321b4b42..0cf433f3 100644 --- a/src/modules/library/backend/queries.ts +++ b/src/modules/library/backend/queries.ts @@ -65,6 +65,13 @@ import { type GeneratedFootprintInput, } from "./import/resolve-generated-footprint"; import { buildIdentityPinMapJson } from "./import/pinmap"; +import { UnqualifiedGeneratorError } from "./import/unqualified-generator-error"; +import { generationFromProvenance } from "./qualification/classify"; +import { + classifyFootprintDataJson, + classifyPlacedFootprints, + getFootprintQualifications, +} from "./qualification/footprint-qualifications"; import { validateFootprintPads, validateSymbolPinsCoverFootprintPads, @@ -451,7 +458,14 @@ function parseFootprintPlacementSnapshot( sourceHash: asString(provenance?.sourceHash), preview, pinMap, + derivationHash: asString(provenance?.derivationHash), }; + // H15 §3.4 stamp: selects the claim rules for this placement even after the + // library row is deleted or updated in place. Qualification stays geometric. + const generation = generationFromProvenance(provenance); + if (generation) { + snapshot.generation = generation; + } if (modelRow) { snapshot.model3d = mapFootprintModelDescriptor(modelRow); } @@ -985,6 +999,7 @@ async function loadComponentFootprintVariants( padCount: detail.padCount, packageCode: detail.packageCode, pinMap: null, + qualification: classifyFootprintDataJson(fpRow.dataJson), }, ]; } @@ -1016,6 +1031,7 @@ async function loadComponentFootprintVariants( padCount: detail.padCount, packageCode: detail.packageCode, pinMap: parsePinMapJson(row.pinMapJson), + qualification: classifyFootprintDataJson(fp.dataJson), }); } return variants; @@ -1938,6 +1954,16 @@ export async function updateComponent( `Footprint "${patch.defaultFootprintId}" does not exist`, ); } + // A flagged footprint cannot BECOME the default (H15 §3.4); an unchanged + // flagged default (e.g. from .opclib intake) is left to the export gate. + if (defaultFootprint.id !== existing.footprintId) { + const qualification = classifyFootprintDataJson(defaultFootprint.dataJson); + if (qualification.status === "unqualified-generator") { + throw new UnqualifiedGeneratorError( + qualification.reason ?? "This generated footprint failed qualification.", + ); + } + } symbolRow = await db .select() .from(symbols) @@ -2311,6 +2337,7 @@ export async function getDefaultFootprintRefs( componentId: components.id, footprintId: components.footprintId, sourceHash: sql`json_extract(${footprints.dataJson}, '$.provenance.sourceHash')`, + derivationHash: sql`json_extract(${footprints.dataJson}, '$.provenance.derivationHash')`, }) .from(components) .leftJoin(footprints, eq(footprints.id, components.footprintId)) @@ -2321,6 +2348,8 @@ export async function getDefaultFootprintRefs( out[row.componentId] = { footprintId: row.footprintId, sourceHash: typeof row.sourceHash === "string" ? row.sourceHash : null, + derivationHash: + typeof row.derivationHash === "string" ? row.derivationHash : null, }; } return out; @@ -2466,5 +2495,9 @@ export function buildSdk(ctx: CoreBackendModuleContext): LibrarySDK { addComponentFootprint(ctx, componentId, input), getDefaultFootprintRefs: (componentIds) => getDefaultFootprintRefs(ctx, componentIds), + getFootprintQualifications: async (footprintIds) => + getFootprintQualifications(getDb(ctx), footprintIds), + classifyPlacedFootprints: async (items) => + classifyPlacedFootprints(getDb(ctx), items), }; } diff --git a/src/modules/library/frontend/ChangeFootprintDialog.tsx b/src/modules/library/frontend/ChangeFootprintDialog.tsx index cbd228ae..c34474de 100644 --- a/src/modules/library/frontend/ChangeFootprintDialog.tsx +++ b/src/modules/library/frontend/ChangeFootprintDialog.tsx @@ -13,6 +13,10 @@ import { TabsTrigger, } from "@shared/frontend/ui"; import { FootprintPreviewCanvas } from "../../../shared/frontend/canvas/preview"; +import { + FootprintUnqualifiedBadge, + isUnqualifiedFootprint, +} from "./components/FootprintUnqualifiedBadge"; import { asFootprintRender } from "./detail-helpers"; import { FootprintGeneratorPanel, @@ -91,6 +95,9 @@ export function ChangeFootprintDialog({ ); const defaultId = detail?.footprint.id ?? ""; const effectiveId = selectedId ?? variants.find((v) => v.isDefault)?.footprintId ?? variants[0]?.footprintId ?? ""; + const effectiveVariant = variants.find((v) => v.footprintId === effectiveId); + // Mirrors the server guard: a flagged footprint cannot become the default. + const effectiveUnqualified = isUnqualifiedFootprint(effectiveVariant?.qualification); const geometry = useFootprintGeometry({ backendURL, moduleId, @@ -209,7 +216,10 @@ export function ChangeFootprintDialog({ : "border-border hover:bg-surface-hover" }`} > -
{v.variantLabel}
+
+ {v.variantLabel} + +
{v.mountType ?? "—"} · {v.padCount} pads {v.isDefault ? " · current" : ""} @@ -233,6 +243,11 @@ export function ChangeFootprintDialog({ {geometry.status === "ready" ? ( ) : null} + {effectiveUnqualified && effectiveId !== defaultId ? ( +

+ {effectiveVariant?.qualification?.reason} +

+ ) : null}
)} @@ -269,9 +284,13 @@ export function ChangeFootprintDialog({ type="button" variant="primary" className="cursor-pointer" - disabled={busy || !effectiveId || effectiveId === defaultId || existingMissing.length > 0} + disabled={busy || !effectiveId || effectiveId === defaultId || existingMissing.length > 0 || effectiveUnqualified} onClick={() => void useExisting()} - title="Set the selected linked footprint as the component default" + title={ + effectiveUnqualified + ? (effectiveVariant?.qualification?.reason ?? "This generated footprint failed qualification") + : "Set the selected linked footprint as the component default" + } > Use selected footprint diff --git a/src/modules/library/frontend/ComponentDetailPage.test.ts b/src/modules/library/frontend/ComponentDetailPage.test.ts index 14a9a9f6..27ab32f9 100644 --- a/src/modules/library/frontend/ComponentDetailPage.test.ts +++ b/src/modules/library/frontend/ComponentDetailPage.test.ts @@ -1,4 +1,8 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; import { describe, expect, test, vi } from "vitest"; +import type { ComponentDetailPayload, ComponentFootprintVariant } from "./types"; vi.mock("../../../shared/frontend/canvas/preview", () => ({ FootprintPreviewCanvas: () => null, @@ -8,7 +12,28 @@ vi.mock("./three-d/ThreeDComponentPreview", () => ({ ThreeDComponentPreview: () => null, })); -import { validateStepUploadFile } from "./ComponentDetailPage"; +const detailState = vi.hoisted(() => ({ + detail: null as ComponentDetailPayload | null, +})); +vi.mock("./hooks/useComponentDetail", () => ({ + useComponentDetail: () => ({ + detail: detailState.detail, + loading: false, + error: null, + setDetail: () => undefined, + }), +})); +vi.mock("./hooks/useFootprintGeometry", () => ({ + useFootprintGeometry: () => ({ status: "loading" }), +})); +vi.mock("./hooks/useLibraryTags", () => ({ + useLibraryTags: () => ({ tags: [] }), +})); +vi.mock("../../../core/frontend/src/providers/ThemeProvider", () => ({ + useTheme: () => ({ mode: "dark" }), +})); + +import { ComponentDetailPage, validateStepUploadFile } from "./ComponentDetailPage"; function makeFile(name: string, size: number): File { return new File([new Uint8Array(size)], name, { type: "model/step" }); @@ -32,3 +57,99 @@ describe("manual STEP upload validation", () => { ); }); }); + +const FLAGGED_REASON = + "Generated before generator provenance was recorded; its geometry cannot be verified."; + +function payload(variant: Partial): ComponentDetailPayload { + const footprintVariant: ComponentFootprintVariant = { + footprintId: "fp-1", + variantLabel: "Generated", + isDefault: true, + sortOrder: 0, + name: "Generated", + mountType: "SMD", + padCount: 2, + packageCode: { imperial: null, metric: null }, + pinMap: null, + ...variant, + }; + return { + component: { + id: "c-1", + name: "R", + description: "", + tags: [], + isBuiltin: false, + symbolId: "s-1", + footprintId: "fp-1", + }, + symbol: { + id: "s-1", + name: "R", + referencePrefix: "R", + pinCount: 2, + warnings: [], + preview: null, + provenance: null, + }, + footprint: { + id: "fp-1", + name: "Generated", + mountType: "SMD", + padCount: 2, + packageCode: { imperial: null, metric: null }, + warnings: [], + preview: null, + provenance: null, + }, + footprintVariants: [footprintVariant], + } as unknown as ComponentDetailPayload; +} + +function renderPage(detail: ComponentDetailPayload): HTMLElement { + detailState.detail = detail; + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + act(() => { + root.render( + createElement(ComponentDetailPage, { + backendURL: "http://backend", + moduleId: "library", + componentId: "c-1", + onBack: () => undefined, + }), + ); + }); + return host; +} + +describe("footprint qualification badge", () => { + test("an unqualified generated footprint shows the badge with its reason", () => { + const host = renderPage( + payload({ + qualification: { + status: "unqualified-generator", + reason: FLAGGED_REASON, + generatorKind: "legacy-generated", + generatorLabel: null, + }, + }), + ); + const badge = host.querySelector('[data-testid="footprint-unqualified-badge"]'); + expect(badge).not.toBeNull(); + expect(badge?.getAttribute("title")).toBe(FLAGGED_REASON); + }); + + test("qualified, not-generated and unclassified footprints show no badge", () => { + for (const qualification of [ + { status: "qualified", reason: null, generatorKind: "template", generatorLabel: "pin-header" }, + { status: "not-generated", reason: null, generatorKind: null, generatorLabel: null }, + undefined, + ] as const) { + const host = renderPage(payload({ qualification })); + expect(host.querySelector('[data-testid="footprint-unqualified-badge"]')).toBeNull(); + } + }); +}); diff --git a/src/modules/library/frontend/ComponentDetailPage.tsx b/src/modules/library/frontend/ComponentDetailPage.tsx index bd1769a8..1de7df67 100644 --- a/src/modules/library/frontend/ComponentDetailPage.tsx +++ b/src/modules/library/frontend/ComponentDetailPage.tsx @@ -27,6 +27,7 @@ import { useTheme } from "../../../core/frontend/src/providers/ThemeProvider"; import { TagTokenInput } from "./components/TagTokenInput"; import { DetailsCard } from "./components/DetailsCard"; import { FootprintOptionsList } from "./components/FootprintOptionsList"; +import { FootprintUnqualifiedBadge } from "./components/FootprintUnqualifiedBadge"; import { PinsTable } from "./components/PinsTable"; import { PreviewModal } from "./components/PreviewModal"; import { ChangeFootprintDialog } from "./ChangeFootprintDialog"; @@ -616,6 +617,9 @@ export function ComponentDetailPage({ Footprint
+ {hasOptions && selectedVariant ? ( diff --git a/src/modules/library/frontend/components/FootprintUnqualifiedBadge.tsx b/src/modules/library/frontend/components/FootprintUnqualifiedBadge.tsx new file mode 100644 index 00000000..709a01d6 --- /dev/null +++ b/src/modules/library/frontend/components/FootprintUnqualifiedBadge.tsx @@ -0,0 +1,33 @@ +import type { ReactElement } from "react"; +import { AlertTriangle } from "lucide-react"; +import { Badge } from "@shared/frontend/ui"; +import type { LibraryFootprintQualification } from "../../../../sdks/library"; + +/** A generated footprint that failed qualification (H15 §3.4): the server refuses it as a new default. */ +export function isUnqualifiedFootprint( + qualification: LibraryFootprintQualification | null | undefined, +): boolean { + return qualification?.status === "unqualified-generator"; +} + +/** Danger badge with the qualification reason as its title; renders nothing otherwise. */ +export function FootprintUnqualifiedBadge({ + qualification, +}: { + qualification: LibraryFootprintQualification | null | undefined; +}): ReactElement | null { + if (!qualification || !isUnqualifiedFootprint(qualification)) return null; + const reason = + qualification.reason ?? "Generated footprint failed qualification."; + return ( + } + title={reason} + aria-label={`Unqualified footprint: ${reason}`} + data-testid="footprint-unqualified-badge" + > + Unqualified + + ); +} diff --git a/src/modules/library/frontend/types.ts b/src/modules/library/frontend/types.ts index 58475ed8..27a501ed 100644 --- a/src/modules/library/frontend/types.ts +++ b/src/modules/library/frontend/types.ts @@ -1,4 +1,8 @@ -import type { LibraryComponent, LibraryPinMapEntry } from "../../../sdks/library"; +import type { + LibraryComponent, + LibraryFootprintQualification, + LibraryPinMapEntry, +} from "../../../sdks/library"; import type { FootprintRenderModel, SymbolRenderModel, @@ -62,6 +66,8 @@ export interface ComponentFootprintVariant { metric: string | null; }; pinMap: LibraryPinMapEntry[] | null; + /** Generator qualification of the stored pads (H15 §3.4). */ + qualification?: LibraryFootprintQualification; } export interface ComponentDetailPayload { diff --git a/src/sdks/library/index.ts b/src/sdks/library/index.ts index b0c2742d..adfbc92b 100644 --- a/src/sdks/library/index.ts +++ b/src/sdks/library/index.ts @@ -1,6 +1,8 @@ import type { LibraryAddComponentFootprintInput, LibraryDefaultFootprintRef, + LibraryFootprintPlacementSnapshot, + LibraryFootprintQualification, LibraryComponent, LibraryComponentDetail, LibraryComponentPlacementDetail, @@ -13,6 +15,7 @@ import type { } from "./types"; export type { + FootprintGenerationStamp, LibraryAddComponentFootprintInput, LibraryAuthoredFootprintSource, LibraryDefaultFootprintRef, @@ -30,6 +33,8 @@ export type { LibraryFootprintModelDescriptor, LibraryFootprintModelStatus, LibraryFootprintPlacementSnapshot, + LibraryFootprintQualification, + LibraryFootprintQualificationStatus, LibraryComponentFootprintVariant, LibraryListTagsOptions, LibraryMountType, @@ -73,4 +78,18 @@ export interface LibrarySDK { getDefaultFootprintRefs( componentIds: readonly string[], ): Promise>; + /** Generator qualification of stored footprint rows; unknown ids are absent. */ + getFootprintQualifications( + footprintIds: readonly string[], + ): Promise>; + /** + * Generator qualification of placed footprints, classified from each + * snapshot's own pads (the geometry the Gerber writes), keyed by `key`. + */ + classifyPlacedFootprints( + items: readonly { + key: string; + snapshot: LibraryFootprintPlacementSnapshot; + }[], + ): Promise>; } diff --git a/src/sdks/library/types.ts b/src/sdks/library/types.ts index 3624e4b8..b742b67e 100644 --- a/src/sdks/library/types.ts +++ b/src/sdks/library/types.ts @@ -122,6 +122,32 @@ export interface LibraryComponentFootprintVariant { }; /** Explicit symbol-pin → footprint-pad map. Null means fallback to matching numbers. */ pinMap: LibraryPinMapEntry[] | null; + /** Read-time generator qualification of this footprint's stored pads (H15 §3.4). */ + qualification?: LibraryFootprintQualification; +} + +export type LibraryFootprintQualificationStatus = + | "qualified" + | "unqualified-generator" + | "not-generated"; + +/** + * Generator qualification, derived at read time from the pad geometry actually + * stored or placed — never from a stored verdict or a generator claim. + */ +export interface LibraryFootprintQualification { + status: LibraryFootprintQualificationStatus; + /** Non-null iff `status === "unqualified-generator"`. */ + reason: string | null; + generatorKind: "ipc7351b" | "template" | "legacy-generated" | null; + /** "SOT-23 · nominal" | "pin-header" | null */ + generatorLabel: string | null; +} + +/** Stamped into placement snapshots at build time (additive; absent on snapshots built before O3). */ +export interface FootprintGenerationStamp { + kind: "ipc7351b" | "template" | "legacy-generated"; + label: string | null; } export interface LibraryComponentDetail { @@ -184,6 +210,14 @@ export interface LibraryFootprintPlacementSnapshot { /** Component-specific symbol-pin → footprint-pad map. Missing/null means fallback to matching numbers. */ pinMap?: LibraryPinMapEntry[] | null; model3d?: LibraryFootprintModelDescriptor; + /** + * How the library row was generated, stamped when the snapshot is built from + * a generated row (absent otherwise, and on snapshots built before the + * stamp). It only selects claim rules; qualification itself is geometric. + */ + generation?: FootprintGenerationStamp; + /** The row's `provenance.derivationHash` (post-processed footprints) at build time. */ + derivationHash?: string | null; } export interface LibraryComponentPlacementDetail { @@ -298,4 +332,6 @@ export interface LibraryAddComponentFootprintInput { export interface LibraryDefaultFootprintRef { footprintId: string; sourceHash: string | null; + /** `provenance.derivationHash` of the default footprint row, when post-processed. */ + derivationHash: string | null; } From 30162dfc2f113772963eb0652f6d84fd1e5a6b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Vy=C5=A1n=C3=BD?= Date: Tue, 10 Nov 2026 14:16:10 +0100 Subject: [PATCH 3/3] feat(library): gate footprint generator on pad qualification Co-Authored-By: Claude Haiku 5.5 --- .../FootprintGeneratorPanel.test.tsx | 449 ++++++++++++++++++ .../FootprintGeneratorPanel.tsx | 246 +++++++--- .../footprint-generator/IpcPresetTab.tsx | 15 +- .../footprint-generator/TemplateTab.tsx | 113 ++++- .../footprint-validation.ts | 50 ++ .../import-wizard/preset-quarantine.test.tsx | 1 - .../import-wizard/steps/FootprintStep.tsx | 20 +- .../import-wizard/useImportWizardStore.ts | 53 ++- 8 files changed, 842 insertions(+), 105 deletions(-) create mode 100644 src/modules/library/frontend/footprint-generator/FootprintGeneratorPanel.test.tsx diff --git a/src/modules/library/frontend/footprint-generator/FootprintGeneratorPanel.test.tsx b/src/modules/library/frontend/footprint-generator/FootprintGeneratorPanel.test.tsx new file mode 100644 index 00000000..17766151 --- /dev/null +++ b/src/modules/library/frontend/footprint-generator/FootprintGeneratorPanel.test.tsx @@ -0,0 +1,449 @@ +// @vitest-environment happy-dom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + PARAMETRIC_TEMPLATES, + defaultValues, + getTemplate, + hashParams, + validateParams, +} from "../../../../shared/rendering/parametric"; +import { generatorUnavailableReason } from "../../shared/generator-policy"; +import { + FootprintGeneratorPanel, + templateToPayload, + type GeneratedFootprintPayload, +} from "./FootprintGeneratorPanel"; +import { FootprintStep } from "../import-wizard/steps/FootprintStep"; +import { + hasCurrentPreset, + useImportWizardStore, +} from "../import-wizard/useImportWizardStore"; + +// Every IPC family is quarantined on the pinned rendering-core (H15 D1); the +// positive paths mark chosen families available, the rest keep the real policy. +const qualification = vi.hoisted(() => ({ available: new Set() })); +vi.mock("../../shared/generator-qualification", async (importOriginal) => { + const actual = + await importOriginal(); + const ipcFamilyAvailability: typeof actual.ipcFamilyAvailability = ( + family, + ...rest + ) => + qualification.available.has(family) + ? { available: true, revision: 2 } + : actual.ipcFamilyAvailability(family, ...rest); + return { ...actual, ipcFamilyAvailability }; +}); +vi.mock("../../../../shared/frontend/canvas/preview", () => ({ + FootprintPreviewCanvas: () => null, + SymbolPreviewCanvas: () => null, +})); +vi.mock("../import-wizard/footprint-editor", () => ({ + FootprintEditorCanvas: () => null, + FootprintEditorToolbar: () => null, + LayerPanel: () => null, + PadPropertyPanel: () => null, + useFootprintEditorStore: Object.assign(() => null, { + getState: () => ({ reset: () => undefined }), + }), +})); + +const BACKEND = "http://test"; +const TEMPLATE_LIST = PARAMETRIC_TEMPLATES.map((t) => ({ + id: t.id, + label: t.label, + description: t.description, + generatorVersion: t.generatorVersion, + schema: t.schema, + defaults: defaultValues(t.schema), +})); + +interface PendingMaterialize { + params: Record; + resolve: () => void; +} +let pending: PendingMaterialize[] = []; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function materializedResponse( + templateId: string, + params: Record, +): Response { + const template = getTemplate(templateId)!; + const validated = validateParams(template.schema, params); + if (!validated.ok) throw new Error("fixture params invalid"); + const result = template.generate(validated.values); + return jsonResponse({ + ok: true, + data: { + templateId, + generatorVersion: template.generatorVersion, + params: validated.values, + paramsHash: hashParams(validated.values), + source: result.source, + metadata: { + name: result.name, + mountType: result.mountType, + packageCode: result.packageCode, + tags: result.tags, + }, + }, + }); +} + +// Materialize requests stay pending until the test resolves them, so the +// order of responses vs. form edits is under test control. The mock ignores +// abort on purpose: only the request-key check may drop a late response. +const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (url === `${BACKEND}/api/modules/library/templates`) { + return jsonResponse({ ok: true, data: { templates: TEMPLATE_LIST } }); + } + const match = /\/templates\/([^/]+)\/materialize$/.exec(url); + if (!match) throw new Error(`unexpected fetch ${url}`); + const templateId = decodeURIComponent(match[1]!); + const { params } = JSON.parse(String(init?.body)) as { + params: Record; + }; + return new Promise((resolve) => { + pending.push({ + params, + resolve: () => resolve(materializedResponse(templateId, params)), + }); + }); +}); + +let root: Root; +let host: HTMLDivElement; +const onGenerated = vi.fn<(g: GeneratedFootprintPayload | null) => void>(); +const onUse = vi.fn<(g: GeneratedFootprintPayload) => void>(); + +async function flush(): Promise { + await act(async () => { + for (let i = 0; i < 5; i += 1) await new Promise((r) => setTimeout(r, 0)); + }); +} + +async function render(element: React.ReactElement): Promise { + await act(async () => root.render(element)); + await flush(); +} + +function panel( + props: Partial> = {}, +): React.ReactElement { + return ( + + ); +} + +function buttonStartingWith(text: string): HTMLButtonElement { + const found = [...host.querySelectorAll("button")].find((b) => + b.textContent?.startsWith(text), + ); + if (!found) throw new Error(`button "${text}" missing`); + return found; +} + +async function click(text: string): Promise { + await act(async () => buttonStartingWith(text).click()); + await flush(); +} + +/** Radix tabs activate on mousedown, not click. */ +async function activateTab(label: string): Promise { + const trigger = [...host.querySelectorAll('[role="tab"]')].find( + (t) => t.textContent === label, + ); + if (!trigger) throw new Error(`tab "${label}" missing`); + await act(async () => { + trigger.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, button: 0 }), + ); + }); + await flush(); +} + +async function typeInto(id: string, value: string): Promise { + const input = host.querySelector(`#${id}`); + if (!input) throw new Error(`input #${id} missing`); + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + await act(async () => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flush(); +} + +async function resolveMaterialize(index: number): Promise { + await act(async () => pending[index]!.resolve()); + await flush(); +} + +function lastEmitted(): GeneratedFootprintPayload | null | undefined { + return onGenerated.mock.calls.at(-1)?.[0]; +} + +function useButton(): HTMLButtonElement { + return buttonStartingWith("Use footprint"); +} + +function templatePayloadFor( + templateId: string, + params: Record, +): GeneratedFootprintPayload { + const template = getTemplate(templateId)!; + const validated = validateParams(template.schema, params); + if (!validated.ok) throw new Error("fixture params invalid"); + const result = template.generate(validated.values); + return templateToPayload({ + templateId, + params: validated.values, + source: result.source, + metadata: { + name: result.name, + mountType: result.mountType, + packageCode: result.packageCode, + tags: [...result.tags], + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + pending = []; + qualification.available = new Set(["chip", "sot"]); + vi.stubGlobal("fetch", fetchMock); + useImportWizardStore.getState().reset(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + useImportWizardStore.getState().reset(); +}); + +describe("IPC tab (T-319)", () => { + test("(a) selecting family and size emits the identical tuple without a Generate click", async () => { + await render(panel()); + expect(lastEmitted()).toBeNull(); + await click("Chip"); + await click("0603"); + expect(lastEmitted()?.generator).toEqual({ + kind: "ipc7351b", + family: "chip", + size: "0603", + density: "nominal", + }); + expect( + [...host.querySelectorAll("button")].some((b) => + b.textContent?.includes("Generate footprint"), + ), + ).toBe(false); + }); + + test("(b) size then density change always emits the displayed tuple", async () => { + await render(panel()); + await click("SOT"); + await click("SOT-23"); + expect(lastEmitted()?.generator).toMatchObject({ + size: "SOT-23", + density: "nominal", + }); + const before = onGenerated.mock.calls.length; + await click("SOT-323"); + expect(lastEmitted()?.generator).toEqual({ + kind: "ipc7351b", + family: "sot", + size: "SOT-323", + density: "nominal", + }); + await click("C (Least)"); + const shown = { + kind: "ipc7351b", + family: "sot", + size: "SOT-323", + density: "least", + }; + expect(lastEmitted()?.generator).toEqual(shown); + for (const [emitted] of onGenerated.mock.calls.slice(before)) { + expect(emitted?.generator).not.toMatchObject({ size: "SOT-23" }); + } + expect(useButton().disabled).toBe(false); + await act(async () => useButton().click()); + expect(onUse).toHaveBeenCalledOnce(); + expect(onUse.mock.calls[0]![0].generator).toEqual(shown); + }); + + test("(e) an unavailable family renders the reason and emits null", async () => { + qualification.available = new Set(); + await render( + panel({ + ipcSelection: { family: "qfn", size: "QFN-16", density: "nominal" }, + }), + ); + const reason = generatorUnavailableReason("qfn"); + expect(reason).not.toBeNull(); + const alert = [...host.querySelectorAll('[role="alert"]')].find( + (el) => el.textContent === reason, + ); + expect(alert).toBeDefined(); + expect(lastEmitted()).toBeNull(); + expect(useButton().disabled).toBe(true); + }); +}); + +describe("Template tab (T-319)", () => { + test("(c) emits null until a template materializes; switching back re-emits the IPC payload", async () => { + await render(panel()); + await click("Chip"); + await click("0603"); + const ipcPayload = lastEmitted(); + expect(ipcPayload).not.toBeNull(); + await activateTab("Template"); + expect(lastEmitted()).toBeNull(); + expect(useButton().disabled).toBe(true); + await click("Generate footprint"); + expect(lastEmitted()).toBeNull(); + await resolveMaterialize(0); + expect(lastEmitted()?.generator).toMatchObject({ + kind: "template", + templateId: TEMPLATE_LIST[0]!.id, + }); + expect(useButton().disabled).toBe(false); + await activateTab("IPC package"); + expect(lastEmitted()).toBe(ipcPayload); + }); + + test("(d) editing a template field emits null and disables Use", async () => { + await render(panel({ activeTab: "template" })); + await click("Generate footprint"); + await resolveMaterialize(0); + expect(lastEmitted()).not.toBeNull(); + expect(useButton().disabled).toBe(false); + await typeInto("tpl-param-pinsPerRow", "5"); + expect(lastEmitted()).toBeNull(); + expect(useButton().disabled).toBe(true); + }); + + test("(f) a late response for edited params is dropped; the current one is emitted", async () => { + await render(panel({ activeTab: "template" })); + await click("Generate footprint"); + expect(pending).toHaveLength(1); + await typeInto("tpl-param-pinsPerRow", "5"); + const emittedBeforeLate = onGenerated.mock.calls.length; + await resolveMaterialize(0); + expect( + onGenerated.mock.calls.slice(emittedBeforeLate).every(([g]) => g === null), + ).toBe(true); + expect(lastEmitted()).toBeNull(); + expect(useButton().disabled).toBe(true); + + await click("Generate footprint"); + expect(pending).toHaveLength(2); + expect(pending[1]!.params).toMatchObject({ pinsPerRow: 5 }); + await resolveMaterialize(1); + const current = lastEmitted(); + expect(current?.generator).toMatchObject({ + kind: "template", + params: { pinsPerRow: 5 }, + }); + expect(useButton().disabled).toBe(false); + }); +}); + +describe("wizard store", () => { + test("(g) remount keeps the Template tab, the form and a committable payload", async () => { + useImportWizardStore.setState({ footprintSource: "preset" }); + await render(); + await activateTab("Template"); + await typeInto("tpl-param-pinsPerRow", "6"); + await click("Generate footprint"); + await resolveMaterialize(0); + const before = useImportWizardStore.getState(); + expect(before.templatePayload).not.toBeNull(); + expect(hasCurrentPreset(before)).toBe(true); + const payload = before.templatePayload; + const selection = before.templateSelection; + + // Leave the step and come back. + await render(<>); + await render(); + + const tab = [...host.querySelectorAll('[role="tab"]')].find( + (t) => t.textContent === "Template", + ); + expect(tab?.getAttribute("aria-selected")).toBe("true"); + expect( + host.querySelector("#tpl-param-pinsPerRow")?.value, + ).toBe("6"); + const after = useImportWizardStore.getState(); + expect(after.templatePayload).toBe(payload); + expect(after.templateSelection).toBe(selection); + expect(after.generatedFootprint?.generator).toEqual(payload!.generator); + expect(hasCurrentPreset(after)).toBe(true); + expect(pending).toHaveLength(1); + }); + + test("(h) a template payload whose params differ from the displayed form is not committable", () => { + const template = TEMPLATE_LIST[0]!; + const displayed = Object.fromEntries( + Object.entries(template.defaults).map(([k, v]) => [ + k, + typeof v === "boolean" ? v : String(v), + ]), + ); + const pinsPerRow = Number(template.defaults.pinsPerRow); + const matching = templatePayloadFor(template.id, template.defaults); + const differing = templatePayloadFor(template.id, { + ...template.defaults, + pinsPerRow: pinsPerRow + 1, + }); + const state = useImportWizardStore.getState(); + state.setFootprintSource("preset"); + state.setGeneratorTab("template"); + state.setTemplateSelection({ templateId: template.id, values: displayed }); + + state.setGeneratedFootprint(differing); + expect(useImportWizardStore.getState().generatedFootprint).toBeNull(); + expect( + hasCurrentPreset({ + ...useImportWizardStore.getState(), + generatedFootprint: differing, + }), + ).toBe(false); + + // Same params after normalisation ("2" displayed vs 2 produced) is current. + state.setGeneratedFootprint(matching); + expect(hasCurrentPreset(useImportWizardStore.getState())).toBe(true); + + // A template payload is never current while the IPC tab is displayed. + expect( + hasCurrentPreset({ + ...useImportWizardStore.getState(), + generatorTab: "ipc", + }), + ).toBe(false); + }); +}); diff --git a/src/modules/library/frontend/footprint-generator/FootprintGeneratorPanel.tsx b/src/modules/library/frontend/footprint-generator/FootprintGeneratorPanel.tsx index 7d6b5c5d..a35dd3d1 100644 --- a/src/modules/library/frontend/footprint-generator/FootprintGeneratorPanel.tsx +++ b/src/modules/library/frontend/footprint-generator/FootprintGeneratorPanel.tsx @@ -1,4 +1,11 @@ -import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from "react"; import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from "@shared/frontend/ui"; import { FootprintPreviewCanvas } from "../../../../shared/frontend/canvas/preview"; import { @@ -12,9 +19,15 @@ import type { } from "../../../../shared/rendering/types"; import { IpcPresetTab, type IpcSelection } from "./IpcPresetTab"; import { TemplateTab, type MaterializedTemplate } from "./TemplateTab"; -import { missingPinNumbers } from "./footprint-validation"; +import { + EMPTY_TEMPLATE_SELECTION, + missingPinNumbers, + sameTemplateIdentity, + type TemplateSelection, +} from "./footprint-validation"; import { generatorUnavailableReason, + sameGeneratorIdentity, type FootprintGeneratorIdentity, } from "../../shared/generator-policy"; @@ -28,12 +41,72 @@ export interface GeneratedFootprintPayload { generator: FootprintGeneratorIdentity; } +export type GeneratorTab = "ipc" | "template"; + const EMPTY_SELECTION: IpcSelection = { family: null, size: null, density: "nominal", }; +export type IpcDerivation = + | { readonly kind: "incomplete" } + | { readonly kind: "unavailable"; readonly reason: string } + | { readonly kind: "ready"; readonly payload: GeneratedFootprintPayload }; + +/** + * The IPC footprint for exactly the displayed tuple (T-319). Generation is + * synchronous, so the payload can never lag the selection. + */ +export function deriveIpcPayload(selection: IpcSelection): IpcDerivation { + const { family, size, density } = selection; + if (!family || !size) return { kind: "incomplete" }; + const unavailable = generatorUnavailableReason(family); + if (unavailable) return { kind: "unavailable", reason: unavailable }; + try { + const result = generateFootprint(family, size, density); + return { + kind: "ready", + payload: { + mode: "generated", + source: result.source, + model: result.model, + metadata: result.metadata, + generator: { kind: "ipc7351b", family, size, density }, + }, + }; + } catch (error) { + return { + kind: "unavailable", + reason: + error instanceof Error ? error.message : "Footprint generation failed", + }; + } +} + +/** True when `payload` was generated from the inputs the active tab displays. */ +function matchesDisplayed( + payload: GeneratedFootprintPayload, + tab: GeneratorTab, + selection: IpcSelection, + templateSelection: TemplateSelection, +): boolean { + if (tab === "template") { + return sameTemplateIdentity(payload.generator, templateSelection); + } + return ( + payload.generator.kind === "ipc7351b" && + selection.family !== null && + selection.size !== null && + sameGeneratorIdentity(payload.generator, { + kind: "ipc7351b", + family: selection.family, + size: selection.size, + density: selection.density, + }) + ); +} + export function templateToPayload( result: MaterializedTemplate, ): GeneratedFootprintPayload { @@ -82,13 +155,20 @@ export function PinCoverage({ interface FootprintGeneratorPanelProps { backendURL: string | null | undefined; moduleId: string; - /** Called with each new result, or `null` when a previous one is invalidated. */ + /** Called with the active tab's current result, or `null` when there is none. */ onGenerated: (generated: GeneratedFootprintPayload | null) => void; /** Symbol pin numbers to validate against; omit to skip coverage checks. */ symbolPinNumbers?: readonly string[]; /** Controlled IPC selection (wizard keeps it in its store). */ ipcSelection?: IpcSelection; onIpcSelectionChange?: (next: IpcSelection) => void; + /** Controlled tab, template form and template result (the wizard keeps them). */ + activeTab?: GeneratorTab; + onActiveTabChange?: (next: GeneratorTab) => void; + templateSelection?: TemplateSelection; + onTemplateSelectionChange?: (next: TemplateSelection) => void; + templatePayload?: GeneratedFootprintPayload | null; + onTemplatePayloadChange?: (next: GeneratedFootprintPayload | null) => void; /** Render the pad-number preview inside the panel. Default true. */ showPreview?: boolean; /** When set, shows a "Use footprint" button gated on pin coverage. */ @@ -105,6 +185,12 @@ export function FootprintGeneratorPanel({ symbolPinNumbers, ipcSelection, onIpcSelectionChange, + activeTab, + onActiveTabChange, + templateSelection, + onTemplateSelectionChange, + templatePayload, + onTemplatePayloadChange, showPreview = true, onUse, useLabel = "Use footprint", @@ -113,17 +199,65 @@ export function FootprintGeneratorPanel({ const [localSelection, setLocalSelection] = useState(EMPTY_SELECTION); const selection = ipcSelection ?? localSelection; - const [generated, setGenerated] = useState( - null, + const [localTab, setLocalTab] = useState("ipc"); + const tab = activeTab ?? localTab; + const [localTemplateSelection, setLocalTemplateSelection] = + useState(EMPTY_TEMPLATE_SELECTION); + const templateForm = templateSelection ?? localTemplateSelection; + const [localTemplatePayload, setLocalTemplatePayload] = + useState(null); + const templateResult = + templatePayload === undefined ? localTemplatePayload : templatePayload; + + const { family, size, density } = selection; + const ipc = useMemo( + () => deriveIpcPayload({ family, size, density }), + [family, size, density], ); - const [ipcError, setIpcError] = useState(null); + const current = + tab === "ipc" + ? ipc.kind === "ready" + ? ipc.payload + : null + : templateResult; - const emit = useCallback( + const onGeneratedRef = useRef(onGenerated); + useEffect(() => { + onGeneratedRef.current = onGenerated; + }); + useEffect(() => { + onGeneratedRef.current(current); + }, [current]); + + const changeTab = useCallback( + (next: GeneratorTab) => { + setLocalTab(next); + onActiveTabChange?.(next); + }, + [onActiveTabChange], + ); + const changeTemplateSelection = useCallback( + (next: TemplateSelection) => { + setLocalTemplateSelection(next); + onTemplateSelectionChange?.(next); + }, + [onTemplateSelectionChange], + ); + const changeTemplatePayload = useCallback( (next: GeneratedFootprintPayload | null) => { - setGenerated(next); - onGenerated(next); + setLocalTemplatePayload(next); + onTemplatePayloadChange?.(next); }, - [onGenerated], + [onTemplatePayloadChange], + ); + const handleMaterialized = useCallback( + (result: MaterializedTemplate) => + changeTemplatePayload(templateToPayload(result)), + [changeTemplatePayload], + ); + const handleTemplateInvalidated = useCallback( + () => changeTemplatePayload(null), + [changeTemplatePayload], ); const changeSelection = useCallback( @@ -134,52 +268,25 @@ export function FootprintGeneratorPanel({ [onIpcSelectionChange], ); - const handleIpcGenerate = useCallback(() => { - if (!selection.family || !selection.size) return; - const unavailable = generatorUnavailableReason(selection.family); - if (unavailable) { - setIpcError(unavailable); - return; - } - try { - const result = generateFootprint( - selection.family, - selection.size, - selection.density, - ); - setIpcError(null); - emit({ - mode: "generated", - source: result.source, - model: result.model, - metadata: result.metadata, - generator: { - kind: "ipc7351b", - family: selection.family, - size: selection.size, - density: selection.density, - }, - }); - } catch (error) { - setIpcError( - error instanceof Error - ? error.message - : "Footprint generation failed", - ); - } - }, [emit, selection]); - const missing = useMemo( () => - generated && symbolPinNumbers - ? missingPinNumbers(symbolPinNumbers, generated.source.pads) + current && symbolPinNumbers + ? missingPinNumbers(symbolPinNumbers, current.source.pads) : [], - [generated, symbolPinNumbers], + [current, symbolPinNumbers], ); + const usable = + current !== null && + matchesDisplayed(current, tab, selection, templateForm); return (
- + + changeTab(value === "template" ? "template" : "ipc") + } + > IPC package @@ -191,23 +298,19 @@ export function FootprintGeneratorPanel({ { - changeSelection({ ...selection, family, size: null }); - emit(null); - }} - onSizeChange={(size) => { - changeSelection({ ...selection, size }); - emit(null); - }} - onDensityChange={(density) => { - changeSelection({ ...selection, density }); - emit(null); - }} - onGenerate={handleIpcGenerate} + onFamilyChange={(nextFamily) => + changeSelection({ ...selection, family: nextFamily, size: null }) + } + onSizeChange={(nextSize) => + changeSelection({ ...selection, size: nextSize }) + } + onDensityChange={(nextDensity) => + changeSelection({ ...selection, density: nextDensity }) + } /> - {ipcError ? ( + {ipc.kind === "unavailable" ? (

- {ipcError} + {ipc.reason}

) : null}
@@ -215,31 +318,34 @@ export function FootprintGeneratorPanel({ emit(templateToPayload(result))} + selection={templateForm} + onSelectionChange={changeTemplateSelection} + onMaterialized={handleMaterialized} + onInvalidated={handleTemplateInvalidated} />
- {generated && showPreview ? ( + {current && showPreview ? (
) : null} - {generated && symbolPinNumbers ? : null} + {current && symbolPinNumbers ? : null} {onUse ? ( diff --git a/src/modules/library/frontend/footprint-generator/IpcPresetTab.tsx b/src/modules/library/frontend/footprint-generator/IpcPresetTab.tsx index 84f24f00..0115eae8 100644 --- a/src/modules/library/frontend/footprint-generator/IpcPresetTab.tsx +++ b/src/modules/library/frontend/footprint-generator/IpcPresetTab.tsx @@ -24,16 +24,17 @@ interface IpcPresetTabProps { onFamilyChange: (family: PackageFamily | null) => void; onSizeChange: (size: string | null) => void; onDensityChange: (density: DensityLevel) => void; - onGenerate: () => void; } -/** Presentational IPC-7351B family / size / density picker (store-free). */ +/** + * Presentational IPC-7351B family / size / density picker (store-free). There + * is no Generate button: the panel derives the footprint from the selection. + */ export const IpcPresetTab = memo(function IpcPresetTab({ selection, onFamilyChange, onSizeChange, onDensityChange, - onGenerate, }: IpcPresetTabProps): ReactElement { const presetFamily = selection.family; const presetSize = selection.size; @@ -149,14 +150,6 @@ export const IpcPresetTab = memo(function IpcPresetTab({ ); })}
- - )}
diff --git a/src/modules/library/frontend/footprint-generator/TemplateTab.tsx b/src/modules/library/frontend/footprint-generator/TemplateTab.tsx index 5506221f..74a8470f 100644 --- a/src/modules/library/frontend/footprint-generator/TemplateTab.tsx +++ b/src/modules/library/frontend/footprint-generator/TemplateTab.tsx @@ -1,13 +1,26 @@ -import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from "react"; import { Button, Field, Input, Select } from "@shared/frontend/ui"; import type { ParamField, ParamSchema, ParamValue, + ParamValues, } from "../../../../shared/rendering/parametric"; import type { FootprintRenderSource } from "../../../../shared/rendering/types"; import { toUserError } from "../utils"; -import { validateTemplateParams } from "./footprint-validation"; +import { + templateRequestKey, + validateTemplateParams, + type TemplateSelection, +} from "./footprint-validation"; interface TemplateInfo { id: string; @@ -104,23 +117,48 @@ function ParamInput({ ); } -/** Parametric-template form: GET /templates, POST /templates/:id/materialize. */ +function formValue(value: ParamValue | undefined): string | boolean | undefined { + return typeof value === "number" ? String(value) : value; +} + +/** + * Parametric-template form: GET /templates, POST /templates/:id/materialize. + * The selection is owned by the caller so it survives remounts. Any edit + * invalidates the previous result, and a response is emitted only while its + * request key still equals the displayed form's key (T-319 race). + */ export function TemplateTab({ backendURL, moduleId, + selection, + onSelectionChange, onMaterialized, + onInvalidated, }: { backendURL: string | null | undefined; moduleId: string; + selection: TemplateSelection; + onSelectionChange: (next: TemplateSelection) => void; onMaterialized: (result: MaterializedTemplate) => void; + /** Any form change: the previous result no longer matches what is displayed. */ + onInvalidated: () => void; }): ReactElement { const base = backendURL ? `${backendURL}/api/modules/${moduleId}` : null; const [templates, setTemplates] = useState(null); const [loadError, setLoadError] = useState(null); - const [templateId, setTemplateId] = useState(""); - const [values, setValues] = useState({}); const [busy, setBusy] = useState(false); const [submitError, setSubmitError] = useState(null); + const templateId = selection.templateId; + const values = selection.values; + + // Read by the load effect without re-running it on every keystroke. + const latest = useRef({ selection, onSelectionChange, onInvalidated }); + useLayoutEffect(() => { + latest.current = { selection, onSelectionChange, onInvalidated }; + }); + const currentKeyRef = useRef(null); + const controllerRef = useRef(null); + useEffect(() => () => controllerRef.current?.abort(), []); useEffect(() => { if (!base) return; @@ -139,11 +177,15 @@ export function TemplateTab({ toUserError(payload, `Templates failed (HTTP ${response.status})`), ); } - setTemplates(payload.data.templates); - const first = payload.data.templates[0]; - if (first) { - setTemplateId(first.id); - setValues(toFormValues(first.defaults)); + const loaded = payload.data.templates; + setTemplates(loaded); + const { selection: shown, onSelectionChange: select, onInvalidated: invalidate } = + latest.current; + // Keep a remembered selection; only an absent or unknown id resets. + const first = loaded[0]; + if (first && !loaded.some((t) => t.id === shown.templateId)) { + invalidate(); + select({ templateId: first.id, values: toFormValues(first.defaults) }); } } catch (error) { if (controller.signal.aborted) return; @@ -164,9 +206,37 @@ export function TemplateTab({ [template, values], ); const errors = validation && !validation.ok ? validation.errors : {}; + const currentKey = + template && validation?.ok + ? templateRequestKey(template.id, validation.values) + : null; + useLayoutEffect(() => { + currentKeyRef.current = currentKey; + }, [currentKey]); + + const change = useCallback( + (next: TemplateSelection) => { + // Drop whatever is in flight before the new form is even rendered. + currentKeyRef.current = null; + controllerRef.current?.abort(); + controllerRef.current = null; + setBusy(false); + setSubmitError(null); + onInvalidated(); + onSelectionChange(next); + }, + [onInvalidated, onSelectionChange], + ); const handleGenerate = useCallback(async () => { if (!base || !template || !validation?.ok) return; + const requestKey = templateRequestKey(template.id, validation.values); + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + currentKeyRef.current = requestKey; + const stale = () => + controller.signal.aborted || currentKeyRef.current !== requestKey; setBusy(true); setSubmitError(null); try { @@ -176,12 +246,14 @@ export function TemplateTab({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ params: validation.values }), + signal: controller.signal, }, ); const payload = (await readJson(response)) as { ok?: boolean; data?: MaterializedTemplate; } | null; + if (stale()) return; if (!response.ok || !payload?.ok || !payload.data) { throw new Error( toUserError(payload, `Generate failed (HTTP ${response.status})`), @@ -189,11 +261,15 @@ export function TemplateTab({ } onMaterialized(payload.data); } catch (error) { + if (stale()) return; setSubmitError( error instanceof Error ? error.message : "Failed to generate footprint", ); } finally { - setBusy(false); + if (controllerRef.current === controller) { + controllerRef.current = null; + setBusy(false); + } } }, [base, onMaterialized, template, validation]); @@ -212,12 +288,13 @@ export function TemplateTab({