finalize.mjs
1055 lines · archify/bin/finalize.mjs at a406594
1import { execFile } from 'node:child_process';
2import { createHash, randomUUID } from 'node:crypto';
3import fs from 'node:fs';
4import path from 'node:path';
6import { canonicalFuturePath, pathsAlias, resolveNativeOutputDirectory, resolveOutputPath } from '../renderers/shared/output-path.mjs';
7import { boundedSidecarStem } from '../renderers/shared/sidecar-path.mjs';
8import {
9 captureAtomicOutput, captureRegularFileBinding, publishRegularFileBinding,
10 releaseRegularFileBinding, removeOwnedRegularFile, verifyAtomicOutput,
11} from '../renderers/shared/atomic-output.mjs';
12import {
13 browserCheckSidecarPaths,
14 CAPTURE_VIEWPORTS,
15 ChromeVisualBrowser,
16 findChrome,
17 persistBrowserCheckFailure,
18 VISUAL_CHECK_VIEWPORTS,
19} from './visual-check.mjs';
20import { startDeliveryUpdateCheck } from './delivery-update.mjs';
22export const FINALIZE_STAGES = Object.freeze(['validate', 'deliver', 'check', 'browser-check']);
23const FINALIZE_UPDATE_DEADLINE_MS = 4_000;
25function sha256(buffer) {
26 return createHash('sha256').update(buffer).digest('hex');
27}
29function identity(file) {
30 try {
31 const bytes = fs.readFileSync(file);
32 return { path: path.resolve(file), sha256: sha256(bytes), bytes: bytes.byteLength };
33 } catch {
34 return { path: path.resolve(file) };
35 }
36}
38function durationMs(start) {
39 return Number((process.hrtime.bigint() - start) / 1000000n);
40}
42function receiptWriteError(file, state) {
43 const error = new Error(`Could not publish finalize evidence "${file}" safely (${state.reason?.code || state.status}).`);
44 error.finalizeCode = 'finalize/receipt-publication';
45 error.finalizeEvidence = { file, state };
46 return error;
47}
49function captureReceipt(file) {
50 fs.mkdirSync(path.dirname(file), { recursive: true });
51 const capture = captureAtomicOutput(file, { requestedEntryPolicy: 'regular-or-absent' });
52 if (capture.status !== 'captured') throw receiptWriteError(file, capture);
53 return capture;
54}
56function writeJsonAtomic(file, value, capture, beforeCommit) {
57 const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
58 const temporary = path.join(path.dirname(capture.commitPath), `.archify-finalize-${randomUUID()}.json`);
59 let binding;
60 let stagedIdentity;
61 try {
62 fs.writeFileSync(temporary, serialized, { flag: 'wx' });
63 const metadata = fs.lstatSync(temporary, { bigint: true });
64 stagedIdentity = { device: metadata.dev, inode: metadata.ino };
65 if (capture.mode !== null) fs.chmodSync(temporary, capture.mode);
66 const staged = captureRegularFileBinding(temporary, {
67 subject: 'finalize-receipt', expectedIdentity: stagedIdentity,
68 expectedSha256: sha256(serialized), expectedBytes: serialized.byteLength,
69 ...(capture.mode === null ? {} : { expectedMode: capture.mode }),
70 });
71 if (staged.status !== 'captured') throw receiptWriteError(file, staged);
72 binding = staged.binding;
73 beforeCommit();
74 const publication = publishRegularFileBinding(binding, temporary, capture.snapshot, { subject: 'finalize-receipt' });
75 if (!['committed', 'committed-with-warning'].includes(publication.status)) throw receiptWriteError(file, publication);
76 // Preserve recovery evidence instead of reporting a clean completion when cleanup failed.
77 if (publication.status === 'committed-with-warning') throw receiptWriteError(file, publication);
78 const next = captureReceipt(file);
79 if (!pathsAlias(next.commitPath, capture.commitPath)) {
80 throw receiptWriteError(file, { status: 'different', reason: { code: 'receipt-slot-changed' } });
81 }
82 return next;
83 } finally {
84 if (binding) releaseRegularFileBinding(binding);
85 if (stagedIdentity) removeOwnedRegularFile(temporary, stagedIdentity, { subject: 'finalize-receipt' });
86 }
87}
89function parsedReceipt(stdout) {
90 const source = String(stdout || '').trim();
91 if (!source) return null;
92 try { return JSON.parse(source); } catch { return null; }
93}
95function isReceiptObject(receipt) {
96 return Boolean(receipt && typeof receipt === 'object' && !Array.isArray(receipt));
97}
99function validIdentity(value) {
100 return isReceiptObject(value)
101 && typeof value.sha256 === 'string'
102 && /^[0-9a-f]{64}$/.test(value.sha256)
103 && Number.isSafeInteger(value.bytes)
104 && value.bytes >= 0;
105}
107function identitiesMatch(left, right) {
108 return validIdentity(left)
109 && validIdentity(right)
110 && left.sha256 === right.sha256
111 && left.bytes === right.bytes;
112}
114function validReceiptId(value) {
115 return typeof value === 'string'
116 && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
117}
119function allChecksPassed(checks) {
120 return Array.isArray(checks) && checks.length > 0
121 && checks.every((check) => isReceiptObject(check) && check.ok === true);
122}
124function validCheckComposition(receipt, quality) {
125 const composition = receipt?.composition;
126 return isReceiptObject(composition)
127 && composition.schemaVersion === 1
128 && composition.profile === quality
129 && composition.status === 'pass'
130 && composition.summary?.errors === 0
131 && (quality !== 'showcase' || composition.summary?.warnings === 0);
132}
134function exactViewportCoverage(entries, viewports, predicate) {
135 if (!Array.isArray(entries) || entries.length !== viewports.length) return false;
136 return viewports.every((expected) => {
137 const matching = entries.filter((entry) => (
138 isReceiptObject(entry)
139 && entry.width === expected.width
140 && entry.height === expected.height
141 && (expected.theme === undefined || entry.requestedTheme === expected.theme)
142 ));
143 return matching.length === 1 && predicate(matching[0], expected);
144 });
145}
147function validBrowserEvidence(receipt) {
148 if (receipt.family === 'explainer') {
149 const viewports = [1280, 1440].flatMap(width => ['light', 'dark'].map(theme => ({ width, height: 900, theme })));
150 const groups = ['containment', 'readability', 'viewerChrome', 'themeStates'];
151 return receipt.viewportPolicy === 'explainer-desktop-v1'
152 && groups.every(group => receipt[group]?.status === 'pass'
153 && exactViewportCoverage(receipt[group].viewports?.map(entry => ({ ...entry, requestedTheme: entry.requestedTheme || entry.theme })), viewports, (entry, expected) =>
154 entry.ok === true && (entry.requestedTheme || entry.theme) === expected.theme))
155 && ['commonQa', 'interactions', 'references', 'noJavaScript', 'print'].every(group =>
156 receipt[group]?.status === 'pass' && Array.isArray(receipt[group].observations) && receipt[group].observations.length > 0
157 && receipt[group].observations.every(entry => entry.ok === true));
158 }
159 return exactViewportCoverage(receipt.containment?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
160 entry.theme === 'light' && entry.ok === true
161 ))
162 && exactViewportCoverage(receipt.readability?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
163 entry.theme === 'light' && entry.ok === true && entry.readabilityOk === true
164 ))
165 && exactViewportCoverage(receipt.viewerChrome?.viewports, VISUAL_CHECK_VIEWPORTS, (entry) => (
166 entry.theme === 'light' && entry.ok === true && entry.viewerChromeOk === true
167 ))
168 && exactViewportCoverage(receipt.themeStates?.viewports,
169 [...VISUAL_CHECK_VIEWPORTS.map((viewport) => ({ ...viewport, theme: 'light' })),
170 ...CAPTURE_VIEWPORTS.map((viewport) => ({ ...viewport, theme: 'dark' }))],
171 (entry, expected) => entry.ok === true
172 && entry.requestedTheme === expected.theme
173 && entry.resolvedTheme === expected.theme);
174}
176function validDeliveryValidation(receipt, quality, { allowShowcaseWarnings = false } = {}) {
177 const validation = receipt?.validation;
178 return isReceiptObject(validation)
179 && Number.isInteger(validation.checkCount)
180 && validation.checkCount > 0
181 && validation.checksPassed === validation.checkCount
182 && validation.compositionStatus === 'pass'
183 && validation.errors === 0
184 && validation.compositionProfile === quality
185 && (quality !== 'showcase' || allowShowcaseWarnings || validation.warnings === 0);
186}
188function validStageReceipt(stage, receipt, quality, options) {
189 if (!isReceiptObject(receipt) || receipt.ok !== true) return false;
190 if (receipt.status && receipt.status !== 'pass') return false;
191 if (stage === 'validate') return receipt.command === 'validate' && Array.isArray(receipt.checks);
192 if (stage === 'deliver') {
193 return receipt.command === 'deliver'
194 && receipt.schemaVersion === 1
195 && validReceiptId(receipt.receiptId)
196 && isReceiptObject(receipt.specification)
197 && validIdentity(receipt.specification)
198 && validIdentity(receipt.artifact)
199 && validDeliveryValidation(receipt, quality, options);
200 }
201 if (stage === 'check') {
202 return validIdentity(receipt.artifact)
203 && allChecksPassed(receipt.checks)
204 && receipt.provenance === 'current'
205 && validReceiptId(receipt.deliveryReceiptId)
206 && validCheckComposition(receipt, quality);
207 }
208 return receipt.command === 'browser-check'
209 && receipt.schemaVersion === 1
210 && receipt.status === 'pass'
211 && receipt.evidenceKind === 'automated-browser'
212 && validIdentity(receipt.artifact)
213 && receipt.provenance === 'current'
214 && validReceiptId(receipt.deliveryReceiptId)
215 && receipt.containment?.status === 'pass'
216 && receipt.themeStates?.status === 'pass'
217 && receipt.readability?.status === 'pass'
218 && receipt.viewerChrome?.status === 'pass'
219 && validBrowserEvidence(receipt);
220}
222function identityMismatchDiagnostic({ stage, expected, actual, expectedReceiptId, actualReceiptId, output }) {
223 return {
224 code: 'finalize/artifact-binding-mismatch',
225 severity: 'error',
226 message: `The ${stage} receipt does not prove the artifact delivered by this finalize run.`,
227 subject: { stage, artifact: output },
228 evidence: {
229 expectedArtifact: expected,
230 actualArtifact: actual,
231 ...(expectedReceiptId ? { expectedDeliveryReceiptId: expectedReceiptId } : {}),
232 ...(actualReceiptId ? { actualDeliveryReceiptId: actualReceiptId } : {}),
233 },
234 supportedFixes: ['finish other delivery attempts for this output, then rerun finalize from the frozen candidate'],
235 };
236}
238function showcaseWarningDiagnostics(receipt) {
239 const issues = receipt.validation.compositionIssues;
240 const warnings = receipt.validation.warnings;
241 if (!Array.isArray(issues) || issues.length !== warnings
242 || !issues.every((issue) => isReceiptObject(issue)
243 && issue.severity === 'warning'
244 && typeof issue.code === 'string'
245 && issue.code.startsWith('composition/')
246 && typeof issue.detail === 'string' && issue.detail.trim())) {
247 return [{
248 code: 'finalize/showcase-warnings', severity: 'error',
249 message: `Showcase delivery reported ${warnings} composition warning(s), but did not provide matching issue details.`,
250 subject: { stage: 'deliver', check: 'composition' },
251 evidence: { warnings, reportedIssues: Array.isArray(issues) ? issues.length : null },
252 supportedFixes: ['run validate on the frozen candidate to inspect the composition warnings, repair them, then rerun finalize'],
253 }];
254 }
255 return issues.map((issue) => {
256 const { code, severity, detail, ...evidence } = issue;
257 return {
258 code, severity: 'error',
259 message: `Showcase delivery reported ${code}; zero warnings are required.`,
260 subject: { stage: 'deliver', check: 'composition', ...(issue.nodeId ? { nodeId: issue.nodeId } : {}) },
261 evidence: { reportedSeverity: severity, ...evidence },
262 supportedFixes: [detail.replace(/^\[[^\]]+\]\s*/, '')],
263 };
264 });
265}
267function stageBindingDiagnostic({ stage, receipt, expectedArtifact, expectedReceiptId, output, specification, type, deliveryValidation }) {
268 if (stage === 'deliver') {
269 if (receipt.type !== type) {
270 return {
271 code: 'finalize/delivery-type-mismatch',
272 severity: 'error',
273 message: 'The delivery command reported a different diagram type than finalize requested.',
274 subject: { stage: 'deliver', artifact: output },
275 evidence: { expectedType: type, actualType: receipt.type },
276 supportedFixes: ['rerun finalize with a delivery command for the requested diagram type'],
277 };
278 }
279 if (!identitiesMatch(receipt.specification, specification)) {
280 return {
281 code: 'finalize/candidate-changed-during-delivery',
282 severity: 'error',
283 message: 'The delivery command froze a different candidate than finalize started with.',
284 subject: { stage: 'validate', candidate: specification.path },
285 evidence: {
286 expectedSha256: specification.sha256,
287 actualSha256: receipt.specification?.sha256,
288 expectedBytes: specification.bytes,
289 actualBytes: receipt.specification?.bytes,
290 },
291 supportedFixes: ['restore the frozen candidate and rerun finalize'],
292 };
293 }
294 if ((!receipt.output || !pathsAlias(receipt.output, output)) || !identitiesMatch(receipt.artifact, identity(output))) {
295 return identityMismatchDiagnostic({
296 stage,
297 expected: receipt.artifact,
298 actual: identity(output),
299 expectedReceiptId: receipt.receiptId,
300 output,
301 });
302 }
303 return null;
304 }
305 if (stage === 'browser-check' && ((type === 'explainer') !== (receipt.family === 'explainer'))) {
306 return { code: 'finalize/browser-family-mismatch', severity: 'error', message: 'Browser evidence covers a different artifact family.',
307 subject: {stage, artifact: output}, evidence: {expectedType:type, actualFamily:receipt.family}, supportedFixes:['Rerun browser-check for the delivered artifact.'] };
308 }
309 if (stage === 'check' && deliveryValidation?.checkCount !== receipt.checks.length) {
310 return {
311 code: 'finalize/delivery-validation-mismatch',
312 severity: 'error',
313 message: 'The delivery validation count does not match the complete checker receipt for the delivered artifact.',
314 subject: { stage: 'check', artifact: output },
315 evidence: {
316 deliveryCheckCount: deliveryValidation?.checkCount,
317 checkerCheckCount: receipt.checks.length,
318 },
319 supportedFixes: ['restore the complete delivery validation receipt and rerun finalize from the frozen candidate'],
320 };
321 }
322 const receiptArtifactPath = receipt.artifact?.path;
323 const receiptFile = receipt.file;
324 const pathMatchesOutput = stage === 'check'
325 ? typeof receiptFile === 'string' && pathsAlias(receiptFile, output)
326 : typeof receiptArtifactPath === 'string' && pathsAlias(receiptArtifactPath, output);
327 if (!pathMatchesOutput
328 || !identitiesMatch(receipt.artifact, expectedArtifact)
329 || receipt.deliveryReceiptId !== expectedReceiptId) {
330 return identityMismatchDiagnostic({
331 stage,
332 expected: expectedArtifact,
333 actual: receipt.artifact,
334 expectedReceiptId,
335 actualReceiptId: receipt.deliveryReceiptId,
336 output,
337 });
338 }
339 return null;
340}
342function finalArtifactDiagnostic({ output, expectedArtifact, expectedReceiptId, type, deliveryPath, inspectDelivery }) {
343 // Recheck dev's pending/lock, physical identity, and regular-file provenance barriers at handoff.
344 const provenance = inspectDelivery?.(output);
345 if (provenance && (!provenance.ok || provenance.receiptId !== expectedReceiptId)) {
346 return provenance.diagnostics?.[0] || identityMismatchDiagnostic({
347 stage: 'finalize', output, expected: expectedArtifact,
348 expectedReceiptId, actualReceiptId: provenance.receiptId,
349 });
350 }
351 const currentArtifact = identity(output);
352 let delivery;
353 try {
354 delivery = JSON.parse(fs.readFileSync(deliveryPath, 'utf8'));
355 } catch {
356 delivery = null;
357 }
358 if (identitiesMatch(currentArtifact, expectedArtifact)
359 && delivery?.schemaVersion === 1
360 && delivery.command === 'deliver'
361 && delivery.status === 'current'
362 && delivery.receiptId === expectedReceiptId
363 && delivery.type === type
364 && typeof delivery.output === 'string' && pathsAlias(delivery.output, output)
365 && identitiesMatch(delivery.artifact, expectedArtifact)) return null;
366 return {
367 code: 'finalize/final-artifact-mismatch',
368 severity: 'error',
369 message: 'The artifact or delivery receipt changed after the final gate completed.',
370 subject: { stage: 'finalize', artifact: output },
371 evidence: {
372 expectedArtifact,
373 currentArtifact,
374 expectedDeliveryReceiptId: expectedReceiptId,
375 expectedType: type,
376 ...(delivery?.receiptId ? { currentDeliveryReceiptId: delivery.receiptId } : {}),
377 ...(delivery?.type ? { currentType: delivery.type } : {}),
378 ...(delivery?.artifact ? { currentDeliveryArtifact: delivery.artifact } : {}),
379 },
380 supportedFixes: ['finish other delivery attempts for this output, then rerun finalize from the frozen candidate'],
381 };
382}
384function stageStatus(stage, exitCode, receipt, quality) {
385 if (exitCode === 2 || receipt?.status === 'skipped') return 'skipped';
386 return exitCode === 0 && validStageReceipt(stage, receipt, quality) ? 'pass' : 'fail';
387}
389function consolidateDiagnostics(diagnostics) {
390 const result = [];
391 const seen = new Set();
392 const overflow = [];
393 let overflowIndex = -1;
394 for (const diagnostic of diagnostics || []) {
395 if (diagnostic?.code === 'viewer/viewport-overflow') {
396 if (overflowIndex < 0) overflowIndex = result.length;
397 overflow.push(diagnostic);
398 continue;
399 }
400 const key = `${diagnostic?.code}${diagnostic?.message}${JSON.stringify(diagnostic?.subject ?? null)}`;
401 if (seen.has(key)) continue;
402 seen.add(key);
403 result.push(diagnostic);
404 }
405 const slimEvidence = (evidence) => {
406 if (!evidence || typeof evidence !== 'object') return evidence;
407 const { innerWidth: _w, innerHeight: _h, readerLayout: _l, readerOverflow: _o, readerFit: _f, ...rest } = evidence;
408 return rest;
409 };
410 if (overflow.length > 1) {
411 const first = overflow[0];
412 const tightest = overflow
413 .map((entry) => entry.subject?.viewport)
414 .filter(Boolean)
415 .sort((a, b) => a.width * a.height - b.width * b.height)[0];
416 const fixes = tightest
417 ? [`contain the rendered layout within ${tightest.width}x${tightest.height} (smallest reported viewport), then rerun browser-check`]
418 : [...new Set(overflow.flatMap((entry) => entry.supportedFixes || []))];
419 result.splice(overflowIndex, 0, {
420 ...first,
421 message: `The rendered artifact overflows ${overflow.length} viewport/theme combinations.`,
422 subject: { diagramType: first.subject?.diagramType },
423 supportedFixes: fixes,
424 evidence: {
425 overflows: overflow.map((entry) => {
426 const { artifact: _artifact, ...subject } = entry.subject || {};
427 return {
428 // Predicted validate-stage overflow carries estimatedTextWidthPx;
429 // the browser-check measurement carries scroll* geometry instead.
430 detection: entry.evidence?.estimatedTextWidthPx != null ? 'validate-prediction' : 'browser-measurement',
431 subject,
432 evidence: slimEvidence(entry.evidence),
433 };
434 }),
435 },
436 });
437 } else if (overflow.length === 1) {
438 const [entry] = overflow;
439 const { artifact: _artifact, ...subject } = entry.subject || {};
440 result.splice(overflowIndex, 0, { ...entry, subject, evidence: slimEvidence(entry.evidence) });
441 }
442 return result;
443}
445function failureDiagnostics(stage, result, receipt, quality) {
446 if (Array.isArray(receipt?.diagnostics) && receipt.diagnostics.length) return receipt.diagnostics;
447 const invalidReceipt = (result.status ?? 1) === 0 && !validStageReceipt(stage, receipt, quality);
448 return [{
449 code: invalidReceipt ? 'finalize/invalid-stage-receipt' : 'finalize/stage-failure',
450 severity: 'error',
451 message: invalidReceipt
452 ? `The ${stage} stage exited successfully without a valid passing receipt.`
453 : `The ${stage} stage did not complete successfully.`,
454 subject: { stage },
455 evidence: {
456 exitCode: result.status ?? 1,
457 ...(result.signal ? { signal: result.signal } : {}),
458 ...(result.error?.message ? { reason: result.error.message } : {}),
459 ...(String(result.stdout || '').trim() ? { stdout: String(result.stdout).trim().slice(0, 2000) } : {}),
460 ...(String(result.stderr || '').trim() ? { stderr: String(result.stderr).trim().slice(0, 2000) } : {}),
461 },
462 supportedFixes: [invalidReceipt
463 ? `restore the ${stage} JSON receipt contract before retrying finalize`
464 : 'use the compact finalize summary to repair the named subject in place, then rerun finalize once'],
465 }];
466}
468function sidecarFile(directory, value) {
469 if (!value) return null;
470 return path.resolve(directory, value);
471}
473function browserEvidence(receipt, artifactPath) {
474 if (!receipt) return {};
475 const directory = receipt.sidecars?.directory
476 ? path.resolve(receipt.sidecars.directory)
477 : path.dirname(path.resolve(artifactPath));
478 return {
479 ...(receipt.sidecars?.receipt ? { browserCheckReceipt: sidecarFile(directory, receipt.sidecars.receipt) } : {}),
480 };
481}
483// Stage receipts (dense validate/check JSON) can exceed execFile's 1 MiB
484// default. Track ARCHIFY_CHECK_MAX_BUFFER so a raised checker limit is not
485// defeated one layer up, with a 4 MiB floor: a lowered knob makes failing
486// stages emit compact classified receipts, but those receipts — and any
487// successful receipt the knob let through — still have to fit the capture.
488// The 64 KiB headroom covers the stage echo over the raw checker receipt:
489// commandCheck appends provenance and deliveryReceiptId after the checker
490// ran, so the printed receipt can exceed the knob that admitted it. Read the
491// knob from the runner's env, not process.env: runFinalize callers supply
492// their own env and the child stages inherit exactly that object.
493function stageCaptureMaxBuffer(env = process.env) {
494 const parsed = Number(env.ARCHIFY_CHECK_MAX_BUFFER);
495 const knob = Number.isFinite(parsed) && Math.floor(parsed) >= 1
496 ? Math.floor(parsed)
497 : 64 * 1024 * 1024;
498 return Math.max(knob, 4 * 1024 * 1024) + 64 * 1024;
499}
501function defaultRunner({ cliPath, args, cwd, env }) {
502 return new Promise((resolve) => {
503 execFile(process.execPath, [cliPath, ...args], { cwd, env, encoding: 'utf8', maxBuffer: stageCaptureMaxBuffer(env) }, (error, stdout, stderr) => {
504 resolve({
505 status: error ? (Number.isInteger(error.code) ? error.code : 1) : 0,
506 stdout,
507 stderr,
508 ...(error ? { error, signal: error.signal } : {}),
509 });
510 });
511 });
512}
514function stageArguments({ stage, type, input, output, quality, repoRoot, outDir, writingMode }) {
515 const qualityArgs = ['--quality', quality];
516 const repoArgs = [...(repoRoot ? ['--repo-root', repoRoot] : []), ...(type==='explainer' && writingMode ? ['--writing',writingMode] : [])];
517 if (stage === 'validate') return ['validate', type, input, ...qualityArgs, ...repoArgs, '--json'];
518 if (stage === 'deliver') return ['deliver', type, input, output, ...qualityArgs, ...repoArgs, '--json'];
519 if (stage === 'check') return ['check', output, '--require-provenance'];
520 return [
521 'browser-check', output, '--json', '--require-provenance',
522 ...(outDir ? ['--out-dir', outDir] : []),
523 ];
524}
526export function defaultFinalizeReceiptPath(output, { outDir } = {}) {
527 // Reuse the physical artifact and cross-directory namespace used by browser evidence.
528 const browserReceipt = browserCheckSidecarPaths(output, { outDir }).receipt;
529 const stem = path.basename(browserReceipt).slice(0, -'.browser-check.json'.length);
530 return path.join(path.dirname(browserReceipt), `${boundedSidecarStem(stem, ['.finalize.json', '.finalize-summary.json'])}.finalize.json`);
531}
533export function defaultFinalizeSummaryPath(receiptPath) {
534 const receipt = path.resolve(receiptPath);
535 const component = path.basename(receipt);
536 const isDefault = /\.finalize\.json$/i.test(component);
537 const stem = component.replace(isDefault ? /\.finalize\.json$/i : /\.json$/i, '');
538 const suffix = isDefault ? '.finalize-summary.json' : '-summary.json';
539 // Already bounded default stems share the receipt namespace; avoid hashing twice.
540 const summaryStem = Buffer.byteLength(`${stem}${suffix}`) <= 255 && `${stem}${suffix}`.length <= 255
541 ? stem : boundedSidecarStem(stem, [suffix]);
542 return path.join(path.dirname(receipt), `${summaryStem}${suffix}`);
543}
545function defaultDeliveryPaths(output) {
546 const artifact = canonicalFuturePath(output);
547 const delivery = artifact.replace(/\.html?$/i, '.delivery.json');
548 return {
549 provenance: delivery,
550 pending: delivery.replace(/\.json$/i, '-pending.json'),
551 lock: delivery.replace(/\.json$/i, '-lock.json'),
552 directoryLock: path.join(path.dirname(artifact), '.archify-delivery-lock.json'),
553 };
554}
556function reservedFinalizePaths({ input, output, outDir, deliveryPaths }) {
557 const browser = browserCheckSidecarPaths(output, { outDir });
558 return [path.resolve(input), path.resolve(output), ...Object.values(deliveryPaths(output)), browser.receipt];
559}
561// Node moves that remove the measured crossings and detours, most specific
562// first. Only positions and sizes change; every relationship keeps its endpoints.
563function placementHints({ crossings = [], detours = [], crowdedSides = [] }) {
564 const name = (relation) => `${relation.from} → ${relation.to}`;
565 const other = (relation, shared) => (relation.from === shared ? relation.to : relation.from);
566 const hints = [];
567 for (const side of crowdedSides) {
568 hints.push(`${side.node} has ${side.relationships} relationships facing its ${side.side} side, which fits ${Math.max(1, Math.floor((side.sidePx - 32) / 14) + 1)} ports: make that side at least ${side.neededPx}px, or move some of those neighbours so they face another side of ${side.node}.`);
569 }
570 for (const crossing of crossings) {
571 const shared = crossing.sharedNode;
572 hints.push(shared
573 ? `${name(crossing.left)} and ${name(crossing.right)} cross next to ${shared}: move node ${other(crossing.left, shared)} or ${other(crossing.right, shared)} so the two reach ${shared} from different sides (for example one level with it, one directly above or below it).`
574 : `${name(crossing.left)} crosses ${name(crossing.right)}: move the node of whichever is a branch, return, or second entrance to the other side of the main path, so that relationship runs through an empty corridor.`);
575 }
576 for (const detour of detours) {
577 if (detour.directCorridorBlockers?.length) continue;
578 hints.push(`${name(detour.relationship)} needs ${detour.bends} bends: move node ${detour.relationship.from} or ${detour.relationship.to} so they share a row or column with matching centers, or sit diagonally with a clear corner.`);
579 }
580 return [...new Set(hints)].slice(0, 8);
581}
583// Route failures call for a connected-scene repair; size and text failures do not.
584const ROUTE_DIAGNOSTIC = /^(clean-flow\/|composition\/(proper-crossing|ambiguous-corridor|border-run|container-border-run|excessive-route-detour|route-rhythm|arrowhead-collision))/;
586export function compactFinalizeReceipt(receipt) {
587 const gates = {};
588 for (const stage of FINALIZE_STAGES) gates[stage] = receipt.stages?.[stage]?.status || 'not-run';
589 const allDiagnostics = receipt.diagnostics || [];
590 const diagnosticLimit = 8;
591 const selectedDiagnostics = [];
592 const selectedIndexes = new Set();
593 const seenCodes = new Set();
594 const seenSubjects = new Set();
595 const subjectKey = (entry) => {
596 const subject = entry?.subject;
597 if (!subject) return null;
598 if (typeof subject === 'string') return subject;
599 if (typeof subject !== 'object' || Array.isArray(subject)) return JSON.stringify(subject);
600 for (const key of ['id', 'edge', 'connection', 'relationship', 'component', 'node', 'path', 'stage']) {
601 if (subject[key] !== undefined) return `${key}:${JSON.stringify(subject[key])}`;
602 }
603 return JSON.stringify(subject);
604 };
605 const addDiagnostic = (entry, index) => {
606 if (selectedDiagnostics.length >= diagnosticLimit || selectedIndexes.has(index)) return;
607 selectedIndexes.add(index);
608 selectedDiagnostics.push({
609 code: entry.code,
610 severity: entry.severity || 'error',
611 message: entry.message,
612 ...(entry.subject ? { subject: entry.subject } : {}),
613 ...(entry.evidence && Object.keys(entry.evidence).length ? { evidence: entry.evidence } : {}),
614 // Verified repairs may require every edit in the set to be applied together.
615 ...(Array.isArray(entry.supportedFixes) && entry.supportedFixes.length
616 ? { supportedFixes: [...entry.supportedFixes] } : {}),
617 });
618 };
619 for (const [index, entry] of allDiagnostics.entries()) {
620 if (seenCodes.has(entry.code)) continue;
621 seenCodes.add(entry.code);
622 addDiagnostic(entry, index);
623 }
624 for (const [index, entry] of allDiagnostics.entries()) {
625 const key = subjectKey(entry);
626 if (!key || seenSubjects.has(key)) continue;
627 seenSubjects.add(key);
628 addDiagnostic(entry, index);
629 }
630 for (const [index, entry] of allDiagnostics.entries()) addDiagnostic(entry, index);
631 const truncated = allDiagnostics.length > selectedDiagnostics.length;
632 const omittedCounts = new Map();
633 if (truncated) {
634 for (const [index, entry] of allDiagnostics.entries()) {
635 omittedCounts.set(entry.code, (omittedCounts.get(entry.code) || 0) + (selectedIndexes.has(index) ? 0 : 1));
636 }
637 }
638 const compact = {
639 schemaVersion: 1,
640 ok: receipt.ok,
641 command: 'finalize',
642 status: receipt.status,
643 type: receipt.type,
644 quality: receipt.quality,
645 specification: receipt.specification,
646 artifact: receipt.artifact,
647 gates,
648 ...(receipt.failedStage ? { failedStage: receipt.failedStage } : {}),
649 diagnostics: selectedDiagnostics,
650 diagnosticSummary: {
651 total: allDiagnostics.length,
652 shown: selectedDiagnostics.length,
653 truncated,
654 ...(truncated ? { omittedByCode: Object.fromEntries([...omittedCounts].filter(([, count]) => count > 0)) } : {}),
655 },
656 evidence: receipt.evidence,
657 ...(receipt.update && receipt.update.status !== 'unavailable'
658 ? { update: receipt.update }
659 : {}),
660 visualReview: receipt.visualReview || 'not-requested',
661 durationMs: receipt.durationMs,
662 ...(receipt.browserCheck ? {browserCheck:receipt.browserCheck} : {}),
663 };
664 const metrics = receipt.stages?.check?.receipt?.composition?.metrics;
665 const reviewSignals = Object.fromEntries([
666 'resolvedCrossovers', 'routesOverSuggestedBends', 'routesOverSuggestedStretch',
667 ].filter((key) => Number.isFinite(metrics?.[key]) && metrics[key] > 0)
668 .map((key) => [key, metrics[key]]));
669 if (receipt.ok && Object.keys(reviewSignals).length) {
670 const routeReview = receipt.stages?.check?.receipt?.composition?.routeReview;
671 const hints = routeReview ? placementHints(routeReview) : [];
672 // Modes without free node placement cannot act on node-move hints.
673 const advisoryOnly = Boolean(receipt.type) && receipt.type !== 'architecture';
674 compact.visualReviewRecommendation = {
675 action: 'inspect-route-readability',
676 signals: reviewSignals,
677 reason: 'Automated gates passed, but crossings or detours still need perceptual review before claiming visual quality.',
678 ...(routeReview ? {
679 affectedRoutes: {
680 crossings: routeReview.crossings.slice(0, 8),
681 detours: routeReview.detours.slice(0, 8),
682 truncated: routeReview.crossings.length > 8 || routeReview.detours.length > 8,
683 },
684 ...(hints.length && !advisoryOnly ? { hints } : {}),
685 repair: !advisoryOnly
686 ? 'Trace these relationships at the desktop viewport. For Architecture, use references/architecture-layout-repair.md: reflow a blocked main path or tangled connected scene, and repair an isolated defect locally only when the surrounding composition is accepted. Preserve all semantic content and user-fixed geometry. Rerun finalize once after the edit.'
687 : 'Advisory only: this mode places or routes these relationships itself. Report them; do not remove relationships or meaning to reduce crossings.',
688 } : {}),
689 };
690 }
691 const leadingSpace = receipt.stages?.check?.receipt?.composition?.leadingSpace;
692 if (receipt.ok && leadingSpace?.reviewSuggested === true) {
693 compact.layoutReviewRecommendation = {
694 action: 'inspect-leading-space',
695 evidence: leadingSpace,
696 reason: 'Measured content, including routes and labels, leaves a large empty area above the diagram. This is a composition suggestion, not a failed gate.',
697 repair: 'Check whether that leading space is intentional. If not, reposition the connected scene nearer the canvas origin while retaining room for its actual boundaries, labels and return routes. Preserve all meaning and user-fixed geometry, then rerun finalize. No screenshot is required.',
698 };
699 }
700 const sequenceColumnSpace = receipt.stages?.check?.receipt?.composition?.sequenceColumnSpace;
701 if (receipt.ok && receipt.type === 'sequence' && sequenceColumnSpace?.reviewSuggested === true) {
702 compact.layoutReviewRecommendation = {
703 action: 'inspect-sequence-width',
704 evidence: sequenceColumnSpace,
705 reason: 'Fixed participant columns leave substantial unused space on the right, after accounting for message labels and notes. This is a layout suggestion, not a failed gate.',
706 repair: 'Check whether the explicit fixed column geometry is intentional. Only when changing that geometry is authorized, save the passing fixed candidate, set only meta.column_fit to "spread", and rerun the complete finalize once with --out-dir <folder>/width-review. If that attempt fails, restore the saved candidate and finalize it with --out-dir <folder>/width-restore; report the remaining suggestion instead of iterating. Preserve participant order, every message, its y position, labels, notes, sources and canvas dimensions. Retain intentional fixed layouts; report the suggestion instead of changing them automatically.',
707 };
708 }
709 if (!receipt.ok && receipt.status === 'fail' && receipt.failedStage === 'validate') {
710 compact.nextAction = {
711 action: 'edit-in-place',
712 candidate: receipt.specification?.path,
713 constraint: receipt.type === 'architecture' && (receipt.diagnostics || []).some((entry) => ROUTE_DIAGNOSTIC.test(entry.code || ''))
714 ? 'Preserve all semantics and user-fixed geometry. Use references/architecture-layout-repair.md to choose a local repair or connected-scene reflow; edit the existing candidate.'
715 : 'Preserve unaffected semantics and geometry; apply each diagnostic\'s supportedFixes, or the fix its message names, and do not replace the whole candidate.',
716 then: 'finalize-once',
717 };
718 }
719 return compact;
720}
722export async function runFinalize({
723 cliPath,
724 type,
725 input,
726 output,
727 quality = 'showcase',
728 repoRoot,
729 writingMode,
730 candidateSha256,
731 preparedExplainer,
732 browserCheck = false,
733 outDir,
734 receiptPath,
735 deliveryPaths = defaultDeliveryPaths,
736 inspectDelivery,
737 cwd = process.cwd(),
738 env = process.env,
739 runCommand = defaultRunner,
740 runBrowserCheck,
741 startUpdateCheck = startDeliveryUpdateCheck,
742 resolveChrome = findChrome,
743 createBrowser = (chromePath, options) => new ChromeVisualBrowser(chromePath, options),
744} = {}) {
745 if (!cliPath || !type || !input || !output) throw new Error('finalize requires cliPath, type, input, and output.');
746 const needsBrowser = type !== 'explainer' || browserCheck;
747 const started = process.hrtime.bigint();
748 const startedAt = new Date().toISOString();
749 const resolvedInput = path.resolve(input);
750 const resolvedOutput = resolveOutputPath({ requestedOutput: output, inputPaths: [resolvedInput] }).outputPath;
751 const resolvedOutDir = outDir === undefined ? undefined : resolveNativeOutputDirectory(outDir);
752 const specification = identity(resolvedInput);
753 if (candidateSha256 && specification.sha256 !== candidateSha256) {
754 const error = new Error(`The candidate changed after validation: expected sha256 ${candidateSha256}, found ${specification.sha256 || 'unreadable'}.`);
755 error.finalizeCode = 'finalize/candidate-changed';
756 error.finalizeEvidence = {
757 candidate: resolvedInput,
758 expectedSha256: candidateSha256,
759 ...(specification.sha256 ? { actualSha256: specification.sha256 } : {}),
760 };
761 throw error;
762 }
763 // Validate raw native CLI paths before creating directories or normalizing them.
764 if (receiptPath !== undefined) resolveOutputPath({ requestedOutput: receiptPath, inputPaths: [resolvedInput], requiredExtension: '.json' });
765 fs.mkdirSync(path.dirname(canonicalFuturePath(resolvedOutput)), { recursive: true });
766 const resolvedReceipt = resolveOutputPath({
767 requestedOutput: receiptPath ?? defaultFinalizeReceiptPath(resolvedOutput, { outDir: resolvedOutDir }),
768 inputPaths: [resolvedInput], requiredExtension: '.json',
769 }).outputPath;
770 const resolvedSummary = resolveOutputPath({
771 requestedOutput: defaultFinalizeSummaryPath(resolvedReceipt), inputPaths: [resolvedInput], requiredExtension: '.json',
772 }).outputPath;
773 const assertReceiptPaths = () => {
774 const reserved = reservedFinalizePaths({ input: resolvedInput, output: resolvedOutput, outDir: resolvedOutDir, deliveryPaths });
775 const receiptCollision = reserved.find((file) => pathsAlias(resolvedReceipt, file));
776 if (receiptCollision) throw new Error(`The finalize receipt must be distinct from the specification, artifact, and gate sidecars: "${receiptCollision}".`);
777 const summaryCollision = [resolvedReceipt, ...reserved].find((file) => pathsAlias(resolvedSummary, file));
778 if (summaryCollision) throw new Error(`The finalize summary must be distinct from the full receipt, specification, artifact, and gate sidecars: "${summaryCollision}".`);
779 };
780 assertReceiptPaths();
781 // Capture both paths before changing either: an unsafe summary cannot leave a new full receipt behind.
782 let receiptCapture = captureReceipt(resolvedReceipt);
783 let summaryCapture = captureReceipt(resolvedSummary);
785 const receipt = {
786 schemaVersion: 1,
787 ok: false,
788 command: 'finalize',
789 status: 'running',
790 type,
791 quality,
792 startedAt,
793 specification,
794 artifact: { path: resolvedOutput },
795 stages: needsBrowser ? {} : {'browser-check':{status:'not-run',execution:'not-requested'}},
796 browserCheck: {requested:needsBrowser,ran:false},
797 diagnostics: [],
798 evidence: { receipt: resolvedReceipt, summaryReceipt: resolvedSummary },
799 visualReview: 'not-requested',
800 };
801 const persistReceipts = () => {
802 if (type === 'explainer') receipt.diagnostics = (receipt.diagnostics || []).map(entry => {
803 const location=preparedExplainer?.sourceMap?.[entry.subject?.path] || {};
804 return {...entry,subject:{input:resolvedInput,line:location.line || 1,component:'document',...(entry.subject || {})},
805 evidence:{reason:entry.code,example:entry.supportedFixes?.[0] || 'Repair the named original draft component and rerun finalize.',...(entry.evidence || {})}};
806 });
807 assertReceiptPaths();
808 for (const [file, capture] of [[resolvedReceipt, receiptCapture], [resolvedSummary, summaryCapture]]) {
809 const current = verifyAtomicOutput(capture.snapshot);
810 if (current.status !== 'match') throw receiptWriteError(file, current);
811 }
812 receiptCapture = writeJsonAtomic(resolvedReceipt, receipt, receiptCapture, assertReceiptPaths);
813 summaryCapture = writeJsonAtomic(resolvedSummary, compactFinalizeReceipt(receipt), summaryCapture, assertReceiptPaths);
814 };
815 persistReceipts();
816 // The gates below usually take seconds, so a slower network can finish the
817 // update check in parallel instead of timing out on every delivery.
818 const updateCheck = startUpdateCheck({ env, deadlineMs: needsBrowser ? FINALIZE_UPDATE_DEADLINE_MS : 200 });
820 // Resolve and launch Chrome only after delivery and strict artifact checks pass.
821 // Invalid candidates must not start a browser that cannot inspect an artifact.
822 let chromePath = null;
823 let browser;
824 let browserStartupError;
825 let browserTransferred = false;
826 const browserFactory = () => {
827 if (browserStartupError) throw browserStartupError;
828 if (browserTransferred || !browser) throw new Error('The finalize browser is unavailable or already consumed.');
829 browserTransferred = true;
830 return browser;
831 };
833 try {
834 let exitCode = 0;
835 for (const stage of (needsBrowser ? ['deliver', 'check', 'browser-check'] : ['deliver','check'])) {
836 const stageStarted = process.hrtime.bigint();
837 const args = stageArguments({
838 stage,
839 type,
840 input: resolvedInput,
841 output: resolvedOutput,
842 quality,
843 repoRoot,
844 writingMode,
845 outDir: resolvedOutDir,
846 });
847 const inProcess = stage === 'browser-check' && runBrowserCheck;
848 let result;
849 if (preparedExplainer) {
850 try {
851 const { assertPreparedDependencies } = await import('../explainer/prepare.mjs');
852 assertPreparedDependencies(preparedExplainer);
853 } catch (error) {
854 result = { status: 1, stdout: JSON.stringify({ok:false,status:'fail',command:stage,stage:'prepare',diagnostics:error.archifyDiagnostics || []}) };
855 }
856 }
857 if (result) {
858 // The original resources changed between gates; do not run another gate on them.
859 } else if (inProcess) {
860 try {
861 chromePath = resolveChrome({ env });
862 if (chromePath) {
863 browser = createBrowser(chromePath, { env });
864 // Retain the startup rejection for the browser gate without emitting
865 // an unhandled rejection before inspect() awaits the same promise.
866 browser.sessionPromise.catch(() => {});
867 }
868 } catch (error) {
869 browserStartupError = error;
870 }
871 // Discovery errors have no executable for the browser factory. Record
872 // a failed gate instead of treating them as ordinary Chrome absence.
873 const checked = browserStartupError && !chromePath
874 ? { exitCode: 1, receipt: persistBrowserCheckFailure(resolvedOutput, {
875 schemaVersion: 1, ok: false, command: 'browser-check', status: 'fail',
876 evidenceKind: 'automated-browser', visualReview: 'not-requested',
877 error: browserStartupError.message,
878 diagnostics: [{
879 code: 'viewer/browser-check-runtime', severity: 'error',
880 message: 'browser-check could not discover Chrome.',
881 subject: { artifact: resolvedOutput },
882 evidence: { reason: browserStartupError.message },
883 supportedFixes: ['resolve the reported Chrome discovery error, then rerun browser-check'],
884 }],
885 }, { outDir: resolvedOutDir }) }
886 : await runBrowserCheck({
887 artifactPath: resolvedOutput,
888 outDir: resolvedOutDir,
889 chromePath,
890 resolveChrome: () => chromePath,
891 browserFactory,
892 });
893 result = { status: checked.exitCode, stdout: JSON.stringify(checked.receipt) };
894 } else {
895 result = await runCommand({ stage, cliPath, args, cwd,
896 env: stage === 'deliver' ? { ...env, ARCHIFY_UPDATE_CHECK_DISABLED: '1' } : env });
897 }
898 if (preparedExplainer && result.status === 0) {
899 try {
900 const { assertPreparedDependencies } = await import('../explainer/prepare.mjs');
901 assertPreparedDependencies(preparedExplainer);
902 } catch (error) {
903 result = { status: 1, stdout: JSON.stringify({ok:false,status:'fail',command:stage,stage:'prepare',diagnostics:error.archifyDiagnostics || []}) };
904 }
905 }
906 const stageReceipt = parsedReceipt(result.stdout);
907 if (stage === 'browser-check') receipt.browserCheck.ran = Boolean(stageReceipt?.containment?.viewports?.length);
908 const code = result.status ?? 1;
909 let status = stageStatus(stage, code, stageReceipt, quality);
910 let stageDiagnostics;
911 const command = [process.execPath, cliPath, ...args];
912 const elapsed = durationMs(stageStarted);
913 if (status === 'pass') {
914 const deliveryReceipt = stage === 'deliver' ? stageReceipt : receipt.stages.deliver?.receipt;
915 const bindingDiagnostic = stageBindingDiagnostic({
916 stage,
917 receipt: stageReceipt,
918 expectedArtifact: deliveryReceipt?.artifact,
919 expectedReceiptId: deliveryReceipt?.receiptId,
920 output: resolvedOutput,
921 specification,
922 type,
923 deliveryValidation: deliveryReceipt?.validation,
924 });
925 if (bindingDiagnostic) stageDiagnostics = [bindingDiagnostic];
926 } else if (stage === 'deliver' && code === 0 && quality === 'showcase'
927 && Number.isSafeInteger(stageReceipt?.validation?.warnings)
928 && stageReceipt.validation.warnings > 0
929 && validStageReceipt(stage, stageReceipt, quality, { allowShowcaseWarnings: true })) {
930 const bindingDiagnostic = stageBindingDiagnostic({
931 stage, receipt: stageReceipt, output: resolvedOutput, specification, type,
932 });
933 stageDiagnostics = bindingDiagnostic ? [bindingDiagnostic] : showcaseWarningDiagnostics(stageReceipt);
934 }
935 if (stageDiagnostics) {
936 status = 'fail';
937 }
938 const stageEntry = {
939 status,
940 exitCode: code,
941 durationMs: elapsed,
942 command,
943 ...(inProcess ? { execution: 'in-process' } : {}),
944 ...(stageReceipt ? { receipt: stageReceipt } : {}),
945 ...(!stageReceipt && String(result.stdout || '').trim() ? { stdout: String(result.stdout).trim() } : {}),
946 ...(String(result.stderr || '').trim() ? { stderr: String(result.stderr).trim() } : {}),
947 ...(result.signal ? { signal: result.signal } : {}),
948 };
950 if (stage === 'deliver' && status === 'pass') {
951 // Non-blocking warnings (for example locale fallbacks) stay visible in
952 // a passing receipt; a later failing gate replaces them.
953 receipt.diagnostics = (stageReceipt.diagnostics || []).filter((entry) => entry?.severity === 'warning');
954 receipt.stages.validate = {
955 status: 'pass',
956 exitCode: 0,
957 durationMs: null,
958 execution: 'embedded-in-deliver',
959 command,
960 receipt: {
961 schemaVersion: 1,
962 ok: true,
963 command: 'validate',
964 specification: stageReceipt.specification,
965 validation: stageReceipt.validation,
966 },
967 };
968 receipt.stages.deliver = stageEntry;
969 } else if (stage === 'deliver') {
970 const validationFailed = stageDiagnostics?.some((diagnostic) => diagnostic.code === 'finalize/candidate-changed-during-delivery')
971 || ['input', 'render', 'check'].includes(stageReceipt?.stage);
972 receipt.stages.validate = validationFailed ? {
973 ...stageEntry,
974 status: 'fail',
975 durationMs: null,
976 execution: 'embedded-in-deliver',
977 } : { status: 'not-run', execution: 'embedded-in-deliver' };
978 receipt.stages.deliver = validationFailed
979 ? { status: 'not-run', execution: 'blocked-by-validate' }
980 : stageEntry;
981 } else {
982 receipt.stages[stage] = stageEntry;
983 }
985 if (stage === 'deliver' && stageReceipt?.artifact) receipt.artifact = {
986 path: resolvedOutput,
987 ...stageReceipt.artifact,
988 };
989 if (stage === 'browser-check') {
990 receipt.evidence = {
991 receipt: resolvedReceipt,
992 summaryReceipt: resolvedSummary,
993 ...browserEvidence(stageReceipt, resolvedOutput),
994 };
995 }
997 if (status !== 'pass') {
998 exitCode = status === 'skipped' ? 2 : (code || 1);
999 receipt.status = status;
1000 receipt.diagnostics = consolidateDiagnostics(
1001 stageDiagnostics || failureDiagnostics(stage, result, stageReceipt, quality),
1002 );
1003 receipt.failedStage = stage === 'deliver'
1004 && receipt.stages.validate.status === 'fail' ? 'validate' : stage;
1005 break;
1006 }
1007 persistReceipts();
1008 }
1010 receipt.ok = exitCode === 0;
1011 receipt.status = receipt.ok ? 'pass' : receipt.status === 'running' ? 'fail' : receipt.status;
1012 if (receipt.ok) {
1013 const deliveryReceipt = receipt.stages.deliver?.receipt;
1014 const diagnostic = finalArtifactDiagnostic({
1015 output: resolvedOutput,
1016 expectedArtifact: deliveryReceipt?.artifact,
1017 expectedReceiptId: deliveryReceipt?.receiptId,
1018 type,
1019 deliveryPath: deliveryPaths(resolvedOutput).provenance,
1020 inspectDelivery,
1021 });
1022 if (diagnostic) {
1023 exitCode = 1;
1024 receipt.ok = false;
1025 receipt.status = 'fail';
1026 receipt.failedStage = 'finalize';
1027 receipt.diagnostics = [diagnostic];
1028 }
1029 }
1030 if (receipt.ok && !identitiesMatch(specification, identity(resolvedInput))) {
1031 exitCode = 1;
1032 receipt.ok = false;
1033 receipt.status = 'fail';
1034 receipt.failedStage = 'finalize';
1035 receipt.diagnostics = [{
1036 code: 'finalize/candidate-changed', severity: 'error',
1037 message: 'The frozen candidate changed before the final handoff.',
1038 subject: { candidate: resolvedInput },
1039 evidence: { expected: specification, actual: identity(resolvedInput) },
1040 supportedFixes: ['restore the frozen candidate, or finalize the edited candidate as a new attempt'],
1041 }];
1042 }
1043 receipt.artifact = receipt.ok
1044 ? { path: resolvedOutput, ...receipt.stages.deliver.receipt.artifact }
1045 : identity(resolvedOutput);
1046 receipt.finishedAt = new Date().toISOString();
1047 receipt.durationMs = durationMs(started);
1048 receipt.update = await updateCheck;
1049 persistReceipts();
1050 return { exitCode, receipt, summary: compactFinalizeReceipt(receipt) };
1051 } finally {
1052 if (browser && !browserTransferred) await browser.close();
1053 await updateCheck;
1054 }
1055}