@protoboardai/ext-release 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.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +290 -2
- package/evals/release-after-hand-edit/case.json +59 -0
- package/lib/bom.ts +444 -0
- package/lib/collect.ts +100 -0
- package/lib/config.ts +45 -0
- package/lib/cutlist.ts +70 -0
- package/lib/documents.ts +287 -0
- package/lib/glb.ts +85 -0
- package/lib/helpers.ts +49 -0
- package/lib/html.ts +126 -0
- package/lib/manifest.ts +159 -0
- package/lib/offers.ts +123 -0
- package/lib/preconditions.ts +141 -0
- package/lib/product.ts +64 -0
- package/lib/release.ts +279 -0
- package/lib/render.ts +203 -0
- package/lib/sections/baseline.ts +164 -0
- package/lib/sections/bom.ts +101 -0
- package/lib/sections/model.ts +78 -0
- package/lib/sections/testplan.ts +76 -0
- package/lib/sections/tests.ts +630 -0
- package/lib/sections/workorder.ts +369 -0
- package/lib/testing.ts +43 -0
- package/lib/types.ts +251 -0
- package/lib/units.ts +141 -0
- package/lib/util.ts +31 -0
- package/ops/release.ts +21 -0
- package/ops/sections.ts +23 -0
- package/package.json +52 -4
- package/protoboard-extension.json +81 -0
- package/skills/release/SKILL.md +191 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The work order of one unit (PK3): the manufacturing process.
|
|
3
|
+
*
|
|
4
|
+
* - ordering checklist: from the whole product's ordering list; supplier,
|
|
5
|
+
* lead time and minimum order where an offer or sourcing record states
|
|
6
|
+
* them, else "unknown";
|
|
7
|
+
* - fabrication orders: one line per part made for the unit, pointing at
|
|
8
|
+
* the provider's own order details (board fab profile, print settings,
|
|
9
|
+
* cut stock, cable cut list) contributed below the main body;
|
|
10
|
+
* - operations in sequence: kit and first-article inspection, board
|
|
11
|
+
* assembly, cable preparation, then the unit's assembly steps in the
|
|
12
|
+
* order of its assembly guide (@protoboardai/ext-docs/plan: mates with
|
|
13
|
+
* their fastener stacks and torques, soldered and plugged connections),
|
|
14
|
+
* the operations other extensions contribute to configure the unit
|
|
15
|
+
* (stage "configure", e.g. load the flight controller's configuration)
|
|
16
|
+
* with the bench test around them, then the steps that must follow the
|
|
17
|
+
* test (propellers, a battery strapped on for use) and a final check;
|
|
18
|
+
* each with parts, tools, materials, torque, an inspection point and the
|
|
19
|
+
* test-plan check that follows;
|
|
20
|
+
* - kitting: what each operation consumes and where it comes from.
|
|
21
|
+
*/
|
|
22
|
+
import { unitPlan, type PlannedStep } from "@protoboardai/ext-docs/plan";
|
|
23
|
+
import { includedWith, orderingList, unitBom, type OrderLine, type UnitBomRow } from "../bom.ts";
|
|
24
|
+
import { unitModules } from "../collect.ts";
|
|
25
|
+
import { cutList, isCable } from "../cutlist.ts";
|
|
26
|
+
import type { Block, Cell, SectionContext, SectionContribution, SectionDiagnostic, Step } from "../types.ts";
|
|
27
|
+
import { unitTests, type UnitTests } from "./tests.ts";
|
|
28
|
+
|
|
29
|
+
const RELEASE = "@protoboardai/ext-release";
|
|
30
|
+
|
|
31
|
+
interface Op extends Step {
|
|
32
|
+
id: string;
|
|
33
|
+
kit: { item: string; qty: number; from: string }[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function workOrderSection(s: SectionContext): Promise<SectionContribution> {
|
|
37
|
+
const { project: p, unit: u, units, release, files } = s;
|
|
38
|
+
const rows = unitBom(p, u);
|
|
39
|
+
const builds = u.perProduct * release.build;
|
|
40
|
+
const lines = orderingList(p, units, release.config);
|
|
41
|
+
const plan = await unitPlan(p, u);
|
|
42
|
+
const tests = unitTests(p, u, plan);
|
|
43
|
+
const blocks: Block[] = [];
|
|
44
|
+
const diagnostics: SectionDiagnostic[] = [];
|
|
45
|
+
blocks.push({ kind: "paragraph", text: `Build ${builds} of this unit (${u.perProduct} per product × ${release.build} ${release.build === 1 ? "product" : "products"}). Ordering quantities are for all of them; operations and kits are per build. Test ids (FAI, JT, BU, FN) are in this unit's test plan.` });
|
|
46
|
+
// ---- builds that are not all the same (a module unit whose instances differ in what their parent sets)
|
|
47
|
+
if (plan.variants.length) {
|
|
48
|
+
const list = plan.variants.map((x) => `${x.field}: ${x.values.map((y) => `${y.build} (${y.path}) ${y.value}`).join(", ")}`).join("; ");
|
|
49
|
+
blocks.push({ kind: "note", level: "caution", text: `The ${plan.builds.length} builds of this unit are not all the same. ${list}. ${plan.variants.every((x) => x.handled) ? "The operations that depend on it list each build." : "Not every operation says which build gets what: check each build against the position it goes to."}` });
|
|
50
|
+
for (const x of plan.variants.filter((y) => !y.handled))
|
|
51
|
+
diagnostics.push({ code: "REL_BUILDS_DIFFER", severity: "warning", message: `the ${plan.builds.length} builds of unit ${u.id} differ in ${x.field} (${x.values.map((y) => `${y.path} ${y.value}`).join(", ")}), and no step says which build gets what; name each instance as its own unit, or check the documents per build` });
|
|
52
|
+
}
|
|
53
|
+
// ---- steps that must follow the product's own bench test, in a unit built before it
|
|
54
|
+
if (u.kind !== "root")
|
|
55
|
+
for (const st of plan.steps.filter((x) => x.afterTest))
|
|
56
|
+
diagnostics.push({ code: "REL_AFTER_TEST_IN_SUBUNIT", severity: "warning", message: `unit ${u.id}: "${st.title}" must follow the bench test (${st.afterTest}), but ${u.id} is built before final assembly, so it is done before the product's own test and configuration; leave it to final assembly (do not name ${u.entry} as a unit) or check the order by hand` });
|
|
57
|
+
|
|
58
|
+
// ---- ordering checklist
|
|
59
|
+
blocks.push({ kind: "heading", text: "Ordering checklist" });
|
|
60
|
+
const buy = checklist(u.kind === "root", rows, lines, builds);
|
|
61
|
+
if (buy.rows.length) {
|
|
62
|
+
blocks.push({
|
|
63
|
+
kind: "table",
|
|
64
|
+
columns: [{ key: "done", label: "✓" }, { key: "line", label: "Line", align: "right" }, { key: "item", label: "Item" }, { key: "qty", label: "Qty", align: "right" }, { key: "supplier", label: "Supplier" }, { key: "lead", label: "Lead time" }, { key: "moq", label: "MOQ", align: "right" }],
|
|
65
|
+
rows: buy.rows,
|
|
66
|
+
note: `${u.kind === "root" ? "Every bought line of the whole product, with the order quantity (spares and packs included)" : "What this unit uses, for all its builds; order it once for the product on system/ordering-list.csv (the line numbers)"}. Supplier, lead time and minimum order come from the offers and sourcing records the project holds; "unknown" means none states it (record offers with ext-sourcing or protoboard parts offers).`,
|
|
67
|
+
});
|
|
68
|
+
} else blocks.push({ kind: "paragraph", text: "Nothing is bought for this unit alone." });
|
|
69
|
+
const included = rows.filter((r) => r.action === "included");
|
|
70
|
+
if (included.length) blocks.push({ kind: "paragraph", text: `Not ordered separately (ships with another part): ${[...new Set(included.map((r) => `${r.item} (with ${includedWith(p.lookup(r.definition)!)})`))].join("; ")}.` });
|
|
71
|
+
|
|
72
|
+
// ---- fabrication orders
|
|
73
|
+
blocks.push({ kind: "heading", text: "Fabrication orders" });
|
|
74
|
+
const bound = unitModules(p, u);
|
|
75
|
+
const fab = aggregate(rows.filter((r) => r.action === "fabricate"), builds);
|
|
76
|
+
// the unit's own module, when a provider designs it (a printed housing that is the unit): it is made here too
|
|
77
|
+
const ownBound = u.kind !== "root" ? bound.find((b) => b.module === u.definition) : undefined;
|
|
78
|
+
if (ownBound && !fab.some((r) => r.definition === u.definition)) {
|
|
79
|
+
const def = p.lookup(u.definition)!;
|
|
80
|
+
fab.unshift({ level: 0, path: "", item: def.name ?? def.id, definition: def.id, kind: "part", action: "fabricate", manufacturer: def.manufacturer ?? "", mpn: "", value: "", package: "", qty_per_unit: 1, qty_per_product: u.perProduct, tier: "project", origin: "", note: ownBound.process ?? (ownBound.capability === "circuit" ? "board" : ownBound.capability), qty: builds } as UnitBomRow & { qty: number });
|
|
81
|
+
}
|
|
82
|
+
if (fab.length) {
|
|
83
|
+
blocks.push({
|
|
84
|
+
kind: "table",
|
|
85
|
+
columns: [{ key: "item", label: "Part" }, { key: "qty", label: "Qty", align: "right" }, { key: "process", label: "Process" }, { key: "details", label: "Order details" }, { key: "files", label: "Files" }],
|
|
86
|
+
rows: fab.map((r) => {
|
|
87
|
+
const b = bound.find((x) => x.module === r.definition);
|
|
88
|
+
const by = b && s.contributors.includes(b.provider) && b.provider !== RELEASE ? `below, from ${b.provider}` : r.kind === "cable" ? (files.some((f) => f.path.endsWith(`/files/harnesses/${r.definition}/cut-list.csv`)) ? "cut list (files)" : "as the assembly guide shows (no cut list in the model)") : "—";
|
|
89
|
+
return { item: r.item, qty: r.qty, process: r.note || r.kind, details: by, files: filesFor(files, r.definition) || "—" };
|
|
90
|
+
}),
|
|
91
|
+
note: "Quantities are for every build of this unit, without spares; the ordering list adds the spares for purchased parts. Send each fabricator the files listed and the order details.",
|
|
92
|
+
});
|
|
93
|
+
} else blocks.push({ kind: "paragraph", text: "No custom parts are made for this unit." });
|
|
94
|
+
|
|
95
|
+
// ---- operations
|
|
96
|
+
const ops = operations(s, rows, lines, plan.steps, tests);
|
|
97
|
+
blocks.push({ kind: "heading", text: "Operations" });
|
|
98
|
+
blocks.push({ kind: "paragraph", text: `${ops.length} operations, in order. Assembly operations follow the assembly guide's steps (same order and parts; its figures show each one). Each ends at an inspection point and names the test that follows.` });
|
|
99
|
+
blocks.push({ kind: "steps", steps: ops });
|
|
100
|
+
|
|
101
|
+
// ---- kitting
|
|
102
|
+
const kitRows: (Record<string, Cell> | { group: string })[] = [];
|
|
103
|
+
for (const op of ops.filter((o) => o.kit.length)) {
|
|
104
|
+
kitRows.push({ group: `${op.id} · ${op.title}` });
|
|
105
|
+
for (const k of op.kit) kitRows.push({ item: k.item, qty: k.qty, from: k.from });
|
|
106
|
+
}
|
|
107
|
+
if (kitRows.length) {
|
|
108
|
+
blocks.push({ kind: "heading", text: "Kitting" });
|
|
109
|
+
blocks.push({ kind: "table", columns: [{ key: "item", label: "Item" }, { key: "qty", label: "Qty / build", align: "right" }, { key: "from", label: "From" }], rows: kitRows, caption: "Kit per operation", note: "Pick one kit per build before starting. Hardware is counted per operation; spares are on the ordering list." });
|
|
110
|
+
}
|
|
111
|
+
return { blocks, ...(diagnostics.length ? { diagnostics } : {}) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checklist(root: boolean, rows: UnitBomRow[], lines: OrderLine[], builds: number) {
|
|
115
|
+
// by definition and MPN: a series passive placed at two values is two lines
|
|
116
|
+
const key = (x: { definition: string; mpn: string }) => `${x.definition}\u0000${x.mpn}`;
|
|
117
|
+
const need = new Map<string, number>();
|
|
118
|
+
for (const r of rows.filter((r) => r.action === "buy")) need.set(key(r), (need.get(key(r)) ?? 0) + r.qty_per_unit * builds);
|
|
119
|
+
const chosen = lines.filter((l) => l.action === "buy" && (root || need.has(key(l))));
|
|
120
|
+
const unknown = (v: unknown, f: (x: never) => string) => (v === null || v === undefined || v === "" ? "unknown" : f(v as never));
|
|
121
|
+
return {
|
|
122
|
+
rows: chosen.map((l) => ({
|
|
123
|
+
done: "☐",
|
|
124
|
+
line: l.line,
|
|
125
|
+
item: `${l.item}${l.value ? `, ${l.value}` : ""}${l.mpn ? ` (${l.mpn})` : ""}`,
|
|
126
|
+
qty: root ? l.order : need.get(key(l))!,
|
|
127
|
+
supplier: l.supplier || "unknown",
|
|
128
|
+
lead: unknown(l.lead_time_weeks, (x: number) => `${x} wk`),
|
|
129
|
+
moq: unknown(l.moq, (x: number) => String(x)),
|
|
130
|
+
})),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Rows of one definition summed, quantities for `builds` builds. */
|
|
135
|
+
function aggregate(rows: UnitBomRow[], builds: number) {
|
|
136
|
+
const by = new Map<string, UnitBomRow & { qty: number }>();
|
|
137
|
+
for (const r of rows) {
|
|
138
|
+
const x = by.get(r.definition) ?? { ...r, qty: 0 };
|
|
139
|
+
x.qty += r.qty_per_unit * builds;
|
|
140
|
+
by.set(r.definition, x);
|
|
141
|
+
}
|
|
142
|
+
return [...by.values()];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const filesFor = (files: SectionContext["files"], module: string) =>
|
|
146
|
+
[...new Set(files.filter((f) => f.path.includes(`/files/`) && f.path.split("/").includes(module)).map((f) => f.path.slice(0, f.path.indexOf(module) + module.length) + "/"))].map((d) => d.slice(d.indexOf("files/"))).join(", ");
|
|
147
|
+
|
|
148
|
+
function operations(s: SectionContext, rows: UnitBomRow[], lines: OrderLine[], steps: PlannedStep[], tests: UnitTests): Op[] {
|
|
149
|
+
const { project: p, unit: u, units } = s;
|
|
150
|
+
const ops: Op[] = [];
|
|
151
|
+
const next = () => String((ops.length + 1) * 10);
|
|
152
|
+
const lineOf = (def: string) => lines.find((l) => l.definition === def);
|
|
153
|
+
const source = (def: string): string => {
|
|
154
|
+
const r = rows.find((x) => x.definition === def);
|
|
155
|
+
const asUnit = u.members.find((m) => m.unit && m.definition === def);
|
|
156
|
+
if (asUnit) return `unit ${asUnit.unit} (units/${asUnit.unit}/)`;
|
|
157
|
+
if (!r) {
|
|
158
|
+
const sub = units.find((x) => x.id !== u.id && unitBom(p, x).some((y) => y.definition === def) && u.members.some((m) => m.unit === x.id));
|
|
159
|
+
return sub ? `in unit ${sub.id} (${sub.folder}/)` : "—";
|
|
160
|
+
}
|
|
161
|
+
if (r.action === "fabricate") return "fabrication order";
|
|
162
|
+
if (r.action === "included") return `with ${includedWith(p.lookup(def)!)}`;
|
|
163
|
+
const l = lineOf(def);
|
|
164
|
+
return l ? `ordering list line ${l.line}` : "ordering list";
|
|
165
|
+
};
|
|
166
|
+
const fai = [...tests.fai.values()].flat();
|
|
167
|
+
const subs = [...new Set(u.members.filter((m) => m.unit).map((m) => m.unit!))];
|
|
168
|
+
|
|
169
|
+
// ---- kit and incoming inspection
|
|
170
|
+
ops.push({
|
|
171
|
+
id: next(),
|
|
172
|
+
title: "Kit and inspect incoming parts",
|
|
173
|
+
actions: [
|
|
174
|
+
"Pick one kit per build from the kitting table below.",
|
|
175
|
+
"Check purchased parts on receipt against bom.csv: part numbers, quantities, no damage.",
|
|
176
|
+
...(subs.length ? [`Receive the finished sub-units: ${subs.map((x) => `${x} (units/${x}/), tested to its own test plan`).join(", ")}.`] : []),
|
|
177
|
+
...(fai.length ? ["First-article inspection of the parts designed in the project (made, or cut and routed here), before any is used."] : []),
|
|
178
|
+
],
|
|
179
|
+
// bought parts no assembly step uses (a housing's inserts, set in its print order; not a board's components): kitted here
|
|
180
|
+
kit: aggregate(rows.filter((r) => r.action === "buy" && r.kind !== "component" && !steps.some((st) => st.parts.some((pt) => pt.def.id === r.definition))), 1).map((r) => ({ item: r.item, qty: r.qty, from: source(r.definition) })),
|
|
181
|
+
...(fai.length ? { tools: ["calipers", "pin gauges or the matching fasteners"] } : {}),
|
|
182
|
+
inspect: fai.length ? "every made part passes first-article inspection; purchased parts match their part numbers" : "purchased parts match their part numbers",
|
|
183
|
+
test: fai.length ? idRanges(fai) : "none",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---- boards
|
|
187
|
+
for (const b of unitModules(p, u).filter((x) => x.capability === "circuit")) {
|
|
188
|
+
const def = p.definitions.get(b.module!)!.def;
|
|
189
|
+
const bu = tests.bringup.length ? `${tests.bringup[0]}…${tests.bringup[tests.bringup.length - 1]} (bring-up)` : "first-article inspection";
|
|
190
|
+
ops.push({
|
|
191
|
+
id: next(),
|
|
192
|
+
title: `Assemble the ${def.name ?? def.id}`,
|
|
193
|
+
actions: [
|
|
194
|
+
`Assemble the board as its fabrication order says: by the fabricator (pnp.csv, bom.csv) or by hand (paste, place, reflow), ${def.children?.length ?? 0} components.`,
|
|
195
|
+
"Clean flux residue; hand-solder any part the order leaves for hand assembly.",
|
|
196
|
+
],
|
|
197
|
+
parts: [{ name: def.name ?? def.id, quantity: 1, ref: def.id }],
|
|
198
|
+
tools: ["reflow oven or hot-air station", "soldering iron", "magnifier"],
|
|
199
|
+
materials: ["solder paste", "solder and flux"],
|
|
200
|
+
inspect: "every component present and oriented as pnp.csv; joints wetted, no bridges (AOI or magnifier)",
|
|
201
|
+
test: bu,
|
|
202
|
+
trace: [def.id],
|
|
203
|
+
kit: [{ item: def.name ?? def.id, qty: 1, from: "fabrication order (bare or assembled board)" }],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- cables to cut and prepare before assembly
|
|
208
|
+
const seen = new Set<string>();
|
|
209
|
+
for (const r of rows.filter((x) => x.kind === "cable")) {
|
|
210
|
+
if (seen.has(r.definition)) continue;
|
|
211
|
+
seen.add(r.definition);
|
|
212
|
+
const def = p.lookup(r.definition)!;
|
|
213
|
+
if (!isCable(def)) continue;
|
|
214
|
+
const cut = cutList(def);
|
|
215
|
+
const c0 = cut[0];
|
|
216
|
+
const needsCut = c0?.length_mm && c0.supplied_length_mm && c0.length_mm < c0.supplied_length_mm;
|
|
217
|
+
if (r.action !== "fabricate" && !needsCut) continue;
|
|
218
|
+
const qty = rows.filter((x) => x.definition === r.definition).reduce((t, x) => t + x.qty_per_unit, 0);
|
|
219
|
+
const step = steps.find((st) => st.plan.wiring.some((w) => w.harness && p.instances.find((i) => i.path === w.harness)?.definition === def.id));
|
|
220
|
+
ops.push({
|
|
221
|
+
id: next(),
|
|
222
|
+
title: `${r.action === "fabricate" ? "Make" : "Prepare"} the ${def.name ?? def.id}${qty > 1 ? ` (× ${qty})` : ""}`,
|
|
223
|
+
actions: [
|
|
224
|
+
// the cut list is packaged (files/harnesses/<id>/cut-list.csv) whenever the model gives one
|
|
225
|
+
c0?.length_mm
|
|
226
|
+
? `Cut to ${c0.length_mm} mm${c0.supplied_length_mm ? ` (supplied ${c0.supplied_length_mm} mm)` : ""}, as files/harnesses/${def.id}/cut-list.csv.`
|
|
227
|
+
: c0
|
|
228
|
+
? `Cut as files/harnesses/${def.id}/cut-list.csv.`
|
|
229
|
+
: "Cut and prepare as the assembly guide shows (the model states no cut list).",
|
|
230
|
+
...(c0?.strip_mm ? [`Strip ${c0.strip_mm} mm at each soldered end and tin.`] : []),
|
|
231
|
+
...(c0?.lay ? [`Lay: ${c0.lay}.`] : []),
|
|
232
|
+
...(cut.length > 1 ? [`Keep the conductor order of the cut list (${cut.length} conductors).`] : []),
|
|
233
|
+
],
|
|
234
|
+
parts: [{ name: def.name ?? def.id, quantity: qty, ref: def.id }],
|
|
235
|
+
tools: ["side cutters", "wire stripper", "rule", ...(c0?.strip_mm ? ["soldering iron"] : [])],
|
|
236
|
+
...(c0?.strip_mm ? { materials: ["solder and flux"] } : {}),
|
|
237
|
+
inspect: `length and strip lengths as the cut list; no nicked strands`,
|
|
238
|
+
test: (tests.fai.get(def.id)?.length ? idRanges(tests.fai.get(def.id)!) : "") || (step ? `${tests.joint.get(step.n)} after it is fitted (op for step ${step.n})` : "continuity when fitted"),
|
|
239
|
+
trace: [def.id],
|
|
240
|
+
kit: [{ item: def.name ?? def.id, qty, from: source(def.id) }],
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- assembly steps (the assembly guide's order), up to the ones that follow the test
|
|
245
|
+
const assembly = (st: PlannedStep): Op => ({
|
|
246
|
+
id: next(),
|
|
247
|
+
title: st.title,
|
|
248
|
+
actions: [`Assembly guide step ${st.n}.`, ...st.actions],
|
|
249
|
+
parts: st.parts.map((pt) => ({ name: pt.def.name ?? pt.def.id, quantity: pt.count, ref: pt.def.id })),
|
|
250
|
+
...(st.tools.length ? { tools: st.tools } : {}),
|
|
251
|
+
...(st.materials.length ? { materials: st.materials } : {}),
|
|
252
|
+
...(st.torque ? { torque: st.torque } : {}),
|
|
253
|
+
inspect: inspection(st),
|
|
254
|
+
test: tests.joint.get(st.n)!,
|
|
255
|
+
trace: st.trace,
|
|
256
|
+
...(st.afterTest ? { notes: [{ level: "caution" as const, text: u.kind === "root" ? `This step follows the bench test and configuration (${st.afterTest}).` : `This step must follow the product's bench test (${st.afterTest}), but this unit is built before final assembly; check the order with the system work order.` }] } : {}),
|
|
257
|
+
kit: st.parts.map((pt) => ({ item: pt.def.name ?? pt.def.id, qty: pt.count, from: source(pt.def.id) })),
|
|
258
|
+
});
|
|
259
|
+
const later = steps.filter((st) => st.afterTest);
|
|
260
|
+
for (const st of steps.filter((x) => !x.afterTest)) ops.push(assembly(st));
|
|
261
|
+
|
|
262
|
+
// ---- bench test, with the contributed configuration inside it: power first, then configure, then the checks that need it
|
|
263
|
+
const fn = tests.items.filter((t) => t.group === "function").map((t) => t.id);
|
|
264
|
+
const bu = tests.bringup;
|
|
265
|
+
const power = bu.filter((id) => tests.items.find((t) => t.id === id)?.phase === "power");
|
|
266
|
+
const running = bu.filter((id) => !power.includes(id));
|
|
267
|
+
const configure = s.operations.filter((o) => o.stage === "configure");
|
|
268
|
+
const span = (ids: string[], dash = "–") => (ids.length ? (ids.length === 1 ? ids[0] : `${ids[0]}${dash}${ids[ids.length - 1]}`) : "");
|
|
269
|
+
const benchTools = (ids: string[]) => (ids.length ? { tools: ["current-limited bench supply", "multimeter", ...(tests.items.some((t) => ids.includes(t.id) && t.check === "Bus scan") ? ["I2C bus adapter or the firmware's scan"] : [])] } : {});
|
|
270
|
+
const laterNote = later.length ? [{ level: "caution" as const, text: `${later.map((x) => `"${x.title}"`).join(" and ")} come${later.length === 1 ? "s" : ""} after this test and any configuration: not before.` }] : [];
|
|
271
|
+
const record = "Record every result in files/test/test-record.csv; mark the unit with the release version and a serial number.";
|
|
272
|
+
const what = u.kind === "root" ? "the product" : "the unit";
|
|
273
|
+
// functional checks need the finished product: after the steps that follow the test when there are any
|
|
274
|
+
const fnHere = later.length ? [] : fn;
|
|
275
|
+
if (configure.length && power.length) {
|
|
276
|
+
ops.push({
|
|
277
|
+
id: next(),
|
|
278
|
+
title: `Power up ${what}`,
|
|
279
|
+
actions: [`Bring-up ${span(power, "…")}: ${coverage(tests, power)}.`],
|
|
280
|
+
...benchTools(power),
|
|
281
|
+
inspect: "no short, no current limit hit; every rail at its design voltage",
|
|
282
|
+
test: span(power),
|
|
283
|
+
notes: laterNote,
|
|
284
|
+
kit: [],
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
for (const o of configure) {
|
|
288
|
+
const { stage: _stage, extension, ...step } = o;
|
|
289
|
+
ops.push({ ...step, id: next(), trace: [...(step.trace ?? []), `contributed by ${extension} (details below)`], kit: [] });
|
|
290
|
+
}
|
|
291
|
+
const testIds = configure.length && power.length ? running : bu;
|
|
292
|
+
// a unit with nothing to bring up and steps after the test is checked once, at the end
|
|
293
|
+
if (testIds.length || configure.length || fnHere.length || !later.length) {
|
|
294
|
+
ops.push({
|
|
295
|
+
id: next(),
|
|
296
|
+
title: `Test ${what}`,
|
|
297
|
+
actions: [
|
|
298
|
+
...(testIds.length ? [`Bring-up ${span(testIds, "…")}: ${coverage(tests, testIds)}.`] : []),
|
|
299
|
+
...(configure.length ? ["Then the bench checks that follow the configuration (test plan, after bring-up)."] : []),
|
|
300
|
+
...(fnHere.length ? [`Functional checks ${fnHere[0]}…${fnHere[fnHere.length - 1]}, one per requirement.`] : []),
|
|
301
|
+
...(later.length ? [] : [record]),
|
|
302
|
+
],
|
|
303
|
+
...benchTools(testIds),
|
|
304
|
+
inspect: later.length ? "every bench check passed and recorded" : "every check in the test plan passed and recorded",
|
|
305
|
+
test: [span(testIds), span(fnHere)].filter(Boolean).join(", ") || "the joint checks above",
|
|
306
|
+
...(later.length && !(configure.length && power.length) ? { notes: laterNote } : {}),
|
|
307
|
+
kit: [],
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---- the steps that follow the test, then the final check
|
|
312
|
+
for (const st of later) ops.push(assembly(st));
|
|
313
|
+
if (later.length) {
|
|
314
|
+
ops.push({
|
|
315
|
+
id: next(),
|
|
316
|
+
title: u.kind === "root" ? "Final check" : "Final check of the unit",
|
|
317
|
+
actions: [
|
|
318
|
+
`Check the work of ${later.map((x) => `"${x.title}"`).join(" and ")} against the assembly guide${later.some((x) => x.perBuild) ? ", each build against its position" : ""}.`,
|
|
319
|
+
...(fn.length ? [`Functional checks ${fn[0]}…${fn[fn.length - 1]}, one per requirement, on the finished ${u.kind === "root" ? "product" : "unit"}.`] : []),
|
|
320
|
+
record,
|
|
321
|
+
],
|
|
322
|
+
inspect: "nothing loose; every check in the test plan passed and recorded",
|
|
323
|
+
test: span(fn) || later.map((x) => tests.joint.get(x.n)).join(", "),
|
|
324
|
+
kit: [],
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return ops.map((o) => ({ ...o, id: `OP ${o.id}` }));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** What a run of bring-up checks covers, in plan order, from their check names ("before power, power up, rails, …"). */
|
|
331
|
+
function coverage(tests: UnitTests, ids: string[]): string {
|
|
332
|
+
const kinds: [RegExp, string][] = [
|
|
333
|
+
[/^Before power/, "before power"],
|
|
334
|
+
[/^Power up/, "power up current-limited"],
|
|
335
|
+
[/^Rail /, "rails"],
|
|
336
|
+
[/^Current/, "current"],
|
|
337
|
+
[/programming|boot/i, "programming and boot"],
|
|
338
|
+
[/^Bus scan/, "bus scans"],
|
|
339
|
+
[/^Smoke test/, "smoke tests"],
|
|
340
|
+
];
|
|
341
|
+
const checks = tests.items.filter((t) => ids.includes(t.id)).map((t) => t.check);
|
|
342
|
+
const out = kinds.filter(([re]) => checks.some((c) => re.test(c))).map(([, label]) => label);
|
|
343
|
+
return out.length ? out.join(", ") : "the checks listed in the test plan";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Test ids as ranges: FAI-1, FAI-2, FAI-3, BU-1 → "FAI-1…FAI-3, BU-1". */
|
|
347
|
+
export function idRanges(ids: string[]): string {
|
|
348
|
+
const out: string[] = [];
|
|
349
|
+
let run: { prefix: string; from: number; to: number } | undefined;
|
|
350
|
+
const flush = () => run && out.push(run.from === run.to ? `${run.prefix}-${run.from}` : `${run.prefix}-${run.from}…${run.prefix}-${run.to}`);
|
|
351
|
+
for (const id of ids) {
|
|
352
|
+
const m = /^(.*)-(\d+)$/.exec(id);
|
|
353
|
+
if (m && run && run.prefix === m[1] && Number(m[2]) === run.to + 1) run.to++;
|
|
354
|
+
else {
|
|
355
|
+
flush();
|
|
356
|
+
run = m ? { prefix: m[1], from: Number(m[2]), to: Number(m[2]) } : undefined;
|
|
357
|
+
if (!m) out.push(id);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
flush();
|
|
361
|
+
return out.join(", ");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function inspection(st: PlannedStep): string {
|
|
365
|
+
if (st.kind === "mate") return `parts seated flush, no gap${st.torque ? "; every fastener at torque and witness-marked" : ""}`;
|
|
366
|
+
if (st.kind === "place") return "the part sits as the figure shows and is secured";
|
|
367
|
+
if (st.kind === "prepare") return "every pin soldered, no bridges; strips flush and square";
|
|
368
|
+
return st.solder ? "joints wetted and concave, no bridges; conductor order as the step's table" : "connectors fully seated and latched; cables clear of moving parts";
|
|
369
|
+
}
|
package/lib/testing.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing a section contributor without a release
|
|
3
|
+
* (`@protoboardai/ext-release/testing`): the units of a project and the
|
|
4
|
+
* context the release passes a `package:<section>` entry, with a figure
|
|
5
|
+
* function that records the specs instead of rendering them.
|
|
6
|
+
*/
|
|
7
|
+
import type { Project, ReleaseConfig } from "@protoboardai/core";
|
|
8
|
+
import type { RenderSpec } from "@protoboardai/ext-docs/renderer";
|
|
9
|
+
import { collectUnitFiles } from "./collect.ts";
|
|
10
|
+
import { SECTIONS, type PackageFile, type RenderedFigure, type SectionContext, type SectionId, type Unit } from "./types.ts";
|
|
11
|
+
import { resolveUnits } from "./units.ts";
|
|
12
|
+
|
|
13
|
+
export interface TestContexts {
|
|
14
|
+
units: Unit[];
|
|
15
|
+
/** Figures asked for so far, by name. */
|
|
16
|
+
figures: Map<string, RenderSpec>;
|
|
17
|
+
files: PackageFile[];
|
|
18
|
+
/** The context for one unit (by id) and section; `operations` as other extensions would contribute them to its work order. */
|
|
19
|
+
context: (unit: string, section: SectionId, contributors?: string[], operations?: SectionContext["operations"]) => SectionContext;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function testContexts(p: Project, config: ReleaseConfig = {}, version = "0.0.0-test"): TestContexts {
|
|
23
|
+
const { units, diagnostics } = resolveUnits(p, config.units ?? []);
|
|
24
|
+
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
25
|
+
if (errors.length) throw new Error(errors.map((d) => d.message).join("; "));
|
|
26
|
+
const files = units.flatMap((u) => collectUnitFiles(p, u));
|
|
27
|
+
const figures = new Map<string, RenderSpec>();
|
|
28
|
+
const figure = async (name: string, spec: RenderSpec): Promise<RenderedFigure> => {
|
|
29
|
+
figures.set(name, spec);
|
|
30
|
+
return { file: `assets/images/${name}.png`, width: spec.width, height: spec.height, anchors: Object.fromEntries((spec.anchors ?? []).map((a, i) => [a.id, { x: 10 + i, y: 20 + i }])) };
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
units,
|
|
34
|
+
figures,
|
|
35
|
+
files,
|
|
36
|
+
context: (id, section, contributors = ["@protoboardai/ext-release"], operations = []) => {
|
|
37
|
+
const unit = units.find((u) => u.id === id);
|
|
38
|
+
if (!unit) throw new Error(`no unit ${id} (units: ${units.map((u) => u.id).join(", ")})`);
|
|
39
|
+
if (!SECTIONS.includes(section)) throw new Error(`no section ${section}`);
|
|
40
|
+
return { project: p, section, unit, units, release: { version, date: "2026-01-01", build: config.build ?? 1, config }, files: files.filter((f) => f.unit === unit.id), contributors, operations, figure, log: () => {} };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/lib/types.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Manufacturing Package's contracts (docs/specs/release-package-and-evals.md
|
|
3
|
+
* §2–§3): package units, the section contract through which extensions
|
|
4
|
+
* contribute content, and the release manifest.
|
|
5
|
+
*
|
|
6
|
+
* An extension contributes to a section by declaring an operation
|
|
7
|
+
* `package:<section>` (effect `pure`) whose entry is a `SectionFn`. The
|
|
8
|
+
* release calls it once per unit and renders what it returns; extensions never
|
|
9
|
+
* render whole documents.
|
|
10
|
+
*/
|
|
11
|
+
import type { Project, ReleaseConfig } from "@protoboardai/core";
|
|
12
|
+
import type { RenderSpec } from "@protoboardai/ext-docs/renderer";
|
|
13
|
+
|
|
14
|
+
export const RELEASE_SCHEMA = "protoboard.release/v0";
|
|
15
|
+
|
|
16
|
+
/** Section ids, in document order within a unit. */
|
|
17
|
+
export const SECTIONS = ["spec", "bom", "work-order", "assembly", "test-plan"] as const;
|
|
18
|
+
export type SectionId = (typeof SECTIONS)[number];
|
|
19
|
+
|
|
20
|
+
/** The document each section becomes in a unit folder. */
|
|
21
|
+
export const SECTION_DOCS: Record<SectionId, { file: string; title: string; code: string }> = {
|
|
22
|
+
spec: { file: "spec-sheet", title: "Spec sheet", code: "SPEC" },
|
|
23
|
+
bom: { file: "bom", title: "Bill of materials", code: "BOM" },
|
|
24
|
+
"work-order": { file: "work-order", title: "Work order", code: "WO" },
|
|
25
|
+
assembly: { file: "assembly-guide", title: "Assembly guide", code: "AG" },
|
|
26
|
+
"test-plan": { file: "test-plan", title: "Test and validation plan", code: "TP" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One folder of the package: the root system, or a module the operator
|
|
31
|
+
* named in `release.units`. Paths are instance paths in the system.
|
|
32
|
+
*/
|
|
33
|
+
export interface Unit {
|
|
34
|
+
/** Folder name: "system" for the root, otherwise the entry as written ("/" becomes "."). */
|
|
35
|
+
id: string;
|
|
36
|
+
/** Package-relative folder: "system" or "units/<id>". */
|
|
37
|
+
folder: string;
|
|
38
|
+
kind: "root" | "instance" | "module";
|
|
39
|
+
/** The `release.units` entry ("" for the root). */
|
|
40
|
+
entry: string;
|
|
41
|
+
name: string;
|
|
42
|
+
definition: string;
|
|
43
|
+
/** Instance paths built as this unit ("" for the root). */
|
|
44
|
+
instances: string[];
|
|
45
|
+
/** The instance whose subtree describes one build of the unit. */
|
|
46
|
+
representative: string;
|
|
47
|
+
/** Builds of this unit per product (instance quantities multiplied through the tree). */
|
|
48
|
+
perProduct: number;
|
|
49
|
+
/** Unit ids the unit is assembled into (empty for the root). */
|
|
50
|
+
parents: string[];
|
|
51
|
+
/**
|
|
52
|
+
* What one build contains, relative to the representative instance: the
|
|
53
|
+
* instances folded into it (not inside a sub-unit), with their quantity per
|
|
54
|
+
* build of this unit. A sub-unit's instance is listed with `unit` set and
|
|
55
|
+
* nothing below it.
|
|
56
|
+
*/
|
|
57
|
+
members: UnitMember[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UnitMember {
|
|
61
|
+
/** Instance path relative to the unit's representative instance. */
|
|
62
|
+
path: string;
|
|
63
|
+
/** Full instance path (of the representative build). */
|
|
64
|
+
instance: string;
|
|
65
|
+
parent: string;
|
|
66
|
+
definition: string;
|
|
67
|
+
name: string;
|
|
68
|
+
/** UHD `ModuleKind`; a `board` (UHD 0.3.0, one PCB) behaves as a `module`. */
|
|
69
|
+
kind: "module" | "group" | "harness" | "board";
|
|
70
|
+
/** Quantity of this instance per build of the unit (its own and its ancestors' within the unit). */
|
|
71
|
+
quantity: number;
|
|
72
|
+
/** Set when this instance is another unit, built in its own folder. */
|
|
73
|
+
unit?: string;
|
|
74
|
+
depth: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Section content: structured, rendered by the release into PDF and HTML
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export type Cell = string | number | null | undefined;
|
|
82
|
+
|
|
83
|
+
export interface Column {
|
|
84
|
+
key: string;
|
|
85
|
+
label: string;
|
|
86
|
+
align?: "left" | "right";
|
|
87
|
+
/** Keep the cell on one line (ids, part numbers). */
|
|
88
|
+
nowrap?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type Block =
|
|
92
|
+
| { kind: "heading"; text: string }
|
|
93
|
+
/** `keep`: stays on the page of the block after it (an introduction to a table). */
|
|
94
|
+
| { kind: "paragraph"; text: string; keep?: boolean }
|
|
95
|
+
| { kind: "list"; items: string[]; ordered?: boolean }
|
|
96
|
+
| { kind: "keyvalues"; rows: { label: string; value: string; note?: string }[] }
|
|
97
|
+
| { kind: "figures"; items: { label: string; value: string; sub?: string }[] }
|
|
98
|
+
| { kind: "table"; columns: Column[]; rows: (Record<string, Cell> | { group: string })[]; caption?: string; note?: string }
|
|
99
|
+
| { kind: "note"; level: "tip" | "caution" | "warning"; text: string }
|
|
100
|
+
| { kind: "steps"; steps: Step[] }
|
|
101
|
+
/**
|
|
102
|
+
* An image (PNG): a figure the release rendered (`SectionContext.figure`) or
|
|
103
|
+
* a file the contribution supplies; `file` is package-relative. `callouts`
|
|
104
|
+
* are lettered bubbles with leaders to points of the image (pixels).
|
|
105
|
+
*/
|
|
106
|
+
| { kind: "image"; file: string; caption: string; width?: number; height?: number; callouts?: Callout[]; size?: "small" | "full" }
|
|
107
|
+
/** A grid of items to lay out at the bench, each with a thumbnail when it has one. */
|
|
108
|
+
| { kind: "kit"; items: KitItem[]; note?: string };
|
|
109
|
+
|
|
110
|
+
export interface Callout {
|
|
111
|
+
x: number;
|
|
112
|
+
y: number;
|
|
113
|
+
label: string;
|
|
114
|
+
accent?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface KitItem {
|
|
118
|
+
name: string;
|
|
119
|
+
quantity: number;
|
|
120
|
+
/** Definition id or instance path, printed small. */
|
|
121
|
+
ref?: string;
|
|
122
|
+
/** Package path of a thumbnail (a rendered figure). */
|
|
123
|
+
image?: string;
|
|
124
|
+
/** Shown instead of an image ("cable", "CAD file not available"). */
|
|
125
|
+
placeholder?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface Step {
|
|
129
|
+
title: string;
|
|
130
|
+
/** Operation or step id shown before the title ("OP-3"); default: the step's number. */
|
|
131
|
+
id?: string;
|
|
132
|
+
/** What to do, in order. */
|
|
133
|
+
actions?: string[];
|
|
134
|
+
/** Parts used in this step; `letter` matches a callout of the figure, `image` is a thumbnail. */
|
|
135
|
+
parts?: { name: string; quantity: number; ref?: string; letter?: string; accent?: boolean; image?: string }[];
|
|
136
|
+
/** The step's figure (a rendered figure: `SectionContext.figure`). */
|
|
137
|
+
figure?: { file: string; width: number; height: number; caption?: string; callouts?: Callout[] };
|
|
138
|
+
/** A table under the actions (connections of a wiring step). */
|
|
139
|
+
table?: { columns: Column[]; rows: Record<string, Cell>[] };
|
|
140
|
+
tools?: string[];
|
|
141
|
+
materials?: string[];
|
|
142
|
+
torque?: string;
|
|
143
|
+
inspect?: string;
|
|
144
|
+
/** The test step that follows. */
|
|
145
|
+
test?: string;
|
|
146
|
+
notes?: { level: "tip" | "caution" | "warning"; text: string }[];
|
|
147
|
+
/** Model references the step covers (link ids, instance paths), shown as a trace line. */
|
|
148
|
+
trace?: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A file a contribution adds to the unit folder. */
|
|
152
|
+
export interface SectionFile {
|
|
153
|
+
/**
|
|
154
|
+
* Unit-relative destination: under `files/` for manufacturing files
|
|
155
|
+
* (`files/firmware/cli.txt`), or a unit-level data file (`bom.csv`).
|
|
156
|
+
*/
|
|
157
|
+
path: string;
|
|
158
|
+
/** Project-relative source file to copy… */
|
|
159
|
+
from?: string;
|
|
160
|
+
/** …or the content to write. */
|
|
161
|
+
content?: string | Buffer;
|
|
162
|
+
/** What it is for (manifest role): fabrication, assembly, firmware-config, purchasing, model, test. */
|
|
163
|
+
role?: string;
|
|
164
|
+
description?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SectionDiagnostic {
|
|
168
|
+
code: string;
|
|
169
|
+
severity: "error" | "warning" | "info";
|
|
170
|
+
message: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** What a `package:<section>` entry returns for one unit. */
|
|
174
|
+
export interface SectionContribution {
|
|
175
|
+
/** Part title within the section ("Flight controller configuration"); omitted for the section's main body. */
|
|
176
|
+
title?: string;
|
|
177
|
+
/** Sort key within the section (lower first; the release's own baseline is 0). */
|
|
178
|
+
order?: number;
|
|
179
|
+
blocks: Block[];
|
|
180
|
+
files?: SectionFile[];
|
|
181
|
+
/**
|
|
182
|
+
* Work order only: operations for the unit's numbered sequence, which the
|
|
183
|
+
* release's own work order places and numbers (OP 10, 20, …) with the rest.
|
|
184
|
+
* Details stay in this contribution's blocks.
|
|
185
|
+
*/
|
|
186
|
+
operations?: ContributedOperation[];
|
|
187
|
+
/** Findings: an error stops the release (e.g. a contributed file that is stale). */
|
|
188
|
+
diagnostics?: SectionDiagnostic[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* An operation another extension adds to a unit's work order.
|
|
193
|
+
* `configure`: after the assembly steps and before the unit's bench test
|
|
194
|
+
* (flash firmware, load a configuration), so before the steps that must
|
|
195
|
+
* follow the test (propellers, a battery strapped on for use).
|
|
196
|
+
*/
|
|
197
|
+
export interface ContributedOperation extends Step {
|
|
198
|
+
stage: "configure";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface SectionContext {
|
|
202
|
+
project: Project;
|
|
203
|
+
section: SectionId;
|
|
204
|
+
unit: Unit;
|
|
205
|
+
units: Unit[];
|
|
206
|
+
release: { version: string; date: string; build: number; config: ReleaseConfig };
|
|
207
|
+
/** The unit's collected manufacturing files (package paths), known before sections run. */
|
|
208
|
+
files: PackageFile[];
|
|
209
|
+
/**
|
|
210
|
+
* Extensions contributing to this section, in call order (the release's own
|
|
211
|
+
* first). A baseline can step aside when a richer contributor is present.
|
|
212
|
+
*/
|
|
213
|
+
contributors: string[];
|
|
214
|
+
/**
|
|
215
|
+
* Operations the other contributors add to this unit's work order, for the
|
|
216
|
+
* release's own work order to number (it runs after them). Empty in the
|
|
217
|
+
* other sections.
|
|
218
|
+
*/
|
|
219
|
+
operations: (ContributedOperation & { extension: string })[];
|
|
220
|
+
/**
|
|
221
|
+
* Render a figure with the release's renderer (headless Chrome, one per
|
|
222
|
+
* release) into the package's assets/images/<name>.png. Served GLB paths are
|
|
223
|
+
* `project/<project-relative path>` (ext-docs' server). The same name with
|
|
224
|
+
* the same spec returns the first result; with another spec it throws.
|
|
225
|
+
*/
|
|
226
|
+
figure: (name: string, spec: RenderSpec) => Promise<RenderedFigure>;
|
|
227
|
+
log: (line: string) => void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A figure in the package: `file` is its package path; anchors are the spec's anchors in image pixels. */
|
|
231
|
+
export interface RenderedFigure {
|
|
232
|
+
file: string;
|
|
233
|
+
width: number;
|
|
234
|
+
height: number;
|
|
235
|
+
anchors: Record<string, { x: number; y: number }>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A `package:<section>` entry. Returning undefined or no blocks contributes nothing to this unit. */
|
|
239
|
+
export type SectionFn = (ctx: SectionContext) => SectionContribution | SectionContribution[] | undefined | Promise<SectionContribution | SectionContribution[] | undefined>;
|
|
240
|
+
|
|
241
|
+
/** A file in the package, before it is written. */
|
|
242
|
+
export interface PackageFile {
|
|
243
|
+
/** Package-relative path (forward slashes). */
|
|
244
|
+
path: string;
|
|
245
|
+
unit?: string;
|
|
246
|
+
role: string;
|
|
247
|
+
/** Project-relative source, when copied. */
|
|
248
|
+
from?: string;
|
|
249
|
+
content: Buffer;
|
|
250
|
+
description?: string;
|
|
251
|
+
}
|