@stacksjs/defaults 0.75.361 → 0.75.362

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +2 -2
  3. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  4. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  5. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  6. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  7. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  8. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  9. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  10. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  11. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  12. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  16. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  17. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  18. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  19. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  20. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  21. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  22. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  24. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  27. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  28. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  29. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  32. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  37. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  38. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  39. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  40. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  42. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  43. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  56. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  57. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  58. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  59. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  69. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  70. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  111. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  120. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  121. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  122. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  124. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  125. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  126. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  127. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  128. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  129. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  130. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  134. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  141. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  147. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  149. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  175. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  186. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  187. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  188. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  189. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  190. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  191. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  192. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  194. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  195. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  196. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  199. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  201. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  205. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  206. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  207. package/ide/vscode/package.json +1 -1
  208. package/package.json +2 -2
@@ -1,4 +1,8 @@
1
+ import { softwareNoticesHtml } from '../renderers/shared/software-notices.mjs';
1
2
  import { spawn } from 'node:child_process';
3
+ import { isExplainerHtml } from '../explainer/check.mjs';
4
+ import { EXPLAINER_VIEWPORTS, inspectExplainerBrowser, explainerObservation, explainerObservationDiagnostics } from '../explainer/browser-check.mjs';
5
+ import { createOwnedTempDirectory } from '../renderers/shared/owned-temp-directory.mjs';
2
6
  import { createHash } from 'node:crypto';
3
7
  import fs from 'node:fs';
4
8
  import os from 'node:os';
@@ -8,6 +12,27 @@ import {
8
12
  DESKTOP_READABILITY_VIEWPORT,
9
13
  MIN_PROJECTED_NODE_TEXT_PX,
10
14
  } from '../renderers/shared/desktop-readability.mjs';
15
+ import {
16
+ backupPublicRegularFileBinding,
17
+ captureAtomicOutput,
18
+ captureRegularFileBinding,
19
+ quarantineRemoveLinkedRegularFileAlias,
20
+ quarantineRemoveRegularFileBinding,
21
+ releaseRegularFileBinding,
22
+ removeEmptyDirectoryWithRetry,
23
+ verifyAtomicOutput,
24
+ verifyRegularFileBinding,
25
+ } from '../renderers/shared/atomic-output.mjs';
26
+ import {
27
+ sameEntry,
28
+ sameLocation,
29
+ sameParent,
30
+ sidecarNamespaceComponentKey,
31
+ } from '../renderers/shared/path-semantics.mjs';
32
+ import {
33
+ boundedSidecarStem,
34
+ sidecarStemFromComponent,
35
+ } from '../renderers/shared/sidecar-path.mjs';
11
36
 
