@protoboardai/ext-release 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The release manifest, `protoboard.release/v0` (release-package-and-evals.md
3
+ * §3): what was released and from what. Everything in it is derived from
4
+ * committed inputs, never from the clock or the machine, so rebuilding the
5
+ * same release in a clean environment gives the same bytes.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { FRAMEWORK_VERSION, stableStringify, uhdVersion, type Project } from "@protoboardai/core";
9
+ import type { Freshness } from "./preconditions.ts";
10
+ import { exportKey } from "./preconditions.ts";
11
+ import { RELEASE_SCHEMA, type PackageFile, type Unit } from "./types.ts";
12
+ import { byCode, sha256 } from "./util.ts";
13
+
14
+ export interface ManifestFile {
15
+ path: string;
16
+ digest: string;
17
+ bytes: number;
18
+ unit?: string;
19
+ role: string;
20
+ /** Project-relative source of a copied file. */
21
+ from?: string;
22
+ }
23
+
24
+ export interface ReleaseManifest {
25
+ schema: typeof RELEASE_SCHEMA;
26
+ project: { name: string; root: string };
27
+ version: string;
28
+ /** Release date: SOURCE_DATE_EPOCH, else the source commit's date. */
29
+ date: string;
30
+ source: { commit: string; dirty: string[] } | null;
31
+ framework: { protoboard: string; uhd: { version?: string; revision?: string } };
32
+ extensions: { id: string; version: string }[];
33
+ tools: { id: string; detail?: string }[];
34
+ /** Font files the documents were printed with (they change the PDFs' bytes). */
35
+ fonts: { file: string; digest: string }[];
36
+ build: number;
37
+ units: { id: string; folder: string; kind: Unit["kind"]; entry: string; definition: string; name: string; instances: string[]; perProduct: number; parents: string[] }[];
38
+ model: { bundle: string; digest: string };
39
+ parts: { id: string; tier: string; kind: string; source: string; provider?: string; service?: string; revision?: string | number; digest: string; envelopeDigest?: string; instances: number }[];
40
+ outputs: {
41
+ module: string;
42
+ capability: string;
43
+ provider: { id: string; version: string };
44
+ config: string;
45
+ configDigest: string;
46
+ moduleDigest?: string;
47
+ record: string;
48
+ projectDigest: string;
49
+ tools: { id: string; detail?: string }[];
50
+ files: { path: string; digest: string }[];
51
+ packaged: string[];
52
+ }[];
53
+ /** Project files other operations generated that the package carries (firmware configuration, …). */
54
+ generated: { path: string; digest: string; operation: string; extension: string; record?: string; packaged: string[]; role: string }[];
55
+ firmware: { path: string; digest: string; from?: string }[];
56
+ check: { errors: number; warnings: number; info: number; findings: { code: string; severity: string; message: string; validator: string }[]; validators: { validator: string; subject?: string; status: string; reason?: string }[] };
57
+ sections: { unit: string; section: string; contributors: string[] }[];
58
+ /** Every file in the package except this manifest. */
59
+ files: ManifestFile[];
60
+ /** sha256 over the canonical `files` list. */
61
+ packageDigest: string;
62
+ }
63
+
64
+ export interface ManifestInputs {
65
+ p: Project;
66
+ version: string;
67
+ date: string;
68
+ source: ReleaseManifest["source"];
69
+ build: number;
70
+ units: Unit[];
71
+ bundle: PackageFile;
72
+ fresh: Freshness;
73
+ files: PackageFile[];
74
+ tools: { id: string; detail?: string }[];
75
+ fonts: { file: string; digest: string }[];
76
+ sections: ReleaseManifest["sections"];
77
+ }
78
+
79
+ export function buildManifest(m: ManifestInputs): ReleaseManifest {
80
+ const { p } = m;
81
+ const uhd = uhdVersion(p.root);
82
+ const used = new Map<string, number>();
83
+ for (const i of p.instances) used.set(i.definition, (used.get(i.definition) ?? 0) + 1);
84
+ const files: ManifestFile[] = m.files
85
+ .map((f) => ({ path: f.path, digest: sha256(f.content), bytes: f.content.length, ...(f.unit ? { unit: f.unit } : {}), role: f.role, ...(f.from ? { from: f.from } : {}) }))
86
+ .sort((a, b) => byCode(a.path, b.path));
87
+ const packagedFrom = (from: string) => files.filter((f) => f.from === from).map((f) => f.path);
88
+ const outputs: ReleaseManifest["outputs"] = [];
89
+ for (const b of [...p.capabilities].filter((b) => b.module && !b.problem).sort((a, b) => byCode(a.module!, b.module!))) {
90
+ const rec = m.fresh.exports.get(exportKey(b));
91
+ if (!rec) continue;
92
+ const own = rec.record.outputs.filter((o) => o.path.startsWith(b.out + "/")).sort((x, y) => byCode(x.path, y.path));
93
+ outputs.push({
94
+ module: b.module!,
95
+ capability: b.capability,
96
+ provider: rec.record.extension,
97
+ config: b.config,
98
+ configDigest: b.configDigest,
99
+ ...(rec.record.capability?.moduleDigest ? { moduleDigest: rec.record.capability.moduleDigest } : {}),
100
+ record: rec.file,
101
+ projectDigest: rec.record.projectDigest,
102
+ tools: rec.record.tools.map((t) => ({ id: t.id, ...(t.detail ? { detail: t.detail } : {}) })),
103
+ files: own.map((o) => ({ path: o.path, digest: o.digest })),
104
+ packaged: own.flatMap((o) => packagedFrom(o.path)),
105
+ });
106
+ }
107
+ const capOut = new Set(outputs.flatMap((o) => o.files.map((f) => f.path)));
108
+ const generated: ReleaseManifest["generated"] = [];
109
+ for (const from of [...new Set(files.filter((f) => f.from && !capOut.has(f.from)).map((f) => f.from!))].sort(byCode)) {
110
+ const w = m.fresh.writers.get(from);
111
+ const f = files.find((x) => x.from === from)!;
112
+ generated.push({ path: from, digest: f.digest, operation: w?.record.operation ?? "", extension: w?.record.extension.id ?? "", ...(w ? { record: w.file } : {}), packaged: packagedFrom(from), role: f.role });
113
+ }
114
+ const findings = p.diagnostics.filter((d) => d.severity !== "info").map((d) => ({ code: d.code, severity: d.severity, message: d.message, validator: d.validator }));
115
+ const count = (s: string) => p.diagnostics.filter((d) => d.severity === s).length;
116
+ const manifest: Omit<ReleaseManifest, "packageDigest"> = {
117
+ schema: RELEASE_SCHEMA,
118
+ project: { name: p.name, root: p.rootDef!.id },
119
+ version: m.version,
120
+ date: m.date,
121
+ source: m.source,
122
+ framework: { protoboard: FRAMEWORK_VERSION, uhd: { ...(uhd.version ? { version: uhd.version } : {}), ...(uhd.revision ? { revision: uhd.revision } : {}) } },
123
+ extensions: p.extensions.map((e) => ({ id: e.manifest.id, version: e.manifest.version })).sort((a, b) => byCode(a.id, b.id)),
124
+ tools: m.tools,
125
+ fonts: m.fonts,
126
+ build: m.build,
127
+ units: m.units.map((u) => ({ id: u.id, folder: u.folder, kind: u.kind, entry: u.entry, definition: u.definition, name: u.name, instances: u.instances, perProduct: u.perProduct, parents: u.parents })),
128
+ model: { bundle: m.bundle.path, digest: sha256(m.bundle.content) },
129
+ parts: [...used.keys()]
130
+ .sort(byCode)
131
+ .map((id) => p.definitions.get(id))
132
+ .filter((r) => !!r)
133
+ .map((r) => ({
134
+ id: r!.def.id,
135
+ tier: r!.origin.tier,
136
+ kind: r!.def.kind ?? "module",
137
+ source: r!.origin.source.file,
138
+ ...(r!.origin.provider ? { provider: r!.origin.provider } : {}),
139
+ ...(r!.origin.service ? { service: r!.origin.service } : {}),
140
+ ...(r!.origin.revision !== undefined ? { revision: r!.origin.revision } : {}),
141
+ digest: r!.origin.digest ?? sha256(stableStringify(r!.def)),
142
+ ...(r!.origin.envelopeDigest ? { envelopeDigest: r!.origin.envelopeDigest } : {}),
143
+ instances: used.get(r!.def.id)!,
144
+ })),
145
+ outputs,
146
+ generated,
147
+ firmware: files.filter((f) => f.role === "firmware-config").map((f) => ({ path: f.path, digest: f.digest, ...(f.from ? { from: f.from } : {}) })),
148
+ check: {
149
+ errors: count("error"),
150
+ warnings: count("warning"),
151
+ info: count("info"),
152
+ findings,
153
+ validators: p.checks.map((c) => ({ validator: c.validator, ...(c.subject ? { subject: c.subject } : {}), status: c.status, ...(c.reason ? { reason: c.reason } : {}) })),
154
+ },
155
+ sections: m.sections,
156
+ files,
157
+ };
158
+ return { ...manifest, packageDigest: "sha256:" + createHash("sha256").update(stableStringify(files)).digest("hex") };
159
+ }
package/lib/offers.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Ordering data, offline only. A release never calls a service; it reads what
3
+ * the project holds. Which offer a line is quoted from, and its price, are
4
+ * the bill of materials' (bom.ts, ext-sourcing `quoteHeldLines`); this
5
+ * module reads the rest: the sourcing record's lifecycle and alternates,
6
+ * the pack the model states, and a listing the model cites.
7
+ *
8
+ * 1. catalog offers vendored by `protoboard parts offers` into
9
+ * `parts/offers.json` (`protoboard.offers/v0`: the parts service's
10
+ * offers by part id, prices by source, with the service and retrieval
11
+ * time);
12
+ * 2. sourcing evidence, `parts/evidence/<id>/sourcing.json`
13
+ * (@protoboardai/ext-sourcing: dated offers with price breaks, lifecycle,
14
+ * alternates);
15
+ * 3. a supplier listing the model itself cites (a `usage_note` trait whose
16
+ * note starts "Supplier listing:" and whose source is the listing URL):
17
+ * where to buy, without price or stock.
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import type { ModuleDef } from "@deltarobotics/uhd";
22
+ import { OFFERS_FILE, OFFERS_SCHEMA, type Project } from "@protoboardai/core";
23
+ import type { OffersFile } from "@protoboardai/core";
24
+ import { readProjectOffers } from "@protoboardai/core/offers";
25
+ import type { Offer, SourcingRecord } from "@protoboardai/ext-sourcing/lib/sourcing.ts";
26
+
27
+ export type { OffersFile } from "@protoboardai/core";
28
+ type CatalogOffer = OffersFile["parts"][string][number];
29
+
30
+ export interface Sourcing {
31
+ /** Offers from every offline source, in the sourcing record's shape. */
32
+ offers: (Offer & { origin: "catalog" | "sourcing" })[];
33
+ record?: SourcingRecord;
34
+ recordFile?: string;
35
+ /** A listing the model cites, when no offer exists. */
36
+ listing?: { supplier: string; url: string };
37
+ /** Units per pack the model states (performance trait pack_quantity). */
38
+ packQuantity?: number;
39
+ }
40
+
41
+ export function readOffersFile(root: string): OffersFile | undefined {
42
+ const f = join(root, OFFERS_FILE);
43
+ if (!existsSync(f)) return undefined;
44
+ const data = JSON.parse(readFileSync(f, "utf8")) as OffersFile;
45
+ if (data.schema !== OFFERS_SCHEMA) throw new Error(`${OFFERS_FILE} is not ${OFFERS_SCHEMA}`);
46
+ return data;
47
+ }
48
+
49
+ /**
50
+ * A catalog offer in the sourcing record's shape. An offer states the MPN it
51
+ * is for (a series part's offers are per value); one vendored before MPNs
52
+ * were required is taken to be for the definition's own MPN.
53
+ */
54
+ const catalogOffer = (o: CatalogOffer, defMpn: string): Offer & { origin: "catalog" } => {
55
+ const currency = o.currency;
56
+ const breaks = currency
57
+ ? o.priceBreaks?.length
58
+ ? o.priceBreaks.map((b) => ({ quantity: b.quantity, unit_price: b.unitPrice, currency }))
59
+ : typeof o.unitPrice === "number"
60
+ ? [{ quantity: o.minQuantity ?? 1, unit_price: o.unitPrice, currency }]
61
+ : []
62
+ : [];
63
+ return {
64
+ origin: "catalog",
65
+ supplier: o.supplier,
66
+ ...(o.sku ? { supplier_sku: o.sku } : {}),
67
+ mpn: o.mpn || defMpn,
68
+ // the listing's link; the library says nothing about how it fetched a price, and a file vendored before that is read the same way
69
+ url: o.url ?? "",
70
+ as_of: o.observedAt,
71
+ via: "api",
72
+ ...(o.authorized !== undefined ? { authorized: o.authorized } : {}),
73
+ stock: o.stock ?? null,
74
+ moq: o.minQuantity ?? null,
75
+ ...(o.packaging ? { packaging: o.packaging } : {}),
76
+ price_breaks: breaks,
77
+ lead_time_weeks: o.leadTimeWeeks ?? null,
78
+ ...(o.market ? { market: o.market } : {}),
79
+ note: "catalog offer",
80
+ };
81
+ };
82
+
83
+ /**
84
+ * Everything the project holds about buying `def`. With `placementMpn` (one
85
+ * value of a series passive, whose placements differ in MPN), only offers
86
+ * and a sourcing record for that MPN count: each catalog offer names the MPN
87
+ * it is for (one vendored without one counts for the definition's own).
88
+ */
89
+ export function sourcingFor(p: Project, def: ModuleDef, catalog?: OffersFile, placementMpn?: string): Sourcing {
90
+ const mpn = def.part_number ?? "";
91
+ const catalogOffers: Sourcing["offers"] = (catalog?.parts[def.id] ?? []).map((o) => catalogOffer(o, mpn));
92
+ // the sourcing record through core's one offer reader (PB-1033), which also says the precedence:
93
+ // a project part's own evidence first, a library part's library offers first
94
+ const held = readProjectOffers(p.root, { catalog: p.config.catalog }).forPart(def.id);
95
+ let record: SourcingRecord | undefined = held.record?.data as SourcingRecord | undefined;
96
+ let recordFile: string | undefined = held.record?.file;
97
+ const own: Sourcing["offers"] = (record?.offers ?? []).map((o) => ({ ...o, origin: "sourcing" as const }));
98
+ const offers: Sourcing["offers"] = held.library || !own.length ? [...catalogOffers, ...own] : [...own, ...catalogOffers];
99
+ let listing: Sourcing["listing"];
100
+ for (const t of def.traits ?? []) {
101
+ const params = (t.params ?? {}) as Record<string, unknown>;
102
+ const note = typeof params.note === "string" ? params.note : "";
103
+ const src = Array.isArray(params.source) ? params.source[0] : params.source;
104
+ if (t.type === "usage_note" && note.startsWith("Supplier listing:") && typeof src === "string") {
105
+ const host = (() => {
106
+ try {
107
+ return new URL(src).hostname.replace(/^www\./, "");
108
+ } catch {
109
+ return src;
110
+ }
111
+ })();
112
+ listing = { supplier: host, url: src };
113
+ break;
114
+ }
115
+ }
116
+ const pack = (def.traits ?? []).map((t) => (t.params as Record<string, unknown> | undefined)?.pack_quantity).find((v) => typeof v === "number" && Number.isInteger(v) && v > 1) as number | undefined;
117
+ const same = (a: string, b: string) => a.trim().toUpperCase() === b.trim().toUpperCase();
118
+ const placed = placementMpn ? offers.filter((o) => same(o.mpn, placementMpn)) : offers;
119
+ // the record's lifecycle and alternates are about its own MPN
120
+ if (placementMpn && record && !same(record.mpn, placementMpn)) record = undefined;
121
+ if (!record && !placed.some((o) => o.origin === "sourcing")) recordFile = undefined;
122
+ return { offers: placed, ...(record ? { record } : {}), ...(recordFile ? { recordFile } : {}), ...(listing ? { listing } : {}), ...(pack ? { packQuantity: pack } : {}) };
123
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * What must hold before a release is written (release-package-and-evals.md §1):
3
+ * `protoboard check` reports no errors, and nothing generated is stale against
4
+ * its sources. Staleness is decided from evidence, never assumed:
5
+ *
6
+ * - providers' checks: any finding whose code ends in `_STALE` (warning or
7
+ * error), and a provider check that was skipped (its result is unknown);
8
+ * - every bound module has been exported, and its out/ is exactly what the
9
+ * latest export record lists (same files, same digests), made from the
10
+ * current config and, when the record has it, the current module;
11
+ * - every file an operation record wrote is still the bytes it wrote
12
+ * (latest record per file; files since deleted are not checked here;
13
+ * earlier packages under release/ are not inputs, so not checked);
14
+ * - the source tree: a git work tree with uncommitted changes outside
15
+ * release/ and operations/ is refused unless `allowDirty` (recorded).
16
+ */
17
+ import { execFileSync } from "node:child_process";
18
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
19
+ import { join, relative } from "node:path";
20
+ import { listOperationRecords, moduleDigest, type CapabilityBinding, type OperationRecord, type Project } from "@protoboardai/core";
21
+ import { posix, sha256 } from "./util.ts";
22
+ import type { SectionDiagnostic } from "./types.ts";
23
+
24
+ export interface Freshness {
25
+ /** `<capability>:<module>` (exportKey) → its latest succeeded export record. */
26
+ exports: Map<string, { file: string; record: OperationRecord }>;
27
+ /** Project file → the latest record that wrote it. */
28
+ writers: Map<string, { file: string; record: OperationRecord }>;
29
+ diagnostics: SectionDiagnostic[];
30
+ }
31
+
32
+ const listFiles = (abs: string, base = abs): string[] =>
33
+ !existsSync(abs) ? [] : readdirSync(abs).flatMap((f) => (statSync(join(abs, f)).isDirectory() ? listFiles(join(abs, f), base) : [posix(relative(base, join(abs, f)))]));
34
+
35
+ /** Records, newest last, with the outputs each one wrote. */
36
+ export function freshness(p: Project): Freshness {
37
+ const diagnostics: SectionDiagnostic[] = [];
38
+ const stale = (message: string) => diagnostics.push({ code: "REL_STALE", severity: "error", message });
39
+ const records = listOperationRecords(p).filter((r) => r.record.status === "succeeded");
40
+ const writers = new Map<string, { file: string; record: OperationRecord }>();
41
+ for (const r of records) for (const o of r.record.outputs) writers.set(o.path, r);
42
+
43
+ // ---- every recorded output is still the bytes its latest writer wrote
44
+ // (not the release's own packages: a release replaces its folder, and nothing reads them back)
45
+ for (const [path, r] of writers) {
46
+ if (r.record.operation === "release" && path.startsWith("release/")) continue;
47
+ const abs = join(p.root, path);
48
+ if (!existsSync(abs)) continue;
49
+ const out = r.record.outputs.find((o) => o.path === path)!;
50
+ if (sha256(readFileSync(abs)) !== out.digest) stale(`${path} was changed after ${r.record.operation} wrote it (${r.file}); re-run the operation instead of editing generated files`);
51
+ }
52
+
53
+ // ---- every bound module: exported, by the current config and module, and out/ untouched.
54
+ // Two capabilities may share a module's out/ (a board's circuit and firmware exports): its
55
+ // files are then checked against the latest export of each capability together.
56
+ const exports = new Map<string, { file: string; record: OperationRecord }>();
57
+ const byOut = new Map<string, { b: CapabilityBinding; rec: { file: string; record: OperationRecord } }[]>();
58
+ for (const b of p.capabilities) {
59
+ if (b.problem || !b.module) continue;
60
+ const rerun = `protoboard run ${b.capability}:export --module ${b.module}`;
61
+ const rec = [...records].reverse().find((r) => r.record.operation === `${b.capability}:export` && r.record.capability?.module === b.module);
62
+ if (!rec) {
63
+ stale(`${b.module} has no recorded ${b.capability}:export (${b.out}/ cannot be traced to a run); run \`${rerun}\``);
64
+ continue;
65
+ }
66
+ exports.set(exportKey(b), rec);
67
+ byOut.set(b.out, [...(byOut.get(b.out) ?? []), { b, rec }]);
68
+ const cap = rec.record.capability!;
69
+ if (cap.config !== b.config || cap.configDigest !== b.configDigest) stale(`${b.module}: ${b.config} changed since the last export (${rec.file}); run \`${rerun}\``);
70
+ if (cap.provider !== providerId(p, b)) stale(`${b.module}: the last export was made by ${cap.provider}, the config now names ${b.provider}; run \`${rerun}\``);
71
+ const def = p.definitions.get(b.module)?.def;
72
+ if (cap.moduleDigest && def && cap.moduleDigest !== moduleDigest(def, p.lookup)) stale(`${b.module} changed since its last export (${rec.file}); run \`protoboard run ${b.capability}:generate --module ${b.module}\` and export`);
73
+ }
74
+ for (const [out, rs] of byOut) {
75
+ const recorded = new Map(rs.flatMap(({ rec }) => rec.record.outputs.filter((o) => o.path.startsWith(out + "/")).map((o) => [o.path, rec] as const)));
76
+ const present = listFiles(join(p.root, out)).map((f) => `${out}/${f}`);
77
+ const names = rs.map(({ b, rec }) => `${b.capability}: ${rec.file}`).join("; ");
78
+ const reruns = rs.map(({ b }) => `\`protoboard run ${b.capability}:export --module ${b.module}\``).join(" or ");
79
+ for (const f of present.filter((f) => !recorded.has(f))) stale(`${f} is not from the last export of ${rs[0].b.module} (${names}); remove it or run ${reruns}`);
80
+ for (const [f, rec] of [...recorded].filter(([f]) => !present.includes(f))) stale(`${f} from the last export of ${rs[0].b.module} is missing (${rec.file}); run ${reruns}`);
81
+ }
82
+ return { exports, writers, diagnostics };
83
+ }
84
+
85
+ /** Key of `Freshness.exports`: one export per capability of a module. */
86
+ export const exportKey = (b: Pick<CapabilityBinding, "capability" | "module">) => `${b.capability}:${b.module}`;
87
+
88
+ const providerId = (p: Project, b: CapabilityBinding) => p.extensions.find((e) => e.package === b.provider)?.manifest.id ?? b.provider;
89
+
90
+ /** Git state of the project: commit and whether design inputs have uncommitted changes. */
91
+ export function gitState(root: string, ignore: string[]): { commit: string; date: string; dirty: string[] } | undefined {
92
+ const git = (...args: string[]) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
93
+ try {
94
+ if (git("rev-parse", "--is-inside-work-tree").trim() !== "true") return undefined;
95
+ // a project inside an ignored folder of another repository is not under version control
96
+ try {
97
+ git("check-ignore", "-q", "protoboard.config.ts");
98
+ return undefined;
99
+ } catch {
100
+ // not ignored
101
+ }
102
+ const commit = git("rev-parse", "HEAD").trim();
103
+ const date = git("log", "-1", "--format=%cI", "HEAD").trim();
104
+ const top = git("rev-parse", "--show-toplevel").trim();
105
+ const prefix = posix(relative(top, root));
106
+ const dirty = git("status", "--porcelain", "--untracked-files=all", "--", ".")
107
+ .split("\n")
108
+ .filter(Boolean)
109
+ .map((l) => l.slice(3).replace(/^"|"$/g, "").split(" -> ").pop()!)
110
+ .map((f) => (prefix ? posix(relative(prefix, f)) : f))
111
+ .filter((f) => !ignore.some((i) => f === i || f.startsWith(i + "/")));
112
+ return { commit, date, dirty };
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Everything that stops a release, as errors; plus the check summary
120
+ * findings worth keeping (warnings), which the manifest records.
121
+ */
122
+ export function preconditions(p: Project, fresh: Freshness): SectionDiagnostic[] {
123
+ const out: SectionDiagnostic[] = [];
124
+ const errors = p.diagnostics.filter((d) => d.severity === "error");
125
+ if (errors.length)
126
+ out.push({
127
+ code: "REL_CHECK_ERRORS",
128
+ severity: "error",
129
+ message: `protoboard check reports ${errors.length} error(s); fix them first: ${errors
130
+ .slice(0, 5)
131
+ .map((d) => `${d.code} ${d.message}`)
132
+ .join(" · ")}${errors.length > 5 ? " · …" : ""}`,
133
+ });
134
+ // stale standard outputs are the freshness check's own findings below, with the same evidence (the records)
135
+ for (const d of p.diagnostics.filter((d) => /_STALE$/.test(d.code) && d.severity !== "info" && d.code !== "PB_OUTPUT_STALE")) out.push({ code: "REL_STALE", severity: "error", message: `${d.code} (${d.validator}): ${d.message}` });
136
+ for (const c of p.checks.filter((c) => c.status === "skipped" && /\/(circuit|cad):check$/.test(c.validator)))
137
+ out.push({ code: "REL_FRESHNESS_UNKNOWN", severity: "error", message: `${c.validator}${c.subject ? ` [${c.subject}]` : ""} was skipped (${c.reason}); without it the module's outputs cannot be shown to be current` });
138
+ for (const d of p.diagnostics.filter((d) => d.code === "PB_OUTPUT_NOT_EXPORTED")) out.push({ code: "REL_NOT_EXPORTED", severity: "error", message: d.message });
139
+ out.push(...fresh.diagnostics);
140
+ return out;
141
+ }
package/lib/product.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The package's first page (PB-935): what the device is, its features, what
3
+ * it does and what it must meet, each requirement with how it is shown,
4
+ * from the project's own statement (`docs/product.json`, and the brief's
5
+ * requirements table; @protoboardai/core readProduct). What the project has
6
+ * not stated is said plainly on the page, never filled in, and reported by
7
+ * `release --check` as a warning.
8
+ */
9
+ import { PRODUCT_FILE, PRODUCT_PART_TEXT, type ProductStatement } from "@protoboardai/core";
10
+ import type { Block, SectionDiagnostic } from "./types.ts";
11
+
12
+ const HOW_TO_STATE = `State it in ${PRODUCT_FILE} (the system-compose skill, step 1).`;
13
+
14
+ /** The warnings a release reports about the statement. */
15
+ export function productDiagnostics(product: ProductStatement): SectionDiagnostic[] {
16
+ const out: SectionDiagnostic[] = [];
17
+ if (product.problems.length) out.push({ code: "REL_PRODUCT_INVALID", severity: "warning", message: `${PRODUCT_FILE} is not used: ${product.problems.join("; ")}` });
18
+ if (product.missing.length) {
19
+ const what = product.missing.map((k) => ({ summary: "what the device is (summary)", features: "its features", functions: "the system's functions", requirements: "its requirements" })[k]);
20
+ out.push({
21
+ code: "REL_PRODUCT_NOT_STATED",
22
+ severity: "warning",
23
+ message: `the package's first page cannot say ${what.join(", ")}: ${product.file ? `${PRODUCT_FILE} does not state ${what.length === 1 ? "it" : "them"}` : `the project has no ${PRODUCT_FILE}`}. The page says so instead; nothing is made up. ${HOW_TO_STATE}`,
24
+ });
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** The first page's blocks. `description` is the root module's own, shown when no summary is stated. */
30
+ export function productBlocks(product: ProductStatement, description?: string): Block[] {
31
+ const out: Block[] = [];
32
+ const missing = (k: keyof typeof PRODUCT_PART_TEXT): Block => ({ kind: "note", level: "caution", text: `${PRODUCT_PART_TEXT[k].missing} ${HOW_TO_STATE}` });
33
+ out.push({ kind: "heading", text: PRODUCT_PART_TEXT.summary.title });
34
+ if (product.summary) out.push({ kind: "paragraph", text: product.summary });
35
+ else {
36
+ if (description) out.push({ kind: "paragraph", text: description });
37
+ out.push(missing("summary"));
38
+ }
39
+ out.push({ kind: "heading", text: PRODUCT_PART_TEXT.features.title });
40
+ out.push(product.features.length ? { kind: "list", items: product.features } : missing("features"));
41
+ out.push({ kind: "heading", text: PRODUCT_PART_TEXT.functions.title });
42
+ if (product.functions.length) {
43
+ const by = product.functions.some((f) => f.by);
44
+ out.push({
45
+ kind: "table",
46
+ columns: [{ key: "id", label: "ID" }, { key: "text", label: "Function" }, ...(by ? [{ key: "by", label: "Done by" }] : [])],
47
+ rows: product.functions.map((f) => ({ id: f.id ?? "", text: f.text, by: f.by ?? "" })),
48
+ ...(product.functionsFrom && product.functionsFrom !== PRODUCT_FILE ? { note: `From ${product.functionsFrom}.` } : {}),
49
+ });
50
+ } else out.push(missing("functions"));
51
+ out.push({ kind: "heading", text: PRODUCT_PART_TEXT.requirements.title });
52
+ if (product.requirements.length) {
53
+ const pass = product.requirements.some((r) => r.pass);
54
+ const ids = product.requirements.some((r) => r.id);
55
+ const unshown = product.requirements.filter((r) => !r.how).length;
56
+ out.push({
57
+ kind: "table",
58
+ columns: [...(ids ? [{ key: "id", label: "ID" }] : []), { key: "text", label: "Requirement" }, { key: "how", label: "How it is shown" }, ...(pass ? [{ key: "pass", label: "Pass" }] : [])],
59
+ rows: product.requirements.map((r) => ({ id: r.id ?? "", text: r.text, how: r.how ?? "Not stated", pass: r.pass ?? "" })),
60
+ note: `From ${product.requirementsFrom}. The system test plan carries one functional check per requirement.${unshown ? ` ${unshown} of ${product.requirements.length} do not say how they are shown; the test plan's method for those follows their wording.` : ""}`,
61
+ });
62
+ } else out.push(missing("requirements"));
63
+ return out;
64
+ }