@nextcommerce/campaigns-os 1.43.1 → 1.43.2

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 (56) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +451 -0
  3. package/README.md +2 -2
  4. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  5. package/contracts/effects.v1.json +8 -8
  6. package/contracts/release-ledger.json +672 -0
  7. package/contracts/supported-surface.json +2 -2
  8. package/docs/build-packet.md +64 -2
  9. package/docs/campaigns-os-build-flow.md +1 -0
  10. package/docs/design-source-package.md +89 -15
  11. package/docs/effects.md +16 -4
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/progress-snapshots.md +10 -6
  15. package/docs/qa-and-test-orders.md +131 -7
  16. package/docs/release-ledger-authoring-guide.md +6 -4
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/skills-revision.md +10 -10
  19. package/package.json +1 -1
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +4 -4
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/build-brief.mjs +6 -4
  31. package/src/built-script-syntax.mjs +480 -0
  32. package/src/campaigns-api-key.mjs +99 -0
  33. package/src/cli-helpers.mjs +118 -0
  34. package/src/cli.mjs +1211 -7495
  35. package/src/design-source-package.mjs +1 -1
  36. package/src/design-source-publication.mjs +898 -0
  37. package/src/diagnostic.mjs +2 -1
  38. package/src/directory-lock.mjs +270 -0
  39. package/src/doctor/checks.mjs +4415 -0
  40. package/src/doctor/inspect.mjs +636 -0
  41. package/src/doctor/next-step.mjs +731 -0
  42. package/src/install-invocation.mjs +29 -0
  43. package/src/invocation.mjs +179 -0
  44. package/src/private-template-source.mjs +1 -1
  45. package/src/progress-node.mjs +6 -35
  46. package/src/proof-policy.mjs +1 -1
  47. package/src/qa-analytics-correctness.mjs +3 -0
  48. package/src/qa-binding-evidence.mjs +76 -11
  49. package/src/qa-browser.mjs +778 -77
  50. package/src/qa-build-scope.mjs +47 -0
  51. package/src/qa-node.mjs +218 -13
  52. package/src/source-html-intake.mjs +1 -1
  53. package/src/source-html-manifest.mjs +9 -2
  54. package/src/stage-ledger.mjs +28 -0
  55. package/src/target-lock.mjs +54 -0
  56. package/src/template-brand-contract.mjs +17 -1
