@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,276 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ import { ADAPTER_WRAPPER_POLICIES, isWrapperPolicy } from "./adapter-decision-contract.mjs";
5
+ import { diagnoseSourceScreenshotRecord } from "./design-source-package.mjs";
6
+
7
+ export const SOURCE_HTML_MANIFEST_REL_PATH = ".campaigns-os/source-html-manifest.json";
8
+ export const SOURCE_HTML_MANIFEST_SCHEMA = "source-html-manifest/v0";
9
+
10
+ export const SOURCE_HASH_PATTERN = /^[0-9a-f]{64}$/;
11
+ const FILE_ROLES = new Set(["page", "partial", "layout", "asset", "export_log", "support"]);
12
+ // The per-page keys the Design Source Package reads as source screenshot
13
+ // proof (see design-source-package.mjs visualCandidatesFrom).
14
+ const SCREENSHOT_RECORD_KEYS = Object.freeze(["screenshot_refs", "source_screenshot_refs", "screenshots"]);
15
+
16
+ export function validateSourceHtmlManifest(manifest) {
17
+ const errors = [];
18
+ const warnings = [];
19
+ const add = (code, message) => errors.push({ code, message });
20
+ const addWarning = (code, message) => warnings.push({ code, message });
21
+
22
+ if (!isObject(manifest)) {
23
+ add("manifest.type", "Source HTML manifest must be a JSON object.");
24
+ return { ok: false, errors, warnings };
25
+ }
26
+
27
+ if (manifest.schema_version !== SOURCE_HTML_MANIFEST_SCHEMA) {
28
+ add("manifest.schema_version", `Expected schema_version "${SOURCE_HTML_MANIFEST_SCHEMA}".`);
29
+ }
30
+ if (manifest.generated_at != null && !isNonEmptyString(manifest.generated_at)) {
31
+ add("manifest.generated_at", "generated_at must be a non-empty string when present.");
32
+ }
33
+ if (manifest.generator != null && !isNonEmptyString(manifest.generator)) {
34
+ add("manifest.generator", "generator must be a non-empty string when present.");
35
+ }
36
+ if (manifest.campaign_slug != null && !isNonEmptyString(manifest.campaign_slug)) {
37
+ add("manifest.campaign_slug", "campaign_slug must be a non-empty string when present.");
38
+ }
39
+ if (manifest.root != null && !isNonEmptyString(manifest.root)) {
40
+ add("manifest.root", "root must be a non-empty string when present.");
41
+ }
42
+ // Operator channel for the document-wrapper policy: the same vocabulary the
43
+ // adapter contract records, declared by whoever hands over the source rather
44
+ // than only by the build stage. An unrecognized value is a WARNING, not an
45
+ // error: rejecting the manifest over one optional hint would throw away
46
+ // pages[], producer_provenance, and files[] and silently drop the run back
47
+ // to filesystem matching — a far larger loss than the key itself. The key is
48
+ // treated as unset instead, so the default policy applies.
49
+ if (manifest.wrapper_policy != null && !isWrapperPolicy(manifest.wrapper_policy)) {
50
+ addWarning(
51
+ "manifest.wrapper_policy",
52
+ `wrapper_policy ${JSON.stringify(manifest.wrapper_policy)} is not one of ${ADAPTER_WRAPPER_POLICIES.join(", ")}; ` +
53
+ `the key is ignored and the default "strip_document_wrappers" applies. The rest of the manifest is used as written.`,
54
+ );
55
+ }
56
+ if (manifest.producer_provenance != null) {
57
+ validateProducerProvenance(manifest.producer_provenance, add);
58
+ }
59
+ if (manifest.files != null) {
60
+ if (!Array.isArray(manifest.files)) {
61
+ add("manifest.files", "files must be an array when present.");
62
+ } else {
63
+ manifest.files.forEach((entry, index) => validateManifestFile(entry, index, add));
64
+ }
65
+ }
66
+
67
+ if (!Array.isArray(manifest.pages)) {
68
+ add("manifest.pages", "pages must be an array.");
69
+ } else {
70
+ manifest.pages.forEach((entry, index) => validateManifestPage(entry, index, add, addWarning));
71
+ }
72
+
73
+ return { ok: errors.length === 0, errors, warnings };
74
+ }
75
+
76
+ // The manifest lives at SOURCE_HTML_MANIFEST_REL_PATH under the source root by
77
+ // default. An explicit `manifestPath` (the `--design-manifest` flag) reads a
78
+ // manifest from anywhere else instead — a source root nobody can write to
79
+ // still gets its screenshot proof and skip declarations from a file the
80
+ // operator owns. Wherever the manifest lives, its pages[].path and
81
+ // files[].path stay relative to the source root, never to the manifest.
82
+ export function readSourceHtmlManifestFile(sourceRoot, { manifestPath = null } = {}) {
83
+ const explicit = typeof manifestPath === "string" && manifestPath.trim() ? resolve(manifestPath) : null;
84
+ const resolvedPath = explicit || resolve(sourceRoot, SOURCE_HTML_MANIFEST_REL_PATH);
85
+ if (!existsSync(resolvedPath) || !statSync(resolvedPath).isFile()) {
86
+ return {
87
+ manifest: null,
88
+ path: explicit,
89
+ warning: explicit ? `Design manifest at ${explicit} does not exist or is not a file. Falling back to filesystem matching.` : null,
90
+ warnings: [],
91
+ validation: null,
92
+ explicit: Boolean(explicit),
93
+ };
94
+ }
95
+ return { ...readManifestAt(resolvedPath), explicit: Boolean(explicit) };
96
+ }
97
+
98
+ function readManifestAt(manifestPath) {
99
+ let manifest;
100
+ try {
101
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
102
+ } catch (error) {
103
+ return {
104
+ manifest: null,
105
+ path: manifestPath,
106
+ warning: `Could not parse source-html manifest at ${manifestPath}: ${error.message}. Falling back to filesystem matching.`,
107
+ warnings: [],
108
+ validation: null,
109
+ };
110
+ }
111
+
112
+ const validation = validateSourceHtmlManifest(manifest);
113
+ if (!validation.ok) {
114
+ const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
115
+ return {
116
+ manifest: null,
117
+ path: manifestPath,
118
+ warning: `Source-html manifest at ${manifestPath} failed ${SOURCE_HTML_MANIFEST_SCHEMA} validation: ${detail}. Falling back to filesystem matching.`,
119
+ warnings: [],
120
+ validation,
121
+ };
122
+ }
123
+ // Non-fatal findings: the manifest is used as written and each one names the
124
+ // key, the value, and what happens instead.
125
+ const warnings = (validation.warnings || []).map(
126
+ (entry) => `Source-html manifest at ${manifestPath}: [${entry.code}] ${entry.message}`,
127
+ );
128
+ return { manifest, path: manifestPath, warning: null, warnings, validation };
129
+ }
130
+
131
+ function validateManifestPage(entry, index, add, addWarning = () => {}) {
132
+ const location = `manifest.pages[${index}]`;
133
+ if (!isObject(entry)) {
134
+ add(location, `${location} must be an object.`);
135
+ return;
136
+ }
137
+ if (!isNonEmptyString(entry.page_id)) {
138
+ add(`${location}.page_id`, `${location}.page_id is required and must be a non-empty string.`);
139
+ }
140
+ // A page entry binds either a source HTML file (path) or a declared
141
+ // out-of-scope skip (skip_reason) — exactly one. Skip-only entries are how
142
+ // a partial-source build declares template-derived pages per page.
143
+ const hasPath = isNonEmptyString(entry.path);
144
+ const hasSkipReason = isNonEmptyString(entry.skip_reason);
145
+ if (hasPath && hasSkipReason) {
146
+ add(`${location}.skip_reason`, `${location} declares both path and skip_reason; a page is either bound to a source file or declared out of source scope, not both.`);
147
+ } else if (!hasPath && !hasSkipReason) {
148
+ add(`${location}.path`, `${location} requires path (source HTML file) or skip_reason (declared out-of-scope page), and each must be a non-empty string.`);
149
+ }
150
+ for (const field of ["page_type", "page_url"]) {
151
+ if (entry[field] != null && !isNonEmptyString(entry[field])) {
152
+ add(`${location}.${field}`, `${location}.${field} must be a non-empty string when present.`);
153
+ }
154
+ }
155
+ if (entry.source_hash != null) {
156
+ if (!isNonEmptyString(entry.source_hash) || !SOURCE_HASH_PATTERN.test(entry.source_hash)) {
157
+ add(`${location}.source_hash`, `${location}.source_hash must be a 64-character lowercase sha256 hex string when present.`);
158
+ }
159
+ }
160
+ warnOnUnusableScreenshotRecords(entry, location, addWarning);
161
+ }
162
+
163
+ // Screenshot records are optional proof, so a malformed one is not a reason to
164
+ // reject the manifest and fall the run back to filesystem matching. It is,
165
+ // however, a reason to say something: without this the Design Source Package
166
+ // drops the record and the operator sees a page missing desktop/mobile proof
167
+ // with no hint that a record for it was authored. Same channel as the
168
+ // wrapper_policy warning above, one warning per record naming the page, the
169
+ // record index, the field, and what happens instead. The accept/reject test itself lives with the package builder, so
170
+ // the warning cannot drift from the behaviour it describes.
171
+ function warnOnUnusableScreenshotRecords(entry, location, addWarning) {
172
+ const pageId = isNonEmptyString(entry.page_id) ? entry.page_id : null;
173
+ for (const key of SCREENSHOT_RECORD_KEYS) {
174
+ const records = entry[key];
175
+ if (!Array.isArray(records)) continue;
176
+ records.forEach((record, index) => {
177
+ if (typeof record === "string") return;
178
+ const rejection = diagnoseSourceScreenshotRecord(record);
179
+ if (!rejection) return;
180
+ const recordLocation = `${location}.${key}[${index}]`;
181
+ addWarning(
182
+ `${recordLocation}.${rejection.field}`,
183
+ `${recordLocation}${pageId ? ` (page_id ${JSON.stringify(pageId)})` : ""} is ignored as source screenshot proof: ` +
184
+ `${rejection.detail}. ${rejection.fix}, or the page counts as having no source proof for that viewport. ` +
185
+ `The rest of the manifest is used as written.`,
186
+ );
187
+ });
188
+ }
189
+ }
190
+
191
+ function validateProducerProvenance(provenance, add) {
192
+ if (!isObject(provenance)) {
193
+ add("manifest.producer_provenance", "producer_provenance must be an object when present.");
194
+ return;
195
+ }
196
+
197
+ if (provenance.source_type != null && !isNonEmptyString(provenance.source_type)) {
198
+ add("manifest.producer_provenance.source_type", "producer_provenance.source_type must be a non-empty string when present.");
199
+ }
200
+ if (provenance.screenshot_fallback_used != null && typeof provenance.screenshot_fallback_used !== "boolean") {
201
+ add("manifest.producer_provenance.screenshot_fallback_used", "producer_provenance.screenshot_fallback_used must be a boolean when present.");
202
+ }
203
+ for (const field of ["generator_repo", "generator_version", "export_log", "figma_file_key", "material_fingerprint"]) {
204
+ if (provenance[field] != null && !isNonEmptyString(provenance[field])) {
205
+ add(`manifest.producer_provenance.${field}`, `producer_provenance.${field} must be a non-empty string when present.`);
206
+ }
207
+ }
208
+ if (provenance.material_fingerprint != null && !SOURCE_HASH_PATTERN.test(provenance.material_fingerprint)) {
209
+ add("manifest.producer_provenance.material_fingerprint", "producer_provenance.material_fingerprint must be a 64-character lowercase sha256 hex string when present.");
210
+ }
211
+ if (provenance.semantic_section_count != null && (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0)) {
212
+ add("manifest.producer_provenance.semantic_section_count", "producer_provenance.semantic_section_count must be a positive integer when present.");
213
+ }
214
+ if (provenance.breakpoint_image_count != null && (!Number.isInteger(provenance.breakpoint_image_count) || provenance.breakpoint_image_count < 0)) {
215
+ add("manifest.producer_provenance.breakpoint_image_count", "producer_provenance.breakpoint_image_count must be a non-negative integer when present.");
216
+ }
217
+ if (provenance.figma_file_keys != null && !Array.isArray(provenance.figma_file_keys)) {
218
+ add("manifest.producer_provenance.figma_file_keys", "producer_provenance.figma_file_keys must be an array when present.");
219
+ }
220
+ if (provenance.section_exports != null) {
221
+ if (!Array.isArray(provenance.section_exports)) {
222
+ add("manifest.producer_provenance.section_exports", "producer_provenance.section_exports must be an array when present.");
223
+ } else {
224
+ provenance.section_exports.forEach((entry, index) => validateSectionExport(entry, index, add));
225
+ }
226
+ }
227
+ }
228
+
229
+ function validateSectionExport(entry, index, add) {
230
+ const location = `manifest.producer_provenance.section_exports[${index}]`;
231
+ if (!isObject(entry)) {
232
+ add(location, `${location} must be an object.`);
233
+ return;
234
+ }
235
+ for (const field of ["section", "type"]) {
236
+ if (!isNonEmptyString(entry[field])) {
237
+ add(`${location}.${field}`, `${location}.${field} is required and must be a non-empty string.`);
238
+ }
239
+ }
240
+ if (entry.node_ids != null && !isObject(entry.node_ids)) {
241
+ add(`${location}.node_ids`, `${location}.node_ids must be an object when present.`);
242
+ }
243
+ for (const field of ["images", "warnings"]) {
244
+ if (entry[field] != null && !Array.isArray(entry[field])) {
245
+ add(`${location}.${field}`, `${location}.${field} must be an array when present.`);
246
+ }
247
+ }
248
+ }
249
+
250
+ function validateManifestFile(entry, index, add) {
251
+ const location = `manifest.files[${index}]`;
252
+ if (!isObject(entry)) {
253
+ add(location, `${location} must be an object.`);
254
+ return;
255
+ }
256
+ if (!isNonEmptyString(entry.path)) {
257
+ add(`${location}.path`, `${location}.path is required and must be a non-empty string.`);
258
+ }
259
+ if (!isNonEmptyString(entry.role) || !FILE_ROLES.has(entry.role)) {
260
+ add(`${location}.role`, `${location}.role is required and must be one of ${[...FILE_ROLES].join(", ")}.`);
261
+ }
262
+ if (!isNonEmptyString(entry.sha256) || !SOURCE_HASH_PATTERN.test(entry.sha256)) {
263
+ add(`${location}.sha256`, `${location}.sha256 is required and must be a 64-character lowercase sha256 hex string.`);
264
+ }
265
+ if (entry.bytes != null && (!Number.isInteger(entry.bytes) || entry.bytes < 0)) {
266
+ add(`${location}.bytes`, `${location}.bytes must be a non-negative integer when present.`);
267
+ }
268
+ }
269
+
270
+ function isObject(value) {
271
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
272
+ }
273
+
274
+ function isNonEmptyString(value) {
275
+ return typeof value === "string" && value.trim().length > 0;
276
+ }
@@ -0,0 +1,284 @@
1
+ // Source preparation check (#262): deterministic classification of the common
2
+ // unprepared-source failures the docs name as pre-build preparation steps —
3
+ // unstripped document wrappers, leftover/broken YAML frontmatter, and internal
4
+ // links still pointing at source files instead of CampaignSpec routes. The
5
+ // scope is the certified template families' page-kit ingestion expectations
6
+ // (docs/quickstart.md "Prepare Raw HTML Source", docs/source-adapters.md);
7
+ // this is NOT a general HTML linter. Asset-path rooting stays owned by the
8
+ // source asset crawl (source_asset.* codes) so the two checks never disagree
9
+ // about the same reference.
10
+ import { existsSync, readFileSync, statSync } from "node:fs";
11
+ import { dirname, resolve } from "node:path";
12
+ import { collectDocumentWrapperNames } from "./adapter-decision-contract.mjs";
13
+
14
+ export const SOURCE_PREP_DOCUMENT_WRAPPER = "source_html.prep.document_wrapper";
15
+ export const SOURCE_PREP_FRONTMATTER_RESIDUE = "source_html.prep.frontmatter_residue";
16
+ export const SOURCE_PREP_INTERNAL_LINK_UNROOTED = "source_html.prep.internal_link_unrooted";
17
+
18
+ export const SOURCE_PREP_CODES = Object.freeze([
19
+ SOURCE_PREP_DOCUMENT_WRAPPER,
20
+ SOURCE_PREP_FRONTMATTER_RESIDUE,
21
+ SOURCE_PREP_INTERNAL_LINK_UNROOTED,
22
+ ]);
23
+
24
+ // Every code maps to the doc section that names the preparation step it
25
+ // enforces. Keep these pointers in sync with the "Source preparation check"
26
+ // section in docs/source-adapters.md.
27
+ export const SOURCE_PREP_DOC_POINTERS = Object.freeze({
28
+ [SOURCE_PREP_DOCUMENT_WRAPPER]:
29
+ 'docs/quickstart.md "Prepare Raw HTML Source" and docs/source-adapters.md "Source preparation check"',
30
+ [SOURCE_PREP_FRONTMATTER_RESIDUE]:
31
+ 'docs/quickstart.md "Prepare Raw HTML Source" and docs/source-adapters.md "Source preparation check"',
32
+ [SOURCE_PREP_INTERNAL_LINK_UNROOTED]:
33
+ 'docs/quickstart.md "Prepare Raw HTML Source" and docs/source-adapters.md "Source preparation check"',
34
+ });
35
+
36
+ // Page Kit frontmatter vocabulary used to recognize a leftover frontmatter
37
+ // block embedded below content. A bare `---` pair with none of these keys is
38
+ // left alone — that is content, not frontmatter, and classifying it would be
39
+ // the general-HTML-linting scope this check refuses.
40
+ const FRONTMATTER_KEY_PATTERN = /^(?:page_type|layout|permalink|title|description|next_url|decline_url|styles|scripts|meta)\s*:/;
41
+
42
+ const FENCE_LINE = /^---\s*$/;
43
+ const ANCHOR_HREF_PATTERN = /<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
44
+ const SKIP_PROTOCOL_PATTERN = /^(?:about|blob|data|javascript|mailto|tel):/i;
45
+
46
+ function isNonEmptyString(value) {
47
+ return typeof value === "string" && value.trim().length > 0;
48
+ }
49
+
50
+ function toPosixPath(value) {
51
+ return String(value || "").replaceAll("\\", "/");
52
+ }
53
+
54
+ function safeIsFile(path) {
55
+ try {
56
+ return existsSync(path) && statSync(path).isFile();
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Splits a page's leading YAML frontmatter (if any) from its body without
64
+ * parsing YAML. Returns fence state so the caller can classify a fence that
65
+ * never closes.
66
+ */
67
+ function scanLeadingFrontmatter(lines) {
68
+ let index = 0;
69
+ while (index < lines.length && lines[index].trim() === "") index += 1;
70
+ if (index >= lines.length || !FENCE_LINE.test(lines[index])) {
71
+ return { present: false, unterminated: false, bodyStart: 0 };
72
+ }
73
+ for (let close = index + 1; close < lines.length; close += 1) {
74
+ if (FENCE_LINE.test(lines[close])) {
75
+ return { present: true, unterminated: false, bodyStart: close + 1 };
76
+ }
77
+ }
78
+ return { present: true, unterminated: true, bodyStart: lines.length };
79
+ }
80
+
81
+ function findEmbeddedFrontmatter(lines, bodyStart) {
82
+ const hits = [];
83
+ let open = -1;
84
+ for (let index = bodyStart; index < lines.length; index += 1) {
85
+ if (!FENCE_LINE.test(lines[index])) continue;
86
+ if (open === -1) {
87
+ open = index;
88
+ continue;
89
+ }
90
+ const block = lines.slice(open + 1, index);
91
+ if (block.some((line) => FRONTMATTER_KEY_PATTERN.test(line.trim()))) {
92
+ hits.push({ line: open + 1 });
93
+ open = -1;
94
+ continue;
95
+ }
96
+ // A fence pair without frontmatter keys is content; the closing fence may
97
+ // still open a real leftover block that follows it.
98
+ open = index;
99
+ }
100
+ // An opening fence below content whose frontmatter-key-shaped lines run to
101
+ // EOF without a closing fence is residue too — page-kit would render the
102
+ // fence and keys literally. A trailing keyless fence stays content.
103
+ let unterminated = null;
104
+ if (open !== -1 && lines.slice(open + 1).some((line) => FRONTMATTER_KEY_PATTERN.test(line.trim()))) {
105
+ unterminated = { line: open + 1 };
106
+ }
107
+ return { hits, unterminated };
108
+ }
109
+
110
+ function cleanHref(raw) {
111
+ const value = String(raw || "").trim();
112
+ if (!value || value.startsWith("#")) return null;
113
+ if (value.startsWith("{{") || value.startsWith("{%")) return null;
114
+ if (value.startsWith("//")) return null;
115
+ if (SKIP_PROTOCOL_PATTERN.test(value)) return null;
116
+ try {
117
+ const url = new URL(value);
118
+ if (url.protocol === "http:" || url.protocol === "https:") return null;
119
+ } catch {
120
+ // Relative and root-relative hrefs are the expected input.
121
+ }
122
+ return value.split("#")[0].split("?")[0].trim();
123
+ }
124
+
125
+ function collectSourceFileLinks({ content, sourceRoot, pagePath, mappedPaths }) {
126
+ const hits = [];
127
+ const seen = new Set();
128
+ const pageDir = dirname(resolve(sourceRoot, pagePath));
129
+ for (const match of String(content || "").matchAll(ANCHOR_HREF_PATTERN)) {
130
+ const cleaned = cleanHref(match[1] ?? match[2] ?? match[3]);
131
+ if (!cleaned || !/\.html?$/i.test(cleaned)) continue;
132
+ if (seen.has(cleaned)) continue;
133
+ const rootRelative = cleaned.startsWith("/");
134
+ const normalized = cleaned.replace(/^\/+/, "");
135
+ const candidatePaths = rootRelative
136
+ ? [resolve(sourceRoot, normalized)]
137
+ : [resolve(pageDir, cleaned), resolve(sourceRoot, normalized)];
138
+ const resolvesToSourceFile = candidatePaths.some((candidate) => safeIsFile(candidate));
139
+ const targetsMappedPage = mappedPaths.has(toPosixPath(normalized));
140
+ if (!resolvesToSourceFile && !targetsMappedPage) continue;
141
+ seen.add(cleaned);
142
+ hits.push(cleaned);
143
+ }
144
+ return hits;
145
+ }
146
+
147
+ function inspectPageContent({ content, sourceRoot, pagePath, mappedPaths }) {
148
+ const findings = [];
149
+ const wrappers = collectDocumentWrapperNames(content);
150
+ if (wrappers.length) {
151
+ findings.push({ code: SOURCE_PREP_DOCUMENT_WRAPPER, wrappers });
152
+ }
153
+
154
+ const lines = String(content || "").split(/\r?\n/);
155
+ const leading = scanLeadingFrontmatter(lines);
156
+ const embedded = findEmbeddedFrontmatter(lines, leading.bodyStart);
157
+ if (leading.unterminated) {
158
+ findings.push({ code: SOURCE_PREP_FRONTMATTER_RESIDUE, variant: "unterminated_leading_fence" });
159
+ }
160
+ if (embedded.hits.length) {
161
+ findings.push({
162
+ code: SOURCE_PREP_FRONTMATTER_RESIDUE,
163
+ variant: "embedded_block",
164
+ lines: embedded.hits.map((hit) => hit.line),
165
+ });
166
+ }
167
+ if (embedded.unterminated) {
168
+ findings.push({
169
+ code: SOURCE_PREP_FRONTMATTER_RESIDUE,
170
+ variant: "unterminated_embedded_block",
171
+ lines: [embedded.unterminated.line],
172
+ });
173
+ }
174
+
175
+ const sourceFileLinks = collectSourceFileLinks({ content, sourceRoot, pagePath, mappedPaths });
176
+ if (sourceFileLinks.length) {
177
+ findings.push({ code: SOURCE_PREP_INTERNAL_LINK_UNROOTED, hrefs: sourceFileLinks });
178
+ }
179
+ return findings;
180
+ }
181
+
182
+ function severityForCode(code, { wrapperPolicy }) {
183
+ if (code === SOURCE_PREP_DOCUMENT_WRAPPER) {
184
+ // preserve_document_wrappers is a recorded adapter decision, not silence —
185
+ // the finding stays visible but stops blocking, mirroring how the
186
+ // certification gate downgrades under a recorded waiver.
187
+ return wrapperPolicy === "preserve_document_wrappers" ? "warning" : "error";
188
+ }
189
+ if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) return "error";
190
+ // Internal-link rewrites are sanctioned build-stage work recorded under
191
+ // cta_rewrite_policy; the adapter gates own the completed-assembly block.
192
+ return "warning";
193
+ }
194
+
195
+ function describeFinding(code, pages, { wrapperPolicy }) {
196
+ const docs = SOURCE_PREP_DOC_POINTERS[code];
197
+ const sample = pages.slice(0, 4);
198
+ const more = pages.length > 4 ? `; plus ${pages.length - 4} more page(s)` : "";
199
+ if (code === SOURCE_PREP_DOCUMENT_WRAPPER) {
200
+ const listed = sample.map((page) => `${page.path} (${page.wrappers.join(", ")})`).join("; ");
201
+ const policyNote = wrapperPolicy === "preserve_document_wrappers"
202
+ ? " The adapter contract records wrapper_policy \"preserve_document_wrappers\", so this is reported without blocking."
203
+ : "";
204
+ return `Mapped source HTML is a full browser document, not page-kit-ready source: ${listed}${more}. Strip <!doctype>, <html>, <head>, and <body> so the campaign layout can wrap the page, or record wrapper_policy "preserve_document_wrappers" as an explicit adapter decision.${policyNote} See ${docs}.`;
205
+ }
206
+ if (code === SOURCE_PREP_FRONTMATTER_RESIDUE) {
207
+ const listed = sample.map((page) => {
208
+ const variants = page.variants.map((variant) => {
209
+ if (variant.variant === "unterminated_leading_fence") return "leading --- fence never closes";
210
+ if (variant.variant === "unterminated_embedded_block") {
211
+ return `frontmatter block opened below content at line ${variant.lines.join(", ")} never closes`;
212
+ }
213
+ return `frontmatter block embedded below content at line ${variant.lines.join(", ")}`;
214
+ });
215
+ return `${page.path} (${variants.join("; ")})`;
216
+ }).join("; ");
217
+ return `Mapped source HTML carries leftover or broken YAML frontmatter that page-kit would render literally or misparse: ${listed}${more}. Keep exactly one closed frontmatter block at the very top of the file (or none, when the packet's page_kit.frontmatter projection supplies it). See ${docs}.`;
218
+ }
219
+ const listed = sample.map((page) => `${page.path} -> ${page.hrefs.slice(0, 3).join(", ")}`).join("; ");
220
+ return `Mapped source HTML still links to source files instead of CampaignSpec routes: ${listed}${more}. Replace internal links and CTA destinations with CampaignSpec-derived routes, usually via campaign_link; source filenames like checkout.html are not built campaign URLs. See ${docs}.`;
221
+ }
222
+
223
+ /**
224
+ * Evaluates the page-kit source-preparation expectations for every mapped
225
+ * source page. Deterministic: same files in, same findings out. Unreadable or
226
+ * missing page files are skipped here — source_html.pages.path owns those.
227
+ *
228
+ * @returns {{ checked_page_count: number, findings: Array<{code, severity, message, docs, pages}> }}
229
+ */
230
+ export function evaluateSourcePreparation({ sourceRoot, pages = [], wrapperPolicy = null }) {
231
+ const mappedPaths = new Set(
232
+ pages
233
+ .map((page) => (isNonEmptyString(page?.path) ? toPosixPath(page.path) : null))
234
+ .filter(Boolean)
235
+ );
236
+ const byCode = new Map();
237
+ let checked = 0;
238
+
239
+ for (const page of pages) {
240
+ if (!isNonEmptyString(page?.path)) continue;
241
+ const fullPath = resolve(sourceRoot, page.path);
242
+ if (!safeIsFile(fullPath)) continue;
243
+ let content;
244
+ try {
245
+ content = readFileSync(fullPath, "utf8");
246
+ } catch {
247
+ continue;
248
+ }
249
+ checked += 1;
250
+ const pageFindings = inspectPageContent({ content, sourceRoot, pagePath: page.path, mappedPaths });
251
+ for (const finding of pageFindings) {
252
+ if (!byCode.has(finding.code)) byCode.set(finding.code, new Map());
253
+ const pagesForCode = byCode.get(finding.code);
254
+ if (!pagesForCode.has(page.path)) {
255
+ pagesForCode.set(page.path, { page_id: page.page_id || null, path: page.path, wrappers: [], hrefs: [], variants: [] });
256
+ }
257
+ const entry = pagesForCode.get(page.path);
258
+ if (finding.wrappers) entry.wrappers.push(...finding.wrappers);
259
+ if (finding.hrefs) entry.hrefs.push(...finding.hrefs);
260
+ if (finding.variant) entry.variants.push({ variant: finding.variant, lines: finding.lines || [] });
261
+ }
262
+ }
263
+
264
+ const findings = [...byCode.entries()]
265
+ .map(([code, pagesForCode]) => {
266
+ const pageList = [...pagesForCode.values()];
267
+ return {
268
+ code,
269
+ severity: severityForCode(code, { wrapperPolicy }),
270
+ message: describeFinding(code, pageList, { wrapperPolicy }),
271
+ docs: SOURCE_PREP_DOC_POINTERS[code],
272
+ pages: pageList.map((page) => ({
273
+ page_id: page.page_id,
274
+ path: page.path,
275
+ ...(page.wrappers.length ? { wrappers: page.wrappers } : {}),
276
+ ...(page.hrefs.length ? { hrefs: page.hrefs } : {}),
277
+ ...(page.variants.length ? { variants: page.variants } : {}),
278
+ })),
279
+ };
280
+ })
281
+ .sort((a, b) => SOURCE_PREP_CODES.indexOf(a.code) - SOURCE_PREP_CODES.indexOf(b.code));
282
+
283
+ return { checked_page_count: checked, findings };
284
+ }