@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,739 @@
1
+ import { HIDDEN_EAGER_MEDIA_SCOPE } from "./polish-page-load.mjs";
2
+ import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
3
+
4
+ export const POLISH_GATE_REQUIRED_EVIDENCE = Object.freeze([
5
+ "visual_review",
6
+ "brand_review",
7
+ "checkout_review",
8
+ "template_residue_review",
9
+ "commerce_flow_review",
10
+ "issues",
11
+ "commands",
12
+ ]);
13
+
14
+ export const POLISH_PRODUCER = "next-campaigns-polish";
15
+
16
+ const SUCCESS_STATUS_PREFIXES = Object.freeze(["completed"]);
17
+
18
+ function isObject(value) {
19
+ return value && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+
22
+ function nonEmptyString(value) {
23
+ return typeof value === "string" && value.trim().length > 0;
24
+ }
25
+
26
+ function normalizeString(value) {
27
+ return nonEmptyString(value) ? value.trim() : null;
28
+ }
29
+
30
+ function normalizeStringArray(value) {
31
+ return Array.isArray(value) ? value.map((entry) => normalizeString(entry)).filter(Boolean) : [];
32
+ }
33
+
34
+ function stageStatus(stage) {
35
+ return String(stage?.status || "");
36
+ }
37
+
38
+ function stageSucceeded(stage) {
39
+ const status = stageStatus(stage);
40
+ return SUCCESS_STATUS_PREFIXES.some((prefix) => status.startsWith(prefix));
41
+ }
42
+
43
+ function terminalAssembly(report) {
44
+ return String(report?.stages?.assembly?.status || "").startsWith("completed");
45
+ }
46
+
47
+ export function currentBuildFingerprint(report) {
48
+ return normalizeString(report?.stages?.assembly?.build_fingerprint)
49
+ || normalizeString(report?.stages?.assembly?.artifact_fingerprint)
50
+ || normalizeString(report?.build_fingerprint)
51
+ || normalizeString(report?.artifact_fingerprint)
52
+ || null;
53
+ }
54
+
55
+ export function currentSourcePackageMaterialFingerprint(report) {
56
+ return normalizeString(report?.design_source_package?.material_fingerprint)
57
+ || normalizeString(report?.inputs?.design_source_package?.material_fingerprint)
58
+ || normalizeString(report?.source_package?.material_fingerprint)
59
+ || normalizeString(report?.source_package_material_fingerprint)
60
+ || null;
61
+ }
62
+
63
+ export function assemblySourcePackageMaterialFingerprint(report) {
64
+ const assembly = report?.stages?.assembly;
65
+ return normalizeString(assembly?.source_package_material_fingerprint)
66
+ || normalizeString(assembly?.evidence?.source_package_material_fingerprint)
67
+ || normalizeString(report?.assembly?.source_package_material_fingerprint)
68
+ || null;
69
+ }
70
+
71
+ // One predicate for "this report claims a Design Source Package but Build never
72
+ // tied itself to it". The polish gate, `doctor`/`next` (through the gate) and
73
+ // the standalone `validate-assembly-report` all answer that question from here,
74
+ // so the three paths cannot drift: before this existed the gate blocked and the
75
+ // validator stayed silent on the same report.
76
+ //
77
+ // It carries the gate's own preconditions, not just the fingerprint
78
+ // comparison. A report still pending Assembly (the shape `prepare-build` and
79
+ // `start` emit, which records the package fingerprint before any build has
80
+ // consumed it) and a report with no build fingerprint yet are both outside
81
+ // this finding: the gate answers not_applicable or blocks on
82
+ // polish.build_fingerprint_missing there, and so the validator must not fail
83
+ // them on freshness.
84
+ //
85
+ // Callers that have already assessed the freshness waivers pass that
86
+ // assessment in rather than paying for a second scan of the same records; the
87
+ // gate does. A malformed waiver is a separate finding — the gate's
88
+ // polish.waiver_expires_at_invalid, mirrored by the validator — and both
89
+ // callers report it before asking this question, so a report whose only
90
+ // waiver record is malformed fails on that finding alone. Asked directly, this
91
+ // predicate treats a malformed record as no active waiver.
92
+ export function assemblySourcePackageFingerprintMissing(report, now = Date.now(), waiverAssessment = null) {
93
+ if (!terminalAssembly(report)) return false;
94
+ if (!currentBuildFingerprint(report)) return false;
95
+ if (!currentSourcePackageMaterialFingerprint(report)) return false;
96
+ if (assemblySourcePackageMaterialFingerprint(report)) return false;
97
+ const assessment = waiverAssessment || assessAssemblySourcePackageFreshnessWaivers(report, now);
98
+ return !assessment.active;
99
+ }
100
+
101
+ export function assessAssemblySourcePackageFreshnessWaivers(report, now = Date.now()) {
102
+ const candidates = [
103
+ ...(Array.isArray(report?.waivers) ? report.waivers : []),
104
+ report?.assembly_source_package_freshness_waiver,
105
+ report?.source_package_freshness_waiver,
106
+ ].filter(Boolean);
107
+ const assessment = { active: null, expired: [], invalid: [] };
108
+ for (const waiver of candidates) {
109
+ if (!isObject(waiver) || !normalizeString(waiver.reason)) continue;
110
+ const scope = normalizeString(waiver.scope);
111
+ const appliesTo = normalizeStringArray(waiver.applies_to);
112
+ const scopeMatches = [
113
+ "assembly_source_package_freshness",
114
+ "source_package_after_build",
115
+ "source_package_stale_after_build",
116
+ ].includes(scope);
117
+ const appliesToMatches = appliesTo.some((entry) => [
118
+ "stages.assembly.source_package_material_fingerprint",
119
+ "design_source_package.material_fingerprint",
120
+ "polish.assembly_source_package_fingerprint_missing",
121
+ "polish.assembly_source_package_stale",
122
+ ].includes(entry));
123
+ const attributed = Boolean(normalizeString(waiver.waived_by) || normalizeString(waiver.owner));
124
+ const timestamped = Boolean(normalizeString(waiver.waived_at) || normalizeString(waiver.created_at));
125
+ if (!(scopeMatches || appliesToMatches) || !attributed || !timestamped) continue;
126
+ const expiresAt = normalizeString(waiver.expires_at);
127
+ if (expiresAt !== null) {
128
+ // expires_at must parse as a real timestamp: an unparseable value is a
129
+ // malformed record the gate refuses to honor, and an explicit expiry
130
+ // that has passed dominates any review_condition on the same record.
131
+ // The boundary is inclusive — expires_at exactly equal to now is
132
+ // expired. Selection is first-valid-wins: expired and invalid records
133
+ // are never selected regardless of position, so order only matters
134
+ // between multiple currently-valid waivers.
135
+ const parsed = Date.parse(expiresAt);
136
+ if (Number.isNaN(parsed)) {
137
+ assessment.invalid.push(waiver);
138
+ continue;
139
+ }
140
+ if (parsed <= now) {
141
+ assessment.expired.push(waiver);
142
+ continue;
143
+ }
144
+ if (!assessment.active) assessment.active = waiver;
145
+ continue;
146
+ }
147
+ if (normalizeString(waiver.review_condition) && !assessment.active) assessment.active = waiver;
148
+ }
149
+ return assessment;
150
+ }
151
+
152
+ function polishEvidence(stage, report) {
153
+ if (isObject(stage?.evidence)) return stage.evidence;
154
+ if (isObject(report?.polish?.evidence)) return report.polish.evidence;
155
+ return null;
156
+ }
157
+
158
+ function fieldHasEvidence(value, field) {
159
+ if (field === "issues") return Array.isArray(value);
160
+ if (field === "commands") {
161
+ if (!Array.isArray(value) || value.length === 0) return false;
162
+ return value.some((entry) => nonEmptyString(entry) || isObject(entry));
163
+ }
164
+ if (field === "visual_review") {
165
+ if (!isObject(value)) return false;
166
+ const screenshots = value.screenshots || value.screenshot_paths || value.paths || value.urls;
167
+ return Array.isArray(screenshots) && screenshots.some(nonEmptyString);
168
+ }
169
+ if (Array.isArray(value)) return value.length > 0;
170
+ if (isObject(value)) return Object.keys(value).length > 0;
171
+ return nonEmptyString(value);
172
+ }
173
+
174
+ function textFragments(value) {
175
+ if (Array.isArray(value)) return value.flatMap(textFragments);
176
+ if (isObject(value)) return Object.values(value).flatMap(textFragments);
177
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return [String(value)];
178
+ return [];
179
+ }
180
+
181
+ function reviewText(value) {
182
+ return textFragments(value).join(" ");
183
+ }
184
+
185
+ function hasNegativeEvidence(value, pattern) {
186
+ const text = reviewText(value);
187
+ if (/\bnot\s+found\b|\bnone\s+found\b|\bnot\s+present\b|\bno\s+(?:starter|template)\b/i.test(text)) return false;
188
+ return pattern.test(text);
189
+ }
190
+
191
+ function buildBriefBlocksTemplateFavicon(report) {
192
+ // Canonical Build Brief location after prepare-build normalization.
193
+ const policy = report?.build_brief?.artifact?.template_residue_policy || null;
194
+ return policy?.block_template_favicon === true;
195
+ }
196
+
197
+ const STARTER_FAVICON_LEAK_RE = /\b(?:starter|template)\s+favicon\s+(?:found|present|matched|leaked|retained|kept|remaining)|images\/favicon\.png\b/i;
198
+
199
+ function faviconRecordCertifiesOk(record) {
200
+ return isObject(record) && (
201
+ record.byte_match === true
202
+ || ["matched_source", "promoted_source", "confirmed_non_template", "no_source_candidate"].includes(String(record.status || record.result || ""))
203
+ );
204
+ }
205
+
206
+ function faviconTextConfirmsSourceMatch(text) {
207
+ return /(?:byte[-_\s]?match|matched|matches|matching).{0,40}(?:source|brand|candidate)|(?:source|brand|candidate).{0,40}(?:matched|matches|matching|byte[-_\s]?match)|promoted.{0,40}source|confirmed.{0,40}(?:non[-_\s]?template|not[-_\s]?template)|\b(?:no|none)\s+source\s+candidate\b/i.test(text);
208
+ }
209
+
210
+ function hasNegativeBumpCompareEvidence(value) {
211
+ const text = reviewText(value);
212
+ if (/\b(?:no|none)\s+(?:equal|same|duplicate|doubled|no[-_\s]?discount).{0,24}(?:compare|strike|original|price)\s*(?:found|present|rendered|shown|visible)?\b/i.test(text)) return false;
213
+ return hasNegativeEvidence(value, /\b(?:equal|same)\s+(?:compare|strike|original)\s+price\s+(?:found|present|rendered|shown|visible)\b|\b(?:equal|same)\s+price\s+(?:compare|strike|original)\s+(?:found|present|rendered|shown|visible)\b|\b(?:compare|strike|original)\s+price\s+(?:equals|===|same\s+as)\s+(?:list|full|retail|original)\b|\b(?:doubled|duplicate)\s+(?:compare|strike|original|price)(?:\s+(?:row|price))?\s+(?:found|present|rendered|shown|visible)\b|\bno[-_\s]?discount\s+(?:compare|strike|original)\s+(?:rendered|shown|visible|present|found)\b/i);
214
+ }
215
+
216
+ function semanticEvidenceProblems(evidence, report) {
217
+ const problems = [];
218
+ const brandReview = evidence?.brand_review;
219
+ const checkoutReview = evidence?.checkout_review;
220
+ const residueReview = evidence?.template_residue_review;
221
+
222
+ const favicon = isObject(brandReview) ? brandReview.favicon : null;
223
+ const faviconCertifiedOk = faviconRecordCertifiesOk(favicon);
224
+ if (buildBriefBlocksTemplateFavicon(report)) {
225
+ const faviconText = reviewText(favicon);
226
+ const faviconTextOk = faviconTextConfirmsSourceMatch(faviconText);
227
+ if (!faviconCertifiedOk && !faviconTextOk) {
228
+ problems.push("stages.polish.evidence.brand_review.favicon must confirm source/brand favicon matching or a documented no-source-candidate outcome when block_template_favicon is true.");
229
+ }
230
+ if (isObject(favicon) && favicon.byte_match === false) {
231
+ problems.push("stages.polish.evidence.brand_review.favicon records byte_match=false while block_template_favicon is true.");
232
+ }
233
+ }
234
+ // A structured record that explicitly certifies the favicon (byte_match /
235
+ // accepted status) is authoritative — do not let a note that merely mentions
236
+ // the starter path (e.g. "copied over assets/images/favicon.png") read as
237
+ // leakage. Free-text-only evidence still gets the negative scan.
238
+ if (!faviconCertifiedOk && hasNegativeEvidence(favicon, STARTER_FAVICON_LEAK_RE)) {
239
+ problems.push("stages.polish.evidence.brand_review.favicon still indicates starter-template favicon leakage.");
240
+ }
241
+ if (!faviconRecordCertifiesOk(residueReview?.starter_favicon) && hasNegativeEvidence(residueReview?.starter_favicon, STARTER_FAVICON_LEAK_RE)) {
242
+ problems.push("stages.polish.evidence.template_residue_review.starter_favicon still indicates starter-template favicon leakage.");
243
+ }
244
+
245
+ const fieldEvidence = checkoutReview?.field_labels ?? checkoutReview?.initial_field_hints ?? checkoutReview?.visible_labels;
246
+ if (!fieldEvidence) {
247
+ problems.push("stages.polish.evidence.checkout_review must confirm initial checkout field labels/placeholders/hints.");
248
+ } else if (hasNegativeEvidence(fieldEvidence, /\b(?:missing|absent|blank|unlabeled|unlabelled|placeholder[-_\s]?stripped|not\s+legible)\b/i)) {
249
+ problems.push("stages.polish.evidence.checkout_review.field_labels must confirm a legible initial field hint/label state.");
250
+ }
251
+
252
+ const bumpEvidence = checkoutReview?.bump_compare_price_rule ?? checkoutReview?.bump_compare_price;
253
+ if (!bumpEvidence) {
254
+ problems.push("stages.polish.evidence.checkout_review must confirm the order-bump compare-price rule.");
255
+ } else if ((isObject(bumpEvidence) && (bumpEvidence.equal_compare_price_found === true || bumpEvidence.same_price_compare_rendered === true)) || hasNegativeBumpCompareEvidence(bumpEvidence)) {
256
+ problems.push("stages.polish.evidence.checkout_review.bump_compare_price_rule must confirm no equal/no-discount compare price renders.");
257
+ }
258
+
259
+ // Brand bleed: when a campaign is cloned from a proven sibling, the sibling's
260
+ // brand defaults ride along — a default promo/sale banner with a fake code,
261
+ // hardcoded non-token colors (e.g. next-core's #C670FE pill), scaffold fonts
262
+ // (Plus Jakarta), and the prior favicon. Polish must affirmatively certify
263
+ // the de-brand pass cleared them. See the Shield build learnings (A3).
264
+ // Field precedence: `brand_bleed` is canonical; `brand_bleed_review` and
265
+ // `debrand` are accepted aliases (in that order) for forward compat.
266
+ const bleedEvidence = brandReview?.brand_bleed ?? brandReview?.brand_bleed_review ?? brandReview?.debrand;
267
+ if (bleedEvidence === undefined || bleedEvidence === null) {
268
+ problems.push("stages.polish.evidence.brand_review.brand_bleed must confirm the cloned-source de-brand pass: no residual promo/sale code or copy, no prior-campaign favicon, no scaffold/non-design fonts, no hardcoded non-token colors.");
269
+ } else {
270
+ const kinds = brandBleedResidualKinds(bleedEvidence);
271
+ if (kinds.length) {
272
+ problems.push(`stages.polish.evidence.brand_review.brand_bleed still indicates cloned-source brand bleed (${kinds.join(", ")}).`);
273
+ }
274
+ }
275
+
276
+ return problems;
277
+ }
278
+
279
+ // Per-kind brand-bleed residue detection. Returns the kinds that still leaked,
280
+ // so the gate message names the actual residue. Each kind has its own phrase
281
+ // vocabulary rather than one monolithic regex.
282
+ const BRAND_BLEED_RESIDUE_PATTERNS = [
283
+ ["residual promo/sale code or copy", /\b(?:promo|sale|discount|coupon)\b[^.;]{0,40}\b(?:code|copy|banner|sale)\b[^.;]{0,40}\b(?:found|present|remain(?:s|ing)?|leaked|retained|kept|still)\b|\b(?:found|present|remain(?:s|ing)?|leaked|retained|kept|still)\b[^.;]{0,40}\b(?:promo|sale|discount|coupon)\b[^.;]{0,40}\b(?:code|copy|banner)\b|\bfake\s+(?:code|sale|coupon)\b/i],
284
+ ["prior-campaign favicon", /\bprior[-_\s]?campaign\b|\bsibling[-_\s]?(?:brand|campaign|source)\b/i],
285
+ ["scaffold/non-design fonts", /\bscaffold\s+fonts?\b|\bplus\s+jakarta\b|\bnon[-_\s]?design\s+fonts?\b/i],
286
+ ["hardcoded non-token color", /(?:^|[^\w])#?c670fe(?:[^\w]|$)|\bhardcoded\s+(?:non[-_\s]?token\s+)?(?:colou?r|hex|purple)\b|\bnon[-_\s]?token\s+colou?r\s+(?:found|present|remain(?:s|ing)?)\b/i],
287
+ ];
288
+
289
+ function brandBleedResidualKinds(value) {
290
+ if (isObject(value)) {
291
+ if (value.cleared === false || value.bleed_found === true || value.residual_found === true) return ["de-brand pass not cleared"];
292
+ if (value.cleared === true || value.bleed_found === false || value.residual_found === false) return [];
293
+ }
294
+ const text = reviewText(value);
295
+ // Explicit "not cleared" wording blocks first — a free-form "cleared: false"
296
+ // must not slip through just because no residue *kind* phrase happens to match.
297
+ // Tightened to avoid over-blocking benign clearances: the boolean form
298
+ // requires a literal "false" (not "no", which collides with "cleared: no
299
+ // bleed"), and bleed/residual "found" is excluded when preceded by "no" (so
300
+ // "no bleed found" reads as cleared, not as residue).
301
+ if (/\bnot\s+cleared\b|\bcleared\s*[:=-]\s*false\b|\b(?<!no\s)(?:bleed|residual)\s+(?:found|present|detected|remain(?:s|ing)?)\b|\bnot\s+de[-_\s]?branded\b/i.test(text)) {
302
+ return ["de-brand pass not cleared"];
303
+ }
304
+ // Otherwise an explicit cleared/none statement short-circuits the phrase scan.
305
+ if (/\bcleared\b|\bno\s+(?:bleed|residue|residual)\b|\bde[-_\s]?brand(?:ed|\s+pass)\b/i.test(text)) {
306
+ return [];
307
+ }
308
+ const kinds = [];
309
+ for (const [label, pattern] of BRAND_BLEED_RESIDUE_PATTERNS) {
310
+ if (hasNegativeEvidence(value, pattern)) kinds.push(label);
311
+ }
312
+ return kinds;
313
+ }
314
+
315
+ function evidenceProblems(evidence, report) {
316
+ if (!isObject(evidence)) {
317
+ return ["stages.polish.evidence must be an object with the required polish evidence categories."];
318
+ }
319
+ const problems = [];
320
+ for (const field of POLISH_GATE_REQUIRED_EVIDENCE) {
321
+ if (!fieldHasEvidence(evidence[field], field)) {
322
+ problems.push(`stages.polish.evidence.${field} is missing or incomplete.`);
323
+ }
324
+ }
325
+ problems.push(...semanticEvidenceProblems(evidence, report));
326
+ return problems;
327
+ }
328
+
329
+ function performedBy(stage, evidence) {
330
+ return normalizeString(stage?.performed_by)
331
+ || normalizeString(stage?.command_identity)
332
+ || normalizeString(evidence?.performed_by)
333
+ || null;
334
+ }
335
+
336
+ function sourceBuildFingerprint(stage, evidence) {
337
+ return normalizeString(stage?.source_build_fingerprint)
338
+ || normalizeString(evidence?.source_build_fingerprint)
339
+ || null;
340
+ }
341
+
342
+ function sourcePackageMaterialFingerprint(stage, evidence) {
343
+ return normalizeString(stage?.source_package_material_fingerprint)
344
+ || normalizeString(evidence?.source_package_material_fingerprint)
345
+ || null;
346
+ }
347
+
348
+ function completedAt(stage, evidence) {
349
+ return normalizeString(stage?.completed_at)
350
+ || normalizeString(evidence?.completed_at)
351
+ || null;
352
+ }
353
+
354
+ function commandMentionsBuild(stage, evidence) {
355
+ const commands = [
356
+ ...(Array.isArray(stage?.commands) ? stage.commands : []),
357
+ ...(Array.isArray(evidence?.commands) ? evidence.commands : []),
358
+ ];
359
+ return commands.some((entry) => /next-campaigns-build|campaigns-os\s+next\s+build/i.test(String(isObject(entry) ? entry.command || entry.name || "" : entry)));
360
+ }
361
+
362
+ function evaluateStructuredPolishGate({ report, required = false, now = Date.now(), currentOutputFingerprint = null } = {}) {
363
+ if (!isObject(report)) {
364
+ return required
365
+ ? {
366
+ status: "blocked",
367
+ code: "polish.report_missing",
368
+ reason: "Polish evidence missing for current build. Run next-campaigns-polish before QA.",
369
+ required_actions: [{ id: "run_polish", kind: "skill", command: "next-campaigns-polish", description: "Run the distinct Polish stage and record structured evidence on stages.polish." }],
370
+ }
371
+ : { status: "not_applicable", code: "polish.not_applicable", reason: "No assembly report is available to evaluate polish evidence." };
372
+ }
373
+
374
+ if (!terminalAssembly(report)) {
375
+ return {
376
+ status: "not_applicable",
377
+ code: "polish.not_applicable",
378
+ reason: "Assembly is not completed yet; polish evidence is required after build completion.",
379
+ };
380
+ }
381
+
382
+ const buildFingerprint = currentBuildFingerprint(report);
383
+ const currentSourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
384
+ const assemblySourcePackageFingerprint = assemblySourcePackageMaterialFingerprint(report);
385
+ const waiverAssessment = assessAssemblySourcePackageFreshnessWaivers(report, now);
386
+ const sourcePackageFreshnessWaiver = waiverAssessment.active;
387
+ const expiredWaiver = waiverAssessment.expired[0] || null;
388
+ const stage = report.stages?.polish;
389
+ const evidence = polishEvidence(stage, report);
390
+ const buildRequiredActions = [
391
+ {
392
+ id: "rerun_build",
393
+ kind: "skill",
394
+ command: "next-campaigns-build",
395
+ description: "Re-run Build/Assembly against the current Design Source Package and record stages.assembly.source_package_material_fingerprint before Polish.",
396
+ },
397
+ ];
398
+ const requiredActions = [
399
+ {
400
+ id: "run_polish",
401
+ kind: "skill",
402
+ command: "next-campaigns-polish",
403
+ description: "Run the distinct Polish stage and record stages.polish.performed_by, source_build_fingerprint, source_package_material_fingerprint when available, completed_at, and structured evidence.",
404
+ },
405
+ ];
406
+
407
+ if (waiverAssessment.invalid.length) {
408
+ const invalid = waiverAssessment.invalid[0];
409
+ return {
410
+ status: "blocked",
411
+ code: "polish.waiver_expires_at_invalid",
412
+ reason: `Source freshness waiver has an unparseable expires_at (${JSON.stringify(invalid.expires_at)}). Record a valid ISO 8601 timestamp, or remove the malformed waiver, before Polish/QA.`,
413
+ build_fingerprint: buildFingerprint,
414
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
415
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
416
+ waiver: invalid,
417
+ required_actions: [
418
+ {
419
+ id: "repair_waiver",
420
+ kind: "manual",
421
+ command: null,
422
+ description: "Correct the waiver's expires_at to a valid ISO 8601 timestamp (or remove the malformed waiver record) on the assembly report.",
423
+ },
424
+ ],
425
+ };
426
+ }
427
+
428
+ if (!buildFingerprint) {
429
+ return {
430
+ status: "blocked",
431
+ code: "polish.build_fingerprint_missing",
432
+ reason: "Current build artifact fingerprint is missing. Build must record stages.assembly.build_fingerprint before Polish/QA handoff.",
433
+ required_actions: requiredActions,
434
+ };
435
+ }
436
+
437
+ if (assemblySourcePackageFingerprintMissing(report, now, waiverAssessment)) {
438
+ return {
439
+ status: "blocked",
440
+ code: "polish.assembly_source_package_fingerprint_missing",
441
+ reason: expiredWaiver
442
+ ? `Assembly is not tied to the current Design Source Package material fingerprint, and the recorded source freshness waiver expired at ${expiredWaiver.expires_at}. Re-run Build before Polish (or record a new waiver).`
443
+ : "Assembly is not tied to the current Design Source Package material fingerprint. Re-run Build before Polish.",
444
+ build_fingerprint: buildFingerprint,
445
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
446
+ ...(expiredWaiver ? { expired_waiver: expiredWaiver } : {}),
447
+ required_actions: buildRequiredActions,
448
+ };
449
+ }
450
+ if (currentSourcePackageFingerprint && assemblySourcePackageFingerprint !== currentSourcePackageFingerprint && !sourcePackageFreshnessWaiver) {
451
+ return {
452
+ status: "blocked",
453
+ code: "polish.assembly_source_package_stale",
454
+ reason: expiredWaiver
455
+ ? `The Design Source Package changed after Build, and the recorded source freshness waiver expired at ${expiredWaiver.expires_at}. Re-run Build against the current source package before Polish (or record a new waiver).`
456
+ : "The Design Source Package changed after Build. Re-run Build against the current source package before Polish.",
457
+ build_fingerprint: buildFingerprint,
458
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
459
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
460
+ ...(expiredWaiver ? { expired_waiver: expiredWaiver } : {}),
461
+ required_actions: buildRequiredActions,
462
+ };
463
+ }
464
+
465
+ if (!isObject(stage)) {
466
+ return {
467
+ status: "blocked",
468
+ code: "polish.evidence_missing",
469
+ reason: "Polish evidence missing for current build. Run next-campaigns-polish before QA.",
470
+ build_fingerprint: buildFingerprint,
471
+ waiver: sourcePackageFreshnessWaiver,
472
+ required_actions: requiredActions,
473
+ };
474
+ }
475
+
476
+ const status = stageStatus(stage);
477
+ if (status === "blocked") {
478
+ return {
479
+ status: "blocked",
480
+ code: "polish.blocked",
481
+ reason: "Polish is recorded as blocked. Resolve the Polish blockers before QA.",
482
+ build_fingerprint: buildFingerprint,
483
+ waiver: sourcePackageFreshnessWaiver,
484
+ required_actions: requiredActions,
485
+ };
486
+ }
487
+ if (!stageSucceeded(stage)) {
488
+ return {
489
+ status: "blocked",
490
+ code: "polish.evidence_missing",
491
+ reason: "Polish evidence missing for current build. Run next-campaigns-polish before QA.",
492
+ build_fingerprint: buildFingerprint,
493
+ waiver: sourcePackageFreshnessWaiver,
494
+ required_actions: requiredActions,
495
+ };
496
+ }
497
+
498
+ const producer = performedBy(stage, evidence);
499
+ if (producer !== POLISH_PRODUCER || commandMentionsBuild(stage, evidence)) {
500
+ return {
501
+ status: "blocked",
502
+ code: "polish.self_certified",
503
+ reason: `Polish success must be produced by ${POLISH_PRODUCER}, not ${producer || "an unspecified or build-owned producer"}. Run a distinct Polish stage before QA.`,
504
+ build_fingerprint: buildFingerprint,
505
+ performed_by: producer,
506
+ waiver: sourcePackageFreshnessWaiver,
507
+ required_actions: requiredActions,
508
+ };
509
+ }
510
+
511
+ const sourceFingerprint = sourceBuildFingerprint(stage, evidence);
512
+ if (!sourceFingerprint) {
513
+ return {
514
+ status: "blocked",
515
+ code: "polish.source_build_fingerprint_missing",
516
+ reason: "Polish evidence is not tied to the current build artifact fingerprint. Re-run next-campaigns-polish against the current build before QA.",
517
+ build_fingerprint: buildFingerprint,
518
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
519
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
520
+ waiver: sourcePackageFreshnessWaiver,
521
+ required_actions: requiredActions,
522
+ };
523
+ }
524
+ // The value the evidence must match is the fingerprint of the output on
525
+ // disk when the caller could recompute it (doctor, QA), not the string build
526
+ // recorded: evidence that matches a recorded value the output has since
527
+ // drifted from is stale in exactly the same way.
528
+ const outputFingerprint = normalizeString(currentOutputFingerprint);
529
+ if (outputFingerprint && outputFingerprint !== buildFingerprint) {
530
+ return {
531
+ status: "blocked",
532
+ code: "polish.output_drift",
533
+ reason: `Built output no longer matches stages.assembly.build_fingerprint (recorded ${buildFingerprint}, current ${outputFingerprint}); polish evidence is bound to a build that no longer exists. Re-run build, then next-campaigns-polish before QA.`,
534
+ build_fingerprint: buildFingerprint,
535
+ current_output_fingerprint: outputFingerprint,
536
+ source_build_fingerprint: sourceFingerprint,
537
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
538
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
539
+ waiver: sourcePackageFreshnessWaiver,
540
+ required_actions: mergeRequiredActions([
541
+ {
542
+ id: "rerun_build",
543
+ kind: "skill",
544
+ command: "next-campaigns-build",
545
+ description: "Re-run Build (page-kit build) and record the current output fingerprint (doctor --json derived.build_output_fingerprint.value) on stages.assembly.build_fingerprint before Polish.",
546
+ },
547
+ ], requiredActions),
548
+ };
549
+ }
550
+ if (sourceFingerprint !== buildFingerprint) {
551
+ return {
552
+ status: "blocked",
553
+ code: "polish.stale",
554
+ reason: "Polish evidence is stale for the current build artifact fingerprint. Re-run next-campaigns-polish before QA.",
555
+ build_fingerprint: buildFingerprint,
556
+ source_build_fingerprint: sourceFingerprint,
557
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
558
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
559
+ waiver: sourcePackageFreshnessWaiver,
560
+ required_actions: requiredActions,
561
+ };
562
+ }
563
+
564
+ const polishSourcePackageFingerprint = sourcePackageMaterialFingerprint(stage, evidence);
565
+ if (currentSourcePackageFingerprint && !polishSourcePackageFingerprint) {
566
+ return {
567
+ status: "blocked",
568
+ code: "polish.source_package_material_fingerprint_missing",
569
+ reason: "Polish evidence is not tied to the current Design Source Package material fingerprint. Re-run next-campaigns-polish against the current source package before QA.",
570
+ build_fingerprint: buildFingerprint,
571
+ source_build_fingerprint: sourceFingerprint,
572
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
573
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
574
+ waiver: sourcePackageFreshnessWaiver,
575
+ required_actions: requiredActions,
576
+ };
577
+ }
578
+ if (currentSourcePackageFingerprint && polishSourcePackageFingerprint !== currentSourcePackageFingerprint) {
579
+ return {
580
+ status: "blocked",
581
+ code: "polish.source_package_stale",
582
+ reason: "Polish evidence is stale for the current Design Source Package material fingerprint. Re-run next-campaigns-polish before QA.",
583
+ build_fingerprint: buildFingerprint,
584
+ source_build_fingerprint: sourceFingerprint,
585
+ source_package_material_fingerprint: polishSourcePackageFingerprint,
586
+ current_source_package_material_fingerprint: currentSourcePackageFingerprint,
587
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
588
+ waiver: sourcePackageFreshnessWaiver,
589
+ required_actions: requiredActions,
590
+ };
591
+ }
592
+
593
+ if (!completedAt(stage, evidence)) {
594
+ return {
595
+ status: "blocked",
596
+ code: "polish.completed_at_missing",
597
+ reason: "Polish evidence is missing completed_at. Re-run or repair the Polish stage record before QA.",
598
+ build_fingerprint: buildFingerprint,
599
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
600
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
601
+ waiver: sourcePackageFreshnessWaiver,
602
+ required_actions: requiredActions,
603
+ };
604
+ }
605
+
606
+ const problems = evidenceProblems(evidence, report);
607
+ if (problems.length) {
608
+ return {
609
+ status: "blocked",
610
+ code: "polish.evidence_incomplete",
611
+ reason: "Polish evidence is incomplete for the current build. Run next-campaigns-polish before QA.",
612
+ build_fingerprint: buildFingerprint,
613
+ source_package_material_fingerprint: currentSourcePackageFingerprint,
614
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
615
+ waiver: sourcePackageFreshnessWaiver,
616
+ problems,
617
+ required_actions: requiredActions,
618
+ };
619
+ }
620
+
621
+ const warnings = [];
622
+ if (!currentSourcePackageFingerprint) {
623
+ warnings.push({
624
+ code: "polish.source_package_material_fingerprint_unavailable",
625
+ message: "No current Design Source Package material fingerprint is available; Polish freshness is using legacy build-fingerprint-only behavior.",
626
+ });
627
+ }
628
+
629
+ const waivedAssemblyFreshness = Boolean(sourcePackageFreshnessWaiver);
630
+ return {
631
+ status: waivedAssemblyFreshness ? "waived" : "pass",
632
+ code: waivedAssemblyFreshness ? "polish.assembly_source_package_waived" : "polish.evidence_current",
633
+ reason: waivedAssemblyFreshness
634
+ ? `Polish evidence is current, structured, and produced by next-campaigns-polish under source freshness waiver: ${sourcePackageFreshnessWaiver.reason}`
635
+ : "Polish evidence is current, structured, and produced by next-campaigns-polish.",
636
+ build_fingerprint: buildFingerprint,
637
+ source_build_fingerprint: sourceFingerprint,
638
+ source_package_material_fingerprint: polishSourcePackageFingerprint || null,
639
+ current_source_package_material_fingerprint: currentSourcePackageFingerprint,
640
+ assembly_source_package_material_fingerprint: assemblySourcePackageFingerprint,
641
+ performed_by: producer,
642
+ waiver: sourcePackageFreshnessWaiver,
643
+ warnings,
644
+ };
645
+ }
646
+
647
+ function mergeRequiredActions(...groups) {
648
+ const actions = [];
649
+ const seen = new Set();
650
+ for (const action of groups.flat()) {
651
+ if (!isObject(action) || !nonEmptyString(action.id) || seen.has(action.id)) continue;
652
+ seen.add(action.id);
653
+ actions.push(action);
654
+ }
655
+ return actions;
656
+ }
657
+
658
+ function ownedCheckpointBlock(hiddenEagerMediaGate) {
659
+ if (isObject(hiddenEagerMediaGate)
660
+ && hiddenEagerMediaGate.id === HIDDEN_EAGER_MEDIA_SCOPE
661
+ && hiddenEagerMediaGate.status === "blocked"
662
+ && nonEmptyString(hiddenEagerMediaGate.code)
663
+ && nonEmptyString(hiddenEagerMediaGate.reason)) {
664
+ return {
665
+ code: hiddenEagerMediaGate.code,
666
+ reason: hiddenEagerMediaGate.reason,
667
+ required_actions: mergeRequiredActions(hiddenEagerMediaGate.required_actions || []),
668
+ };
669
+ }
670
+ return {
671
+ code: "polish.hidden_eager_media.capture_malformed",
672
+ reason: "Package-owned page-load evidence is missing or malformed; run polish capture before completing Polish.",
673
+ required_actions: [HIDDEN_EAGER_MEDIA_ACTIONS.capture],
674
+ };
675
+ }
676
+
677
+ // The hidden eager-media checkpoint is a package-owned category of Polish,
678
+ // but its authority depends on the current packet route plan. Callers compute
679
+ // that checkpoint from their one packet/report snapshot and inject it here;
680
+ // this evaluator never self-reads a second, potentially inconsistent snapshot.
681
+ export function evaluatePolishGate({
682
+ report,
683
+ required = false,
684
+ now = Date.now(),
685
+ hiddenEagerMediaGate = undefined,
686
+ currentOutputFingerprint = null,
687
+ } = {}) {
688
+ const base = evaluateStructuredPolishGate({ report, required, now, currentOutputFingerprint });
689
+ if (!terminalAssembly(report)) return base;
690
+ if (hiddenEagerMediaGate === undefined && !required) return base;
691
+
692
+ const ownedId = HIDDEN_EAGER_MEDIA_SCOPE;
693
+ const hiddenStatus = hiddenEagerMediaGate?.id === ownedId
694
+ ? hiddenEagerMediaGate.status
695
+ : null;
696
+ if (hiddenStatus !== "pass" && hiddenStatus !== "waived") {
697
+ const owned = ownedCheckpointBlock(hiddenEagerMediaGate);
698
+ if (base.status === "blocked") {
699
+ return {
700
+ ...base,
701
+ required_actions: mergeRequiredActions(base.required_actions || [], owned.required_actions),
702
+ owned_checkpoint_id: ownedId,
703
+ owned_checkpoint_status: "blocked",
704
+ owned_checkpoint_only: false,
705
+ };
706
+ }
707
+ return {
708
+ ...base,
709
+ status: "blocked",
710
+ code: owned.code,
711
+ reason: owned.reason,
712
+ required_actions: owned.required_actions,
713
+ owned_checkpoint_id: ownedId,
714
+ owned_checkpoint_status: "blocked",
715
+ owned_checkpoint_only: true,
716
+ };
717
+ }
718
+
719
+ if (hiddenStatus === "waived" && base.status !== "blocked") {
720
+ const hiddenOnly = base.status === "pass";
721
+ return {
722
+ ...base,
723
+ status: "waived",
724
+ code: hiddenOnly ? hiddenEagerMediaGate.code : base.code,
725
+ reason: `${base.reason} ${hiddenEagerMediaGate.reason}`,
726
+ ...(hiddenOnly && hiddenEagerMediaGate.waiver ? { waiver: hiddenEagerMediaGate.waiver } : {}),
727
+ owned_checkpoint_id: ownedId,
728
+ owned_checkpoint_status: "waived",
729
+ owned_checkpoint_only: hiddenOnly,
730
+ };
731
+ }
732
+
733
+ return {
734
+ ...base,
735
+ owned_checkpoint_id: ownedId,
736
+ owned_checkpoint_status: hiddenStatus,
737
+ owned_checkpoint_only: false,
738
+ };
739
+ }