@@ -0,0 +1,4415 @@
1
+ // Doctor checks: the check registries, validatePacket and the validators they run.
2
+ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
+ import { withHtmlScanSnapshot, readHtmlScanText } from "../html-scan.mjs";
4
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
6
+ import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
7
+ import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthsDisagree } from "../proof-policy.mjs";
8
+ import { QA_GATE_PLACEHOLDER_TEXT_RESIDUE, qaGatePassedForCurrentBuild } from "../stage-ledger.mjs";
9
+ import {
10
+ validateAdapterDecisionGates,
11
+ validateAdapterDecisionShape,
12
+ validateAdapterSourceFiles,
13
+ } from "../adapter-decision-contract.mjs";
14
+ import { createDoctorCheckRegistry, runDoctorCheckRegistry } from "../doctor-check-registry.mjs";
15
+ import { evaluateSourcePreparation } from "../source-prep.mjs";
16
+ import { isLoopbackHostname } from "../remit.mjs";
17
+ import { publicRouteForPage } from "../source-html-intake.mjs";
18
+ import {
19
+ readSourceHtmlManifestFile,
20
+ SOURCE_HASH_PATTERN,
21
+ SOURCE_HTML_MANIFEST_SCHEMA,
22
+ } from "../source-html-manifest.mjs";
23
+ import { validateAssemblyReportThemeBlock, validateThemeContextBlock } from "../brand-theme.mjs";
24
+ import { singleLineDetail, singleLineField } from "../text-safety.mjs";
25
+ import {
26
+ campaignRouteRoot,
27
+ isAbsoluteHttpUrl,
28
+ normalizePageKitRoute,
29
+ normalizePublicRouteSlug,
30
+ packetRouteRoot,
31
+ runtimeRelativeRouteForSpecValue,
32
+ stripPublicRoutePrefix,
33
+ } from "../route-identity.mjs";
34
+ import { evaluatePageKitBuildSummary } from "../page-kit-build-summary.mjs";
35
+ import {
36
+ LOCAL_PROOF_BUILD_COMMAND,
37
+ LOCAL_PROOF_BUILD_ENVIRONMENT,
38
+ LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD,
39
+ LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
40
+ LOCAL_PROOF_NEVER_EDIT_RULE,
41
+ LOCAL_PROOF_PARITY_COMMAND,
42
+ LOCAL_PROOF_PARITY_FIELD,
43
+ LOCAL_PROOF_PARITY_SCOPE,
44
+ recordedBuildEnvironment,
45
+ recordedProductionParity,
46
+ } from "../local-proof.mjs";
47
+ import {
48
+ contractHasPaletteResidueChecks,
49
+ demoAssetConfig,
50
+ paymentMethodMarkupMatches,
51
+ paymentMethodStaticScanGaps,
52
+ withoutHiddenPaymentLogos,
53
+ placeholderTextResidueConfig,
54
+ placeholderTextResidueMatches,
55
+ } from "../template-brand-contract.mjs";
56
+ import {
57
+ scanBuiltOutputContentResidue,
58
+ loadBriefPayload,
59
+ briefUrgencyVerified,
60
+ collectRenderedHtmlFiles,
61
+ evaluateProofAssets,
62
+ attestationBlockers,
63
+ visibleText,
64
+ BRIEF_PAYLOAD_REL_PATH,
65
+ } from "../content-residue.mjs";
66
+ import {
67
+ resolveCommerceCatalog,
68
+ resolvePacketCommerceCatalogPath,
69
+ resolveTemplateBrandContract,
70
+ } from "../private-template-source.mjs";
71
+ import { assessTemplateFreshness, defaultSdkSupportPolicy, renderTemplateFreshness } from "../template-freshness.mjs";
72
+ import { computeBuildFingerprint, resolveBuiltSiteScope } from "../built-site-scope.mjs";
73
+ import {
74
+ UPSELL_SELECTOR_SCOPE,
75
+ evaluateUpsellSelectorScope,
76
+ isPostPurchasePageType,
77
+ } from "../upsell-selector-scope.mjs";
78
+ import { CAMPAIGN_IDENTITY, evaluateCampaignIdentity, externalScriptSources } from "../campaign-identity.mjs";
79
+ import { SDK_MARKUP, evaluateSdkMarkup } from "../sdk-markup.mjs";
80
+ import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSyntax } from "../built-script-syntax.mjs";
81
+ import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
82
+ import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
83
+ import {
84
+ assemblySourcePackageFingerprintMissing,
85
+ assessAssemblySourcePackageFreshnessWaivers,
86
+ currentBuildFingerprint,
87
+ } from "../polish-gate.mjs";
88
+ import { loadPageKitCampaignEntry, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
89
+ import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE } from "../page-kit-store-profile.mjs";
90
+ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
91
+ // ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
92
+ // campaign authoring UI (e.g. a Map Builder bundle) import the same rules, so a
93
+ // spec check is authored once and reaches internal teams and agencies alike.
94
+ // Authored as pure TypeScript with no heavy deps; compiled to plain ESM by
95
+ // `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
96
+ // node engine in package.json without type-stripping. build runs on `prepare`,
97
+ // so a fresh install (including the git-ref consumer) always has dist.
98
+ import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
99
+ import { cmd, asInvocation } from "../install-invocation.mjs";
100
+ import {
101
+ isObject,
102
+ isNonEmptyString,
103
+ optionalString,
104
+ firstNonEmptyString,
105
+ readJson,
106
+ sha256File,
107
+ relFromDir,
108
+ resolveFromFile,
109
+ extractFrontmatterValue,
110
+ addIssue,
111
+ } from "../cli-helpers.mjs";
112
+ import { resolveCampaignsApiKeySource, describeCampaignKeyRejection } from "../campaigns-api-key.mjs";
113
+
114
+ const PACKET_SCHEMA = "campaign-runtime-build-packet/v0";
115
+ const CONTEXT_SCHEMA = "campaign-runtime-build-context/v0";
116
+ const REPORT_SCHEMA = "campaign-runtime-assembly-report/v0";
117
+ const PROOF_POLICY_REQUIRED_FIELDS = Object.freeze([
118
+ "browser_qa_required",
119
+ "typed_card_depth",
120
+ "localhost_development_domain_allowed",
121
+ "non_localhost_origin_allowlist_required",
122
+ "order_path_depth",
123
+ "operator_approval_state",
124
+ ]);
125
+
126
+ // `--proxy-base` overrides DEFAULT_PROXY_BASE (src/spec-fetch.mjs) for
127
+ // staging environments or a local backend. The same flag aims the
128
+ // credential-bearing rails (remit, verdict publish, `telemetry list`), and
129
+ // those require https unless the host is loopback — see assertSecureProxyBase
130
+ // in src/remit.mjs.
131
+
132
+
133
+ const KNOWN_TEMPLATE_FAMILIES = new Set([
134
+ "undecided",
135
+ "apollo",
136
+ "apollo-mv-single-step",
137
+ "olympus",
138
+ "demeter",
139
+ "olympus-mv-single-step",
140
+ "olympus-mv-two-step",
141
+ "shop-single-step",
142
+ "shop-three-step",
143
+ "custom",
144
+ ]);
145
+
146
+ // Certified template families: present in the commerce surface catalog AND
147
+ // carrying a template brand contract. The OS automates certified families
148
+ // only — "NEXT provides the rails": deterministic assembly, residue QA, and
149
+ // pricing contracts all assume a certified family. "custom" (or any family
150
+ // outside the catalog) is an explicit operator decision recorded as a
151
+ // waiver, never a default road the agent can wander onto.
152
+ // Recomputed per call (a handful of small JSON reads) so long-lived
153
+ // processes never serve a stale certified set after contract edits.
154
+ function certifiedTemplateFamilies(catalog = resolveCommerceCatalog()) {
155
+ const certified = Object.keys(catalog.families || {}).filter((family) => {
156
+ try {
157
+ return resolveTemplateBrandContract(family) !== null;
158
+ } catch {
159
+ return false;
160
+ }
161
+ });
162
+ return new Set(certified);
163
+ }
164
+
165
+ function isCertifiedTemplateFamily(family, catalog) {
166
+ return certifiedTemplateFamilies(catalog).has(String(family || ""));
167
+ }
168
+
169
+ function isKnownTemplateFamily(family) {
170
+ const value = String(family || "");
171
+ return KNOWN_TEMPLATE_FAMILIES.has(value) || certifiedTemplateFamilies().has(value);
172
+ }
173
+
174
+ function isSynthesizedBuiltSitePacket(packet) {
175
+ return packet?._synthesized?.from === "built_site";
176
+ }
177
+
178
+ const KNOWN_DEPLOY_TARGETS = new Set([
179
+ "netlify",
180
+ "cloudflare-pages",
181
+ "vercel",
182
+ "shopify-proxy",
183
+ "agency-ci",
184
+ "local-serve",
185
+ "unknown",
186
+ ]);
187
+ // The localhost QA path: the built _site/ is served locally instead of
188
+ // deployed. Localhost on any port is a Development domain, so doctor and
189
+ // `next` read a localhost deploy URL under this target as the intended state,
190
+ // not as a deploy that has not happened.
191
+ const LOCAL_SERVE_DEPLOY_TARGET = "local-serve";
192
+
193
+ const REQUIRED_STORE_PROFILE_FIELDS = [
194
+ "store_url",
195
+ ];
196
+
197
+ // Packet fields removed in supported surface 1.28.0. They were booleans no
198
+ // command read since the permission gate on test orders was retired; doctor
199
+ // warns when a packet still carries one.
200
+ const REMOVED_QA_POLICY_FIELDS = ["test_orders_allowed", "sandbox_test_card_confirmed"];
201
+
202
+ const US_MARKET_COPY_PATTERNS = [
203
+ { label: "USPS", regex: /\bUSPS\b/i },
204
+ { label: "ships from the USA", regex: /\bships?\s+from\s+(?:the\s+)?(?:USA|U\.S\.A\.|US|U\.S\.|United States)\b/i },
205
+ { label: "US warehouse", regex: /\b(?:US|U\.S\.|USA|United States)\s+warehouse\b/i },
206
+ { label: "contiguous US", regex: /\bcontiguous\s+(?:US|U\.S\.|USA|United States)\b/i },
207
+ { label: "US-only shipping", regex: /\b(?:US|U\.S\.|USA|United States)(?:-|\s+)?only\b/i },
208
+ { label: "All US orders ship free", regex: /\bAll\s+(?:US|U\.S\.|USA|United States)\s+orders\s+ship\s+free\b/i },
209
+ { label: "Made in USA", regex: /\bMade\s+in\s+(?:the\s+)?(?:USA|U\.S\.A\.|US|U\.S\.|United States)\b/i },
210
+ { label: "manufactured in the USA", regex: /\bmanufactur(?:ed|ing)\s+in\s+(?:the\s+)?(?:USA|U\.S\.A\.|US|U\.S\.|United States)\b/i },
211
+ ];
212
+
213
+ const HARDCODED_CURRENCY_REGEX = /\$\s?\d[\d,]*(?:\.\d+)?(?:\/[A-Za-z]+)?/g;
214
+ const HARDCODED_PHONE_REGEX = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g;
215
+
216
+ const SDK_ROUTING_META_TAGS = [
217
+ "next-success-url",
218
+ "next-upsell-accept-url",
219
+ "next-upsell-decline-url",
220
+ ];
221
+
222
+ function normalizeFunnels(spec) {
223
+ if (Array.isArray(spec?.funnels)) return spec.funnels;
224
+ if (Array.isArray(spec?.funnel_pages)) {
225
+ return [{ id: "default", weight: 100, pages: spec.funnel_pages }];
226
+ }
227
+ return [];
228
+ }
229
+
230
+ function activeSpecPages(spec) {
231
+ const pages = [];
232
+ for (const funnel of normalizeFunnels(spec)) {
233
+ for (const page of Array.isArray(funnel.pages) ? funnel.pages : []) {
234
+ if (page && page.enabled !== false && isNonEmptyString(page.id)) {
235
+ pages.push({ ...page, funnel_id: funnel.id || "default" });
236
+ }
237
+ }
238
+ }
239
+ return pages;
240
+ }
241
+
242
+ function hasHtmlExtensionRoute(value) {
243
+ if (!isNonEmptyString(value)) return false;
244
+ const raw = value.trim();
245
+ try {
246
+ const url = new URL(raw);
247
+ return /\.html$/i.test(url.pathname);
248
+ } catch {
249
+ return /\.html$/i.test(raw.replace(/[?#].*$/, ""));
250
+ }
251
+ }
252
+
253
+ function collectHtmlFiles(root) {
254
+ const files = [];
255
+ const resolvedRoot = resolve(root);
256
+ if (!existsSync(resolvedRoot) || !statSync(resolvedRoot).isDirectory()) return files;
257
+
258
+ function walk(dir) {
259
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
260
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
261
+ const fullPath = join(dir, entry.name);
262
+ if (entry.isDirectory()) {
263
+ walk(fullPath);
264
+ } else if (entry.isFile() && extname(entry.name).toLowerCase() === ".html") {
265
+ files.push({
266
+ path: relative(resolvedRoot, fullPath),
267
+ name: entry.name,
268
+ basename: basename(entry.name, ".html"),
269
+ bytes: statSync(fullPath).size,
270
+ sha256: sha256File(fullPath),
271
+ });
272
+ }
273
+ }
274
+ }
275
+
276
+ walk(resolvedRoot);
277
+ return files.sort((a, b) => a.path.localeCompare(b.path));
278
+ }
279
+
280
+ const BUILT_TEXT_EXTENSIONS = new Set([".html", ".css", ".js", ".mjs", ".json"]);
281
+ const BUILT_TEXT_SCAN_IGNORED_DIRS = new Set(["node_modules", ".git", "_includes", "_layouts"]);
282
+ const DISCOUNT_CLAIM_TOLERANCE = 0.01;
283
+
284
+ function collectBuiltTextFiles(root) {
285
+ const files = [];
286
+ const resolvedRoot = resolve(root);
287
+ if (!existsSync(resolvedRoot) || !statSync(resolvedRoot).isDirectory()) return files;
288
+
289
+ function walk(dir) {
290
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
291
+ if (BUILT_TEXT_SCAN_IGNORED_DIRS.has(entry.name)) continue;
292
+ const fullPath = join(dir, entry.name);
293
+ if (entry.isDirectory()) {
294
+ walk(fullPath);
295
+ } else if (entry.isFile() && BUILT_TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
296
+ files.push({
297
+ path: relative(resolvedRoot, fullPath),
298
+ name: entry.name,
299
+ bytes: statSync(fullPath).size,
300
+ });
301
+ }
302
+ }
303
+ }
304
+
305
+ walk(resolvedRoot);
306
+ return files.sort((a, b) => a.path.localeCompare(b.path));
307
+ }
308
+
309
+ // Doctor Check Registry: keep packet/spec/build/artifact check order as data so
310
+ // agents add new checks in one deterministic slot instead of editing a long call chain.
311
+ function runDoctorChecks(checks, registryContext, options = {}) {
312
+ if (!isObject(registryContext?.derived) || !Array.isArray(registryContext.derived.doctor_checks)) {
313
+ throw new Error("Doctor check registry execution needs derived.doctor_checks for deterministic trace output.");
314
+ }
315
+ const executed = runDoctorCheckRegistry(checks, registryContext, options);
316
+ registryContext.derived.doctor_checks.push(...executed);
317
+ return executed;
318
+ }
319
+
320
+ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
321
+ {
322
+ id: "campaign-spec.rule-registry",
323
+ phase: "spec",
324
+ run: ({ spec, errors, warnings }) => validateCampaignSpecRuleRegistry(spec, errors, warnings),
325
+ },
326
+ {
327
+ id: "spec.identity_export",
328
+ phase: "spec",
329
+ run: ({ spec, warnings, ready }) => validateSpecIdentityExport(spec, warnings, ready),
330
+ },
331
+ {
332
+ id: "campaign.route_slug_identity",
333
+ phase: "spec",
334
+ run: ({ spec, packet, errors, ready }) => validateRouteSlugIdentity(spec, packet, errors, ready),
335
+ },
336
+ {
337
+ id: "campaign.route_root",
338
+ phase: "spec",
339
+ run: ({ packet, errors, ready }) => validateRouteRootDeclaration(packet, errors, ready),
340
+ },
341
+ {
342
+ id: "spec.public_routes",
343
+ phase: "spec",
344
+ run: ({ spec, errors, ready }) => validateSpecPublicRoutes(spec, errors, ready),
345
+ },
346
+ {
347
+ id: "spec.store_profile",
348
+ phase: "spec",
349
+ run: ({ spec, packet, errors, warnings, ready, derived, buildState }) =>
350
+ validateSpecStoreProfile(spec, errors, warnings, ready, { packet, derived, buildState }),
351
+ },
352
+ {
353
+ id: PAGE_KIT_SDK_VERSION_SCOPE,
354
+ phase: "target",
355
+ run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState),
356
+ },
357
+ {
358
+ id: PAGE_KIT_STORE_PROFILE_SCOPE,
359
+ phase: "target",
360
+ run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetStoreProfile(spec, errors, warnings, ready, derived, buildState),
361
+ },
362
+ {
363
+ id: "spec.shipping_countries",
364
+ phase: "spec",
365
+ run: ({ spec, warnings, ready }) => validateSpecShippingCountries(spec, warnings, ready),
366
+ },
367
+ {
368
+ id: "spec.routing_meta_tags",
369
+ phase: "spec",
370
+ run: ({ spec, packet, warnings, ready, derived, buildState }) => validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived, buildState),
371
+ },
372
+ {
373
+ id: "source_html.coverage",
374
+ phase: "source",
375
+ run: ({ packet, packetPath, spec, errors, warnings, ready, derived, buildState }) => validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived, buildState),
376
+ },
377
+ {
378
+ id: "source_html.preparation",
379
+ phase: "source",
380
+ run: ({ packet, packetPath, errors, warnings, ready, derived }) => validateSourcePreparation(packet, packetPath, errors, warnings, ready, derived),
381
+ },
382
+ {
383
+ id: "spec.package_availability",
384
+ phase: "spec",
385
+ run: ({ spec, warnings, ready }) => validateSpecPackageAvailability(spec, warnings, ready),
386
+ },
387
+ {
388
+ id: "built_output.target_root",
389
+ phase: "built-output",
390
+ run: ({ packet, errors, warnings, ready, derived, buildState }) => validateBuiltOutputTargetRoot(packet, errors, warnings, ready, derived, buildState),
391
+ },
392
+ {
393
+ id: "built_output.fingerprint",
394
+ phase: "built-output",
395
+ run: ({ packet, errors, warnings, ready, derived, buildState }) => validateBuildOutputFingerprint(packet, errors, warnings, ready, derived, buildState),
396
+ },
397
+ {
398
+ id: "built_output.pages",
399
+ phase: "built-output",
400
+ run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuiltOutputPages(spec, packet, errors, warnings, ready, derived, buildState),
401
+ },
402
+ {
403
+ id: UPSELL_SELECTOR_SCOPE,
404
+ phase: "built-output",
405
+ run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateUpsellSelectorScope(spec, packet, errors, warnings, ready, derived, buildState),
406
+ },
407
+ {
408
+ id: CAMPAIGN_IDENTITY,
409
+ phase: "built-output",
410
+ run: ({ packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived),
411
+ },
412
+ {
413
+ id: SDK_MARKUP,
414
+ phase: "built-output",
415
+ run: ({ packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived),
416
+ },
417
+ {
418
+ id: SCRIPT_SYNTAX,
419
+ phase: "built-output",
420
+ run: ({ packet, errors, warnings, ready, derived }) => validateBuiltScriptSyntax(packet, errors, warnings, ready, derived),
421
+ },
422
+ {
423
+ id: "built_output.sdk_meta_tags",
424
+ phase: "built-output",
425
+ run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuiltSdkMetaTags(spec, packet, errors, warnings, ready, derived, buildState),
426
+ },
427
+ {
428
+ id: "built_output.build_summary",
429
+ phase: "built-output",
430
+ run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuildSummary(spec, packet, errors, warnings, ready, derived, buildState),
431
+ },
432
+ {
433
+ id: "built_output.route_drift",
434
+ phase: "built-output",
435
+ run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuiltRouteDrift(spec, packet, errors, warnings, ready, derived, buildState),
436
+ },
437
+ {
438
+ id: "built_output.content_residue",
439
+ phase: "built-output",
440
+ run: ({ packet, errors, warnings, ready, derived, buildState }) => validateBuiltContentResidue(packet, errors, warnings, ready, derived, buildState),
441
+ },
442
+ {
443
+ id: "built_output.proof_attestation",
444
+ phase: "built-output",
445
+ run: ({ packet, errors, warnings, ready, derived, buildState }) => validateProofAttestation(packet, errors, warnings, ready, derived, buildState),
446
+ },
447
+ ], { registryId: "packet.spec" });
448
+
449
+ const PACKET_DOCTOR_CHECKS = createDoctorCheckRegistry([
450
+ {
451
+ id: "campaign.api_key",
452
+ phase: "packet",
453
+ run: ({ packet, spec, warnings, ready }) => validateCampaignsApiKey(packet, spec, warnings, ready),
454
+ },
455
+ {
456
+ id: "assembly.commerce_catalog",
457
+ phase: "template-contract",
458
+ run: ({ packet, packetPath, spec, errors, warnings, ready, derived, buildState }) => validateCommerceCatalog(packet, packetPath, spec, errors, warnings, ready, derived, buildState),
459
+ },
460
+ {
461
+ id: "market_copy",
462
+ phase: "copy",
463
+ run: ({ spec, warnings, ready, derived }) => validateMarketSensitiveCopy(spec, warnings, ready, derived),
464
+ },
465
+ {
466
+ id: "source_html.adapter_contract",
467
+ phase: "source",
468
+ run: ({ packet, packetPath, spec, errors, warnings, ready, derived, buildState }) => validateAdapterContracts(packet, packetPath, spec, errors, warnings, ready, derived, buildState),
469
+ },
470
+ {
471
+ id: "qa.proof_policy",
472
+ phase: "qa",
473
+ run: ({ packet, packetPath, report, warnings, ready }) => validateProofPolicy(packet, warnings, ready, { report, packetPath }),
474
+ },
475
+ {
476
+ id: "build_brief.artifact",
477
+ phase: "brief",
478
+ run: ({ packet, packetPath, spec, context, errors, warnings, ready }) => validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready),
479
+ },
480
+ ], { registryId: "packet.always" });
481
+
482
+ const ARTIFACT_DOCTOR_CHECKS = createDoctorCheckRegistry([
483
+ // Artifact phases are deterministic labels for inspection/filtering; artifact
484
+ // presence is gated by `when` because context and report sidecars are optional.
485
+ {
486
+ id: "context.shape",
487
+ phase: "context",
488
+ when: ({ context }) => Boolean(context),
489
+ run: ({ context, errors, warnings, ready, derived }) => validateContext(context, errors, warnings, ready, derived),
490
+ },
491
+ {
492
+ id: "assembly_report.shape",
493
+ phase: "report",
494
+ when: ({ report }) => Boolean(report),
495
+ run: ({ report, errors, warnings, ready }) => validateAssemblyReportShape(report, errors, warnings, ready),
496
+ },
497
+ ], { registryId: "artifact.optional" });
498
+
499
+ function validatePacket(packet, packetPath, errors, warnings, ready, derived, buildState = {}) {
500
+ if (!isObject(packet)) {
501
+ addIssue(errors, "packet.type", "Build Packet must be a JSON object.");
502
+ return;
503
+ }
504
+ const synthesizedBuiltSite = isSynthesizedBuiltSitePacket(packet);
505
+ if (packet.schema_version !== PACKET_SCHEMA) addIssue(errors, "schema_version", `Expected ${PACKET_SCHEMA}.`);
506
+ else ready.push(`Build Packet schema ${PACKET_SCHEMA}`);
507
+
508
+ requireString(packet, errors, "campaign.public_route_slug");
509
+ requireBoolean(packet, errors, "campaign.allowed_domains_confirmed");
510
+ if (!resolveCampaignIdentity(packet.spec)) addIssue(errors, packet.spec?.local_spec_id != null ? "spec.local_identity" : "spec.map_id", "Packet spec requires exactly one valid map_id or local_spec_id.", { kind: packet.spec?.local_spec_id != null ? "local_spec" : "saved_map" });
511
+ if (packet.spec?.local_spec_id != null && (packet.spec.spec_url != null || !isNonEmptyString(packet.spec.local_path))) {
512
+ addIssue(errors, "spec.local_identity", "Local-spec packets require a local_path and no saved-Map spec_url.");
513
+ }
514
+ if (!synthesizedBuiltSite) {
515
+ requireString(packet, errors, "source_html.root");
516
+ requireArray(packet, errors, "source_html.pages");
517
+ } else {
518
+ ready.push("Synthesized built-site packet: source_html provenance is absent by design");
519
+ }
520
+ requireString(packet, errors, "assembly.target_repo");
521
+ requireString(packet, errors, "assembly.output_dir");
522
+ requireString(packet, errors, "assembly.template_family");
523
+ // Test Orders have no permission flag: the two booleans that once gated
524
+ // them left the packet in surface 1.28.0. A packet still carrying them is
525
+ // valid (nothing reads them); doctor says so once so the residue is removed.
526
+ // The schema requires qa as an object (proof_policy and the notes live
527
+ // there); with the boolean checks gone this is the check that keeps doctor
528
+ // and bundle check agreeing on a packet whose qa is missing or malformed.
529
+ if (!isObject(packet.qa)) addIssue(errors, "qa", "qa must be an object.");
530
+ const removedQaPolicyFields = REMOVED_QA_POLICY_FIELDS.filter((field) => isObject(packet.qa) && field in packet.qa);
531
+ if (removedQaPolicyFields.length) {
532
+ addIssue(warnings, "qa.removed_policy_fields", `qa.${removedQaPolicyFields.join(" and qa.")} ${removedQaPolicyFields.length > 1 ? "are" : "is"} no longer part of the Build Packet (removed in supported surface 1.28.0; nothing reads ${removedQaPolicyFields.length > 1 ? "them" : "it"}). Delete the field${removedQaPolicyFields.length > 1 ? "s" : ""} from qa; test orders run from --test-order <mode> alone.`);
533
+ }
534
+
535
+ if (!isKnownTemplateFamily(packet.assembly?.template_family)) {
536
+ addIssue(errors, "assembly.template_family", `Unknown template family "${packet.assembly?.template_family}".`);
537
+ }
538
+ if (!KNOWN_DEPLOY_TARGETS.has(packet.deploy?.target)) {
539
+ addIssue(errors, "deploy.target", `Unknown deploy target "${packet.deploy?.target}".`);
540
+ }
541
+
542
+ if (!synthesizedBuiltSite && (packet.assembly?.template_family === "undecided" || packet.assembly?.template_lock?.locked !== true)) {
543
+ addIssue(errors, "assembly.template_lock", "Template family must be explicitly locked before commerce wiring.");
544
+ }
545
+
546
+ // Certified-template gate: a decided family must be certified (catalog +
547
+ // brand contract) or carry an explicit uncertified waiver. Uncertified
548
+ // families have no deterministic assembly path, no residue QA, and no
549
+ // pricing contract — the OS cannot stand behind the output, so the
550
+ // decision to leave the rails is recorded, never improvised.
551
+ const decidedFamily = packet.assembly?.template_family;
552
+ if (isNonEmptyString(decidedFamily) && decidedFamily !== "undecided") {
553
+ if (isCertifiedTemplateFamily(decidedFamily)) {
554
+ ready.push(`Template family "${decidedFamily}" is certified (commerce catalog + brand contract)`);
555
+ // Certification freshness (#263): certified is not the whole story — an
556
+ // operator must also see which SDK the certification evidence covers.
557
+ // Current freshness stays informational (ready); a stale or unrecorded
558
+ // verification surfaces as a warning, because an older evidence record
559
+ // is not current certification.
560
+ const freshness = assessTemplateFreshness({
561
+ family: decidedFamily,
562
+ catalog: resolveCommerceCatalog(),
563
+ sdkSupportPolicy: defaultSdkSupportPolicy(),
564
+ });
565
+ const freshnessLine = renderTemplateFreshness(freshness);
566
+ if (freshness.state === "current") {
567
+ ready.push(freshnessLine);
568
+ } else {
569
+ addIssue(warnings, "assembly.template_certification.freshness", freshnessLine);
570
+ }
571
+ } else {
572
+ const waiver = packet.assembly?.template_certification?.waiver;
573
+ if (waiver && isNonEmptyString(waiver.reason)) {
574
+ addIssue(warnings, "assembly.template_certification", `Template family "${decidedFamily}" is NOT certified; proceeding under recorded waiver: ${waiver.reason}. Deterministic assembly, residue QA, and pricing contracts do not cover this family.`);
575
+ } else {
576
+ addIssue(errors, "assembly.template_certification", `Template family "${decidedFamily}" is not certified. Certified families: ${[...certifiedTemplateFamilies()].sort().join(", ")}. Pick a certified family, or rerun prepare-build with --allow-uncertified-template "<reason>" to record an explicit waiver.`);
577
+ }
578
+ }
579
+ }
580
+
581
+ const deployUrl = packet.deploy?.preview_url || packet.deploy?.production_url;
582
+ if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
583
+ if (!deployUrl) {
584
+ ready.push("Deploy target is local-serve: serve the built _site/ locally and record the localhost URL on deploy.preview_url; localhost on any port is a Development domain, so no SDK origin allowlist entry is needed for QA.");
585
+ } else if (isLocalhostDevelopmentOrigin(deployUrl)) {
586
+ ready.push(`Deploy target is local-serve and the deploy URL ${deployUrl} is localhost: Campaigns App treats localhost on any port as a Development domain, so SDK initialization is allowed and analytics are suppressed for local QA.`);
587
+ } else if (isLoopbackDeployUrl(deployUrl)) {
588
+ // A static server bound to 127.0.0.1 or [::1] is the same local serve;
589
+ // the Development-domain rule is stated for the hostname localhost, so
590
+ // say which spelling to fall back to if the SDK refuses the numeric one.
591
+ ready.push(`Deploy target is local-serve and the deploy URL ${deployUrl} is a loopback origin: it is served locally for QA. Campaigns App states its Development-domain rule for the hostname localhost on any port; if SDK initialization is refused on this host, open the same server as http://localhost:<port>/ and record that URL instead.`);
592
+ } else {
593
+ addIssue(warnings, "deploy.local_serve_url", `deploy.target is local-serve but the recorded deploy URL ${deployUrl} is not a localhost or loopback origin. Record the served localhost URL, or set deploy.target to where that origin is actually hosted.`);
594
+ }
595
+ validateLocalProof(packet, buildState?.report || null, errors, warnings, ready);
596
+ } else if (packet.campaign?.allowed_domains_confirmed !== true) {
597
+ if (isLocalhostDevelopmentOrigin(deployUrl)) {
598
+ ready.push("Deploy URL is localhost; Campaigns App treats localhost on any port as a Development domain, so SDK initialization is allowed and analytics are suppressed for local QA.");
599
+ } else {
600
+ addIssue(warnings, "campaign.allowed_domains_confirmed", "Non-localhost preview/production origins are not confirmed in the Campaigns App SDK origin allowlist. SDK runtime checks may be blocked after deploy.");
601
+ }
602
+ }
603
+
604
+ if (synthesizedBuiltSite) {
605
+ const builtPages = (Array.isArray(packet.pages) ? packet.pages : []).map((page) => ({
606
+ page_id: String(page?.page_id || page?.id || page?.route || "page"),
607
+ type: String(page?.type || page?.page_type || "page"),
608
+ role: pageRole(String(page?.type || page?.page_type || "page")),
609
+ route: typeof page?.route === "string" ? page.route : null,
610
+ }));
611
+ derived.scope = {
612
+ mode: "built_site",
613
+ built_pages: builtPages,
614
+ out_of_scope_pages: [],
615
+ previewable_routes: builtPages.map((page) => ({ page_id: page.page_id, type: page.type, route: page.route })),
616
+ blocked_runtime_pages: [],
617
+ };
618
+ derived.source_root = null;
619
+ } else {
620
+ const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
621
+ derived.source_root = sourceRoot;
622
+ if (!sourceRoot || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
623
+ addIssue(errors, "source_html.root", `Source root does not exist: ${packet.source_html?.root}`);
624
+ }
625
+ }
626
+
627
+ const targetRepo = resolveFromFile(packetPath, packet.assembly?.target_repo);
628
+ derived.target_repo = targetRepo;
629
+ const pageKitCampaignConfig = loadPageKitCampaignEntry({
630
+ targetRepo,
631
+ publicRouteSlug: packet?.campaign?.public_route_slug,
632
+ });
633
+ buildState.pageKitCampaignConfig = pageKitCampaignConfig;
634
+ derived.page_kit_campaign_config = projectPageKitCampaignLoad(pageKitCampaignConfig);
635
+ if (!targetRepo || !existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
636
+ addIssue(errors, "assembly.target_repo", `Target repo does not exist: ${packet.assembly?.target_repo}`);
637
+ } else {
638
+ const outputDir = resolve(targetRepo, packet.assembly?.output_dir || "");
639
+ derived.target_output_dir = outputDir;
640
+ derived.scaffold_required = !existsSync(outputDir);
641
+ derived.scaffold_reason = derived.scaffold_required
642
+ ? `Target output directory does not exist: ${packet.assembly?.output_dir}`
643
+ : null;
644
+ if (derived.scaffold_required) {
645
+ addIssue(warnings, "page_kit.scaffold_required", "Target campaign output directory is missing; setup should run before build.");
646
+ } else {
647
+ ready.push("Target campaign output directory exists");
648
+ }
649
+ const pkgPath = join(targetRepo, "package.json");
650
+ if (!existsSync(pkgPath)) {
651
+ addIssue(warnings, "page_kit.package_json", "Target repo has no package.json. Page-kit command detection is unavailable.");
652
+ } else {
653
+ const pkg = readJson(pkgPath);
654
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
655
+ if (!deps["next-campaign-page-kit"]) {
656
+ addIssue(warnings, "page_kit.dependency", "Target package.json does not declare next-campaign-page-kit; page-kit may still be installed through another local path.");
657
+ } else {
658
+ ready.push(`Target page-kit dependency ${deps["next-campaign-page-kit"]}`);
659
+ }
660
+ }
661
+ }
662
+
663
+ let spec = null;
664
+ if (synthesizedBuiltSite) {
665
+ derived.spec_path = null;
666
+ ready.push("Synthesized built-site packet skips CampaignSpec/source checks; built-output gates should run through doctor --built or qa --site.");
667
+ } else {
668
+ const localSpecPath = packet.spec?.local_path;
669
+ const specPath = isNonEmptyString(localSpecPath) ? resolveFromFile(packetPath, localSpecPath) : null;
670
+ derived.spec_path = specPath;
671
+ let specStatus = "missing";
672
+ if (localSpecPath != null && !isNonEmptyString(localSpecPath)) {
673
+ // A present-but-unusable local_path is a malformed packet, not an
674
+ // unconfigured one. Both block, but they are repaired in different
675
+ // places, so the operator is told which one they have.
676
+ addIssue(errors, "spec.local_path", `CampaignSpec local_path must be a non-empty string; the packet declares ${typeof localSpecPath}. Repair the packet's spec.local_path before build or QA.`);
677
+ } else if (!isNonEmptyString(localSpecPath)) {
678
+ addIssue(errors, "spec.local_path", "No local CampaignSpec path is present. Assembly must use a local exported CampaignSpec JSON so page coverage, routing, meta tags, and commerce refs are not guessed.");
679
+ } else if (!existsSync(specPath)) {
680
+ addIssue(errors, "spec.local_path", `CampaignSpec local_path does not exist: ${localSpecPath}`);
681
+ } else {
682
+ try {
683
+ const loaded = readJson(specPath);
684
+ if (isObject(loaded)) {
685
+ spec = loaded;
686
+ specStatus = "ok";
687
+ } else {
688
+ specStatus = "root_not_object";
689
+ addIssue(errors, "spec.local_path", "CampaignSpec local_path must contain a JSON object. Restore a valid packet-local CampaignSpec export before build or QA.");
690
+ }
691
+ } catch {
692
+ specStatus = "invalid_json";
693
+ addIssue(errors, "spec.local_path", "CampaignSpec local_path is not valid JSON. Restore a valid packet-local CampaignSpec export before build or QA.");
694
+ }
695
+ }
696
+ buildState.specStatus = specStatus;
697
+ if (specStatus === "ok") {
698
+ const specMapId = spec.spec_identity?.map_id || spec.map_id;
699
+ if ((specMapId && specMapId !== packet.spec.map_id)
700
+ || ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
701
+ && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
702
+ const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
703
+ addIssue(errors, localIdentity ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: localIdentity ? "local_spec" : "saved_map" });
704
+ }
705
+ ready.push("Local CampaignSpec parsed");
706
+ runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
707
+ } else {
708
+ runDoctorChecks(
709
+ SPEC_DOCTOR_CHECKS,
710
+ { packet, packetPath, spec: null, targetRepo, errors, warnings, ready, derived, buildState },
711
+ { phase: "target" },
712
+ );
713
+ }
714
+ }
715
+
716
+ if (synthesizedBuiltSite) {
717
+ ready.push("Packet source/proof checks skipped for synthesized built-site packet");
718
+ } else {
719
+ runDoctorChecks(PACKET_DOCTOR_CHECKS, { packet, packetPath, spec, context: buildState.context, report: buildState.report, errors, warnings, ready, derived, buildState });
720
+ }
721
+
722
+ if (!packet.deploy?.preview_url && !packet.deploy?.production_url) {
723
+ const partialScope = derived.scope?.mode === "partial";
724
+ addIssue(
725
+ warnings,
726
+ "deploy.preview_url",
727
+ partialScope
728
+ ? "No preview or production URL yet. After deploy, mapped pages are route/visual-testable; checkout/runtime launch QA remains blocked for out-of-scope pages."
729
+ : "No preview or production URL yet. QA remains blocked after build/polish."
730
+ );
731
+ }
732
+ // Test Orders use global test cards that bypass the gateway and create no
733
+ // transactions, so they need no per-packet permission. The packet qa.* booleans
734
+ // are retained as informational metadata but no longer gate test orders or QA
735
+ // stage progression.
736
+ }
737
+
738
+ function validateCampaignSpecRuleRegistry(spec, errors, warnings) {
739
+ // ADR-003: run the shared campaign-spec rule registry — the single public
740
+ // source of CampaignSpec validation. specOnlyRules is the right preset here:
741
+ // the doctor runs without a deployed URL, so any rule requiring one is
742
+ // skipped. These pure spec-shape rules are complementary to the
743
+ // packet/build-aware spec checks; both run so internal teams and agencies get
744
+ // identical spec-shape validation. Emitted under the single spec.validation
745
+ // code, with rule identity preserved in detail for JSON consumers.
746
+ try {
747
+ for (const violation of runRules(normalizeCampaignSpec(spec), specOnlyRules)) {
748
+ addIssue(
749
+ violation.severity === "error" ? errors : warnings,
750
+ "spec.validation",
751
+ violation.message,
752
+ { ruleId: violation.ruleId, path: violation.path, data: violation.data }
753
+ );
754
+ }
755
+ } catch (error) {
756
+ addIssue(errors, "spec.validation", `CampaignSpec validation failed: ${error.message}`);
757
+ }
758
+ }
759
+
760
+ function validateAdapterContracts(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
761
+ const packetContract = packet.source_html?.adapter_contract;
762
+ validateAdapterDecisionShape(packetContract, "source_html.adapter_contract", warnings, ready, { addIssue });
763
+ validateAdapterSourceFiles({
764
+ decisions: packetContract,
765
+ sourceRoot: resolveFromFile(packetPath, packet.source_html?.root),
766
+ pages: packet.source_html?.pages || [],
767
+ warnings,
768
+ ready,
769
+ addIssue,
770
+ });
771
+
772
+ const contextDecisions = buildState.context?.adapter_decisions;
773
+ const reportDecisions = buildState.report?.adapter_decisions;
774
+
775
+ const decisions = reportDecisions || contextDecisions || packetContract;
776
+ validateAdapterDecisionGates({
777
+ decisions,
778
+ location: reportDecisions ? "report.adapter_decisions" : contextDecisions ? "context.adapter_decisions" : "source_html.adapter_contract",
779
+ specPages: activeSpecPages(spec),
780
+ family: packet.assembly?.template_family,
781
+ assemblyComplete: isStageComplete(buildState.report, "assembly"),
782
+ targetRepo: derived.target_repo,
783
+ errors,
784
+ warnings,
785
+ ready,
786
+ addIssue,
787
+ });
788
+ }
789
+
790
+ function validateProofPolicy(packet, warnings, ready, { report = null, packetPath = null } = {}) {
791
+ const policy = packet.qa?.proof_policy;
792
+ if (!policy) {
793
+ addIssue(warnings, "qa.proof_policy", "qa.proof_policy is missing. New packets make browser QA, typed-card depth, SDK origin allowlist state, order path depth, and approval state explicit.");
794
+ return;
795
+ }
796
+ validateProofPolicyObject(policy, "qa.proof_policy", warnings, ready, { requireBrowserQa: true });
797
+ // The report's proof_policy is a mirror written at prepare-build. A packet
798
+ // edited afterwards (by hand, or by an older `qa policy set` that never
799
+ // refreshed the mirror) leaves the two disagreeing, and
800
+ // assessPurchaseProofCoverage then reads the depth as unknown — so `next`
801
+ // could not reach done and nothing named the fix. Advisory, never a
802
+ // blocker: the warning carries the one command that reconciles them.
803
+ const drift = orderPathDepthDrift(packet, report);
804
+ if (drift) {
805
+ addIssue(warnings, ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText({ packetDepth: drift.packet, reportDepth: drift.report, packetPath }));
806
+ }
807
+ }
808
+
809
+ // The packet's declared order-path depth beside the report's mirror when the
810
+ // two disagree, else null. The comparison itself is the leaf's
811
+ // orderPathDepthsDisagree, which `next` also asks of a coverage result.
812
+ function orderPathDepthDrift(packet, report) {
813
+ const packetDepth = optionalString(packet?.qa?.proof_policy?.order_path_depth);
814
+ const reportDepth = optionalString(report?.proof_policy?.order_path_depth);
815
+ return orderPathDepthsDisagree(packetDepth, reportDepth) ? { packet: packetDepth, report: reportDepth } : null;
816
+ }
817
+
818
+ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready) {
819
+ const briefRef = packet.build_brief || context?.build_brief || null;
820
+ const normalizedPath = optionalString(packet.build_brief?.normalized_path)
821
+ || optionalString(context?.build_brief?.normalized_path);
822
+ if (!briefRef || !normalizedPath) {
823
+ addIssue(warnings, "build_brief.missing", "No Campaign Build Brief artifact is referenced. Existing builds may continue, but new build intake should provide or generate .campaign-runtime/input/campaign-build-brief.normalized.json so business/design decisions are durable.");
824
+ return;
825
+ }
826
+
827
+ const resolvedPath = resolveFromFile(packetPath, normalizedPath);
828
+ if (!resolvedPath || !existsSync(resolvedPath)) {
829
+ addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is missing: ${normalizedPath}`);
830
+ return;
831
+ }
832
+
833
+ const brief = readJson(resolvedPath);
834
+ const result = validateCampaignBuildBriefArtifact(brief, { spec });
835
+ for (const issue of result.errors) errors.push(issue);
836
+ for (const issue of result.warnings) warnings.push(issue);
837
+ ready.push(...result.ready);
838
+
839
+ if (context?.build_brief?.status && brief.status && context.build_brief.status !== brief.status) {
840
+ addIssue(warnings, "build_brief.context_status", `Build context says brief status is "${context.build_brief.status}" but normalized artifact says "${brief.status}". Rerun prepare-build to refresh the handoff.`);
841
+ }
842
+ }
843
+
844
+ function validateProofPolicyObject(policy, location, warnings, ready, { requireBrowserQa = false } = {}) {
845
+ if (!isObject(policy)) {
846
+ addIssue(warnings, location, `${location} must be an object when present.`);
847
+ return;
848
+ }
849
+ for (const field of PROOF_POLICY_REQUIRED_FIELDS) {
850
+ if (!(field in policy)) {
851
+ addIssue(warnings, `${location}.${field}`, `${location}.${field} is missing; proof policy must make browser QA, typed-card depth, SDK origin allowlist state, order path depth, and approval state explicit.`);
852
+ }
853
+ }
854
+ if (policy.browser_qa_required != null && typeof policy.browser_qa_required !== "boolean") {
855
+ addIssue(warnings, `${location}.browser_qa_required`, `${location}.browser_qa_required must be a boolean.`);
856
+ } else if ("browser_qa_required" in policy && requireBrowserQa && policy.browser_qa_required !== true) {
857
+ addIssue(warnings, `${location}.browser_qa_required`, "Browser QA should stay explicit in the packet/report before launch proof.");
858
+ }
859
+ if (!isNonEmptyString(policy.typed_card_depth)) {
860
+ addIssue(warnings, `${location}.typed_card_depth`, `${location}.typed_card_depth should name the intended typed-card depth, usually common.`);
861
+ }
862
+ if ("localhost_development_domain_allowed" in policy && policy.localhost_development_domain_allowed !== true) {
863
+ addIssue(warnings, `${location}.localhost_development_domain_allowed`, `${location}.localhost_development_domain_allowed should be true; localhost on any port is the public Development-domain QA origin.`);
864
+ }
865
+ if ("non_localhost_origin_allowlist_required" in policy && policy.non_localhost_origin_allowlist_required !== true) {
866
+ addIssue(warnings, `${location}.non_localhost_origin_allowlist_required`, `${location}.non_localhost_origin_allowlist_required should be true; preview/production origins require SDK origin allowlist confirmation.`);
867
+ }
868
+ if (!isNonEmptyString(policy.order_path_depth)) {
869
+ addIssue(warnings, `${location}.order_path_depth`, `${location}.order_path_depth should name checkout/upsell order-path depth.`);
870
+ }
871
+ if (!isNonEmptyString(policy.operator_approval_state)) {
872
+ addIssue(warnings, `${location}.operator_approval_state`, `${location}.operator_approval_state should be explicit, e.g. not_required_global_test_cards.`);
873
+ }
874
+ if (policy.qa_portal_publish_default != null && typeof policy.qa_portal_publish_default !== "boolean") {
875
+ addIssue(warnings, `${location}.qa_portal_publish_default`, `${location}.qa_portal_publish_default must be a boolean when present.`);
876
+ }
877
+ ready.push(`${location} loaded: browser=${policy.browser_qa_required === true}, typed_card_depth=${policy.typed_card_depth || "unspecified"}, order_path_depth=${policy.order_path_depth || "unspecified"}`);
878
+ }
879
+
880
+ export function validateSpecStoreProfile(spec, errors, warnings, ready, { packet = null, derived = {}, buildState = {} } = {}) {
881
+ const campaign = spec?.campaign || {};
882
+ const missing = REQUIRED_STORE_PROFILE_FIELDS.filter((field) => !isNonEmptyString(campaign[field]));
883
+ if (missing.length > 0) {
884
+ addIssue(
885
+ errors,
886
+ "spec.store_profile",
887
+ `CampaignSpec campaign is missing required Store Profile field for page-kit campaigns.json: ${missing.join(", ")}. Add ${missing.map((field) => `campaign.${field}`).join(", ")} to the CampaignSpec Store Profile, then rerun start/prepare-build. Campaigns OS does not infer or silently mutate these storefront/legal values.`,
888
+ {
889
+ missing_fields: missing.map((field) => `campaign.${field}`),
890
+ repair: {
891
+ owner: "operator",
892
+ action: `Update the CampaignSpec Store Profile export with merchant storefront metadata, then rerun ${cmd("start")} or ${cmd("prepare-build")}.`,
893
+ example_patch: Object.fromEntries(missing.map((field) => [field, field === "store_url" ? "https://<merchant-store-domain>" : "<merchant value>"])),
894
+ },
895
+ }
896
+ );
897
+ return;
898
+ }
899
+ ready.push("CampaignSpec required Store Profile fields are present for page-kit campaigns.json");
900
+
901
+ // R2-B5: a store profile can pass the required-field check yet
902
+ // still be unable to serve a real shopper — the gap neither doctor nor
903
+ // browser QA surfaced in the Round 2 run. These are real-shopper readiness
904
+ // *warnings* (not blockers): a routing/visual-only run is fine, and they are
905
+ // independent of test orders (test cards bypass the gateway). The concern is
906
+ // that a real customer cannot complete checkout against a placeholder store
907
+ // URL or a store with no payment methods configured.
908
+ const storeUrl = campaign.store_url;
909
+ if (isNonEmptyString(storeUrl) && looksLikePlaceholderStoreUrl(storeUrl)) {
910
+ addIssue(
911
+ warnings,
912
+ "spec.store_profile.placeholder_store_url",
913
+ `CampaignSpec campaign.store_url "${storeUrl}" looks like a local/placeholder store, not a live storefront. Localhost is valid as a Development-domain QA origin, but a real shopper cannot transact against it. Set the merchant's production store_url before launch.`
914
+ );
915
+ } else if (isNonEmptyString(storeUrl)) {
916
+ ready.push("CampaignSpec store_url points at a non-placeholder storefront");
917
+ }
918
+
919
+ const paymentMethods = campaign.available_payment_methods;
920
+ if (Array.isArray(paymentMethods) && paymentMethods.length === 0) {
921
+ addIssue(
922
+ warnings,
923
+ "spec.store_profile.no_payment_methods",
924
+ "CampaignSpec campaign.available_payment_methods is empty. A real shopper would have no payment method to complete checkout. Confirm the store's payment methods before launch."
925
+ );
926
+ }
927
+
928
+ // Every starter-template family's checkout page calls
929
+ // {% campaign_include 'payment-methods.html' %} with no arguments, and the
930
+ // include defaults show_paypal/show_klarna/show_apple_pay/show_google_pay to
931
+ // true. So a method the spec does not support still renders unless the build
932
+ // passes show_<method>=false on that include call. When the spec declares its
933
+ // supported methods and one of those four is absent from both
934
+ // available_payment_methods and available_express_payment_methods, warn so the
935
+ // build disables it (or the spec adds it). Methods may be plain strings or
936
+ // { code, label } objects.
937
+ //
938
+ // Once the checkout is built, the rendered page is the authority: doctor
939
+ // reads _site/<slug>/<checkout route>/index.html for the method's markup and
940
+ // stays silent when none shipped — the same markers browser QA's
941
+ // template-residue gate keys on — instead of repeating a pre-build advisory
942
+ // the build already satisfied.
943
+ const normalizeMethod = (method) =>
944
+ String(method && typeof method === "object" ? method.code : method).toLowerCase().replace(/[\s-]+/g, "_");
945
+ const supportedMethods = new Set([
946
+ ...(Array.isArray(paymentMethods) ? paymentMethods : []).map(normalizeMethod),
947
+ ...(Array.isArray(campaign.available_express_payment_methods) ? campaign.available_express_payment_methods : []).map(normalizeMethod),
948
+ ]);
949
+ if (supportedMethods.size === 0) return;
950
+ const unsupportedDefaults = STARTER_TEMPLATE_DEFAULT_ON_PAYMENT_METHODS.filter((method) => !supportedMethods.has(method));
951
+ if (unsupportedDefaults.length === 0) return;
952
+
953
+ const family = packet?.assembly?.template_family;
954
+ const builtCheckouts = builtCheckoutPagesForSpec(spec, packet, derived);
955
+ if (builtCheckouts.length === 0) {
956
+ const includeCall = `{% campaign_include 'payment-methods.html' ${unsupportedDefaults.map((method) => `show_${method}=false`).join(" ")} %}`;
957
+ addIssue(
958
+ warnings,
959
+ "spec.store_profile.payment_methods_default_on",
960
+ `Starter-template checkout pages render ${unsupportedDefaults.join(", ")} by default: the checkout page includes payment-methods.html with no arguments and the include defaults show_${unsupportedDefaults.length > 1 ? "<method>" : unsupportedDefaults[0]} to true, but the CampaignSpec does not list ${unsupportedDefaults.length > 1 ? "them" : "it"} in available_payment_methods/available_express_payment_methods. `
961
+ + `Pass ${unsupportedDefaults.map((method) => `show_${method}=false`).join(" ")} on that include call in the ${isNonEmptyString(family) ? `${family} ` : ""}checkout page (${includeCall}) or add the method to the spec, so unsupported methods do not ship. Doctor re-reads the built checkout once it exists.`,
962
+ {
963
+ methods: unsupportedDefaults,
964
+ template_family: isNonEmptyString(family) ? family : null,
965
+ basis: "spec_only",
966
+ repair: {
967
+ owner: "operator",
968
+ action: `Pass ${unsupportedDefaults.map((method) => `show_${method}=false`).join(" ")} on the checkout page's payment-methods.html include call, or add the method(s) to the CampaignSpec, then rebuild.`,
969
+ include_call: includeCall,
970
+ },
971
+ }
972
+ );
973
+ return;
974
+ }
975
+
976
+ const chrome = isNonEmptyString(family) ? resolveBrandContractOnce(derived, family).contract?.default_residue?.payment_chrome || null : null;
977
+ const shipped = [];
978
+ for (const built of builtCheckouts) {
979
+ const html = readFileSync(built.path, "utf8");
980
+ for (const method of unsupportedDefaults) {
981
+ const markers = paymentMethodMarkupMatches(html, method, chrome);
982
+ if (markers.length) shipped.push({ page_id: built.page_id, file: built.file, method, markers, forced_logo: paymentLogoForcedOn(html, method) });
983
+ }
984
+ }
985
+ const builtFiles = [...new Set(builtCheckouts.map((built) => built.file))].join(", ");
986
+ // What the static scan could not attribute (compound selectors, shared
987
+ // chrome assets) stays with browser QA; name it so "no markup" is never
988
+ // read as "nothing left to check".
989
+ const gaps = { compound_selectors: [], shared_assets: [] };
990
+ for (const method of unsupportedDefaults) {
991
+ const methodGaps = paymentMethodStaticScanGaps(chrome, method);
992
+ gaps.compound_selectors.push(...methodGaps.compound_selectors);
993
+ gaps.shared_assets.push(...methodGaps.shared_assets);
994
+ }
995
+ gaps.compound_selectors = [...new Set(gaps.compound_selectors)];
996
+ gaps.shared_assets = [...new Set(gaps.shared_assets)];
997
+ const gapClauses = [];
998
+ if (gaps.shared_assets.length) gapClauses.push(`shared chrome asset${gaps.shared_assets.length > 1 ? "s" : ""} ${gaps.shared_assets.join(", ")}`);
999
+ if (gaps.compound_selectors.length) gapClauses.push(`compound selector${gaps.compound_selectors.length > 1 ? "s" : ""} ${gaps.compound_selectors.join(", ")}`);
1000
+ const gapNote = gapClauses.length ? `; left to browser QA: ${gapClauses.join(" and ")}` : "";
1001
+ if (shipped.length === 0) {
1002
+ ready.push(`Built checkout carries no ${unsupportedDefaults.join(", ")} payment-method markup (${builtFiles})${gapNote}`);
1003
+ return;
1004
+ }
1005
+ const shippedMethods = [...new Set(shipped.map((hit) => hit.method))];
1006
+ const forcedLogoMethods = [...new Set(shipped.filter((hit) => hit.forced_logo).map((hit) => hit.method))];
1007
+ const evidence = shipped.map((hit) => `${hit.file}: ${hit.method} (${hit.markers.join(", ")})`).join("; ");
1008
+ addIssue(
1009
+ warnings,
1010
+ "spec.store_profile.payment_methods_default_on",
1011
+ `Built checkout still renders ${shippedMethods.join(", ")}, which the CampaignSpec does not list in available_payment_methods/available_express_payment_methods: ${evidence}. `
1012
+ + `Pass ${shippedMethods.map((method) => `show_${method}=false`).join(" ")} on the checkout page's payment-methods.html include call and rebuild (or add the method to the spec); browser QA's template-residue gate fails on this markup.`
1013
+ + (forcedLogoMethods.length ? ` The payment-logos.html row forces ${forcedLogoMethods.join(", ")} on: remove payment_flags.${forcedLogoMethods.map((method) => `show_${method}`).join("/")}: true from the page frontmatter (the row otherwise shows only what the campaign offers).` : ""),
1014
+ {
1015
+ methods: shippedMethods,
1016
+ template_family: isNonEmptyString(family) ? family : null,
1017
+ basis: "built_output",
1018
+ pages: shipped,
1019
+ static_scan_gaps: gaps,
1020
+ repair: {
1021
+ owner: "operator",
1022
+ action: `Pass ${shippedMethods.map((method) => `show_${method}=false`).join(" ")} on the checkout page's payment-methods.html include call, or add the method(s) to the CampaignSpec, then rebuild.`,
1023
+ include_call: `{% campaign_include 'payment-methods.html' ${shippedMethods.map((method) => `show_${method}=false`).join(" ")} %}`,
1024
+ },
1025
+ }
1026
+ );
1027
+ }
1028
+
1029
+ // A visible payment-logos.html <img> for this method: the template only leaves
1030
+ // one visible when the page forces it with payment_flags.show_<method>: true.
1031
+ function paymentLogoForcedOn(html, method) {
1032
+ const code = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
1033
+ return new RegExp(`<img\\b[^>]*\\sdata-payment-logo\\s*=\\s*["']${code}["']`, "i").test(withoutHiddenPaymentLogos(html));
1034
+ }
1035
+
1036
+ // The four methods every starter-template payment-methods include renders
1037
+ // unless the checkout page passes show_<method>=false.
1038
+ export const STARTER_TEMPLATE_DEFAULT_ON_PAYMENT_METHODS = Object.freeze(["paypal", "klarna", "apple_pay", "google_pay"]);
1039
+
1040
+ // Built checkout pages on disk for the spec's active checkout pages: the
1041
+ // rendered _site/<slug>/<route>/index.html files that exist. Empty before a
1042
+ // build (or when the spec declares no checkout page), which is the pre-build
1043
+ // state the spec-only advisory covers.
1044
+ function builtCheckoutPagesForSpec(spec, packet, derived = {}) {
1045
+ const targetRepo = derived?.target_repo;
1046
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1047
+ if (!targetRepo || !publicRouteSlug) return [];
1048
+ const built = [];
1049
+ for (const page of activeSpecPages(spec)) {
1050
+ if (String(page?.type || page?.page_type || "").toLowerCase().trim() !== "checkout") continue;
1051
+ const path = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
1052
+ if (!path || !existsSync(path) || !statSync(path).isFile()) continue;
1053
+ built.push({ page_id: page.id, path, file: relative(targetRepo, path).split(sep).join("/") });
1054
+ }
1055
+ return built;
1056
+ }
1057
+
1058
+ // R2-B5: a best-effort check for store URLs that clearly cannot be
1059
+ // a live storefront (local dev hosts, reserved test/example TLDs). Intentionally
1060
+ // conservative — only obvious non-production hosts trip it, so a real merchant
1061
+ // domain never false-positives. A non-URL string is left to other validators.
1062
+ function looksLikePlaceholderStoreUrl(value) {
1063
+ let url;
1064
+ try {
1065
+ url = new URL(String(value).trim());
1066
+ } catch {
1067
+ return false;
1068
+ }
1069
+ const host = url.hostname.toLowerCase();
1070
+ if (["localhost", "127.0.0.1", "0.0.0.0", "::1"].includes(host)) return true;
1071
+ if (/\.(local|test|example|invalid|localhost)$/.test(host)) return true;
1072
+ if (host === "example.com" || host.endsWith(".example.com")) return true;
1073
+ return false;
1074
+ }
1075
+
1076
+ // A URL whose host is a loopback address (localhost, 127.0.0.1, [::1]) —
1077
+ // the remit rail's rule, reused so local-serve and the loopback receiver
1078
+ // agree on what "local" means. Distinct from isLocalhostDevelopmentOrigin,
1079
+ // which states the Campaigns App Development-domain rule (hostname localhost).
1080
+ function isLoopbackDeployUrl(value) {
1081
+ if (!isNonEmptyString(value)) return false;
1082
+ try {
1083
+ return isLoopbackHostname(new URL(String(value).trim()).hostname);
1084
+ } catch {
1085
+ return false;
1086
+ }
1087
+ }
1088
+
1089
+ export function isLocalhostDevelopmentOrigin(value) {
1090
+ if (!isNonEmptyString(value)) return false;
1091
+ let url;
1092
+ try {
1093
+ url = new URL(String(value).trim());
1094
+ } catch {
1095
+ return false;
1096
+ }
1097
+ return url.hostname.toLowerCase() === "localhost";
1098
+ }
1099
+
1100
+ // Local proof mode rows (deploy.target local-serve). Once the build stage is
1101
+ // terminal, the served _site/ must be the DEVELOPMENT render — a production
1102
+ // build's protocol-relative vendor loaders fail over a plain-HTTP local serve
1103
+ // and void polish capture — and the production render must have been proven
1104
+ // to differ from it only in environment-gated output (`page-kit parity`).
1105
+ // Both facts are read from the assembly stage's free-form evidence.
1106
+ function validateLocalProof(packet, report, errors, warnings, ready) {
1107
+ if (!stageIsTerminal(report?.stages?.assembly?.status)) {
1108
+ ready.push(`Local proof mode: the build stage renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD}; polish capture and QA run against that served output, and ${asInvocation(LOCAL_PROOF_PARITY_COMMAND)} proves the production render before commit.`);
1109
+ return;
1110
+ }
1111
+ const environment = recordedBuildEnvironment(report);
1112
+ if (environment === LOCAL_PROOF_BUILD_ENVIRONMENT) {
1113
+ ready.push(`Local proof mode: the built _site/ is recorded as a ${LOCAL_PROOF_BUILD_ENVIRONMENT} render (${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD}); vendor loaders are environment-gated out, SDK dl_* events still fire.`);
1114
+ } else {
1115
+ addIssue(warnings, LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE, environment
1116
+ ? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}" under deploy.target local-serve. A production build served over plain HTTP fails polish capture unwaivably on its protocol-relative vendor loaders (//host/...). Rebuild with ${LOCAL_PROOF_BUILD_COMMAND}, record the environment as "${LOCAL_PROOF_BUILD_ENVIRONMENT}", and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`
1117
+ : `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded under deploy.target local-serve. The build stage renders the development environment for local proof (${LOCAL_PROOF_BUILD_COMMAND}) and records it there; without the record doctor cannot tell a development render from a production build that will fail polish capture over plain HTTP. ${LOCAL_PROOF_NEVER_EDIT_RULE}`);
1118
+ }
1119
+ const parity = recordedProductionParity(report);
1120
+ const parityCommand = asInvocation(LOCAL_PROOF_PARITY_COMMAND);
1121
+ if (!parity) {
1122
+ addIssue(warnings, LOCAL_PROOF_PARITY_SCOPE, `Production parity is not recorded (${LOCAL_PROOF_PARITY_FIELD}). After proving the development build, run ${parityCommand} before committing: it renders the current source in development and production into temp dirs and asserts the served _site/ is the current development render and that production differs only in environment-gated output (same pages, route slugs, Campaign Cart pin and next-api-key). The PR preview is the second check, not the first.`);
1123
+ return;
1124
+ }
1125
+ const currentFingerprint = optionalString(report?.stages?.assembly?.build_fingerprint) || null;
1126
+ if (parity.build_fingerprint && currentFingerprint && parity.build_fingerprint !== currentFingerprint) {
1127
+ addIssue(warnings, LOCAL_PROOF_PARITY_SCOPE, `Production parity was recorded for build ${parity.build_fingerprint}, but stages.assembly.build_fingerprint is now ${currentFingerprint}. Re-run ${parityCommand} on the current build.`);
1128
+ return;
1129
+ }
1130
+ if (parity.status === "pass") {
1131
+ ready.push(`Local proof production parity: PASS — ${singleLineField(String(parity.summary || ""))}`);
1132
+ return;
1133
+ }
1134
+ const difference = isObject(parity.first_difference) ? parity.first_difference : null;
1135
+ addIssue(errors, LOCAL_PROOF_PARITY_SCOPE, `Production parity FAILED${difference ? ` — first non-gated difference: ${singleLineField(String(difference.kind))} at ${singleLineField(String(difference.route))}${difference.line ? ` line ${difference.line}` : ""}: ${singleLineField(String(difference.detail || ""))}` : `: ${singleLineField(String(parity.summary || ""))}`}. Rebuild in development, re-prove, and run ${parityCommand} again. ${LOCAL_PROOF_NEVER_EDIT_RULE}`, difference ? { first_difference: difference } : undefined);
1136
+ }
1137
+
1138
+ function validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState) {
1139
+ const report = buildState?.report || null;
1140
+ const required = stageIsTerminal(report?.stages?.setup?.status)
1141
+ || stageIsTerminal(report?.stages?.assembly?.status)
1142
+ || derived.scaffold_required !== true;
1143
+ const gate = evaluatePageKitSdkVersion({
1144
+ spec,
1145
+ specStatus: buildState?.specStatus || "ok",
1146
+ targetLoad: buildState?.pageKitCampaignConfig,
1147
+ waivers: report?.waivers,
1148
+ required,
1149
+ });
1150
+ derived.checkpoint_gates.push(gate);
1151
+
1152
+ const inertCounts = Object.fromEntries(
1153
+ ["stale", "foreign", "malformed", "expired"].map((kind) => [kind, gate.waiver_assessment?.inert_counts?.[kind] || 0]),
1154
+ );
1155
+ const inertTotal = Object.values(inertCounts).reduce((sum, count) => sum + count, 0);
1156
+ if (inertTotal > 0) {
1157
+ addIssue(
1158
+ warnings,
1159
+ "page_kit.sdk_version.waiver_inert",
1160
+ `SDK-pin waiver history contains ${inertTotal} inert record(s); stale, foreign, malformed, and expired decisions never satisfy the current checkpoint.`,
1161
+ { counts: inertCounts },
1162
+ );
1163
+ }
1164
+
1165
+ if (gate.status === "blocked") {
1166
+ addIssue(errors, gate.code, gate.reason, { checkpoint_gate: gate });
1167
+ return;
1168
+ }
1169
+ if (gate.status === "waived") {
1170
+ addIssue(warnings, gate.code, `${gate.reason} Waived by ${gate.waiver.waived_by}: ${gate.waiver.reason}`, { checkpoint_gate: gate });
1171
+ ready.push(`SDK-pin checkpoint accepted under named-human exception (${gate.waiver.waived_by}).`);
1172
+ return;
1173
+ }
1174
+ if (gate.status === "not_applicable") {
1175
+ ready.push("SDK-pin checkpoint not applicable before Page Kit scaffold; it becomes mandatory once the target entry exists or setup completes.");
1176
+ return;
1177
+ }
1178
+ if (gate.code === "page_kit.sdk_version.repo_newer") {
1179
+ // The repo pin is the authority (#413): a completed bump the Map has not
1180
+ // been re-saved for is advisory, and the ready line names what ships.
1181
+ addIssue(warnings, gate.code, gate.reason, { checkpoint_gate: gate });
1182
+ ready.push(`Target campaigns.json SDK version ${gate.observed_sdk_version} is what ships; the CampaignSpec pin ${gate.expected_sdk_version} is a stale build hint.`);
1183
+ return;
1184
+ }
1185
+ ready.push(`Target campaigns.json SDK version matches CampaignSpec (${gate.expected_sdk_version}).`);
1186
+ }
1187
+
1188
+ function validateTargetStoreProfile(spec, errors, warnings, ready, derived, buildState) {
1189
+ const report = buildState?.report || null;
1190
+ const required = stageIsTerminal(report?.stages?.setup?.status)
1191
+ || stageIsTerminal(report?.stages?.assembly?.status)
1192
+ || derived.scaffold_required !== true;
1193
+ const gate = evaluatePageKitStoreProfile({
1194
+ specCampaign: spec?.campaign || {},
1195
+ specStatus: buildState?.specStatus || "ok",
1196
+ targetLoad: buildState?.pageKitCampaignConfig,
1197
+ waivers: report?.waivers,
1198
+ required,
1199
+ });
1200
+ derived.checkpoint_gates.push(gate);
1201
+
1202
+ const inertCounts = Object.fromEntries(
1203
+ ["stale", "foreign", "malformed", "expired"].map((kind) => [kind, gate.waiver_assessment?.inert_counts?.[kind] || 0]),
1204
+ );
1205
+ const inertTotal = Object.values(inertCounts).reduce((sum, count) => sum + count, 0);
1206
+ if (inertTotal > 0) {
1207
+ addIssue(
1208
+ warnings,
1209
+ "page_kit.store_profile.waiver_inert",
1210
+ `Store Profile waiver history contains ${inertTotal} inert record(s); stale, foreign, malformed, and expired decisions never satisfy the current checkpoint.`,
1211
+ { counts: inertCounts },
1212
+ );
1213
+ }
1214
+
1215
+ if (gate.status === "blocked") {
1216
+ addIssue(errors, gate.code, gate.reason, { checkpoint_gate: gate });
1217
+ return;
1218
+ }
1219
+ if (gate.status === "waived") {
1220
+ addIssue(warnings, gate.code, `${gate.reason} Waived by ${gate.waiver.waived_by}: ${gate.waiver.reason}`, { checkpoint_gate: gate });
1221
+ ready.push(`Store Profile checkpoint accepted under named-human exception (${gate.waiver.waived_by}).`);
1222
+ return;
1223
+ }
1224
+ if (gate.status === "not_applicable") {
1225
+ ready.push("Store Profile checkpoint not applicable before Page Kit scaffold; it becomes mandatory once the target entry exists or setup completes.");
1226
+ return;
1227
+ }
1228
+ if (gate.warning_fields.length) {
1229
+ addIssue(warnings, gate.code, gate.reason, { checkpoint_gate: gate });
1230
+ return;
1231
+ }
1232
+ ready.push("Target campaigns.json Store Profile matches the CampaignSpec across all nine governed fields.");
1233
+ }
1234
+
1235
+ function validateSpecShippingCountries(spec, warnings, ready) {
1236
+ const countries = spec?.campaign?.available_shipping_countries;
1237
+ if (countries === "all" || (Array.isArray(countries) && countries.length === 0)) {
1238
+ ready.push("CampaignSpec shipping countries: all countries");
1239
+ return;
1240
+ }
1241
+ if (Array.isArray(countries)) {
1242
+ ready.push(`CampaignSpec shipping countries: ${countries.join(", ")}`);
1243
+ return;
1244
+ }
1245
+ if (countries == null) {
1246
+ ready.push("CampaignSpec shipping countries: all countries");
1247
+ return;
1248
+ }
1249
+ addIssue(warnings, "spec.available_shipping_countries", 'CampaignSpec campaign.available_shipping_countries should be "all" or an array of country codes.');
1250
+ }
1251
+
1252
+ // Commerce refs may be numeric in Map exports; general text fields may not.
1253
+ function firstCommerceRef(...values) {
1254
+ for (const value of values) {
1255
+ if (isNonEmptyString(value)) return value.trim();
1256
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
1257
+ }
1258
+ return null;
1259
+ }
1260
+
1261
+ function specPackageRecords(spec) {
1262
+ const records = [];
1263
+ const add = (pkg, source) => {
1264
+ if (!isObject(pkg)) return;
1265
+ const ref = firstCommerceRef(pkg.ref_id, pkg.package_id, pkg.id);
1266
+ if (!ref) return;
1267
+ records.push({ ref, source, package: pkg });
1268
+ };
1269
+
1270
+ for (const page of activeSpecPages(spec)) {
1271
+ for (const pkg of Array.isArray(page.packages) ? page.packages : []) {
1272
+ add(pkg, `page:${page.id}`);
1273
+ }
1274
+ }
1275
+ for (const offer of Array.isArray(spec?.offers) ? spec.offers : []) {
1276
+ for (const pkg of Array.isArray(offer.packages) ? offer.packages : []) {
1277
+ add(pkg, `offer:${offer.ref_id || offer.code || offer.name || "unknown"}`);
1278
+ }
1279
+ }
1280
+ for (const pkg of Array.isArray(spec?.packages) ? spec.packages : []) {
1281
+ add(pkg, "packages");
1282
+ }
1283
+
1284
+ return records;
1285
+ }
1286
+
1287
+ function specPackageRefs(spec) {
1288
+ return new Set(specPackageRecords(spec).map((record) => String(record.ref)));
1289
+ }
1290
+
1291
+ function specShippingRefs(spec) {
1292
+ const refs = new Set();
1293
+ const add = (method) => {
1294
+ const ref = firstCommerceRef(method?.ref_id, method?.id, method?.shipping_method_id);
1295
+ if (ref) refs.add(String(ref));
1296
+ };
1297
+
1298
+ for (const method of Array.isArray(spec?.shipping_methods) ? spec.shipping_methods : []) add(method);
1299
+ for (const offer of Array.isArray(spec?.offers) ? spec.offers : []) {
1300
+ for (const method of Array.isArray(offer.shipping_methods) ? offer.shipping_methods : []) add(method);
1301
+ }
1302
+
1303
+ return refs;
1304
+ }
1305
+
1306
+ // R2-B1: the set of refs the CampaignSpec itself declares as real
1307
+ // commerce entities — packages (page/offer/top-level), shipping methods, and
1308
+ // offer ref_ids. A reference whose value matches one of these points at a
1309
+ // genuinely-declared entity, so the demo-ref check below should not treat it
1310
+ // as a starter placeholder even when the Map export omitted ref-level
1311
+ // `_provenance.api` stamping (the provenance gap that produced the noise).
1312
+ function specDeclaredCommerceRefs(spec) {
1313
+ const refs = new Set([...specPackageRefs(spec), ...specShippingRefs(spec)]);
1314
+ for (const offer of Array.isArray(spec?.offers) ? spec.offers : []) {
1315
+ const ref = firstCommerceRef(offer?.ref_id, offer?.id);
1316
+ if (ref) refs.add(String(ref));
1317
+ }
1318
+ return refs;
1319
+ }
1320
+
1321
+ function validateSpecPackageAvailability(spec, warnings, ready) {
1322
+ const unavailable = specPackageRecords(spec).filter((record) => {
1323
+ const availability = firstNonEmptyString(
1324
+ record.package.product_purchase_availability,
1325
+ record.package.purchase_availability,
1326
+ record.package.availability
1327
+ );
1328
+ return availability && availability.toLowerCase() === "unavailable";
1329
+ });
1330
+
1331
+ if (!unavailable.length) {
1332
+ ready.push("CampaignSpec package purchase availability has no unavailable package refs in active build data");
1333
+ return;
1334
+ }
1335
+
1336
+ const sample = unavailable
1337
+ .slice(0, 6)
1338
+ .map((record) => `${record.ref} (${record.source})`)
1339
+ .join(", ");
1340
+ const more = unavailable.length > 6 ? `; plus ${unavailable.length - 6} more` : "";
1341
+ addIssue(
1342
+ warnings,
1343
+ "spec.package_unavailable",
1344
+ `CampaignSpec contains package refs marked product_purchase_availability=unavailable: ${sample}${more}. Checkout or upsell API calls may 403 until the store variant is available.`
1345
+ );
1346
+ }
1347
+
1348
+ function validateSpecIdentityExport(spec, warnings, ready) {
1349
+ const identity = spec?.spec_identity;
1350
+ if (isObject(identity) && resolveCampaignIdentity(identity) && isNonEmptyString(identity.public_route_slug)) {
1351
+ ready.push(`CampaignSpec spec_identity includes ${identity.local_spec_id ? "local_spec_id" : "map_id"} and public_route_slug`);
1352
+ return;
1353
+ }
1354
+
1355
+ addIssue(
1356
+ warnings,
1357
+ "spec_identity.export",
1358
+ "CampaignSpec is missing complete spec_identity: declare map_id for a saved Map or local_spec_id for a local spec, plus public_route_slug. CLI identity overrides should stay diagnostic-only."
1359
+ );
1360
+ }
1361
+
1362
+ // Identity cross-check for the root key of every built-output gate. The c1
1363
+ // negative control (2026-08-02) showed that corrupting campaign.public_route_slug
1364
+ // (classically: to the Map ID) raises no blocker — every built_output.* check
1365
+ // roots at _site/<public_route_slug>/, finds nothing to check, and silently
1366
+ // stops running, so doctor output is indistinguishable from a healthy run
1367
+ // while all built-output guarantees are off. The packet slug must match the
1368
+ // spec's declared public route slug and must not be the Map ID.
1369
+ export function validateRouteSlugIdentity(spec, packet, errors, ready) {
1370
+ const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1371
+ if (!packetSlug) return;
1372
+
1373
+ const mapId = optionalString(spec?.spec_identity?.map_id)
1374
+ || optionalString(spec?.map_id)
1375
+ || optionalString(packet?.spec?.map_id);
1376
+ const specSlug = normalizePublicRouteSlug(
1377
+ optionalString(spec?.spec_identity?.public_route_slug)
1378
+ || optionalString(spec?.campaign?.slug)
1379
+ || optionalString(spec?.campaign?.id)
1380
+ );
1381
+ const specDeclaresMapIdSlug = Boolean(specSlug) && Boolean(mapId) && specSlug === mapId;
1382
+
1383
+ if (mapId && packetSlug === mapId && !specDeclaresMapIdSlug) {
1384
+ addIssue(
1385
+ errors,
1386
+ "campaign.route_slug_identity",
1387
+ `Packet campaign.public_route_slug "${packetSlug}" equals the Map ID. The Map ID is spec identity (spec_identity.map_id), not a route; built output lives at _site/<public_route_slug>/, so this slug disarms every built-output check. Set the packet slug to the campaign's public route slug${specSlug ? ` ("${specSlug}")` : ""} or re-run prepare-build from the spec.`
1388
+ );
1389
+ return;
1390
+ }
1391
+
1392
+ if (specSlug && packetSlug !== specSlug) {
1393
+ addIssue(
1394
+ errors,
1395
+ "campaign.route_slug_identity",
1396
+ `Packet campaign.public_route_slug "${packetSlug}" does not match the CampaignSpec declared public route slug "${specSlug}". Built-output checks root at _site/<public_route_slug>/, so a wrong slug silently disarms them. Correct the packet slug or re-run prepare-build from the spec.`
1397
+ );
1398
+ return;
1399
+ }
1400
+
1401
+ if (specSlug) {
1402
+ // Kilo review (PR #174): only claim "not the Map ID" when that is true —
1403
+ // a spec may (confusingly, but authoritatively) declare its route slug
1404
+ // equal to its Map ID, and the packet matching it is not an error here.
1405
+ ready.push(
1406
+ specDeclaresMapIdSlug
1407
+ ? `Packet public_route_slug "${packetSlug}" matches CampaignSpec identity (note: the spec declares its public route slug equal to its Map ID)`
1408
+ : `Packet public_route_slug "${packetSlug}" matches CampaignSpec identity and is not the Map ID`
1409
+ );
1410
+ }
1411
+ }
1412
+
1413
+ // Loud failure for the state the c1 control exposed: _site/ was built but
1414
+ // _site/<public_route_slug>/ is absent, so the whole built_output.* family is
1415
+ // about to silently skip — the exact shape a slug corrupted to the Map ID
1416
+ // produces. When _site/ does not exist at all (pre-build, or gate tests that
1417
+ // never run page-kit) this stays quiet regardless of assembly status —
1418
+ // sdk_hints.meta_tags already reports that deferral, and "assembly recorded
1419
+ // complete without any built output" is a pre-existing contract other
1420
+ // lifecycle checks exercise.
1421
+ export function validateBuiltOutputTargetRoot(packet, errors, warnings, ready, derived = {}, buildState = {}) {
1422
+ const targetRepo = derived.target_repo;
1423
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1424
+ if (!targetRepo || !publicRouteSlug) return;
1425
+
1426
+ // isDirectory, not bare existence: a stray regular file at _site/<slug>
1427
+ // must not read as a found root — downstream built_output.* checks would
1428
+ // still skip on it (Kilo review, PR #174).
1429
+ const siteRoot = join(targetRepo, "_site", publicRouteSlug);
1430
+ if (existsSync(siteRoot) && statSync(siteRoot).isDirectory()) {
1431
+ ready.push(`Built output root found at _site/${publicRouteSlug}/`);
1432
+ return;
1433
+ }
1434
+
1435
+ const siteDir = join(targetRepo, "_site");
1436
+ if (!existsSync(siteDir) || !statSync(siteDir).isDirectory()) return;
1437
+
1438
+ const assemblyComplete = isStageComplete(buildState.report, "assembly");
1439
+ const builtRoots = readdirSync(siteDir, { withFileTypes: true })
1440
+ .filter((entry) => entry.isDirectory())
1441
+ .map((entry) => entry.name)
1442
+ .sort();
1443
+ addIssue(
1444
+ assemblyComplete ? errors : warnings,
1445
+ "built_output.target_root",
1446
+ `Target route not found in _site: expected built output at _site/${publicRouteSlug}/ but the slug root does not exist (built root(s): ${builtRoots.length ? builtRoots.join(", ") : "none"}). `
1447
+ + `Every built_output.* check roots at _site/<public_route_slug>/ and skips when it is missing, so built-output verification cannot run until the route exists or campaign.public_route_slug is corrected.`,
1448
+ { expected_root: `_site/${publicRouteSlug}/`, built_roots: builtRoots, assembly_complete: assemblyComplete }
1449
+ );
1450
+ }
1451
+
1452
+ export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived = {}, buildState = {}) {
1453
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1454
+ if (!publicRouteSlug) return;
1455
+ const routeRoot = campaignRouteRoot(packet);
1456
+
1457
+ // R2-B2: the spec only carries unrooted routing-meta *hints*; the
1458
+ // page-kit build roots them when it renders _site/<slug>/. Once that built
1459
+ // output exists and assembly is complete, validateBuiltSdkMetaTags checks the
1460
+ // actual rendered values authoritatively. Re-warning on the spec literal here
1461
+ // would just repeat a "fix before QA" message the build already satisfied
1462
+ // (browser QA later proved the deployed output correct), so defer to the
1463
+ // built-output check instead of double-flagging.
1464
+ const targetRepo = derived.target_repo;
1465
+ const siteRoot = targetRepo ? join(targetRepo, "_site", publicRouteSlug) : null;
1466
+ if (isStageComplete(buildState.report, "assembly") && siteRoot && existsSync(siteRoot)) {
1467
+ ready.push(`CampaignSpec routing meta deferred to built-output verification (_site/${publicRouteSlug}/).`);
1468
+ return;
1469
+ }
1470
+
1471
+ const hits = [];
1472
+ for (const page of activeSpecPages(spec)) {
1473
+ const metaTags = page.sdk_hints?.meta_tags;
1474
+ if (!isObject(metaTags)) continue;
1475
+
1476
+ for (const tag of SDK_ROUTING_META_TAGS) {
1477
+ const value = metaTags[tag];
1478
+ if (!isNonEmptyString(value)) continue;
1479
+ const route = value.trim();
1480
+ if (isRuntimeRootedRoutingMeta(route, publicRouteSlug, routeRoot)) continue;
1481
+ hits.push(`${page.id}:${tag}=${route}`);
1482
+ }
1483
+ }
1484
+
1485
+ if (!hits.length) {
1486
+ ready.push(`CampaignSpec SDK routing meta tags are runtime-rooted for ${routeRoot}`);
1487
+ return;
1488
+ }
1489
+
1490
+ const sample = hits.slice(0, 5).join("; ");
1491
+ const more = hits.length > 5 ? `; plus ${hits.length - 5} more` : "";
1492
+ addIssue(
1493
+ warnings,
1494
+ "routing_meta.runtime_root",
1495
+ `CampaignSpec sdk_hints.meta_tags routing values must render as campaign-rooted paths before QA. Expected values like "${routeRoot}upsell/" for ${SDK_ROUTING_META_TAGS.join(", ")}; found ${sample}${more}.`
1496
+ );
1497
+ }
1498
+
1499
+ // Pages declared out of source scope (#238/#239: a manifest skip_reason entry
1500
+ // or CampaignSpec build_scope "partial") assemble from the template family and
1501
+ // are not built by a partial-scope build, so their absence from _site/ is the
1502
+ // declared state — not a missing-page failure. In-scope pages keep the full
1503
+ // post-assembly escalation.
1504
+ //
1505
+ // The authority is stages.prepare_build.declared_out_of_scope on the recorded
1506
+ // assembly report — NOT derived.scope.out_of_scope_pages, which also contains
1507
+ // blocked pages carrying the auto-generated skip_reason remedy text (a packet
1508
+ // blocked on MISSING_SOURCE_PAGE has skip mappings too, and those pages must
1509
+ // keep failing loud). With no report recorded the set is empty, so every page
1510
+ // stays in scope — the safe default.
1511
+ function declaredOutOfScopePageIds(buildState) {
1512
+ const declared = buildState?.report?.stages?.prepare_build?.declared_out_of_scope;
1513
+ if (!Array.isArray(declared)) return new Set();
1514
+ return new Set(declared.map((skip) => skip?.page_id).filter(isNonEmptyString));
1515
+ }
1516
+
1517
+ export function validateBuiltSdkMetaTags(spec, packet, errors, warnings, ready, derived, buildState = {}) {
1518
+ const expectedPages = activeSpecPages(spec)
1519
+ .map((page) => ({
1520
+ page,
1521
+ metaTags: page.sdk_hints?.meta_tags,
1522
+ }))
1523
+ .filter(({ metaTags }) => isObject(metaTags) && Object.keys(metaTags).length > 0);
1524
+ if (expectedPages.length === 0) return;
1525
+
1526
+ // A spec key the SDK does not read (sdk-meta-tags.mjs, the list QA reads
1527
+ // too) is a stale Map page hint, not a tag the build owes: it is never
1528
+ // required and never `missing`, whether or not it rendered. One advisory
1529
+ // per page names the keys and the reason, so the fix is an edit to the
1530
+ // Map, not the build; it does not wait for built output.
1531
+ for (const { page, metaTags } of expectedPages) {
1532
+ const ignoredTags = Object.keys(metaTags).filter((name) => isSdkIgnoredMetaTag(name));
1533
+ if (ignoredTags.length === 0) continue;
1534
+ addIssue(
1535
+ warnings,
1536
+ "sdk_hints.meta_tags.ignored_by_sdk",
1537
+ `CampaignSpec page "${page.id}" lists SDK meta tag(s) the Campaign Cart SDK does not read; remove from the Map's page hints: ${describeSdkIgnoredMetaTags(ignoredTags)}.`,
1538
+ { page_id: page.id, tags: ignoredTags }
1539
+ );
1540
+ }
1541
+
1542
+ const allExpectedTags = [...new Set(expectedPages.flatMap(({ metaTags }) => Object.keys(metaTags)))]
1543
+ .filter((name) => !isSdkIgnoredMetaTag(name))
1544
+ .sort();
1545
+ const targetRepo = derived.target_repo;
1546
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1547
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1548
+ const assemblyComplete = isStageComplete(buildState.report, "assembly");
1549
+
1550
+ if (!siteRoot || !existsSync(siteRoot)) {
1551
+ if (allExpectedTags.length === 0) return;
1552
+ addIssue(
1553
+ warnings,
1554
+ "sdk_hints.meta_tags",
1555
+ `CampaignSpec expects SDK meta tags (${allExpectedTags.join(", ")}). Doctor cannot verify rendered output until page-kit build writes _site/${publicRouteSlug || "<slug>"}/.`
1556
+ );
1557
+ return;
1558
+ }
1559
+
1560
+ const outOfScopePageIds = declaredOutOfScopePageIds(buildState);
1561
+ const skippedOutOfScope = [];
1562
+ let checked = 0;
1563
+ for (const { page, metaTags } of expectedPages) {
1564
+ const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
1565
+ if (!builtPath || !existsSync(builtPath)) {
1566
+ // A declared out-of-scope page has no built HTML by declaration; a page
1567
+ // that IS built despite the declaration still gets its meta verified
1568
+ // below, so the skip covers exactly the declared absence.
1569
+ if (outOfScopePageIds.has(page.id)) {
1570
+ skippedOutOfScope.push(page.id);
1571
+ continue;
1572
+ }
1573
+ const issue = {
1574
+ code: "built_output.page_missing",
1575
+ message: `Built HTML is missing for CampaignSpec page "${page.id}" at ${builtPath ? relFromDir(targetRepo, builtPath) : "_site/<slug>/..."}.`,
1576
+ detail: { page_id: page.id },
1577
+ };
1578
+ (assemblyComplete ? errors : warnings).push(issue);
1579
+ continue;
1580
+ }
1581
+
1582
+ checked += 1;
1583
+ const content = readFileSync(builtPath, "utf8");
1584
+
1585
+ for (const [name, expectedValue] of Object.entries(metaTags)) {
1586
+ if (isSdkIgnoredMetaTag(name)) continue;
1587
+ const actualValue = extractMetaContent(content, name);
1588
+ if (!isNonEmptyString(actualValue)) {
1589
+ addIssue(
1590
+ assemblyComplete ? errors : warnings,
1591
+ "sdk_hints.meta_tags.missing",
1592
+ `Built page "${page.id}" is missing SDK meta tag "${name}" expected from CampaignSpec.`,
1593
+ { page_id: page.id, file: relFromDir(targetRepo, builtPath) }
1594
+ );
1595
+ continue;
1596
+ }
1597
+ if (SDK_ROUTING_META_TAGS.includes(name) && isNonEmptyString(expectedValue)) {
1598
+ const expectedRoute = runtimeRouteForMetaValue(expectedValue, publicRouteSlug, campaignRouteRoot(packet));
1599
+ if (expectedRoute && actualValue.trim() !== expectedRoute) {
1600
+ addIssue(
1601
+ assemblyComplete ? errors : warnings,
1602
+ "sdk_hints.meta_tags.route_mismatch",
1603
+ `Built page "${page.id}" emits ${name}="${actualValue}", expected "${expectedRoute}".`,
1604
+ { page_id: page.id, file: relFromDir(targetRepo, builtPath) }
1605
+ );
1606
+ }
1607
+ }
1608
+ }
1609
+ }
1610
+
1611
+ if (checked > 0) ready.push(`Built SDK meta tags checked in _site/${publicRouteSlug}/ for ${checked} page(s)`);
1612
+ if (skippedOutOfScope.length > 0) {
1613
+ ready.push(`Built SDK meta verification skipped for ${skippedOutOfScope.length} declared out-of-scope page(s): ${skippedOutOfScope.join(", ")}`);
1614
+ }
1615
+ }
1616
+
1617
+ // Build output fingerprint. Every stage after build binds its evidence to
1618
+ // stages.assembly.build_fingerprint by string equality, so the value has to
1619
+ // be one anyone can recompute from the output that is actually on disk.
1620
+ // Doctor recomputes it from _site/<slug>/ on every run and publishes the
1621
+ // current value at derived.build_output_fingerprint (the value build records,
1622
+ // and the value an operator checks by hand), then compares it with what the
1623
+ // report recorded: equal = pass, different = the output changed since build
1624
+ // recorded it (a rebuild, a toolkit upgrade, a hand edit), absent = build has
1625
+ // not recorded it yet. Stale is blocking once assembly is complete because
1626
+ // every polish/QA artifact bound to the old value is then evidence about a
1627
+ // build that no longer exists.
1628
+ export function validateBuildOutputFingerprint(packet, errors, warnings, ready, derived = {}, buildState = {}) {
1629
+ const targetRepo = derived.target_repo;
1630
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1631
+ if (!targetRepo || !publicRouteSlug) return;
1632
+ const siteRoot = join(targetRepo, "_site", publicRouteSlug);
1633
+ if (!existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) return;
1634
+
1635
+ const current = computeBuildFingerprint(siteRoot);
1636
+ // The root was a directory a moment ago; if it is not one now (removed
1637
+ // between the check and the walk) there is no output to fingerprint and no
1638
+ // verdict to give, the same skip as a missing root.
1639
+ if (!current.ok) return;
1640
+ const recorded = currentBuildFingerprint(buildState.report);
1641
+ const assemblyComplete = isStageComplete(buildState.report, "assembly");
1642
+ const status = !recorded ? "missing" : recorded === current.fingerprint ? "pass" : "stale";
1643
+ derived.build_output_fingerprint = {
1644
+ root: `_site/${publicRouteSlug}/`,
1645
+ algorithm: current.algorithm,
1646
+ value: current.fingerprint,
1647
+ file_count: current.file_count,
1648
+ excluded: current.excluded,
1649
+ recorded: recorded || null,
1650
+ status,
1651
+ };
1652
+ if (status === "pass") {
1653
+ ready.push(`Build output fingerprint matches stages.assembly.build_fingerprint (${current.file_count} file(s) under _site/${publicRouteSlug}/)`);
1654
+ return;
1655
+ }
1656
+ if (status === "missing") {
1657
+ addIssue(
1658
+ warnings,
1659
+ "built_output.fingerprint_missing",
1660
+ `Build has not recorded stages.assembly.build_fingerprint. The current output fingerprint of _site/${publicRouteSlug}/ is ${current.fingerprint} (${current.file_count} file(s); doctor --json derived.build_output_fingerprint.value); record it on stages.assembly.build_fingerprint after page-kit build.`,
1661
+ { root: `_site/${publicRouteSlug}/`, current: current.fingerprint, file_count: current.file_count, assembly_complete: assemblyComplete }
1662
+ );
1663
+ return;
1664
+ }
1665
+ addIssue(
1666
+ assemblyComplete ? errors : warnings,
1667
+ "built_output.fingerprint_stale",
1668
+ `Built output under _site/${publicRouteSlug}/ no longer matches stages.assembly.build_fingerprint (recorded ${recorded}, current ${current.fingerprint}, ${current.file_count} file(s)). `
1669
+ + "The output changed after build recorded it; re-run build (page-kit build, then record the current fingerprint) before polish or QA evidence can bind to it.",
1670
+ { root: `_site/${publicRouteSlug}/`, recorded, current: current.fingerprint, file_count: current.file_count, assembly_complete: assemblyComplete }
1671
+ );
1672
+ }
1673
+
1674
+ function validateBuiltOutputPages(spec, packet, errors, warnings, ready, derived, buildState = {}) {
1675
+ const pages = activeSpecPages(spec);
1676
+ if (pages.length === 0) return;
1677
+
1678
+ const targetRepo = derived.target_repo;
1679
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1680
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1681
+ if (!siteRoot || !existsSync(siteRoot)) return;
1682
+
1683
+ const assemblyComplete = isStageComplete(buildState.report, "assembly");
1684
+ let checked = 0;
1685
+ for (const page of pages) {
1686
+ const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
1687
+ if (!builtPath || !existsSync(builtPath)) continue;
1688
+
1689
+ checked += 1;
1690
+ validateBuiltHtmlStructure(
1691
+ readFileSync(builtPath, "utf8"),
1692
+ builtPath,
1693
+ targetRepo,
1694
+ page,
1695
+ spec,
1696
+ publicRouteSlug,
1697
+ errors,
1698
+ warnings,
1699
+ assemblyComplete
1700
+ );
1701
+ }
1702
+
1703
+ if (checked > 0) ready.push(`Built HTML structure and commerce refs checked in _site/${publicRouteSlug}/ for ${checked} page(s)`);
1704
+ }
1705
+
1706
+ // Upsell selector scope (#270). Every doctor invocation, deliberately — not
1707
+ // only the one that follows assembly. The real-world instance was introduced by
1708
+ // a LATER human review round that layered a correctly-scoped selector on top of
1709
+ // an existing unscoped one and left both in place, so a gate that fired only at
1710
+ // first assembly would have watched the defect arrive and said nothing. It also
1711
+ // stays blocking regardless of stage status, unlike the per-page structure
1712
+ // checks that soften to warnings before assembly completes: built markup that
1713
+ // charges a shopper is not a work-in-progress state that becomes true later.
1714
+ function validateUpsellSelectorScope(spec, packet, errors, warnings, ready, derived, buildState = {}) {
1715
+ const targetRepo = derived.target_repo;
1716
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1717
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1718
+ const pages = [];
1719
+ if (siteRoot && existsSync(siteRoot)) {
1720
+ // Enumerate from the FILESYSTEM, not from the CampaignSpec, so both doctor
1721
+ // paths scan the same set. Walking active spec pages would miss any built
1722
+ // page the spec does not declare — the ordinary state for a page-kit
1723
+ // `campaign-build` campaign, and the state route drift produces — which
1724
+ // would leave the packet path blind to exactly the pages `doctor --built`
1725
+ // catches. A gate whose coverage depends on which flag you passed is not
1726
+ // the gate this was written to be.
1727
+ const declaredByPath = new Map();
1728
+ for (const page of activeSpecPages(spec)) {
1729
+ const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
1730
+ if (builtPath) declaredByPath.set(resolve(builtPath), page);
1731
+ }
1732
+ const scope = resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug });
1733
+ for (const builtPage of (scope.ok ? scope.pages : [])) {
1734
+ const declared = declaredByPath.get(resolve(builtPage.built_path)) || null;
1735
+ // Declared type wins only when it is the post-purchase answer; otherwise
1736
+ // the route-inferred type stands. Same fail-closed rule the evaluator
1737
+ // applies between a declared type and the page's own next-page-type meta:
1738
+ // any signal saying "post-purchase" is enough.
1739
+ const declaredType = declared?.type || null;
1740
+ pages.push({
1741
+ page_id: declared?.id || builtPage.page_id,
1742
+ page_type: isPostPurchasePageType(declaredType) ? declaredType : builtPage.page_type,
1743
+ file: relFromDir(targetRepo, builtPage.built_path),
1744
+ content: readFileSync(builtPage.built_path, "utf8"),
1745
+ });
1746
+ }
1747
+ }
1748
+ recordUpsellSelectorScopeGate({
1749
+ subject: {
1750
+ public_route_slug: publicRouteSlug || null,
1751
+ site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
1752
+ },
1753
+ pages,
1754
+ waivers: buildState?.report?.waivers,
1755
+ errors,
1756
+ warnings,
1757
+ ready,
1758
+ derived,
1759
+ });
1760
+ }
1761
+
1762
+ // Shared by both doctor entry points: the packet path above and the
1763
+ // built-site-only path (`doctor --built`), which is how a page-kit
1764
+ // `campaign-build` campaign with no hand-authored packet gets inspected — and
1765
+ // therefore the invocation that most needs this gate.
1766
+ function recordUpsellSelectorScopeGate({ subject, pages, waivers, errors, warnings, ready, derived }) {
1767
+ const gate = evaluateUpsellSelectorScope({ subject, pages, waivers });
1768
+ if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
1769
+
1770
+ const inertCounts = Object.fromEntries(
1771
+ ["stale", "foreign", "malformed", "expired"].map((kind) => [kind, gate.waiver_assessment?.inert_counts?.[kind] || 0]),
1772
+ );
1773
+ const inertTotal = Object.values(inertCounts).reduce((sum, count) => sum + count, 0);
1774
+ if (inertTotal > 0) {
1775
+ addIssue(
1776
+ warnings,
1777
+ "built_output.upsell_selector_scope.waiver_inert",
1778
+ `Upsell selector-scope waiver history contains ${inertTotal} inert record(s); stale, foreign, malformed, and expired decisions never satisfy the current checkpoint.`,
1779
+ { counts: inertCounts },
1780
+ );
1781
+ }
1782
+
1783
+ if (gate.status === "blocked") {
1784
+ addIssue(errors, gate.code, gate.reason, { checkpoint_gate: gate });
1785
+ return gate;
1786
+ }
1787
+ if (gate.status === "waived") {
1788
+ addIssue(warnings, gate.code, `${gate.reason} Waived by ${gate.waiver.waived_by}: ${gate.waiver.reason}`, { checkpoint_gate: gate });
1789
+ ready.push(`Upsell selector-scope checkpoint accepted under named-human exception (${gate.waiver.waived_by}).`);
1790
+ return gate;
1791
+ }
1792
+ if (gate.status === "not_applicable") {
1793
+ ready.push("Upsell selector-scope checkpoint not applicable: no built upsell/downsell page to scan yet.");
1794
+ return gate;
1795
+ }
1796
+ if (gate.warned.length) {
1797
+ addIssue(warnings, gate.code, gate.reason, { checkpoint_gate: gate });
1798
+ // A ready line beside the warning, so "the gate ran and found no cart-writing
1799
+ // selector" and "the gate did not run" are never the same JSON shape.
1800
+ ready.push(`Upsell selector-scope scan found 0 cart-writing selector(s) on ${gate.pages_scanned} built post-purchase page(s), with ${gate.warned.length} select-mode note(s)`);
1801
+ return gate;
1802
+ }
1803
+ ready.push(`Every bundle selector on ${gate.pages_scanned} built post-purchase page(s) is scoped away from the live cart (${gate.selectors_scanned} selector(s) scanned)`);
1804
+ return gate;
1805
+ }
1806
+
1807
+ // Cross-page campaign identity (#301). Every doctor invocation, like the
1808
+ // selector-scope gate above and for the same reason: the borrowed page that
1809
+ // carries another funnel's key or tag arrives in a later edit round as often
1810
+ // as at first assembly. Enumerates from the filesystem so both doctor paths
1811
+ // scan the same pages, and stays blocking regardless of stage status.
1812
+ function validateCampaignIdentity(packet, errors, ready, derived) {
1813
+ const targetRepo = derived.target_repo;
1814
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1815
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1816
+ const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
1817
+ recordCampaignIdentityGate({
1818
+ subject: {
1819
+ public_route_slug: publicRouteSlug || null,
1820
+ site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
1821
+ },
1822
+ pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [],
1823
+ errors,
1824
+ ready,
1825
+ derived,
1826
+ });
1827
+ }
1828
+
1829
+ // The identity evaluator is pure, so the filesystem work happens here: each
1830
+ // built page's HTML plus the LOCAL scripts it loads. The API key of every
1831
+ // certified family lives in a shared config.js the pages reference by
1832
+ // `<script src>`, not in the page itself, so a page-only scan would see no
1833
+ // key at all and pass a borrowed page whose config.js names another store.
1834
+ // Absolute srcs resolve against the site root first (page-kit emits
1835
+ // `/<slug>/config.js`), then the campaign directory (a root-served campaign
1836
+ // emits `/config.js`); relative srcs resolve against the page. Remote and
1837
+ // missing scripts contribute nothing.
1838
+ function collectBuiltPageIdentityInputs(scope, targetRepo) {
1839
+ const scriptCache = new Map();
1840
+ const readScript = (path) => {
1841
+ if (!scriptCache.has(path)) {
1842
+ let content = null;
1843
+ try {
1844
+ if (existsSync(path) && statSync(path).isFile()) content = readFileSync(path, "utf8");
1845
+ } catch {
1846
+ content = null;
1847
+ }
1848
+ scriptCache.set(path, content);
1849
+ }
1850
+ return scriptCache.get(path);
1851
+ };
1852
+ const resolveLocalScript = (src, builtPath) => {
1853
+ const raw = String(src || "").trim();
1854
+ if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
1855
+ const clean = raw.replace(/[?#].*$/, "");
1856
+ if (!clean) return null;
1857
+ if (clean.startsWith("/")) {
1858
+ const rel = clean.replace(/^\/+/, "");
1859
+ const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
1860
+ return candidates.find((candidate) => existsSync(candidate)) || null;
1861
+ }
1862
+ return resolve(dirname(builtPath), clean);
1863
+ };
1864
+ return scope.pages.map((page) => {
1865
+ const content = readFileSync(page.built_path, "utf8");
1866
+ const scripts = [];
1867
+ for (const src of externalScriptSources(content)) {
1868
+ const path = resolveLocalScript(src, page.built_path);
1869
+ const scriptContent = path ? readScript(path) : null;
1870
+ if (scriptContent == null) continue;
1871
+ scripts.push({ src, file: relFromDir(targetRepo, path), content: scriptContent });
1872
+ }
1873
+ return {
1874
+ page_id: page.page_id,
1875
+ route: page.route,
1876
+ file: relFromDir(targetRepo, page.built_path),
1877
+ content,
1878
+ scripts,
1879
+ };
1880
+ });
1881
+ }
1882
+
1883
+ function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
1884
+ const gate = evaluateCampaignIdentity({ subject, pages });
1885
+ if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
1886
+
1887
+ if (gate.status === "blocked") {
1888
+ // One error per finding, each under its own code, so a report reader can
1889
+ // tell key drift from tag drift without parsing prose; every error carries
1890
+ // the whole gate so the JSON shape matches the other checkpoint gates.
1891
+ for (const finding of gate.findings) {
1892
+ addIssue(errors, finding.code, finding.message, { finding, checkpoint_gate: gate });
1893
+ }
1894
+ return gate;
1895
+ }
1896
+ if (gate.status === "not_applicable") {
1897
+ ready.push("Campaign identity checkpoint not applicable: no built page to scan yet.");
1898
+ return gate;
1899
+ }
1900
+ const skipped = gate.pages_skipped.length ? `; skipped ${gate.pages_skipped.length} parked page(s): ${gate.pages_skipped.join(", ")}` : "";
1901
+ ready.push(`All ${gate.pages_scanned} built page(s) agree on campaign identity (next-funnel ${gate.identity.funnel ? `"${gate.identity.funnel}"` : "not declared"}, API key ${gate.identity.api_key ? "consistent" : "not declared"})${skipped}`);
1902
+ return gate;
1903
+ }
1904
+
1905
+ // Static SDK markup checks (#303). Every doctor invocation, both entry points,
1906
+ // filesystem enumeration, blocking regardless of stage status — the same
1907
+ // contract as the two gates above, for the same reasons.
1908
+ function validateSdkMarkup(packet, errors, warnings, ready, derived) {
1909
+ const targetRepo = derived.target_repo;
1910
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1911
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1912
+ const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
1913
+ recordSdkMarkupGate({
1914
+ subject: {
1915
+ public_route_slug: publicRouteSlug || null,
1916
+ site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
1917
+ },
1918
+ pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [],
1919
+ errors,
1920
+ warnings,
1921
+ ready,
1922
+ derived,
1923
+ });
1924
+ }
1925
+
1926
+ // Campaign-owned script syntax (#480). Every doctor invocation, both entry
1927
+ // points, filesystem enumeration, blocking regardless of stage status — the
1928
+ // same contract as the gates above: a script that throws a SyntaxError on
1929
+ // load is not a work-in-progress state that becomes true later.
1930
+ function validateBuiltScriptSyntax(packet, errors, warnings, ready, derived) {
1931
+ const targetRepo = derived.target_repo;
1932
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1933
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1934
+ const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
1935
+ recordScriptSyntaxGate({
1936
+ subject: {
1937
+ public_route_slug: publicRouteSlug || null,
1938
+ site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
1939
+ },
1940
+ inputs: scope?.ok ? collectBuiltScriptSyntaxInputs(scope, targetRepo) : {},
1941
+ errors,
1942
+ warnings,
1943
+ ready,
1944
+ derived,
1945
+ });
1946
+ }
1947
+
1948
+ function recordScriptSyntaxGate({ subject, inputs, errors, warnings, ready, derived }) {
1949
+ const gate = evaluateBuiltScriptSyntax({ subject, ...inputs });
1950
+ if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
1951
+ // A referenced local script missing from the built output is a warning
1952
+ // (#502). One terminal disposition per gate, as with SDK markup: while a
1953
+ // parse failure blocks, the missing scripts stay on gate.warned[].
1954
+ if (gate.status !== "blocked" && Array.isArray(warnings)) {
1955
+ for (const item of gate.warned) addIssue(warnings, item.code, item.message, { finding: item, checkpoint_gate: gate });
1956
+ }
1957
+ if (gate.status === "blocked") {
1958
+ // One error per file, each naming the file, line and column.
1959
+ for (const finding of gate.findings) {
1960
+ addIssue(errors, finding.code, finding.message, { finding, checkpoint_gate: gate });
1961
+ }
1962
+ return gate;
1963
+ }
1964
+ if (gate.status === "not_applicable") {
1965
+ ready.push(`Script syntax checkpoint not applicable: ${gate.reason}`);
1966
+ return gate;
1967
+ }
1968
+ ready.push(`All ${gate.scripts_scanned} campaign-owned script(s) loaded by built pages parse`);
1969
+ return gate;
1970
+ }
1971
+
1972
+ function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
1973
+ const gate = evaluateSdkMarkup({ subject, pages });
1974
+ if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
1975
+
1976
+ if (gate.status === "not_applicable") {
1977
+ ready.push("SDK markup checkpoint not applicable: no built page to scan yet.");
1978
+ return gate;
1979
+ }
1980
+ // Blockers and advisories each carry their own code (the kit's lint code,
1981
+ // lower-cased, under the gate id) and the finding, so a reader can filter
1982
+ // by shape without parsing prose.
1983
+ for (const item of gate.findings) addIssue(errors, item.code, item.message, { finding: item, checkpoint_gate: gate });
1984
+ // One terminal disposition per gate: while blockers stand, the advisories
1985
+ // stay on gate.warned[] (visible in --json) and are surfaced as warnings
1986
+ // only once the gate passes, so a blocked gate does not also read as a
1987
+ // warned one.
1988
+ if (gate.status !== "blocked") {
1989
+ for (const item of gate.warned) addIssue(warnings, item.code, item.message, { finding: item, checkpoint_gate: gate });
1990
+ }
1991
+ // Unknown data-next-* names are information, not a warning: the certified
1992
+ // templates carry a handful of their own data-next-* hooks the SDK never
1993
+ // reads, and a warning that fires on every canonical build is noise that
1994
+ // trains readers to skip the channel. The list stays on the gate and in
1995
+ // one ready line, where an invented attribute is still one grep away.
1996
+ if (gate.unknown_attributes.length) {
1997
+ ready.push(`SDK markup: ${gate.unknown_attributes.length} data-next-* name(s) not in the Campaign Cart ${gate.sdk_attribute_index_version} attribute index (advisory; the SDK does not read them): ${gate.unknown_attributes.map((item) => item.name).join(", ")}`);
1998
+ }
1999
+ if (gate.status === "blocked") return gate;
2000
+ ready.push(`SDK markup checks passed on ${gate.pages_scanned} built page(s)${gate.warned.length ? ` with ${gate.warned.length} advisory finding(s)` : ""}`);
2001
+ return gate;
2002
+ }
2003
+
2004
+ // Route drift: a CampaignSpec page whose declared route has no built page at
2005
+ // that path. page-kit derives the public route from the source FILENAME, so a
2006
+ // file named presell-running.html builds at /presell-running/ even if the spec
2007
+ // page_url says "presell/". QA resolves page URLs from the spec page_url, so
2008
+ // this drift makes QA fetch phantom URLs (404s) and misreport pages as down —
2009
+ // exactly what happened on the Shield QA (presell/landing). validateBuiltOutputPages
2010
+ // silently skips missing pages, so this surfaces the drift and the actual
2011
+ // built routes for reconciliation. See the Shield build QA (#1).
2012
+ export function validateBuiltRouteDrift(spec, packet, errors, warnings, ready, derived, buildState = {}) {
2013
+ const pages = activeSpecPages(spec);
2014
+ if (pages.length === 0) return;
2015
+ const targetRepo = derived.target_repo;
2016
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2017
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
2018
+ if (!siteRoot || !existsSync(siteRoot)) return;
2019
+
2020
+ const claimed = new Set();
2021
+ const drifted = [];
2022
+ const unverifiable = [];
2023
+ const skippedOutOfScope = [];
2024
+ const outOfScopePageIds = declaredOutOfScopePageIds(buildState);
2025
+ // Served-route display honors route_root: a root-served campaign's pages
2026
+ // are reached at /<route>/, not /<slug>/<route>/, even though the built
2027
+ // files still live under _site/<slug>/.
2028
+ const routeRoot = campaignRouteRoot(packet);
2029
+ const rootSegments = routeRoot === "/" ? [] : [publicRouteSlug];
2030
+ for (const page of pages) {
2031
+ const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
2032
+ if (builtPath && existsSync(builtPath)) {
2033
+ claimed.add(resolve(builtPath));
2034
+ continue;
2035
+ }
2036
+ // Declared out-of-scope pages (#238) are not built by a partial-scope
2037
+ // build; their absence is the declared state, not route drift. A built
2038
+ // page is still claimed above regardless of declaration.
2039
+ if (outOfScopePageIds.has(page.id)) {
2040
+ skippedOutOfScope.push(page.id);
2041
+ continue;
2042
+ }
2043
+ if (!builtPath) {
2044
+ // No page_url / source permalink to resolve a route from: doctor cannot
2045
+ // determine the expected route, so this is "unverifiable", not drift.
2046
+ unverifiable.push({ page_id: page.id, type: page.type || "page", reason: "no page_url / source permalink to resolve an expected route" });
2047
+ continue;
2048
+ }
2049
+ const segments = [...rootSegments, ...relFromDir(siteRoot, dirname(builtPath)).split("/")].filter((segment) => segment && segment !== ".");
2050
+ drifted.push({
2051
+ page_id: page.id,
2052
+ type: page.type || "page",
2053
+ expected_route: `/${segments.join("/")}/`,
2054
+ });
2055
+ }
2056
+
2057
+ // Denominator is the in-scope page count when a declaration is present:
2058
+ // "2/9 verified, 7 declared out of scope" reads as seven attempted-but-
2059
+ // unverified pages, while "2/2 in-scope verified" states what was actually
2060
+ // checked. Full-scope campaigns keep the original phrasing untouched.
2061
+ const inScopeCount = pages.length - skippedOutOfScope.length;
2062
+ const verifiedNote = (skippedOutOfScope.length
2063
+ ? `${claimed.size}/${inScopeCount} in-scope verified, ${skippedOutOfScope.length} declared out of scope`
2064
+ : `${claimed.size}/${pages.length} verified`)
2065
+ + `${unverifiable.length ? `, ${unverifiable.length} unverifiable` : ""}`;
2066
+ if (drifted.length === 0) {
2067
+ ready.push(`Built routes match CampaignSpec page routes (${verifiedNote})`);
2068
+ if (unverifiable.length) {
2069
+ addIssue(
2070
+ warnings,
2071
+ "built_output.route_unverifiable",
2072
+ `Doctor could not determine the expected route for ${unverifiable.length} CampaignSpec page(s) (no page_url / source permalink): ${unverifiable.map((u) => `"${u.page_id}" (${u.type})`).join(", ")}.`,
2073
+ { unverifiable },
2074
+ );
2075
+ }
2076
+ return;
2077
+ }
2078
+
2079
+ const scope = resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug });
2080
+ const servedPrefix = routeRoot === "/" ? "/" : `/${publicRouteSlug}/`;
2081
+ const unmatched = (scope.ok ? scope.pages : [])
2082
+ .filter((builtPage) => !claimed.has(resolve(builtPage.built_path)))
2083
+ .map((builtPage) => `${servedPrefix}${builtPage.route ? `${builtPage.route}/` : ""}`.replace(/\/{2,}/g, "/"));
2084
+ const assemblyComplete = isStageComplete(buildState.report, "assembly");
2085
+ addIssue(
2086
+ assemblyComplete ? errors : warnings,
2087
+ "built_output.route_drift",
2088
+ `CampaignSpec page(s) have no built page at their declared route: ${drifted.map((d) => `"${d.page_id}" (${d.type}) → ${d.expected_route}`).join("; ")}. `
2089
+ + (unmatched.length ? `Built output has unmatched route(s): ${unmatched.join(", ")}. ` : "")
2090
+ + (unverifiable.length ? `Unverifiable (no page_url): ${unverifiable.map((u) => `"${u.page_id}"`).join(", ")}. ` : "")
2091
+ + `page-kit routes by source filename, so reconcile the spec page_url with the built route — otherwise QA (which resolves URLs from page_url) targets phantom URLs and reports live pages as 404.`,
2092
+ // Detail granularity contract: per-page issues (built_output.page_missing)
2093
+ // carry only that page's identity — a declared page never produces one, so
2094
+ // the declared list would be dead weight there. This aggregate is the
2095
+ // campaign-level route reconciliation, and its detail is the full
2096
+ // inventory; declared_out_of_scope belongs here because the ready summary
2097
+ // that otherwise reports the skips is suppressed when drift fires.
2098
+ { drifted, unmatched_built_routes: unmatched, unverifiable, verified_count: claimed.size, declared_out_of_scope: skippedOutOfScope },
2099
+ );
2100
+ }
2101
+
2102
+ function validateBuildSummary(spec, packet, errors, warnings, ready, derived, buildState = {}) {
2103
+ const targetRepo = derived.target_repo;
2104
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2105
+ const result = evaluatePageKitBuildSummary({
2106
+ targetRepo,
2107
+ publicRouteSlug,
2108
+ activePages: activeSpecPages(spec),
2109
+ assemblyComplete: isStageComplete(buildState.report, "assembly"),
2110
+ builtPathForPage: (page) => builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived),
2111
+ });
2112
+ for (const issue of result.errors) addIssue(errors, issue.code, issue.message, issue.detail ?? null);
2113
+ for (const issue of result.warnings) addIssue(warnings, issue.code, issue.message, issue.detail ?? null);
2114
+ ready.push(...result.ready);
2115
+ }
2116
+
2117
+ function validateBuiltHtmlStructure(content, builtPath, targetRepo, page, spec, publicRouteSlug, errors, warnings, assemblyComplete) {
2118
+ const issueTarget = assemblyComplete ? errors : warnings;
2119
+ const relPath = relFromDir(targetRepo, builtPath);
2120
+ if (!/<body(?:\s|>)/i.test(content) || !/<\/body>/i.test(content)) {
2121
+ addIssue(issueTarget, "built_output.body_missing", `Built page "${page.id}" does not contain a complete <body> element.`, { page_id: page.id, file: relPath });
2122
+ }
2123
+ if (!/(data-next-|window\.next|next-page-type|campaign-cart-sdk|campaign-cart)/i.test(content)) {
2124
+ addIssue(issueTarget, "built_output.runtime_missing", `Built page "${page.id}" has no obvious Campaign Cart runtime markers.`, { page_id: page.id, file: relPath });
2125
+ }
2126
+ validateBuiltPreCheckoutBootstrap(content, builtPath, targetRepo, page, issueTarget);
2127
+ validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget);
2128
+ validateBuiltStarterLogoResidue(content, builtPath, targetRepo, page, issueTarget);
2129
+ validateBuiltPageKitAssetPaths(content, builtPath, targetRepo, page, publicRouteSlug, issueTarget);
2130
+ validateBuiltScriptAssets(content, builtPath, targetRepo, page, publicRouteSlug, issueTarget);
2131
+ validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, issueTarget);
2132
+ validateBuiltAnalyticsContract(content, builtPath, targetRepo, page, spec, issueTarget);
2133
+ }
2134
+
2135
+ // Build-time enforcement of the declared analytics contract (CampaignSpec
2136
+ // `analytics` block). This is the static twin of the runtime QA correctness
2137
+ // leg: where QA confirms a content param FIRES on a live page, this confirms the
2138
+ // built page even HAS a handler for it — catching the gap before QA runs.
2139
+ //
2140
+ // Specifically the "?reviews=n with no handler" case from the Chamelo Shield
2141
+ // build: the spec (or a synthesized one) declares a content param, but the
2142
+ // built page never wired `data-next-hide="param.<name>=='n'"`, so the param
2143
+ // silently no-ops. Only fires when the spec declares `analytics.params.content`;
2144
+ // silent otherwise (the common case until specs carry an analytics block).
2145
+ export function validateBuiltAnalyticsContract(content, builtPath, targetRepo, page, spec, issueTarget) {
2146
+ const contentParams = spec?.analytics?.params?.content;
2147
+ if (!Array.isArray(contentParams) || contentParams.length === 0) return;
2148
+ const relPath = relFromDir(targetRepo, builtPath);
2149
+ for (const cp of contentParams) {
2150
+ const name = typeof cp?.name === "string" ? cp.name.trim() : "";
2151
+ if (!name) continue;
2152
+ // A content param applies to this page when `pages` is unspecified (all
2153
+ // pages) or explicitly lists this page id. An explicit empty `pages: []`
2154
+ // (applies to no page) is a spec-shape misconfiguration flagged once at
2155
+ // spec-validation time by AnalyticsContractShape, not per built page here.
2156
+ const pages = Array.isArray(cp.pages) ? cp.pages : null;
2157
+ if (pages && !pages.includes(page.id)) continue;
2158
+ // The SDK drives content-param visibility via data-next-hide/show using
2159
+ // `param.<name>` (persisted to sessionStorage). Require the reference to sit
2160
+ // inside an actual data-next-hide/show attribute — a bare `param.<name>` in
2161
+ // a script/comment/pixel is not a handler (avoids false negatives).
2162
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2163
+ const handlerPattern = new RegExp(
2164
+ `data-next-(?:hide|show)\\s*=\\s*["'][^"']*\\bparam\\.${escaped}\\b[^"']*["']`,
2165
+ "i",
2166
+ );
2167
+ if (!handlerPattern.test(content)) {
2168
+ addIssue(
2169
+ issueTarget,
2170
+ "analytics_contract.content_param_no_handler",
2171
+ `Built page "${page.id}" declares analytics content param "?${name}" but has no data-next-hide/show="param.${name}…" handler. The param will silently no-op — the Chamelo Shield "?reviews=n with no handler" gap.`,
2172
+ { page_id: page.id, file: relPath, param: name },
2173
+ );
2174
+ }
2175
+ }
2176
+ }
2177
+
2178
+ // Per-page starter-logo residue, scanned against the BUILT `_site/<slug>`
2179
+ // output. The starter brand logo (next-logo.png on img.brand-logo) must be
2180
+ // swapped for the campaign's real logo on every page.
2181
+ //
2182
+ // Division of responsibility vs the existing generic residue scan: the packet
2183
+ // path already emits `template_contract.literal_residue` (via
2184
+ // collectGenericTemplateResidueMatches, which includes the next-logo pattern),
2185
+ // but that scan runs against `derived.target_output_dir` — the page-kit SOURCE
2186
+ // dir (src/<slug>) — and is gated on assembly-complete. This check is the
2187
+ // BUILT-output signal: per page, unconditional, over `_site/<slug>`. A logo
2188
+ // that survives the page-kit build into _site (the receipt case) is caught here
2189
+ // even when the source scan didn't run or was a non-blocking source-side note.
2190
+ // The two can both fire for one logo (it lives in source AND built); fixing the
2191
+ // source and rebuilding clears both. See the Shield build QA (#5).
2192
+ export function validateBuiltStarterLogoResidue(content, builtPath, targetRepo, page, issueTarget) {
2193
+ const occurrences = (content.match(/\bnext-logo\.(?:png|svg|webp)\b/gi) || []).length;
2194
+ if (occurrences === 0) return;
2195
+ addIssue(
2196
+ issueTarget,
2197
+ "built_output.starter_logo_residue",
2198
+ `Built page "${page.id}" still references the starter logo next-logo.png (${occurrences} occurrence(s)). Replace the .brand-logo asset with the campaign's real logo before deploy.`,
2199
+ { page_id: page.id, file: relFromDir(targetRepo, builtPath), occurrences },
2200
+ );
2201
+ }
2202
+
2203
+ // Pre-checkout pages (presell/landing — SDK page_type "product") must ship the
2204
+ // Campaign Cart bootstrap, not just inert data-next attributes. Without the
2205
+ // loader + next-page-type meta, every SDK feature silently no-ops: conditional
2206
+ // visibility (param.banner/param.seen), utmTransfer (UTM/query carry-through to
2207
+ // checkout — top-of-funnel ad attribution), and SDK analytics. The generic
2208
+ // runtime-marker check above passes on a lone data-next-* attribute, so this
2209
+ // dedicated check guards the pre-checkout boundary. See the Shield build
2210
+ // learnings (A1): base-presell.html / base-landing.html shipped without it.
2211
+ const PRE_CHECKOUT_PAGE_TYPES = new Set([
2212
+ "presell", "advertorial", "listicle", "review",
2213
+ "landing", "lander", "lp", "product",
2214
+ ]);
2215
+
2216
+ function sdkLoaderScriptPresent(content) {
2217
+ // Only the campaign-cart loader counts. A loose `loader.js` match would let an
2218
+ // unrelated bundle (analytics/lazy-image loader) falsely satisfy the check and
2219
+ // re-introduce the missing-SDK bug, so the src must identify the campaign-cart
2220
+ // loader specifically. We do not scan for inline ESM imports: that is not a
2221
+ // real bootstrap path for this SDK and is trivially spoofed by a comment or a
2222
+ // JSON <script> blob.
2223
+ for (const tag of content.matchAll(/<script\b[^>]*>/gi)) {
2224
+ const srcMatch = tag[0].match(/\bsrc\s*=\s*["']([^"']+)["']/i);
2225
+ if (!srcMatch) continue;
2226
+ if (/campaign-cart(?:@[^"']*)?\/dist\/loader\.js/i.test(srcMatch[1])) return true;
2227
+ }
2228
+ return false;
2229
+ }
2230
+
2231
+ // Order-bump templates (bump-check01/bump-switch01) ship BOTH a per-unit price
2232
+ // row (Option A) and a line-total price row (Option B) behind Liquid guards,
2233
+ // with a "pick ONE" comment. If a build leaves both rendered, the bump shows
2234
+ // doubled prices. The template now defaults to per-unit only, so this guards
2235
+ // the built output against a regression where both rows survive. See the
2236
+ // Shield build learnings (B2). Spurious strikethrough (compare == price) is
2237
+ // covered separately as a polish-gate evidence requirement.
2238
+ const BUMP_BLOCK_PATTERN = /data-component\s*=\s*["']prepurchase-upsell["']/gi;
2239
+ const BUMP_PER_UNIT_DISPLAYS = ["unitPrice", "originalUnitPrice"];
2240
+ const BUMP_LINE_TOTAL_DISPLAYS = ["price", "originalPrice"];
2241
+
2242
+ function bumpDisplaysPresent(block, displays) {
2243
+ return displays.some((name) => new RegExp(`data-next-toggle-display\\s*=\\s*["']${name}["']`, "i").test(block));
2244
+ }
2245
+
2246
+ const CHECKOUT_BUMP_PAGE_TYPES = new Set(["checkout", "select"]);
2247
+
2248
+ export function validateBuiltBumpPricing(content, builtPath, targetRepo, page, issueTarget) {
2249
+ const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
2250
+ if (!CHECKOUT_BUMP_PAGE_TYPES.has(type)) return;
2251
+
2252
+ // Slice the document into per-bump blocks at each prepurchase-upsell anchor.
2253
+ const anchorOffsets = [...content.matchAll(BUMP_BLOCK_PATTERN)].map((match) => match.index);
2254
+ if (anchorOffsets.length === 0) return;
2255
+ const relPath = relFromDir(targetRepo, builtPath);
2256
+ let doubled = 0;
2257
+ for (let i = 0; i < anchorOffsets.length; i += 1) {
2258
+ const block = content.slice(anchorOffsets[i], anchorOffsets[i + 1] ?? content.length);
2259
+ if (bumpDisplaysPresent(block, BUMP_PER_UNIT_DISPLAYS) && bumpDisplaysPresent(block, BUMP_LINE_TOTAL_DISPLAYS)) {
2260
+ doubled += 1;
2261
+ }
2262
+ }
2263
+ if (doubled > 0) {
2264
+ addIssue(
2265
+ issueTarget,
2266
+ "built_output.bump_double_price",
2267
+ `Built page "${page.id}" renders ${doubled} order bump(s) with BOTH a per-unit price row (Option A) and a line-total price row (Option B). Pick one: pass show_per_unit_price / show_line_total_price to the bump include so a single price row renders (rendering both doubles the displayed price).`,
2268
+ { page_id: page.id, file: relPath, doubled_bumps: doubled },
2269
+ );
2270
+ }
2271
+ }
2272
+
2273
+ export function validateBuiltPreCheckoutBootstrap(content, builtPath, targetRepo, page, issueTarget) {
2274
+ const type = String(page?.type || page?.page_type || "").toLowerCase().trim();
2275
+ if (!PRE_CHECKOUT_PAGE_TYPES.has(type)) return;
2276
+
2277
+ const relPath = relFromDir(targetRepo, builtPath);
2278
+ const hasLoader = sdkLoaderScriptPresent(content);
2279
+ const hasPageTypeMeta = isNonEmptyString(extractMetaContent(content, "next-page-type"));
2280
+ if (hasLoader && hasPageTypeMeta) return;
2281
+
2282
+ const missing = [
2283
+ !hasLoader ? "the Campaign Cart loader script (campaign-cart@v{sdk_version}/dist/loader.js)" : null,
2284
+ !hasPageTypeMeta ? 'the <meta name="next-page-type"> tag' : null,
2285
+ ].filter(Boolean);
2286
+ addIssue(
2287
+ issueTarget,
2288
+ "built_output.pre_checkout_sdk_bootstrap",
2289
+ `SDK not bootstrapped on pre-checkout page "${page.id}" (type "${type}"): missing ${missing.join(" and ")}. Without it, conditional visibility (param.banner/param.seen), utmTransfer (UTM carry-through to checkout — ad attribution), and SDK analytics silently no-op. Emit the same config.js → loader.js → next-funnel/next-page-type bootstrap that the checkout layout uses.`,
2290
+ { page_id: page.id, file: relPath, missing: { loader: !hasLoader, page_type_meta: !hasPageTypeMeta } },
2291
+ );
2292
+ }
2293
+
2294
+ function validateBuiltScriptAssets(content, builtPath, targetRepo, page, publicRouteSlug, issueTarget) {
2295
+ for (const tag of content.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)) {
2296
+ const src = tag[1];
2297
+ const resolved = resolveBuiltAssetPath(src, builtPath, targetRepo);
2298
+ if (!resolved || existsSync(resolved)) continue;
2299
+ if (pageKitAssetPathViolation(src, publicRouteSlug)) continue;
2300
+ addIssue(
2301
+ issueTarget,
2302
+ "built_output.script_missing",
2303
+ `Built page "${page.id}" references script "${src}", but the file does not exist in built output.`,
2304
+ { page_id: page.id, file: relFromDir(targetRepo, builtPath), script: src }
2305
+ );
2306
+ }
2307
+ }
2308
+
2309
+ export function validateBuiltPageKitAssetPaths(content, builtPath, targetRepo, page, publicRouteSlug, issueTarget) {
2310
+ const hits = collectPageKitAssetPathViolations(content, publicRouteSlug)
2311
+ .filter((hit) => {
2312
+ const resolved = resolveBuiltAssetPath(hit.reference, builtPath, targetRepo);
2313
+ return resolved && !existsSync(resolved);
2314
+ });
2315
+
2316
+ if (!hits.length) return;
2317
+
2318
+ const slug = normalizePublicRouteSlug(publicRouteSlug);
2319
+ const sample = hits
2320
+ .slice(0, 5)
2321
+ .map((hit) => `${hit.reference} (${hit.kind}, line ${hit.line})`)
2322
+ .join("; ");
2323
+ const more = hits.length > 5 ? `; plus ${hits.length - 5} more` : "";
2324
+ const renderedExample = slug ? `/${slug}/config.js` : "/<slug>/config.js";
2325
+ const sourceExample = slug ? `src/${slug}/assets/config.js` : "src/<slug>/assets/config.js";
2326
+
2327
+ addIssue(
2328
+ issueTarget,
2329
+ "built_output.pagekit_asset_path",
2330
+ `Built page "${page.id}" references page-kit asset path(s) that do not exist in built output: ${sample}${more}. next-campaign-page-kit copies ${sourceExample} to "${renderedExample}" (not "/assets/config.js" or "/${slug || "<slug>"}/assets/config.js"). Use "{{ 'config.js' | campaign_asset }}" in page-kit source, or rewrite raw passthrough HTML to the campaign-rooted built URL.`,
2331
+ {
2332
+ page_id: page.id,
2333
+ file: relFromDir(targetRepo, builtPath),
2334
+ references: hits.map((hit) => ({
2335
+ reference: hit.reference,
2336
+ expected: hit.expected,
2337
+ line: hit.line,
2338
+ kind: hit.kind,
2339
+ })),
2340
+ }
2341
+ );
2342
+ }
2343
+
2344
+ export function collectPageKitAssetPathViolations(content, publicRouteSlug) {
2345
+ const slug = normalizePublicRouteSlug(publicRouteSlug);
2346
+ const hits = [];
2347
+ const seen = new Set();
2348
+
2349
+ const record = (kind, reference, index) => {
2350
+ const hit = pageKitAssetPathViolation(reference, slug);
2351
+ if (!hit) return;
2352
+ const key = `${kind}:${hit.reference}:${lineNumberAt(content, index || 0)}`;
2353
+ if (seen.has(key)) return;
2354
+ seen.add(key);
2355
+ hits.push({
2356
+ ...hit,
2357
+ kind,
2358
+ line: lineNumberAt(content, index || 0),
2359
+ });
2360
+ };
2361
+
2362
+ for (const match of content.matchAll(/\b(src|href)=["']([^"']+)["']/gi)) {
2363
+ record(match[1].toLowerCase(), match[2], match.index);
2364
+ }
2365
+ for (const match of content.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi)) {
2366
+ record("css-url", match[2], match.index);
2367
+ }
2368
+
2369
+ return hits;
2370
+ }
2371
+
2372
+ function pageKitAssetPathViolation(reference, publicRouteSlug) {
2373
+ const raw = String(reference || "").trim();
2374
+ if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
2375
+
2376
+ const clean = raw.replace(/[?#].*$/, "");
2377
+ const slugAssetsPrefix = publicRouteSlug ? `/${publicRouteSlug}/assets/` : null;
2378
+ let assetPath = null;
2379
+
2380
+ if (clean.startsWith("/assets/")) {
2381
+ assetPath = clean.slice("/assets/".length);
2382
+ } else if (slugAssetsPrefix && clean.startsWith(slugAssetsPrefix)) {
2383
+ assetPath = clean.slice(slugAssetsPrefix.length);
2384
+ }
2385
+
2386
+ if (!assetPath || assetPath.startsWith("../") || assetPath.includes("/../")) return null;
2387
+ return {
2388
+ reference: raw,
2389
+ asset_path: assetPath,
2390
+ expected: publicRouteSlug ? `/${publicRouteSlug}/${assetPath}` : `/<slug>/${assetPath}`,
2391
+ };
2392
+ }
2393
+
2394
+ function resolveBuiltAssetPath(src, builtPath, targetRepo) {
2395
+ if (!isNonEmptyString(src)) return null;
2396
+ const raw = src.trim();
2397
+ if (raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
2398
+ const clean = raw.replace(/[?#].*$/, "");
2399
+ if (!clean || clean.startsWith("#")) return null;
2400
+ if (clean.startsWith("/")) return join(targetRepo, "_site", clean.replace(/^\/+/, ""));
2401
+ return resolve(dirname(builtPath), clean);
2402
+ }
2403
+
2404
+ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, issueTarget) {
2405
+ const packageRefs = specPackageRefs(spec);
2406
+ const shippingRefs = specShippingRefs(spec);
2407
+ const relPath = relFromDir(targetRepo, builtPath);
2408
+ const badPackages = [...extractRenderedPackageRefs(content)].filter((ref) => packageRefs.size > 0 && !packageRefs.has(ref));
2409
+ const badShipping = [...extractRenderedShippingRefs(content)].filter((ref) => shippingRefs.size > 0 && !shippingRefs.has(ref));
2410
+
2411
+ if (badPackages.length > 0) {
2412
+ addIssue(
2413
+ issueTarget,
2414
+ "built_output.package_ref",
2415
+ `Built page "${page.id}" references package ID(s) not present in CampaignSpec: ${[...new Set(badPackages)].join(", ")}.`,
2416
+ { page_id: page.id, file: relPath }
2417
+ );
2418
+ }
2419
+ if (badShipping.length > 0) {
2420
+ addIssue(
2421
+ issueTarget,
2422
+ "built_output.shipping_ref",
2423
+ `Built page "${page.id}" references shipping ID(s) not present in CampaignSpec: ${[...new Set(badShipping)].join(", ")}.`,
2424
+ { page_id: page.id, file: relPath }
2425
+ );
2426
+ }
2427
+ }
2428
+
2429
+ function extractRenderedPackageRefs(content) {
2430
+ const refs = new Set();
2431
+ for (const match of content.matchAll(/\bdata-next-package-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
2432
+ for (const match of content.matchAll(/\bdata-package-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
2433
+ for (const match of content.matchAll(/["']?packageId["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gi)) {
2434
+ addRenderedRef(refs, match[1] || match[2] || match[3]);
2435
+ }
2436
+ return refs;
2437
+ }
2438
+
2439
+ function extractRenderedShippingRefs(content) {
2440
+ const refs = new Set();
2441
+ for (const match of content.matchAll(/\bdata-next-shipping-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
2442
+ for (const match of content.matchAll(/["']?shippingId["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gi)) {
2443
+ addRenderedRef(refs, match[1] || match[2] || match[3]);
2444
+ }
2445
+ return refs;
2446
+ }
2447
+
2448
+ function addRenderedRef(refs, value) {
2449
+ const ref = String(value || "").trim();
2450
+ if (/^[A-Za-z0-9_-]+$/.test(ref)) refs.add(ref);
2451
+ }
2452
+
2453
+ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
2454
+ if (!targetRepo || !publicRouteSlug) return null;
2455
+ const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
2456
+ const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
2457
+ if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
2458
+ const clean = route.replace(/^\/+|\/+$/g, "");
2459
+ return clean ? join(targetRepo, "_site", publicRouteSlug, clean, "index.html") : join(targetRepo, "_site", publicRouteSlug, "index.html");
2460
+ }
2461
+
2462
+ function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page) {
2463
+ if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
2464
+
2465
+ const expectedTerminal = terminalRouteSegment(publicRouteForPage(page));
2466
+ const candidates = [];
2467
+ for (const file of collectHtmlFiles(targetOutputDir)) {
2468
+ if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
2469
+ const fullPath = join(targetOutputDir, file.path);
2470
+ const content = readFileSync(fullPath, "utf8");
2471
+ const permalink = extractFrontmatterValue(content, "permalink");
2472
+ if (!isNonEmptyString(permalink)) continue;
2473
+ const relative = stripPublicRoutePrefix(normalizePageKitRoute(permalink), publicRouteSlug);
2474
+ if (terminalRouteSegment(relative) === expectedTerminal) candidates.push(relative);
2475
+ }
2476
+
2477
+ return candidates.length === 1 ? candidates[0] : null;
2478
+ }
2479
+
2480
+ function extractMetaContent(content, name) {
2481
+ const escaped = escapeRegExp(name);
2482
+ const metaTag = new RegExp(`<meta\\b(?=[^>]*\\bname=["']${escaped}["'])([^>]*)>`, "i").exec(content);
2483
+ if (!metaTag) return null;
2484
+ const contentAttr = /\bcontent=["']([^"']*)["']/i.exec(metaTag[1]);
2485
+ return contentAttr ? contentAttr[1] : "";
2486
+ }
2487
+
2488
+ function runtimeRouteForMetaValue(value, publicRouteSlug, routeRoot = null) {
2489
+ if (!isNonEmptyString(value)) return null;
2490
+ const route = value.trim();
2491
+ if (isAbsoluteHttpUrl(route)) return route;
2492
+ if (isRuntimeRootedRoutingMeta(route, publicRouteSlug, routeRoot)) return route;
2493
+ const root = routeRoot || `/${publicRouteSlug}/`;
2494
+ const normalized = runtimeRelativeRouteForSpecValue(route, publicRouteSlug);
2495
+ return normalized ? `${root}${normalized}` : root;
2496
+ }
2497
+
2498
+ function terminalRouteSegment(route) {
2499
+ const normalized = normalizePageKitRoute(route);
2500
+ const parts = normalized.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
2501
+ return parts.length ? parts[parts.length - 1] : "";
2502
+ }
2503
+
2504
+ function escapeRegExp(value) {
2505
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2506
+ }
2507
+
2508
+ // Declared route_root must be "/" or agree with public_route_slug — any other
2509
+ // prefix would make the packet describe a route surface that contradicts the
2510
+ // slug identity every other check roots on, which is the silent-disarm shape
2511
+ // the c1 negative control exposed for the slug itself.
2512
+ export function validateRouteRootDeclaration(packet, errors, ready) {
2513
+ const declared = packet?.campaign?.route_root;
2514
+ if (declared == null) return;
2515
+ const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2516
+ // The packet rule is exact: "/" or the canonical "/<public_route_slug>/"
2517
+ // only, mirroring the schema pattern, and it is the same rule every other
2518
+ // stage reads the packet by. So a near miss ("/example", "//example//") is
2519
+ // blocked here and honoured nowhere — the silent-disarm split this check
2520
+ // exists to close.
2521
+ const honoured = packetRouteRoot(declared, slug);
2522
+ if (honoured === "/") {
2523
+ ready.push(`Campaign is declared root-served (route_root "/"): routing metas and public routes validate against site-root paths; public_route_slug "${slug}" remains identity, not a path prefix`);
2524
+ return;
2525
+ }
2526
+ if (honoured) {
2527
+ ready.push(`Campaign route_root "/${slug}/" matches public_route_slug`);
2528
+ return;
2529
+ }
2530
+ addIssue(
2531
+ errors,
2532
+ "campaign.route_root",
2533
+ `Packet campaign.route_root ${JSON.stringify(declared)} is invalid. Declare exactly "/" for a root-served campaign or exactly "/${slug || "<public_route_slug>"}/" (leading and trailing slash) for the default slug-prefixed root. Any other value would contradict public_route_slug, which stays the campaign identity and the _site/<public_route_slug>/ built-output directory name.`
2534
+ );
2535
+ }
2536
+
2537
+ function isRuntimeRootedRoutingMeta(value, publicRouteSlug, routeRoot = null) {
2538
+ if (isAbsoluteHttpUrl(value)) return true;
2539
+ const route = value.trim();
2540
+ if (!route.startsWith("/")) return false;
2541
+ const root = routeRoot || (publicRouteSlug ? `/${publicRouteSlug}/` : null);
2542
+ // Root-served: every absolute path is rooted at the served surface.
2543
+ if (root === "/") return true;
2544
+ if (!root) return false;
2545
+ const prefix = root.replace(/\/+$/, "");
2546
+ return route === prefix || route.startsWith(`${prefix}/`);
2547
+ }
2548
+
2549
+ export function validateMarketSensitiveCopy(spec, warnings, ready, derived) {
2550
+ return withHtmlScanSnapshot(() => scanMarketSensitiveCopy(spec, warnings, ready, derived), { reuse: true });
2551
+ }
2552
+
2553
+ function scanMarketSensitiveCopy(spec, warnings, ready, derived) {
2554
+ const scope = deriveMarketScope(spec);
2555
+ const currencyScope = deriveCurrencyCopyScope(spec);
2556
+ const storePhone = firstNonEmptyString(spec?.campaign?.store_phone, spec?.campaign?.phone);
2557
+ if (!scope.needsCopyReview && !currencyScope.needsCopyReview && !storePhone) return;
2558
+
2559
+ const scanRoots = [];
2560
+ if (derived.source_root && existsSync(derived.source_root) && statSync(derived.source_root).isDirectory()) {
2561
+ scanRoots.push({ label: "source", root: derived.source_root });
2562
+ }
2563
+ if (derived.target_output_dir && existsSync(derived.target_output_dir) && statSync(derived.target_output_dir).isDirectory()) {
2564
+ scanRoots.push({ label: "target", root: derived.target_output_dir });
2565
+ }
2566
+
2567
+ const matches = scope.needsCopyReview ? collectMarketCopyMatches(scanRoots) : [];
2568
+ if (scope.needsCopyReview && !matches.length) {
2569
+ ready.push(`Market-sensitive copy scan found no obvious US-only patterns (${scope.reasons.join(", ")}).`);
2570
+ } else if (matches.length) {
2571
+ const matchSummary = summarizeCopyMatches(matches);
2572
+ addIssue(
2573
+ warnings,
2574
+ "market_copy.us_specific_claims",
2575
+ `Campaign market scope needs copy review (${scope.reasons.join(", ")}), and source/template files contain US-specific starter copy: ${matchSummary}. Confirm or replace this copy; do not remove it automatically.`
2576
+ );
2577
+ }
2578
+
2579
+ if (currencyScope.needsCopyReview) {
2580
+ const currencyMatches = collectHardcodedCurrencyMatches(scanRoots);
2581
+ // R2-B2: the assembled/built campaign output is the QA artifact.
2582
+ // Once the build has actually produced output and that output is
2583
+ // currency-clean, residual $ in the *source* HTML is the raw input the build
2584
+ // tokenized — not a live-page defect. Warn on the built output; downgrade
2585
+ // source-only residue to an info note so a correct build stops re-tripping
2586
+ // this warning. Guard on the target containing built HTML, not merely an
2587
+ // (empty) output directory existing — an empty target means the build has
2588
+ // not run yet, so source warnings must still stand.
2589
+ const targetScanRoot = scanRoots.find((scanRoot) => scanRoot.label === "target");
2590
+ const hasBuiltTarget = Boolean(targetScanRoot) && collectHtmlFiles(targetScanRoot.root).length > 0;
2591
+ const targetMatches = currencyMatches.filter((match) => match.surface === "target");
2592
+ const reportableMatches = hasBuiltTarget ? targetMatches : currencyMatches;
2593
+ if (!currencyMatches.length) {
2594
+ ready.push(`Hardcoded currency scan found no obvious static $ amounts (${currencyScope.reasons.join(", ")}).`);
2595
+ } else if (!reportableMatches.length) {
2596
+ ready.push(`Hardcoded currency scan: built output is currency-clean; ${currencyMatches.length} static $ amount(s) remain only in source HTML (raw input tokenized by build).`);
2597
+ } else {
2598
+ addIssue(
2599
+ warnings,
2600
+ "copy.hardcoded_currency_symbol",
2601
+ `Campaign currency scope needs copy review (${currencyScope.reasons.join(", ")}), and ${hasBuiltTarget ? "built campaign output" : "prepared HTML"} contains hardcoded $ amounts outside SDK-bound or skipped regions: ${summarizeCopyMatches(reportableMatches)}. Use SDK display tokens or remove static currency strings.`
2602
+ );
2603
+ }
2604
+ }
2605
+
2606
+ if (storePhone) {
2607
+ const phoneMatches = collectHardcodedPhoneMatches(scanRoots, storePhone);
2608
+ if (!phoneMatches.length) {
2609
+ ready.push("Hardcoded phone scan found no mismatched static phone numbers.");
2610
+ } else {
2611
+ addIssue(
2612
+ warnings,
2613
+ "copy.hardcoded_phone",
2614
+ `Campaign Store Profile phone is "${storePhone}", but prepared HTML contains different hardcoded phone numbers outside skipped regions: ${summarizeCopyMatches(phoneMatches)}. Use the campaign.store_phone binding or remove static phone strings.`
2615
+ );
2616
+ }
2617
+ }
2618
+ }
2619
+
2620
+ function deriveMarketScope(spec) {
2621
+ const campaign = spec?.campaign || {};
2622
+ const defaultCurrency = normalizeCurrency(campaign.currency);
2623
+ const currencies = [...new Set([
2624
+ ...normalizeCurrencyList(campaign.available_currencies),
2625
+ ...normalizeCurrencyList(campaign.additional_currencies),
2626
+ ...normalizeCurrencyList(campaign.additionalCurrencies),
2627
+ ])];
2628
+ const additionalCurrencies = currencies.filter((currency) => currency && currency !== defaultCurrency);
2629
+ const countries = campaign.available_shipping_countries;
2630
+ const countryList = Array.isArray(countries) ? countries.map((country) => String(country).trim()).filter(Boolean) : [];
2631
+ const nonUsCountries = countryList.filter((country) => !isUsCountryCode(country));
2632
+ const marketMode = String(campaign.market_mode || campaign.marketMode || campaign.market_scope || spec?.market_mode || "").trim();
2633
+ const reasons = [];
2634
+
2635
+ if (additionalCurrencies.length) reasons.push(`additional currencies: ${additionalCurrencies.join(", ")}`);
2636
+ if (countries === "all") reasons.push("available_shipping_countries=all");
2637
+ if (nonUsCountries.length) reasons.push(`non-US shipping countries: ${nonUsCountries.join(", ")}`);
2638
+ if (/country|multi/i.test(marketMode)) reasons.push(`market mode: ${marketMode}`);
2639
+
2640
+ return { needsCopyReview: reasons.length > 0, reasons };
2641
+ }
2642
+
2643
+ function deriveCurrencyCopyScope(spec) {
2644
+ const campaign = spec?.campaign || {};
2645
+ const defaultCurrency = normalizeCurrency(campaign.currency);
2646
+ const currencies = [...new Set([
2647
+ ...normalizeCurrencyList(campaign.available_currencies),
2648
+ ...normalizeCurrencyList(campaign.additional_currencies),
2649
+ ...normalizeCurrencyList(campaign.additionalCurrencies),
2650
+ ])];
2651
+ const reasons = [];
2652
+
2653
+ if (currencies.length > 1) reasons.push(`available currencies: ${currencies.join(", ")}`);
2654
+ if (defaultCurrency && defaultCurrency !== "USD") reasons.push(`default currency: ${defaultCurrency}`);
2655
+
2656
+ return { needsCopyReview: reasons.length > 0, reasons };
2657
+ }
2658
+
2659
+ function normalizeCurrency(value) {
2660
+ return isNonEmptyString(value) ? value.trim().toUpperCase() : "";
2661
+ }
2662
+
2663
+ function normalizeCurrencyList(value) {
2664
+ if (Array.isArray(value)) return value.map(normalizeCurrency).filter(Boolean);
2665
+ if (isNonEmptyString(value)) return value.split(",").map(normalizeCurrency).filter(Boolean);
2666
+ return [];
2667
+ }
2668
+
2669
+ function isUsCountryCode(value) {
2670
+ const country = String(value).trim().toUpperCase();
2671
+ return ["US", "USA", "UNITED STATES", "UNITED STATES OF AMERICA"].includes(country);
2672
+ }
2673
+
2674
+ function collectMarketCopyMatches(scanRoots) {
2675
+ const matches = [];
2676
+ for (const { label: surface, root } of scanRoots) {
2677
+ for (const file of collectHtmlFiles(root)) {
2678
+ const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
2679
+ for (const pattern of US_MARKET_COPY_PATTERNS) {
2680
+ const match = content.match(pattern.regex);
2681
+ if (match) {
2682
+ matches.push({
2683
+ surface,
2684
+ path: file.path,
2685
+ line: lineNumberAt(content, match.index || 0),
2686
+ label: pattern.label,
2687
+ text: match[0],
2688
+ });
2689
+ }
2690
+ }
2691
+ }
2692
+ }
2693
+ return matches;
2694
+ }
2695
+
2696
+ function collectHardcodedCurrencyMatches(scanRoots) {
2697
+ const matches = [];
2698
+ for (const { label: surface, root } of scanRoots) {
2699
+ for (const file of collectHtmlFiles(root)) {
2700
+ const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
2701
+ for (const match of content.matchAll(HARDCODED_CURRENCY_REGEX)) {
2702
+ matches.push({
2703
+ surface,
2704
+ path: file.path,
2705
+ line: lineNumberAt(content, match.index || 0),
2706
+ label: match[0].replace(/\s+/g, " ").trim(),
2707
+ text: match[0],
2708
+ });
2709
+ }
2710
+ }
2711
+ }
2712
+ return matches;
2713
+ }
2714
+
2715
+ function collectHardcodedPhoneMatches(scanRoots, storePhone) {
2716
+ const expected = normalizePhoneNumber(storePhone);
2717
+ if (!expected) return [];
2718
+
2719
+ const matches = [];
2720
+ for (const { label: surface, root } of scanRoots) {
2721
+ for (const file of collectHtmlFiles(root)) {
2722
+ const content = maskMarketLintIgnoredRegions(readHtmlScanText(join(root, file.path)));
2723
+ for (const match of content.matchAll(HARDCODED_PHONE_REGEX)) {
2724
+ const found = normalizePhoneNumber(match[0]);
2725
+ if (!found || found === expected) continue;
2726
+ matches.push({
2727
+ surface,
2728
+ path: file.path,
2729
+ line: lineNumberAt(content, match.index || 0),
2730
+ label: match[0].replace(/\s+/g, " ").trim(),
2731
+ text: match[0],
2732
+ });
2733
+ }
2734
+ }
2735
+ }
2736
+ return matches;
2737
+ }
2738
+
2739
+ function maskMarketLintIgnoredRegions(content) {
2740
+ const ignoredElement =
2741
+ /<([A-Za-z][A-Za-z0-9:-]*)(?=[^>]*(?:data-next-display|data-next-bundle-display|data-skip-market-lint\s*=\s*["']true["']))[^>]*>[\s\S]*?<\/\1>/gi;
2742
+ const ignoredTag =
2743
+ /<[^>]*(?:data-next-display|data-next-bundle-display|data-skip-market-lint\s*=\s*["']true["'])[^>]*>/gi;
2744
+
2745
+ return content
2746
+ .replace(ignoredElement, preserveNewlinesMask)
2747
+ .replace(ignoredTag, preserveNewlinesMask);
2748
+ }
2749
+
2750
+ function preserveNewlinesMask(value) {
2751
+ return value.replace(/[^\n]/g, " ");
2752
+ }
2753
+
2754
+ function lineNumberAt(content, index) {
2755
+ return content.slice(0, index).split("\n").length;
2756
+ }
2757
+
2758
+ function normalizePhoneNumber(value) {
2759
+ const digits = String(value || "").replace(/\D/g, "");
2760
+ if (digits.length === 11 && digits.startsWith("1")) return digits.slice(1);
2761
+ return digits;
2762
+ }
2763
+
2764
+ function summarizeCopyMatches(matches) {
2765
+ const summary = matches
2766
+ .slice(0, 8)
2767
+ .map((match) => `${match.surface}:${match.path}:${match.line} "${match.label}"`)
2768
+ .join("; ");
2769
+ const more = matches.length > 8 ? `; plus ${matches.length - 8} more` : "";
2770
+ return `${summary}${more}`;
2771
+ }
2772
+
2773
+ function validateCampaignsApiKey(packet, spec, warnings, ready) {
2774
+ const apiKey = resolveCampaignsApiKey(packet, spec, process.env);
2775
+ if (apiKey.present) {
2776
+ ready.push(`Campaigns API key available via ${apiKey.source}`);
2777
+ if (apiKey.warning) addIssue(warnings, "campaign.api_key_source", apiKey.warning);
2778
+ return;
2779
+ }
2780
+ if (apiKey.rejected) {
2781
+ // Present but refused is its own finding: the operator fixes a value, not
2782
+ // a missing declaration. Names the source; the value is never printed.
2783
+ addIssue(warnings, "campaign.api_key_rejected", apiKey.warning, { source: apiKey.rejected.source, kind: apiKey.rejected.kind });
2784
+ return;
2785
+ }
2786
+
2787
+ addIssue(
2788
+ warnings,
2789
+ "campaign.api_key_source",
2790
+ apiKey.warning || "Campaigns API key was not found in the local CampaignSpec, packet, or declared env var. API-side package/shipping/offer confirmation is deferred."
2791
+ );
2792
+ }
2793
+
2794
+ // The malformed-key description starts mid-sentence ("the Campaigns API key
2795
+ // from …"), which reads wrong at the head of a warning line; the env-name one
2796
+ // starts with an identifier (`api_key_source "env:…"`) that must not be
2797
+ // touched. Only the former is capitalised.
2798
+ function sentenceCase(text) {
2799
+ return typeof text === "string" ? text.replace(/^the /, "The ") : text;
2800
+ }
2801
+
2802
+ // Doctor's view of the key is a projection of the one resolver the remit
2803
+ // rails use (resolveCampaignsApiKeySource): the same sources in the same
2804
+ // order, the same shape gate, the same refusal vocabulary. A value that is
2805
+ // there but refused on shape is reported as refused — naming the source,
2806
+ // never the value — where doctor used to call any non-empty value present.
2807
+ // What doctor adds is the "nothing usable" explanation, read from the
2808
+ // packet's declared source, since the resolver reports absence without a why.
2809
+ function resolveCampaignsApiKey(packet, spec, env) {
2810
+ const resolved = resolveCampaignsApiKeySource(packet, null, env, { spec });
2811
+ if (resolved.key) {
2812
+ return {
2813
+ present: true,
2814
+ source: resolved.origin,
2815
+ warning: resolved.origin.startsWith("packet.")
2816
+ ? "Campaigns API key is stored directly in the Build Packet. This is allowed for local/public-client builds, but shared fixtures may prefer CampaignSpec or env sourcing."
2817
+ : null,
2818
+ };
2819
+ }
2820
+ if (resolved.rejected) {
2821
+ return {
2822
+ present: false,
2823
+ source: resolved.rejected.source,
2824
+ rejected: resolved.rejected,
2825
+ warning: `${sentenceCase(describeCampaignKeyRejection(resolved.rejected))} API-side package/shipping/offer confirmation is deferred.`,
2826
+ };
2827
+ }
2828
+
2829
+ const source = packet?.campaign?.api_key_source;
2830
+ if (!isNonEmptyString(source)) {
2831
+ return {
2832
+ present: false,
2833
+ source: null,
2834
+ warning: "No Campaigns API key source is declared, and the local CampaignSpec does not include campaign.campaigns_api_key. API-side package/shipping/offer confirmation is deferred.",
2835
+ };
2836
+ }
2837
+
2838
+ if (source.startsWith("env:")) {
2839
+ // A set variable was either accepted (key) or refused (rejected) above,
2840
+ // so reaching here means it is unset.
2841
+ const envName = source.slice("env:".length).trim();
2842
+ return {
2843
+ present: false,
2844
+ source,
2845
+ warning: `Environment variable ${envName} is not set, and the local CampaignSpec does not include campaign.campaigns_api_key. API-side package/shipping/offer confirmation is deferred.`,
2846
+ };
2847
+ }
2848
+
2849
+ if (source === "provided-out-of-band") {
2850
+ return {
2851
+ present: false,
2852
+ source,
2853
+ warning: "API key source is declared out-of-band; doctor cannot confirm Campaigns API refs before build.",
2854
+ };
2855
+ }
2856
+
2857
+ return {
2858
+ present: false,
2859
+ source,
2860
+ warning: `Unsupported API key source "${source}". Use CampaignSpec campaign.campaigns_api_key, packet campaign.campaigns_api_key, or env:<VAR>.`,
2861
+ };
2862
+ }
2863
+
2864
+ function routeLabel(route) {
2865
+ return route === "" ? "entry route (empty page_url)" : route;
2866
+ }
2867
+
2868
+ function validateSpecPublicRoutes(spec, errors, ready) {
2869
+ const pages = activeSpecPages(spec);
2870
+ const routeMap = new Map();
2871
+ let routeErrors = 0;
2872
+
2873
+ for (const page of pages) {
2874
+ if (hasHtmlExtensionRoute(page.page_url)) {
2875
+ routeErrors += 1;
2876
+ addIssue(
2877
+ errors,
2878
+ "spec.page_url_html_extension",
2879
+ `Page "${page.label || page.id}" declares page_url "${page.page_url}". CampaignSpec page_url is a Page Kit public route, not a source filename; use "${normalizePageKitRoute(page.page_url) || "(entry route)"}" instead.`
2880
+ );
2881
+ }
2882
+
2883
+ const route = publicRouteForPage(page);
2884
+ const prior = routeMap.get(route);
2885
+ if (prior) {
2886
+ routeErrors += 1;
2887
+ addIssue(
2888
+ errors,
2889
+ "spec.route_collision",
2890
+ `Pages "${prior.label || prior.id}" and "${page.label || page.id}" both resolve to ${routeLabel(route)}. Set distinct page_url values before assembly.`
2891
+ );
2892
+ } else {
2893
+ routeMap.set(route, page);
2894
+ }
2895
+ }
2896
+
2897
+ if (pages.length > 0 && routeErrors === 0) ready.push("CampaignSpec public page routes are Page Kit-compatible");
2898
+ }
2899
+
2900
+ function pageRole(type) {
2901
+ if (["checkout", "upsell", "downsell", "thankyou", "receipt", "select"].includes(type)) return "runtime";
2902
+ return "visual";
2903
+ }
2904
+
2905
+ function summarizeScopePages(pages) {
2906
+ return pages.map((page) => `${page.type || "page"}:${page.page_id}`).join(", ");
2907
+ }
2908
+
2909
+ /**
2910
+ * Build a context-aware error message for a CampaignSpec page that has no source mapping.
2911
+ * When `design_source` is set, point the operator at the producing pipeline so the missing
2912
+ * source HTML can be regenerated instead of leaving the operator guessing.
2913
+ *
2914
+ * Per docs/entry-points.md, today's recognized producers:
2915
+ * - figma: run figma-sections-export
2916
+ * - ai-generated: re-run the producing agent (Claude/Codex/etc.)
2917
+ * - hand-authored / template-stock: no design_source set; falls through to generic
2918
+ *
2919
+ * Future producers (Penpot, Sketch, plain Markdown converters, etc.) slot in by adding a
2920
+ * `design_source.type` value here. The fallback path emits a generic "produce the source
2921
+ * HTML for this page" message so unknown types don't crash the operator's UX.
2922
+ *
2923
+ * @param {object} page Active spec page (may carry `design_source`).
2924
+ * @returns {string}
2925
+ */
2926
+ function coverageErrorMessage(page) {
2927
+ const designSource = page && isObject(page.design_source) ? page.design_source : null;
2928
+ if (designSource) {
2929
+ const fileUrl = optionalString(designSource.file_url);
2930
+ if (designSource.type === "figma" && fileUrl) {
2931
+ return `Active CampaignSpec page "${page.id}" has no source mapping. Design is in Figma at ${fileUrl}; supply the source-html manifest for the page (see docs/design-source-package.md) — the exporter that produced the design emits it — then rerun prepare-build.`;
2932
+ }
2933
+ if (designSource.type === "ai-generated") {
2934
+ const fileUrlHint = fileUrl ? ` (design reference: ${fileUrl})` : "";
2935
+ return `Active CampaignSpec page "${page.id}" has no source mapping. design_source.type="ai-generated"${fileUrlHint} — re-run the producing agent so the source HTML and source-html manifest land in the source root, then rerun prepare-build. See docs/entry-points.md for the AI-generated entry point contract.`;
2936
+ }
2937
+ if (!fileUrl) {
2938
+ return `Active CampaignSpec page "${page.id}" has no source mapping. design_source is set but file_url is missing — add file_url to the spec before requesting a build.`;
2939
+ }
2940
+ return `Active CampaignSpec page "${page.id}" has no source mapping. design_source.type="${designSource.type}" at ${fileUrl}; produce the source HTML for this page (or update design_source.type to a recognized producer — see docs/entry-points.md) before rerunning prepare-build.`;
2941
+ }
2942
+ return `Active CampaignSpec page "${page.id}" has no source mapping.`;
2943
+ }
2944
+
2945
+ /**
2946
+ * Build the optional `detail` payload for a source-coverage error. Captures the design_source
2947
+ * pointer when present so downstream agents/UIs can render a clickable link without re-parsing
2948
+ * the message string.
2949
+ *
2950
+ * @param {object} page Active spec page.
2951
+ * @returns {object | null}
2952
+ */
2953
+ function coverageErrorDetail(page) {
2954
+ const designSource = page && isObject(page.design_source) ? page.design_source : null;
2955
+ // Always carry page_id so downstream consumers (including the prepare-build
2956
+ // gate dedup in addPrepareBuildGateErrors) can match this issue to the page
2957
+ // without re-parsing the message string.
2958
+ return {
2959
+ page_id: page?.id ?? null,
2960
+ ...(designSource
2961
+ ? {
2962
+ design_source: {
2963
+ type: designSource.type || null,
2964
+ file_url: optionalString(designSource.file_url) || null,
2965
+ },
2966
+ }
2967
+ : {}),
2968
+ };
2969
+ }
2970
+
2971
+ // A declared out-of-scope page whose scope decision carries `template_stock`
2972
+ // (recorded by prepare-build on dec_page_scope_<page>) is the locked family's
2973
+ // own page: the build stage materialises it, and once its built HTML exists at
2974
+ // the page's route it is a built page like any mapped one — previewable, and
2975
+ // no longer a reason to block runtime QA. Until the build has written it, it
2976
+ // stays out of scope exactly as before, so an unbuilt declaration is unchanged.
2977
+ function templateStockDecision(buildState, pageId) {
2978
+ const decisions = buildState?.report?.decisions;
2979
+ if (!Array.isArray(decisions)) return null;
2980
+ const decision = decisions.find((entry) => entry?.id === `dec_page_scope_${pageId}` && entry?.template_stock === true);
2981
+ return decision || null;
2982
+ }
2983
+
2984
+ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
2985
+ const pages = packet.source_html?.pages || [];
2986
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2987
+ const materialisedTemplateStock = [];
2988
+ const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
2989
+ validateSourceHtmlManifestAtRoot(sourceRoot, {
2990
+ spec,
2991
+ errors,
2992
+ warnings,
2993
+ ready,
2994
+ manifestPath: recordedDesignManifestPath(packet, packetPath),
2995
+ });
2996
+ const active = activeSpecPages(spec);
2997
+ const specPartialScope = spec?.build_scope?.mode === "partial";
2998
+ const specPartialReasons = Array.isArray(spec?.build_scope?.reasons) ? spec.build_scope.reasons.filter(isNonEmptyString) : [];
2999
+ const activeIds = new Set(active.map((page) => page.id));
3000
+ const mappedIds = new Set();
3001
+ const activeById = new Map(active.map((page) => [page.id, page]));
3002
+ const builtPages = [];
3003
+ const outOfScopePages = [];
3004
+
3005
+ for (const page of pages) {
3006
+ if (!isNonEmptyString(page.page_id)) {
3007
+ addIssue(errors, "source_html.pages.page_id", "Every source page mapping needs page_id.");
3008
+ continue;
3009
+ }
3010
+ mappedIds.add(page.page_id);
3011
+ const specPage = activeById.get(page.page_id);
3012
+ if (!activeIds.has(page.page_id)) {
3013
+ addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
3014
+ }
3015
+ if (page.path) {
3016
+ const fullPath = resolve(sourceRoot, page.path);
3017
+ if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
3018
+ addIssue(errors, "source_html.pages.path", `Source page file does not exist: ${page.path}`);
3019
+ } else {
3020
+ // Slice 6: drift detection. When the packet carries a manifest-derived
3021
+ // source_hash for this page, compute the on-disk file's actual hash
3022
+ // and warn when they diverge. A mismatch means the file was edited
3023
+ // after the manifest was written, which is the signal that "design
3024
+ // handoff is stale" — operator should re-run the producer or accept
3025
+ // the local edits and regenerate the manifest.
3026
+ //
3027
+ // Silent when source_hash is absent (template-stock, hand-authored,
3028
+ // and producers that haven't adopted Slice 6 yet). Never an error —
3029
+ // drift is a warning so a build can still ship.
3030
+ const expectedHash = optionalString(page.source_hash);
3031
+ if (expectedHash) {
3032
+ const actualHash = sha256File(fullPath);
3033
+ if (actualHash !== expectedHash) {
3034
+ addIssue(
3035
+ warnings,
3036
+ "source_html.pages.source_hash",
3037
+ `Source page "${page.page_id}" hash mismatch — file at ${page.path} has changed since the manifest was written (manifest sha256=${expectedHash.slice(0, 12)}…, on-disk sha256=${actualHash.slice(0, 12)}…). Re-run the producer to refresh the manifest, or accept the local edits.`,
3038
+ );
3039
+ }
3040
+ }
3041
+ if (specPage) {
3042
+ builtPages.push({
3043
+ page_id: specPage.id,
3044
+ type: specPage.type || "page",
3045
+ role: pageRole(specPage.type),
3046
+ route: publicRouteForPage(specPage),
3047
+ source_path: page.path,
3048
+ });
3049
+ }
3050
+ }
3051
+ } else if (!page.skip_reason) {
3052
+ addIssue(errors, "source_html.pages.skip_reason", `Source mapping "${page.page_id}" needs path or skip_reason.`);
3053
+ } else {
3054
+ const stockDecision = specPage ? templateStockDecision(buildState, specPage.id) : null;
3055
+ const builtPath = stockDecision ? builtHtmlPathForPage(derived.target_repo, publicRouteSlug, specPage, derived) : null;
3056
+ if (stockDecision && builtPath && existsSync(builtPath)) {
3057
+ const family = optionalString(stockDecision.template_family) || "selected";
3058
+ builtPages.push({
3059
+ page_id: specPage.id,
3060
+ type: specPage.type || "page",
3061
+ role: pageRole(specPage.type),
3062
+ route: publicRouteForPage(specPage),
3063
+ source_path: null,
3064
+ template_stock: true,
3065
+ template_family: family,
3066
+ });
3067
+ materialisedTemplateStock.push({ page_id: specPage.id, family });
3068
+ continue;
3069
+ }
3070
+ const skipped = specPage
3071
+ ? {
3072
+ page_id: specPage.id,
3073
+ type: specPage.type || "page",
3074
+ role: pageRole(specPage.type),
3075
+ route: publicRouteForPage(specPage),
3076
+ skip_reason: page.skip_reason,
3077
+ ...(stockDecision ? { template_stock: true, template_family: optionalString(stockDecision.template_family) } : {}),
3078
+ }
3079
+ : { page_id: page.page_id, type: "unknown", role: "unknown", route: null, skip_reason: page.skip_reason };
3080
+ outOfScopePages.push(skipped);
3081
+ addIssue(
3082
+ warnings,
3083
+ "source_html.pages.skip_reason",
3084
+ stockDecision
3085
+ ? `CampaignSpec page "${page.page_id}" is template stock and not built yet: ${page.skip_reason} Keep it unbuilt unless explicitly opted in. For an opted-in page, the build stage materialises it from the ${optionalString(stockDecision.template_family) || "selected"} family's own page; it joins the previewable routes once its built HTML exists.`
3086
+ : `CampaignSpec page "${page.page_id}" is out of scope for this partial build: ${page.skip_reason}`,
3087
+ );
3088
+ }
3089
+ }
3090
+
3091
+ if (materialisedTemplateStock.length > 0) {
3092
+ ready.push(`Template-stock page(s) materialised by the build stage: ${materialisedTemplateStock.map((entry) => `${entry.page_id} (${entry.family})`).join(", ")}`);
3093
+ }
3094
+
3095
+ for (const page of active) {
3096
+ if (!mappedIds.has(page.id)) {
3097
+ addIssue(errors, "source_html.pages.coverage", coverageErrorMessage(page), coverageErrorDetail(page));
3098
+ }
3099
+ }
3100
+
3101
+ const runtimeBlocked = outOfScopePages.filter((page) => page.role === "runtime");
3102
+ // A CampaignSpec build_scope "partial" declaration is discharged once every
3103
+ // page it took out of scope has been materialised: the declaration named
3104
+ // template-stock pages, and they now exist. With nothing materialised the
3105
+ // declaration stands on its own, as before.
3106
+ const partialScopeOpen = outOfScopePages.length > 0
3107
+ || (specPartialScope && materialisedTemplateStock.length === 0);
3108
+ derived.scope = {
3109
+ mode: partialScopeOpen ? "partial" : active.length ? "full" : "unknown",
3110
+ built_pages: builtPages,
3111
+ out_of_scope_pages: outOfScopePages,
3112
+ out_of_scope_reasons: specPartialReasons,
3113
+ previewable_routes: builtPages.map((page) => ({ page_id: page.page_id, type: page.type, route: page.route })),
3114
+ blocked_runtime_pages: runtimeBlocked,
3115
+ };
3116
+
3117
+ if (partialScopeOpen) {
3118
+ const reasonSummary = specPartialReasons.length ? ` Reasons: ${specPartialReasons.join("; ")}.` : "";
3119
+ addIssue(
3120
+ warnings,
3121
+ "scope.partial_build",
3122
+ `Partial build scope detected. Built/previewable pages: ${summarizeScopePages(builtPages) || "none"}; out-of-scope pages: ${summarizeScopePages(outOfScopePages) || "declared in CampaignSpec build_scope"}.${reasonSummary}`
3123
+ );
3124
+ const buildScopeRuntimeBlocked = specPartialReasons.some((reason) => /\b(checkout|upsell|downsell|receipt|thankyou|runtime)\b/i.test(reason));
3125
+ if (runtimeBlocked.length > 0 || buildScopeRuntimeBlocked) {
3126
+ addIssue(
3127
+ warnings,
3128
+ "scope.runtime_qa_blocked",
3129
+ `Checkout/runtime launch QA is blocked for out-of-scope pages: ${summarizeScopePages(runtimeBlocked) || "declared in CampaignSpec build_scope"}. Preview QA should cover only the built routes.`
3130
+ );
3131
+ }
3132
+ }
3133
+
3134
+ if (active.length > 0 && active.every((page) => mappedIds.has(page.id))) {
3135
+ ready.push(partialScopeOpen
3136
+ ? "Source mappings cover active CampaignSpec pages with explicit partial-scope skip reasons"
3137
+ : "Source mappings cover active CampaignSpec pages");
3138
+ }
3139
+ if (builtPages.length > 0) {
3140
+ ready.push(partialScopeOpen
3141
+ ? `Partial build previewable routes: ${builtPages.map((page) => routeLabel(page.route)).join(", ")}`
3142
+ : "All mapped CampaignSpec pages are build candidates");
3143
+ }
3144
+ }
3145
+
3146
+ // Source preparation check (#262). Runs at every doctor evaluation — start and
3147
+ // build embed doctor, so this is the start preflight the issue asks for while
3148
+ // staying re-checkable after source edits. Blocking findings surface as doctor
3149
+ // errors, which drive status "blocked" and a doctor-blocked / prepare-build next stage exactly
3150
+ // like other unprepared-input states. Detection and severity policy live in
3151
+ // src/source-prep.mjs; the codes and fixes are documented in
3152
+ // docs/source-adapters.md "Source preparation check".
3153
+ function validateSourcePreparation(packet, packetPath, errors, warnings, ready, derived = {}) {
3154
+ const sourceRoot = resolveFromFile(packetPath, packet.source_html?.root);
3155
+ if (!sourceRoot || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) return;
3156
+ const pages = Array.isArray(packet.source_html?.pages) ? packet.source_html.pages : [];
3157
+ const result = evaluateSourcePreparation({
3158
+ sourceRoot,
3159
+ pages,
3160
+ wrapperPolicy: packet.source_html?.adapter_contract?.wrapper_policy,
3161
+ });
3162
+ derived.source_preparation = {
3163
+ checked_page_count: result.checked_page_count,
3164
+ finding_codes: result.findings.map((finding) => finding.code),
3165
+ };
3166
+ for (const finding of result.findings) {
3167
+ addIssue(
3168
+ finding.severity === "error" ? errors : warnings,
3169
+ finding.code,
3170
+ finding.message,
3171
+ { pages: finding.pages, docs: finding.docs }
3172
+ );
3173
+ }
3174
+ if (result.checked_page_count > 0 && result.findings.length === 0) {
3175
+ ready.push("Mapped source pages pass the page-kit preparation check (document wrappers stripped, no leftover frontmatter, no source-file internal links)");
3176
+ }
3177
+ }
3178
+
3179
+ // The manifest prepare-build read is recorded on the Design Source Package
3180
+ // (html-funnel contribution, provenance.manifest_path, relative to the package
3181
+ // file). Doctor reads the same file back, so a manifest supplied through
3182
+ // --design-manifest from outside the source root is still the one doctor
3183
+ // validates; with nothing recorded, the default path under the source root
3184
+ // stands.
3185
+ function recordedDesignManifestPath(packet, packetPath) {
3186
+ const packagePath = resolveFromFile(packetPath, packet?.design_source_package?.path);
3187
+ if (!packagePath || !existsSync(packagePath) || !statSync(packagePath).isFile()) return null;
3188
+ let value;
3189
+ try {
3190
+ value = JSON.parse(readFileSync(packagePath, "utf8"));
3191
+ } catch {
3192
+ return null;
3193
+ }
3194
+ const htmlFunnel = (Array.isArray(value?.contributions) ? value.contributions : [])
3195
+ .find((contribution) => contribution?.kind === "html_funnel");
3196
+ const recorded = optionalString(htmlFunnel?.provenance?.manifest_path);
3197
+ return recorded ? resolve(dirname(packagePath), recorded) : null;
3198
+ }
3199
+
3200
+ function validateSourceHtmlManifestAtRoot(sourceRoot, { spec, errors, warnings, ready, manifestPath = null } = {}) {
3201
+ if (!isNonEmptyString(sourceRoot) || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) return;
3202
+ const result = readSourceHtmlManifestFile(sourceRoot, { manifestPath });
3203
+ if (!result.path) return;
3204
+ if (result.validation && !result.validation.ok) {
3205
+ const detail = result.validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
3206
+ addIssue(warnings, "source_html.manifest", `Source-html manifest failed ${SOURCE_HTML_MANIFEST_SCHEMA} validation: ${detail}. Re-run or fix the producer before relying on manifest-derived page mappings.`);
3207
+ return;
3208
+ }
3209
+ if (result.warning) {
3210
+ addIssue(warnings, "source_html.manifest", result.warning);
3211
+ return;
3212
+ }
3213
+ for (const warning of result.warnings || []) {
3214
+ addIssue(warnings, "source_html.manifest", warning);
3215
+ }
3216
+ validateSourceProducerProvenance(result.manifest, { spec, errors, warnings, ready });
3217
+ ready.push(`Source-html manifest ${SOURCE_HTML_MANIFEST_SCHEMA} validated`);
3218
+ }
3219
+
3220
+ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, ready }) {
3221
+ const generator = optionalString(manifest?.generator) || "";
3222
+ const rawProvenance = manifest?.producer_provenance;
3223
+ const provenance = isObject(rawProvenance) ? rawProvenance : {};
3224
+ const expectsFigma = generator.startsWith("figma-sections-export@") || activeSpecPages(spec).some(hasFigmaDesignSource);
3225
+ if (!expectsFigma) return;
3226
+
3227
+ if (!rawProvenance) {
3228
+ addIssue(
3229
+ errors,
3230
+ "source_html.producer_provenance",
3231
+ "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
3232
+ );
3233
+ }
3234
+
3235
+ if (provenance.source_type !== "semantic_figma_export") {
3236
+ addIssue(
3237
+ errors,
3238
+ "source_html.producer_provenance.source_type",
3239
+ `Figma source manifest source_type is "${provenance.source_type || "missing"}"; expected "semantic_figma_export". Screenshot or hand-authored fallback output cannot satisfy the Figma provenance gate.`
3240
+ );
3241
+ }
3242
+ if (provenance.screenshot_fallback_used !== false) {
3243
+ addIssue(
3244
+ errors,
3245
+ "source_html.producer_provenance.screenshot_fallback_used",
3246
+ "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
3247
+ );
3248
+ }
3249
+ if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
3250
+ addIssue(
3251
+ errors,
3252
+ "source_html.producer_provenance.semantic_section_count",
3253
+ "Figma source manifest must report semantic_section_count > 0."
3254
+ );
3255
+ }
3256
+ if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
3257
+ addIssue(
3258
+ errors,
3259
+ "source_html.producer_provenance.material_fingerprint",
3260
+ "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
3261
+ );
3262
+ }
3263
+
3264
+ const files = Array.isArray(manifest?.files) ? manifest.files : [];
3265
+ if (!files.some((entry) => entry.role === "partial")) {
3266
+ addIssue(errors, "source_html.files.partial", "Figma source manifest files[] must include section partials.");
3267
+ }
3268
+ if (!files.some((entry) => entry.role === "asset")) {
3269
+ addIssue(errors, "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
3270
+ }
3271
+
3272
+ const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
3273
+ if (!sectionExports.length) {
3274
+ addIssue(errors, "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
3275
+ } else {
3276
+ const withoutNodeIds = sectionExports
3277
+ .filter((entry) => {
3278
+ const nodeIds = isObject(entry?.node_ids) ? entry.node_ids : null;
3279
+ return !nodeIds || !Object.keys(nodeIds).length;
3280
+ })
3281
+ .map((entry) => entry?.section || "unknown");
3282
+ if (withoutNodeIds.length) {
3283
+ addIssue(
3284
+ warnings,
3285
+ "source_html.producer_provenance.section_exports.node_ids",
3286
+ `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
3287
+ );
3288
+ }
3289
+ }
3290
+
3291
+ if (errors.every((issue) => !String(issue.code || "").startsWith("source_html.producer_provenance") && !["source_html.files.partial", "source_html.files.asset"].includes(issue.code))) {
3292
+ ready.push("Figma producer provenance gate passed: semantic_figma_export with package fingerprint");
3293
+ }
3294
+ }
3295
+
3296
+ function hasFigmaDesignSource(page) {
3297
+ const designSource = page && isObject(page.design_source) ? page.design_source : null;
3298
+ return Boolean(designSource && (String(designSource.type || "").toLowerCase() === "figma" || /figma\.com\//i.test(optionalString(designSource.file_url))));
3299
+ }
3300
+
3301
+ // Helpers ported from the private build-packet doctor (ADR-003 step 2) so the
3302
+ // shared-concern template-contract checks live here in the public doctor too.
3303
+ function frontmatterList(contract, key) {
3304
+ const value = contract?.frontmatter?.[key];
3305
+ return Array.isArray(value) ? value.map((item) => String(item)) : [];
3306
+ }
3307
+ function contractMentions(contract, pattern, keys = ["requiredWhenCloning", "replaceFromSpecOrApi"]) {
3308
+ return keys.flatMap((key) => frontmatterList(contract, key)).some((value) => pattern.test(value));
3309
+ }
3310
+ function packageRefsFromEntries(entries) {
3311
+ if (!Array.isArray(entries)) return [];
3312
+ return entries
3313
+ .map((entry) => entry?.ref_id ?? entry?.package_ref_id ?? entry?.package_id ?? entry?.id)
3314
+ .filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
3315
+ .map((value) => String(value));
3316
+ }
3317
+ function offerRefsFromEntries(entries) {
3318
+ if (!Array.isArray(entries)) return [];
3319
+ return entries
3320
+ .flatMap((entry) => [entry?.package_ref_id, entry?.package_id, entry?.ref_id, entry?.code])
3321
+ .filter((value) => value !== undefined && value !== null && String(value).trim().length > 0)
3322
+ .map((value) => String(value));
3323
+ }
3324
+
3325
+ function isAutomatableTemplateFamily(family) {
3326
+ return isNonEmptyString(family) && family !== "undecided" && family !== "custom";
3327
+ }
3328
+
3329
+ // One resolution of the family brand contract per doctor run, keyed by the
3330
+ // run's `derived` — the in-process bag every check reads. The JSON-visible
3331
+ // summary lands on derived.brand_contract: `state` (no_family, no_contract,
3332
+ // no_palette_checks, inspected, defect), `family`, and for a defect the
3333
+ // loader's code and a one-line detail — the shape the `next` advisories read.
3334
+ // The contract object itself stays in process. A defect is reported once, by
3335
+ // the first check that reports it, at that check's severity: before this, a
3336
+ // standard packet with an unreadable contract carried the same finding twice
3337
+ // (an error from the catalog check and a warning from the pricing scan).
3338
+ const BRAND_CONTRACT_RESOLUTIONS = new WeakMap();
3339
+
3340
+ // The one resolution: the contract object (or the loader's error) and the
3341
+ // summary every consumer reads. null is "resolved to no contract", never
3342
+ // "something went wrong": no public contract file AND no private fragment
3343
+ // carrying a brandContract, for which QA emits no residue rows. A defect
3344
+ // throws instead; the two must not be collapsed.
3345
+ function resolveBrandContract(family) {
3346
+ const label = optionalString(family);
3347
+ if (!label) return { contract: null, error: null, summary: { state: "no_family", family: null } };
3348
+ try {
3349
+ const contract = resolveTemplateBrandContract(label);
3350
+ const state = contract ? (contractHasPaletteResidueChecks(contract) ? "inspected" : "no_palette_checks") : "no_contract";
3351
+ return { contract, error: null, summary: { state, family: label } };
3352
+ } catch (error) {
3353
+ return {
3354
+ contract: null,
3355
+ error,
3356
+ summary: {
3357
+ state: "defect",
3358
+ family: label,
3359
+ code: safeBrandContractCode(error?.code),
3360
+ detail: singleLineDetail(error instanceof Error ? error.message : error),
3361
+ },
3362
+ };
3363
+ }
3364
+ }
3365
+
3366
+ function resolveBrandContractOnce(derived, family) {
3367
+ if (BRAND_CONTRACT_RESOLUTIONS.has(derived)) return BRAND_CONTRACT_RESOLUTIONS.get(derived);
3368
+ const { contract, error, summary } = resolveBrandContract(family);
3369
+ derived.brand_contract = summary;
3370
+ const resolution = { contract, error, reported: false };
3371
+ BRAND_CONTRACT_RESOLUTIONS.set(derived, resolution);
3372
+ return resolution;
3373
+ }
3374
+
3375
+ function reportBrandContractDefectOnce(resolution, collection, family) {
3376
+ if (!resolution.error || resolution.reported) return;
3377
+ resolution.reported = true;
3378
+ addIssue(
3379
+ collection,
3380
+ "template_contract.brand_contract",
3381
+ `Template brand contract for "${family}" failed to load: ${resolution.error.message}`,
3382
+ templateBrandContractErrorDetail(resolution.error, family),
3383
+ );
3384
+ }
3385
+
3386
+ function loadTemplateFamilyBrandContract(family, errors, warnings, derived, { required = false } = {}) {
3387
+ const resolution = resolveBrandContractOnce(derived, family);
3388
+ if (resolution.error) {
3389
+ reportBrandContractDefectOnce(resolution, required ? errors : warnings, family);
3390
+ return null;
3391
+ }
3392
+ if (!resolution.contract && required) {
3393
+ addIssue(
3394
+ errors,
3395
+ "template_contract.brand_contract",
3396
+ `Template family "${family}" has no brand/residue/pricing contract at contracts/template-brand-contract.${family}.v0.json. Add the contract before treating this family as promoted/agent-ready.`,
3397
+ {
3398
+ template_family: family,
3399
+ reason: "missing_file",
3400
+ contract_path: `contracts/template-brand-contract.${family}.v0.json`,
3401
+ },
3402
+ );
3403
+ }
3404
+ return resolution.contract;
3405
+ }
3406
+
3407
+ function templateBrandContractErrorDetail(error, family) {
3408
+ return {
3409
+ template_family: family || null,
3410
+ reason: typeof error?.code === "string" ? error.code : "load_error",
3411
+ message: error instanceof Error ? error.message : String(error),
3412
+ };
3413
+ }
3414
+
3415
+ export function validateCommerceCatalog(packet, packetPath, spec, errors, warnings, ready, derived = {}, buildState = {}) {
3416
+ const family = packet.assembly?.template_family;
3417
+ // Match the private doctor (build-packet.js): the ported template_contract.*
3418
+ // checks below do not apply to non-automatable families. The pre-existing
3419
+ // agentContract / demo_ref / shipping checks keep running for all families.
3420
+ const familyAutomatable = isAutomatableTemplateFamily(family);
3421
+ const catalogInfo = packet.assembly?.commerce_catalog || {};
3422
+ if (catalogInfo.required !== true) return;
3423
+ const catalogResolution = resolvePacketCommerceCatalogPath(packetPath, catalogInfo);
3424
+ const catalogPath = catalogResolution.path;
3425
+ if (!catalogPath || !existsSync(catalogPath)) {
3426
+ addIssue(errors, "assembly.commerce_catalog.path", "Commerce catalog is required but not found.");
3427
+ return;
3428
+ }
3429
+ if (catalogResolution.source === "stale_packet_path") {
3430
+ // Not a warning: the catalog resolved and nothing about the build changes.
3431
+ // The line tells the operator the packet still names one machine's
3432
+ // checkout, and that a re-prepare records the toolkit default (null).
3433
+ ready.push(
3434
+ `Commerce catalog resolved to the running toolkit's copy; the packet's recorded path ${catalogResolution.recorded} ` +
3435
+ "does not exist here (it names the checkout that ran prepare-build). Re-run prepare-build to clear the machine-local path.",
3436
+ );
3437
+ }
3438
+ const catalog = resolveCommerceCatalog(catalogPath);
3439
+ if (familyAutomatable && catalog.agentContractVersion !== 1) {
3440
+ addIssue(warnings, "template_contract.catalog_version", "Commerce surface catalog agentContractVersion is not 1; verify contract semantics before build.");
3441
+ }
3442
+ if (!isObject(catalog.sharedFrontmatterVocabulary)) {
3443
+ addIssue(errors, "catalog.sharedFrontmatterVocabulary", "Commerce catalog is missing sharedFrontmatterVocabulary.");
3444
+ } else {
3445
+ ready.push("Commerce catalog sharedFrontmatterVocabulary loaded");
3446
+ }
3447
+ const contract = catalog.families?.[family]?.agentContract;
3448
+ if (!contract) {
3449
+ addIssue(errors, "template_contract.agentContract", `Template family "${family}" has no agentContract.`);
3450
+ return;
3451
+ }
3452
+ ready.push(`Template agentContract loaded for ${family}`);
3453
+ const brandContract = loadTemplateFamilyBrandContract(family, errors, warnings, derived, { required: familyAutomatable });
3454
+ if (brandContract) {
3455
+ ready.push(`Template brand/residue/pricing contract loaded for ${family}`);
3456
+ validateTemplateFamilyInventory(brandContract, errors, ready);
3457
+ }
3458
+ if (familyAutomatable && contract.status && contract.status !== "agent-ready") {
3459
+ addIssue(warnings, "template_contract.status", `Template family "${family}" contract status is "${contract.status}"; treat this as guided assembly, not full automation.`);
3460
+ }
3461
+ const assemblyComplete = isStageComplete(buildState.report, "assembly");
3462
+ if (assemblyComplete) {
3463
+ validateBuiltContractResidue(contract, warnings, ready, derived, spec);
3464
+ // H3.1/H3.2: pre-QA warnings off the family brand contract. Doctor warns
3465
+ // (the fix happens during build/polish); browser QA enforces the same
3466
+ // placeholder-text terms as a blocker in the verdict.
3467
+ validateBuiltPlaceholderTextResidue(brandContract, warnings, ready, derived, { report: buildState.report });
3468
+ validateBuiltDemoAssetFidelity(brandContract, warnings, ready, derived);
3469
+ } else {
3470
+ for (const value of contract.frontmatter?.demoOnlyValues || []) {
3471
+ addIssue(warnings, "frontmatter.demoOnlyValues", `Replace demo-only starter value before launch: ${value}`);
3472
+ }
3473
+ for (const value of contract.frontmatter?.replaceFromSpecOrApi || []) {
3474
+ addIssue(warnings, "frontmatter.replaceFromSpecOrApi", `Must be replaced from CampaignSpec/API: ${value}`);
3475
+ }
3476
+ for (const value of contract.frontmatter?.removeWhenUnsupported || []) {
3477
+ addIssue(warnings, "frontmatter.removeWhenUnsupported", `Remove when unsupported by target campaign: ${value}`);
3478
+ }
3479
+ }
3480
+ const consumesExplicitShipping = contractMentionsShipping(contract);
3481
+ if (family === "shop-three-step") {
3482
+ ready.push("shop-three-step uses dynamic shipping via window.next.getShippingMethods(); do not copy Olympus-style shipping_methods frontmatter into it.");
3483
+ }
3484
+ if (!consumesExplicitShipping) {
3485
+ validateUnsupportedShippingFrontmatter(packet, packetPath, family, warnings, ready, derived);
3486
+ } else if (spec && !Array.isArray(spec.shipping_methods)) {
3487
+ addIssue(errors, "template_contract.shipping_methods", `${family} contract references shipping_methods but CampaignSpec has no shipping_methods array.`);
3488
+ }
3489
+ if (spec) {
3490
+ const demoRefHits = collectDemoRefHits(spec, catalog.sharedFrontmatterVocabulary);
3491
+ for (const hit of demoRefHits) {
3492
+ addIssue(
3493
+ warnings,
3494
+ "template_contract.demo_ref",
3495
+ `CampaignSpec contains a starter-looking demo ref "${hit.value}" at ${hit.path}. Confirm it came from the actual Campaigns API or attach _provenance.api/source metadata.`
3496
+ );
3497
+ }
3498
+
3499
+ // ADR-003 step 2: template-contract checks ported from the private doctor.
3500
+ const specPages = activeSpecPages(spec);
3501
+
3502
+ const mismatchedFamilies = specPages.filter(
3503
+ (page) => isNonEmptyString(page.sdk_hints?.template_family) && page.sdk_hints.template_family !== family
3504
+ );
3505
+ if (familyAutomatable && mismatchedFamilies.length > 0) {
3506
+ addIssue(
3507
+ errors,
3508
+ "template_contract.spec_family",
3509
+ `CampaignSpec sdk_hints.template_family disagrees with packet template family on pages: ${mismatchedFamilies.map((page) => `${page.id}=${page.sdk_hints.template_family}`).join(", ")}.`
3510
+ );
3511
+ }
3512
+
3513
+ const checkoutPackageRefs = specPages
3514
+ .filter((page) => page.type === "checkout" || page.type === "select")
3515
+ .flatMap((page) => packageRefsFromEntries(page.packages));
3516
+ if (familyAutomatable && contractMentions(contract, /\b(packages\.main_package|single_offer\.package_id|variant_slots)\b/) && checkoutPackageRefs.length === 0) {
3517
+ addIssue(
3518
+ errors,
3519
+ "template_contract.packages",
3520
+ `Template family "${family}" requires checkout package frontmatter, but the active CampaignSpec checkout/select pages have no package refs.`
3521
+ );
3522
+ }
3523
+
3524
+ const upsellPages = specPages.filter((page) => page.type === "upsell" || page.type === "downsell");
3525
+ const upsellPackageRefs = upsellPages.flatMap((page) => [
3526
+ ...packageRefsFromEntries(page.packages),
3527
+ ...offerRefsFromEntries(page.offers),
3528
+ ]);
3529
+ if (
3530
+ familyAutomatable &&
3531
+ contractMentions(contract, /\b(upsell_offer|upsell_bundle_tiers|inline upsell)\b/, ["optionalWhenSupported", "replaceFromSpecOrApi", "demoOnlyValues"]) &&
3532
+ upsellPages.length > 0 &&
3533
+ upsellPackageRefs.length === 0
3534
+ ) {
3535
+ addIssue(
3536
+ errors,
3537
+ "template_contract.upsell_refs",
3538
+ `Template family "${family}" exposes upsell frontmatter, but active upsell/downsell pages have no package or offer refs to replace demo values.`
3539
+ );
3540
+ }
3541
+ validateCodeLessUpsellOfferBinding({ familyAutomatable, contract, family, upsellPages, errors, ready });
3542
+ validateExitPopContract(brandContract, spec, family, warnings, ready, derived, buildState);
3543
+ }
3544
+ }
3545
+
3546
+ function validateCodeLessUpsellOfferBinding({ familyAutomatable, contract, family, upsellPages, errors, ready }) {
3547
+ if (!familyAutomatable || !Array.isArray(upsellPages) || upsellPages.length === 0) return;
3548
+ const usesVoucherJson = contractMentions(contract, /\bvouchers_json\b/, ["replaceFromSpecOrApi", "demoOnlyValues", "optionalWhenSupported"]);
3549
+ const supportsOfferRef = contractMentions(contract, /\boffer_ref(?:_id)?\b/, ["replaceFromSpecOrApi", "demoOnlyValues", "optionalWhenSupported", "requiredWhenCloning"]);
3550
+ if (!usesVoucherJson || supportsOfferRef) return;
3551
+
3552
+ const codeLessDiscountOffers = [];
3553
+ for (const page of upsellPages) {
3554
+ for (const offer of Array.isArray(page.offers) ? page.offers : []) {
3555
+ if (!isCodeLessDiscountOffer(offer)) continue;
3556
+ codeLessDiscountOffers.push({
3557
+ page_id: page.id,
3558
+ label: page.label || null,
3559
+ offer_ref_id: offer.ref_id || offer.id || null,
3560
+ benefit_type: offer.benefit?.type || null,
3561
+ benefit_value: offer.benefit?.value || null,
3562
+ });
3563
+ }
3564
+ }
3565
+
3566
+ if (!codeLessDiscountOffers.length) {
3567
+ ready.push(`${family} upsell offers have code-backed discounts or no code-less discount offer binding requirement`);
3568
+ return;
3569
+ }
3570
+
3571
+ addIssue(
3572
+ errors,
3573
+ "template_contract.upsell_offer_binding",
3574
+ `Template family "${family}" exposes post-purchase upsell vouchers_json but CampaignSpec has code-less discount offer refs: ${codeLessDiscountOffers.map((offer) => `${offer.page_id}:${offer.offer_ref_id || "unknown"}`).join(", ")}. Add a supported offer-ref binding to the template/SDK adapter, use a code-backed offer, or block assembly; otherwise accepted upsells can commit at full price.`,
3575
+ { offers: codeLessDiscountOffers, template_family: family }
3576
+ );
3577
+ }
3578
+
3579
+ function isCodeLessDiscountOffer(offer) {
3580
+ if (!isObject(offer)) return false;
3581
+ if (isNonEmptyString(offer.code)) return false;
3582
+ if (offer.ref_id === undefined && offer.id === undefined) return false;
3583
+ const benefit = isObject(offer.benefit) ? offer.benefit : null;
3584
+ const benefitType = String(benefit?.type || "").toLowerCase();
3585
+ const value = benefit?.value;
3586
+ if (!benefit || !/(percentage|percent|discount|fixed|amount)/.test(benefitType)) return false;
3587
+ if (value === undefined || value === null) return false;
3588
+ return String(value).trim().length > 0 && Number.isFinite(Number(value));
3589
+ }
3590
+
3591
+ export function validateTemplateFamilyInventory(contract, errors, ready) {
3592
+ const inventory = contract.family_inventory;
3593
+ if (!isObject(inventory)) {
3594
+ addIssue(errors, "template_contract.family_inventory", `Template brand contract for "${contract.family}" is missing family_inventory.`);
3595
+ return;
3596
+ }
3597
+ const required = [
3598
+ "supported_pages",
3599
+ "required_sdk_anchors",
3600
+ "theme_insertion_point",
3601
+ "default_color_residue",
3602
+ "pricing_presentation",
3603
+ "bundle_picker",
3604
+ "order_bump",
3605
+ "upsell_downsell",
3606
+ "exit_pop",
3607
+ "qa_selectors",
3608
+ ];
3609
+ const missing = required.filter((key) => !hasPopulatedInventoryValue(inventory[key]));
3610
+ if (missing.length) {
3611
+ addIssue(errors, "template_contract.family_inventory", `Template brand contract for "${contract.family}" family_inventory is missing or empty: ${missing.join(", ")}.`);
3612
+ return;
3613
+ }
3614
+ ready.push(`Template family inventory matrix loaded for ${contract.family}`);
3615
+ }
3616
+
3617
+ function hasPopulatedInventoryValue(value) {
3618
+ if (value === undefined || value === null) return false;
3619
+ if (typeof value === "string") return value.trim().length > 0;
3620
+ if (Array.isArray(value)) return value.length > 0;
3621
+ if (isObject(value)) return Object.values(value).some((entry) => hasPopulatedInventoryValue(entry));
3622
+ return true;
3623
+ }
3624
+
3625
+ export function validateExitPopContract(contract, spec, family, warnings, ready, derived, buildState = {}) {
3626
+ const exitPop = contract?.exit_pop;
3627
+ if (!exitPop || !spec) return;
3628
+ const hasGovernedOfferSurface = activeSpecPages(spec).some((page) => (
3629
+ page?.type === "checkout" && (page?.exit_intent?.enabled === true || page?.promo_code_input?.enabled === true)
3630
+ ));
3631
+ if (hasGovernedOfferSurface) {
3632
+ ready.push(`${family} exit-pop/promo-code behavior is governed by CampaignSpec offer-surface fields`);
3633
+ return;
3634
+ }
3635
+
3636
+ const inventoryExitPop = contract.family_inventory?.exit_pop;
3637
+ if (!isStageComplete(buildState.report, "assembly")) {
3638
+ if (inventoryExitPop?.default_included === true) {
3639
+ addIssue(
3640
+ warnings,
3641
+ "template_contract.exit_pop",
3642
+ `Template family "${family}" includes an exit-pop by default, but active CampaignSpec checkout pages do not define exit_intent or promo_code_input. Strip the widget or wire a mapped offer/code through the SDK coupon path during build.`
3643
+ );
3644
+ }
3645
+ return;
3646
+ }
3647
+
3648
+ const targetOutputDir = derived.target_output_dir;
3649
+ if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return;
3650
+ const residueHits = collectLiteralMatches(targetOutputDir, exitPop.residue_literals || []);
3651
+ if (residueHits.length) {
3652
+ addIssue(
3653
+ warnings,
3654
+ "template_contract.exit_pop_residue",
3655
+ `Assembly is recorded complete, but target output contains exit-pop/template offer residue while CampaignSpec has no checkout exit_intent or promo_code_input: ${summarizeCopyMatches(residueHits)}. Strip it or add a mapped offer surface.`
3656
+ );
3657
+ } else {
3658
+ ready.push(`Built target output has no ungoverned ${family} exit-pop residue`);
3659
+ }
3660
+ const blankHits = collectLiteralMatches(targetOutputDir, exitPop.blank_widget_literals || []);
3661
+ if (blankHits.length) {
3662
+ addIssue(
3663
+ warnings,
3664
+ "template_contract.exit_pop_blank_widget",
3665
+ `Assembly is recorded complete, but target output still contains default/blank exit-pop widget copy or coupon placeholders: ${summarizeCopyMatches(blankHits)}.`
3666
+ );
3667
+ }
3668
+ }
3669
+
3670
+ function validateBuiltContractResidue(contract, warnings, ready, derived, spec = null) {
3671
+ const targetOutputDir = derived.target_output_dir;
3672
+ if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) {
3673
+ addIssue(warnings, "frontmatter.build_state", "Assembly is recorded complete, but doctor cannot scan the target output directory for remaining starter contract residue.");
3674
+ return;
3675
+ }
3676
+
3677
+ const literalValues = [
3678
+ ...(contract.frontmatter?.demoOnlyValues || []),
3679
+ ...(contract.frontmatter?.removeWhenUnsupported || []),
3680
+ ].filter((value) => isNonEmptyString(String(value)) && !String(value).includes("."));
3681
+ const hits = collectLiteralMatches(targetOutputDir, literalValues);
3682
+ if (!hits.length) {
3683
+ ready.push("Built target output has no obvious demo-only or unsupported starter contract residue");
3684
+ } else {
3685
+ addIssue(
3686
+ warnings,
3687
+ "frontmatter.build_residue",
3688
+ `Assembly is recorded complete, but target output still contains starter contract residue: ${summarizeCopyMatches(hits)}.`
3689
+ );
3690
+ }
3691
+
3692
+ const genericHits = collectGenericTemplateResidueMatches(targetOutputDir);
3693
+ if (genericHits.length) {
3694
+ addIssue(
3695
+ warnings,
3696
+ "template_contract.literal_residue",
3697
+ `Assembly is recorded complete, but built output still contains generic starter/template placeholders: ${summarizeCopyMatches(genericHits)}. Replace these from CampaignSpec/API or remove dead template references before QA.`
3698
+ );
3699
+ } else {
3700
+ ready.push("Built target output has no generic starter placeholder or promo-code residue");
3701
+ }
3702
+
3703
+ const maxDiscount = maxSpecDiscountPercent(spec);
3704
+ if (maxDiscount !== null) {
3705
+ const discountHits = collectOverstatedDiscountClaimMatches(targetOutputDir, maxDiscount);
3706
+ if (discountHits.length) {
3707
+ addIssue(
3708
+ warnings,
3709
+ "template_contract.discount_claim_residue",
3710
+ `Assembly is recorded complete, but built output claims discount percentages above the CampaignSpec maximum (${formatPercent(maxDiscount)}): ${summarizeCopyMatches(discountHits)}. Generate promo/banner/timer copy from actual offers and vouchers.`
3711
+ );
3712
+ } else {
3713
+ ready.push(`Built target output has no promo discount claims above CampaignSpec max (${formatPercent(maxDiscount)})`);
3714
+ }
3715
+ } else {
3716
+ const discountClaims = collectDiscountClaimMatches(targetOutputDir);
3717
+ if (discountClaims.length) {
3718
+ addIssue(
3719
+ warnings,
3720
+ "template_contract.discount_claim_unverified",
3721
+ `Assembly is recorded complete, but built output contains promo discount percentage claims without explicit CampaignSpec percentage discount values: ${summarizeCopyMatches(discountClaims)}. Confirm the intended business logic with the build request/merchant notes or remove the claims before launch.`
3722
+ );
3723
+ } else {
3724
+ ready.push("Built target output has no promo discount percentage claims requiring CampaignSpec verification");
3725
+ }
3726
+ }
3727
+ }
3728
+
3729
+ // H3.1 (doctor surface): literal placeholder TEXT in built HTML. Word-boundary
3730
+ // matched off the family brand contract's placeholder_text_residue.terms, so
3731
+ // the doctor warning and the browser QA blocker key off one declared term set.
3732
+ // Scans the VISIBLE text of each page (not the includes/layouts the family
3733
+ // ships), the same surface the browser gate reads: an `<input
3734
+ // placeholder="Placeholder">` hint, a data-* hook, a comment or a script
3735
+ // string is not rendered copy and must not warn where QA would pass.
3736
+ //
3737
+ // `report`: once QA has recorded this gate as passed on the current build
3738
+ // (qaGatePassedForCurrentBuild), the browser's verdict outranks this static
3739
+ // approximation — any remaining static hit demotes to a ready line instead of
3740
+ // a warning, so `next` stops asking for a fix QA already cleared. A rebuild
3741
+ // changes the fingerprint and the warning returns until QA runs again.
3742
+ export function validateBuiltPlaceholderTextResidue(brandContract, warnings, ready, derived, { report = null } = {}) {
3743
+ const config = placeholderTextResidueConfig(brandContract);
3744
+ if (!config) return;
3745
+ const targetOutputDir = derived.target_output_dir;
3746
+ if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return;
3747
+ const hits = collectPlaceholderTextResidueMatches(targetOutputDir, config.terms);
3748
+ if (!hits.length) {
3749
+ ready.push("Built target output has no literal template placeholder text");
3750
+ return;
3751
+ }
3752
+ const terms = [...new Set(hits.map((hit) => hit.label))].join(", ");
3753
+ if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report) })) {
3754
+ ready.push(`Static scan still sees placeholder-term text (${terms}: ${summarizeCopyMatches(hits)}), but the browser residue gate passed on this build; QA's rendered-text verdict stands.`);
3755
+ return;
3756
+ }
3757
+ addIssue(
3758
+ warnings,
3759
+ "template_contract.placeholder_text_residue",
3760
+ `Assembly is recorded complete, but built output still contains literal template placeholder text (${terms}): ${summarizeCopyMatches(hits)}. Replace with CampaignSpec/design copy; browser QA blocks on these terms.`,
3761
+ );
3762
+ }
3763
+
3764
+ function collectPlaceholderTextResidueMatches(root, terms) {
3765
+ const matches = [];
3766
+ for (const file of collectHtmlFiles(root)) {
3767
+ if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
3768
+ const content = visibleText(readHtmlScanText(join(root, file.path)), { keepLines: true });
3769
+ for (const match of placeholderTextResidueMatches(content, terms)) {
3770
+ matches.push({
3771
+ surface: "target",
3772
+ path: file.path,
3773
+ line: lineNumberAt(content, match.index || 0),
3774
+ label: match.term,
3775
+ text: match.match,
3776
+ });
3777
+ }
3778
+ }
3779
+ return matches;
3780
+ }
3781
+
3782
+ // Rendered-output content-residue scan (assembly-surfaces prototype): scans
3783
+ // BUILT pages for the needs-merchant-input marker and urgency chrome rendered
3784
+ // without verified offer urgency (blockers), plus demo/placeholder residue and
3785
+ // generic content anti-pattern hits (warnings feeding the review/attestation
3786
+ // queue). Scans _site output, not frontmatter — layout/script-rendered chrome
3787
+ // only exists after the build.
3788
+ export function validateBuiltContentResidue(packet, errors, warnings, ready, derived, buildState = {}) {
3789
+ if (!isStageComplete(buildState.report, "assembly")) return;
3790
+ // Scan the RENDERED _site output, not the campaign source dir: the Liquid
3791
+ // guards ({% if countdown_label %}) only resolve at build time, and layout/
3792
+ // script-rendered chrome only exists in the built pages.
3793
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
3794
+ const siteRoot = derived.target_repo && publicRouteSlug ? join(derived.target_repo, "_site", publicRouteSlug) : null;
3795
+ if (!siteRoot || !existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) return;
3796
+ const brief = loadBriefPayload(derived.target_repo);
3797
+ const urgencyVerified = briefUrgencyVerified(brief?.payload);
3798
+ const findings = scanBuiltOutputContentResidue(siteRoot, { urgencyVerified });
3799
+ if (!findings.length) {
3800
+ ready.push("Built output carries no needs-input markers, unverified urgency chrome, or content-residue hits");
3801
+ return;
3802
+ }
3803
+ // One issue per finding id, carrying the FULL file inventory: the id keeps
3804
+ // the issue list readable (a demo term on every page is one problem, not
3805
+ // five), while the file list tells the operator everything to fix.
3806
+ const byId = new Map();
3807
+ for (const finding of findings) {
3808
+ const group = byId.get(finding.id) || { first: finding, files: new Set() };
3809
+ group.files.add(finding.file);
3810
+ byId.set(finding.id, group);
3811
+ }
3812
+ const describeFiles = (files) => {
3813
+ const list = [...files].sort();
3814
+ const shown = list.slice(0, 5).join(", ");
3815
+ return list.length > 5 ? `${list.length} pages: ${shown}, …` : shown;
3816
+ };
3817
+ // The AI-assembled path is "a READABLE brief payload exists" — an unreadable
3818
+ // one already blocks via proof_attestation.unreadable, and pointing the
3819
+ // operator at offer.urgency.verified inside a file that cannot be parsed
3820
+ // would be wrong remediation copy.
3821
+ const briefReadable = Boolean(brief && !brief.error && brief.payload);
3822
+ for (const [id, group] of byId) {
3823
+ const finding = group.first;
3824
+ const where = describeFiles(group.files);
3825
+ if (id === "needs_merchant_input_marker") {
3826
+ addIssue(
3827
+ errors,
3828
+ "content_residue.needs_merchant_input",
3829
+ `Built output still renders needs-merchant-input marker(s) (${where}; e.g. "${finding.excerpt}"). Collect the missing merchant input (attestation lane) before publish.`,
3830
+ );
3831
+ } else if (id === "unverified_urgency_countdown") {
3832
+ // The scanner emits this only when the brief does not verify urgency
3833
+ // (urgencyVerified=false). Severity keys on the path: with a readable
3834
+ // brief payload (AI-assembled) it blocks; a designed-source campaign
3835
+ // with no brief payload may carry an intentional countdown — warning.
3836
+ if (briefReadable) {
3837
+ addIssue(
3838
+ errors,
3839
+ "content_residue.unverified_urgency",
3840
+ `Built output renders countdown chrome without verified offer urgency (${where}). Set offer.urgency.verified in ${BRIEF_PAYLOAD_REL_PATH} from a real promotion window, or blank the urgency slots.`,
3841
+ );
3842
+ } else if (brief?.error) {
3843
+ // Unreadable brief payload: the run is already blocked by
3844
+ // proof_attestation.unreadable with the right remediation (repair the
3845
+ // brief). Urgency is re-evaluated on the next doctor run once the
3846
+ // brief parses — "confirm the urgency is real" copy here would point
3847
+ // the operator at the wrong fix.
3848
+ } else {
3849
+ addIssue(
3850
+ warnings,
3851
+ "content_residue.urgency_unattested",
3852
+ `Built output renders countdown chrome and no brief payload attests the promotion window (${where}). Confirm the urgency is real (a genuine offer window or live inventory) before launch.`,
3853
+ );
3854
+ }
3855
+ } else if (id === "demo_residue_term" || id === "bracket_placeholder_stub") {
3856
+ addIssue(
3857
+ warnings,
3858
+ "content_residue.demo_residue",
3859
+ `Built output carries template demo/placeholder residue (${where}; e.g. "${finding.excerpt}"). Fill or blank the slot — demo values must never ship.`,
3860
+ );
3861
+ } else {
3862
+ addIssue(
3863
+ warnings,
3864
+ "content_residue.anti_pattern",
3865
+ `Built output matches content anti-pattern "${id}" (${where}; e.g. "${finding.excerpt}"). ${finding.rule || "Remove it or route it through brief-sourced proof."} This is a review warning and nothing downstream blocks on it: the claim is the operator's and the client's responsibility — remove or evidence it, never make it more plausible.`,
3866
+ );
3867
+ }
3868
+ }
3869
+ }
3870
+
3871
+ // Proof-attestation gate over the brief payload's proof_assets (click-wrap
3872
+ // lane). Usable = verified:true OR attestation_status:"accepted". Enforcement
3873
+ // keys on whether a non-usable asset's content actually SHIPPED in built
3874
+ // output: shipped pending/non-attestable content blocks (collect-inputs);
3875
+ // non-shipped, non-usable assets stay warnings (the attestation queue), so a
3876
+ // producer that correctly excluded them is not blocked.
3877
+ export function validateProofAttestation(packet, errors, warnings, ready, derived, buildState = {}) {
3878
+ const brief = loadBriefPayload(derived.target_repo);
3879
+ if (!brief) return; // not an AI-assembled run — no brief payload, no gate
3880
+ if (brief.error) {
3881
+ // Fail closed: the brief payload is the source of attestation state. If
3882
+ // it exists but cannot be read, unapproved proof could ship unevaluated.
3883
+ addIssue(errors, "proof_attestation.unreadable", `Brief payload at ${BRIEF_PAYLOAD_REL_PATH} failed to parse: ${brief.error}. Repair or regenerate it — the attestation gate cannot evaluate proof states.`);
3884
+ return;
3885
+ }
3886
+ const proofAssets = brief.payload?.proof_assets;
3887
+ if (!Array.isArray(proofAssets) || proofAssets.length === 0) {
3888
+ ready.push("Brief payload declares no proof assets (scenario-framing fallback applies)");
3889
+ return;
3890
+ }
3891
+ const assemblyComplete = isStageComplete(buildState.report, "assembly");
3892
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
3893
+ const siteRoot = derived.target_repo && publicRouteSlug ? join(derived.target_repo, "_site", publicRouteSlug) : null;
3894
+ let renderedText = "";
3895
+ if (assemblyComplete && siteRoot && existsSync(siteRoot)) {
3896
+ renderedText = collectRenderedHtmlFiles(siteRoot)
3897
+ .map((file) => readFileSync(file, "utf8"))
3898
+ .join("\n");
3899
+ }
3900
+ const findings = evaluateProofAssets(proofAssets, renderedText);
3901
+ const usable = findings.filter((f) => f.state === "verified" || f.state === "accepted").length;
3902
+ const pending = findings.filter((f) => f.state === "pending");
3903
+ const nonAttestable = findings.filter((f) => f.state === "non_attestable");
3904
+ if (!assemblyComplete) {
3905
+ // Shipped-content evaluation needs built output; before assembly there is
3906
+ // nothing to judge and a "none shipped" warning would be both misleading
3907
+ // and noisy on every pre-build doctor run.
3908
+ ready.push(
3909
+ `Brief payload declares ${proofAssets.length} proof asset(s) (${usable} usable, ${pending.length} pending attestation, ${nonAttestable.length} non-attestable); shipped-content evaluation runs after assembly.`,
3910
+ );
3911
+ return;
3912
+ }
3913
+ const { shippedNonAttestable, shippedPending } = attestationBlockers(findings);
3914
+ for (const finding of shippedNonAttestable) {
3915
+ addIssue(
3916
+ errors,
3917
+ "proof_attestation.non_attestable_shipped",
3918
+ `Built output ships non-attestable proof asset ${finding.assetId ?? "(unidentified)"} (${finding.modality ?? "unknown modality"}). This proof class is never attestable — remove it from the content.`,
3919
+ );
3920
+ }
3921
+ for (const finding of shippedPending) {
3922
+ addIssue(
3923
+ errors,
3924
+ "proof_attestation.pending_shipped",
3925
+ `Built output ships proof asset ${finding.assetId ?? "(unidentified)"} (${finding.modality ?? "unknown modality"}) whose attestation is not accepted. Collect the merchant's click-wrap attestation or remove the claim.`,
3926
+ );
3927
+ }
3928
+ if (!shippedNonAttestable.length && !shippedPending.length && (pending.length || nonAttestable.length)) {
3929
+ addIssue(
3930
+ warnings,
3931
+ "proof_attestation.queue",
3932
+ `Brief payload carries ${pending.length} attestation-pending and ${nonAttestable.length} non-attestable proof asset(s); none shipped in built output. Pending items are the merchant attestation checklist.`,
3933
+ );
3934
+ }
3935
+ if (usable) ready.push(`${usable} proof asset(s) usable (verified or attestation accepted)`);
3936
+ }
3937
+
3938
+ // H3.2 (doctor surface): the family's own demo placeholder assets surviving
3939
+ // into built output. Reuses the literal-match infra over the demo-asset
3940
+ // basenames declared in the brand contract. Warning only — the agent re-skins.
3941
+ export function validateBuiltDemoAssetFidelity(brandContract, warnings, ready, derived) {
3942
+ const config = demoAssetConfig(brandContract);
3943
+ if (!config || !config.assetBasenames.length) return;
3944
+ const targetOutputDir = derived.target_output_dir;
3945
+ if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return;
3946
+ const hits = collectLiteralMatches(targetOutputDir, config.assetBasenames);
3947
+ if (hits.length) {
3948
+ const assets = [...new Set(hits.map((hit) => hit.label))].join(", ");
3949
+ addIssue(
3950
+ warnings,
3951
+ "template_contract.demo_asset_residue",
3952
+ `Assembly is recorded complete, but built output still references template demo assets (${assets}): ${summarizeCopyMatches(hits)}. Re-skin to the campaign's real assets rather than shipping template placeholders.`,
3953
+ );
3954
+ } else {
3955
+ ready.push("Built target output references no template demo placeholder assets");
3956
+ }
3957
+ }
3958
+
3959
+ function collectLiteralMatches(root, values) {
3960
+ if (!values.length) return [];
3961
+ const escaped = values.map((value) => escapeRegExp(String(value))).join("|");
3962
+ const regex = new RegExp(escaped, "g");
3963
+ const matches = [];
3964
+ for (const file of collectHtmlFiles(root)) {
3965
+ if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
3966
+ const content = readHtmlScanText(join(root, file.path));
3967
+ for (const match of content.matchAll(regex)) {
3968
+ matches.push({
3969
+ surface: "target",
3970
+ path: file.path,
3971
+ line: lineNumberAt(content, match.index || 0),
3972
+ label: match[0],
3973
+ text: match[0],
3974
+ });
3975
+ }
3976
+ }
3977
+ return matches;
3978
+ }
3979
+
3980
+ const GENERIC_TEMPLATE_RESIDUE_PATTERNS = [
3981
+ { id: "promo_code_placeholder", label: "XXCODE", pattern: /\bXXCODE\b/gi },
3982
+ { id: "package_title_placeholder", label: "Package Title", pattern: /\bPackage Title\b/g },
3983
+ { id: "product_title_placeholder", label: "Product Title", pattern: /\bProduct Title\b/g },
3984
+ { id: "spec_ref_placeholder", label: "SPEC_*_REF", pattern: /\bSPEC_[A-Z0-9_]*_REF\b/g },
3985
+ { id: "starter_logo_asset", label: "next-logo.png", pattern: /\bnext-logo\.(?:png|svg|webp)\b/g },
3986
+ ];
3987
+
3988
+ function collectGenericTemplateResidueMatches(root) {
3989
+ return collectPatternMatches(root, GENERIC_TEMPLATE_RESIDUE_PATTERNS);
3990
+ }
3991
+
3992
+ function collectPatternMatches(root, patterns) {
3993
+ if (!patterns.length) return [];
3994
+ const matches = [];
3995
+ const compiledPatterns = patterns.map((entry) => {
3996
+ const flags = entry.pattern.flags.includes("g") ? entry.pattern.flags : `${entry.pattern.flags}g`;
3997
+ return { ...entry, regex: new RegExp(entry.pattern.source, flags) };
3998
+ });
3999
+ for (const file of collectBuiltTextFiles(root)) {
4000
+ const content = readHtmlScanText(join(root, file.path));
4001
+ for (const entry of compiledPatterns) {
4002
+ entry.regex.lastIndex = 0;
4003
+ for (const match of content.matchAll(entry.regex)) {
4004
+ matches.push({
4005
+ surface: "target",
4006
+ path: file.path,
4007
+ line: lineNumberAt(content, match.index || 0),
4008
+ label: entry.label,
4009
+ text: match[0],
4010
+ kind: entry.id,
4011
+ });
4012
+ }
4013
+ }
4014
+ }
4015
+ return matches;
4016
+ }
4017
+
4018
+ function maxSpecDiscountPercent(spec) {
4019
+ if (!spec || typeof spec !== "object") return null;
4020
+ const values = [];
4021
+
4022
+ function visit(value, key = "") {
4023
+ if (Array.isArray(value)) {
4024
+ for (const entry of value) visit(entry, key);
4025
+ return;
4026
+ }
4027
+ if (!value || typeof value !== "object") return;
4028
+
4029
+ if (isPercentDiscountBenefit(value, key)) {
4030
+ addPercentValue(values, value.value);
4031
+ }
4032
+
4033
+ for (const [entryKey, entryValue] of Object.entries(value)) {
4034
+ if (isDiscountPercentKey(entryKey)) addPercentValue(values, entryValue);
4035
+ visit(entryValue, entryKey);
4036
+ }
4037
+ }
4038
+
4039
+ visit(spec);
4040
+ return values.length ? Math.max(...values) : null;
4041
+ }
4042
+
4043
+ function isPercentDiscountBenefit(value, key) {
4044
+ if (!isObject(value) || !Object.hasOwn(value, "value")) return false;
4045
+ const type = normalizeSpecKey(value.type || "");
4046
+ if (!/(?:^|_)(?:percent|percentage)(?:_|$)/.test(type)) return false;
4047
+ const context = normalizeSpecKey(key);
4048
+ if (context === "benefit") return true;
4049
+ return /(?:^|_)(?:discount|saving|savings|save|off|offer|promo|coupon|voucher|package)(?:_|$)/.test(type);
4050
+ }
4051
+
4052
+ function isDiscountPercentKey(key) {
4053
+ const normalized = normalizeSpecKey(key);
4054
+ const mentionsPercent = /(?:^|_)(?:percent|percentage)(?:_|$)/.test(normalized);
4055
+ const mentionsOffer = /(?:^|_)(?:discount|saving|savings|save|off|offer|promo|coupon|voucher)(?:_|$)/.test(normalized);
4056
+ return mentionsPercent && mentionsOffer;
4057
+ }
4058
+
4059
+ function normalizeSpecKey(value) {
4060
+ return String(value || "")
4061
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
4062
+ .replace(/[^a-z0-9]+/gi, "_")
4063
+ .replace(/^_+|_+$/g, "")
4064
+ .toLowerCase();
4065
+ }
4066
+
4067
+ function addPercentValue(values, value) {
4068
+ const parsed = Number.parseFloat(String(value ?? "").replace("%", "").trim());
4069
+ if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) values.push(parsed);
4070
+ }
4071
+
4072
+ function collectDiscountClaimMatches(root) {
4073
+ const claimPattern = /\b(?:save(?:\s+up\s+to)?\s+(\d{1,3}(?:\.\d+)?)\s*(?:%|\bpercent\b)|(\d{1,3}(?:\.\d+)?)\s*(?:%|\bpercent\b)\s*(?:off|discount)\b)/gi;
4074
+ const matches = [];
4075
+ for (const file of collectBuiltTextFiles(root)) {
4076
+ const content = readHtmlScanText(join(root, file.path));
4077
+ for (const match of content.matchAll(claimPattern)) {
4078
+ const claimed = Number.parseFloat(match[1] || match[2]);
4079
+ if (!Number.isFinite(claimed)) continue;
4080
+ matches.push({
4081
+ surface: "target",
4082
+ path: file.path,
4083
+ line: lineNumberAt(content, match.index || 0),
4084
+ label: `${formatPercent(claimed)} claim`,
4085
+ text: match[0],
4086
+ claimed_percent: claimed,
4087
+ });
4088
+ }
4089
+ }
4090
+ return matches;
4091
+ }
4092
+
4093
+ function collectOverstatedDiscountClaimMatches(root, maxDiscount) {
4094
+ return collectDiscountClaimMatches(root)
4095
+ .filter((match) => match.claimed_percent > maxDiscount + DISCOUNT_CLAIM_TOLERANCE)
4096
+ .map((match) => ({ ...match, max_spec_percent: maxDiscount }));
4097
+ }
4098
+
4099
+ function formatPercent(value) {
4100
+ const rounded = Math.round(value * 100) / 100;
4101
+ return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(2)}%`;
4102
+ }
4103
+
4104
+ function contractMentionsShipping(contract) {
4105
+ return Object.values(contract.frontmatter || {})
4106
+ .flatMap((value) => Array.isArray(value) ? value : [])
4107
+ .some((value) => String(value).includes("shipping_methods") || String(value).includes("shipping_method"));
4108
+ }
4109
+
4110
+ function validateUnsupportedShippingFrontmatter(packet, packetPath, family, warnings, ready, derived) {
4111
+ const hits = collectShippingFrontmatterHits(packet, packetPath, derived);
4112
+ if (hits.length === 0) {
4113
+ ready.push(`${family} contract has no explicit shipping frontmatter residue in currently available mapped source/target pages`);
4114
+ return;
4115
+ }
4116
+ addIssue(
4117
+ warnings,
4118
+ "template_contract.shipping_unused",
4119
+ `${family} does not consume explicit shipping frontmatter, but mapped page frontmatter still declares ${summarizeShippingFrontmatterHits(hits)}. Remove copied shipping_methods/shipping_method values and let the family resolve shipping through its own SDK/runtime surface.`
4120
+ );
4121
+ }
4122
+
4123
+ function collectShippingFrontmatterHits(packet, packetPath, derived = {}) {
4124
+ const hits = [];
4125
+ const seen = new Set();
4126
+ const addFile = (surface, root, relPath) => {
4127
+ if (!root || !relPath) return;
4128
+ const filePath = resolve(root, relPath);
4129
+ const key = `${surface}:${filePath}`;
4130
+ if (seen.has(key) || !existsSync(filePath) || !statSync(filePath).isFile()) return;
4131
+ seen.add(key);
4132
+ const frontmatter = extractYamlFrontmatter(readFileSync(filePath, "utf8"));
4133
+ if (!frontmatter) return;
4134
+ for (const hit of shippingFrontmatterKeys(frontmatter)) {
4135
+ hits.push({ surface, path: relFromDir(root, filePath), key: hit.key, line: hit.line });
4136
+ }
4137
+ };
4138
+
4139
+ const sourceRoot = derived.source_root || resolveFromFile(packetPath, packet.source_html?.root);
4140
+ for (const page of packet.source_html?.pages || []) addFile("source", sourceRoot, page.path);
4141
+
4142
+ const targetOutputDir = derived.target_output_dir;
4143
+ if (targetOutputDir && existsSync(targetOutputDir) && statSync(targetOutputDir).isDirectory()) {
4144
+ for (const file of collectHtmlFiles(targetOutputDir)) addFile("target", targetOutputDir, file.path);
4145
+ }
4146
+
4147
+ return hits;
4148
+ }
4149
+
4150
+ function extractYamlFrontmatter(content) {
4151
+ const match = String(content || "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
4152
+ return match ? match[1] : "";
4153
+ }
4154
+
4155
+ function shippingFrontmatterKeys(frontmatter) {
4156
+ const hits = [];
4157
+ const lines = String(frontmatter || "").split(/\r?\n/);
4158
+ lines.forEach((line, index) => {
4159
+ const match = line.match(/^\s*(shipping_methods|shipping_method)\s*:/);
4160
+ if (match) hits.push({ key: match[1], line: index + 2 });
4161
+ });
4162
+ return hits;
4163
+ }
4164
+
4165
+ function summarizeShippingFrontmatterHits(hits) {
4166
+ const limit = 4;
4167
+ const summary = hits.slice(0, limit).map((hit) => `${hit.surface}:${hit.path}:${hit.line} (${hit.key})`);
4168
+ const more = hits.length > limit ? ` and ${hits.length - limit} more` : "";
4169
+ return `${summary.join(", ")}${more}`;
4170
+ }
4171
+
4172
+ export function collectDemoRefHits(spec, vocab) {
4173
+ const demoValues = new Set(
4174
+ Object.values(vocab || {})
4175
+ .flatMap((entry) => Array.isArray(entry.demoOnlyValues) ? entry.demoOnlyValues : [])
4176
+ .map((value) => String(value))
4177
+ );
4178
+ if (demoValues.size === 0) return [];
4179
+ // R2-B1: a ref whose value matches a real commerce entity the
4180
+ // spec declares (package/offer/shipping_method) is legitimate, not a
4181
+ // starter placeholder. The Map exporter does not stamp `_provenance.api`
4182
+ // down to the ref level, so provenance alone was missing it and flagging
4183
+ // valid low-integer API refs (e.g. "1"/"2"). Suppressing declared refs
4184
+ // kills that noise while still flagging refs that point at nothing the
4185
+ // spec defines.
4186
+ const declaredRefs = specDeclaredCommerceRefs(spec);
4187
+ const hits = new Set();
4188
+ const results = [];
4189
+ const visit = (value, key = "", path = [], provenanceStack = []) => {
4190
+ if (Array.isArray(value)) {
4191
+ value.forEach((item, index) => visit(item, key, [...path, String(index)], provenanceStack));
4192
+ } else if (isObject(value)) {
4193
+ const nextStack = [...provenanceStack, value._provenance].filter(Boolean);
4194
+ for (const [childKey, childValue] of Object.entries(value)) {
4195
+ if (childKey === "_provenance") continue;
4196
+ visit(childValue, childKey, [...path, childKey], nextStack);
4197
+ }
4198
+ } else if (["ref_id", "package_id", "package_ref_id", "shipping_method"].includes(key) && demoValues.has(String(value))) {
4199
+ const hitKey = `${path.join(".")}:${String(value)}`;
4200
+ if (!hits.has(hitKey) && !isApiSourcedProvenance(provenanceStack) && !declaredRefs.has(String(value))) {
4201
+ hits.add(hitKey);
4202
+ results.push({ value: String(value), path: path.join(".") || key });
4203
+ }
4204
+ }
4205
+ };
4206
+ visit(spec);
4207
+ return results;
4208
+ }
4209
+
4210
+ function isApiSourcedProvenance(provenanceStack) {
4211
+ return provenanceStack.some((provenance) => {
4212
+ if (!isObject(provenance)) return false;
4213
+ if (provenance.api === true || provenance.api_sourced === true) return true;
4214
+ const source = String(provenance.source || provenance.origin || "").toLowerCase();
4215
+ return source.includes("api") || source.includes("campaigns");
4216
+ });
4217
+ }
4218
+
4219
+ function isStageComplete(report, stage) {
4220
+ const status = report?.stages?.[stage]?.status;
4221
+ return isNonEmptyString(status) && status.startsWith("completed");
4222
+ }
4223
+
4224
+ function validateContext(context, errors, warnings, ready, derived) {
4225
+ if (context.schema_version !== CONTEXT_SCHEMA) addIssue(warnings, "context.schema_version", `Context schema should be ${CONTEXT_SCHEMA}.`);
4226
+ else ready.push(`Build context schema ${CONTEXT_SCHEMA}`);
4227
+ if (context.source_adapter !== "html_funnel") {
4228
+ addIssue(warnings, "context.source_adapter", "Only html_funnel is supported in the current release.");
4229
+ }
4230
+ if (Array.isArray(context.prompts_required) && context.prompts_required.length > 0) {
4231
+ for (const prompt of context.prompts_required) {
4232
+ addIssue(warnings, `context.prompts_required.${prompt.code || "prompt"}`, prompt.message || "Context has unresolved prompts.");
4233
+ }
4234
+ }
4235
+ validateCommerceZoneFindings(context.commerce_zone_findings, warnings, ready);
4236
+ validateAdapterDecisionShape(context.adapter_decisions, "context.adapter_decisions", warnings, ready, { addIssue });
4237
+ if (context.scaffold?.required === true) {
4238
+ derived.scaffold_required = true;
4239
+ derived.scaffold_reason = context.scaffold.reason || "Build context says setup is required.";
4240
+ }
4241
+ const themeResult = validateThemeContextBlock(context.theme);
4242
+ for (const error of themeResult.errors) errors.push(error);
4243
+ for (const warning of themeResult.warnings) warnings.push(warning);
4244
+ ready.push(...themeResult.ready);
4245
+ }
4246
+
4247
+ export function validateCommerceZoneFindings(findings, warnings, ready) {
4248
+ if (!Array.isArray(findings) || findings.length === 0) return;
4249
+ const shellRequired = findings.filter((finding) => finding?.requires_template_shell === true);
4250
+ if (shellRequired.length === 0) {
4251
+ ready.push("Source commerce zones inspected; no SDK-owned commerce shell placeholders declared");
4252
+ return;
4253
+ }
4254
+ for (const finding of shellRequired) {
4255
+ const zones = Array.isArray(finding.commerce_zones) && finding.commerce_zones.length
4256
+ ? finding.commerce_zones.join(", ")
4257
+ : finding.zones?.filter((zone) => zone !== "sdk_owned_declared").join(", ") || "commerce zone";
4258
+ addIssue(
4259
+ warnings,
4260
+ "source_html.commerce_shell_required",
4261
+ `Source page "${finding.path}" declares SDK-owned commerce zone(s): ${zones}. During build, adopt the selected starter-template family shell for these zones; do not wrap borrowed partials in a custom checkout/upsell structure. Browser QA will verify rendered commerce structure when the family contract declares it.`
4262
+ );
4263
+ }
4264
+ }
4265
+
4266
+ function validateAssemblyReportShape(report, errors, warnings, ready) {
4267
+ // Doctor already reports the source-package freshness finding from
4268
+ // derived.polish_gate, so the shape check leaves it out here and the same
4269
+ // finding is not listed twice in one doctor run. The standalone
4270
+ // `validate-assembly-report` has no gate behind it, so it keeps the check.
4271
+ const result = validateAssemblyReport(report, { checkSourcePackageFreshness: false });
4272
+ for (const error of result.errors) errors.push(error);
4273
+ for (const warning of result.warnings) warnings.push(warning);
4274
+ ready.push(...result.ready);
4275
+ }
4276
+
4277
+ function validateAssemblyReport(report, { checkSourcePackageFreshness = true } = {}) {
4278
+ const errors = [];
4279
+ const warnings = [];
4280
+ const ready = [];
4281
+ if (!isObject(report)) {
4282
+ addIssue(errors, "report.type", "Assembly report must be a JSON object.");
4283
+ return { ok: false, status: "blocked", errors, warnings, ready };
4284
+ }
4285
+ if (report.schema_version !== REPORT_SCHEMA) addIssue(errors, "schema_version", `Expected ${REPORT_SCHEMA}.`);
4286
+ else ready.push(`Assembly report schema ${REPORT_SCHEMA}`);
4287
+ for (const path of ["run_id", "generated_at", "status", "identity.map_id", "identity.public_route_slug", "inputs.packet_path", "template_family.value"]) {
4288
+ if (path === "identity.map_id") {
4289
+ if (!resolveCampaignIdentity(report.identity)) addIssue(errors, "identity.map_id", "Assembly Report requires exactly one valid map_id or local_spec_id.");
4290
+ } else requireString(report, errors, path);
4291
+ }
4292
+ if (report.identity?.local_spec_id != null && !/^sha256:[0-9a-f]{64}$/.test(report.identity.spec_material_hash || "")) {
4293
+ addIssue(errors, "identity.spec_material_hash", "Local-spec reports require a current SHA-256 material spec hash.");
4294
+ }
4295
+ const stages = report.stages;
4296
+ if (!isObject(stages)) {
4297
+ addIssue(errors, "stages", "stages object is required.");
4298
+ } else {
4299
+ for (const stage of ASSEMBLY_REPORT_STAGE_KEYS) {
4300
+ if (!isObject(stages[stage])) {
4301
+ addIssue(errors, `stages.${stage}`, `${stage} stage is required.`);
4302
+ continue;
4303
+ }
4304
+ requireString(report, errors, `stages.${stage}.status`);
4305
+ for (const field of ["inputs", "outputs", "commands", "blockers", "warnings"]) {
4306
+ if (stages[stage][field] !== undefined && !Array.isArray(stages[stage][field])) {
4307
+ addIssue(errors, `stages.${stage}.${field}`, `stages.${stage}.${field} must be an array.`);
4308
+ }
4309
+ }
4310
+ }
4311
+ }
4312
+ for (const field of ["decisions", "evidence", "blockers", "warnings"]) {
4313
+ if (!Array.isArray(report[field])) addIssue(errors, field, `${field} must be an array.`);
4314
+ }
4315
+ const themeResult = validateAssemblyReportThemeBlock(report.theme);
4316
+ for (const error of themeResult.errors) errors.push(error);
4317
+ for (const warning of themeResult.warnings) warnings.push(warning);
4318
+ ready.push(...themeResult.ready);
4319
+ validateAdapterDecisionShape(report.adapter_decisions, "report.adapter_decisions", warnings, ready, { addIssue });
4320
+ validateAssemblyProofPolicy(report.proof_policy, warnings, ready);
4321
+ if (checkSourcePackageFreshness) validateAssemblySourcePackageFreshness(report, errors);
4322
+ const status = errors.length ? "blocked" : warnings.length ? "ready_with_warnings" : "ready";
4323
+ return { ok: errors.length === 0, status, errors, warnings, ready };
4324
+ }
4325
+
4326
+ // The two source-freshness conditions the polish gate blocks on that a
4327
+ // standalone report validation can answer from the report alone. Both read the
4328
+ // gate's own helpers, and the waiver records are scanned once for both. The
4329
+ // order mirrors the gate: a malformed waiver record is reported on its own and
4330
+ // the freshness question is not asked until it is fixed, so the validator and
4331
+ // the gate name the same single finding for that report.
4332
+ function validateAssemblySourcePackageFreshness(report, errors) {
4333
+ const waiverAssessment = assessAssemblySourcePackageFreshnessWaivers(report);
4334
+ if (waiverAssessment.invalid.length) {
4335
+ const invalid = waiverAssessment.invalid[0];
4336
+ addIssue(
4337
+ errors,
4338
+ "stages.assembly.waiver_expires_at_invalid",
4339
+ `Source freshness waiver has an unparseable expires_at (${JSON.stringify(invalid.expires_at)}). Record a valid ISO 8601 timestamp, or remove the malformed waiver record: the polish gate refuses to honor it either way.`,
4340
+ );
4341
+ return;
4342
+ }
4343
+ if (assemblySourcePackageFingerprintMissing(report, Date.now(), waiverAssessment)) {
4344
+ addIssue(
4345
+ errors,
4346
+ "stages.assembly.source_package_material_fingerprint",
4347
+ "Report declares a Design Source Package material fingerprint, so stages.assembly.source_package_material_fingerprint is required: without it nothing proves the build consumed the current source context. Re-run Build against the current Design Source Package, or record a source freshness waiver.",
4348
+ );
4349
+ }
4350
+ }
4351
+
4352
+ function validateAssemblyProofPolicy(policy, warnings, ready) {
4353
+ if (policy == null) {
4354
+ addIssue(warnings, "report.proof_policy", "report.proof_policy is missing. Assembly Reports should mirror qa.proof_policy so browser QA, typed-card depth, SDK origin allowlist state, order path depth, and approval state are inspectable.");
4355
+ return;
4356
+ }
4357
+ validateProofPolicyObject(policy, "report.proof_policy", warnings, ready);
4358
+ }
4359
+ const BRAND_CONTRACT_ERROR_CODES = new Set([
4360
+ "parse_error",
4361
+ "schema_mismatch",
4362
+ "extends_cycle",
4363
+ "extends_missing_parent",
4364
+ "family_mismatch",
4365
+ ]);
4366
+
4367
+ export function safeBrandContractCode(code) {
4368
+ const value = optionalString(code);
4369
+ return value && BRAND_CONTRACT_ERROR_CODES.has(value) ? value : "unknown";
4370
+ }
4371
+
4372
+ function requireString(object, errors, path) {
4373
+ if (!isNonEmptyString(getPath(object, path))) addIssue(errors, path, `${path} is required.`);
4374
+ }
4375
+
4376
+ function requireBoolean(object, errors, path) {
4377
+ if (typeof getPath(object, path) !== "boolean") addIssue(errors, path, `${path} must be boolean.`);
4378
+ }
4379
+
4380
+ function requireArray(object, errors, path) {
4381
+ if (!Array.isArray(getPath(object, path))) addIssue(errors, path, `${path} must be an array.`);
4382
+ }
4383
+
4384
+ function getPath(object, path) {
4385
+ return path.split(".").reduce((cursor, part) => {
4386
+ if (!isObject(cursor) && !Array.isArray(cursor)) return undefined;
4387
+ return cursor[part];
4388
+ }, object);
4389
+ }
4390
+
4391
+ export {
4392
+ PACKET_SCHEMA,
4393
+ CONTEXT_SCHEMA,
4394
+ REPORT_SCHEMA,
4395
+ certifiedTemplateFamilies,
4396
+ isCertifiedTemplateFamily,
4397
+ LOCAL_SERVE_DEPLOY_TARGET,
4398
+ activeSpecPages,
4399
+ collectHtmlFiles,
4400
+ runDoctorChecks,
4401
+ ARTIFACT_DOCTOR_CHECKS,
4402
+ validatePacket,
4403
+ orderPathDepthDrift,
4404
+ recordUpsellSelectorScopeGate,
4405
+ collectBuiltPageIdentityInputs,
4406
+ recordCampaignIdentityGate,
4407
+ recordScriptSyntaxGate,
4408
+ recordSdkMarkupGate,
4409
+ summarizeCopyMatches,
4410
+ resolveBrandContract,
4411
+ resolveBrandContractOnce,
4412
+ reportBrandContractDefectOnce,
4413
+ collectGenericTemplateResidueMatches,
4414
+ validateAssemblyReport,
4415
+ };