12
37
  export const VISUAL_CHECK_VIEWPORTS = Object.freeze([
13
38
  DESKTOP_READABILITY_VIEWPORT,
@@ -16,13 +41,22 @@ export const VISUAL_CHECK_VIEWPORTS = Object.freeze([
16
41
  Object.freeze({ width: 2048, height: 1320 }),
17
42
  ]);
18
43
 
19
- const CAPTURE_VIEWPORTS = Object.freeze([
44
+ export const CAPTURE_VIEWPORTS = Object.freeze([
20
45
  VISUAL_CHECK_VIEWPORTS[0],
21
46
  VISUAL_CHECK_VIEWPORTS[VISUAL_CHECK_VIEWPORTS.length - 1],
22
47
  ]);
23
- const THEMES = Object.freeze(['light', 'dark']);
48
+ export const THEMES = Object.freeze(['light', 'dark']);
24
49
  const EXIT = Object.freeze({ pass: 0, fail: 1, skipped: 2 });
25
50
  export const CHROME_NO_SANDBOX_ENV = 'ARCHIFY_CHROME_NO_SANDBOX';
51
+ const VISUAL_SIDECAR_SUFFIXES = Object.freeze([
52
+ '.visual-check.json',
53
+ '.visual-check.html',
54
+ ...CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map(
55
+ (theme) => `.visual-check.${width}x${height}.${theme}.png`,
56
+ )),
57
+ ]);
58
+ const visualEvidenceOwnerships = new WeakSet();
59
+ export const CHROME_STARTUP_TIMEOUT_MS = 90000;
26
60
 
27
61
  function sha256(buffer) {
28
62
  return createHash('sha256').update(buffer).digest('hex');
@@ -34,49 +68,1220 @@ function htmlEscape(value) {
34
68
  })[char]);
35
69
  }
36
70
 
37
- function safeUnlink(file) {
71
+ function pathEntry(file) {
38
72
  try {
39
- fs.rmSync(file, { force: true });
40
- } catch {
41
- // A stale optional sidecar must never make the delivered HTML mutable.
73
+ return { exists: true, stat: fs.lstatSync(file) };
74
+ } catch (error) {
75
+ if (error?.code === 'ENOENT') return { exists: false };
76
+ return { exists: null, error };
42
77
  }
43
78
  }
44
79
 
45
- function writeAtomic(file, contents) {
46
- const temporary = `${file}.tmp-${process.pid}`;
47
- try {
48
- fs.writeFileSync(temporary, contents, { flag: 'w' });
49
- fs.renameSync(temporary, file);
50
- } finally {
51
- safeUnlink(temporary);
80
+ function visualEvidencePaths(outputs) {
81
+ return outputs.capture === false ? [] : [outputs.contactSheet, ...outputs.screenshots.map((entry) => entry.path)];
82
+ }
83
+
84
+ function regularFileEvidence(file, { expectedLinks = 1 } = {}) {
85
+ const captured = captureRegularFileBinding(file, {
86
+ subject: 'evidence-file',
87
+ expectedLinks,
88
+ });
89
+ if (captured.status !== 'captured') {
90
+ const systemCode = captured.reason?.systemCode;
91
+ return {
92
+ ok: false,
93
+ reason: systemCode === 'ENOENT'
94
+ ? { code: 'missing-evidence-file' }
95
+ : captured.reason?.code === 'evidence-file-not-regular-file'
96
+ ? { code: 'evidence-path-not-regular-file' }
97
+ : {
98
+ code: 'evidence-file-unreadable',
99
+ ...(systemCode ? { systemCode } : {}),
100
+ ...(captured.reason?.code ? { bindingCode: captured.reason.code } : {}),
101
+ },
102
+ };
52
103
  }
104
+ const released = releaseRegularFileBinding(captured.binding);
105
+ if (released.status !== 'released') {
106
+ return {
107
+ ok: false,
108
+ reason: {
109
+ code: 'evidence-file-unreadable',
110
+ ...(released.reason?.systemCode ? { systemCode: released.reason.systemCode } : {}),
111
+ ...(released.reason?.code ? { bindingCode: released.reason.code } : {}),
112
+ },
113
+ };
114
+ }
115
+ return {
116
+ ok: true,
117
+ evidence: {
118
+ file: path.basename(file),
119
+ sha256: captured.content.sha256,
120
+ bytes: captured.content.bytes,
121
+ },
122
+ identity: captured.identity,
123
+ };
124
+ }
125
+
126
+ function readRegularFileContents(file, { subject = 'file' } = {}) {
127
+ const captured = captureRegularFileBinding(file, {
128
+ subject,
129
+ expectedLinks: 1,
130
+ includeContent: true,
131
+ });
132
+ if (captured.status !== 'captured') return { ok: false, reason: captured.reason };
133
+ const released = releaseRegularFileBinding(captured.binding);
134
+ if (released.status !== 'released') return { ok: false, reason: released.reason };
135
+ return {
136
+ ok: true,
137
+ bytes: captured.content.buffer,
138
+ evidence: {
139
+ sha256: captured.content.sha256,
140
+ bytes: captured.content.bytes,
141
+ },
142
+ identity: captured.identity,
143
+ };
144
+ }
145
+
146
+ function regularFileCaptureError(label, captured) {
147
+ const reason = captured?.reason || { code: 'regular-file-capture-failed' };
148
+ const error = new Error(`${label} could not be read safely (${reason.code}).`);
149
+ if (reason.systemCode) error.code = reason.systemCode;
150
+ error.evidenceReason = reason;
151
+ return error;
53
152
  }
54
153
 
55
154
  function screenshotKey(width, height, theme) {
56
155
  return `${width}x${height}:${theme}`;
57
156
  }
58
157
 
59
- export function sidecarPaths(artifactPath) {
60
- const artifact = path.resolve(artifactPath);
61
- const stem = artifact.replace(/\.html?$/i, '');
62
- const base = `${stem}.visual-check`;
63
- const screenshots = CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map((theme) => ({
158
+ function resolvedSidecarNamespace(artifact, component) {
159
+ const namespace = sidecarNamespaceComponentKey(path.dirname(artifact), component);
160
+ if (namespace.status === 'resolved') return namespace;
161
+ const error = new Error(
162
+ `Could not establish a stable sidecar namespace (${namespace.reason?.code || 'unknown'}).`,
163
+ );
164
+ error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE';
165
+ error.sidecarNamespaceReason = namespace.reason;
166
+ throw error;
167
+ }
168
+
169
+ function portableSidecarComponentKey(component) {
170
+ return component
171
+ .normalize('NFC')
172
+ .toLocaleUpperCase('en-US')
173
+ .toLocaleLowerCase('en-US')
174
+ .normalize('NFC');
175
+ }
176
+
177
+ function targetSidecarNamespace(directory, component) {
178
+ const namespace = sidecarNamespaceComponentKey(directory, component);
179
+ if (namespace.status === 'resolved') return namespace.componentKey;
180
+ if (namespace.reason?.code === 'sidecar-directory-missing') {
181
+ // A pure path calculation must not create --out-dir. Use the portable
182
+ // collision envelope until the directory exists and can be probed.
183
+ return portableSidecarComponentKey(component);
184
+ }
185
+ const error = new Error(
186
+ `Could not establish target sidecar semantics (${namespace.reason?.code || 'unknown'}).`,
187
+ );
188
+ error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE';
189
+ error.sidecarNamespaceReason = namespace.reason;
190
+ throw error;
191
+ }
192
+
193
+ function evidenceSidecarPaths(artifactPath, { outDir, command = 'visual-check', capture = true, captureViewports = CAPTURE_VIEWPORTS } = {}) {
194
+ let artifact = path.resolve(artifactPath);
195
+ try {
196
+ // Existing aliases, including Windows 8.3 spellings, must share the
197
+ // physical artifact's evidence namespace even when the stem is short.
198
+ artifact = fs.realpathSync.native(artifact);
199
+ } catch (error) {
200
+ if (!['ENOENT', 'ENOTDIR', 'ENAMETOOLONG'].includes(error?.code)) throw error;
201
+ // Missing artifacts still need deterministic failure sidecar paths.
202
+ }
203
+ const namespace = resolvedSidecarNamespace(artifact, path.basename(artifact));
204
+ artifact = path.join(namespace.directoryPath, path.basename(artifact));
205
+ const namespaceStem = sidecarStemFromComponent(namespace.componentKey);
206
+ const directory = outDir ? path.resolve(outDir) : path.dirname(artifact);
207
+ const targetComponentKey = outDir
208
+ ? targetSidecarNamespace(directory, namespace.componentKey)
209
+ : namespace.componentKey;
210
+ const portableTargetKey = outDir
211
+ ? portableSidecarComponentKey(namespace.componentKey)
212
+ : namespace.componentKey;
213
+ const targetAliasesSourceSpelling = targetComponentKey !== namespace.componentKey
214
+ || portableTargetKey !== namespace.componentKey;
215
+ const stem = boundedSidecarStem(
216
+ namespaceStem.stem,
217
+ capture ? VISUAL_SIDECAR_SUFFIXES : ['.browser-check.json'],
218
+ targetAliasesSourceSpelling ? {
219
+ force: true,
220
+ hashDomain: `${command}-artifact\0${artifact}\0target-component\0${portableTargetKey}`,
221
+ } : namespaceStem.options,
222
+ );
223
+ const base = path.join(directory, `${stem}.${command}`);
224
+ const screenshots = (capture ? captureViewports : []).flatMap(({ width, height }) => THEMES.map((theme) => ({
64
225
  width,
65
226
  height,
66
227
  theme,
67
228
  path: `${base}.${width}x${height}.${theme}.png`,
68
229
  })));
69
230
  return {
70
- base,
231
+ base, command, capture,
71
232
  receipt: `${base}.json`,
72
233
  contactSheet: `${base}.html`,
73
234
  screenshots,
74
235
  };
75
236
  }
76
237
 
77
- function cleanupCaptureSidecars(paths) {
78
- safeUnlink(paths.contactSheet);
79
- for (const screenshot of paths.screenshots) safeUnlink(screenshot.path);
238
+ export function sidecarPaths(artifactPath, options = {}) {
239
+ return evidenceSidecarPaths(artifactPath, options);
240
+ }
241
+
242
+ export function browserCheckSidecarPaths(artifactPath, options = {}) {
243
+ return evidenceSidecarPaths(artifactPath, { ...options, command: 'browser-check', capture: false });
244
+ }
245
+
246
+ function evidencePathConflict(artifactPath, outputs, file, reason) {
247
+ const target = file || outputs.receipt;
248
+ const error = `${outputs.command} will not replace the existing unowned evidence path "${target}".`;
249
+ return {
250
+ ok: false,
251
+ error,
252
+ diagnostic: failureDiagnostic({
253
+ code: 'viewer/evidence-path-conflict',
254
+ message: error,
255
+ subject: { artifact: path.resolve(artifactPath), evidencePath: target },
256
+ evidence: { reason },
257
+ supportedFixes: [
258
+ `move the conflicting file aside, or choose a separate --out-dir, then rerun ${outputs.command}`,
259
+ ],
260
+ }),
261
+ };
262
+ }
263
+
264
+ function visualEvidenceOwnershipMatches(ownership, artifactPath, outputs) {
265
+ return visualEvidenceOwnerships.has(ownership)
266
+ && ownership.active
267
+ && sameEntry(ownership.artifact, artifactPath).status === 'match'
268
+ && sameLocation(ownership.receipt, outputs.receipt).status === 'match';
269
+ }
270
+
271
+ function visualEvidenceTargets(outputs) {
272
+ return [
273
+ { kind: 'receipt', path: outputs.receipt },
274
+ ...(outputs.capture === false ? [] : [{ kind: 'contact-sheet', path: outputs.contactSheet }]),
275
+ ...outputs.screenshots.map((entry, index) => ({
276
+ kind: 'screenshot',
277
+ index,
278
+ path: entry.path,
279
+ })),
280
+ ];
281
+ }
282
+
283
+ function evidenceWriteFailure(artifactPath, message, errors) {
284
+ return {
285
+ ok: false,
286
+ error: message,
287
+ diagnostic: failureDiagnostic({
288
+ code: 'viewer/evidence-write',
289
+ message,
290
+ subject: { artifact: path.resolve(artifactPath) },
291
+ evidence: { errors },
292
+ supportedFixes: ['restore write access to the evidence directory and rerun visual-check'],
293
+ }),
294
+ };
295
+ }
296
+
297
+ function fileIdentity(stat) {
298
+ return {
299
+ device: stat.dev,
300
+ inode: stat.ino,
301
+ mode: Number(stat.mode & 0o777n),
302
+ links: stat.nlink,
303
+ };
304
+ }
305
+
306
+ function identityMatches(stat, identity, { directory = false } = {}) {
307
+ return stat.dev === identity.device
308
+ && stat.ino === identity.inode
309
+ && Number(stat.mode & 0o777n) === identity.mode
310
+ && (directory ? stat.isDirectory() : stat.isFile())
311
+ && !stat.isSymbolicLink();
312
+ }
313
+
314
+ function evidenceIdentityMatches(actual, expected, expectedLinks = expected?.links ?? 1) {
315
+ return actual?.device === expected?.device
316
+ && actual?.inode === expected?.inode
317
+ && actual?.mode === expected?.mode
318
+ && BigInt(actual?.links ?? 0) === BigInt(expectedLinks);
319
+ }
320
+
321
+ function stagedOutputs(outputs, stagingDirectory) {
322
+ return {
323
+ receipt: path.join(stagingDirectory, 'receipt.json'),
324
+ contactSheet: path.join(stagingDirectory, 'contact-sheet.html'),
325
+ screenshots: outputs.screenshots.map((entry, index) => ({
326
+ ...entry,
327
+ path: path.join(stagingDirectory, `capture-${index}.png`),
328
+ })),
329
+ };
330
+ }
331
+
332
+ function stagedPathFor(ownership, finalPath) {
333
+ return ownership.stagingPaths.get(finalPath);
334
+ }
335
+
336
+ function registerStagedEvidence(ownership, finalPath) {
337
+ const stagedPath = stagedPathFor(ownership, finalPath);
338
+ let stat;
339
+ try {
340
+ stat = fs.lstatSync(stagedPath, { bigint: true });
341
+ } catch (error) {
342
+ throw new Error(`visual-check could not inspect staged evidence "${stagedPath}": ${error.message}`);
343
+ }
344
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.ino === 0n) {
345
+ throw new Error(`visual-check staged evidence is not a uniquely owned regular file: "${stagedPath}".`);
346
+ }
347
+ if (stat.nlink !== 1n) {
348
+ ownership.stagedEntries.set(finalPath, {
349
+ path: stagedPath,
350
+ identity: fileIdentity(stat),
351
+ });
352
+ throw new Error(`visual-check staged evidence has an external hard link: "${stagedPath}".`);
353
+ }
354
+ const targetMode = ownership.targets.get(finalPath)?.mode;
355
+ if (targetMode !== null && targetMode !== undefined
356
+ && Number(stat.mode & 0o777n) !== targetMode) {
357
+ fs.chmodSync(stagedPath, targetMode);
358
+ const adjusted = fs.lstatSync(stagedPath, { bigint: true });
359
+ if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile()) {
360
+ throw new Error(`visual-check staged evidence changed while preserving its mode: "${stagedPath}".`);
361
+ }
362
+ stat = adjusted;
363
+ }
364
+ const entry = {
365
+ path: stagedPath,
366
+ identity: fileIdentity(stat),
367
+ };
368
+ ownership.stagedEntries.set(finalPath, entry);
369
+ const evidence = regularFileEvidence(stagedPath);
370
+ if (!evidence.ok || !evidenceIdentityMatches(evidence.identity, entry.identity)) {
371
+ throw new Error(`visual-check could not bind staged evidence "${stagedPath}" to its contents.`);
372
+ }
373
+ entry.evidence = {
374
+ sha256: evidence.evidence.sha256,
375
+ bytes: evidence.evidence.bytes,
376
+ };
377
+ }
378
+
379
+ function writeStagedEvidence(ownership, finalPath, contents) {
380
+ const stagedPath = stagedPathFor(ownership, finalPath);
381
+ const target = ownership.targets.get(finalPath);
382
+ const mode = target?.mode ?? 0o666;
383
+ let descriptor;
384
+ try {
385
+ descriptor = fs.openSync(stagedPath, 'wx', mode);
386
+ let stat = fs.fstatSync(descriptor, { bigint: true });
387
+ if (!stat.isFile() || stat.ino === 0n) {
388
+ throw new Error('the newly created staging entry has no unique regular-file identity');
389
+ }
390
+ if (target?.mode !== null && target?.mode !== undefined
391
+ && Number(stat.mode & 0o777n) !== target.mode) {
392
+ fs.fchmodSync(descriptor, target.mode);
393
+ const adjusted = fs.fstatSync(descriptor, { bigint: true });
394
+ if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile()
395
+ || Number(adjusted.mode & 0o777n) !== target.mode) {
396
+ throw new Error('the staged evidence mode could not be preserved');
397
+ }
398
+ stat = adjusted;
399
+ }
400
+ const entry = {
401
+ path: stagedPath,
402
+ identity: fileIdentity(stat),
403
+ };
404
+ ownership.stagedEntries.set(finalPath, entry);
405
+ if (stat.nlink !== 1n) {
406
+ throw new Error('the newly created staging entry has an external hard link');
407
+ }
408
+ fs.writeFileSync(descriptor, contents);
409
+ const bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
410
+ entry.evidence = { sha256: sha256(bytes), bytes: bytes.byteLength };
411
+ } finally {
412
+ if (descriptor !== undefined) fs.closeSync(descriptor);
413
+ }
414
+ }
415
+
416
+ function unlinkOwnedEntry(file, identity, {
417
+ evidence,
418
+ expectedLinks,
419
+ } = {}) {
420
+ const verifiedLinks = Number(expectedLinks ?? identity.links ?? 1);
421
+ let captured = captureRegularFileBinding(file, {
422
+ subject: 'visual-cleanup',
423
+ expectedIdentity: identity,
424
+ expectedMode: identity.mode,
425
+ expectedLinks: verifiedLinks,
426
+ ...(evidence ? {
427
+ expectedSha256: evidence.sha256,
428
+ expectedBytes: evidence.bytes,
429
+ } : {}),
430
+ });
431
+ const reportedLinks = Number(captured.reason?.links);
432
+ if (captured.status === 'unsupported'
433
+ && captured.reason?.code === 'visual-cleanup-hardlinked'
434
+ && Number.isSafeInteger(reportedLinks)
435
+ && reportedLinks > verifiedLinks) {
436
+ captured = captureRegularFileBinding(file, {
437
+ subject: 'visual-cleanup',
438
+ expectedIdentity: identity,
439
+ expectedMode: identity.mode,
440
+ expectedLinks: reportedLinks,
441
+ ...(evidence ? {
442
+ expectedSha256: evidence.sha256,
443
+ expectedBytes: evidence.bytes,
444
+ } : {}),
445
+ });
446
+ }
447
+ if (captured.status !== 'captured') {
448
+ if (captured.reason?.systemCode === 'ENOENT') return { status: 'absent' };
449
+ return {
450
+ status: captured.status === 'different' || captured.status === 'unsupported'
451
+ ? 'different'
452
+ : 'unknown',
453
+ reason: captured.reason?.code?.includes('content-changed') ? 'content' : 'identity',
454
+ error: new Error(captured.reason?.code || 'visual cleanup binding failed'),
455
+ bindingState: captured.reason,
456
+ };
457
+ }
458
+ let removal;
459
+ let released;
460
+ try {
461
+ removal = quarantineRemoveRegularFileBinding(captured.binding, file, {
462
+ subject: 'visual-cleanup',
463
+ expectedLinks: captured.identity?.links ? Number(captured.identity.links) : verifiedLinks,
464
+ });
465
+ } finally {
466
+ released = releaseRegularFileBinding(captured.binding);
467
+ }
468
+ if (released.status !== 'released') {
469
+ return {
470
+ status: 'unknown',
471
+ reason: 'identity',
472
+ error: new Error(released.reason?.code || 'visual cleanup binding release failed'),
473
+ bindingState: released.reason,
474
+ };
475
+ }
476
+ if (removal.status === 'removed' || removal.status === 'absent') return removal;
477
+ return {
478
+ status: removal.status === 'preserved' || removal.status === 'different'
479
+ || removal.status === 'unsupported' ? 'different' : 'unknown',
480
+ reason: 'identity',
481
+ error: new Error(removal.reason?.code || 'visual cleanup quarantine failed'),
482
+ bindingState: removal.reason,
483
+ ...(removal.recoveryDirectory ? {
484
+ recoveryDirectory: removal.recoveryDirectory,
485
+ recoveryFile: removal.recoveryFile,
486
+ } : {}),
487
+ };
488
+ }
489
+
490
+ function backupOwnedPublishedEntry(file, backupPath, identity, evidence) {
491
+ const captured = captureRegularFileBinding(file, {
492
+ subject: 'previous-evidence',
493
+ expectedIdentity: identity,
494
+ expectedMode: identity.mode,
495
+ expectedSha256: evidence.sha256,
496
+ expectedBytes: evidence.bytes,
497
+ expectedLinks: 1,
498
+ });
499
+ if (captured.status !== 'captured') {
500
+ return { ...captured, backupCreated: false, backupVerified: false };
501
+ }
502
+ let backup;
503
+ let released;
504
+ try {
505
+ backup = backupPublicRegularFileBinding(captured.binding, file, backupPath, {
506
+ subject: 'previous-evidence',
507
+ });
508
+ } finally {
509
+ released = releaseRegularFileBinding(captured.binding);
510
+ }
511
+ if (released.status !== 'released') {
512
+ return {
513
+ status: 'recovery-required',
514
+ reason: {
515
+ code: 'previous-evidence-binding-release-failed',
516
+ bindingState: released.reason,
517
+ },
518
+ recoveryDirectory: backup?.recoveryDirectory || path.dirname(backupPath),
519
+ recoveryFile: backup?.recoveryFile || (backup?.backupCreated ? backupPath : undefined),
520
+ target: backup?.target || file,
521
+ backupCreated: backup?.backupCreated === true,
522
+ backupVerified: backup?.backupVerified === true,
523
+ };
524
+ }
525
+ return backup;
526
+ }
527
+
528
+ function publishedRemovalError(file, result) {
529
+ const code = result.reason?.code;
530
+ let reason;
531
+ if (result.status === 'preserved') {
532
+ reason = 'published entry changed during retirement; its successor was restored and preserved';
533
+ } else if (result.status === 'recovery-required') {
534
+ reason = 'published entry changed during retirement; recovery material was preserved';
535
+ } else if (code?.includes('content-changed')) {
536
+ reason = 'published entry content changed; it was preserved';
537
+ } else if (code?.includes('mode-changed')) {
538
+ reason = 'published entry mode changed; it was preserved';
539
+ } else if (result.status === 'different' || result.status === 'unsupported') {
540
+ reason = 'published entry identity changed; it was preserved';
541
+ } else {
542
+ reason = code || 'published entry could not be safely retired; it was preserved';
543
+ }
544
+ return {
545
+ file,
546
+ reason,
547
+ ...(code ? { code } : {}),
548
+ ...(result.recoveryDirectory ? { recoveryDirectory: result.recoveryDirectory } : {}),
549
+ ...(result.recoveryFile ? { recoveryFile: result.recoveryFile } : {}),
550
+ ...(result.target ? { target: result.target } : {}),
551
+ };
552
+ }
553
+
554
+ function cleanupStagedEvidence(ownership, { removeDirectory = false } = {}) {
555
+ const errors = [];
556
+ for (const [finalPath, entry] of [...ownership.stagedEntries]) {
557
+ const result = unlinkOwnedEntry(entry.path, entry.identity, { evidence: entry.evidence });
558
+ if (result.status === 'removed' || result.status === 'absent') {
559
+ ownership.stagedEntries.delete(finalPath);
560
+ } else {
561
+ errors.push({
562
+ file: entry.path,
563
+ reason: result.status === 'different' && result.reason === 'content'
564
+ ? 'staging entry content changed; it was preserved'
565
+ : result.status === 'different'
566
+ ? 'staging entry identity changed; it was preserved'
567
+ : result.error?.message,
568
+ });
569
+ }
570
+ }
571
+ const directories = [
572
+ ownership.inspection,
573
+ ...(removeDirectory ? [{
574
+ directory: ownership.stagingDirectory,
575
+ identity: ownership.stagingIdentity,
576
+ }] : []),
577
+ ].filter(Boolean);
578
+ for (const { directory, identity } of directories) {
579
+ try {
580
+ const stat = fs.lstatSync(directory, { bigint: true });
581
+ if (!identity || !identityMatches(stat, identity, { directory: true })) {
582
+ errors.push({
583
+ file: directory,
584
+ recoveryDirectory: directory,
585
+ reason: 'staging directory identity changed; it was preserved',
586
+ });
587
+ } else {
588
+ removeEmptyDirectoryWithRetry(directory);
589
+ }
590
+ } catch (error) {
591
+ if (error?.code !== 'ENOENT') {
592
+ errors.push({ file: directory, recoveryDirectory: directory, reason: error.message });
593
+ }
594
+ }
595
+ }
596
+ return errors;
597
+ }
598
+
599
+ function verifyVisualEvidenceTargets(artifactPath, outputs, ownership) {
600
+ for (const target of visualEvidenceTargets(outputs)) {
601
+ const captured = ownership.targets.get(target.path);
602
+ const verification = captured ? verifyAtomicOutput(captured.snapshot) : {
603
+ status: 'unknown',
604
+ reason: { code: 'evidence-snapshot-missing' },
605
+ };
606
+ if (verification.status !== 'match') {
607
+ return evidencePathConflict(artifactPath, outputs, target.path, verification.reason);
608
+ }
609
+ }
610
+ return { ok: true };
611
+ }
612
+
613
+ function beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs) {
614
+ const targets = visualEvidenceTargets(outputs);
615
+ const candidates = targets.map((target) => target.path);
616
+ const previousEvidence = new Map();
617
+ try {
618
+ fs.mkdirSync(path.dirname(outputs.receipt), { recursive: true });
619
+ } catch (error) {
620
+ return evidenceWriteFailure(
621
+ artifactPath,
622
+ 'visual-check could not create its evidence directory.',
623
+ [{ file: path.dirname(outputs.receipt), reason: error.message }],
624
+ );
625
+ }
626
+
627
+ const capturedTargets = new Map();
628
+ for (const target of targets) {
629
+ const captured = captureAtomicOutput(target.path, {
630
+ requestedEntryPolicy: 'regular-or-absent',
631
+ });
632
+ if (captured.status !== 'captured') {
633
+ return evidencePathConflict(artifactPath, outputs, target.path, captured.reason);
634
+ }
635
+ capturedTargets.set(target.path, captured);
636
+ }
637
+
638
+ const parent = capturedTargets.get(outputs.receipt).snapshot.slot;
639
+ for (const target of targets.slice(1)) {
640
+ const slot = capturedTargets.get(target.path).snapshot.slot;
641
+ if (slot.parentDevice !== parent.parentDevice || slot.parentInode !== parent.parentInode) {
642
+ return evidencePathConflict(artifactPath, outputs, target.path, {
643
+ code: 'evidence-parent-mismatch',
644
+ });
645
+ }
646
+ }
647
+
648
+ const existing = candidates.filter((file) => (
649
+ capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing'
650
+ ));
651
+ if (existing.length) {
652
+ const receiptEntry = capturedTargets.get(outputs.receipt).snapshot.requestedEntry;
653
+ if (receiptEntry.kind !== 'existing') {
654
+ return evidencePathConflict(artifactPath, outputs, existing[0], {
655
+ code: 'ownership-receipt-missing',
656
+ });
657
+ }
658
+
659
+ const previousReceipt = readRegularFileContents(outputs.receipt, {
660
+ subject: 'ownership-receipt',
661
+ });
662
+ const capturedReceipt = capturedTargets.get(outputs.receipt).snapshot.target;
663
+ if (!previousReceipt.ok || !evidenceIdentityMatches(previousReceipt.identity, {
664
+ device: capturedReceipt.device,
665
+ inode: capturedReceipt.inode,
666
+ mode: capturedReceipt.mode,
667
+ links: 1,
668
+ })) {
669
+ return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
670
+ code: 'ownership-receipt-invalid',
671
+ ...(previousReceipt.reason?.code ? { bindingCode: previousReceipt.reason.code } : {}),
672
+ ...(previousReceipt.reason?.systemCode
673
+ ? { systemCode: previousReceipt.reason.systemCode } : {}),
674
+ });
675
+ }
676
+ const previousBytes = previousReceipt.bytes;
677
+ let previous;
678
+ try {
679
+ previous = JSON.parse(previousBytes.toString('utf8'));
680
+ } catch (error) {
681
+ return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
682
+ code: 'ownership-receipt-invalid',
683
+ ...(error?.code ? { systemCode: error.code } : {}),
684
+ });
685
+ }
686
+ previousEvidence.set(outputs.receipt, {
687
+ ...previousReceipt.evidence,
688
+ });
689
+
690
+ if (previous?.schemaVersion !== 1 || previous?.command !== outputs.command) {
691
+ return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
692
+ code: 'ownership-receipt-schema-mismatch',
693
+ });
694
+ }
695
+ const recordedReceiptName = previous?.sidecars?.receipt;
696
+ const recordedContactName = previous?.sidecars?.contactSheet;
697
+ const recordedDirectory = previous?.sidecars?.directory || path.dirname(previous?.artifact?.path || '');
698
+ const receiptNameSafe = typeof recordedReceiptName === 'string'
699
+ // path-contract-allow: portable-logical-path -- receipt filenames are serialized logical basenames.
700
+ && path.basename(recordedReceiptName) === recordedReceiptName;
701
+ const contactNameSafe = outputs.capture === false || typeof recordedContactName === 'string'
702
+ // path-contract-allow: portable-logical-path -- contact-sheet filenames are serialized logical basenames.
703
+ && path.basename(recordedContactName) === recordedContactName;
704
+ if (!path.isAbsolute(recordedDirectory) || !receiptNameSafe || !contactNameSafe
705
+ || sameLocation(path.join(recordedDirectory, recordedReceiptName), outputs.receipt).status !== 'match'
706
+ || (outputs.capture !== false && sameLocation(path.join(recordedDirectory, recordedContactName), outputs.contactSheet).status !== 'match')) {
707
+ return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
708
+ code: 'ownership-sidecar-path-mismatch',
709
+ });
710
+ }
711
+ if (typeof previous?.artifact?.path !== 'string'
712
+ || !path.isAbsolute(previous.artifact.path)
713
+ || sameEntry(previous.artifact.path, artifactPath).status !== 'match'
714
+ || previous.artifact.sha256 !== sha256(artifactBytes)
715
+ || previous.artifact.bytes !== artifactBytes.byteLength) {
716
+ return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
717
+ code: 'ownership-artifact-mismatch',
718
+ });
719
+ }
720
+
721
+ const existingEvidence = visualEvidencePaths(outputs).filter((file) => (
722
+ capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing'
723
+ ));
724
+ const recordedEvidence = previous?.sidecars?.files;
725
+ if (existingEvidence.length && !Array.isArray(recordedEvidence)) {
726
+ return evidencePathConflict(artifactPath, outputs, existingEvidence[0], {
727
+ code: 'ownership-evidence-digests-missing',
728
+ });
729
+ }
730
+ if (Array.isArray(recordedEvidence)) {
731
+ const allowed = new Map(visualEvidencePaths(outputs).map((file) => [path.basename(file), file]));
732
+ const recorded = new Map();
733
+ for (const item of recordedEvidence) {
734
+ if (!item || typeof item.file !== 'string' || recorded.has(item.file) || !allowed.has(item.file)) {
735
+ return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
736
+ code: 'ownership-evidence-list-invalid',
737
+ });
738
+ }
739
+ recorded.set(item.file, item);
740
+ }
741
+ if (recorded.size !== existingEvidence.length
742
+ || existingEvidence.some((file) => !recorded.has(path.basename(file)))) {
743
+ return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
744
+ code: 'ownership-evidence-list-mismatch',
745
+ });
746
+ }
747
+ for (const file of existingEvidence) {
748
+ const actual = regularFileEvidence(file);
749
+ const expected = recorded.get(path.basename(file));
750
+ if (!actual.ok
751
+ || actual.evidence.sha256 !== expected.sha256
752
+ || actual.evidence.bytes !== expected.bytes) {
753
+ return evidencePathConflict(artifactPath, outputs, file, {
754
+ code: actual.ok ? 'ownership-evidence-digest-mismatch' : actual.reason.code,
755
+ ...(!actual.ok && actual.reason.systemCode
756
+ ? { systemCode: actual.reason.systemCode } : {}),
757
+ });
758
+ }
759
+ previousEvidence.set(file, {
760
+ sha256: actual.evidence.sha256,
761
+ bytes: actual.evidence.bytes,
762
+ });
763
+ }
764
+ }
765
+
766
+ }
767
+
768
+ for (const target of targets) {
769
+ const verification = verifyAtomicOutput(capturedTargets.get(target.path).snapshot);
770
+ if (verification.status !== 'match') {
771
+ return evidencePathConflict(artifactPath, outputs, target.path, verification.reason);
772
+ }
773
+ }
774
+
775
+ let stagingDirectory;
776
+ let stagingStat;
777
+ try {
778
+ stagingDirectory = fs.mkdtempSync(path.toNamespacedPath(
779
+ path.join(parent.parentPath, '.archify-visual-check-'),
780
+ ));
781
+ stagingStat = fs.lstatSync(stagingDirectory, { bigint: true });
782
+ if (!stagingStat.isDirectory() || stagingStat.isSymbolicLink() || stagingStat.ino === 0n) {
783
+ throw new Error('the newly created staging directory has no stable directory identity');
784
+ }
785
+ } catch (error) {
786
+ return evidenceWriteFailure(
787
+ artifactPath,
788
+ 'visual-check could not create a private evidence staging directory.',
789
+ [{ file: parent.parentPath, reason: error.message }],
790
+ );
791
+ }
792
+
793
+ const staged = stagedOutputs(outputs, stagingDirectory);
794
+ const ownership = {
795
+ active: true,
796
+ artifact: path.resolve(artifactPath),
797
+ receipt: outputs.receipt,
798
+ targets: capturedTargets,
799
+ stagingDirectory,
800
+ stagingIdentity: fileIdentity(stagingStat),
801
+ stagedOutputs: staged,
802
+ stagingPaths: new Map([
803
+ [outputs.receipt, staged.receipt],
804
+ [outputs.contactSheet, staged.contactSheet],
805
+ ...outputs.screenshots.map((entry, index) => [entry.path, staged.screenshots[index].path]),
806
+ ]),
807
+ stagedEntries: new Map(),
808
+ previousEvidence,
809
+ };
810
+ visualEvidenceOwnerships.add(ownership);
811
+ return { ok: true, ownership };
812
+ }
813
+
814
+ function recordVisualEvidenceFiles(receipt, outputs, ownership) {
815
+ const files = [];
816
+ for (const file of visualEvidencePaths(outputs)) {
817
+ const staged = ownership.stagedEntries.get(file);
818
+ if (!staged || !currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
819
+ const error = new Error(`visual-check could not bind evidence file "${file}" to its receipt.`);
820
+ error.evidenceReason = { code: 'staged-evidence-content-mismatch' };
821
+ throw error;
822
+ }
823
+ files.push({ ...staged.evidence, file: path.basename(file) });
824
+ }
825
+ receipt.sidecars.files = files;
826
+ }
827
+
828
+ function currentEvidenceMatches(file, identity, evidence, {
829
+ expectedLinks = identity.links ?? 1,
830
+ } = {}) {
831
+ if (!evidence) return false;
832
+ const current = regularFileEvidence(file, { expectedLinks: Number(expectedLinks) });
833
+ return current.ok
834
+ && evidenceIdentityMatches(current.identity, identity, expectedLinks)
835
+ && current.evidence.sha256 === evidence.sha256
836
+ && current.evidence.bytes === evidence.bytes;
837
+ }
838
+
839
+ function restoreOwnedBackup(backup, errors) {
840
+ const expectedLinks = backup.identity.links ?? 1n;
841
+ const expectedLinkCount = Number(expectedLinks);
842
+ let backupStat;
843
+ try {
844
+ backupStat = fs.lstatSync(backup.path, { bigint: true });
845
+ } catch {
846
+ errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' });
847
+ return;
848
+ }
849
+ if (!identityMatches(backupStat, backup.identity) || backupStat.nlink !== expectedLinks) {
850
+ errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' });
851
+ return;
852
+ }
853
+ let captured = backup.evidence && Number.isSafeInteger(expectedLinkCount)
854
+ ? captureRegularFileBinding(backup.path, {
855
+ subject: 'restore-backup',
856
+ expectedIdentity: backup.identity,
857
+ expectedMode: backup.identity.mode,
858
+ expectedSha256: backup.evidence.sha256,
859
+ expectedBytes: backup.evidence.bytes,
860
+ expectedLinks: expectedLinkCount,
861
+ })
862
+ : { status: 'unknown', reason: { code: 'restore-backup-evidence-unavailable' } };
863
+ let contentChanged = false;
864
+ if (captured.status !== 'captured'
865
+ && captured.reason?.code === 'restore-backup-content-changed') {
866
+ captured = captureRegularFileBinding(backup.path, {
867
+ subject: 'restore-backup-claimant',
868
+ expectedIdentity: backup.identity,
869
+ expectedMode: backup.identity.mode,
870
+ expectedLinks: expectedLinkCount,
871
+ });
872
+ contentChanged = captured.status === 'captured';
873
+ }
874
+ if (captured.status !== 'captured') {
875
+ errors.push({
876
+ file: backup.path,
877
+ reason: 'backup bytes, mode, or identity changed; it was preserved',
878
+ ...(captured.reason?.code ? { code: captured.reason.code } : {}),
879
+ });
880
+ return;
881
+ }
882
+ let released;
883
+ try {
884
+ const finalEntry = pathEntry(backup.finalPath);
885
+ if (finalEntry.exists === true) {
886
+ errors.push({
887
+ file: backup.path,
888
+ reason: 'final path is occupied; the owned backup was preserved',
889
+ });
890
+ return;
891
+ }
892
+ if (finalEntry.exists === null) {
893
+ errors.push({ file: backup.finalPath, reason: finalEntry.error?.message });
894
+ return;
895
+ }
896
+ try {
897
+ fs.linkSync(backup.path, backup.finalPath);
898
+ const linkedCount = expectedLinkCount + 1;
899
+ const staged = verifyRegularFileBinding(captured.binding, {
900
+ filePath: backup.path,
901
+ expectedLinks: linkedCount,
902
+ });
903
+ const published = verifyRegularFileBinding(captured.binding, {
904
+ filePath: backup.finalPath,
905
+ expectedLinks: linkedCount,
906
+ });
907
+ if (staged.status !== 'match' || published.status !== 'match') {
908
+ const removed = quarantineRemoveRegularFileBinding(
909
+ captured.binding,
910
+ backup.finalPath,
911
+ { subject: 'restored-evidence', expectedLinks: linkedCount },
912
+ );
913
+ errors.push({
914
+ file: backup.path,
915
+ reason: removed.status === 'removed'
916
+ ? 'restored entry identity could not be verified; the owned backup was preserved'
917
+ : removed.status === 'preserved'
918
+ ? 'restored entry changed during cleanup; its successor and the owned backup were preserved'
919
+ : 'restored entry changed before rollback; both entries were preserved',
920
+ ...(removed.reason?.code ? { code: removed.reason.code } : {}),
921
+ ...(removed.recoveryDirectory ? { recoveryDirectory: removed.recoveryDirectory } : {}),
922
+ ...(removed.recoveryFile ? { recoveryFile: removed.recoveryFile } : {}),
923
+ });
924
+ return;
925
+ }
926
+ const removed = unlinkOwnedEntry(backup.path, backup.identity, {
927
+ evidence: captured.content,
928
+ expectedLinks: linkedCount,
929
+ });
930
+ const finalMatches = verifyRegularFileBinding(captured.binding, {
931
+ filePath: backup.finalPath,
932
+ expectedLinks: expectedLinkCount,
933
+ }).status === 'match';
934
+ if (removed.status !== 'removed' || !finalMatches) {
935
+ errors.push({ file: backup.path, reason: 'restored entry link count could not be finalized' });
936
+ } else if (contentChanged) {
937
+ errors.push({
938
+ file: backup.finalPath,
939
+ reason: 'the backup content changed externally; its current bytes were restored and preserved',
940
+ });
941
+ }
942
+ } catch (error) {
943
+ errors.push({
944
+ file: backup.path,
945
+ reason: error?.code === 'EEXIST'
946
+ ? 'final path was claimed during restore; the owned backup was preserved'
947
+ : error.message,
948
+ });
949
+ }
950
+ } finally {
951
+ released = releaseRegularFileBinding(captured.binding);
952
+ if (released.status !== 'released') {
953
+ errors.push({
954
+ file: backup.path,
955
+ reason: 'restored backup binding could not be released cleanly',
956
+ ...(released.reason?.code ? { code: released.reason.code } : {}),
957
+ });
958
+ }
959
+ }
960
+ }
961
+
962
+ function commitVisualEvidence(artifactPath, outputs, ownership, desiredPaths) {
963
+ const verification = verifyVisualEvidenceTargets(artifactPath, outputs, ownership);
964
+ if (!verification.ok) {
965
+ const cleanupErrors = cleanupStagedEvidence(ownership, { removeDirectory: true });
966
+ if (cleanupErrors.length) verification.diagnostic.evidence.rollbackErrors = cleanupErrors;
967
+ ownership.active = false;
968
+ return verification;
969
+ }
970
+
971
+ const desired = new Set(desiredPaths);
972
+ const stagedBindings = new Map();
973
+ const releaseStagedBindings = () => {
974
+ const failures = [];
975
+ for (const [finalPath, binding] of stagedBindings) {
976
+ const released = releaseRegularFileBinding(binding);
977
+ if (released.status !== 'released') {
978
+ failures.push({
979
+ file: finalPath,
980
+ reason: released.reason?.code,
981
+ bindingState: released.reason,
982
+ ...(released.reason?.recoveryDirectory
983
+ ? { recoveryDirectory: released.reason.recoveryDirectory } : {}),
984
+ });
985
+ }
986
+ }
987
+ stagedBindings.clear();
988
+ return failures;
989
+ };
990
+ for (const finalPath of desired) {
991
+ const staged = ownership.stagedEntries.get(finalPath);
992
+ const captured = staged ? captureRegularFileBinding(staged.path, {
993
+ subject: 'staged-evidence',
994
+ expectedIdentity: staged.identity,
995
+ expectedMode: staged.identity.mode,
996
+ expectedSha256: staged.evidence?.sha256,
997
+ expectedBytes: staged.evidence?.bytes,
998
+ expectedLinks: 1,
999
+ }) : null;
1000
+ if (!staged || captured?.status !== 'captured') {
1001
+ const failure = evidenceWriteFailure(
1002
+ artifactPath,
1003
+ 'visual-check could not verify its staged evidence before publication.',
1004
+ [{
1005
+ file: staged?.path || stagedPathFor(ownership, finalPath),
1006
+ reason: captured?.reason?.code || 'staged entry missing or changed',
1007
+ }],
1008
+ );
1009
+ failure.diagnostic.evidence.errors.push(
1010
+ ...releaseStagedBindings(),
1011
+ ...cleanupStagedEvidence(ownership, { removeDirectory: true }),
1012
+ );
1013
+ ownership.active = false;
1014
+ return failure;
1015
+ }
1016
+ stagedBindings.set(finalPath, captured.binding);
1017
+ }
1018
+
1019
+ const backups = [];
1020
+ const published = [];
1021
+ let conflict = null;
1022
+ try {
1023
+ for (const [index, target] of visualEvidenceTargets(outputs).entries()) {
1024
+ const captured = ownership.targets.get(target.path);
1025
+ if (captured.snapshot.target.kind !== 'file') continue;
1026
+ const backupPath = path.join(ownership.stagingDirectory, `previous-${index}`);
1027
+ const identity = {
1028
+ device: captured.snapshot.target.device,
1029
+ inode: captured.snapshot.target.inode,
1030
+ mode: captured.snapshot.target.mode,
1031
+ links: 1n,
1032
+ };
1033
+ const evidence = ownership.previousEvidence.get(target.path);
1034
+ if (!evidence) {
1035
+ conflict = evidencePathConflict(artifactPath, outputs, target.path, {
1036
+ code: 'target-evidence-missing-during-commit',
1037
+ });
1038
+ throw new Error(conflict.error);
1039
+ }
1040
+ const backup = {
1041
+ targetPath: target.path,
1042
+ finalPath: captured.commitPath,
1043
+ path: backupPath,
1044
+ identity,
1045
+ evidence,
1046
+ };
1047
+ const backupResult = backupOwnedPublishedEntry(
1048
+ captured.commitPath,
1049
+ backupPath,
1050
+ identity,
1051
+ evidence,
1052
+ );
1053
+ if (backupResult.backupCreated) backups.push(backup);
1054
+ if (backupResult.status !== 'backed-up'
1055
+ || !backupResult.backupCreated
1056
+ || !backupResult.backupVerified) {
1057
+ conflict = evidencePathConflict(artifactPath, outputs, target.path, {
1058
+ code: backupResult.reason?.code || 'target-backup-failed-during-commit',
1059
+ backupCreated: backupResult.backupCreated === true,
1060
+ backupVerified: backupResult.backupVerified === true,
1061
+ ...(backupResult.recoveryDirectory
1062
+ ? { recoveryDirectory: backupResult.recoveryDirectory } : {}),
1063
+ ...(backupResult.recoveryFile ? { recoveryFile: backupResult.recoveryFile } : {}),
1064
+ ...(backupResult.target ? { target: backupResult.target } : {}),
1065
+ ...(backupResult.reason ? { bindingState: backupResult.reason } : {}),
1066
+ });
1067
+ throw new Error(conflict.error);
1068
+ }
1069
+ }
1070
+
1071
+ const publicationOrder = visualEvidenceTargets(outputs)
1072
+ .filter((target) => desired.has(target.path))
1073
+ .sort((left, right) => Number(left.kind === 'receipt') - Number(right.kind === 'receipt'));
1074
+ for (const target of publicationOrder) {
1075
+ const captured = ownership.targets.get(target.path);
1076
+ const staged = ownership.stagedEntries.get(target.path);
1077
+ const stagedBinding = stagedBindings.get(target.path);
1078
+ const beforeLink = verifyRegularFileBinding(stagedBinding, {
1079
+ filePath: staged.path,
1080
+ expectedLinks: 1,
1081
+ });
1082
+ if (beforeLink.status !== 'match') {
1083
+ conflict = evidencePathConflict(artifactPath, outputs, target.path, {
1084
+ code: beforeLink.reason?.code?.includes('content-changed')
1085
+ ? 'staged-evidence-content-mismatch'
1086
+ : beforeLink.reason?.code || 'staged-evidence-content-mismatch',
1087
+ });
1088
+ throw new Error(conflict.error);
1089
+ }
1090
+ try {
1091
+ fs.linkSync(staged.path, captured.commitPath);
1092
+ } catch (error) {
1093
+ if (error?.code === 'EEXIST') {
1094
+ conflict = evidencePathConflict(artifactPath, outputs, target.path, {
1095
+ code: 'target-created-during-commit',
1096
+ });
1097
+ }
1098
+ throw error;
1099
+ }
1100
+ published.push({
1101
+ finalPath: captured.commitPath,
1102
+ stagedPath: staged.path,
1103
+ identity: staged.identity,
1104
+ evidence: staged.evidence,
1105
+ binding: stagedBinding,
1106
+ });
1107
+ const stagedLinked = verifyRegularFileBinding(stagedBinding, {
1108
+ filePath: staged.path,
1109
+ expectedLinks: 2,
1110
+ });
1111
+ const publishedLinked = verifyRegularFileBinding(stagedBinding, {
1112
+ filePath: captured.commitPath,
1113
+ expectedLinks: 2,
1114
+ });
1115
+ if (stagedLinked.status !== 'match' || publishedLinked.status !== 'match') {
1116
+ conflict = evidencePathConflict(artifactPath, outputs, target.path, {
1117
+ code: 'published-entry-identity-mismatch',
1118
+ stagedState: stagedLinked.reason,
1119
+ publishedState: publishedLinked.reason,
1120
+ });
1121
+ throw new Error(conflict.error);
1122
+ }
1123
+ const removed = quarantineRemoveRegularFileBinding(stagedBinding, staged.path, {
1124
+ subject: 'staged-evidence',
1125
+ expectedLinks: 2,
1126
+ });
1127
+ if (removed.status !== 'removed' && removed.status !== 'preserved') {
1128
+ conflict = evidencePathConflict(artifactPath, outputs, target.path, {
1129
+ code: removed.reason?.code || 'published-entry-identity-mismatch',
1130
+ });
1131
+ throw new Error(`visual-check could not retire staged evidence "${staged.path}".`);
1132
+ }
1133
+ if (removed.status === 'removed') ownership.stagedEntries.delete(target.path);
1134
+ const finalized = verifyRegularFileBinding(stagedBinding, {
1135
+ filePath: captured.commitPath,
1136
+ expectedLinks: 1,
1137
+ });
1138
+ if (finalized.status !== 'match') {
1139
+ conflict = evidencePathConflict(artifactPath, outputs, target.path, {
1140
+ code: 'published-entry-link-count-mismatch',
1141
+ bindingState: finalized.reason,
1142
+ });
1143
+ throw new Error(conflict.error);
1144
+ }
1145
+ }
1146
+
1147
+ // Receipt publication is the linearization point for the evidence set.
1148
+ // Recheck every earlier member afterwards so a writer that replaces a PNG
1149
+ // or the contact sheet while later members publish cannot yield success.
1150
+ for (const entry of published) {
1151
+ const finalState = verifyRegularFileBinding(entry.binding, {
1152
+ filePath: entry.finalPath,
1153
+ expectedLinks: 1,
1154
+ });
1155
+ if (finalState.status !== 'match') {
1156
+ conflict = evidencePathConflict(artifactPath, outputs, entry.finalPath, {
1157
+ code: 'published-set-identity-mismatch',
1158
+ bindingState: finalState.reason,
1159
+ });
1160
+ throw new Error(conflict.error);
1161
+ }
1162
+ }
1163
+
1164
+ for (const backup of backups) {
1165
+ if (!currentEvidenceMatches(
1166
+ backup.path,
1167
+ backup.identity,
1168
+ ownership.previousEvidence.get(backup.targetPath),
1169
+ )) {
1170
+ conflict = evidencePathConflict(artifactPath, outputs, backup.targetPath, {
1171
+ code: 'backup-content-changed-before-cleanup',
1172
+ });
1173
+ throw new Error(conflict.error);
1174
+ }
1175
+ }
1176
+
1177
+ // SMB keeps removed staging names pending until every bound handle closes.
1178
+ // Publication and backup verification are complete before directory cleanup.
1179
+ const cleanupErrors = releaseStagedBindings();
1180
+ for (const backup of backups) {
1181
+ const removed = unlinkOwnedEntry(backup.path, backup.identity, {
1182
+ evidence: backup.evidence,
1183
+ });
1184
+ if (removed.status !== 'removed' && removed.status !== 'absent') {
1185
+ cleanupErrors.push({
1186
+ file: backup.path,
1187
+ reason: removed.status === 'different' && removed.reason === 'content'
1188
+ ? 'backup content changed; it was preserved'
1189
+ : removed.status === 'different'
1190
+ ? 'backup identity changed; it was preserved'
1191
+ : removed.error?.message,
1192
+ });
1193
+ }
1194
+ }
1195
+ cleanupErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true }));
1196
+ ownership.active = false;
1197
+ let recoveryDirectory;
1198
+ if (cleanupErrors.length) {
1199
+ try {
1200
+ const retained = fs.lstatSync(ownership.stagingDirectory, { bigint: true });
1201
+ if (identityMatches(retained, ownership.stagingIdentity, { directory: true })) {
1202
+ recoveryDirectory = ownership.stagingDirectory;
1203
+ }
1204
+ } catch {}
1205
+ recoveryDirectory ||= cleanupErrors.find((entry) => entry.recoveryDirectory)?.recoveryDirectory;
1206
+ }
1207
+ return {
1208
+ ok: true,
1209
+ status: cleanupErrors.length ? 'committed-with-warning' : 'committed',
1210
+ cleanupErrors,
1211
+ ...(recoveryDirectory ? { recoveryDirectory } : {}),
1212
+ committedReceipt: outputs.receipt,
1213
+ };
1214
+ } catch (error) {
1215
+ const rollbackErrors = [];
1216
+ for (const entry of [...published].reverse()) {
1217
+ let removed;
1218
+ let ownedMismatch;
1219
+ for (const expectedLinks of [2, 1]) {
1220
+ const matches = verifyRegularFileBinding(entry.binding, {
1221
+ filePath: entry.finalPath,
1222
+ expectedLinks,
1223
+ });
1224
+ if (matches.status === 'match') {
1225
+ removed = quarantineRemoveRegularFileBinding(
1226
+ entry.binding,
1227
+ entry.finalPath,
1228
+ { subject: 'published-evidence', expectedLinks },
1229
+ );
1230
+ break;
1231
+ }
1232
+ if (matches.reason?.code?.includes('content-changed')
1233
+ || matches.reason?.code?.includes('mode-changed')) {
1234
+ ownedMismatch = matches;
1235
+ } else {
1236
+ ownedMismatch ||= matches;
1237
+ }
1238
+ const reportedLinks = Number(matches.reason?.links);
1239
+ if (matches.status === 'unsupported'
1240
+ && matches.reason?.code === 'staged-evidence-hardlinked'
1241
+ && Number.isSafeInteger(reportedLinks)
1242
+ && reportedLinks > expectedLinks) {
1243
+ const current = verifyRegularFileBinding(entry.binding, {
1244
+ filePath: entry.finalPath,
1245
+ expectedLinks: reportedLinks,
1246
+ });
1247
+ if (current.status === 'match') {
1248
+ removed = quarantineRemoveRegularFileBinding(
1249
+ entry.binding,
1250
+ entry.finalPath,
1251
+ { subject: 'published-evidence', expectedLinks: reportedLinks },
1252
+ );
1253
+ break;
1254
+ }
1255
+ }
1256
+ }
1257
+ if (!removed) {
1258
+ const claimantRemoval = quarantineRemoveLinkedRegularFileAlias(
1259
+ entry.stagedPath,
1260
+ entry.finalPath,
1261
+ { subject: 'published-evidence-claimant' },
1262
+ );
1263
+ removed = claimantRemoval.status === 'removed' ? claimantRemoval : ownedMismatch;
1264
+ }
1265
+ if (removed.status !== 'removed' && removed.status !== 'absent') {
1266
+ rollbackErrors.push(publishedRemovalError(entry.finalPath, removed));
1267
+ }
1268
+ }
1269
+ for (const backup of [...backups].reverse()) restoreOwnedBackup(backup, rollbackErrors);
1270
+ rollbackErrors.push(...releaseStagedBindings());
1271
+ rollbackErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true }));
1272
+ ownership.active = false;
1273
+ if (conflict) {
1274
+ if (rollbackErrors.length) conflict.diagnostic.evidence.rollbackErrors = rollbackErrors;
1275
+ return conflict;
1276
+ }
1277
+ return evidenceWriteFailure(
1278
+ artifactPath,
1279
+ 'visual-check could not atomically publish its evidence set.',
1280
+ [{ reason: error.message }, ...rollbackErrors],
1281
+ );
1282
+ } finally {
1283
+ releaseStagedBindings();
1284
+ }
80
1285
  }
