@nextcommerce/campaigns-os 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,326 @@
1
+ // Workflow Findings — the human/agent findings channel within Run Telemetry
2
+ // (the "Findings Sidecar" lane). See docs/workflow-findings-sidecar.md (Run
3
+ // Telemetry); the Run Record embeds a per-run snapshot of these findings.
4
+ //
5
+ // This module owns local finding capture only: validate, append, list, and
6
+ // export Workflow Findings. It deliberately does NOT cluster, route, create
7
+ // Linear issues, or remit — remit lives behind consent in remit.mjs /
8
+ // run-record.mjs, and aggregation is internal tooling's job. Capturing a
9
+ // finding must never require Linear access or NEXT internal context, so this
10
+ // module has no network or credential dependencies.
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+
16
+ export const WORKFLOW_FINDING_SCHEMA = "campaigns-os-workflow-finding/v0";
17
+ export const FINDINGS_JOURNAL_REL_PATH = ".campaign-runtime/workflow-findings.jsonl";
18
+
19
+ export const FINDING_STAGES = [
20
+ "overall",
21
+ "intake",
22
+ "start",
23
+ "doctor",
24
+ "setup",
25
+ "build",
26
+ "polish",
27
+ "deploy",
28
+ "qa",
29
+ "test-order",
30
+ "next",
31
+ ];
32
+
33
+ export const FINDING_KINDS = [
34
+ "positive_signal",
35
+ "friction",
36
+ "missing_prompt",
37
+ "blocker",
38
+ "docs_gap",
39
+ "automation_gap",
40
+ "idea",
41
+ ];
42
+
43
+ export const FINDING_AUTHOR_TYPES = ["operator", "agent", "system"];
44
+
45
+ export const FINDING_EVIDENCE_QUALITY = [
46
+ "operator_report",
47
+ "artifact_referenced",
48
+ "artifact_attached",
49
+ "system_observed",
50
+ ];
51
+
52
+ // Required core fields. Strict here; permissive about optional context.
53
+ const REQUIRED_FIELDS = ["schema_version", "id", "created_at", "stage", "kind", "summary"];
54
+
55
+ function isNonEmptyString(value) {
56
+ return typeof value === "string" && value.trim().length > 0;
57
+ }
58
+
59
+ /**
60
+ * Hand-rolled validator matching the repo convention (no AJV). Checks the
61
+ * required core and the closed enums; leaves optional context fields
62
+ * permissive. Returns `{ ok, errors }` where each error is `{ code, message }`.
63
+ */
64
+ export function validateWorkflowFinding(finding) {
65
+ const errors = [];
66
+ const add = (code, message) => errors.push({ code, message });
67
+
68
+ if (!finding || typeof finding !== "object" || Array.isArray(finding)) {
69
+ add("finding.type", "Workflow Finding must be a JSON object.");
70
+ return { ok: false, errors };
71
+ }
72
+
73
+ for (const field of REQUIRED_FIELDS) {
74
+ if (!isNonEmptyString(finding[field])) {
75
+ add(`finding.${field}`, `Missing or empty required field "${field}".`);
76
+ }
77
+ }
78
+
79
+ if (finding.schema_version != null && finding.schema_version !== WORKFLOW_FINDING_SCHEMA) {
80
+ add("finding.schema_version", `Expected schema_version "${WORKFLOW_FINDING_SCHEMA}".`);
81
+ }
82
+ if (isNonEmptyString(finding.stage) && !FINDING_STAGES.includes(finding.stage)) {
83
+ add("finding.stage", `Unknown stage "${finding.stage}". Allowed: ${FINDING_STAGES.join(", ")}.`);
84
+ }
85
+ if (isNonEmptyString(finding.kind) && !FINDING_KINDS.includes(finding.kind)) {
86
+ add("finding.kind", `Unknown kind "${finding.kind}". Allowed: ${FINDING_KINDS.join(", ")}.`);
87
+ }
88
+ if (finding.author_type != null && !FINDING_AUTHOR_TYPES.includes(finding.author_type)) {
89
+ add("finding.author_type", `Unknown author_type "${finding.author_type}". Allowed: ${FINDING_AUTHOR_TYPES.join(", ")}.`);
90
+ }
91
+ if (finding.evidence_quality != null && !FINDING_EVIDENCE_QUALITY.includes(finding.evidence_quality)) {
92
+ add("finding.evidence_quality", `Unknown evidence_quality "${finding.evidence_quality}". Allowed: ${FINDING_EVIDENCE_QUALITY.join(", ")}.`);
93
+ }
94
+ if (finding.artifact_paths != null && !Array.isArray(finding.artifact_paths)) {
95
+ add("finding.artifact_paths", "artifact_paths must be an array of strings when present.");
96
+ }
97
+ if (finding.command_exit_status != null && !Number.isInteger(finding.command_exit_status)) {
98
+ add("finding.command_exit_status", "command_exit_status must be an integer when present.");
99
+ }
100
+ if (finding.safe_to_share != null && typeof finding.safe_to_share !== "boolean") {
101
+ add("finding.safe_to_share", "safe_to_share must be a boolean when present.");
102
+ }
103
+ if (finding.run_id != null && typeof finding.run_id !== "string") {
104
+ add("finding.run_id", "run_id must be a string or null when present.");
105
+ }
106
+
107
+ return { ok: errors.length === 0, errors };
108
+ }
109
+
110
+ /**
111
+ * Resolve the journal path. Precedence:
112
+ * 1. explicit `--journal <path>`
113
+ * 2. packet-adjacent target repo when `--packet <path>` is supplied
114
+ * 3. current working directory otherwise
115
+ */
116
+ export function resolveJournalPath(args, cwd = process.cwd()) {
117
+ if (isNonEmptyString(args.journal)) return resolve(args.journal);
118
+ if (isNonEmptyString(args.packet)) {
119
+ return join(dirname(resolve(args.packet)), FINDINGS_JOURNAL_REL_PATH);
120
+ }
121
+ return join(resolve(cwd), FINDINGS_JOURNAL_REL_PATH);
122
+ }
123
+
124
+ function generateFindingId(now) {
125
+ return `wf_${now.getTime()}_${randomBytes(4).toString("hex")}`;
126
+ }
127
+
128
+ function coerceBoolean(value) {
129
+ if (value === true || value === "true") return true;
130
+ if (value === false || value === "false") return false;
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Build a Workflow Finding object from parsed CLI flags. Generated values
136
+ * (id, created_at, schema_version) and sensible defaults (author_type,
137
+ * evidence_quality) are filled here so the JSONL line is self-describing.
138
+ */
139
+ export function buildFinding(input, { now = new Date() } = {}) {
140
+ const finding = {
141
+ schema_version: WORKFLOW_FINDING_SCHEMA,
142
+ id: isNonEmptyString(input.id) ? input.id : generateFindingId(now),
143
+ created_at: isNonEmptyString(input.created_at) ? input.created_at : now.toISOString(),
144
+ stage: isNonEmptyString(input.stage) ? input.stage.trim() : undefined,
145
+ kind: isNonEmptyString(input.kind) ? input.kind.trim() : undefined,
146
+ summary: isNonEmptyString(input.summary) ? input.summary.trim() : undefined,
147
+ };
148
+
149
+ const artifactPaths = parseArtifactPaths(input.artifact_paths);
150
+ const optional = {
151
+ details: input.details,
152
+ expected: input.expected,
153
+ actual: input.actual,
154
+ severity: input.severity,
155
+ command: input.command,
156
+ source_type: input.source_type,
157
+ template_family: input.template_family,
158
+ map_id: input.map_id,
159
+ campaign_slug: input.campaign_slug,
160
+ target_repo: input.target_repo,
161
+ packet_path: input.packet_path,
162
+ assembly_report_path: input.assembly_report_path,
163
+ qa_run_id: input.qa_run_id,
164
+ run_id: input.run_id,
165
+ suggested_owner: input.suggested_owner,
166
+ };
167
+ for (const [key, value] of Object.entries(optional)) {
168
+ if (isNonEmptyString(value)) finding[key] = value.trim();
169
+ }
170
+ if (artifactPaths.length) finding.artifact_paths = artifactPaths;
171
+ if (Number.isInteger(input.command_exit_status)) finding.command_exit_status = input.command_exit_status;
172
+
173
+ const safeToShare = coerceBoolean(input.safe_to_share);
174
+ if (safeToShare !== null) finding.safe_to_share = safeToShare;
175
+
176
+ // author_type: default operator for manual CLI adds; explicit flag wins.
177
+ finding.author_type = FINDING_AUTHOR_TYPES.includes(input.author_type) ? input.author_type : "operator";
178
+
179
+ // evidence_quality: explicit flag wins; otherwise infer artifact_referenced
180
+ // when artifact paths were supplied, else operator_report.
181
+ if (FINDING_EVIDENCE_QUALITY.includes(input.evidence_quality)) {
182
+ finding.evidence_quality = input.evidence_quality;
183
+ } else {
184
+ finding.evidence_quality = artifactPaths.length ? "artifact_referenced" : "operator_report";
185
+ }
186
+
187
+ return finding;
188
+ }
189
+
190
+ function parseArtifactPaths(value) {
191
+ if (Array.isArray(value)) return value.filter(isNonEmptyString).map((entry) => entry.trim());
192
+ if (isNonEmptyString(value)) {
193
+ return value
194
+ .split(",")
195
+ .map((entry) => entry.trim())
196
+ .filter(Boolean);
197
+ }
198
+ return [];
199
+ }
200
+
201
+ /**
202
+ * Append exactly one validated finding as one JSONL line. Never rewrites
203
+ * existing entries — the journal is append-only.
204
+ */
205
+ export function appendFinding(journalPath, finding) {
206
+ const validation = validateWorkflowFinding(finding);
207
+ if (!validation.ok) {
208
+ const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
209
+ throw new Error(`Workflow Finding failed validation: ${detail}`);
210
+ }
211
+ mkdirSync(dirname(resolve(journalPath)), { recursive: true });
212
+ appendFileSync(resolve(journalPath), `${JSON.stringify(finding)}\n`);
213
+ return finding;
214
+ }
215
+
216
+ /**
217
+ * Read the journal. Returns `{ findings, malformed }`. Malformed lines are
218
+ * preserved as `{ line, raw, error }` rather than throwing, so one bad line
219
+ * never blocks listing or export of the rest of the Learning Trail.
220
+ */
221
+ export function readJournal(journalPath) {
222
+ const resolved = resolve(journalPath);
223
+ if (!existsSync(resolved)) return { findings: [], malformed: [] };
224
+ const text = readFileSync(resolved, "utf8");
225
+ const findings = [];
226
+ const malformed = [];
227
+ const lines = text.split("\n");
228
+ for (let index = 0; index < lines.length; index += 1) {
229
+ const raw = lines[index];
230
+ if (!raw.trim()) continue;
231
+ try {
232
+ findings.push(JSON.parse(raw));
233
+ } catch (error) {
234
+ malformed.push({ line: index + 1, raw, error: error.message });
235
+ }
236
+ }
237
+ return { findings, malformed };
238
+ }
239
+
240
+ function groupByStageThenKind(findings) {
241
+ // Internal aggregation groups by Observation Stage first, Finding Kind
242
+ // second. The local export mirrors that ordering so a pasted summary reads
243
+ // the same way the dashboard will later.
244
+ const byStage = new Map();
245
+ for (const finding of findings) {
246
+ const stage = isNonEmptyString(finding.stage) ? finding.stage : "(unknown)";
247
+ const kind = isNonEmptyString(finding.kind) ? finding.kind : "(unknown)";
248
+ if (!byStage.has(stage)) byStage.set(stage, new Map());
249
+ const byKind = byStage.get(stage);
250
+ if (!byKind.has(kind)) byKind.set(kind, []);
251
+ byKind.get(kind).push(finding);
252
+ }
253
+ const stageOrder = (stage) => {
254
+ const index = FINDING_STAGES.indexOf(stage);
255
+ return index === -1 ? FINDING_STAGES.length : index;
256
+ };
257
+ const kindOrder = (kind) => {
258
+ const index = FINDING_KINDS.indexOf(kind);
259
+ return index === -1 ? FINDING_KINDS.length : index;
260
+ };
261
+ return [...byStage.entries()]
262
+ .sort((a, b) => stageOrder(a[0]) - stageOrder(b[0]) || a[0].localeCompare(b[0]))
263
+ .map(([stage, byKind]) => ({
264
+ stage,
265
+ kinds: [...byKind.entries()]
266
+ .sort((a, b) => kindOrder(a[0]) - kindOrder(b[0]) || a[0].localeCompare(b[0]))
267
+ .map(([kind, items]) => ({ kind, items })),
268
+ }));
269
+ }
270
+
271
+ /**
272
+ * Markdown summary grouped by Observation Stage then Finding Kind. Includes
273
+ * counts and short summaries and references (artifact paths, run IDs) only —
274
+ * never artifact contents. Pasteable into Linear/GitHub/Slack.
275
+ */
276
+ export function exportSummaryMarkdown(findings) {
277
+ const lines = ["# Campaigns OS Workflow Findings", ""];
278
+ lines.push(`Total findings: ${findings.length}`, "");
279
+ if (!findings.length) {
280
+ lines.push("_No findings recorded yet._");
281
+ return `${lines.join("\n")}\n`;
282
+ }
283
+ for (const { stage, kinds } of groupByStageThenKind(findings)) {
284
+ const stageCount = kinds.reduce((sum, group) => sum + group.items.length, 0);
285
+ lines.push(`## ${stage} (${stageCount})`, "");
286
+ for (const { kind, items } of kinds) {
287
+ lines.push(`### ${kind} (${items.length})`);
288
+ for (const finding of items) {
289
+ const refs = [];
290
+ if (Array.isArray(finding.artifact_paths) && finding.artifact_paths.length) {
291
+ refs.push(`artifacts: ${finding.artifact_paths.join(", ")}`);
292
+ }
293
+ if (isNonEmptyString(finding.qa_run_id)) refs.push(`qa_run: ${finding.qa_run_id}`);
294
+ if (isNonEmptyString(finding.map_id)) refs.push(`map: ${finding.map_id}`);
295
+ const refSuffix = refs.length ? ` — _${refs.join("; ")}_` : "";
296
+ lines.push(`- ${finding.summary || "(no summary)"}${refSuffix}`);
297
+ }
298
+ lines.push("");
299
+ }
300
+ }
301
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
302
+ }
303
+
304
+ /**
305
+ * Structured JSON export for internal ingestion. Validates each entry and
306
+ * strips nothing but also adds nothing — artifact contents are never present
307
+ * because findings only ever store references.
308
+ */
309
+ export function exportJson(findings) {
310
+ const invalid = [];
311
+ for (const finding of findings) {
312
+ const validation = validateWorkflowFinding(finding);
313
+ if (!validation.ok) invalid.push({ id: finding?.id || null, errors: validation.errors });
314
+ }
315
+ if (invalid.length) {
316
+ const detail = invalid
317
+ .map((entry) => `${entry.id || "(no id)"}: ${entry.errors.map((error) => error.code).join(", ")}`)
318
+ .join(" | ");
319
+ throw new Error(`Findings Journal has invalid entries; refusing to export: ${detail}`);
320
+ }
321
+ return {
322
+ schema_version: WORKFLOW_FINDING_SCHEMA,
323
+ count: findings.length,
324
+ findings,
325
+ };
326
+ }
@@ -0,0 +1,68 @@
1
+ // Path identity, answered once for every module that asks "is this the same
2
+ // file" or "which spelling of this path do I record".
3
+ //
4
+ // A path reached through a symlinked checkout and the same file reached
5
+ // directly are one file; two lexically different spellings of a path that
6
+ // does not exist cannot be shown to be one file. Every caller used to pick a
7
+ // policy for the second case on its own — one compared missing paths by
8
+ // spelling, another refused to treat them as equal — so the two decisions
9
+ // that key on packet identity (session join/refuse, context-to-report
10
+ // binding) could disagree on the same checkout. The policy is now an
11
+ // argument. A leaf: node built-ins only.
12
+
13
+ import { realpathSync } from "node:fs";
14
+ import { basename, dirname, join, resolve } from "node:path";
15
+
16
+ // The path as the filesystem knows it: the real path when it exists, else
17
+ // the real path of its nearest existing ancestor with the missing tail
18
+ // re-appended. What run sessions, Run Record artifact refs and portable
19
+ // (`--strip-paths`) output record. Canonicalising only the side that exists
20
+ // is worse than canonicalising neither — a real base and a lexical target
21
+ // relativize to `../<link>/…` — so a path that is not there yet still
22
+ // resolves through the directory links above it.
23
+ export function canonicalPath(path) {
24
+ const resolved = resolve(path);
25
+ const missing = [];
26
+ let cursor = resolved;
27
+ for (;;) {
28
+ try {
29
+ return join(realpathSync(cursor), ...missing);
30
+ } catch (error) {
31
+ if (!absentOrMalformed(error)) throw error;
32
+ const parent = dirname(cursor);
33
+ if (parent === cursor) return resolved;
34
+ missing.unshift(basename(cursor));
35
+ cursor = parent;
36
+ }
37
+ }
38
+ }
39
+
40
+ // The same file. Equal once resolved, or one file behind any symlinks. With
41
+ // `requireExisting`, a side that is not on disk is never the same file as
42
+ // anything spelled differently; without it, a missing path compares by its
43
+ // canonical spelling (the directory links above it resolved).
44
+ export function sameFile(left, right, { requireExisting = false } = {}) {
45
+ const resolvedLeft = resolve(left);
46
+ const resolvedRight = resolve(right);
47
+ if (resolvedLeft === resolvedRight) return true;
48
+ if (!requireExisting) return canonicalPath(resolvedLeft) === canonicalPath(resolvedRight);
49
+ const real = (path) => {
50
+ try {
51
+ return realpathSync(path);
52
+ } catch (error) {
53
+ if (absentOrMalformed(error)) return null;
54
+ throw error;
55
+ }
56
+ };
57
+ const realLeft = real(resolvedLeft);
58
+ return realLeft !== null && realLeft === real(resolvedRight);
59
+ }
60
+
61
+ // The errors a best-effort read may treat as "nothing there": the path is
62
+ // absent (ENOENT), a component of it is not a directory (ENOTDIR), or the
63
+ // bytes are not JSON (SyntaxError). A permission failure, EISDIR, EIO and
64
+
65
+ // every other error are not absence and must reach the caller.
66
+ export function absentOrMalformed(error) {
67
+ return error?.code === "ENOENT" || error?.code === "ENOTDIR" || error instanceof SyntaxError;
68
+ }
@@ -0,0 +1,105 @@
1
+ // Gate actions: the vocabulary a checkpoint gate publishes as
2
+ // `required_actions[]` and the one rule that turns an action into the text an
3
+ // operator reads.
4
+ //
5
+ // A gate's action is `{ id, kind, command, description }`: a runnable command
6
+ // carrying the `--packet <packet>` placeholder, or a manual step with no
7
+ // command. Doctor, `next`, the QA runner's resolve printer and its theme-gate
8
+ // printer each rendered that shape for themselves — four spellings of the
9
+ // placeholder substitution — and the polish checkpoint's actions were declared
10
+ // once in polish-node and copied byte for byte into polish-gate, which
11
+ // polish-node imports and so could not import from. A leaf: shell-token only.
12
+
13
+ import { shellToken } from "./shell-token.mjs";
14
+ import { localProofRebuildText } from "./local-proof.mjs";
15
+ import { invocationPrefixFor } from "./install-mode.mjs";
16
+ import { dirname as installModeDirname, resolve as installModeResolve } from "node:path";
17
+ import { fileURLToPath as installModeFileUrl } from "node:url";
18
+ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(import.meta.url)), "..");
19
+
20
+ // The recorded actions of the hidden eager-media checkpoint, keyed by the
21
+ // short name each producer reaches for. The gate evaluators publish these
22
+ // objects unchanged; renderers never re-spell their text.
23
+ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
24
+ capture: Object.freeze({
25
+ id: "polish.hidden_eager_media.capture",
26
+ kind: "command",
27
+ command: "campaigns-os polish capture --packet <packet> --base-url <url>",
28
+ description: "Capture package-owned page-load evidence for every mapped route and fixed viewport.",
29
+ }),
30
+ install_browser: Object.freeze({
31
+ id: "polish.hidden_eager_media.install_browser",
32
+ kind: "command",
33
+ command: "campaigns-os qa install-browser",
34
+ description: "Install the package-owned Playwright Chromium runtime before rerunning polish capture (npm run qa:install-browser from a checkout).",
35
+ }),
36
+ waive: Object.freeze({
37
+ id: "polish.hidden_eager_media.waive",
38
+ kind: "command",
39
+ command: "campaigns-os checkpoint waive --packet <packet> --gate polish.hidden_eager_media --reason \"<why>\" --waived-by \"<named human>\" --review-condition \"<trigger>\"",
40
+ description: "Record an exact named-human waiver for the current hidden eager-media findings.",
41
+ }),
42
+ repair: Object.freeze({
43
+ id: "polish.hidden_eager_media.repair",
44
+ kind: "manual",
45
+ command: null,
46
+ description: "Make each reported hidden media element visible, defer it with exact preload=none/metadata, or reduce its aggregate transferred bytes to at most 1,048,576; then recapture.",
47
+ }),
48
+ repair_authority: Object.freeze({
49
+ id: "polish.hidden_eager_media.repair_authority",
50
+ kind: "manual",
51
+ command: null,
52
+ description: "Repair the packet or Assembly Report campaign identity, build fingerprint, and mapped route plan before capture.",
53
+ }),
54
+ // A capture over plain HTTP whose ledger shows a failed cross-origin http:
55
+ // dependency is a production build served locally: its protocol-relative
56
+ // vendor loaders resolved to http:// and failed. The action is to rebuild in
57
+ // the development environment (local proof mode), never to edit the
58
+ // generated include that emits the loader.
59
+ local_proof_rebuild: Object.freeze({
60
+ id: "polish.hidden_eager_media.local_proof_rebuild",
61
+ kind: "manual",
62
+ command: null,
63
+ description: localProofRebuildText(),
64
+ }),
65
+ });
66
+
67
+ const PACKET_PLACEHOLDER = "--packet <packet>";
68
+
69
+ // The placeholder becomes the packet this run read, shell-quoted, so the
70
+ // command is copy-pasteable. A function replacement, so `$&`, `$$` or `$1`
71
+ // inside the path are inserted literally instead of being read as
72
+ // replacement patterns. No packet, or no command: returned as given.
73
+ export function substitutePacket(command, packetPath) {
74
+ if (typeof command !== "string" || typeof packetPath !== "string" || !packetPath) return command;
75
+ return command.replace(PACKET_PLACEHOLDER, () => `--packet ${shellToken(packetPath)}`);
76
+ }
77
+
78
+ // Whole-token match against the declared TEMPLATE, never the substituted
79
+ // string: a packet path that happens to contain "--report" must not be
80
+ // mistaken for an option the action declared, and a flag that merely shares
81
+ // the prefix (say --report-format) is not the option itself.
82
+ function templateDeclares(template, flag) {
83
+ return typeof template === "string" && template.split(/\s+/).includes(flag);
84
+ }
85
+
86
+ // The text an operator acts on: the runnable command with the packet
87
+ // substituted, else the manual description, else nothing. With `reportPath`,
88
+ // a packet-scoped command that does not already name a report gains
89
+ // `--report <path>`, so a remediation acts on the report the inspection read
90
+ // rather than on the default sidecar it would otherwise resolve.
91
+ export function requiredActionText(action, { packetPath = null, reportPath = null } = {}) {
92
+ const template = typeof action?.command === "string" ? action.command : null;
93
+ let command = substitutePacket(template, packetPath);
94
+ if (command && reportPath && templateDeclares(template, "--packet") && !templateDeclares(template, "--report")) {
95
+ command = `${command} --report ${shellToken(reportPath)}`;
96
+ }
97
+ // Registry commands are stored bare; the printed text is spelled for the
98
+ // install this package runs from (bare from a checkout, `npx campaigns-os`
99
+ // from a campaign folder), once, here.
100
+ if (command && command.startsWith("campaigns-os ")) {
101
+ return `${invocationPrefixFor(PACKAGE_ROOT)} ${command.slice("campaigns-os ".length)}`;
102
+ }
103
+ if (command) return command;
104
+ return typeof action?.description === "string" && action.description ? action.description : null;
105
+ }
@@ -0,0 +1,53 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+
6
+ // A synchronous doctor's snapshot ends with the invocation. No file contents
7
+ // survive into another command, even when a host imports the CLI once and reuses it.
8
+ const snapshots = new AsyncLocalStorage();
9
+ const MAX_RETAINED_BYTES = 16 * 1024 * 1024;
10
+ const MAX_RETAINED_FILES = 4096;
11
+ export function withHtmlScanSnapshot(operation, { reuse = false } = {}) {
12
+ if (reuse && snapshots.getStore()?.active) return operation();
13
+ const snapshot = { files: new Map(), retainedBytes: 0, active: true };
14
+ return snapshots.run(snapshot, () => {
15
+ try {
16
+ return operation();
17
+ } finally {
18
+ // AsyncLocalStorage propagates to queued callbacks too. Expire the store
19
+ // at synchronous return, even if a future caller returns a Promise.
20
+ snapshot.active = false;
21
+ snapshot.files.clear();
22
+ snapshot.retainedBytes = 0;
23
+ }
24
+ });
25
+ }
26
+
27
+ function file(path) {
28
+ const snapshot = snapshots.getStore();
29
+ if (!snapshot?.active) return { bytes: readFileSync(path) };
30
+ const key = resolve(path);
31
+ if (snapshot.files.has(key)) return snapshot.files.get(key);
32
+ const entry = { bytes: readFileSync(path) };
33
+ // Budget buffer + worst-case decoded UTF-16 text together. Oversize files
34
+ // retain the original read-per-use behavior; tiny files are count-bounded.
35
+ const cost = entry.bytes.length * 3;
36
+ if (snapshot.files.size < MAX_RETAINED_FILES && snapshot.retainedBytes + cost <= MAX_RETAINED_BYTES) {
37
+ snapshot.files.set(key, entry);
38
+ snapshot.retainedBytes += cost;
39
+ }
40
+ return entry;
41
+ }
42
+
43
+ export function readHtmlScanText(path) {
44
+ const entry = file(path);
45
+ return entry.text ??= entry.bytes.toString('utf8');
46
+ }
47
+
48
+ // Byte-preserving generic digest; only an active read-only scan scope caches it.
49
+ // Callers that write artifacts must hash outside that scope to observe new bytes.
50
+ export function htmlScanDigest(path) {
51
+ const entry = file(path);
52
+ return entry.digest ??= createHash('sha256').update(entry.bytes).digest('hex');
53
+ }