import type { ProjectFile } from "./project"; import { flattenKeys } from "./geometry"; import type { Node } from "./phy"; import { eligibleKeyIds, unregisteredKeys } from "./idx"; import { parseKeycode } from "./map"; import { validateSchema } from "../io/validateSchema"; export type ValidationIssue = { severity: "blocksSave" | "blocksOutput" | "warning"; code: string; message: string; path?: string; ids?: string[]; }; /** Performs semantic validation of a complete mock project. T-13/T-16/T-21/T-25. */ export function validateProject(p: ProjectFile): ValidationIssue[] { const out: ValidationIssue[] = []; for (const layer of ["phy", "idx", "elc", "map"] as const) for (const e of validateSchema(layer, p[layer])) out.push({ severity: "blocksSave", code: "schema", message: e.message, path: `${layer}${e.path}` }); if (p.format !== "beyond-kle-mock-project") out.push({ severity: "blocksSave", code: "project-format", message: "Unsupported ProjectFile format", path: "/format", }); if (p.version !== "0.1.0") out.push({ severity: "blocksSave", code: "version", message: "Unsupported ProjectFile version", path: "/version", }); for (const layer of ["phy", "idx", "elc", "map"] as const) if (p[layer].version !== "0.1.0") out.push({ severity: "blocksSave", code: "version", message: `Unsupported ${layer} version`, path: `/${layer}/version`, }); const ids = new Map(); const add = (id: string, path: string) => ids.set(id, [...(ids.get(id) ?? []), path]); const walk = (ns: Node[], path: string) => ns.forEach((n, i) => { const q = `${path}/${i}`; add(n.id, q); if (n.type === "cluster") walk(n.children, q + "/children"); if (n.type === "variantGroup") { if (!n.children.some((v) => v.id === n.default)) out.push({ severity: "blocksSave", code: "missing-default-variant", message: "VariantGroup default does not reference a child", path: q + "/default", ids: [n.id, n.default], }); for (const v of n.children) { add(v.id, q + "/children"); walk(v.children, q + `/children/${v.id}/children`); } } }); walk(p.phy.root, "/phy/root"); for (const [id, paths] of ids) if (paths.length > 1) out.push({ severity: "blocksSave", code: "duplicate-id", message: "Duplicate PHY node ID", ids: [id], path: paths[1], }); const nodes = new Set(ids.keys()), keys = new Set(eligibleKeyIds(p.phy)); const ref = (id: string, path: string, valid: Set = nodes) => { if (!valid.has(id)) out.push({ severity: "blocksSave", code: "missing-reference", message: `Unknown reference: ${id}`, path, ids: [id], }); }; p.idx.order.forEach((id, i) => ref(id, `/idx/order/${i}`, keys)); p.elc.keys.forEach((k, i) => ref(k.key, `/elc/keys/${i}/key`, keys)); p.elc.ledChains?.forEach((c, i) => c.order.forEach((id, j) => ref(id, `/elc/ledChains/${i}/order/${j}`))); p.map.layers.forEach((l, i) => l.keymap.forEach((a, j) => { ref(a.keyId, `/map/layers/${i}/keymap/${j}/keyId`, keys); const keycode = parseKeycode(a.keycode); if (keycode.kind === "layer" && !p.map.layers.some((candidate) => candidate.name === keycode.layer)) out.push({ severity: "blocksSave", code: "missing-map-layer-reference", message: `Unknown MAP layer reference: ${keycode.layer}`, path: `/map/layers/${i}/keymap/${j}/keycode`, ids: [l.name, keycode.layer ?? ""], }); }), ); const providerIds = new Map(); p.elc.providers.forEach((provider, index) => { const first = providerIds.get(provider.id); if (first !== undefined) out.push({ severity: "blocksSave", code: "duplicate-provider-id", message: "Provider IDs must be unique", path: `/elc/providers/${index}/id`, ids: [provider.id], }); else providerIds.set(provider.id, index); if (!provider.id || provider.id.includes(".")) out.push({ severity: "blocksSave", code: "invalid-provider-id", message: "Provider IDs must be nonempty and cannot contain '.'", path: `/elc/providers/${index}/id`, ids: [provider.id], }); }); const chainIds = new Set(); p.elc.ledChains?.forEach((chain, index) => { if (chainIds.has(chain.id)) out.push({ severity: "blocksSave", code: "duplicate-led-chain-id", message: "LED chain IDs must be unique", path: `/elc/ledChains/${index}/id`, ids: [chain.id], }); chainIds.add(chain.id); }); const wiredKeys = new Set(); p.elc.keys.forEach((wiring, index) => { if (wiredKeys.has(wiring.key)) out.push({ severity: "blocksSave", code: "duplicate-key-wiring", message: "A key may have only one ELC wiring entry", path: `/elc/keys/${index}/key`, ids: [wiring.key], }); wiredKeys.add(wiring.key); }); const validatePinRef = (pin: string, path: string) => { if (pin === "VCC" || pin === "GND") return; const parts = pin.split("."); const providerId = parts[0]; const pinName = parts[1]; if (parts.length !== 2 || !providerId || !pinName) { out.push({ severity: "blocksSave", code: "invalid-pin-reference", message: `Malformed PinRef: ${pin}`, path, ids: [pin], }); return; } const provider = p.elc.providers.find((candidate) => candidate.id === providerId); if (!provider) { out.push({ severity: "blocksSave", code: "missing-pin-provider", message: `Unknown PinRef provider: ${providerId}`, path, ids: [providerId, pin], }); return; } let valid = true; if (provider.kind === "analog-mux") valid = /^(0|[1-9]\d*)$/.test(pinName) && Number(pinName) < provider.channels; else if (provider.part.toUpperCase() === "RP2040") valid = /^GP(?:[0-9]|[12][0-9])$/.test(pinName); if (!valid) out.push({ severity: "blocksSave", code: "unknown-provider-pin", message: `Unknown pin ${pinName} on provider ${providerId}`, path, ids: [providerId, pinName], }); }; p.elc.providers.forEach((provider, index) => { if (provider.kind === "analog-mux") { provider.select.forEach((pin, i) => validatePinRef(pin, `/elc/providers/${index}/select/${i}`)); validatePinRef(provider.common, `/elc/providers/${index}/common`); if (provider.enable !== undefined) validatePinRef(provider.enable, `/elc/providers/${index}/enable`); } }); p.elc.keys.forEach((wiring, index) => Object.entries(wiring.terminals).forEach(([terminal, pin]) => validatePinRef(pin, `/elc/keys/${index}/terminals/${terminal}`), ), ); p.elc.ledChains?.forEach((chain, index) => validatePinRef(chain.data, `/elc/ledChains/${index}/data`)); p.output.matrix?.rowPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/rowPins/${index}`)); p.output.matrix?.colPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/colPins/${index}`)); const profileIds = new Set(p.phy.profiles.map((x) => x.id)); const checkProfiles = (ns: Node[]) => ns.forEach((n, i) => { if (n.type === "key" && !profileIds.has(n.profile)) out.push({ severity: "blocksSave", code: "missing-profile", message: `Unknown profile: ${n.profile}`, path: `/phy/key/${n.id}/profile`, ids: [n.id, n.profile], }); if (n.type === "cluster") checkProfiles(n.children); if (n.type === "variantGroup") n.children.forEach((v) => checkProfiles(v.children)); }); checkProfiles(p.phy.root); for (const [i, pr] of p.phy.profiles.entries()) { if (pr.unitSize.x <= 0 || pr.unitSize.y <= 0) out.push({ severity: "blocksSave", code: "positive-unit-size", message: "unitSize values must be positive", path: `/phy/profiles/${i}/unitSize`, }); } const keyWalk = (ns: Node[]) => ns.forEach((n) => { if (n.type === "key" && (n.size.w <= 0 || n.size.h <= 0)) out.push({ severity: "blocksSave", code: "positive-key-size", message: "Key size values must be positive", path: `/phy/key/${n.id}/size`, ids: [n.id], }); if (n.type === "cluster") keyWalk(n.children); if (n.type === "variantGroup") n.children.forEach((v) => keyWalk(v.children)); }); keyWalk(p.phy.root); const unreg = unregisteredKeys(p.phy, p.idx); for (const id of unreg) out.push({ severity: "blocksOutput", code: "idx-unregistered", message: "Eligible key is not in IDX order", ids: [id], }); const profileSizes = p.phy.profiles.map((x) => `${x.unitSize.x},${x.unitSize.y}`); if (new Set(profileSizes).size > 1) out.push({ severity: "warning", code: "unit-size-mismatch", message: "Profile unitSize values differ; geometry editing is restricted", path: "/phy/profiles", }); let active: string[] = []; try { active = flattenKeys(p.phy, p.output.variantSelection) .filter((x) => !x.key.decal) .map((x) => x.key.id); } catch (e) { out.push({ severity: "blocksSave", code: "invalid-variant-selection", message: String(e) }); } for (const id of active) { if (!p.elc.keys.some((k) => k.key === id)) out.push({ severity: "blocksOutput", code: "unwired-key", message: "Selected key has no wiring", ids: [id], }); for (const l of p.map.layers) if (!l.keymap.some((a) => a.keyId === id)) out.push({ severity: "blocksOutput", code: "unassigned-key", message: `Selected key is not assigned in layer ${l.name}`, ids: [id, l.name], }); } const branches = new Map>(); const exclusivity = (ns: Node[], branch: Array<[string, string]>) => ns.forEach((n) => { if (n.type === "key") branches.set(n.id, branch); if (n.type === "cluster") exclusivity(n.children, branch); if (n.type === "variantGroup") for (const v of n.children) exclusivity(v.children, [...branch, [n.id, v.id]]); }); exclusivity(p.phy.root, []); const mutuallyExclusive = (a: string, b: string) => { for (const [group, variant] of branches.get(a) ?? []) if ((branches.get(b) ?? []).some(([g, v]) => g === group && v !== variant)) return true; return false; }; const unordered = (a: string, b: string) => (a < b ? `${a}\0${b}` : `${b}\0${a}`); const contacts = p.elc.keys.filter((w) => w.detect === "contact"); for (let i = 0; i < contacts.length; i++) for (let j = i + 1; j < contacts.length; j++) { const a = contacts[i]!, b = contacts[j]!; if (a.key === b.key || mutuallyExclusive(a.key, b.key)) continue; const ap = a.diode ? a.diode === "a-to-b" ? [a.terminals.a, a.terminals.b] : [a.terminals.b, a.terminals.a] : null; const bp = b.diode ? b.diode === "a-to-b" ? [b.terminals.a, b.terminals.b] : [b.terminals.b, b.terminals.a] : null; const same = ap && bp ? ap[0] === bp[0] && ap[1] === bp[1] : unordered(a.terminals.a, a.terminals.b) === unordered(b.terminals.a, b.terminals.b); if (same) out.push({ severity: "blocksOutput", code: "wiring-collision", message: "Non-exclusive keys share the same electrical connection", ids: [a.key, b.key], }); } const layerNames = new Set(); for (const [i, l] of p.map.layers.entries()) { if (layerNames.has(l.name)) out.push({ severity: "blocksSave", code: "duplicate-layer-name", message: "Layer names must be unique", path: `/map/layers/${i}/name`, ids: [l.name], }); layerNames.add(l.name); } for (const [li, l] of p.map.layers.entries()) { const assignments = new Map(); for (const [ai, a] of l.keymap.entries()) { const prev = assignments.get(a.keyId); if (prev !== undefined) out.push({ severity: "blocksSave", code: "duplicate-map-key", message: "A key may occur only once in a layer keymap", path: `/map/layers/${li}/keymap/${ai}/keyId`, ids: [a.keyId, l.name], }); assignments.set(a.keyId, ai); } } return out; }