@protoboardai/ext-docs 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +18 -0
  3. package/README.md +78 -2
  4. package/evals/build-datasheet/case.json +147 -0
  5. package/fonts/KodeMono-Bold.ttf +0 -0
  6. package/fonts/KodeMono-Medium.ttf +0 -0
  7. package/fonts/KodeMono-Regular.ttf +0 -0
  8. package/fonts/KodeMono-SemiBold.ttf +0 -0
  9. package/fonts/OFL.txt +93 -0
  10. package/lib/blocks.ts +268 -0
  11. package/lib/bom.ts +126 -0
  12. package/lib/build.ts +202 -0
  13. package/lib/charts.ts +241 -0
  14. package/lib/context.ts +122 -0
  15. package/lib/derived.ts +181 -0
  16. package/lib/docs/assembly.ts +674 -0
  17. package/lib/docs/common.ts +81 -0
  18. package/lib/docs/datasheet.ts +875 -0
  19. package/lib/docs/product.ts +45 -0
  20. package/lib/figures.ts +195 -0
  21. package/lib/format.ts +221 -0
  22. package/lib/html.ts +135 -0
  23. package/lib/model.ts +312 -0
  24. package/lib/package/assembly.ts +268 -0
  25. package/lib/package/common.ts +109 -0
  26. package/lib/package/plan.ts +432 -0
  27. package/lib/package/spec.ts +203 -0
  28. package/lib/png.ts +91 -0
  29. package/lib/render/cdp.ts +5 -0
  30. package/lib/render/page/render.html +17 -0
  31. package/lib/render/page/render.js +390 -0
  32. package/lib/render/renderer.ts +161 -0
  33. package/lib/render/server.ts +105 -0
  34. package/lib/standin.ts +36 -0
  35. package/lib/standins/multirotor.ts +283 -0
  36. package/lib/templates/doc.css +247 -0
  37. package/lib/templates/flow.js +131 -0
  38. package/lib/testdata.ts +85 -0
  39. package/lib/types.ts +120 -0
  40. package/lib/values.ts +309 -0
  41. package/lib/verify.ts +276 -0
  42. package/ops/docs.ts +52 -0
  43. package/ops/package.ts +9 -0
  44. package/package.json +62 -4
  45. package/protoboard-extension.json +94 -0
  46. package/skills/uhd-tech-docs/SKILL.md +234 -0
  47. package/skills/uhd-tech-docs/references/docspec.md +56 -0
  48. package/skills/uhd-tech-docs/references/house-style.md +50 -0
  49. package/skills/uhd-tech-docs/references/style-study.md +270 -0
  50. package/skills/uhd-tech-docs/references/test-data.md +49 -0
  51. package/skills/uhd-tech-docs/references/value-queries.md +67 -0
  52. package/skills/uhd-tech-docs/templates/test-data.example.json +33 -0
  53. package/skills/uhd-tech-docs/templates/test-data.schema.json +213 -0
  54. package/skills/uhd-tech-docs-verify/SKILL.md +104 -0
  55. package/skills/uhd-tech-docs-verify/references/visual-review.md +43 -0
