@alphazede/bearing-lite 0.2.2 → 1.0.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.
- package/CONTRIBUTING.md +11 -6
- package/README.md +224 -131
- package/com.github.copilot/hooks/hooks.json +33 -0
- package/hooks/assurance-budget.cjs +33 -11
- package/hooks/com.anthropic.claude-code/mapping.md +35 -16
- package/hooks/plan-package.cjs +5 -4
- package/hooks/policy.cjs +10 -4
- package/hooks/profiles.cjs +119 -0
- package/hooks/te-host.cjs +40 -5
- package/hooks/transition-order.cjs +12 -4
- package/hooks/verification.cjs +358 -0
- package/package.json +6 -3
- package/plugin.json +2 -34
- package/profiles.json +1 -0
- package/schemas/implementation.schema.json +204 -5
- package/schemas/journey.schema.json +3 -3
- package/schemas/profiles.schema.json +359 -0
- package/schemas/verification.schema.json +126 -0
- package/skills/{set-bearings → architectural-alignment}/SKILL.md +17 -13
- package/skills/{set-bearings → architectural-alignment}/templates/workspace.md +1 -1
- package/skills/bearing-lite/SKILL.md +22 -22
- package/skills/bearing-lite/references/assurance-policy.md +32 -16
- package/skills/bearing-lite/references/lineups.md +7 -110
- package/skills/bearing-lite/references/owner-stops.md +13 -13
- package/skills/bearing-lite/references/peer-synthesis.md +11 -12
- package/skills/bearing-lite/references/profiles.md +149 -0
- package/skills/bearing-lite/references/resume.md +30 -0
- package/skills/bearing-lite/references/review-policy.md +3 -3
- package/skills/bearing-lite/references/role-routing.mmd +14 -14
- package/skills/bearing-lite/references/task-state.md +10 -10
- package/skills/bearing-lite/references/task-state.mmd +2 -2
- package/skills/bearing-lite/references/verification.md +52 -0
- package/skills/bearing-lite/templates/task.md +29 -28
- package/skills/{explorer → coordinator}/SKILL.md +16 -16
- package/skills/{crewmate → implementer}/SKILL.md +17 -15
- package/skills/{repository-fit → intake}/SKILL.md +10 -9
- package/skills/integration-engineer/SKILL.md +17 -12
- package/skills/light-implementer/SKILL.md +6 -5
- package/skills/onboard-bearing/SKILL.md +46 -0
- package/skills/plan-integrator/SKILL.md +14 -14
- package/skills/{map-the-route → planning-and-design}/SKILL.md +27 -27
- package/skills/{map-the-route → planning-and-design}/references/artifact-grammar.md +29 -27
- package/skills/prompt/SKILL.md +245 -0
- package/skills/requirements-engineer/SKILL.md +11 -11
- package/skills/{park-ranger → reviewer}/SKILL.md +16 -13
- package/skills/{gather-supplies → scope-definition}/SKILL.md +13 -13
- package/skills/scribe/SKILL.md +8 -8
- package/skills/systems-modeler/SKILL.md +5 -3
- package/skills/test-engineer/SKILL.md +16 -13
- package/templates/dod-manifest-v1.html +648 -0
- package/tools/render-dod-manifest.mjs +1743 -0
- package/lineups.json +0 -1
- package/schemas/lineups.schema.json +0 -145
- package/skills/navigator/SKILL.md +0 -36
- package/skills/surveyor/SKILL.md +0 -48
- package/skills/validator/SKILL.md +0 -37
- package/skills/validator/references/grading-rubric.md +0 -40
|
@@ -0,0 +1,1743 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic Definition of Done Manifest renderer (DES-BDL-009, DEC-BDL-045).
|
|
4
|
+
* Consumes implementation.json.dod_manifest. Never authors model semantics
|
|
5
|
+
* or fills absent evidence. Node.js standard library only.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
export const TEMPLATE_VERSION = "1.0.1";
|
|
13
|
+
export const TEMPLATE_RELATIVE = "templates/dod-manifest-v1.html";
|
|
14
|
+
|
|
15
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
16
|
+
const MAX_SVG_BYTES = 512 * 1024;
|
|
17
|
+
const MAX_PNG_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
const SECTIONS = Object.freeze([
|
|
20
|
+
{ id: "s1", title: "BLUF and Lifecycle state" },
|
|
21
|
+
{ id: "s2", title: "Source, baseline, candidate, and template identity" },
|
|
22
|
+
{ id: "s3", title: "Outcome, scope, exclusions, and Definition of Done" },
|
|
23
|
+
{
|
|
24
|
+
id: "s4",
|
|
25
|
+
title:
|
|
26
|
+
"Requirements, architecture, design, interfaces, model inventory, and embedded model views",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "s5",
|
|
30
|
+
title: "Development strategy, task graph, roles, dependencies, and write sets",
|
|
31
|
+
},
|
|
32
|
+
{ id: "s6", title: "V&V cases, assurance cadence, evidence, and pass/fail status" },
|
|
33
|
+
{ id: "s7", title: "Documentation impact and completion" },
|
|
34
|
+
{
|
|
35
|
+
id: "s8",
|
|
36
|
+
title: "Risks, gaps, exceptions, anomaly handling, rollback, and recovery",
|
|
37
|
+
},
|
|
38
|
+
{ id: "s9", title: "Authority, owner decisions, approvals, and closeout status" },
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const SVG_ALLOWED_TAGS = new Set([
|
|
42
|
+
"svg",
|
|
43
|
+
"g",
|
|
44
|
+
"path",
|
|
45
|
+
"rect",
|
|
46
|
+
"circle",
|
|
47
|
+
"ellipse",
|
|
48
|
+
"line",
|
|
49
|
+
"polyline",
|
|
50
|
+
"polygon",
|
|
51
|
+
"text",
|
|
52
|
+
"tspan",
|
|
53
|
+
"defs",
|
|
54
|
+
"marker",
|
|
55
|
+
"title",
|
|
56
|
+
"desc",
|
|
57
|
+
"use",
|
|
58
|
+
"symbol",
|
|
59
|
+
"clippath",
|
|
60
|
+
"lineargradient",
|
|
61
|
+
"radialgradient",
|
|
62
|
+
"stop",
|
|
63
|
+
"image",
|
|
64
|
+
"style",
|
|
65
|
+
"mask",
|
|
66
|
+
"pattern",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const SVG_ALLOWED_ATTRS = new Set([
|
|
70
|
+
"id",
|
|
71
|
+
"class",
|
|
72
|
+
"viewbox",
|
|
73
|
+
"width",
|
|
74
|
+
"height",
|
|
75
|
+
"x",
|
|
76
|
+
"y",
|
|
77
|
+
"dx",
|
|
78
|
+
"dy",
|
|
79
|
+
"x1",
|
|
80
|
+
"y1",
|
|
81
|
+
"x2",
|
|
82
|
+
"y2",
|
|
83
|
+
"cx",
|
|
84
|
+
"cy",
|
|
85
|
+
"r",
|
|
86
|
+
"rx",
|
|
87
|
+
"ry",
|
|
88
|
+
"d",
|
|
89
|
+
"points",
|
|
90
|
+
"fill",
|
|
91
|
+
"stroke",
|
|
92
|
+
"stroke-width",
|
|
93
|
+
"stroke-linejoin",
|
|
94
|
+
"stroke-linecap",
|
|
95
|
+
"stroke-dasharray",
|
|
96
|
+
"fill-opacity",
|
|
97
|
+
"stroke-opacity",
|
|
98
|
+
"opacity",
|
|
99
|
+
"transform",
|
|
100
|
+
"text-anchor",
|
|
101
|
+
"font-family",
|
|
102
|
+
"font-size",
|
|
103
|
+
"font-weight",
|
|
104
|
+
"xml:space",
|
|
105
|
+
"xmlns",
|
|
106
|
+
"role",
|
|
107
|
+
"aria-labelledby",
|
|
108
|
+
"aria-label",
|
|
109
|
+
"aria-hidden",
|
|
110
|
+
"preserveaspectratio",
|
|
111
|
+
"markerwidth",
|
|
112
|
+
"markerheight",
|
|
113
|
+
"markerunits",
|
|
114
|
+
"orient",
|
|
115
|
+
"refx",
|
|
116
|
+
"refy",
|
|
117
|
+
"gradientunits",
|
|
118
|
+
"offset",
|
|
119
|
+
"stop-color",
|
|
120
|
+
"stop-opacity",
|
|
121
|
+
"clip-path",
|
|
122
|
+
"mask",
|
|
123
|
+
"display",
|
|
124
|
+
"overflow",
|
|
125
|
+
"vector-effect",
|
|
126
|
+
"fill-rule",
|
|
127
|
+
"clip-rule",
|
|
128
|
+
"marker-end",
|
|
129
|
+
"marker-start",
|
|
130
|
+
"marker-mid",
|
|
131
|
+
"dominant-baseline",
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
export class RenderError extends Error {
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} code
|
|
137
|
+
* @param {string} message
|
|
138
|
+
* @param {{ html?: string, failures?: object[] }} [extra]
|
|
139
|
+
*/
|
|
140
|
+
constructor(code, message, extra = {}) {
|
|
141
|
+
super(message);
|
|
142
|
+
this.name = "RenderError";
|
|
143
|
+
this.code = code;
|
|
144
|
+
this.html = extra.html;
|
|
145
|
+
this.failures = extra.failures || [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const SUPPLEMENT_KEYS = new Set(["model_id", "digest", "view"]);
|
|
150
|
+
const VIEW_KEYS = new Set(["format", "path", "inline", "alt"]);
|
|
151
|
+
|
|
152
|
+
export function bindingRequirement(modelId = "<model.id>") {
|
|
153
|
+
return [
|
|
154
|
+
`BINDING_REQUIRED ${modelId}: activated model (selected OR required, including render_status READY/MISSING/STALE) must bind a digest-checked view.`,
|
|
155
|
+
"Minimal planning binding:",
|
|
156
|
+
` "digest": "sha256:<hex of view bytes>",`,
|
|
157
|
+
` "view": { "format": "svg"|"png", "path": "<path relative to implementation.json>" }`,
|
|
158
|
+
"or SVG only: \"view\": { \"format\": \"svg\", \"inline\": \"<svg ...></svg>\" } with the same digest over UTF-8 inline bytes.",
|
|
159
|
+
"Append-only alternative: dod_manifest.closeout.model_views[] { model_id, digest, view } keyed to a planning model id. Supplemental entries may not rewrite planning rows or override mode, source, viewpoint, trace, limitations, activation, or an already-planned binding.",
|
|
160
|
+
"Inactive (selected=false AND required=false) or not_applicable+reason needs no view.",
|
|
161
|
+
"Do not invent SVG/PNG semantics. Renderer will not scrape design.md or architecture HTML.",
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function sha256Hex(buf) {
|
|
166
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function normalizeDigest(value) {
|
|
170
|
+
if (value == null || value === "") return "";
|
|
171
|
+
return String(value)
|
|
172
|
+
.trim()
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.replace(/^sha256:/, "");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function esc(value) {
|
|
178
|
+
return String(value ?? "")
|
|
179
|
+
.replace(/&/g, "&")
|
|
180
|
+
.replace(/</g, "<")
|
|
181
|
+
.replace(/>/g, ">")
|
|
182
|
+
.replace(/"/g, """)
|
|
183
|
+
.replace(/'/g, "'");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isNaStatus(value) {
|
|
187
|
+
const s = String(value ?? "")
|
|
188
|
+
.trim()
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.replace(/[_-]+/g, " ");
|
|
191
|
+
return s === "n/a" || s === "na" || s === "not applicable" || s === "notapplicable";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function statusKind(raw) {
|
|
195
|
+
const s = String(raw ?? "")
|
|
196
|
+
.trim()
|
|
197
|
+
.toLowerCase()
|
|
198
|
+
.replace(/[_-]+/g, " ");
|
|
199
|
+
if (!s) return "plan";
|
|
200
|
+
if (isNaStatus(s) || s === "not started") return "na";
|
|
201
|
+
if (
|
|
202
|
+
/^(pass|passed|confirmed|authorized|ready|embedded|complete|done|accepted|not authorized)$/.test(s)
|
|
203
|
+
) {
|
|
204
|
+
return "pass";
|
|
205
|
+
}
|
|
206
|
+
if (/(gap|missing|blocked|fail|failed|stale|error|rejected|refuted)/.test(s)) return "gap";
|
|
207
|
+
if (/(hold|pending|waiting|open)/.test(s)) return "hold";
|
|
208
|
+
if (/(plan|planned|proposed|unused|observed)/.test(s)) return "plan";
|
|
209
|
+
return "plan";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function statusChip(raw, reason) {
|
|
213
|
+
const kind = statusKind(raw);
|
|
214
|
+
const label = raw == null || String(raw).trim() === "" ? "unspecified" : String(raw);
|
|
215
|
+
let html = `<span class="status status-${kind}"><span class="mark" aria-hidden="true"></span>${esc(label)}</span>`;
|
|
216
|
+
if (reason != null && String(reason).trim() !== "") {
|
|
217
|
+
html += `<span class="reason">${esc(reason)}</span>`;
|
|
218
|
+
}
|
|
219
|
+
return html;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sectionNA(value) {
|
|
223
|
+
if (value == null) return { na: true, reason: "No source projection was provided.", value: null };
|
|
224
|
+
if (Array.isArray(value)) {
|
|
225
|
+
if (value.length === 0) {
|
|
226
|
+
return { na: true, reason: "None recorded.", value };
|
|
227
|
+
}
|
|
228
|
+
return { na: false, value };
|
|
229
|
+
}
|
|
230
|
+
if (typeof value === "object") {
|
|
231
|
+
if (value.not_applicable === true || value.na === true || isNaStatus(value.status)) {
|
|
232
|
+
return {
|
|
233
|
+
na: true,
|
|
234
|
+
reason: value.reason || value.evidence || "Not applicable.",
|
|
235
|
+
value,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { na: false, value };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function field(row, ...keys) {
|
|
243
|
+
for (const key of keys) {
|
|
244
|
+
if (row && row[key] != null && row[key] !== "") return row[key];
|
|
245
|
+
}
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function asRows(value) {
|
|
250
|
+
const parsed = sectionNA(value);
|
|
251
|
+
if (parsed.na) return parsed;
|
|
252
|
+
if (Array.isArray(parsed.value)) return { na: false, value: parsed.value };
|
|
253
|
+
return { na: false, value: [parsed.value] };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function resolveUnder(rootDir, relativePath) {
|
|
257
|
+
const rel = String(relativePath ?? "");
|
|
258
|
+
if (!rel || rel.includes("\0")) {
|
|
259
|
+
throw new RenderError("PATH_TRAVERSAL", "Asset path is empty or contains NUL.");
|
|
260
|
+
}
|
|
261
|
+
if (path.isAbsolute(rel) || /^[A-Za-z]:[\\/]/.test(rel)) {
|
|
262
|
+
throw new RenderError("PATH_TRAVERSAL", `Absolute asset path rejected: ${rel}`);
|
|
263
|
+
}
|
|
264
|
+
const root = path.resolve(rootDir);
|
|
265
|
+
const resolved = path.resolve(root, rel);
|
|
266
|
+
const relToRoot = path.relative(root, resolved);
|
|
267
|
+
if (relToRoot.startsWith("..") || path.isAbsolute(relToRoot) || relToRoot.includes("\0")) {
|
|
268
|
+
throw new RenderError("PATH_TRAVERSAL", `Asset path escapes root: ${rel}`);
|
|
269
|
+
}
|
|
270
|
+
return resolved;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isCssWhitespace(ch) {
|
|
274
|
+
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** CSS Syntax 3 §4.3.7 consume an escaped code point, applied across the input. */
|
|
278
|
+
function decodeCssEscapes(input) {
|
|
279
|
+
const s = String(input);
|
|
280
|
+
let out = "";
|
|
281
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
282
|
+
if (s[i] !== "\\") {
|
|
283
|
+
out += s[i];
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const next = s[i + 1];
|
|
287
|
+
if (next === undefined) {
|
|
288
|
+
out += "\uFFFD";
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
if (next === "\n" || next === "\f") {
|
|
292
|
+
i += 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (next === "\r") {
|
|
296
|
+
i += 1;
|
|
297
|
+
if (s[i + 1] === "\n") i += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (/[0-9A-Fa-f]/.test(next)) {
|
|
301
|
+
let hex = next;
|
|
302
|
+
let j = i + 2;
|
|
303
|
+
while (j < s.length && hex.length < 6 && /[0-9A-Fa-f]/.test(s[j])) {
|
|
304
|
+
hex += s[j];
|
|
305
|
+
j += 1;
|
|
306
|
+
}
|
|
307
|
+
if (s[j] === "\r" && s[j + 1] === "\n") j += 2;
|
|
308
|
+
else if (isCssWhitespace(s[j])) j += 1;
|
|
309
|
+
const code = parseInt(hex, 16);
|
|
310
|
+
if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
|
|
311
|
+
out += "\uFFFD";
|
|
312
|
+
} else {
|
|
313
|
+
out += String.fromCodePoint(code);
|
|
314
|
+
}
|
|
315
|
+
i = j - 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
out += next;
|
|
319
|
+
i += 1;
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function stripCssComments(css) {
|
|
325
|
+
let out = "";
|
|
326
|
+
let i = 0;
|
|
327
|
+
const s = String(css);
|
|
328
|
+
while (i < s.length) {
|
|
329
|
+
if (s.startsWith("/*", i)) {
|
|
330
|
+
const end = s.indexOf("*/", i + 2);
|
|
331
|
+
if (end < 0) return "@import /*unterminated";
|
|
332
|
+
i = end + 2;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
out += s[i];
|
|
336
|
+
i += 1;
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function normalizedCss(value) {
|
|
342
|
+
return stripCssComments(decodeCssEscapes(String(value)));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function unsafeCssValue(value) {
|
|
346
|
+
const v = normalizedCss(value);
|
|
347
|
+
if (
|
|
348
|
+
/@import\b/i.test(v) ||
|
|
349
|
+
/expression\s*\(/i.test(v) ||
|
|
350
|
+
/javascript:/i.test(v) ||
|
|
351
|
+
/-moz-binding/i.test(v)
|
|
352
|
+
) {
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
const urls = [...v.matchAll(/url\s*\(\s*(['"]?)([^)'"]*)\1\s*\)/gi)];
|
|
356
|
+
for (const match of urls) {
|
|
357
|
+
const target = match[2].trim();
|
|
358
|
+
if (target.startsWith("#")) continue;
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function prefixCssSelectors(css, prefix) {
|
|
365
|
+
const s = normalizedCss(css).trim();
|
|
366
|
+
if (!s) return s;
|
|
367
|
+
if (/@/.test(s) || /</.test(s)) {
|
|
368
|
+
throw new RenderError("UNSAFE_ASSET", "Unsafe SVG stylesheet.");
|
|
369
|
+
}
|
|
370
|
+
let out = "";
|
|
371
|
+
let i = 0;
|
|
372
|
+
while (i < s.length) {
|
|
373
|
+
while (i < s.length && /\s/.test(s[i])) i += 1;
|
|
374
|
+
if (i >= s.length) break;
|
|
375
|
+
const brace = s.indexOf("{", i);
|
|
376
|
+
if (brace < 0) {
|
|
377
|
+
if (s.slice(i).trim()) throw new RenderError("UNSAFE_ASSET", "Unsafe SVG stylesheet.");
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
const prelude = s.slice(i, brace).trim();
|
|
381
|
+
if (!prelude) throw new RenderError("UNSAFE_ASSET", "Unsafe SVG stylesheet.");
|
|
382
|
+
let depth = 1;
|
|
383
|
+
let j = brace + 1;
|
|
384
|
+
let quote = null;
|
|
385
|
+
while (j < s.length && depth > 0) {
|
|
386
|
+
const ch = s[j];
|
|
387
|
+
if (quote) {
|
|
388
|
+
if (ch === "\\") {
|
|
389
|
+
j += 2;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (ch === quote) quote = null;
|
|
393
|
+
} else if (ch === '"' || ch === "'") {
|
|
394
|
+
quote = ch;
|
|
395
|
+
} else if (ch === "{") {
|
|
396
|
+
throw new RenderError("UNSAFE_ASSET", "Unsafe SVG stylesheet.");
|
|
397
|
+
} else if (ch === "}") {
|
|
398
|
+
depth -= 1;
|
|
399
|
+
if (depth === 0) break;
|
|
400
|
+
}
|
|
401
|
+
j += 1;
|
|
402
|
+
}
|
|
403
|
+
if (depth !== 0) throw new RenderError("UNSAFE_ASSET", "Unsafe SVG stylesheet.");
|
|
404
|
+
const body = s.slice(brace, j + 1);
|
|
405
|
+
const prefixed = prelude
|
|
406
|
+
.split(",")
|
|
407
|
+
.map((sel) => {
|
|
408
|
+
const t = sel.trim();
|
|
409
|
+
if (!t) throw new RenderError("UNSAFE_ASSET", "Unsafe SVG stylesheet.");
|
|
410
|
+
return t.startsWith(prefix) ? t : `${prefix} ${t}`;
|
|
411
|
+
})
|
|
412
|
+
.join(", ");
|
|
413
|
+
out += `${prefixed} ${body}`;
|
|
414
|
+
i = j + 1;
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function sanitizeStylesheet(css, scopeSelector) {
|
|
420
|
+
if (unsafeCssValue(css) || /</.test(String(css))) {
|
|
421
|
+
throw new RenderError("UNSAFE_ASSET", "Unsafe SVG stylesheet.");
|
|
422
|
+
}
|
|
423
|
+
return prefixCssSelectors(css, scopeSelector);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function sanitizeStyleAttr(value) {
|
|
427
|
+
const decoded = decodeCssEscapes(String(value));
|
|
428
|
+
const parts = decoded
|
|
429
|
+
.split(";")
|
|
430
|
+
.map((part) => part.trim())
|
|
431
|
+
.filter(Boolean);
|
|
432
|
+
const kept = [];
|
|
433
|
+
for (const part of parts) {
|
|
434
|
+
const idx = part.indexOf(":");
|
|
435
|
+
if (idx < 1) {
|
|
436
|
+
throw new RenderError("UNSAFE_ASSET", "Malformed SVG style attribute.");
|
|
437
|
+
}
|
|
438
|
+
const name = part.slice(0, idx).trim().toLowerCase();
|
|
439
|
+
const val = part.slice(idx + 1).trim();
|
|
440
|
+
if (!/^[a-z-]+$/.test(name) || unsafeCssValue(val)) {
|
|
441
|
+
throw new RenderError("UNSAFE_ASSET", `Unsafe SVG style: ${name}`);
|
|
442
|
+
}
|
|
443
|
+
kept.push(`${name}: ${val}`);
|
|
444
|
+
}
|
|
445
|
+
return kept.join("; ");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function sanitizeHref(name, value, tag) {
|
|
449
|
+
const v = String(value).trim();
|
|
450
|
+
if (tag === "image" && (name === "href" || name === "xlink:href")) {
|
|
451
|
+
if (!/^data:image\/png;base64,[A-Za-z0-9+/]+=*$/.test(v.replace(/\s+/g, ""))) {
|
|
452
|
+
throw new RenderError("UNSAFE_ASSET", "SVG image href must be an embedded PNG data URI.");
|
|
453
|
+
}
|
|
454
|
+
const b64 = v.split(",", 2)[1] || "";
|
|
455
|
+
const buf = Buffer.from(b64, "base64");
|
|
456
|
+
if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_MAGIC)) {
|
|
457
|
+
throw new RenderError("UNSAFE_ASSET", "SVG image data URI is not a PNG.");
|
|
458
|
+
}
|
|
459
|
+
return v.replace(/\s+/g, "");
|
|
460
|
+
}
|
|
461
|
+
if (!/^#[A-Za-z][\w:-]*$/.test(v)) {
|
|
462
|
+
throw new RenderError("UNSAFE_ASSET", `SVG ${name} must be a same-document fragment.`);
|
|
463
|
+
}
|
|
464
|
+
return v;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const XML_PREDEFINED = Object.freeze({
|
|
468
|
+
amp: "&",
|
|
469
|
+
lt: "<",
|
|
470
|
+
gt: ">",
|
|
471
|
+
quot: '"',
|
|
472
|
+
apos: "'",
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
function isXmlChar(code) {
|
|
476
|
+
return (
|
|
477
|
+
code === 0x9 ||
|
|
478
|
+
code === 0xa ||
|
|
479
|
+
code === 0xd ||
|
|
480
|
+
(code >= 0x20 && code <= 0xd7ff) ||
|
|
481
|
+
(code >= 0xe000 && code <= 0xfffd) ||
|
|
482
|
+
(code >= 0x10000 && code <= 0x10ffff)
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function decodeXmlEntities(value, where) {
|
|
487
|
+
const s = String(value);
|
|
488
|
+
if (!s.includes("&")) return s;
|
|
489
|
+
let out = "";
|
|
490
|
+
let i = 0;
|
|
491
|
+
while (i < s.length) {
|
|
492
|
+
const amp = s.indexOf("&", i);
|
|
493
|
+
if (amp < 0) {
|
|
494
|
+
out += s.slice(i);
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
out += s.slice(i, amp);
|
|
498
|
+
const semi = s.indexOf(";", amp + 1);
|
|
499
|
+
if (semi < 0 || semi === amp + 1 || s.slice(amp + 1, semi).includes("&")) {
|
|
500
|
+
throw new RenderError("UNSAFE_ASSET", `Malformed XML character reference in SVG ${where}.`);
|
|
501
|
+
}
|
|
502
|
+
const body = s.slice(amp + 1, semi);
|
|
503
|
+
let decoded;
|
|
504
|
+
if (body[0] === "#") {
|
|
505
|
+
let code;
|
|
506
|
+
if (body[1] === "x" || body[1] === "X") {
|
|
507
|
+
const digits = body.slice(2);
|
|
508
|
+
if (!digits || /[^0-9A-Fa-f]/.test(digits)) {
|
|
509
|
+
throw new RenderError("UNSAFE_ASSET", `Malformed XML character reference in SVG ${where}.`);
|
|
510
|
+
}
|
|
511
|
+
code = Number.parseInt(digits, 16);
|
|
512
|
+
} else {
|
|
513
|
+
const digits = body.slice(1);
|
|
514
|
+
if (!digits || /[^0-9]/.test(digits)) {
|
|
515
|
+
throw new RenderError("UNSAFE_ASSET", `Malformed XML character reference in SVG ${where}.`);
|
|
516
|
+
}
|
|
517
|
+
code = Number.parseInt(digits, 10);
|
|
518
|
+
}
|
|
519
|
+
if (!Number.isInteger(code) || !isXmlChar(code)) {
|
|
520
|
+
throw new RenderError("UNSAFE_ASSET", `Malformed XML character reference in SVG ${where}.`);
|
|
521
|
+
}
|
|
522
|
+
decoded = String.fromCodePoint(code);
|
|
523
|
+
} else if (Object.prototype.hasOwnProperty.call(XML_PREDEFINED, body)) {
|
|
524
|
+
decoded = XML_PREDEFINED[body];
|
|
525
|
+
} else {
|
|
526
|
+
throw new RenderError("UNSAFE_ASSET", `Unsupported XML entity in SVG ${where}: &${body};`);
|
|
527
|
+
}
|
|
528
|
+
out += decoded;
|
|
529
|
+
i = semi + 1;
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function decodeSvgXmlValue(value, where) {
|
|
535
|
+
const decoded = decodeXmlEntities(value, where);
|
|
536
|
+
if (decoded.includes("\0")) {
|
|
537
|
+
throw new RenderError("UNSAFE_ASSET", "SVG contains NUL.");
|
|
538
|
+
}
|
|
539
|
+
if (/javascript:/i.test(decoded)) {
|
|
540
|
+
throw new RenderError("UNSAFE_ASSET", "SVG contains script, foreignObject, or javascript: URL.");
|
|
541
|
+
}
|
|
542
|
+
return decoded;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function parseAttrs(raw, tag) {
|
|
546
|
+
const attrs = [];
|
|
547
|
+
let i = 0;
|
|
548
|
+
const s = raw;
|
|
549
|
+
while (i < s.length) {
|
|
550
|
+
while (i < s.length && /\s/.test(s[i])) i += 1;
|
|
551
|
+
if (i >= s.length) break;
|
|
552
|
+
const rest = s.slice(i);
|
|
553
|
+
const m = /^([A-Za-z_:][\w:.-]*)/.exec(rest);
|
|
554
|
+
if (!m) {
|
|
555
|
+
throw new RenderError("UNSAFE_ASSET", `Malformed SVG attribute in <${tag}>`);
|
|
556
|
+
}
|
|
557
|
+
const name = m[1];
|
|
558
|
+
i += name.length;
|
|
559
|
+
while (i < s.length && /\s/.test(s[i])) i += 1;
|
|
560
|
+
let value = "";
|
|
561
|
+
if (s[i] === "=") {
|
|
562
|
+
i += 1;
|
|
563
|
+
while (i < s.length && /\s/.test(s[i])) i += 1;
|
|
564
|
+
const quote = s[i];
|
|
565
|
+
if (quote === '"' || quote === "'") {
|
|
566
|
+
i += 1;
|
|
567
|
+
const end = s.indexOf(quote, i);
|
|
568
|
+
if (end < 0) throw new RenderError("UNSAFE_ASSET", "Unterminated SVG attribute.");
|
|
569
|
+
value = s.slice(i, end);
|
|
570
|
+
i = end + 1;
|
|
571
|
+
} else {
|
|
572
|
+
throw new RenderError("UNSAFE_ASSET", "Unquoted SVG attributes are rejected.");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
value = decodeSvgXmlValue(value, `attribute ${name}`);
|
|
576
|
+
const lower = name.toLowerCase();
|
|
577
|
+
if (lower.startsWith("on") || lower === "srcdoc" || lower.startsWith("xmlns:xsl")) {
|
|
578
|
+
throw new RenderError("UNSAFE_ASSET", `Unsafe SVG attribute: ${name}`);
|
|
579
|
+
}
|
|
580
|
+
if (lower === "href" || lower === "xlink:href") {
|
|
581
|
+
attrs.push([lower === "xlink:href" ? "href" : name, sanitizeHref(lower, value, tag)]);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (lower === "style") {
|
|
585
|
+
attrs.push(["style", sanitizeStyleAttr(value)]);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (!SVG_ALLOWED_ATTRS.has(lower) && lower !== "xmlns:xlink") {
|
|
589
|
+
throw new RenderError("UNSAFE_ASSET", `SVG attribute not allowed: ${name}`);
|
|
590
|
+
}
|
|
591
|
+
const cssVal = normalizedCss(value);
|
|
592
|
+
if (unsafeCssValue(value) && /url\s*\(/i.test(cssVal) && !cssVal.trim().startsWith("url(#")) {
|
|
593
|
+
throw new RenderError("UNSAFE_ASSET", `Unsafe SVG attribute value: ${name}`);
|
|
594
|
+
}
|
|
595
|
+
attrs.push([name, value]);
|
|
596
|
+
}
|
|
597
|
+
return attrs;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function sanitizeSvg(raw) {
|
|
601
|
+
if (typeof raw !== "string") {
|
|
602
|
+
throw new RenderError("UNSAFE_ASSET", "SVG view must be a string.");
|
|
603
|
+
}
|
|
604
|
+
const input = raw.replace(/^\uFEFF/, "");
|
|
605
|
+
const needsScope = /<style[\s>/]/i.test(input);
|
|
606
|
+
const scopeClass = `dod-svg-${createHash("sha256").update(input, "utf8").digest("hex").slice(0, 16)}`;
|
|
607
|
+
if (Buffer.byteLength(input, "utf8") > MAX_SVG_BYTES) {
|
|
608
|
+
throw new RenderError("UNSAFE_ASSET", "SVG exceeds size limit.");
|
|
609
|
+
}
|
|
610
|
+
if (input.includes("\0")) {
|
|
611
|
+
throw new RenderError("UNSAFE_ASSET", "SVG contains NUL.");
|
|
612
|
+
}
|
|
613
|
+
if (/<!DOCTYPE/i.test(input) || /<!ENTITY/i.test(input) || /<\?/.test(input) || /<!\[CDATA\[/i.test(input)) {
|
|
614
|
+
throw new RenderError("UNSAFE_ASSET", "SVG declares doctype, entity, CDATA, or processing instruction.");
|
|
615
|
+
}
|
|
616
|
+
if (/<script/i.test(input) || /<foreignobject/i.test(input) || /javascript:/i.test(input)) {
|
|
617
|
+
throw new RenderError("UNSAFE_ASSET", "SVG contains script, foreignObject, or javascript: URL.");
|
|
618
|
+
}
|
|
619
|
+
const out = [];
|
|
620
|
+
let i = 0;
|
|
621
|
+
let depth = 0;
|
|
622
|
+
let sawSvg = false;
|
|
623
|
+
const open = [];
|
|
624
|
+
while (i < input.length) {
|
|
625
|
+
if (input.startsWith("<!--", i)) {
|
|
626
|
+
const end = input.indexOf("-->", i + 4);
|
|
627
|
+
if (end < 0) throw new RenderError("UNSAFE_ASSET", "Unterminated SVG comment.");
|
|
628
|
+
i = end + 3;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const lt = input.indexOf("<", i);
|
|
632
|
+
if (lt < 0) {
|
|
633
|
+
if (input.slice(i).trim()) out.push(esc(decodeSvgXmlValue(input.slice(i), "text")));
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
if (lt > i) out.push(esc(decodeSvgXmlValue(input.slice(i, lt), "text")));
|
|
637
|
+
if (input.startsWith("</", lt)) {
|
|
638
|
+
const m = /^<\/([A-Za-z][\w:-]*)\s*>/.exec(input.slice(lt));
|
|
639
|
+
if (!m) throw new RenderError("UNSAFE_ASSET", "Malformed SVG end tag.");
|
|
640
|
+
const name = m[1].toLowerCase();
|
|
641
|
+
if (!SVG_ALLOWED_TAGS.has(name)) {
|
|
642
|
+
throw new RenderError("UNSAFE_ASSET", `SVG tag not allowed: ${name}`);
|
|
643
|
+
}
|
|
644
|
+
if (open.pop() !== name) throw new RenderError("UNSAFE_ASSET", "SVG tag mismatch.");
|
|
645
|
+
out.push(`</${name}>`);
|
|
646
|
+
depth -= 1;
|
|
647
|
+
i = lt + m[0].length;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const tagM = /^<([A-Za-z][\w:-]*)/.exec(input.slice(lt));
|
|
651
|
+
if (!tagM) throw new RenderError("UNSAFE_ASSET", "Malformed SVG start tag.");
|
|
652
|
+
const rawName = tagM[1];
|
|
653
|
+
const name = rawName.toLowerCase();
|
|
654
|
+
if (!SVG_ALLOWED_TAGS.has(name)) {
|
|
655
|
+
throw new RenderError("UNSAFE_ASSET", `SVG tag not allowed: ${name}`);
|
|
656
|
+
}
|
|
657
|
+
let p = lt + tagM[0].length;
|
|
658
|
+
while (p < input.length && /\s/.test(input[p])) p += 1;
|
|
659
|
+
let q = p;
|
|
660
|
+
let quote = null;
|
|
661
|
+
while (q < input.length) {
|
|
662
|
+
const ch = input[q];
|
|
663
|
+
if (quote) {
|
|
664
|
+
if (ch === quote) quote = null;
|
|
665
|
+
q += 1;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (ch === '"' || ch === "'") {
|
|
669
|
+
quote = ch;
|
|
670
|
+
q += 1;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
if (ch === ">") break;
|
|
674
|
+
q += 1;
|
|
675
|
+
}
|
|
676
|
+
if (q >= input.length) throw new RenderError("UNSAFE_ASSET", "Unterminated SVG tag.");
|
|
677
|
+
const attrRaw = input.slice(p, q).trim().replace(/\/\s*$/, "");
|
|
678
|
+
const selfClosing = /\/\s*$/.test(input.slice(p, q)) || input[q - 1] === "/";
|
|
679
|
+
const attrs = parseAttrs(attrRaw, name);
|
|
680
|
+
if (name === "svg") {
|
|
681
|
+
sawSvg = true;
|
|
682
|
+
if (needsScope) {
|
|
683
|
+
let hasClass = false;
|
|
684
|
+
for (const pair of attrs) {
|
|
685
|
+
if (pair[0] === "class") {
|
|
686
|
+
const parts = String(pair[1]).split(/\s+/).filter(Boolean);
|
|
687
|
+
if (!parts.includes(scopeClass)) parts.push(scopeClass);
|
|
688
|
+
pair[1] = parts.join(" ");
|
|
689
|
+
hasClass = true;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (!hasClass) attrs.push(["class", scopeClass]);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
let attrHtml = "";
|
|
696
|
+
for (const [k, v] of attrs) {
|
|
697
|
+
attrHtml += ` ${k}="${esc(v)}"`;
|
|
698
|
+
}
|
|
699
|
+
i = q + 1;
|
|
700
|
+
if (name === "style") {
|
|
701
|
+
const close = input.toLowerCase().indexOf("</style>", i);
|
|
702
|
+
if (close < 0) throw new RenderError("UNSAFE_ASSET", "Unterminated SVG style.");
|
|
703
|
+
const css = decodeSvgXmlValue(input.slice(i, close), "style");
|
|
704
|
+
const scoped = sanitizeStylesheet(css, `.${scopeClass}`);
|
|
705
|
+
if (/</.test(scoped)) {
|
|
706
|
+
throw new RenderError("UNSAFE_ASSET", "Unsafe SVG stylesheet.");
|
|
707
|
+
}
|
|
708
|
+
out.push(`<style${attrHtml}>${scoped}</style>`);
|
|
709
|
+
i = close + "</style>".length;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (selfClosing) {
|
|
713
|
+
out.push(`<${name}${attrHtml} />`);
|
|
714
|
+
} else {
|
|
715
|
+
out.push(`<${name}${attrHtml}>`);
|
|
716
|
+
open.push(name);
|
|
717
|
+
depth += 1;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (open.length !== 0 || depth !== 0) {
|
|
721
|
+
throw new RenderError("UNSAFE_ASSET", "SVG tags were not closed.");
|
|
722
|
+
}
|
|
723
|
+
if (!sawSvg) throw new RenderError("UNSAFE_ASSET", "View is not an SVG document.");
|
|
724
|
+
return out.join("");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function embedPng(buf) {
|
|
728
|
+
if (!Buffer.isBuffer(buf)) buf = Buffer.from(buf);
|
|
729
|
+
if (buf.length < 8 || buf.length > MAX_PNG_BYTES || !buf.subarray(0, 8).equals(PNG_MAGIC)) {
|
|
730
|
+
throw new RenderError("UNSAFE_ASSET", "View is not a PNG or exceeds size limit.");
|
|
731
|
+
}
|
|
732
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function modelActivated(model) {
|
|
736
|
+
if (!model || typeof model !== "object") return false;
|
|
737
|
+
if (model.not_applicable === true || isNaStatus(model.mode) || isNaStatus(model.render_status)) {
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
if (model.selected === false && model.required === false) return false;
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function viewSpec(model) {
|
|
745
|
+
if (!model) return null;
|
|
746
|
+
if (model.view && typeof model.view === "object") return model.view;
|
|
747
|
+
if (Array.isArray(model.views) && model.views[0] && typeof model.views[0] === "object") {
|
|
748
|
+
return model.views[0];
|
|
749
|
+
}
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function hasPlannedBinding(model) {
|
|
754
|
+
return Boolean(viewSpec(model) || (model && model.digest));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function indexSupplementalModelViews(closeout, planningModels) {
|
|
758
|
+
const indexed = new Map();
|
|
759
|
+
const raw = closeout && closeout.model_views;
|
|
760
|
+
if (raw == null) return indexed;
|
|
761
|
+
if (!Array.isArray(raw)) {
|
|
762
|
+
throw new RenderError("SCHEMA", "closeout.model_views must be an array.");
|
|
763
|
+
}
|
|
764
|
+
const byId = new Map();
|
|
765
|
+
for (const model of planningModels) {
|
|
766
|
+
if (model && model.id) byId.set(String(model.id), model);
|
|
767
|
+
}
|
|
768
|
+
for (const entry of raw) {
|
|
769
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
770
|
+
throw new RenderError("SCHEMA", "closeout.model_views entries must be objects.");
|
|
771
|
+
}
|
|
772
|
+
const modelId = entry.model_id;
|
|
773
|
+
if (!modelId || typeof modelId !== "string") {
|
|
774
|
+
throw new RenderError("SCHEMA", "closeout.model_views[].model_id is required.");
|
|
775
|
+
}
|
|
776
|
+
for (const key of Object.keys(entry)) {
|
|
777
|
+
if (!SUPPLEMENT_KEYS.has(key)) {
|
|
778
|
+
throw new RenderError(
|
|
779
|
+
"SEMANTIC_OVERRIDE",
|
|
780
|
+
`closeout.model_views for ${modelId} may not set planning field ${key}.`
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (entry.view != null) {
|
|
785
|
+
if (typeof entry.view !== "object" || Array.isArray(entry.view)) {
|
|
786
|
+
throw new RenderError("SCHEMA", `${modelId} closeout.model_views.view must be an object.`);
|
|
787
|
+
}
|
|
788
|
+
for (const key of Object.keys(entry.view)) {
|
|
789
|
+
if (!VIEW_KEYS.has(key)) {
|
|
790
|
+
throw new RenderError(
|
|
791
|
+
"SEMANTIC_OVERRIDE",
|
|
792
|
+
`closeout.model_views for ${modelId} view may not set ${key}.`
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (indexed.has(modelId)) {
|
|
798
|
+
throw new RenderError("DUPLICATE_VIEW_BINDING", `Duplicate closeout.model_views for ${modelId}.`);
|
|
799
|
+
}
|
|
800
|
+
const planned = byId.get(modelId);
|
|
801
|
+
if (!planned) {
|
|
802
|
+
throw new RenderError(
|
|
803
|
+
"UNKNOWN_VIEW_BINDING",
|
|
804
|
+
`closeout.model_views model_id ${modelId} is not a planning model.`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
if (!modelActivated(planned)) {
|
|
808
|
+
throw new RenderError(
|
|
809
|
+
"CONFLICTING_VIEW_BINDING",
|
|
810
|
+
`closeout.model_views may not bind an inactive or N/A planning model ${modelId}.`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (hasPlannedBinding(planned)) {
|
|
814
|
+
throw new RenderError(
|
|
815
|
+
"CONFLICTING_VIEW_BINDING",
|
|
816
|
+
`closeout.model_views may not override an already-planned binding for ${modelId}.`
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
if (!entry.digest || !entry.view) {
|
|
820
|
+
throw new RenderError(
|
|
821
|
+
"MISSING_VIEW",
|
|
822
|
+
`${modelId} closeout.model_views requires digest and view.\n${bindingRequirement(modelId)}`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
indexed.set(modelId, entry);
|
|
826
|
+
}
|
|
827
|
+
return indexed;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function loadView(model, assetRoot) {
|
|
831
|
+
const spec = viewSpec(model);
|
|
832
|
+
if (!spec) return null;
|
|
833
|
+
const format = String(spec.format || "").toLowerCase();
|
|
834
|
+
if (format !== "svg" && format !== "png") {
|
|
835
|
+
throw new RenderError(
|
|
836
|
+
"MISSING_VIEW",
|
|
837
|
+
`${model.id || "model"} view.format must be "svg" or "png".\n${bindingRequirement(model.id)}`
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
if (format === "svg" && spec.inline != null) {
|
|
841
|
+
const bytes = Buffer.from(String(spec.inline), "utf8");
|
|
842
|
+
return { format, bytes, svg: sanitizeSvg(String(spec.inline)), pngUri: null };
|
|
843
|
+
}
|
|
844
|
+
if (!spec.path) {
|
|
845
|
+
throw new RenderError(
|
|
846
|
+
"MISSING_VIEW",
|
|
847
|
+
`${model.id || "model"} view.path or svg view.inline is required.\n${bindingRequirement(model.id)}`
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
const resolved = resolveUnder(assetRoot, spec.path);
|
|
851
|
+
if (!existsSync(resolved)) {
|
|
852
|
+
throw new RenderError("MISSING_VIEW", `${model.id || "model"} view path not found: ${spec.path}`);
|
|
853
|
+
}
|
|
854
|
+
const bytes = readFileSync(resolved);
|
|
855
|
+
if (format === "svg") {
|
|
856
|
+
return { format, bytes, svg: sanitizeSvg(bytes.toString("utf8")), pngUri: null };
|
|
857
|
+
}
|
|
858
|
+
return { format, bytes, svg: null, pngUri: embedPng(bytes) };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function verifyDigest(model, bytes) {
|
|
862
|
+
const expected = normalizeDigest(model.digest);
|
|
863
|
+
if (!expected) {
|
|
864
|
+
throw new RenderError(
|
|
865
|
+
"MISSING_VIEW",
|
|
866
|
+
`${model.id || "model"} is missing digest.\n${bindingRequirement(model.id)}`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
const actual = sha256Hex(bytes);
|
|
870
|
+
if (actual !== expected) {
|
|
871
|
+
throw new RenderError(
|
|
872
|
+
"STALE_VIEW",
|
|
873
|
+
`${model.id || "model"} view digest is stale: expected sha256:${expected}, actual sha256:${actual}`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
return actual;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function tableHint() {
|
|
880
|
+
return ` <p class="table-hint">Tables scroll horizontally on a narrow screen.</p>`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function renderTable(captionId, caption, headers, rows) {
|
|
884
|
+
const head = headers
|
|
885
|
+
.map((h) => ` <th scope="col">${esc(h)}</th>`)
|
|
886
|
+
.join("\n");
|
|
887
|
+
const body = rows
|
|
888
|
+
.map((row) => {
|
|
889
|
+
const kind = row.kind || statusKind(row.status);
|
|
890
|
+
const cells = row.cells
|
|
891
|
+
.map((cell, idx) => {
|
|
892
|
+
if (idx === 0) {
|
|
893
|
+
return ` <th scope="row" class="id">${cell}</th>`;
|
|
894
|
+
}
|
|
895
|
+
const digestClass = headers[idx] && /digest/i.test(headers[idx]) ? ' class="cell-digest"' : "";
|
|
896
|
+
return ` <td${digestClass}>${cell}</td>`;
|
|
897
|
+
})
|
|
898
|
+
.join("\n");
|
|
899
|
+
return ` <tr class="row-${kind}">\n${cells}\n </tr>`;
|
|
900
|
+
})
|
|
901
|
+
.join("\n");
|
|
902
|
+
return [
|
|
903
|
+
tableHint(),
|
|
904
|
+
` <div class="table-scroll" role="region" tabindex="0" aria-labelledby="${esc(captionId)}">`,
|
|
905
|
+
" <table>",
|
|
906
|
+
` <caption id="${esc(captionId)}">${esc(caption)}</caption>`,
|
|
907
|
+
" <thead>",
|
|
908
|
+
" <tr>",
|
|
909
|
+
head,
|
|
910
|
+
" </tr>",
|
|
911
|
+
" </thead>",
|
|
912
|
+
" <tbody>",
|
|
913
|
+
body,
|
|
914
|
+
" </tbody>",
|
|
915
|
+
" </table>",
|
|
916
|
+
" </div>",
|
|
917
|
+
].join("\n");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function naBlock(title, reason) {
|
|
921
|
+
return [
|
|
922
|
+
` <p class="banner"><strong>${esc(title)} not applicable.</strong> ${esc(reason)}</p>`,
|
|
923
|
+
].join("\n");
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function listBlock(items, emptyReason) {
|
|
927
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
928
|
+
return ` <p>${esc(emptyReason)}</p>`;
|
|
929
|
+
}
|
|
930
|
+
const lis = items.map((item) => ` <li>${esc(item)}</li>`).join("\n");
|
|
931
|
+
return ` <ul class="plain">\n${lis}\n </ul>`;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function metaBlock(entries) {
|
|
935
|
+
if (!entries.length) return "";
|
|
936
|
+
const rows = entries
|
|
937
|
+
.map(([dt, dd]) => ` <dt>${esc(dt)}</dt>\n <dd>${dd}</dd>`)
|
|
938
|
+
.join("\n");
|
|
939
|
+
return ` <dl class="meta">\n${rows}\n </dl>`;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function cellText(value) {
|
|
943
|
+
if (value == null || value === "") return "—";
|
|
944
|
+
if (Array.isArray(value)) return esc(value.join(", "));
|
|
945
|
+
if (typeof value === "object") return esc(JSON.stringify(value));
|
|
946
|
+
return esc(value);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function cellStatus(row) {
|
|
950
|
+
return statusChip(field(row, "status", "render_status", "state"), field(row, "reason", "limitations_reason"));
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function heading(index, section) {
|
|
954
|
+
const title =
|
|
955
|
+
section.id === "s6"
|
|
956
|
+
? "V&V cases, assurance cadence, evidence, and pass/fail status"
|
|
957
|
+
: esc(section.title);
|
|
958
|
+
return ` <h2 id="h-${section.id}"><span class="secno">${index}</span>${title}</h2>`;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function wrapSection(section, inner) {
|
|
962
|
+
return [
|
|
963
|
+
` <section class="block" id="${section.id}" aria-labelledby="h-${section.id}">`,
|
|
964
|
+
inner,
|
|
965
|
+
" </section>",
|
|
966
|
+
].join("\n");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function identityEntries(man, doc) {
|
|
970
|
+
const entries = [];
|
|
971
|
+
if (Array.isArray(man.identity)) {
|
|
972
|
+
for (const item of man.identity) {
|
|
973
|
+
entries.push([item.label || item.id || "Field", `<code>${esc(item.value ?? "")}</code>`]);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
entries.push(["Manifest file", `<code>${esc(man.output_name || "")}</code>`]);
|
|
977
|
+
entries.push(["Template", `<code>dod-manifest-v1 ${esc(man.template_version || TEMPLATE_VERSION)}</code>`]);
|
|
978
|
+
entries.push(["Source artifact", "<code>implementation.json.dod_manifest</code>"]);
|
|
979
|
+
if (doc?.source_baseline?.revision) {
|
|
980
|
+
const hasBaseline = entries.some(([label]) => String(label).toLowerCase() === "baseline");
|
|
981
|
+
if (!hasBaseline) {
|
|
982
|
+
entries.push(["Baseline", `<code>${esc(doc.source_baseline.revision)}</code>`]);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return entries;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function strategyEntries(doc, man) {
|
|
989
|
+
const entries = [];
|
|
990
|
+
const raw = man.development_strategy ?? doc?.journey_settings?.development_strategy;
|
|
991
|
+
if (typeof raw === "string") entries.push(["Development strategy", `<code>${esc(raw)}</code>`]);
|
|
992
|
+
else if (raw && typeof raw === "object" && raw.mode) {
|
|
993
|
+
entries.push(["Development strategy", `<code>${esc(raw.mode)}</code>`]);
|
|
994
|
+
}
|
|
995
|
+
const conc = man.concurrency ?? doc?.journey_settings?.concurrency;
|
|
996
|
+
if (typeof conc === "string") entries.push(["Concurrency", esc(conc)]);
|
|
997
|
+
else if (conc && typeof conc === "object") {
|
|
998
|
+
if (conc.dispatch_ready_independent_work === true) {
|
|
999
|
+
entries.push([
|
|
1000
|
+
"Concurrency",
|
|
1001
|
+
"Dispatch every dependency-ready, proven-independent task within host and resource limits.",
|
|
1002
|
+
]);
|
|
1003
|
+
} else if (conc.summary) {
|
|
1004
|
+
entries.push(["Concurrency", esc(conc.summary)]);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return entries;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function cadenceEntries(doc, man) {
|
|
1011
|
+
const c = man.assurance_cadence ?? man.cadence ?? doc?.journey_settings?.assurance_cadence;
|
|
1012
|
+
if (!c || typeof c !== "object") return [];
|
|
1013
|
+
const entries = [];
|
|
1014
|
+
if (c.test_engineer?.assurance) {
|
|
1015
|
+
entries.push(["Test Engineer assurance", `<code>${esc(c.test_engineer.assurance)}</code>`]);
|
|
1016
|
+
}
|
|
1017
|
+
if (c.reviewer) entries.push(["Reviewer", `<code>${esc(c.reviewer)}</code>`]);
|
|
1018
|
+
if (c.integration_engineer?.execution) {
|
|
1019
|
+
entries.push([
|
|
1020
|
+
"Integration Engineer execution",
|
|
1021
|
+
`<code>${esc(c.integration_engineer.execution)}</code>`,
|
|
1022
|
+
]);
|
|
1023
|
+
}
|
|
1024
|
+
return entries;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function renderModelFigure(model, loaded, boundDigest) {
|
|
1028
|
+
const captionId = `view-${esc(String(model.id || "model").toLowerCase())}`;
|
|
1029
|
+
const title = field(model, "title", "viewpoint", "id");
|
|
1030
|
+
let graphic;
|
|
1031
|
+
if (loaded.format === "svg") {
|
|
1032
|
+
graphic = loaded.svg;
|
|
1033
|
+
} else {
|
|
1034
|
+
graphic = `<img src="${loaded.pngUri}" alt="${esc(title)}" />`;
|
|
1035
|
+
}
|
|
1036
|
+
const digest = normalizeDigest(boundDigest || model.digest);
|
|
1037
|
+
return [
|
|
1038
|
+
` <figure class="model" id="${captionId}">`,
|
|
1039
|
+
` <div class="model-scroll" role="region" tabindex="0" aria-label="${esc(title)}">`,
|
|
1040
|
+
` ${graphic}`,
|
|
1041
|
+
" </div>",
|
|
1042
|
+
" <figcaption>",
|
|
1043
|
+
` <strong>${esc(model.id)}.</strong> ${esc(field(model, "limitations") || "Digest-bound human projection. Source artifacts remain authoritative.")}`,
|
|
1044
|
+
" <dl>",
|
|
1045
|
+
" <dt>Mode</dt>",
|
|
1046
|
+
` <dd>${esc(field(model, "mode"))}</dd>`,
|
|
1047
|
+
" <dt>Source</dt>",
|
|
1048
|
+
` <dd>${esc(field(model, "source"))}</dd>`,
|
|
1049
|
+
" <dt>Digest</dt>",
|
|
1050
|
+
` <dd class="cell-digest">sha256:${esc(digest)}</dd>`,
|
|
1051
|
+
" <dt>Viewpoint</dt>",
|
|
1052
|
+
` <dd>${esc(field(model, "viewpoint"))}</dd>`,
|
|
1053
|
+
" <dt>Trace links</dt>",
|
|
1054
|
+
` <dd>${esc(field(model, "trace"))}</dd>`,
|
|
1055
|
+
" <dt>Limitation</dt>",
|
|
1056
|
+
` <dd>${esc(field(model, "limitations"))}</dd>`,
|
|
1057
|
+
" </dl>",
|
|
1058
|
+
" </figcaption>",
|
|
1059
|
+
" </figure>",
|
|
1060
|
+
].join("\n");
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function closeoutEntries(closeout) {
|
|
1064
|
+
if (!closeout || typeof closeout !== "object") {
|
|
1065
|
+
return {
|
|
1066
|
+
candidate: "pending",
|
|
1067
|
+
changed: [],
|
|
1068
|
+
evidence: [],
|
|
1069
|
+
gaps: [],
|
|
1070
|
+
acceptance: "pending",
|
|
1071
|
+
receipts: [],
|
|
1072
|
+
amendments: [],
|
|
1073
|
+
extras: [],
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
const known = new Set([
|
|
1077
|
+
"candidate",
|
|
1078
|
+
"changed_paths",
|
|
1079
|
+
"evidence",
|
|
1080
|
+
"open_gaps",
|
|
1081
|
+
"owner_acceptance",
|
|
1082
|
+
"slice_receipts",
|
|
1083
|
+
"amendments",
|
|
1084
|
+
"owner_amendments",
|
|
1085
|
+
"documentation_completion",
|
|
1086
|
+
"anomalies",
|
|
1087
|
+
"repairs",
|
|
1088
|
+
"recovery_used",
|
|
1089
|
+
"residual_gaps",
|
|
1090
|
+
"completion_status",
|
|
1091
|
+
"model_views",
|
|
1092
|
+
]);
|
|
1093
|
+
const extras = Object.keys(closeout)
|
|
1094
|
+
.filter((k) => !known.has(k))
|
|
1095
|
+
.sort()
|
|
1096
|
+
.map((k) => [k, closeout[k]]);
|
|
1097
|
+
return {
|
|
1098
|
+
candidate: closeout.candidate ?? "pending",
|
|
1099
|
+
changed: Array.isArray(closeout.changed_paths) ? closeout.changed_paths : [],
|
|
1100
|
+
evidence: Array.isArray(closeout.evidence) ? closeout.evidence : [],
|
|
1101
|
+
gaps: Array.isArray(closeout.open_gaps)
|
|
1102
|
+
? closeout.open_gaps
|
|
1103
|
+
: Array.isArray(closeout.residual_gaps)
|
|
1104
|
+
? closeout.residual_gaps
|
|
1105
|
+
: [],
|
|
1106
|
+
acceptance: closeout.owner_acceptance ?? "pending",
|
|
1107
|
+
receipts: Array.isArray(closeout.slice_receipts) ? closeout.slice_receipts : [],
|
|
1108
|
+
amendments: Array.isArray(closeout.amendments)
|
|
1109
|
+
? closeout.amendments
|
|
1110
|
+
: Array.isArray(closeout.owner_amendments)
|
|
1111
|
+
? closeout.owner_amendments
|
|
1112
|
+
: [],
|
|
1113
|
+
documentation: closeout.documentation_completion,
|
|
1114
|
+
anomalies: closeout.anomalies,
|
|
1115
|
+
repairs: closeout.repairs,
|
|
1116
|
+
recovery: closeout.recovery_used,
|
|
1117
|
+
completion: closeout.completion_status,
|
|
1118
|
+
extras,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function listOrPending(arr, pendingLabel = "pending") {
|
|
1123
|
+
if (!Array.isArray(arr) || arr.length === 0) return pendingLabel;
|
|
1124
|
+
return arr.map((item) => (typeof item === "string" ? item : JSON.stringify(item))).join(", ");
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function currentSliceStatus(closeout, taskId) {
|
|
1128
|
+
const slices = closeout && closeout.current_execution_state && closeout.current_execution_state.slice_state;
|
|
1129
|
+
if (!slices || typeof slices !== "object" || Array.isArray(slices) || taskId == null) return "";
|
|
1130
|
+
const value = slices[taskId];
|
|
1131
|
+
return value == null || String(value).trim() === "" ? "" : value;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function currentRiskDisposition(closeout, riskId) {
|
|
1135
|
+
const rows = closeout && closeout.risk_disposition;
|
|
1136
|
+
if (!Array.isArray(rows) || riskId == null) return null;
|
|
1137
|
+
return rows.find((row) => row && (row.id === riskId || row.risk_id === riskId)) || null;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* @param {object} doc
|
|
1142
|
+
* @param {{ assetRoot?: string, templateHtml?: string, templatePath?: string }} [opts]
|
|
1143
|
+
*/
|
|
1144
|
+
export function renderDodManifest(doc, opts = {}) {
|
|
1145
|
+
if (!doc || typeof doc !== "object" || !doc.dod_manifest || typeof doc.dod_manifest !== "object") {
|
|
1146
|
+
throw new RenderError("SCHEMA", "implementation.json.dod_manifest is required.");
|
|
1147
|
+
}
|
|
1148
|
+
const man = doc.dod_manifest;
|
|
1149
|
+
if (!man.title || !man.output_name || !man.template_version) {
|
|
1150
|
+
throw new RenderError("SCHEMA", "dod_manifest requires title, output_name, and template_version.");
|
|
1151
|
+
}
|
|
1152
|
+
if (String(man.template_version) !== TEMPLATE_VERSION) {
|
|
1153
|
+
throw new RenderError(
|
|
1154
|
+
"SCHEMA",
|
|
1155
|
+
`dod_manifest.template_version ${man.template_version} does not match renderer ${TEMPLATE_VERSION}.`
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
const assetRoot = opts.assetRoot || process.cwd();
|
|
1159
|
+
const failures = [];
|
|
1160
|
+
const modelFigures = [];
|
|
1161
|
+
const modelsParsed = asRows(man.models);
|
|
1162
|
+
const planningModels = modelsParsed.na ? [] : modelsParsed.value;
|
|
1163
|
+
const supplementalViews = indexSupplementalModelViews(man.closeout, planningModels);
|
|
1164
|
+
|
|
1165
|
+
const modelRows = [];
|
|
1166
|
+
if (!modelsParsed.na) {
|
|
1167
|
+
for (const model of planningModels) {
|
|
1168
|
+
const activated = modelActivated(model);
|
|
1169
|
+
const plannedSpec = viewSpec(model);
|
|
1170
|
+
const supplemental = supplementalViews.get(model.id);
|
|
1171
|
+
const spec = plannedSpec || (supplemental && supplemental.view) || null;
|
|
1172
|
+
const boundDigest = model.digest || (supplemental && supplemental.digest) || "";
|
|
1173
|
+
let kind = statusKind(model.render_status);
|
|
1174
|
+
let statusLabel = model.render_status || "";
|
|
1175
|
+
let reason = field(model, "reason");
|
|
1176
|
+
if (!activated) {
|
|
1177
|
+
if (isNaStatus(model.render_status) || isNaStatus(model.mode) || model.not_applicable) {
|
|
1178
|
+
kind = "na";
|
|
1179
|
+
statusLabel = model.render_status || "Not applicable";
|
|
1180
|
+
reason = reason || field(model, "limitations") || "Not applicable.";
|
|
1181
|
+
}
|
|
1182
|
+
modelRows.push({
|
|
1183
|
+
kind,
|
|
1184
|
+
status: statusLabel,
|
|
1185
|
+
cells: [
|
|
1186
|
+
esc(model.id),
|
|
1187
|
+
cellText(model.mode),
|
|
1188
|
+
cellText(model.source),
|
|
1189
|
+
cellText(model.digest || "None"),
|
|
1190
|
+
cellText(model.viewpoint),
|
|
1191
|
+
cellText(model.trace),
|
|
1192
|
+
cellText(model.limitations),
|
|
1193
|
+
statusChip(statusLabel, reason),
|
|
1194
|
+
],
|
|
1195
|
+
});
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
try {
|
|
1199
|
+
if (String(model.render_status || "").toLowerCase() === "missing") {
|
|
1200
|
+
throw new RenderError(
|
|
1201
|
+
"MISSING_VIEW",
|
|
1202
|
+
`${model.id} selected/required view is missing.\n${bindingRequirement(model.id)}`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
if (String(model.render_status || "").toLowerCase() === "stale") {
|
|
1206
|
+
throw new RenderError("STALE_VIEW", `${model.id} selected/required view is marked stale.`);
|
|
1207
|
+
}
|
|
1208
|
+
if (!spec) {
|
|
1209
|
+
throw new RenderError(
|
|
1210
|
+
"MISSING_VIEW",
|
|
1211
|
+
`${model.id} has no view binding.\n${bindingRequirement(model.id)}`
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
const loaded = loadView({ id: model.id, view: spec }, assetRoot);
|
|
1215
|
+
verifyDigest({ id: model.id, digest: boundDigest }, loaded.bytes);
|
|
1216
|
+
statusLabel = model.render_status || "Embedded";
|
|
1217
|
+
kind = statusKind(statusLabel) === "plan" ? "pass" : statusKind(statusLabel);
|
|
1218
|
+
modelRows.push({
|
|
1219
|
+
kind,
|
|
1220
|
+
status: statusLabel,
|
|
1221
|
+
cells: [
|
|
1222
|
+
esc(model.id),
|
|
1223
|
+
cellText(model.mode),
|
|
1224
|
+
cellText(model.source),
|
|
1225
|
+
model.digest ? `sha256:${esc(normalizeDigest(model.digest))}` : cellText("None"),
|
|
1226
|
+
cellText(model.viewpoint),
|
|
1227
|
+
cellText(model.trace),
|
|
1228
|
+
cellText(model.limitations),
|
|
1229
|
+
statusChip(statusLabel),
|
|
1230
|
+
],
|
|
1231
|
+
});
|
|
1232
|
+
modelFigures.push(renderModelFigure(model, loaded, boundDigest));
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
const code = err instanceof RenderError ? err.code : "UNSAFE_ASSET";
|
|
1235
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1236
|
+
failures.push({ code, message, modelId: model.id });
|
|
1237
|
+
const gapLabel =
|
|
1238
|
+
code === "STALE_VIEW" ? model.render_status || "Stale" : model.render_status || "Missing";
|
|
1239
|
+
modelRows.push({
|
|
1240
|
+
kind: "gap",
|
|
1241
|
+
status: gapLabel,
|
|
1242
|
+
cells: [
|
|
1243
|
+
esc(model.id),
|
|
1244
|
+
cellText(model.mode),
|
|
1245
|
+
cellText(model.source),
|
|
1246
|
+
cellText(model.digest || "None"),
|
|
1247
|
+
cellText(model.viewpoint),
|
|
1248
|
+
cellText(model.trace),
|
|
1249
|
+
cellText(model.limitations),
|
|
1250
|
+
statusChip(gapLabel, message.split("\n")[0]),
|
|
1251
|
+
],
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const stateLabel = man.state || "planning";
|
|
1258
|
+
const lede =
|
|
1259
|
+
man.lede ||
|
|
1260
|
+
"Human-readable projection of the source artifacts. The source artifacts remain authoritative.";
|
|
1261
|
+
|
|
1262
|
+
const s1 = [
|
|
1263
|
+
heading(1, SECTIONS[0]),
|
|
1264
|
+
` <div class="bluf">`,
|
|
1265
|
+
` <p><strong>${esc(man.bluf || "")}</strong></p>`,
|
|
1266
|
+
` <p><strong>Lifecycle state.</strong> ${statusChip(stateLabel)}</p>`,
|
|
1267
|
+
` <p>This manifest compares authorized intent with planned work, evidence, and gaps. It does not grant execution, acceptance, release, or deployment.</p>`,
|
|
1268
|
+
` <ul class="legend status-key" aria-label="Status key">`,
|
|
1269
|
+
` <li><span class="status status-plan"><span class="mark" aria-hidden="true"></span>Planned</span> not yet evidenced</li>`,
|
|
1270
|
+
` <li><span class="status status-pass"><span class="mark" aria-hidden="true"></span>Confirmed</span> or embedded</li>`,
|
|
1271
|
+
` <li><span class="status status-hold"><span class="mark" aria-hidden="true"></span>Hold</span> waiting on owner or candidate</li>`,
|
|
1272
|
+
` <li><span class="status status-gap"><span class="mark" aria-hidden="true"></span>Gap</span> selected content missing</li>`,
|
|
1273
|
+
` <li><span class="status status-na"><span class="mark" aria-hidden="true"></span>Not applicable</span> with a visible reason</li>`,
|
|
1274
|
+
" </ul>",
|
|
1275
|
+
" </div>",
|
|
1276
|
+
].join("\n");
|
|
1277
|
+
|
|
1278
|
+
const s2 = [heading(2, SECTIONS[1]), metaBlock(identityEntries(man, doc))].join("\n");
|
|
1279
|
+
|
|
1280
|
+
const dodParsed = asRows(man.definition_of_done);
|
|
1281
|
+
const dodTable = dodParsed.na
|
|
1282
|
+
? naBlock("Definition of Done", dodParsed.reason)
|
|
1283
|
+
: renderTable(
|
|
1284
|
+
"cap-dod",
|
|
1285
|
+
"Definition of Done criteria",
|
|
1286
|
+
["ID", "Criterion", "Evidence", "Status"],
|
|
1287
|
+
dodParsed.value.map((row) => ({
|
|
1288
|
+
kind: statusKind(row.status),
|
|
1289
|
+
status: row.status,
|
|
1290
|
+
cells: [
|
|
1291
|
+
esc(row.id),
|
|
1292
|
+
cellText(row.criterion || row.statement),
|
|
1293
|
+
cellText(row.evidence),
|
|
1294
|
+
cellStatus(row),
|
|
1295
|
+
],
|
|
1296
|
+
}))
|
|
1297
|
+
);
|
|
1298
|
+
const s3 = [
|
|
1299
|
+
heading(3, SECTIONS[2]),
|
|
1300
|
+
" <h3>Planned outcome</h3>",
|
|
1301
|
+
` <p>${esc(man.outcome || "")}</p>`,
|
|
1302
|
+
" <h3>Scope</h3>",
|
|
1303
|
+
listBlock(man.scope, "None recorded."),
|
|
1304
|
+
" <h3>Exclusions</h3>",
|
|
1305
|
+
listBlock(man.exclusions, "None recorded."),
|
|
1306
|
+
dodTable,
|
|
1307
|
+
].join("\n");
|
|
1308
|
+
|
|
1309
|
+
const reqParsed = asRows(man.requirements);
|
|
1310
|
+
const reqTable = reqParsed.na
|
|
1311
|
+
? naBlock("Requirements", reqParsed.reason)
|
|
1312
|
+
: renderTable(
|
|
1313
|
+
"cap-req",
|
|
1314
|
+
"Requirements and acceptance",
|
|
1315
|
+
["ID", "Statement", "Source", "Status"],
|
|
1316
|
+
reqParsed.value.map((row) => ({
|
|
1317
|
+
kind: statusKind(row.status),
|
|
1318
|
+
status: row.status,
|
|
1319
|
+
cells: [
|
|
1320
|
+
esc(row.id),
|
|
1321
|
+
cellText(row.statement),
|
|
1322
|
+
cellText(row.source),
|
|
1323
|
+
cellStatus(row),
|
|
1324
|
+
],
|
|
1325
|
+
}))
|
|
1326
|
+
);
|
|
1327
|
+
const archParsed = asRows(man.architecture);
|
|
1328
|
+
const archTable = archParsed.na
|
|
1329
|
+
? naBlock("Architecture, design, and interfaces", archParsed.reason)
|
|
1330
|
+
: renderTable(
|
|
1331
|
+
"cap-des",
|
|
1332
|
+
"Architecture, design, and interfaces",
|
|
1333
|
+
["ID", "Decision", "Interfaces", "Status"],
|
|
1334
|
+
archParsed.value.map((row) => ({
|
|
1335
|
+
kind: statusKind(row.status),
|
|
1336
|
+
status: row.status,
|
|
1337
|
+
cells: [
|
|
1338
|
+
esc(row.id),
|
|
1339
|
+
cellText(row.decision || row.summary),
|
|
1340
|
+
cellText(row.interfaces || row.contract),
|
|
1341
|
+
cellStatus(row),
|
|
1342
|
+
],
|
|
1343
|
+
}))
|
|
1344
|
+
);
|
|
1345
|
+
const modelTable = modelsParsed.na
|
|
1346
|
+
? naBlock("Model inventory", modelsParsed.reason)
|
|
1347
|
+
: renderTable(
|
|
1348
|
+
"cap-model",
|
|
1349
|
+
"System-model inventory. Systems Modeler owns semantics; this page only projects prepared views.",
|
|
1350
|
+
["ID", "Mode", "Source", "Digest", "Viewpoint", "Trace", "Limitation", "Render"],
|
|
1351
|
+
modelRows
|
|
1352
|
+
);
|
|
1353
|
+
const figures = modelFigures.length
|
|
1354
|
+
? [" <h3>Embedded model views</h3>", ...modelFigures].join("\n")
|
|
1355
|
+
: "";
|
|
1356
|
+
const s4 = [
|
|
1357
|
+
heading(4, SECTIONS[3]),
|
|
1358
|
+
" <h3>Requirements and acceptance</h3>",
|
|
1359
|
+
reqTable,
|
|
1360
|
+
" <h3>Architecture, design, and interfaces</h3>",
|
|
1361
|
+
archTable,
|
|
1362
|
+
" <h3>Model inventory</h3>",
|
|
1363
|
+
modelTable,
|
|
1364
|
+
figures,
|
|
1365
|
+
].join("\n");
|
|
1366
|
+
|
|
1367
|
+
const taskParsed = asRows(man.tasks);
|
|
1368
|
+
const taskTable = taskParsed.na
|
|
1369
|
+
? naBlock("Task graph", taskParsed.reason)
|
|
1370
|
+
: renderTable(
|
|
1371
|
+
"cap-task",
|
|
1372
|
+
"Implementation tasks, roles, dependencies, and exact write sets",
|
|
1373
|
+
["ID", "Role", "Depends on", "Write set", "Status"],
|
|
1374
|
+
taskParsed.value.map((row) => {
|
|
1375
|
+
const planned = field(row, "status");
|
|
1376
|
+
const current = currentSliceStatus(man.closeout, row.id);
|
|
1377
|
+
return {
|
|
1378
|
+
kind: statusKind(current || planned),
|
|
1379
|
+
status: current || planned,
|
|
1380
|
+
cells: [
|
|
1381
|
+
esc(row.id),
|
|
1382
|
+
cellText(row.role),
|
|
1383
|
+
cellText(row.depends_on),
|
|
1384
|
+
cellText(row.write_set),
|
|
1385
|
+
statusChip(current || planned),
|
|
1386
|
+
],
|
|
1387
|
+
};
|
|
1388
|
+
})
|
|
1389
|
+
);
|
|
1390
|
+
const lineupParsed = asRows(man.lineup);
|
|
1391
|
+
const lineupTable = lineupParsed.na
|
|
1392
|
+
? ""
|
|
1393
|
+
: [
|
|
1394
|
+
" <h3>Roles and routes</h3>",
|
|
1395
|
+
renderTable(
|
|
1396
|
+
"cap-lineup",
|
|
1397
|
+
"Role and session routing",
|
|
1398
|
+
["Role", "Session", "Configured primary", "Effective route", "Status"],
|
|
1399
|
+
lineupParsed.value.map((row) => ({
|
|
1400
|
+
kind: statusKind(row.status),
|
|
1401
|
+
status: row.status,
|
|
1402
|
+
cells: [
|
|
1403
|
+
esc(row.role),
|
|
1404
|
+
cellText(row.session),
|
|
1405
|
+
cellText(row.configured_primary),
|
|
1406
|
+
cellText(row.effective_route),
|
|
1407
|
+
cellStatus(row),
|
|
1408
|
+
],
|
|
1409
|
+
}))
|
|
1410
|
+
),
|
|
1411
|
+
].join("\n");
|
|
1412
|
+
const s5 = [
|
|
1413
|
+
heading(5, SECTIONS[4]),
|
|
1414
|
+
metaBlock(strategyEntries(doc, man)),
|
|
1415
|
+
" <h3>Task graph</h3>",
|
|
1416
|
+
taskTable,
|
|
1417
|
+
lineupTable,
|
|
1418
|
+
].join("\n");
|
|
1419
|
+
|
|
1420
|
+
const vvParsed = asRows(man.vv);
|
|
1421
|
+
const vvTable = vvParsed.na
|
|
1422
|
+
? naBlock("V&V", vvParsed.reason)
|
|
1423
|
+
: renderTable(
|
|
1424
|
+
"cap-seit",
|
|
1425
|
+
"SEIT cases and projected evidence",
|
|
1426
|
+
["ID", "Method", "Cadence", "Evidence", "Status"],
|
|
1427
|
+
vvParsed.value.map((row) => ({
|
|
1428
|
+
kind: statusKind(row.status),
|
|
1429
|
+
status: row.status,
|
|
1430
|
+
cells: [
|
|
1431
|
+
esc(row.id),
|
|
1432
|
+
cellText(row.method),
|
|
1433
|
+
cellText(row.cadence),
|
|
1434
|
+
cellText(row.evidence),
|
|
1435
|
+
cellStatus(row),
|
|
1436
|
+
],
|
|
1437
|
+
}))
|
|
1438
|
+
);
|
|
1439
|
+
const s6 = [heading(6, SECTIONS[5]), metaBlock(cadenceEntries(doc, man)), vvTable].join("\n");
|
|
1440
|
+
|
|
1441
|
+
const docParsed = asRows(man.documentation);
|
|
1442
|
+
const docTable = docParsed.na
|
|
1443
|
+
? naBlock("Documentation impact", docParsed.reason)
|
|
1444
|
+
: renderTable(
|
|
1445
|
+
"cap-doc",
|
|
1446
|
+
"Documentation impact, timing, and verification",
|
|
1447
|
+
["Surface", "Impact", "Owner", "Timing", "Verification", "Status"],
|
|
1448
|
+
docParsed.value.map((row) => ({
|
|
1449
|
+
kind: statusKind(row.status),
|
|
1450
|
+
status: row.status,
|
|
1451
|
+
cells: [
|
|
1452
|
+
esc(row.surface || row.path || row.id),
|
|
1453
|
+
cellText(row.impact),
|
|
1454
|
+
cellText(row.owner || row.task),
|
|
1455
|
+
cellText(row.timing),
|
|
1456
|
+
cellText(row.verification),
|
|
1457
|
+
cellStatus(row),
|
|
1458
|
+
],
|
|
1459
|
+
}))
|
|
1460
|
+
);
|
|
1461
|
+
const s7 = [heading(7, SECTIONS[6]), docTable].join("\n");
|
|
1462
|
+
|
|
1463
|
+
const riskParsed = asRows(man.risks);
|
|
1464
|
+
const riskTable = riskParsed.na
|
|
1465
|
+
? naBlock("Risks and recovery", riskParsed.reason)
|
|
1466
|
+
: renderTable(
|
|
1467
|
+
"cap-risk",
|
|
1468
|
+
"Risks, gaps, exceptions, anomaly handling, rollback, and recovery",
|
|
1469
|
+
[
|
|
1470
|
+
"ID",
|
|
1471
|
+
"Risk",
|
|
1472
|
+
"Control",
|
|
1473
|
+
"Recovery",
|
|
1474
|
+
"Status",
|
|
1475
|
+
"Current mitigation",
|
|
1476
|
+
"Current evidence",
|
|
1477
|
+
],
|
|
1478
|
+
riskParsed.value.map((row) => {
|
|
1479
|
+
const planned = field(row, "status");
|
|
1480
|
+
const disp = currentRiskDisposition(man.closeout, row.id);
|
|
1481
|
+
const current = disp ? field(disp, "status") : "";
|
|
1482
|
+
return {
|
|
1483
|
+
kind: statusKind(current || planned),
|
|
1484
|
+
status: current || planned,
|
|
1485
|
+
cells: [
|
|
1486
|
+
esc(row.id),
|
|
1487
|
+
cellText(row.risk || row.item),
|
|
1488
|
+
cellText(row.control || row.handling),
|
|
1489
|
+
cellText(row.recovery),
|
|
1490
|
+
statusChip(current || planned),
|
|
1491
|
+
cellText(disp ? field(disp, "mitigation") : ""),
|
|
1492
|
+
cellText(disp ? field(disp, "evidence") : ""),
|
|
1493
|
+
],
|
|
1494
|
+
};
|
|
1495
|
+
})
|
|
1496
|
+
);
|
|
1497
|
+
const s8 = [heading(8, SECTIONS[7]), riskTable].join("\n");
|
|
1498
|
+
|
|
1499
|
+
const authParsed = asRows(man.authority);
|
|
1500
|
+
const authTable = authParsed.na
|
|
1501
|
+
? naBlock("Authority", authParsed.reason)
|
|
1502
|
+
: renderTable(
|
|
1503
|
+
"cap-auth",
|
|
1504
|
+
"Authority, decisions, and approvals",
|
|
1505
|
+
["Event", "Owner", "Evidence", "Status"],
|
|
1506
|
+
authParsed.value.map((row) => ({
|
|
1507
|
+
kind: statusKind(row.status),
|
|
1508
|
+
status: row.status,
|
|
1509
|
+
cells: [
|
|
1510
|
+
esc(row.event || row.id),
|
|
1511
|
+
cellText(row.owner),
|
|
1512
|
+
cellText(row.evidence),
|
|
1513
|
+
cellStatus(row),
|
|
1514
|
+
],
|
|
1515
|
+
}))
|
|
1516
|
+
);
|
|
1517
|
+
const close = closeoutEntries(man.closeout);
|
|
1518
|
+
const closeTable = renderTable(
|
|
1519
|
+
"cap-close",
|
|
1520
|
+
"Append-only closeout",
|
|
1521
|
+
["Candidate", "Changed paths", "Evidence", "Open gaps", "Owner acceptance"],
|
|
1522
|
+
[
|
|
1523
|
+
{
|
|
1524
|
+
kind: statusKind(close.acceptance),
|
|
1525
|
+
status: close.acceptance,
|
|
1526
|
+
cells: [
|
|
1527
|
+
statusChip(close.candidate),
|
|
1528
|
+
cellText(listOrPending(close.changed, "pending")),
|
|
1529
|
+
cellText(listOrPending(close.evidence, "pending")),
|
|
1530
|
+
cellText(listOrPending(close.gaps, "none recorded")),
|
|
1531
|
+
statusChip(close.acceptance),
|
|
1532
|
+
],
|
|
1533
|
+
},
|
|
1534
|
+
]
|
|
1535
|
+
);
|
|
1536
|
+
const receiptTable = close.receipts.length
|
|
1537
|
+
? [
|
|
1538
|
+
" <h3>Slice receipts</h3>",
|
|
1539
|
+
renderTable(
|
|
1540
|
+
"cap-receipts",
|
|
1541
|
+
"Append-only slice receipts",
|
|
1542
|
+
["Slice", "Status", "Route", "Residual / blocker"],
|
|
1543
|
+
close.receipts.map((row) => ({
|
|
1544
|
+
kind: statusKind(row.status),
|
|
1545
|
+
status: row.status,
|
|
1546
|
+
cells: [
|
|
1547
|
+
esc(row.slice || row.id),
|
|
1548
|
+
statusChip(row.status),
|
|
1549
|
+
cellText(row.route),
|
|
1550
|
+
cellText(row.residual || row.blocker || "none recorded"),
|
|
1551
|
+
],
|
|
1552
|
+
}))
|
|
1553
|
+
),
|
|
1554
|
+
].join("\n")
|
|
1555
|
+
: "";
|
|
1556
|
+
const amendTable = close.amendments.length
|
|
1557
|
+
? [
|
|
1558
|
+
" <h3>Owner-approved amendments</h3>",
|
|
1559
|
+
renderTable(
|
|
1560
|
+
"cap-amend",
|
|
1561
|
+
"Append-only owner amendments",
|
|
1562
|
+
["ID", "Decision", "Evidence"],
|
|
1563
|
+
close.amendments.map((row) => ({
|
|
1564
|
+
kind: "pass",
|
|
1565
|
+
status: row.status || "CONFIRMED",
|
|
1566
|
+
cells: [
|
|
1567
|
+
esc(row.id || row.event),
|
|
1568
|
+
cellText(row.decision || row.statement),
|
|
1569
|
+
cellText(row.evidence || row.source),
|
|
1570
|
+
],
|
|
1571
|
+
}))
|
|
1572
|
+
),
|
|
1573
|
+
].join("\n")
|
|
1574
|
+
: "";
|
|
1575
|
+
const extraClose = [];
|
|
1576
|
+
if (close.documentation != null) {
|
|
1577
|
+
extraClose.push(["Documentation completion", cellText(close.documentation)]);
|
|
1578
|
+
}
|
|
1579
|
+
if (close.anomalies != null) extraClose.push(["Anomalies", cellText(close.anomalies)]);
|
|
1580
|
+
if (close.repairs != null) extraClose.push(["Repairs", cellText(close.repairs)]);
|
|
1581
|
+
if (close.recovery != null) extraClose.push(["Recovery used", cellText(close.recovery)]);
|
|
1582
|
+
if (close.completion != null) extraClose.push(["Completion status", cellText(close.completion)]);
|
|
1583
|
+
for (const [k, v] of close.extras) extraClose.push([k, cellText(v)]);
|
|
1584
|
+
const s9 = [
|
|
1585
|
+
heading(9, SECTIONS[8]),
|
|
1586
|
+
" <p>Closeout is append-only over the owner-approved planning projection. Changing planning content requires an explicit owner-approved plan amendment. This page does not grant execution, acceptance, release, or deployment.</p>",
|
|
1587
|
+
authTable,
|
|
1588
|
+
" <h3>Closeout actuals</h3>",
|
|
1589
|
+
closeTable,
|
|
1590
|
+
extraClose.length ? metaBlock(extraClose) : "",
|
|
1591
|
+
receiptTable,
|
|
1592
|
+
amendTable,
|
|
1593
|
+
` <p class="banner"><strong>Non-authoritative projection.</strong> Source artifacts remain the authority. State: ${esc(stateLabel)}.</p>`,
|
|
1594
|
+
].join("\n");
|
|
1595
|
+
|
|
1596
|
+
const inner = [
|
|
1597
|
+
wrapSection(SECTIONS[0], s1),
|
|
1598
|
+
wrapSection(SECTIONS[1], s2),
|
|
1599
|
+
wrapSection(SECTIONS[2], s3),
|
|
1600
|
+
wrapSection(SECTIONS[3], s4),
|
|
1601
|
+
wrapSection(SECTIONS[4], s5),
|
|
1602
|
+
wrapSection(SECTIONS[5], s6),
|
|
1603
|
+
wrapSection(SECTIONS[6], s7),
|
|
1604
|
+
wrapSection(SECTIONS[7], s8),
|
|
1605
|
+
wrapSection(SECTIONS[8], s9),
|
|
1606
|
+
].join("\n\n");
|
|
1607
|
+
|
|
1608
|
+
const toc = SECTIONS.map((section, idx) => {
|
|
1609
|
+
const label = idx === 5 ? "V&V cases, assurance cadence, evidence, and status" : esc(section.title);
|
|
1610
|
+
return ` <li><a href="#${section.id}">${label}</a></li>`;
|
|
1611
|
+
}).join("\n");
|
|
1612
|
+
|
|
1613
|
+
const body = [
|
|
1614
|
+
` <a class="skip" href="#main">Skip to content</a>`,
|
|
1615
|
+
` <div class="page">`,
|
|
1616
|
+
` <header class="mast">`,
|
|
1617
|
+
` <div class="kicker">`,
|
|
1618
|
+
` <span class="stamp stamp-state">State: ${esc(stateLabel)}</span>`,
|
|
1619
|
+
` <span class="stamp">Template ${esc(TEMPLATE_VERSION)}</span>`,
|
|
1620
|
+
` <span class="stamp stamp-warn">Not authority</span>`,
|
|
1621
|
+
` </div>`,
|
|
1622
|
+
` <h1>${esc(man.title)}</h1>`,
|
|
1623
|
+
` <p class="lede">${esc(lede)}</p>`,
|
|
1624
|
+
` </header>`,
|
|
1625
|
+
` <div class="layout">`,
|
|
1626
|
+
` <nav class="toc" aria-label="Manifest sections">`,
|
|
1627
|
+
` <p class="toc-title" id="toc-title">On this page</p>`,
|
|
1628
|
+
` <ol>`,
|
|
1629
|
+
toc,
|
|
1630
|
+
` </ol>`,
|
|
1631
|
+
` </nav>`,
|
|
1632
|
+
` <main id="main">`,
|
|
1633
|
+
inner,
|
|
1634
|
+
` </main>`,
|
|
1635
|
+
` </div>`,
|
|
1636
|
+
` <footer class="colophon">`,
|
|
1637
|
+
` <p>${esc(man.title)}. State: ${esc(stateLabel)}. Template ${esc(TEMPLATE_VERSION)}. Self-contained HTML and CSS with no script, no external font, no CDN, and no network image.</p>`,
|
|
1638
|
+
` </footer>`,
|
|
1639
|
+
` </div>`,
|
|
1640
|
+
].join("\n");
|
|
1641
|
+
|
|
1642
|
+
const templateHtml =
|
|
1643
|
+
opts.templateHtml ||
|
|
1644
|
+
readFileSync(opts.templatePath || defaultTemplatePath(), "utf8").replace(/\r\n/g, "\n");
|
|
1645
|
+
if (!templateHtml.includes("<!--DOD_TITLE-->") || !templateHtml.includes("<!--DOD_BODY-->")) {
|
|
1646
|
+
throw new RenderError("SCHEMA", "dod-manifest-v1.html is missing required placeholders.");
|
|
1647
|
+
}
|
|
1648
|
+
const html = templateHtml
|
|
1649
|
+
.replace("<!--DOD_TITLE-->", esc(man.title))
|
|
1650
|
+
.replace("<!--DOD_BODY-->", body)
|
|
1651
|
+
.replace(/\r\n/g, "\n");
|
|
1652
|
+
const normalized = html.endsWith("\n") ? html : `${html}\n`;
|
|
1653
|
+
|
|
1654
|
+
if (failures.length) {
|
|
1655
|
+
throw new RenderError(
|
|
1656
|
+
failures[0].code,
|
|
1657
|
+
failures.map((item) => item.message).join("\n"),
|
|
1658
|
+
{ html: normalized, failures }
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
return { html: normalized, outputName: man.output_name, digest: sha256Hex(Buffer.from(normalized, "utf8")) };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
export function defaultTemplatePath() {
|
|
1665
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", TEMPLATE_RELATIVE);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
export function outputPathFor(inputPath, outputName, explicitOut) {
|
|
1669
|
+
if (explicitOut) return path.resolve(explicitOut);
|
|
1670
|
+
return path.join(path.dirname(path.resolve(inputPath)), outputName);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
export function renderFile(inputPath, options = {}) {
|
|
1674
|
+
const resolved = path.resolve(inputPath);
|
|
1675
|
+
const raw = readFileSync(resolved, "utf8").replace(/^\uFEFF/, "");
|
|
1676
|
+
const doc = JSON.parse(raw);
|
|
1677
|
+
return renderDodManifest(doc, { ...options, assetRoot: options.assetRoot || path.dirname(resolved) });
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function parseArgs(argv) {
|
|
1681
|
+
const args = argv.slice(2);
|
|
1682
|
+
let input = null;
|
|
1683
|
+
let check = false;
|
|
1684
|
+
let out = null;
|
|
1685
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1686
|
+
const arg = args[i];
|
|
1687
|
+
if (arg === "--check") check = true;
|
|
1688
|
+
else if (arg === "--out") {
|
|
1689
|
+
out = args[i + 1];
|
|
1690
|
+
i += 1;
|
|
1691
|
+
if (!out) throw new RenderError("USAGE", "Missing value for --out.");
|
|
1692
|
+
} else if (arg.startsWith("-")) {
|
|
1693
|
+
throw new RenderError("USAGE", `Unknown flag: ${arg}`);
|
|
1694
|
+
} else if (!input) input = arg;
|
|
1695
|
+
else throw new RenderError("USAGE", "Multiple input paths are not supported.");
|
|
1696
|
+
}
|
|
1697
|
+
if (!input) {
|
|
1698
|
+
throw new RenderError(
|
|
1699
|
+
"USAGE",
|
|
1700
|
+
"Usage: node tools/render-dod-manifest.mjs <implementation.json> [--check] [--out path]"
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
return { input, check, out };
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function main(argv = process.argv) {
|
|
1707
|
+
try {
|
|
1708
|
+
const { input, check, out } = parseArgs(argv);
|
|
1709
|
+
const result = renderFile(input, {});
|
|
1710
|
+
const target = outputPathFor(input, result.outputName, out);
|
|
1711
|
+
if (check) {
|
|
1712
|
+
if (!existsSync(target)) {
|
|
1713
|
+
throw new RenderError("CHECK_MISMATCH", `Expected output is missing: ${target}`);
|
|
1714
|
+
}
|
|
1715
|
+
const expected = readFileSync(target, "utf8").replace(/\r\n/g, "\n");
|
|
1716
|
+
const expectedNorm = expected.endsWith("\n") ? expected : `${expected}\n`;
|
|
1717
|
+
if (expectedNorm !== result.html) {
|
|
1718
|
+
throw new RenderError(
|
|
1719
|
+
"CHECK_MISMATCH",
|
|
1720
|
+
`Byte mismatch for ${target}\nexpected sha256:${sha256Hex(Buffer.from(expectedNorm, "utf8"))}\nactual sha256:${result.digest}`
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
process.stdout.write(`RENDER_CHECK_PASS digest=sha256:${result.digest} output=${target} template=${TEMPLATE_VERSION}\n`);
|
|
1724
|
+
return 0;
|
|
1725
|
+
}
|
|
1726
|
+
writeFileSync(target, result.html, "utf8");
|
|
1727
|
+
process.stdout.write(`RENDER_WRITE path=${target} digest=sha256:${result.digest} template=${TEMPLATE_VERSION}\n`);
|
|
1728
|
+
return 0;
|
|
1729
|
+
} catch (err) {
|
|
1730
|
+
const code = err instanceof RenderError ? err.code : "ERROR";
|
|
1731
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1732
|
+
process.stderr.write(`${code}: ${message}\n`);
|
|
1733
|
+
if (code === "USAGE") return 2;
|
|
1734
|
+
return 1;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
1739
|
+
if (invoked) {
|
|
1740
|
+
process.exitCode = main();
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
export { main, SECTIONS };
|