81
1286
 
82
1287
  function executable(file, platform = process.platform) {
@@ -89,24 +1294,38 @@ function executable(file, platform = process.platform) {
89
1294
  }
90
1295
  }
91
1296
 
92
- function findOnPath(command, env, platform) {
93
- const directories = String(env.PATH || '').split(path.delimiter).filter(Boolean);
1297
+ function findOnPath(command, env, platform, resolveExecutable) {
1298
+ // Use the target platform's path grammar, not the host's: these candidates
1299
+ // are checked through an injected platform (see the findChrome tests), and
1300
+ // the host `path` object on Windows would join POSIX directories with
1301
+ // backslashes and split PATH on ';'.
1302
+ const pathApi = platform === 'win32' ? path.win32 : path.posix;
1303
+ const directories = String(env.PATH || '').split(pathApi.delimiter).filter(Boolean);
94
1304
  const extensions = platform === 'win32'
95
1305
  ? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
96
1306
  : [''];
97
1307
  for (const directory of directories) {
98
1308
  for (const extension of extensions) {
99
- const candidate = path.join(directory, `${command}${extension}`);
100
- const resolved = executable(candidate, platform);
1309
+ const candidate = pathApi.join(directory, `${command}${extension}`);
1310
+ const resolved = resolveExecutable(candidate, platform);
101
1311
  if (resolved) return resolved;
102
1312
  }
103
1313
  }
104
1314
  return null;
105
1315
  }
106
1316
 
107
- export function findChrome({ env = process.env, platform = process.platform } = {}) {
1317
+ export function findChrome({
1318
+ env = process.env,
1319
+ platform = process.platform,
1320
+ resolveExecutable = executable,
1321
+ } = {}) {
1322
+ // Windows environment names are case-insensitive, including when callers
1323
+ // provide a plain object instead of the special process.env object.
1324
+ if (platform === 'win32') {
1325
+ env = Object.fromEntries(Object.entries(env).map(([name, value]) => [name.toUpperCase(), value]));
1326
+ }
108
1327
  if (Object.prototype.hasOwnProperty.call(env, 'ARCHIFY_CHROME')) {
109
- return executable(env.ARCHIFY_CHROME, platform);
1328
+ return resolveExecutable(env.ARCHIFY_CHROME, platform);
110
1329
  }
111
1330
 
112
1331
  const fixed = [];
@@ -117,41 +1336,91 @@ export function findChrome({ env = process.env, platform = process.platform } =
117
1336
  '/Applications/Chromium.app/Contents/MacOS/Chromium',
118
1337
  );
119
1338
  } else if (platform === 'win32') {
1339
+ const pathApi = path.win32;
120
1340
  for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
121
1341
  fixed.push(
122
- path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
123
- path.join(root, 'Chromium', 'Application', 'chrome.exe'),
1342
+ pathApi.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
1343
+ pathApi.join(root, 'Chromium', 'Application', 'chrome.exe'),
124
1344
  );
125
1345
  }
1346
+ commands.push('chrome', 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser');
126
1347
  } else {
127
1348
  commands.push('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser');
128
1349
  }
129
1350
 
130
1351
  for (const candidate of fixed) {
131
- const resolved = executable(candidate, platform);
1352
+ const resolved = resolveExecutable(candidate, platform);
132
1353
  if (resolved) return resolved;
133
1354
  }
134
1355
  for (const command of commands) {
135
- const resolved = findOnPath(command, env, platform);
1356
+ const resolved = findOnPath(command, env, platform, resolveExecutable);
1357
+ if (resolved) return resolved;
1358
+ }
1359
+ // Preserve every Chrome/Chromium candidate's priority, including portable
1360
+ // PATH installations, before falling back to Windows' bundled browser.
1361
+ if (platform === 'win32') {
1362
+ for (const root of [env['PROGRAMFILES(X86)'], env.PROGRAMFILES, env.LOCALAPPDATA].filter(Boolean)) {
1363
+ const candidate = path.win32.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe');
1364
+ const resolved = resolveExecutable(candidate, platform);
1365
+ if (resolved) return resolved;
1366
+ }
1367
+ return findOnPath('msedge', env, platform, resolveExecutable);
1368
+ }
1369
+ // macOS and Linux: probe Edge behind every Chrome/Chromium candidate, the
1370
+ // same way Windows does. #920 asked for `microsoft-edge`,
1371
+ // `microsoft-edge-stable`, and the macOS application bundle.
1372
+ if (platform === 'darwin') {
1373
+ const edgeApp = '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge';
1374
+ const resolved = resolveExecutable(edgeApp, platform);
1375
+ if (resolved) return resolved;
1376
+ }
1377
+ for (const command of ['microsoft-edge', 'microsoft-edge-stable']) {
1378
+ const resolved = findOnPath(command, env, platform, resolveExecutable);
136
1379
  if (resolved) return resolved;
137
1380
  }
138
1381
  return null;
139
1382
  }
140
1383
 
141
1384
  class PipeCdp {
142
- constructor(child, { failureDetails = () => '' } = {}) {
1385
+ constructor(child, { failureDetails = () => '', socketUrl = null } = {}) {
143
1386
  this.child = child;
144
1387
  this.failureDetails = failureDetails;
145
1388
  this.nextId = 1;
146
1389
  this.buffer = '';
1390
+ this.receivedBytes = 0;
1391
+ this.receivedMessages = 0;
1392
+ this.completedWrites = 0;
1393
+ this.writtenBytes = 0;
147
1394
  this.pending = new Map();
148
1395
  this.waiters = [];
1396
+ this.subscribers = new Set();
1397
+ if (socketUrl) {
1398
+ // Bun does not preserve Chrome's extra inherited pipe descriptors on macOS.
1399
+ // Keep the CDP protocol and receipts; use a private loopback WebSocket instead.
1400
+ this.readPipe = { readable: true, readableEnded: false, destroyed: false };
1401
+ this.writePipe = { writable: true, writableEnded: false, destroyed: false, writableLength: 0 };
1402
+ this.socketPromise = Promise.resolve(socketUrl).then(url => new Promise((resolve, reject) => {
1403
+ const socket = new WebSocket(url);
1404
+ socket.addEventListener('open', () => resolve(socket), { once: true });
1405
+ socket.addEventListener('message', event => this.consume(String(event.data) + '\0'));
1406
+ socket.addEventListener('error', () => { const error = new Error('Chrome WebSocket failed'); reject(error); this.failAll(error); });
1407
+ socket.addEventListener('close', () => this.failAll(new Error('Chrome WebSocket closed')));
1408
+ }));
1409
+ this.socketPromise.catch(error => this.failAll(error));
1410
+ this.writePipe.write = (message, callback) => {
1411
+ this.socketPromise.then(socket => { socket.send(message.slice(0, -1)); callback(); }, callback);
1412
+ };
1413
+ } else {
149
1414
  this.writePipe = child.stdio[3];
150
1415
  this.readPipe = child.stdio[4];
151
1416
  this.readPipe.setEncoding('utf8');
152
1417
  this.readPipe.on('data', (chunk) => this.consume(chunk));
153
1418
  this.writePipe.on('error', (error) => this.failAll(this.failure('write pipe', error)));
154
1419
  this.readPipe.on('error', (error) => this.failAll(this.failure('read pipe', error)));
1420
+ this.readPipe.once('end', () => this.failAll(this.failure('read pipe', new Error('stream ended'))));
1421
+ this.readPipe.once('close', () => this.failAll(this.failure('read pipe', new Error('stream closed'))));
1422
+ this.writePipe.once('close', () => this.failAll(this.failure('write pipe', new Error('stream closed'))));
1423
+ }
155
1424
  child.once('error', (error) => this.failAll(this.failure('process launch', error)));
156
1425
  child.once('close', (code, signal) => {
157
1426
  const ending = signal ? `signal ${signal}` : `exit code ${code}`;
@@ -162,13 +1431,20 @@ class PipeCdp {
162
1431
  failure(stage, error) {
163
1432
  const code = error?.code ? ` [${error.code}]` : '';
164
1433
  const details = this.failureDetails();
165
- return new Error([
1434
+ const failure = new Error([
166
1435
  `Chrome DevTools ${stage} failed: ${error?.message || String(error)}${code}`,
167
1436
  details,
1437
+ `Chrome transport: Node ${process.version}, libuv ${process.versions.uv}, ${process.platform}; pid=${this.child.pid ?? 'unavailable'}, exitCode=${this.child.exitCode}, signalCode=${this.child.signalCode}.`,
1438
+ `Chrome read pipe: readable=${this.readPipe.readable}, ended=${this.readPipe.readableEnded}, destroyed=${this.readPipe.destroyed}, receivedBytes=${this.receivedBytes}, messages=${this.receivedMessages}, bufferedBytes=${Buffer.byteLength(this.buffer)}.`,
1439
+ `Chrome write pipe: writable=${this.writePipe.writable}, ended=${this.writePipe.writableEnded}, destroyed=${this.writePipe.destroyed}, completedWrites=${this.completedWrites}, writtenBytes=${this.writtenBytes}, bufferedBytes=${this.writePipe.writableLength}.`,
168
1440
  ].filter(Boolean).join('\n'));
1441
+ if (error?.code) failure.code = error.code;
1442
+ if (error?.method) failure.method = error.method;
1443
+ return failure;
169
1444
  }
170
1445
 
171
1446
  consume(chunk) {
1447
+ this.receivedBytes += Buffer.byteLength(chunk);
172
1448
  this.buffer += chunk;
173
1449
  let boundary;
174
1450
  while ((boundary = this.buffer.indexOf('\0')) >= 0) {
@@ -182,6 +1458,7 @@ class PipeCdp {
182
1458
  this.failAll(new Error(`Chrome DevTools returned invalid JSON: ${error.message}`));
183
1459
  continue;
184
1460
  }
1461
+ this.receivedMessages += 1;
185
1462
  if (message.id) {
186
1463
  const pending = this.pending.get(message.id);
187
1464
  if (!pending) continue;
@@ -191,6 +1468,7 @@ class PipeCdp {
191
1468
  else pending.resolve(message.result || {});
192
1469
  continue;
193
1470
  }
1471
+ for (const subscriber of this.subscribers) subscriber(message);
194
1472
  for (const waiter of [...this.waiters]) {
195
1473
  if (waiter.method !== message.method) continue;
196
1474
  if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue;
@@ -201,6 +1479,11 @@ class PipeCdp {
201
1479
  }
202
1480
  }
203
1481
 
1482
+ subscribe(callback) {
1483
+ this.subscribers.add(callback);
1484
+ return () => this.subscribers.delete(callback);
1485
+ }
1486
+
204
1487
  send(method, params = {}, sessionId = undefined, timeoutMs = 15000) {
205
1488
  const id = this.nextId++;
206
1489
  const message = { id, method, params };
@@ -208,12 +1491,21 @@ class PipeCdp {
208
1491
  return new Promise((resolve, reject) => {
209
1492
  const timer = setTimeout(() => {
210
1493
  this.pending.delete(id);
211
- reject(new Error(`${method}: timed out after ${timeoutMs}ms`));
1494
+ const error = new Error(`${method}: timed out after ${timeoutMs}ms`);
1495
+ error.code = 'ERR_CHROME_CDP_TIMEOUT';
1496
+ error.method = method;
1497
+ reject(this.failure('protocol timeout', error));
212
1498
  }, timeoutMs);
213
1499
  this.pending.set(id, { method, resolve, reject, timer });
214
1500
  try {
215
- this.writePipe.write(`${JSON.stringify(message)}\0`, (error) => {
216
- if (error) this.failAll(this.failure('write pipe', error));
1501
+ const serialized = `${JSON.stringify(message)}\0`;
1502
+ this.writePipe.write(serialized, (error) => {
1503
+ if (error) {
1504
+ this.failAll(this.failure('write pipe', error));
1505
+ } else {
1506
+ this.completedWrites += 1;
1507
+ this.writtenBytes += Buffer.byteLength(serialized);
1508
+ }
217
1509
  });
218
1510
  } catch (error) {
219
1511
  this.failAll(this.failure('write pipe', error));
@@ -226,7 +1518,7 @@ class PipeCdp {
226
1518
  const waiter = { method, sessionId, resolve, reject, timer: null };
227
1519
  waiter.timer = setTimeout(() => {
228
1520
  this.waiters.splice(this.waiters.indexOf(waiter), 1);
229
- reject(new Error(`${method}: event timed out after ${timeoutMs}ms`));
1521
+ reject(this.failure('event timeout', new Error(`${method}: event timed out after ${timeoutMs}ms`)));
230
1522
  }, timeoutMs);
231
1523
  this.waiters.push(waiter);
232
1524
  });
@@ -289,16 +1581,37 @@ async function evaluate(cdp, sessionId, expression, awaitPromise = false) {
289
1581
  return response.result?.value;
290
1582
  }
291
1583
 
1584
+ function browserEnvironment(env, platform) {
1585
+ if (platform !== 'win32') return env;
1586
+ // Agent-launched Git Bash can inherit this app-health shim. Edge then
1587
+ // relaunches and loses the DevTools pipes (#790). Change the browser's copy
1588
+ // only, and leave unknown/combined layers (including privilege settings) alone.
1589
+ return Object.fromEntries(Object.entries(env).filter(([name, value]) =>
1590
+ !(name.toUpperCase() === '__COMPAT_LAYER'
1591
+ && /^\s*DetectorsAppHealth\s*$/i.test(String(value)))));
1592
+ }
1593
+
292
1594
  export class ChromeVisualBrowser {
1595
+ #profile;
293
1596
  constructor(chromePath, {
1597
+ platform = process.platform,
294
1598
  env = process.env,
295
1599
  getuid = typeof process.getuid === 'function' ? () => process.getuid() : null,
296
1600
  spawnImpl = spawn,
1601
+ startupTimeoutMs = CHROME_STARTUP_TIMEOUT_MS,
1602
+ shutdownTimeoutMs = 1500,
297
1603
  } = {}) {
298
- this.profileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-visual-check-profile-'));
1604
+ this.#profile = createOwnedTempDirectory('archify-visual-check-profile-');
1605
+ this.profileRoot = this.#profile.path;
1606
+ this.shutdownTimeoutMs = shutdownTimeoutMs;
299
1607
  this.stderr = '';
300
1608
  const args = chromeVisualBrowserArgs(this.profileRoot, { env, getuid });
301
- this.child = spawnImpl(chromePath, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
1609
+ const websocket = Boolean(process.versions.bun) && spawnImpl === spawn;
1610
+ if (websocket) args[args.indexOf('--remote-debugging-pipe')] = '--remote-debugging-port=0';
1611
+ this.child = spawnImpl(chromePath, args, {
1612
+ stdio: websocket ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
1613
+ env: browserEnvironment(env, platform),
1614
+ });
302
1615
  this.child.stderr.setEncoding('utf8');
303
1616
  this.child.stderr.on('data', (chunk) => {
304
1617
  this.stderr = `${this.stderr}${chunk}`.slice(-8000);
@@ -307,6 +1620,7 @@ export class ChromeVisualBrowser {
307
1620
  this.stderr = `${this.stderr}\nChrome stderr stream failed: ${error.message}`.trim().slice(-8000);
308
1621
  });
309
1622
  this.cdp = new PipeCdp(this.child, {
1623
+ socketUrl: websocket ? this.websocketUrl(startupTimeoutMs) : null,
310
1624
  failureDetails: () => {
311
1625
  const exit = this.child.signalCode
312
1626
  ? `signal ${this.child.signalCode}`
@@ -315,19 +1629,44 @@ export class ChromeVisualBrowser {
315
1629
  return [
316
1630
  `Chrome process: ${exit}.`,
317
1631
  stderr ? `Chrome stderr:\n${stderr}` : '',
1632
+ platform === 'win32' && this.cdp?.receivedBytes === 0
1633
+ ? 'Windows browser startup may have relaunched before connecting to the DevTools pipe. '
1634
+ + 'Retry from a non-elevated PowerShell opened normally, outside an elevated or compatibility-mode parent. '
1635
+ + 'Archify does not disable browser de-elevation or the sandbox to preserve pipe handles.'
1636
+ : '',
318
1637
  ].filter(Boolean).join('\n');
319
1638
  },
320
1639
  });
1640
+ this.startupTimeoutMs = startupTimeoutMs;
321
1641
  this.sessionPromise = this.attach();
322
1642
  }
323
1643
 
1644
+ async websocketUrl(timeoutMs) {
1645
+ const started = Date.now();
1646
+ while (Date.now() - started < timeoutMs) {
1647
+ if (this.child.exitCode != null) throw new Error('Chrome exited before exposing DevTools');
1648
+ try {
1649
+ const lines = fs.readFileSync(path.join(this.profileRoot, 'DevToolsActivePort'), 'utf8').trim().split('\n');
1650
+ if (/^\d+$/.test(lines[0]) && lines[1]?.startsWith('/devtools/browser/')) return `ws://127.0.0.1:${lines[0]}${lines[1]}`;
1651
+ } catch {}
1652
+ await new Promise(resolve => setTimeout(resolve, 50));
1653
+ }
1654
+ throw new Error('Chrome did not expose its private DevTools endpoint');
1655
+ }
1656
+
324
1657
  async attach() {
325
- const targets = await this.cdp.send('Target.getTargets');
1658
+ // Process launch can be delayed substantially on a busy desktop. Keep the
1659
+ // longer allowance inside one gate invocation so an authoring agent does
1660
+ // not turn startup jitter into repeated tool calls or candidate edits.
1661
+ const targets = await this.cdp.send(
1662
+ 'Target.getTargets', {}, undefined, this.startupTimeoutMs,
1663
+ );
326
1664
  let target = targets.targetInfos?.find((item) => item.type === 'page');
327
1665
  if (!target) {
328
1666
  const created = await this.cdp.send('Target.createTarget', { url: 'about:blank' });
329
1667
  target = { targetId: created.targetId };
330
1668
  }
1669
+ this.targetId = target.targetId;
331
1670
  const attached = await this.cdp.send('Target.attachToTarget', {
332
1671
  targetId: target.targetId,
333
1672
  flatten: true,
@@ -337,7 +1676,21 @@ export class ChromeVisualBrowser {
337
1676
  return attached.sessionId;
338
1677
  }
339
1678
 
340
- async inspect({ artifactPath, width, height, theme, screenshotPath }) {
1679
+ async resetPage() {
1680
+ await this.sessionPromise;
1681
+ const previousTarget = this.targetId;
1682
+ const created = await this.cdp.send('Target.createTarget', { url: 'about:blank' });
1683
+ const attached = await this.cdp.send('Target.attachToTarget', { targetId: created.targetId, flatten: true });
1684
+ await this.cdp.send('Page.enable', {}, attached.sessionId);
1685
+ await this.cdp.send('Runtime.enable', {}, attached.sessionId);
1686
+ this.targetId = created.targetId;
1687
+ this.sessionPromise = Promise.resolve(attached.sessionId);
1688
+ if (previousTarget) await this.cdp.send('Target.closeTarget', { targetId: previousTarget });
1689
+ await this.cdp.send('Target.activateTarget', { targetId: this.targetId });
1690
+ await this.cdp.send('Page.bringToFront', {}, attached.sessionId);
1691
+ }
1692
+
1693
+ async inspect({ artifactPath, width, height, theme, screenshotPath, writeScreenshot }) {
341
1694
  const sessionId = await this.sessionPromise;
342
1695
  await this.cdp.send('Emulation.setDeviceMetricsOverride', {
343
1696
  width,
@@ -346,9 +1699,13 @@ export class ChromeVisualBrowser {
346
1699
  mobile: false,
347
1700
  }, sessionId);
348
1701
 
349
- const url = new URL(pathToFileURL(artifactPath).href);
1702
+ const url = pathToFileURL(artifactPath);
350
1703
  url.searchParams.set('theme', theme);
351
1704
  const loaded = this.cdp.waitFor('Page.loadEventFired', sessionId);
1705
+ // Navigation can fail before this waiter is awaited. Attach a rejection
1706
+ // handler immediately so a later load failure never escapes as an
1707
+ // unhandled rejection; awaiting `loaded` below still reports it normally.
1708
+ loaded.catch(() => {});
352
1709
  const navigation = await this.cdp.send('Page.navigate', { url: url.href }, sessionId);
353
1710
  if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`);
354
1711
  await loaded;
@@ -359,6 +1716,9 @@ export class ChromeVisualBrowser {
359
1716
  var fontsReady = document.fonts && document.fonts.ready
360
1717
  ? document.fonts.ready.catch(function () {})
361
1718
  : Promise.resolve();
1719
+ if (window.Archify && Archify.layoutStability && typeof Archify.layoutStability.whenStable === 'function') {
1720
+ return fontsReady.then(function () { return Archify.layoutStability.whenStable(); });
1721
+ }
362
1722
  return fontsReady.then(function () {
363
1723
  if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') {
364
1724
  return Archify.readerLayout.whenStable();
@@ -396,22 +1756,40 @@ export class ChromeVisualBrowser {
396
1756
  var viewBoxWidth = viewBox ? viewBox.width : 0;
397
1757
  var scale = viewBoxWidth > 0 ? Math.min(1, diagramWidth / viewBoxWidth) : 0;
398
1758
  var minimum = null;
1759
+ var minimumNonEdge = null;
1760
+ var minimumEdge = null;
399
1761
  if (svg && scale > 0) {
400
- Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"]')).forEach(function (text) {
1762
+ Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"], g[data-detail="context"] text')).forEach(function (text) {
1763
+ if (text.getAttribute('data-detail') === 'fine' || text.closest('[data-detail="fine"]')) return;
1764
+ var edge = text.closest('[data-edge-from][data-edge-to]');
1765
+ var node = text.closest('[data-node-id]');
1766
+ var context = text.getAttribute('data-detail') === 'context' || Boolean(text.closest('g[data-detail="context"]'));
401
1767
  var detail = text.hasAttribute('data-node-label')
402
1768
  ? 'primary'
403
- : text.hasAttribute('data-boundary-label') ? 'boundary' : 'context';
404
- if (detail === 'context' && !text.closest('[data-node-id]')) return;
1769
+ : text.hasAttribute('data-boundary-label') ? 'boundary'
1770
+ : context && edge ? 'edge' : 'context';
1771
+ if (detail === 'context' && !node) return;
405
1772
  var sourceFontPx = parseFloat(text.getAttribute('font-size') || '');
406
1773
  if (!Number.isFinite(sourceFontPx)) return;
407
1774
  var projectedFontPx = sourceFontPx * scale;
408
- if (!minimum || projectedFontPx < minimum.projectedFontPx) {
409
- minimum = {
410
- text: (text.textContent || '').trim(),
411
- detail: detail,
412
- sourceFontPx: sourceFontPx,
413
- projectedFontPx: projectedFontPx
414
- };
1775
+ var entry = {
1776
+ text: (text.textContent || '').trim(),
1777
+ detail: detail,
1778
+ owner: edge ? {
1779
+ kind: 'edge',
1780
+ id: edge.getAttribute('data-edge-id'),
1781
+ from: edge.getAttribute('data-edge-from'),
1782
+ to: edge.getAttribute('data-edge-to')
1783
+ } : node ? { kind: 'node', id: node.getAttribute('data-node-id') }
1784
+ : detail === 'boundary' ? { kind: 'boundary', id: null } : null,
1785
+ sourceFontPx: sourceFontPx,
1786
+ projectedFontPx: projectedFontPx
1787
+ };
1788
+ if (!minimum || projectedFontPx < minimum.projectedFontPx) minimum = entry;
1789
+ if (detail === 'edge') {
1790
+ if (!minimumEdge || projectedFontPx < minimumEdge.projectedFontPx) minimumEdge = entry;
1791
+ } else if (!minimumNonEdge || projectedFontPx < minimumNonEdge.projectedFontPx) {
1792
+ minimumNonEdge = entry;
415
1793
  }
416
1794
  });
417
1795
  }
@@ -426,24 +1804,96 @@ export class ChromeVisualBrowser {
426
1804
  && typeof Archify.viewerChromeLayout.stageRect === 'function'
427
1805
  ? Archify.viewerChromeLayout.stageRect()
428
1806
  : (stage ? stage.getBoundingClientRect() : null);
429
- var navigationDockRect = navigationDock ? navigationDock.getBoundingClientRect() : null;
1807
+ // The dock may lift to the viewport floor while the page scrolls; the
1808
+ // stage contract is about its resting position.
1809
+ var navigationDockRect = !navigationDock ? null
1810
+ : window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.dockRect === 'function'
1811
+ ? Archify.viewerChromeLayout.dockRect()
1812
+ : navigationDock.getBoundingClientRect();
430
1813
  var stageDockIntersectionArea = intersectionArea(stageRect, navigationDockRect);
431
1814
  var viewerChromeReceipt = window.Archify && Archify.viewerChromeLayout
432
1815
  && typeof Archify.viewerChromeLayout.receipt === 'function'
433
1816
  ? Archify.viewerChromeLayout.receipt()
434
1817
  : null;
1818
+ // Read rendered boxes only; source ownership, offsets and hard pins are unknown.
1819
+ var workflowLanes = svg ? Array.from(svg.querySelectorAll('rect[data-composition-frame-kind="lane"]')).map(function (lane) {
1820
+ var rect = lane.getBoundingClientRect();
1821
+ var members = Array.from(svg.querySelectorAll('g[data-node-id]')).map(function (node) {
1822
+ var box = node.querySelector('rect');
1823
+ if (!box) return null;
1824
+ var bounds = box.getBoundingClientRect();
1825
+ if (bounds.top < rect.top - 1 || bounds.bottom > rect.bottom + 1
1826
+ || bounds.left < rect.left - 1 || bounds.right > rect.right + 1) return null;
1827
+ return { id: node.getAttribute('data-node-id'), top: bounds.top, bottom: bounds.bottom };
1828
+ }).filter(Boolean);
1829
+ var top = members.length ? members.reduce(function (minimum, node) { return Math.min(minimum, node.top); }, Infinity) : null;
1830
+ var bottom = members.length ? members.reduce(function (maximum, node) { return Math.max(maximum, node.bottom); }, -Infinity) : null;
1831
+ return {
1832
+ frameId: lane.getAttribute('data-composition-frame-id'),
1833
+ heightPx: Math.round(rect.height),
1834
+ nodeCount: members.length,
1835
+ nodeIds: members.slice(0, 12).map(function (node) { return node.id; }),
1836
+ nodeSpanPx: top === null ? null : Math.round(bottom - top),
1837
+ spaceAboveNodesPx: top === null ? null : Math.round(top - rect.top),
1838
+ spaceBelowNodesPx: bottom === null ? null : Math.round(rect.bottom - bottom)
1839
+ };
1840
+ }).sort(function (a, b) { return b.heightPx - a.heightPx; }).slice(0, 6) : [];
1841
+ // Vertical page budget: every block that stacks above or below the SVG,
1842
+ // so an overflow receipt can say which part must give up how many pixels.
1843
+ function outerHeight(element) {
1844
+ if (!element) return 0;
1845
+ var style = window.getComputedStyle(element);
1846
+ if (element.hidden || style.display === 'none') return 0;
1847
+ return element.getBoundingClientRect().height
1848
+ + (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0);
1849
+ }
1850
+ var bodyStyle = window.getComputedStyle(document.body);
1851
+ var diagramStyle = diagram ? window.getComputedStyle(diagram) : null;
1852
+ var svgHeight = svg ? svg.getBoundingClientRect().height : 0;
1853
+ var notesBelowFold = Boolean(reader) && document.documentElement.getAttribute('data-reader-rail') === 'bottom';
1854
+ var pageComposition = {
1855
+ bodyPaddingPx: Math.round((parseFloat(bodyStyle.paddingTop) || 0) + (parseFloat(bodyStyle.paddingBottom) || 0)),
1856
+ headerPx: Math.round(outerHeight(reader && reader.querySelector('.header'))),
1857
+ diagramChromePx: Math.round(outerHeight(diagram) - svgHeight),
1858
+ svgPx: Math.round(svgHeight),
1859
+ cardsPx: notesBelowFold ? 0 : Math.round(outerHeight(reader && reader.querySelector('.cards'))),
1860
+ viewBoxHeight: viewBox ? viewBox.height : 0
1861
+ };
1862
+ var svgRect = svg && svg.getBoundingClientRect();
1863
+ var diagramRect = diagram && diagram.getBoundingClientRect();
1864
+ var documentScrollUnclipped = Boolean(svgRect && diagramRect
1865
+ && svgRect.top >= diagramRect.top - 1 && svgRect.left >= diagramRect.left - 1
1866
+ && svgRect.bottom <= diagramRect.bottom + 1 && svgRect.right <= diagramRect.right + 1
1867
+ && diagram.scrollHeight <= diagram.clientHeight + 1
1868
+ && diagram.scrollWidth <= diagram.clientWidth + 1
1869
+ && [document.documentElement, document.body].every(function (element) {
1870
+ return !['hidden', 'clip'].includes(window.getComputedStyle(element).overflowY);
1871
+ }));
435
1872
  return {
1873
+ pageComposition: pageComposition,
436
1874
  innerWidth: window.innerWidth,
437
1875
  innerHeight: window.innerHeight,
438
1876
  scrollWidth: Math.ceil(document.documentElement.scrollWidth),
439
- scrollHeight: Math.ceil(document.documentElement.scrollHeight),
1877
+ // Bottom notes and index are reading material below the fold; the
1878
+ // Reader excludes them from the one-screen fit, and so does this check.
1879
+ scrollHeight: Math.max(window.innerHeight, Math.ceil(document.documentElement.scrollHeight - (
1880
+ notesBelowFold ? outerHeight(reader.querySelector('.reader-rail')) : 0))),
440
1881
  resolvedTheme: document.documentElement.getAttribute('data-theme') || '',
1882
+ readerLayout: document.documentElement.getAttribute('data-reader-layout') || null,
1883
+ readerOverflow: document.documentElement.getAttribute('data-reader-overflow') || null,
1884
+ readerFit: svg ? svg.getAttribute('data-reader-fit') : null,
1885
+ diagramType: svg ? svg.getAttribute('data-diagram-type') : null,
1886
+ documentScrollUnclipped: documentScrollUnclipped,
441
1887
  readerWidth: reader ? reader.getBoundingClientRect().width : 0,
442
1888
  diagramWidth: diagramWidth,
443
1889
  viewBoxWidth: viewBoxWidth,
1890
+ workflowLanes: workflowLanes,
444
1891
  minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null,
1892
+ minimumProjectedNonEdgeTextPx: minimumNonEdge ? minimumNonEdge.projectedFontPx : null,
1893
+ minimumProjectedEdgeTextPx: minimumEdge ? minimumEdge.projectedFontPx : null,
445
1894
  minimumProjectedNodeText: minimum ? minimum.text : null,
446
1895
  minimumProjectedNodeTextDetail: minimum ? minimum.detail : null,
1896
+ minimumProjectedNodeTextOwner: minimum ? minimum.owner : null,
447
1897
  hasLegend: Boolean(legendRect && legendRect.width && legendRect.height),
448
1898
  hasNavigationDock: Boolean(navigationDockRect && navigationDockRect.width && navigationDockRect.height),
449
1899
  legendDockIntersectionArea: stageDockIntersectionArea > 0
@@ -467,31 +1917,59 @@ export class ChromeVisualBrowser {
467
1917
  captureBeyondViewport: false,
468
1918
  }, sessionId, 20000);
469
1919
  if (!capture.data) throw new Error('Chrome returned an empty screenshot.');
470
- fs.writeFileSync(screenshotPath, Buffer.from(capture.data, 'base64'));
1920
+ const screenshot = Buffer.from(capture.data, 'base64');
1921
+ if (writeScreenshot) writeScreenshot(screenshot);
1922
+ else fs.writeFileSync(screenshotPath, screenshot, { flag: 'wx' });
471
1923
  }
472
1924
  return metrics;
473
1925
  }
474
1926
 
475
- async close() {
1927
+ close() {
1928
+ if (!this.closePromise) this.closePromise = this.finishClose();
1929
+ return this.closePromise;
1930
+ }
1931
+
1932
+ async finishClose() {
476
1933
  this.cdp.failAll(new Error('visual-check finished'));
477
- if (this.child.exitCode === null && this.child.signalCode === null) {
478
- this.child.kill('SIGTERM');
479
- await new Promise((resolve) => {
480
- const timer = setTimeout(() => {
481
- if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGKILL');
482
- resolve();
483
- }, 1500);
484
- this.child.once('exit', () => {
485
- clearTimeout(timer);
486
- resolve();
487
- });
488
- });
489
- }
490
1934
  try {
491
- fs.rmSync(this.profileRoot, { recursive: true, force: true });
492
- } catch {
493
- // Chrome may briefly retain profile files on Windows; evidence is done.
1935
+ if (this.child.exitCode === null && this.child.signalCode === null) {
1936
+ const exited = () => this.child.exitCode !== null || this.child.signalCode !== null;
1937
+ const signalAndWait = (signal) => new Promise((resolve, reject) => {
1938
+ let timer;
1939
+ const finish = () => {
1940
+ clearTimeout(timer);
1941
+ this.child.removeListener('exit', finish);
1942
+ resolve(true);
1943
+ };
1944
+ this.child.once('exit', finish);
1945
+ timer = setTimeout(() => {
1946
+ this.child.removeListener('exit', finish);
1947
+ resolve(exited());
1948
+ }, this.shutdownTimeoutMs);
1949
+ try {
1950
+ this.child.kill(signal);
1951
+ } catch (error) {
1952
+ clearTimeout(timer);
1953
+ this.child.removeListener('exit', finish);
1954
+ reject(error);
1955
+ }
1956
+ });
1957
+ if (!await signalAndWait('SIGTERM') && !await signalAndWait('SIGKILL')) {
1958
+ const error = new Error(`Chrome did not exit; retained temporary profile "${this.#profile.path}".`);
1959
+ error.code = 'ARCHIFY_TEMP_CLEANUP';
1960
+ error.directory = this.#profile.path;
1961
+ throw error;
1962
+ }
1963
+ }
1964
+ } finally {
1965
+ // `exit` only reports that Chrome's main process is gone. A helper may
1966
+ // still hold an inherited pipe open, so explicitly release every stream
1967
+ // this browser instance owns before its test cleanup hook resolves.
1968
+ for (const stream of [this.child.stderr, this.child.stdio[3], this.child.stdio[4]]) {
1969
+ if (stream && !stream.destroyed) stream.destroy();
1970
+ }
494
1971
  }
1972
+ await this.#profile.cleanup();
495
1973
  }
496
1974
  }
497
1975
 
@@ -505,8 +1983,29 @@ function observation({ width, height, theme, metrics }) {
505
1983
  const minimumProjectedNodeTextPx = metrics.minimumProjectedNodeTextPx == null
506
1984
  ? null
507
1985
  : Number(metrics.minimumProjectedNodeTextPx);
1986
+ const minimumProjectedNonEdgeTextPx = metrics.minimumProjectedNonEdgeTextPx == null
1987
+ ? null
1988
+ : Number(metrics.minimumProjectedNonEdgeTextPx);
1989
+ const minimumProjectedEdgeTextPx = metrics.minimumProjectedEdgeTextPx == null
1990
+ ? null
1991
+ : Number(metrics.minimumProjectedEdgeTextPx);
508
1992
  const readabilityOk = minimumProjectedNodeTextPx == null
509
1993
  || minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX;
1994
+ const readerLayout = metrics.readerLayout || null;
1995
+ const readerOverflow = metrics.readerOverflow || null;
1996
+ const readerFit = metrics.readerFit || null;
1997
+ const verticalScrollAccepted = Boolean(
1998
+ overflowY
1999
+ && !overflowX
2000
+ && readabilityOk
2001
+ && Number.isFinite(minimumProjectedNodeTextPx)
2002
+ && ((readerLayout === 'adaptive' && readerOverflow === 'authored' && (readerFit === 'intrinsic-height' || readerFit === 'width-first'))
2003
+ || (readerFit === 'authored-height' && metrics.diagramType === 'architecture'
2004
+ && metrics.documentScrollUnclipped === true))
2005
+ );
2006
+ const authoredClipped = readerFit === 'authored-height' && metrics.diagramType === 'architecture'
2007
+ && metrics.documentScrollUnclipped !== true;
2008
+ const containmentOk = !authoredClipped && !overflowX && (!overflowY || verticalScrollAccepted);
510
2009
  const legendDockIntersectionArea = Number(metrics.legendDockIntersectionArea) || 0;
511
2010
  const dockStageIntersectionArea = Number(metrics.dockStageIntersectionArea) || 0;
512
2011
  const dockStageGap = metrics.dockStageGap == null ? null : Number(metrics.dockStageGap);
@@ -530,13 +2029,26 @@ function observation({ width, height, theme, metrics }) {
530
2029
  scrollHeight,
531
2030
  overflowX,
532
2031
  overflowY,
533
- ok: !overflowX && !overflowY,
2032
+ verticalScrollAccepted,
2033
+ overflowDisposition: authoredClipped || overflowX || (overflowY && !verticalScrollAccepted)
2034
+ ? 'unexpected-overflow'
2035
+ : verticalScrollAccepted ? 'readable-vertical-scroll' : 'contained',
2036
+ readerLayout,
2037
+ readerOverflow,
2038
+ readerFit,
2039
+ ...(readerFit === 'authored-height' ? { diagramType: metrics.diagramType, documentScrollUnclipped: metrics.documentScrollUnclipped === true } : {}),
2040
+ ok: containmentOk,
534
2041
  readerWidth: Number(metrics.readerWidth) || null,
535
2042
  diagramWidth: Number(metrics.diagramWidth) || null,
2043
+ ...(metrics.pageComposition ? { pageComposition: metrics.pageComposition } : {}),
536
2044
  viewBoxWidth: Number(metrics.viewBoxWidth) || null,
2045
+ ...(metrics.workflowLanes?.length ? { workflowLanes: metrics.workflowLanes } : {}),
537
2046
  minimumProjectedNodeTextPx,
2047
+ minimumProjectedNonEdgeTextPx,
2048
+ minimumProjectedEdgeTextPx,
538
2049
  minimumProjectedNodeText: metrics.minimumProjectedNodeText || null,
539
2050
  minimumProjectedNodeTextDetail: metrics.minimumProjectedNodeTextDetail || null,
2051
+ minimumProjectedNodeTextOwner: metrics.minimumProjectedNodeTextOwner || null,
540
2052
  minimumRequiredNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX,
541
2053
  readabilityOk,
542
2054
  hasLegend: Boolean(metrics.hasLegend),
@@ -557,14 +2069,14 @@ function contactSheetHtml({ artifactPath, receipt, screenshots }) {
557
2069
  const cards = screenshots.map((entry) => `
558
2070
  <figure>
559
2071
  <img src="${htmlEscape(entry.file)}" alt="${htmlEscape(`${entry.theme} ${entry.width} by ${entry.height}`)}">
560
- <figcaption><strong>${htmlEscape(entry.theme.toUpperCase())}</strong> · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}</figcaption>
2072
+ <figcaption><strong>${htmlEscape(entry.theme.toUpperCase())}</strong> · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}${entry.verticalScrollAccepted ? ' · readable vertical scroll' : ''}</figcaption>
561
2073
  </figure>`).join('');
562
2074
  return `<!doctype html>
563
2075
  <html lang="en">
564
2076
  <head>
565
- <meta charset="utf-8">
2077
+ <meta charset="utf-8">${softwareNoticesHtml}
566
2078
  <meta name="viewport" content="width=device-width,initial-scale=1">
567
- <title>Automated browser evidence · ${htmlEscape(path.basename(artifactPath))}</title>
2079
+ <title>Archify automated browser evidence · ${htmlEscape(path.basename(artifactPath))}</title>
568
2080
  <style>
569
2081
  *{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
570
2082
  </style>
@@ -578,6 +2090,46 @@ function contactSheetHtml({ artifactPath, receipt, screenshots }) {
578
2090
  `;
579
2091
  }
580
2092
 
2093
+ const publishedBrowserEvidenceReceipts = new WeakSet();
2094
+
2095
+ // Keep detailed observations in the bound receipt; expose every review image and
2096
+ // every failure without repeating the same viewport metrics in CLI context.
2097
+ export function summarizeBrowserEvidence(receipt) {
2098
+ const directory = receipt.sidecars?.directory || path.dirname(receipt.artifact.path);
2099
+ const evidencePath = (file) => file ? path.resolve(directory, file) : undefined;
2100
+ const published = publishedBrowserEvidenceReceipts.has(receipt);
2101
+ return {
2102
+ schemaVersion: receipt.schemaVersion,
2103
+ command: receipt.command,
2104
+ ok: receipt.ok,
2105
+ status: receipt.status,
2106
+ evidenceKind: receipt.evidenceKind,
2107
+ visualReview: receipt.visualReview,
2108
+ artifact: receipt.artifact,
2109
+ provenance: receipt.provenance,
2110
+ deliveryReceiptId: receipt.deliveryReceiptId,
2111
+ state: receipt.state,
2112
+ chrome: receipt.chrome,
2113
+ checks: Object.fromEntries(['containment', 'readability', 'viewerChrome', 'themeStates', 'captures']
2114
+ .filter((key) => receipt[key])
2115
+ .map((key) => [key, receipt[key].status])),
2116
+ error: receipt.error,
2117
+ publication: receipt.publication,
2118
+ diagnostics: receipt.diagnostics || [],
2119
+ evidence: {
2120
+ receipt: published ? evidencePath(receipt.sidecars?.receipt) : undefined,
2121
+ contactSheet: published ? evidencePath(receipt.captures?.contactSheet) : undefined,
2122
+ screenshots: (published ? receipt.captures?.screenshots || [] : []).map((entry) => ({
2123
+ path: evidencePath(entry.file),
2124
+ width: entry.width,
2125
+ height: entry.height,
2126
+ theme: entry.theme,
2127
+ resolvedTheme: entry.resolvedTheme,
2128
+ })),
2129
+ },
2130
+ };
2131
+ }
2132
+
581
2133
  function viewportSubject(artifact, entry) {
582
2134
  return {
583
2135
  artifact,
@@ -589,13 +2141,92 @@ function failureDiagnostic({ code, message, subject, evidence, supportedFixes, s
589
2141
  return { code, severity, message, subject, evidence, supportedFixes };
590
2142
  }
591
2143
 
592
- function observationDiagnostics({ artifact, allObservations, readabilityObservations }) {
2144
+ function resolveSidecarDirectory(artifactPath, outputs, compareParents) {
2145
+ const artifact = path.resolve(artifactPath);
2146
+ const artifactDirectory = path.dirname(artifact);
2147
+ const sidecarDirectory = path.dirname(outputs.receipt);
2148
+ const comparison = compareParents(outputs.receipt, artifact);
2149
+
2150
+ const sidecars = {
2151
+ ...(comparison?.status === 'different' ? { directory: sidecarDirectory } : {}),
2152
+ receipt: path.basename(outputs.receipt),
2153
+ ...(outputs.capture === false ? {} : { contactSheet: path.basename(outputs.contactSheet) }),
2154
+ };
2155
+ if (comparison?.status === 'match' || comparison?.status === 'different') {
2156
+ return { ok: true, sidecars };
2157
+ }
2158
+
2159
+ const reason = comparison?.reason || { code: 'invalid-path-identity-result' };
2160
+ const error = `visual-check could not determine the physical identity of its evidence directory "${sidecarDirectory}" relative to the artifact directory "${artifactDirectory}".`;
2161
+ return {
2162
+ ok: false,
2163
+ sidecars,
2164
+ error,
2165
+ diagnostic: failureDiagnostic({
2166
+ code: 'viewer/sidecar-directory-identity',
2167
+ message: error,
2168
+ subject: { artifact, sidecarDirectory },
2169
+ evidence: { comparison: reason },
2170
+ supportedFixes: [
2171
+ 'use existing, accessible artifact and evidence directories, then rerun visual-check',
2172
+ ],
2173
+ }),
2174
+ };
2175
+ }
2176
+
2177
+ // Turn a measured vertical overflow into the pixel budget an author can act on:
2178
+ // which stacked block holds the height, and what the SVG or cards must shrink to.
2179
+ export function verticalBudgetFixes(entry) {
2180
+ const page = entry.pageComposition;
2181
+ if (!entry.overflowY || !page) return [];
2182
+ const excess = entry.scrollHeight - entry.innerHeight;
2183
+ const fixes = [];
2184
+ const stacked = `${page.bodyPaddingPx}px body padding + ${page.headerPx}px header + ${page.diagramChromePx}px diagram chrome + ${page.svgPx}px SVG + ${page.cardsPx}px cards = ${entry.scrollHeight}px against ${entry.innerHeight}px`;
2185
+ if (page.svgPx > 0 && page.viewBoxHeight > 0) {
2186
+ const targetSvg = page.svgPx - excess;
2187
+ const targetViewBoxHeight = Math.floor(page.viewBoxHeight * targetSvg / page.svgPx);
2188
+ if (entry.readerLayout === 'adaptive') {
2189
+ fixes.push(targetSvg > 0
2190
+ ? `the page is ${excess}px too tall (${stacked}); the Reader already narrowed the stage to its ${entry.diagramWidth}px minimum, so the SVG height only follows meta.viewBox: keep every node and relationship and reduce the viewBox height to at most ${targetViewBoxHeight} (from ${page.viewBoxHeight}) by tightening vertical gaps and empty rows`
2191
+ : `the page is ${excess}px too tall (${stacked}) and the SVG alone exceeds the viewport at the minimum reader width; split the diagram into two`);
2192
+ } else {
2193
+ fixes.push(`the page is ${excess}px too tall (${stacked}); the SVG spans the full ${entry.diagramWidth}px reader width because its viewBox ratio is below 1.55 and it declares no automatic Reader fit, so the Reader can neither narrow it nor accept readable vertical scroll: either remove meta.viewBox so the renderer sizes the canvas and declares the fit, or make the viewBox at least 1.55x wider than tall`);
2194
+ }
2195
+ }
2196
+ if (page.cardsPx >= excess) {
2197
+ fixes.push(`alternatively, conclusion cards occupy ${page.cardsPx}px below the diagram; shortening card copy or dropping a card row so cards take at most ${page.cardsPx - excess}px also fits`);
2198
+ }
2199
+ return fixes;
2200
+ }
2201
+
2202
+ function observationDiagnostics({ artifact, allObservations, readabilityObservations, command }) {
593
2203
  const diagnostics = [];
594
2204
  for (const entry of allObservations) {
2205
+ if (entry.resolvedTheme !== entry.theme) {
2206
+ diagnostics.push(failureDiagnostic({
2207
+ code: 'viewer/theme-state',
2208
+ message: `The rendered artifact resolved ${entry.resolvedTheme || 'no theme'} instead of the requested ${entry.theme} theme at ${entry.width}x${entry.height}.`,
2209
+ subject: viewportSubject(artifact, entry),
2210
+ evidence: {
2211
+ requestedTheme: entry.theme,
2212
+ resolvedTheme: entry.resolvedTheme,
2213
+ },
2214
+ supportedFixes: [
2215
+ `restore deterministic ${entry.theme} theme resolution, then rerun ${command}`,
2216
+ ],
2217
+ }));
2218
+ }
595
2219
  if (!entry.ok) {
2220
+ const authoredClipped = entry.readerFit === 'authored-height' && entry.diagramType === 'architecture'
2221
+ && !entry.documentScrollUnclipped;
2222
+ const budgetFixes = authoredClipped
2223
+ ? ['restore the full SVG inside the diagram panel and allow normal document scrolling; remove internal scrollers and clipping without changing authored geometry']
2224
+ : verticalBudgetFixes(entry);
596
2225
  diagnostics.push(failureDiagnostic({
597
- code: 'viewer/viewport-overflow',
598
- message: `The rendered artifact overflows the ${entry.width}x${entry.height} ${entry.theme} viewport.`,
2226
+ code: authoredClipped ? 'viewer/diagram-clipped' : 'viewer/viewport-overflow',
2227
+ message: authoredClipped
2228
+ ? `The authored Architecture canvas is clipped or cannot scroll in the document at ${entry.width}x${entry.height} (${entry.theme}).`
2229
+ : `The rendered artifact overflows the ${entry.width}x${entry.height} ${entry.theme} viewport.`,
599
2230
  subject: viewportSubject(artifact, entry),
600
2231
  evidence: {
601
2232
  innerWidth: entry.innerWidth,
@@ -604,9 +2235,27 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat
604
2235
  scrollHeight: entry.scrollHeight,
605
2236
  overflowX: entry.overflowX,
606
2237
  overflowY: entry.overflowY,
2238
+ overflowDisposition: entry.overflowDisposition,
2239
+ readerLayout: entry.readerLayout,
2240
+ readerOverflow: entry.readerOverflow,
2241
+ readerFit: entry.readerFit,
2242
+ ...(authoredClipped ? { documentScrollUnclipped: false } : {}),
2243
+ ...(entry.overflowY && entry.pageComposition ? {
2244
+ pageComposition: entry.pageComposition,
2245
+ pageCompositionMeasurement: 'CSS pixels at this viewport; body padding, header, diagram chrome, SVG and cards stack vertically and sum to scrollHeight',
2246
+ } : {}),
2247
+ ...(entry.overflowY && entry.workflowLanes?.length ? {
2248
+ workflowLanes: entry.workflowLanes,
2249
+ measurement: 'CSS pixels; rendered node boxes geometrically contained in each lane frame; spaces include headers and routing, not guaranteed removable space',
2250
+ } : {}),
607
2251
  },
608
2252
  supportedFixes: [
609
- `contain the rendered layout within ${entry.width}x${entry.height}, then rerun visual-check`,
2253
+ ...budgetFixes,
2254
+ ...(entry.overflowY && entry.workflowLanes?.length ? [
2255
+ 'run validate workflow <source.json> --layout-json and compare the tallest rendered lane frames with source lanes, col and yOffset; frame IDs are rendered indices, not source lane IDs',
2256
+ 'where ownership and explicit geometry permit, distribute stacked steps across logical columns and meaningful lanes before increasing yOffset; preserve nodes, branches, labels and hard pins',
2257
+ `read references/authoring-contract.md#workflow-viewport-repair, then validate and deliver the changed source before rerunning ${command} on the new artifact; this is inspection guidance, not a verified coordinate fix`,
2258
+ ] : budgetFixes.length ? [] : [`contain the rendered layout within ${entry.width}x${entry.height}, then rerun ${command}`]),
610
2259
  ],
611
2260
  }));
612
2261
  }
@@ -617,7 +2266,7 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat
617
2266
  subject: viewportSubject(artifact, entry),
618
2267
  evidence: { legendDockIntersectionArea: entry.legendDockIntersectionArea },
619
2268
  supportedFixes: [
620
- 'move the SVG Legend or Viewer Dock until legendDockIntersectionArea is 0, then rerun visual-check',
2269
+ `move the SVG Legend or Viewer Dock until legendDockIntersectionArea is 0, then rerun ${command}`,
621
2270
  ],
622
2271
  }));
623
2272
  }
@@ -635,7 +2284,7 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat
635
2284
  requiredDockStageGap: entry.requiredDockStageGap,
636
2285
  },
637
2286
  supportedFixes: [
638
- `adjust Viewer stage reservation or clipping until dockStageGap is at least ${entry.requiredDockStageGap} and dockStageIntersectionArea is 0, then rerun visual-check`,
2287
+ `adjust Viewer stage reservation or clipping until dockStageGap is at least ${entry.requiredDockStageGap} and dockStageIntersectionArea is 0, then rerun ${command}`,
639
2288
  ],
640
2289
  }));
641
2290
  }
@@ -649,25 +2298,30 @@ function observationDiagnostics({ artifact, allObservations, readabilityObservat
649
2298
  evidence: {
650
2299
  text: entry.minimumProjectedNodeText,
651
2300
  detail: entry.minimumProjectedNodeTextDetail,
2301
+ owner: entry.minimumProjectedNodeTextOwner,
652
2302
  minimumProjectedNodeTextPx: entry.minimumProjectedNodeTextPx,
653
2303
  minimumRequiredNodeTextPx: entry.minimumRequiredNodeTextPx,
654
2304
  },
655
2305
  supportedFixes: [
656
- `increase projected node text to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun visual-check`,
2306
+ `increase projected ${entry.minimumProjectedNodeTextOwner?.kind === 'edge' ? 'relationship label' : entry.minimumProjectedNodeTextOwner?.kind === 'boundary' ? 'boundary label' : 'node text'} to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun ${command}`,
657
2307
  ],
658
2308
  }));
659
2309
  }
660
2310
  return diagnostics;
661
2311
  }
662
2312
 
663
- function baseReceipt({ artifactPath, artifact, outputs, chrome }) {
2313
+ function baseReceipt({ artifactPath, artifact, sidecars, chrome, deliveryProvenance, command, capture }) {
664
2314
  return {
665
2315
  schemaVersion: 1,
666
2316
  ok: false,
667
- command: 'visual-check',
2317
+ command,
668
2318
  evidenceKind: 'automated-browser',
669
2319
  status: 'fail',
670
- visualReview: 'pending',
2320
+ visualReview: capture ? 'pending' : 'not-requested',
2321
+ ...(deliveryProvenance ? {
2322
+ provenance: deliveryProvenance.status,
2323
+ ...(deliveryProvenance.receiptId ? { deliveryReceiptId: deliveryProvenance.receiptId } : {}),
2324
+ } : {}),
671
2325
  artifact: {
672
2326
  path: artifactPath,
673
2327
  sha256: sha256(artifact),
@@ -676,61 +2330,282 @@ function baseReceipt({ artifactPath, artifact, outputs, chrome }) {
676
2330
  state: { detail: 'read', motion: 'still' },
677
2331
  chrome,
678
2332
  diagnostics: [],
679
- containment: { status: 'fail', viewports: [] },
2333
+ containment: {
2334
+ status: 'fail',
2335
+ policy: 'fit-or-reader-declared-readable-vertical-scroll',
2336
+ viewports: [],
2337
+ },
2338
+ themeStates: { status: 'fail', viewports: [] },
680
2339
  readability: { status: 'fail', minimumProjectedNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX, viewports: [] },
681
2340
  viewerChrome: { status: 'fail', viewports: [] },
682
- captures: { status: 'fail', screenshots: [], contactSheet: null },
683
- sidecars: {
684
- receipt: path.basename(outputs.receipt),
685
- contactSheet: path.basename(outputs.contactSheet),
686
- },
2341
+ captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null },
2342
+ sidecars: { ...sidecars, files: [] },
2343
+ };
2344
+ }
2345
+
2346
+ function appendEvidencePublicationFailure(receipt, failure) {
2347
+ if (!failure?.ok) {
2348
+ if (!receipt.error) receipt.error = failure.error;
2349
+ receipt.diagnostics = [...(receipt.diagnostics || []), failure.diagnostic];
2350
+ }
2351
+ }
2352
+
2353
+ function appendEvidenceCleanupWarning(receipt, publication) {
2354
+ if (publication?.status !== 'committed-with-warning') return false;
2355
+ const committedStatus = receipt.status;
2356
+ const cleanupErrors = publication.cleanupErrors || [];
2357
+ const cleanupMessage = publication.recoveryDirectory
2358
+ ? `The evidence set was committed, but cleanup is incomplete. Recovery directory: "${publication.recoveryDirectory}".`
2359
+ : 'The evidence set was committed, but cleanup is incomplete.';
2360
+ receipt.ok = false;
2361
+ receipt.status = 'fail';
2362
+ receipt.error = receipt.error ? `${receipt.error} ${cleanupMessage}` : cleanupMessage;
2363
+ receipt.publication = {
2364
+ status: publication.status,
2365
+ committedReceipt: publication.committedReceipt,
2366
+ ...(publication.recoveryDirectory
2367
+ ? { recoveryDirectory: publication.recoveryDirectory } : {}),
2368
+ cleanupErrors,
2369
+ committedStatus,
687
2370
  };
2371
+ receipt.diagnostics = [...(receipt.diagnostics || []), failureDiagnostic({
2372
+ code: 'viewer/evidence-cleanup-incomplete',
2373
+ severity: 'warning',
2374
+ message: cleanupMessage,
2375
+ subject: {
2376
+ artifact: receipt.artifact?.path,
2377
+ receipt: publication.committedReceipt,
2378
+ },
2379
+ evidence: {
2380
+ publicationStatus: publication.status,
2381
+ ...(publication.recoveryDirectory
2382
+ ? { recoveryDirectory: publication.recoveryDirectory } : {}),
2383
+ cleanupErrors,
2384
+ },
2385
+ supportedFixes: publication.recoveryDirectory
2386
+ ? [`inspect and safely retire the retained files under "${publication.recoveryDirectory}"`]
2387
+ : ['resolve the reported filesystem cleanup failure before rerunning visual-check'],
2388
+ })];
2389
+ return true;
2390
+ }
2391
+
2392
+ function publishReceiptOnly(artifactPath, outputs, receipt, ownership) {
2393
+ const cleanupErrors = cleanupStagedEvidence(ownership);
2394
+ if (cleanupErrors.length) {
2395
+ const failure = evidenceWriteFailure(
2396
+ artifactPath,
2397
+ 'visual-check could not safely retire this run\'s incomplete staged evidence.',
2398
+ cleanupErrors,
2399
+ );
2400
+ cleanupStagedEvidence(ownership, { removeDirectory: true });
2401
+ ownership.active = false;
2402
+ return failure;
2403
+ }
2404
+ try {
2405
+ writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
2406
+ } catch (error) {
2407
+ const failure = evidenceWriteFailure(
2408
+ artifactPath,
2409
+ 'visual-check could not stage its evidence receipt.',
2410
+ [{ file: stagedPathFor(ownership, outputs.receipt), reason: error.message }],
2411
+ );
2412
+ cleanupStagedEvidence(ownership, { removeDirectory: true });
2413
+ ownership.active = false;
2414
+ return failure;
2415
+ }
2416
+ return commitVisualEvidence(artifactPath, outputs, ownership, [outputs.receipt]);
688
2417
  }
689
2418
 
690
- function persistReceipt(outputs, receipt) {
691
- writeAtomic(outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
2419
+ function persistBrowserEvidenceFailure(
2420
+ artifactPath,
2421
+ failure,
2422
+ { outDir, compareSidecarParents = sameParent, ownership, command = 'visual-check', capture = true, captureViewports = CAPTURE_VIEWPORTS } = {},
2423
+ ) {
2424
+ const outputs = evidenceSidecarPaths(artifactPath, { outDir, command, capture, captureViewports });
2425
+ const directory = resolveSidecarDirectory(artifactPath, outputs, compareSidecarParents);
2426
+ const capturedArtifact = readRegularFileContents(artifactPath, {
2427
+ subject: 'failure-artifact',
2428
+ });
2429
+ const artifactBytes = capturedArtifact.ok ? capturedArtifact.bytes : null;
2430
+ const receipt = {
2431
+ ...failure,
2432
+ ok: false, status: 'fail',
2433
+ ...(artifactBytes ? {
2434
+ artifact: {
2435
+ path: path.resolve(artifactPath),
2436
+ sha256: sha256(artifactBytes),
2437
+ bytes: artifactBytes.byteLength,
2438
+ },
2439
+ } : {}),
2440
+ containment: {
2441
+ status: 'fail',
2442
+ policy: 'fit-or-reader-declared-readable-vertical-scroll',
2443
+ viewports: [],
2444
+ },
2445
+ captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null },
2446
+ sidecars: { ...directory.sidecars, files: [] },
2447
+ };
2448
+ if (!directory.ok) {
2449
+ receipt.error = directory.error;
2450
+ receipt.diagnostics = [...(failure.diagnostics || []), directory.diagnostic];
2451
+ return receipt;
2452
+ }
2453
+ let activeOwnership = ownership;
2454
+ if (!visualEvidenceOwnershipMatches(activeOwnership, artifactPath, outputs)) {
2455
+ if (!artifactBytes) {
2456
+ const conflict = evidencePathConflict(artifactPath, outputs, outputs.receipt, {
2457
+ code: 'artifact-unreadable',
2458
+ });
2459
+ receipt.error = conflict.error;
2460
+ receipt.diagnostics = [...(failure.diagnostics || []), conflict.diagnostic];
2461
+ return receipt;
2462
+ }
2463
+ const preflight = beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs);
2464
+ if (!preflight.ok) {
2465
+ receipt.error = preflight.error;
2466
+ receipt.diagnostics = [...(failure.diagnostics || []), preflight.diagnostic];
2467
+ return receipt;
2468
+ }
2469
+ activeOwnership = preflight.ownership;
2470
+ }
2471
+ const publication = publishReceiptOnly(artifactPath, outputs, receipt, activeOwnership);
2472
+ if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt);
2473
+ appendEvidencePublicationFailure(receipt, publication);
2474
+ appendEvidenceCleanupWarning(receipt, publication);
2475
+ return receipt;
692
2476
  }
693
2477
 
694
- export async function runVisualCheck({
2478
+ async function runBrowserEvidence({
695
2479
  artifactPath,
2480
+ outDir,
696
2481
  chromePath,
697
2482
  resolveChrome = findChrome,
698
2483
  browserFactory = async (resolvedChrome) => new ChromeVisualBrowser(resolvedChrome),
2484
+ deliveryProvenance,
2485
+ verifyArtifact,
2486
+ compareSidecarParents = sameParent,
2487
+ command, capture,
699
2488
  } = {}) {
700
- if (!artifactPath) throw new Error('visual-check requires one delivered HTML artifact.');
2489
+ if (!artifactPath) throw new Error(`${command} requires one delivered HTML artifact.`);
701
2490
  const artifact = path.resolve(artifactPath);
702
- if (!/\.html?$/i.test(artifact)) throw new Error('visual-check requires an .html artifact.');
703
- const artifactBytes = fs.readFileSync(artifact);
704
- const outputs = sidecarPaths(artifact);
705
- cleanupCaptureSidecars(outputs);
706
- safeUnlink(outputs.receipt);
707
-
708
- const resolvedChrome = chromePath || resolveChrome();
2491
+ if (!/\.html?$/i.test(artifact)) throw new Error(`${command} requires an .html artifact.`);
2492
+ const capturedArtifact = captureRegularFileBinding(artifact, {
2493
+ subject: 'visual-check-artifact',
2494
+ expectedLinks: 1,
2495
+ includeContent: true,
2496
+ });
2497
+ if (capturedArtifact.status !== 'captured') {
2498
+ throw regularFileCaptureError('The visual-check artifact', capturedArtifact);
2499
+ }
2500
+ const artifactBytes = capturedArtifact.content.buffer;
2501
+ const explainer = isExplainerHtml(artifactBytes.toString('utf8'));
2502
+ const inspectionViewports = explainer ? EXPLAINER_VIEWPORTS : VISUAL_CHECK_VIEWPORTS;
2503
+ const captureViewports = explainer ? EXPLAINER_VIEWPORTS : CAPTURE_VIEWPORTS;
2504
+ const observe = explainer ? explainerObservation : observation;
2505
+ try {
2506
+ const outputs = evidenceSidecarPaths(artifact, { outDir, command, capture, captureViewports });
2507
+ const directory = resolveSidecarDirectory(artifact, outputs, compareSidecarParents);
709
2508
  const receipt = baseReceipt({
710
2509
  artifactPath: artifact,
711
2510
  artifact: artifactBytes,
712
- outputs,
713
- chrome: resolvedChrome
714
- ? { status: 'available', executable: resolvedChrome }
715
- : { status: 'unavailable', executable: null },
2511
+ sidecars: directory.sidecars,
2512
+ chrome: { status: 'not-checked', executable: null },
2513
+ deliveryProvenance, command, capture,
716
2514
  });
2515
+ if (explainer) {
2516
+ receipt.family = 'explainer';
2517
+ receipt.viewportPolicy = 'explainer-desktop-v1';
2518
+ receipt.containment.policy = 'horizontal-containment-readable-document-scroll';
2519
+ }
2520
+ if (!directory.ok) {
2521
+ receipt.error = directory.error;
2522
+ receipt.diagnostics = [directory.diagnostic];
2523
+ return { exitCode: EXIT.fail, receipt };
2524
+ }
2525
+ const preflight = beginVisualEvidenceWrite(artifact, artifactBytes, outputs);
2526
+ if (!preflight.ok) {
2527
+ receipt.error = preflight.error;
2528
+ receipt.diagnostics = [preflight.diagnostic];
2529
+ return { exitCode: EXIT.fail, receipt };
2530
+ }
2531
+ const ownership = preflight.ownership;
2532
+ let inspectionArtifact;
2533
+ try {
2534
+ // Chrome's file loader cannot reliably open long Windows/UNC paths. Keep
2535
+ // the one inspected snapshot local; publish evidence on its target volume.
2536
+ ownership.inspection = {
2537
+ directory: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-inspection-')),
2538
+ };
2539
+ const stat = fs.lstatSync(ownership.inspection.directory, { bigint: true });
2540
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.ino === 0n) {
2541
+ throw new Error('the private inspection directory has no stable identity');
2542
+ }
2543
+ ownership.inspection.identity = fileIdentity(stat);
2544
+ inspectionArtifact = path.join(ownership.inspection.directory, 'artifact-snapshot.html');
2545
+ ownership.stagingPaths.set(artifact, inspectionArtifact);
2546
+ writeStagedEvidence(ownership, artifact, artifactBytes);
2547
+ } catch (error) {
2548
+ return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, {
2549
+ ...receipt,
2550
+ status: 'fail',
2551
+ ok: false,
2552
+ error: `visual-check could not stage its private artifact snapshot: ${error.message}`,
2553
+ diagnostics: [failureDiagnostic({
2554
+ code: 'viewer/artifact-snapshot',
2555
+ message: 'visual-check could not bind a private artifact snapshot.',
2556
+ subject: { artifact },
2557
+ evidence: { reason: error.message },
2558
+ supportedFixes: ['retry after resolving the reported staging filesystem error'],
2559
+ })],
2560
+ }, { outDir, compareSidecarParents, ownership, command, capture, captureViewports }) };
2561
+ }
2562
+ const verifyInspectionArtifact = () => {
2563
+ const entry = ownership.stagedEntries.get(artifact);
2564
+ if (!entry || !currentEvidenceMatches(entry.path, entry.identity, entry.evidence)) {
2565
+ throw new Error('The private visual-check artifact snapshot changed during inspection.');
2566
+ }
2567
+ };
2568
+ try {
2569
+ verifyArtifact?.(artifactBytes);
2570
+ } catch (error) {
2571
+ return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, {
2572
+ schemaVersion: 1, command, evidenceKind: 'automated-browser', visualReview: capture ? 'pending' : 'not-requested',
2573
+ artifact: { path: artifact, sha256: sha256(artifactBytes), bytes: artifactBytes.byteLength },
2574
+ provenance: error.deliveryProvenance?.status, error: error.message,
2575
+ diagnostics: error.archifyDiagnostics || [],
2576
+ }, { outDir, compareSidecarParents, ownership, command, capture, captureViewports }) };
2577
+ }
2578
+
2579
+ const resolvedChrome = chromePath || resolveChrome();
2580
+ receipt.chrome = resolvedChrome
2581
+ ? { status: 'available', executable: resolvedChrome }
2582
+ : { status: 'unavailable', executable: null };
717
2583
 
718
2584
  if (!resolvedChrome) {
719
2585
  receipt.status = 'skipped';
720
2586
  receipt.containment.status = 'skipped';
721
2587
  receipt.readability.status = 'skipped';
722
2588
  receipt.viewerChrome.status = 'skipped';
723
- receipt.captures.status = 'skipped';
724
- receipt.error = 'Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path.';
2589
+ receipt.themeStates.status = 'skipped';
2590
+ if (capture) receipt.captures.status = 'skipped';
2591
+ receipt.error = 'No supported browser found. Install Chrome/Chromium (or Microsoft Edge), or set ARCHIFY_CHROME to its executable path.';
725
2592
  receipt.diagnostics = [failureDiagnostic({
726
2593
  code: 'viewer/chrome-unavailable',
727
2594
  severity: 'warning',
728
2595
  message: receipt.error,
729
2596
  subject: { artifact },
730
2597
  evidence: { executable: null },
731
- supportedFixes: ['set ARCHIFY_CHROME to a Chrome or Chromium executable and rerun visual-check'],
2598
+ supportedFixes: [`set ARCHIFY_CHROME to a Chrome/Chromium executable (or Microsoft Edge) and rerun ${command}`],
732
2599
  })];
733
- persistReceipt(outputs, receipt);
2600
+ const publication = publishReceiptOnly(artifact, outputs, receipt, ownership);
2601
+ if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt);
2602
+ if (!publication.ok) {
2603
+ appendEvidencePublicationFailure(receipt, publication);
2604
+ return { exitCode: EXIT.fail, receipt };
2605
+ }
2606
+ if (appendEvidenceCleanupWarning(receipt, publication)) {
2607
+ return { exitCode: EXIT.fail, receipt };
2608
+ }
734
2609
  return { exitCode: EXIT.skipped, receipt };
735
2610
  }
736
2611
 
@@ -738,40 +2613,77 @@ export async function runVisualCheck({
738
2613
  try {
739
2614
  browser = await browserFactory(resolvedChrome);
740
2615
  const observations = new Map();
741
- const screenshotsByKey = new Map(outputs.screenshots.map((entry) => [
2616
+ const screenshotsByKey = new Map(outputs.screenshots.map((entry, index) => [
742
2617
  screenshotKey(entry.width, entry.height, entry.theme),
743
- entry,
2618
+ { ...entry, stagedPath: ownership.stagedOutputs.screenshots[index].path },
744
2619
  ]));
745
2620
 
746
- for (const viewport of VISUAL_CHECK_VIEWPORTS) {
747
- const key = screenshotKey(viewport.width, viewport.height, 'light');
748
- const screenshot = screenshotsByKey.get(key);
749
- const metrics = await browser.inspect({
750
- artifactPath: artifact,
751
- ...viewport,
752
- theme: 'light',
753
- ...(screenshot ? { screenshotPath: screenshot.path } : {}),
754
- });
755
- observations.set(key, observation({ ...viewport, theme: 'light', metrics }));
756
- }
757
- for (const viewport of CAPTURE_VIEWPORTS) {
758
- const key = screenshotKey(viewport.width, viewport.height, 'dark');
759
- const screenshot = screenshotsByKey.get(key);
760
- const metrics = await browser.inspect({
761
- artifactPath: artifact,
762
- ...viewport,
763
- theme: 'dark',
764
- screenshotPath: screenshot.path,
765
- });
766
- observations.set(key, observation({ ...viewport, theme: 'dark', metrics }));
2621
+ try {
2622
+ const inspect = (options) => explainer ? inspectExplainerBrowser(browser, options) : browser.inspect(options);
2623
+ for (const viewport of inspectionViewports) {
2624
+ const key = screenshotKey(viewport.width, viewport.height, 'light');
2625
+ const screenshot = screenshotsByKey.get(key);
2626
+ const metrics = await inspect({
2627
+ artifactPath: inspectionArtifact,
2628
+ ...viewport,
2629
+ theme: 'light',
2630
+ ...(screenshot ? {
2631
+ screenshotPath: screenshot.stagedPath,
2632
+ writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes),
2633
+ } : {}),
2634
+ });
2635
+ verifyInspectionArtifact();
2636
+ if (screenshot) {
2637
+ if (screenshot && !ownership.stagedEntries.has(screenshot.path)) {
2638
+ registerStagedEvidence(ownership, screenshot.path);
2639
+ } else {
2640
+ const staged = ownership.stagedEntries.get(screenshot.path);
2641
+ if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
2642
+ throw new Error('The staged visual-check screenshot changed after capture.');
2643
+ }
2644
+ }
2645
+ }
2646
+ observations.set(key, observe({ ...viewport, theme: 'light', metrics }));
2647
+ }
2648
+ for (const viewport of captureViewports) {
2649
+ const key = screenshotKey(viewport.width, viewport.height, 'dark');
2650
+ const screenshot = screenshotsByKey.get(key);
2651
+ const metrics = await inspect({
2652
+ artifactPath: inspectionArtifact,
2653
+ ...viewport,
2654
+ theme: 'dark',
2655
+ ...(screenshot ? {
2656
+ screenshotPath: screenshot.stagedPath,
2657
+ writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes),
2658
+ } : {}),
2659
+ });
2660
+ verifyInspectionArtifact();
2661
+ if (screenshot && !ownership.stagedEntries.has(screenshot.path)) {
2662
+ registerStagedEvidence(ownership, screenshot.path);
2663
+ } else if (screenshot) {
2664
+ const staged = ownership.stagedEntries.get(screenshot.path);
2665
+ if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
2666
+ throw new Error('The staged visual-check screenshot changed after capture.');
2667
+ }
2668
+ }
2669
+ observations.set(key, observe({ ...viewport, theme: 'dark', metrics }));
2670
+ }
2671
+
2672
+ } finally {
2673
+ if (browser?.close) await browser.close();
767
2674
  }
768
2675
 
769
- const afterBytes = fs.readFileSync(artifact);
770
- if (sha256(afterBytes) !== receipt.artifact.sha256 || afterBytes.byteLength !== receipt.artifact.bytes) {
771
- throw new Error('The delivered artifact changed while visual-check was running.');
2676
+ let artifactVerification = verifyRegularFileBinding(capturedArtifact.binding);
2677
+ if (artifactVerification.status !== 'match') {
2678
+ throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification);
2679
+ }
2680
+ verifyArtifact?.(artifactBytes);
2681
+ artifactVerification = verifyRegularFileBinding(capturedArtifact.binding);
2682
+ if (artifactVerification.status !== 'match') {
2683
+ throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification);
772
2684
  }
773
2685
 
774
- receipt.containment.viewports = VISUAL_CHECK_VIEWPORTS.map(({ width, height }) => (
2686
+ receipt.containment.viewports = inspectionViewports.map(({ width, height }) => (
775
2687
  observations.get(screenshotKey(width, height, 'light'))
776
2688
  ));
777
2689
  receipt.readability.viewports = receipt.containment.viewports.map((entry) => ({ ...entry }));
@@ -781,49 +2693,122 @@ export async function runVisualCheck({
781
2693
  file: path.basename(entry.path),
782
2694
  }));
783
2695
  const allObservations = [...observations.values()];
2696
+ receipt.themeStates.viewports = allObservations.map((entry) => ({
2697
+ width: entry.width,
2698
+ height: entry.height,
2699
+ requestedTheme: entry.theme,
2700
+ resolvedTheme: entry.resolvedTheme,
2701
+ ok: entry.resolvedTheme === entry.theme,
2702
+ }));
2703
+ const themeStatePass = receipt.themeStates.viewports.every(entry => entry.ok);
784
2704
  const containmentPass = allObservations.every((entry) => entry.ok);
785
2705
  const readabilityPass = receipt.readability.viewports.every((entry) => entry.readabilityOk);
786
2706
  const viewerChromePass = allObservations.every((entry) => entry.viewerChromeOk);
787
- receipt.diagnostics = observationDiagnostics({
2707
+ receipt.diagnostics = (explainer ? explainerObservationDiagnostics : observationDiagnostics)({
788
2708
  artifact,
789
2709
  allObservations,
790
- readabilityObservations: receipt.readability.viewports,
2710
+ readabilityObservations: receipt.readability.viewports, command,
791
2711
  });
2712
+ receipt.themeStates.status = themeStatePass ? 'pass' : 'fail';
792
2713
  receipt.containment.status = containmentPass ? 'pass' : 'fail';
793
2714
  receipt.readability.status = readabilityPass ? 'pass' : 'fail';
794
2715
  receipt.viewerChrome.status = viewerChromePass ? 'pass' : 'fail';
795
- receipt.captures.status = 'pass';
796
- receipt.captures.contactSheet = path.basename(outputs.contactSheet);
797
- receipt.status = containmentPass && readabilityPass && viewerChromePass ? 'pass' : 'fail';
798
- receipt.ok = containmentPass && readabilityPass && viewerChromePass;
799
- writeAtomic(outputs.contactSheet, contactSheetHtml({
2716
+ if (capture) {
2717
+ receipt.captures.status = 'pass';
2718
+ receipt.captures.contactSheet = path.basename(outputs.contactSheet);
2719
+ }
2720
+ if (explainer) {
2721
+ receipt.containment.viewports = allObservations.map(entry => ({ ...entry }));
2722
+ receipt.readability.viewports = allObservations.map(entry => ({ ...entry }));
2723
+ receipt.viewerChrome.viewports = allObservations.map(entry => ({ ...entry }));
2724
+ receipt.commonQa = { status: allObservations.every(e => e.consoleErrors.length === 0 && e.duplicateIds.length === 0 && e.keyboardIssues.length === 0 && e.keyboardTraversal?.ok) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.consoleErrors.length===0&&e.duplicateIds.length===0&&e.keyboardIssues.length===0&&e.keyboardTraversal?.ok===true})) };
2725
+ receipt.interactions = { status: allObservations.every(e => e.interactionFailures.length === 0) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.interactionFailures.length===0,failures:e.interactionFailures})) };
2726
+ receipt.references = { status: allObservations.every(e => e.references.length === 0) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.references.length===0,failures:e.references})) };
2727
+ receipt.noJavaScript = { status: allObservations.every(e => e.noJavaScript.every(n => n.ok)) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,theme:e.theme,ok:e.noJavaScript.every(n=>n.ok),states:e.noJavaScript})) };
2728
+ receipt.print = { status: allObservations.every(e => e.print.ok) ? 'pass' : 'fail', observations: allObservations.map(e => ({width:e.width,...e.print})) };
2729
+ }
2730
+ receipt.ok = containmentPass && themeStatePass && readabilityPass && viewerChromePass && (!explainer || receipt.diagnostics.length === 0);
2731
+ receipt.status = receipt.ok ? 'pass' : 'fail';
2732
+ if (capture) writeStagedEvidence(ownership, outputs.contactSheet, contactSheetHtml({
800
2733
  artifactPath: artifact,
801
2734
  receipt,
802
2735
  screenshots: receipt.captures.screenshots,
803
2736
  }));
804
- persistReceipt(outputs, receipt);
2737
+ recordVisualEvidenceFiles(receipt, outputs, ownership);
2738
+ writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
2739
+ const publication = commitVisualEvidence(
2740
+ artifact,
2741
+ outputs,
2742
+ ownership,
2743
+ visualEvidenceTargets(outputs).map((target) => target.path),
2744
+ );
2745
+ if (!publication.ok) {
2746
+ appendEvidencePublicationFailure(receipt, publication);
2747
+ receipt.ok = false;
2748
+ receipt.status = 'fail';
2749
+ return { exitCode: EXIT.fail, receipt };
2750
+ }
2751
+ publishedBrowserEvidenceReceipts.add(receipt);
2752
+ if (appendEvidenceCleanupWarning(receipt, publication)) {
2753
+ return { exitCode: EXIT.fail, receipt };
2754
+ }
805
2755
  return { exitCode: receipt.ok ? EXIT.pass : EXIT.fail, receipt };
806
2756
  } catch (error) {
807
- cleanupCaptureSidecars(outputs);
2757
+ const startupTimeout = error.code === 'ERR_CHROME_CDP_TIMEOUT'
2758
+ && error.method === 'Target.getTargets';
808
2759
  receipt.status = 'fail';
809
2760
  receipt.ok = false;
810
2761
  receipt.error = error.message;
811
2762
  receipt.containment.status = 'fail';
812
2763
  receipt.readability.status = 'fail';
813
2764
  receipt.viewerChrome.status = 'fail';
814
- receipt.captures.status = 'fail';
2765
+ receipt.themeStates.status = 'fail';
2766
+ receipt.captures.status = capture ? 'fail' : 'not-requested';
815
2767
  receipt.captures.screenshots = [];
816
2768
  receipt.captures.contactSheet = null;
817
- receipt.diagnostics = [failureDiagnostic({
818
- code: 'viewer/visual-check-runtime',
819
- message: 'visual-check could not complete its Chrome inspection.',
2769
+ if (error.deliveryProvenance) receipt.provenance = error.deliveryProvenance.status;
2770
+ receipt.diagnostics = error.archifyDiagnostics || [failureDiagnostic({
2771
+ code: error.code === 'ARCHIFY_TEMP_CLEANUP' ? 'viewer/temp-cleanup-incomplete'
2772
+ : startupTimeout ? 'viewer/chrome-startup-timeout' : `viewer/${command}-runtime`,
2773
+ message: error.code === 'ARCHIFY_TEMP_CLEANUP'
2774
+ ? 'Browser cleanup could not safely finish; inspect the reported retained path.'
2775
+ : startupTimeout
2776
+ ? 'Chrome did not finish its initial DevTools handshake within the startup window.'
2777
+ : `${command} could not complete its Chrome inspection.`,
820
2778
  subject: { artifact },
821
- evidence: { reason: error.message },
822
- supportedFixes: ['resolve the reported Chrome inspection error, then rerun visual-check'],
2779
+ evidence: { reason: error.message, ...(error.directory ? { retainedDirectory: error.directory } : {}) },
2780
+ supportedFixes: error.code === 'ARCHIFY_TEMP_CLEANUP'
2781
+ ? ['resolve the reported process or filesystem failure; verify directory ownership before manual cleanup']
2782
+ : startupTimeout
2783
+ ? [
2784
+ 'do not edit or simplify the artifact because this is a browser-startup failure',
2785
+ `retry ${command} once after host load subsides; if it repeats, stop and report the environment failure`,
2786
+ ]
2787
+ : [`resolve the reported Chrome inspection error, then rerun ${command}`],
823
2788
  })];
824
- persistReceipt(outputs, receipt);
825
- return { exitCode: EXIT.fail, receipt };
2789
+ return {
2790
+ exitCode: EXIT.fail,
2791
+ receipt: persistBrowserEvidenceFailure(artifact, receipt, {
2792
+ outDir,
2793
+ compareSidecarParents,
2794
+ ownership, command, capture, captureViewports,
2795
+ }),
2796
+ };
2797
+ }
826
2798
  } finally {
827
- if (browser?.close) await browser.close();
2799
+ releaseRegularFileBinding(capturedArtifact.binding);
828
2800
  }
829
2801
  }
2802
+
2803
+ export function persistVisualCheckFailure(artifactPath, failure, options = {}) {
2804
+ return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'visual-check', capture: true });
2805
+ }
2806
+ export function persistBrowserCheckFailure(artifactPath, failure, options = {}) {
2807
+ return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'browser-check', capture: false });
2808
+ }
2809
+ export function runVisualCheck(options = {}) {
2810
+ return runBrowserEvidence({ ...options, command: 'visual-check', capture: true });
2811
+ }
2812
+ export function runBrowserCheck(options = {}) {
2813
+ return runBrowserEvidence({ ...options, command: 'browser-check', capture: false });
2814
+ }