@@ -0,0 +1,131 @@
1
+ /*
2
+ * Paginator for uhd-tech-docs documents (runs once in headless Chrome at
3
+ * build time; the saved HTML is already paginated, so this script is removed).
4
+ *
5
+ * Blocks in #flow are poured into A4 page frames cloned from #page-template:
6
+ * data-page="cover|full" the block is a whole page of its own
7
+ * data-break="before" start a new page before the block
8
+ * .sec / .sub / .keep-next kept with the following block
9
+ * table.split may split between rows (header repeated, "continued")
10
+ * Pages whose body still overflows are marked .overflow (the verifier fails them).
11
+ *
12
+ * It measures only once every declared font face and every image has loaded:
13
+ * measuring with a fallback font gives other page breaks, so the same
14
+ * document would paginate differently on a busy machine.
15
+ */
16
+ function flowPages() {
17
+ const tpl = document.getElementById("page-template");
18
+ const pagesEl = document.getElementById("pages");
19
+ const flow = document.getElementById("flow");
20
+ const pages = [];
21
+ let body = null;
22
+
23
+ function newPage(kind) {
24
+ const page = tpl.content.firstElementChild.cloneNode(true);
25
+ if (kind) page.classList.add(kind);
26
+ pagesEl.appendChild(page);
27
+ pages.push(page);
28
+ body = page.querySelector(".pg-body");
29
+ return page;
30
+ }
31
+ const overflows = () => body.scrollHeight > body.clientHeight + 1;
32
+ const isKeepNext = (el) => el && (el.matches(".sec, .sub, .keep-next") || el.dataset.keepNext === "1");
33
+
34
+ function moveTrailingHeadings() {
35
+ const moved = [];
36
+ while (body.lastElementChild && isKeepNext(body.lastElementChild) && body.children.length > 1) {
37
+ moved.unshift(body.lastElementChild);
38
+ body.removeChild(body.lastElementChild);
39
+ }
40
+ return moved;
41
+ }
42
+
43
+ function splitTable(block) {
44
+ // block is (or wraps) table.split; move rows to a continuation until the page fits
45
+ const table = block.matches("table.split") ? block : block.querySelector("table.split");
46
+ if (!table) return null;
47
+ const rows = [...table.tBodies[0].rows];
48
+ if (rows.length < 4) return null;
49
+ const cont = block.cloneNode(true);
50
+ const ctable = cont.matches("table.split") ? cont : cont.querySelector("table.split");
51
+ ctable.tBodies[0].innerHTML = "";
52
+ cont.querySelectorAll(".cap, figcaption").forEach((c) => c.remove());
53
+ cont.classList.add("continued");
54
+ let moved = 0;
55
+ while (overflows() && table.tBodies[0].rows.length > 2) {
56
+ const last = table.tBodies[0].rows[table.tBodies[0].rows.length - 1];
57
+ ctable.tBodies[0].insertBefore(last, ctable.tBodies[0].firstChild);
58
+ moved++;
59
+ }
60
+ // avoid orphaned group headings at the bottom
61
+ const lastRow = table.tBodies[0].rows[table.tBodies[0].rows.length - 1];
62
+ if (lastRow && lastRow.classList.contains("group") && table.tBodies[0].rows.length > 1) {
63
+ ctable.tBodies[0].insertBefore(lastRow, ctable.tBodies[0].firstChild);
64
+ moved++;
65
+ }
66
+ if (overflows() || table.tBodies[0].rows.length < 2) {
67
+ // could not fit even two rows: give the rows back and move the whole block
68
+ while (ctable.tBodies[0].rows.length) table.tBodies[0].appendChild(ctable.tBodies[0].rows[0]);
69
+ return null;
70
+ }
71
+ return moved ? cont : null;
72
+ }
73
+
74
+ function place(block) {
75
+ if (!body) newPage();
76
+ if (block.dataset.page) {
77
+ if (body.children.length) newPage(block.dataset.page);
78
+ else pages[pages.length - 1].classList.add(block.dataset.page);
79
+ body.appendChild(block);
80
+ if (overflows()) pages[pages.length - 1].classList.add("overflow");
81
+ body = null;
82
+ return;
83
+ }
84
+ if (block.dataset.break === "before" && body.children.length) newPage();
85
+ body.appendChild(block);
86
+ if (!overflows()) return;
87
+ const cont = splitTable(block);
88
+ if (cont) {
89
+ newPage();
90
+ place(cont);
91
+ return;
92
+ }
93
+ if (body.children.length === 1) {
94
+ pages[pages.length - 1].classList.add("overflow");
95
+ return;
96
+ }
97
+ body.removeChild(block);
98
+ const carry = moveTrailingHeadings();
99
+ newPage();
100
+ for (const c of carry) body.appendChild(c);
101
+ place(block);
102
+ }
103
+
104
+ const blocks = [...flow.children];
105
+ for (const b of blocks) place(b);
106
+ flow.remove();
107
+
108
+ // page numbers, table of contents
109
+ const total = pages.length;
110
+ pages.forEach((p, i) => {
111
+ const n = p.querySelector(".pg-num");
112
+ if (n) n.textContent = `${String(i + 1).padStart(2, "0")} / ${String(total).padStart(2, "0")}`;
113
+ p.dataset.page = String(i + 1);
114
+ if (p.querySelector(".pg-body").scrollHeight > p.querySelector(".pg-body").clientHeight + 1) p.classList.add("overflow");
115
+ });
116
+ document.querySelectorAll("ol.toc").forEach((toc) => {
117
+ const items = [];
118
+ pages.forEach((p, i) => p.querySelectorAll("h1.sec, .step-head h2").forEach((h) => items.push({ t: h.dataset.toc || h.textContent.trim(), n: h.dataset.num || "", page: i + 1 })));
119
+ toc.innerHTML = items
120
+ .map((it) => `<li><span class="toc-n">${it.n}</span><span class="toc-t">${it.t}</span><span class="toc-dots"></span><span class="toc-p">${String(it.page).padStart(2, "0")}</span></li>`)
121
+ .join("");
122
+ });
123
+ document.getElementById("page-template").remove();
124
+ document.body.dataset.pages = String(total);
125
+ window.__flowDone = true;
126
+ }
127
+
128
+ Promise.all([...document.fonts].map((f) => f.load().catch(() => null)))
129
+ .then(() => document.fonts.ready)
130
+ .then(() => Promise.all([...document.images].map((i) => (i.complete ? 0 : new Promise((r) => { i.onload = i.onerror = r; })))))
131
+ .then(flowPages);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Test data for documents: `docs/test-data/<test-id>.json` in the project.
3
+ *
4
+ * One file per test id. `status` is "standin" (generated from the model by
5
+ * a stand-in recipe, drawn with a STAND-IN watermark) or "measured" (real
6
+ * results). The stand-in generator never overwrites a measured file, so
7
+ * dropping a measured file in place of a stand-in swaps it into every
8
+ * document on the next build. Schema: the uhd-tech-docs skill's templates/test-data.schema.json.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync } from "fs";
11
+ import { join } from "path";
12
+
13
+ export interface TestColumn {
14
+ id: string;
15
+ label: string;
16
+ unit: string;
17
+ }
18
+
19
+ export interface TestData {
20
+ $schema?: string;
21
+ id: string;
22
+ title: string;
23
+ status: "standin" | "measured";
24
+ subject: { system: string; modules?: string[] };
25
+ /** Test conditions, each with a unit ("" for none). */
26
+ conditions: { name: string; value: number | string; unit: string }[];
27
+ provenance: {
28
+ method: string;
29
+ /** Model queries the stand-in was derived from (values.ts syntax). */
30
+ derivedFrom?: string[];
31
+ assumptions?: string[];
32
+ generator?: string;
33
+ generatedAt?: string;
34
+ /** Measured data: who, when, with what. */
35
+ operator?: string;
36
+ date?: string;
37
+ instruments?: string[];
38
+ rawFiles?: string[];
39
+ };
40
+ columns: TestColumn[];
41
+ rows: (number | string)[][];
42
+ summary: { id: string; label: string; value: number | string; unit: string }[];
43
+ /** Project-relative path, set when loaded. */
44
+ file?: string;
45
+ }
46
+
47
+ export function validateTestData(t: any, file: string): string[] {
48
+ const errs: string[] = [];
49
+ const need = (cond: boolean, msg: string) => cond || errs.push(`${file}: ${msg}`);
50
+ need(typeof t.id === "string" && t.id.length > 0, "id missing");
51
+ need(file.endsWith(`${t.id}.json`), `id "${t.id}" does not match the file name`);
52
+ need(t.status === "standin" || t.status === "measured", `status must be "standin" or "measured"`);
53
+ need(typeof t.title === "string", "title missing");
54
+ need(t.subject && typeof t.subject.system === "string", "subject.system missing");
55
+ need(Array.isArray(t.conditions) && t.conditions.every((c: any) => c.name && "value" in c && typeof c.unit === "string"), "conditions[] need name, value, unit");
56
+ need(t.provenance && typeof t.provenance.method === "string", "provenance.method missing");
57
+ if (t.status === "measured") need(Boolean(t.provenance?.date && (t.provenance?.operator || t.provenance?.instruments?.length)), "measured data needs provenance.date and operator or instruments");
58
+ need(Array.isArray(t.columns) && t.columns.every((c: any) => c.id && c.label && typeof c.unit === "string"), "columns[] need id, label, unit");
59
+ need(Array.isArray(t.rows) && t.rows.every((r: any) => Array.isArray(r) && r.length === t.columns?.length), "every row needs one value per column");
60
+ need(Array.isArray(t.summary) && t.summary.every((s: any) => s.id && "value" in s && typeof s.unit === "string"), "summary[] need id, value, unit");
61
+ for (const r of t.rows ?? []) for (const v of r) if (typeof v === "number" && !Number.isFinite(v)) errs.push(`${file}: non-finite value in rows`);
62
+ return errs;
63
+ }
64
+
65
+ /** `absDocs`/`docsDir`: the documents folder, absolute and project-relative. */
66
+ export function loadTestData(absDocs: string, docsDir: string): { tests: Map<string, TestData>; errors: string[] } {
67
+ const dir = join(absDocs, "test-data");
68
+ const tests = new Map<string, TestData>();
69
+ const errors: string[] = [];
70
+ if (!existsSync(dir)) return { tests, errors };
71
+ for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
72
+ const t = JSON.parse(readFileSync(join(dir, f), "utf8"));
73
+ const errs = validateTestData(t, f);
74
+ errors.push(...errs);
75
+ if (!errs.length) tests.set(t.id, { ...t, file: `${docsDir}/test-data/${f}` });
76
+ }
77
+ return { tests, errors };
78
+ }
79
+
80
+ /** Column values by id. */
81
+ export function column(t: TestData, id: string): number[] {
82
+ const i = t.columns.findIndex((c) => c.id === id);
83
+ if (i < 0) throw new Error(`${t.id}: no column ${id}`);
84
+ return t.rows.map((r) => Number(r[i]));
85
+ }
package/lib/types.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Per-system documentation config: `<system>/docs/docspec.ts` default-exports
3
+ * a DocConfig. It says how to *present* the model (step order, cameras,
4
+ * which parameters are "key"), never what the values are: every value in a
5
+ * document is a query into the model (see values.ts).
6
+ */
7
+ import type { Mat4 } from "@deltarobotics/uhd";
8
+
9
+ export type Vec = [number, number, number];
10
+
11
+ export interface CameraSpec {
12
+ /** Direction from the target toward the camera (world, Z up). */
13
+ dir?: number[];
14
+ up?: number[];
15
+ margin?: number;
16
+ shiftX?: number;
17
+ shiftY?: number;
18
+ }
19
+
20
+ export interface StepGroupSpec {
21
+ /** Stable id, used in file names. */
22
+ id: string;
23
+ /** Verb-phrase title, e.g. "Mount the motors". */
24
+ title: string;
25
+ /** Mate link ids (as in assemble().mates[].linkId) covered by this step; `*` wildcards allowed. */
26
+ mates?: string[];
27
+ /** Wiring link ids (wiringChecklist linkId, with the sub-assembly prefix, e.g. "stack/fc_esc_cable"); wildcards allowed. */
28
+ wiring?: string[];
29
+ /** Instance paths placed in this step without a mate (placement hints, strapped parts). */
30
+ place?: string[];
31
+ /**
32
+ * Hand-written actions done first in the step, for work the model does not
33
+ * carry as an instance or a mate (a printed part of an enclosure fitted
34
+ * before what covers it, such as a button plunger). Value queries in
35
+ * {{…}} as in notes. The generated actions follow.
36
+ */
37
+ before?: string[];
38
+ /**
39
+ * Actions the model cannot generate, appended after the step's generated ones (the guide's list and the
40
+ * release's work order): a fold, a part laid in place last.
41
+ */
42
+ actions?: string[];
43
+ /** Presentation notes shown under the step (tips / cautions). Model-derived notes are added automatically. */
44
+ notes?: { kind: "tip" | "caution" | "warning"; text: string }[];
45
+ camera?: CameraSpec;
46
+ /** Explode distance for this step (mm). */
47
+ explode?: number;
48
+ /** Instances drawn as context from this step on even when not yet assembled (e.g. hidden until later). */
49
+ hideContext?: string[];
50
+ /** Figure style: exploded assembly (default) or a wiring figure. */
51
+ figure?: "assembly" | "wiring" | "none";
52
+ /**
53
+ * "test": done only after the unit's bench test and configuration (a
54
+ * battery strapped on for use). The Manufacturing Package's work order runs
55
+ * the test before such steps and a final check after them. Steps that fit
56
+ * propellers are treated this way without the flag.
57
+ */
58
+ after?: "test";
59
+ }
60
+
61
+ export interface KeySpec {
62
+ label: string;
63
+ /** Value query (values.ts). */
64
+ q: string;
65
+ /** Format options (format.ts). */
66
+ f?: string;
67
+ sub?: string;
68
+ }
69
+
70
+ export interface DocConfig {
71
+ title?: string;
72
+ subtitle?: string;
73
+ docId?: string;
74
+ revision?: string;
75
+ /** Root instance path for assemble() (default []). */
76
+ root?: string[];
77
+ /** Placements for modules no mate reaches (strapped/free), from model constants. */
78
+ placementHints?: { path: string[]; matrix: Mat4; note: string }[];
79
+ /** Default iso camera for the system. */
80
+ camera?: CameraSpec;
81
+ datasheet?: {
82
+ keyFigures?: KeySpec[];
83
+ specGroups?: { title: string; rows: KeySpec[] }[];
84
+ /** Interfaces to call out on the mechanical figure: "<instance path>.<interface id>". */
85
+ mechanicalCallouts?: string[];
86
+ /** Module whose pinout / interface allocation is tabulated (instance path). */
87
+ hub?: string;
88
+ /**
89
+ * Draw the bodies placed by placement hints on the cover (default false: a
90
+ * strapped battery is left off). True for parts held inside an enclosure.
91
+ */
92
+ heroHints?: boolean;
93
+ /**
94
+ * The Mechanical section's envelope table and figure captions. Omitted: the
95
+ * multirotor recipe (wheelbase, arm spacing, plates, props, motors),
96
+ * dimensioned on the drawings.
97
+ */
98
+ envelope?: { rows: KeySpec[]; note?: string; topCaption?: string; sideCaption?: string };
99
+ /**
100
+ * Performance figures as model queries, for a system whose parts state no performance table (not a
101
+ * multirotor): a summary table and bar charts (each item a query; `refs` draw target lines).
102
+ */
103
+ performance?: {
104
+ title?: string;
105
+ rows?: KeySpec[];
106
+ note?: string;
107
+ charts?: { title: string; y: string; unit: string; items: { label: string; q: string }[]; refs?: { label: string; q: string }[]; caption?: string; decimals?: number }[];
108
+ };
109
+ };
110
+ assembly?: {
111
+ steps: StepGroupSpec[];
112
+ explode?: number;
113
+ /** Instance path prefixes never drawn in step figures (e.g. props until their step). */
114
+ lateParts?: string[];
115
+ };
116
+ testData?: {
117
+ /** Stand-in recipe: a module name under lib/standins/ of @protoboardai/ext-docs ("multirotor"). */
118
+ standins?: string;
119
+ };
120
+ }
package/lib/values.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Value queries: every number or fact a document shows is a query string,
3
+ * resolved against the live UHD model at build time and again at verify
4
+ * time. Nothing is typed into a document by hand.
5
+ *
6
+ * def:<moduleId>:<path> a field of a module definition
7
+ * def:dolphinrc-f405-v3-flight-controller:interfaces[id=bec_5v].parameters[id=voltage].value
8
+ * def:cnhl-black-series-1100mah-6s-100c:domains[domain=mechanical].weight_g
9
+ * inst:<instance path>:<path> the same, addressed by instance (stack/fc, arm_fl/motor)
10
+ * sys:<path> system-level results: checks, bom, assembly, wiring, scenarios
11
+ * sys:checks.diagnostics[rule=propulsion_current].details.totalPeak
12
+ * derived:<name> a named derivation (derived.ts) with its inputs listed
13
+ * derived:<name>(a,b) a parameterised derivation, e.g. derived:frameDistance(frame.motor_mount_fl,frame.motor_mount_fr)
14
+ * ver:<partId>:<path> the part's committed verification record (verification.json)
15
+ * test:<test-id>:<path> a test-data file (docs/test-data/<test-id>.json); rows[i][j] takes columns[j].unit
16
+ *
17
+ * Paths: `.key`, `[n]`, `[key=value]` (first match), `.length`.
18
+ */
19
+ import { existsSync, readFileSync } from "fs";
20
+ import { join, relative } from "path";
21
+ import type { ModuleDef } from "@deltarobotics/uhd";
22
+ import { bodyKey } from "@deltarobotics/uhd";
23
+ import type { SystemCheckResult } from "@deltarobotics/uhd";
24
+ import type { WiringStep } from "@deltarobotics/uhd";
25
+ import type { LoadedSystem, SceneModel } from "./model.ts";
26
+ import { unitFromKey, unitFromKeys } from "./format.ts";
27
+ import type { TestData } from "./testdata.ts";
28
+
29
+ export type ValueStatus = "source" | "model" | "assumption" | "derived" | "standin" | "measured" | "gap";
30
+
31
+ export interface ResolvedValue {
32
+ q: string;
33
+ value: unknown;
34
+ unit?: string;
35
+ status: ValueStatus;
36
+ /** URL or repo path the value is cited from. */
37
+ source?: string;
38
+ note?: string;
39
+ /** For derived values: the input queries. */
40
+ inputs?: string[];
41
+ formula?: string;
42
+ }
43
+
44
+ export interface DerivedDef {
45
+ label: string;
46
+ unit?: string;
47
+ formula: string;
48
+ compute: (ctx: DocContext, args?: string[]) => { value: unknown; inputs: string[]; note?: string; unit?: string };
49
+ }
50
+
51
+ export interface DocContext {
52
+ sys: LoadedSystem;
53
+ scene: SceneModel;
54
+ bom: any[];
55
+ checks: SystemCheckResult;
56
+ wiring: WiringStep[];
57
+ scenarioChecks: { id: string; label: string; description: string; diagnostics: SystemCheckResult["diagnostics"] }[];
58
+ /** Each circuit board below the system (a module with nets), checked on its own as protoboard check does. */
59
+ boards: { path: string; id: string; diagnostics: SystemCheckResult["diagnostics"] }[];
60
+ tests: Map<string, TestData>;
61
+ derived: Record<string, DerivedDef>;
62
+ /** Instance path -> module definition (leaf and assembly instances). */
63
+ instances: Map<string, ModuleDef>;
64
+ /** A value lookup (resolve). */
65
+ resolve: (q: string) => ResolvedValue;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Paths
70
+ // ---------------------------------------------------------------------------
71
+
72
+ type Seg = { key: string } | { index: number } | { match: [string, string] };
73
+
74
+ export function parsePath(path: string): Seg[] {
75
+ const segs: Seg[] = [];
76
+ let i = 0;
77
+ while (i < path.length) {
78
+ const c = path[i];
79
+ if (c === ".") {
80
+ i++;
81
+ continue;
82
+ }
83
+ if (c === "[") {
84
+ const end = path.indexOf("]", i);
85
+ if (end < 0) throw new Error(`unclosed [ in ${path}`);
86
+ const body = path.slice(i + 1, end);
87
+ const eq = body.indexOf("=");
88
+ if (eq >= 0) segs.push({ match: [body.slice(0, eq), body.slice(eq + 1)] });
89
+ else segs.push({ index: Number(body) });
90
+ i = end + 1;
91
+ continue;
92
+ }
93
+ let j = i;
94
+ while (j < path.length && path[j] !== "." && path[j] !== "[") j++;
95
+ segs.push({ key: path.slice(i, j) });
96
+ i = j;
97
+ }
98
+ return segs;
99
+ }
100
+
101
+ interface Walk {
102
+ value: unknown;
103
+ parent?: any;
104
+ lastKey?: string;
105
+ /** The keys along the path, in order (not the values of [k=v] matches, which name items). */
106
+ keys: string[];
107
+ sources: string[];
108
+ trail: any[];
109
+ }
110
+
111
+ export function walk(root: unknown, path: string): Walk {
112
+ let cur: any = root;
113
+ let parent: any;
114
+ let lastKey: string | undefined;
115
+ const keys: string[] = [];
116
+ const sources: string[] = [];
117
+ const trail: any[] = [root];
118
+ const noteSource = (o: any) => {
119
+ if (o && typeof o === "object" && !Array.isArray(o)) {
120
+ const s = o.source ?? o.params?.source ?? o.metadata?.source;
121
+ if (typeof s === "string") sources.push(s);
122
+ else if (Array.isArray(s) && typeof s[0] === "string") sources.push(s[0]);
123
+ }
124
+ };
125
+ noteSource(cur);
126
+ for (const seg of parsePath(path)) {
127
+ if (cur === undefined || cur === null) return { value: undefined, parent, lastKey, keys, sources, trail };
128
+ parent = cur;
129
+ if ("key" in seg) {
130
+ if (seg.key === "length" && Array.isArray(cur)) cur = cur.length;
131
+ else cur = cur[seg.key];
132
+ lastKey = seg.key;
133
+ keys.push(seg.key);
134
+ } else if ("index" in seg) cur = cur[seg.index];
135
+ else {
136
+ const [k, v] = seg.match;
137
+ cur = Array.isArray(cur) ? cur.find((x) => String(x?.[k]) === v) : undefined;
138
+ lastKey = v;
139
+ }
140
+ trail.push(cur);
141
+ noteSource(cur);
142
+ }
143
+ return { value: cur, parent, lastKey, keys, sources, trail };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Evidence per module
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /** The first of the definition's evidence folders (project parts/evidence, or its package) that has `name`. */
151
+ export function evidenceFile(sys: LoadedSystem, id: string, name: string): string | undefined {
152
+ return sys.evidenceDirs(id).map((d) => join(d, name)).find((f) => existsSync(f));
153
+ }
154
+
155
+ export function moduleSources(sys: LoadedSystem, id: string): { id: string; title: string; url: string; authority?: string }[] {
156
+ const p = evidenceFile(sys, id, "sources.json");
157
+ if (!p) return [];
158
+ try {
159
+ const j = JSON.parse(readFileSync(p, "utf8"));
160
+ return Array.isArray(j) ? j : j.sources ?? [];
161
+ } catch {
162
+ return [];
163
+ }
164
+ }
165
+
166
+ export function moduleVerification(sys: LoadedSystem, id: string): any | undefined {
167
+ const p = evidenceFile(sys, id, "verification.json");
168
+ return p ? JSON.parse(readFileSync(p, "utf8")) : undefined;
169
+ }
170
+
171
+ function assumptionFor(def: ModuleDef, keys: string[]): string | undefined {
172
+ const lower = keys.filter(Boolean).map((k) => k.toLowerCase());
173
+ for (const t of def.traits ?? []) {
174
+ if (t.type !== "assumption") continue;
175
+ const field = String(t.params?.field ?? "").toLowerCase();
176
+ if (field && lower.some((k) => k === field || field.split(/[ ,/]+/).includes(k))) return String(t.params?.reason ?? t.params?.note ?? field);
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ function resolveDef(sys: LoadedSystem, q: string, def: ModuleDef | undefined, path: string): ResolvedValue {
182
+ if (!def) return { q, value: undefined, status: "gap", note: "module not found" };
183
+ const w = walk(def, path);
184
+ // a parameter stated as a range has no `value`: use the range
185
+ if ((w.value === undefined || w.value === null) && w.lastKey === "value" && Array.isArray(w.parent?.range)) w.value = w.parent.range;
186
+ let unit: string | undefined;
187
+ if (w.lastKey === "value" || w.lastKey === "range") unit = w.parent?.unit;
188
+ else if (w.lastKey === "weight_g") unit = "g";
189
+ // the last key's unit, else the nearest along the path (lowest_latency_ms.goggles_3 is in ms)
190
+ else if (w.lastKey) unit = unitFromKeys(w.keys);
191
+ const keys = w.trail.filter((x) => x && typeof x === "object" && !Array.isArray(x) && typeof x.id === "string").map((x) => x.id);
192
+ // an assumption trait, or any trait/spec object carrying its own `assumption` note (supplied_from, torque, material)
193
+ const inAssumptionTrait = w.trail.some((x) => x && typeof x === "object" && (x.type === "assumption" || (typeof x.assumption === "string" && x.assumption) || (typeof x.params?.assumption === "string" && x.params.assumption)));
194
+ const assumption = inAssumptionTrait ? "assumption trait" : assumptionFor(def, [...keys, w.lastKey ?? ""]);
195
+ const srcs = moduleSources(sys, def.id);
196
+ const verified = Boolean(moduleVerification(sys, def.id));
197
+ const source = w.sources.at(-1) ?? srcs[0]?.url ?? (def.artifacts ?? []).find((a) => a.url)?.url;
198
+ let status: ValueStatus = verified || srcs.length ? "source" : "model";
199
+ if (assumption) status = "assumption";
200
+ if (w.value === undefined || w.value === null) status = "gap";
201
+ return { q, value: w.value, unit, status, source: status === "model" ? `model: ${def.id}` : source, note: assumption };
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Resolver
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export function makeResolver(ctx: Omit<DocContext, "resolve">): (q: string) => ResolvedValue {
209
+ const memo = new Map<string, ResolvedValue>();
210
+ const resolve = (q: string): ResolvedValue => {
211
+ if (memo.has(q)) return memo.get(q)!;
212
+ const r = resolveUncached(q);
213
+ memo.set(q, r);
214
+ return r;
215
+ };
216
+ const full = ctx as DocContext;
217
+ full.resolve = resolve;
218
+
219
+ const resolveUncached = (q: string): ResolvedValue => {
220
+ const colon = q.indexOf(":");
221
+ const scheme = q.slice(0, colon);
222
+ const rest = q.slice(colon + 1);
223
+ if (scheme === "def" || scheme === "inst") {
224
+ const c = rest.indexOf(":");
225
+ const target = c < 0 ? rest : rest.slice(0, c);
226
+ const path = c < 0 ? "" : rest.slice(c + 1);
227
+ const def = scheme === "def" ? ctx.sys.lookup(target) : ctx.instances.get(target);
228
+ return resolveDef(ctx.sys, q, def, path);
229
+ }
230
+ if (scheme === "sys") {
231
+ const root = {
232
+ system: ctx.sys.system,
233
+ checks: ctx.checks,
234
+ bom: ctx.bom,
235
+ wiring: ctx.wiring,
236
+ scenarios: ctx.scenarioChecks,
237
+ boards: ctx.boards,
238
+ assembly: {
239
+ mates: ctx.scene.assembly.mates.length,
240
+ placements: ctx.scene.assembly.placements.length,
241
+ hardware: ctx.scene.assembly.hardware.length,
242
+ issues: ctx.scene.assembly.issues,
243
+ },
244
+ };
245
+ const w = walk(root, rest);
246
+ return { q, value: w.value, unit: unitFromKeys(w.keys), status: w.value === undefined ? "gap" : "model", source: `UHD ${rest.split(/[.[]/)[0]}` };
247
+ }
248
+ if (scheme === "derived") {
249
+ const call = rest.match(/^([\w.]+)\((.*)\)$/);
250
+ const d = call ? ctx.derived[call[1]] : ctx.derived[rest];
251
+ if (d && call) {
252
+ const r = d.compute(full, call[2].split(",").map((x) => x.trim()));
253
+ const inputs = r.inputs.map(resolve);
254
+ const status: ValueStatus = inputs.some((i) => i.status === "standin") ? "standin" : r.value === undefined ? "gap" : inputs.some((i) => i.status === "assumption") ? "assumption" : "derived";
255
+ return { q, value: r.value, unit: r.unit ?? d.unit, status, inputs: r.inputs, formula: d.formula, note: r.note };
256
+ }
257
+ if (!d) return { q, value: undefined, status: "gap", note: `no derivation "${rest}"` };
258
+ const r = d.compute(full);
259
+ const inputs = r.inputs.map(resolve);
260
+ const status: ValueStatus = inputs.some((i) => i.status === "standin")
261
+ ? "standin"
262
+ : inputs.some((i) => i.status === "gap") && r.value === undefined
263
+ ? "gap"
264
+ : inputs.some((i) => i.status === "assumption")
265
+ ? "assumption"
266
+ : "derived";
267
+ return { q, value: r.value, unit: r.unit ?? d.unit, status, inputs: r.inputs, formula: d.formula, note: r.note };
268
+ }
269
+ if (scheme === "ver") {
270
+ const c = rest.indexOf(":");
271
+ const id = c < 0 ? rest : rest.slice(0, c);
272
+ const file = evidenceFile(ctx.sys, id, "verification.json");
273
+ const v = moduleVerification(ctx.sys, id);
274
+ if (!v || !file) return { q, value: undefined, status: "gap", note: `no verification record for ${id}` };
275
+ const w = walk(v, c < 0 ? "" : rest.slice(c + 1));
276
+ const shown = file.startsWith(ctx.sys.root) ? relative(ctx.sys.root, file).split("\\").join("/") : `${id}/verification.json (catalog evidence)`;
277
+ return { q, value: w.value, status: w.value === undefined ? "gap" : "model", source: shown };
278
+ }
279
+ if (scheme === "test") {
280
+ const c = rest.indexOf(":");
281
+ const id = c < 0 ? rest : rest.slice(0, c);
282
+ const t = ctx.tests.get(id);
283
+ if (!t) return { q, value: undefined, status: "gap", note: `no test data "${id}"` };
284
+ const path = c < 0 ? "" : rest.slice(c + 1);
285
+ const w = walk(t, path);
286
+ const cell = path.match(/^rows\[(\d+)\]\[(\d+)\]$/);
287
+ const unit = cell ? t.columns[Number(cell[2])]?.unit || undefined : w.parent?.unit || unitFromKeys(w.keys);
288
+ return { q, value: w.value, unit, status: t.status === "measured" ? "measured" : "standin", source: t.file };
289
+ }
290
+ throw new Error(`unknown value query scheme in "${q}"`);
291
+ };
292
+ return resolve;
293
+ }
294
+
295
+ /** Instance path -> def for every child at every level (assemblies included). */
296
+ export function instanceMap(system: ModuleDef, lookup: (id: string) => ModuleDef | undefined): Map<string, ModuleDef> {
297
+ const out = new Map<string, ModuleDef>();
298
+ const visit = (d: ModuleDef, prefix: string[]) => {
299
+ for (const c of d.children ?? []) {
300
+ const def = lookup(c.moduleDefId);
301
+ if (!def) continue;
302
+ const path = [...prefix, c.id];
303
+ out.set(bodyKey(path), def);
304
+ visit(def, path);
305
+ }
306
+ };
307
+ visit(system, []);
308
+ return out;
309
+ }