@nextcommerce/campaigns-os 1.48.0 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/agents/claude/CLAUDE.md +6 -5
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +254 -2
  13. package/contracts/release-ledger.json +1239 -0
  14. package/contracts/supported-surface.json +4 -4
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +12 -6
  18. package/docs/build-packet.md +101 -12
  19. package/docs/campaign-build-brief.md +25 -1
  20. package/docs/effects.md +6 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +10 -0
  24. package/docs/qa-and-test-orders.md +66 -11
  25. package/docs/runtime-readiness.md +1 -1
  26. package/docs/sdk-storage-compatibility.md +1 -1
  27. package/docs/skills-revision.md +10 -10
  28. package/package.json +1 -1
  29. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  30. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +4 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +5 -4
  40. package/skills/next-campaigns-qa/SKILL.md +6 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +25 -2
  44. package/src/build-brief.mjs +68 -21
  45. package/src/built-site-scope.mjs +39 -6
  46. package/src/built-smoke-qc.mjs +1117 -0
  47. package/src/campaign-identity.mjs +36 -2
  48. package/src/cart-placeholders.mjs +730 -0
  49. package/src/cli.mjs +320 -42
  50. package/src/commercial-journey.mjs +65 -4
  51. package/src/commercial-parity.mjs +6 -1
  52. package/src/doctor/checks.mjs +291 -24
  53. package/src/doctor/inspect.mjs +53 -2
  54. package/src/doctor/next-step.mjs +1 -1
  55. package/src/install-mode.mjs +0 -8
  56. package/src/invocation.mjs +5 -2
  57. package/src/local-preview-policy.mjs +1 -1
  58. package/src/local-proof.mjs +4 -1
  59. package/src/polish-browser.mjs +218 -1
  60. package/src/polish-capture.mjs +1 -1
  61. package/src/polish-media-weight.mjs +492 -0
  62. package/src/polish-node.mjs +96 -4
  63. package/src/progress-node.mjs +5 -1
  64. package/src/qa-binding-evidence.mjs +21 -0
  65. package/src/qa-browser.mjs +338 -97
  66. package/src/qa-content-params.mjs +889 -0
  67. package/src/qa-node.mjs +114 -14
  68. package/src/qa-order-bump.mjs +22 -1
  69. package/src/qa-policy-links.mjs +1019 -0
  70. package/src/qa-tracking-params.mjs +1389 -0
  71. package/src/qa-url-privacy.mjs +168 -0
  72. package/src/qc-accept.mjs +446 -0
  73. package/src/qc-check-registry.mjs +83 -0
  74. package/src/qc-results.mjs +1049 -0
  75. package/src/sdk-attribute-index.mjs +71 -0
  76. package/src/sdk-markup.mjs +2 -2
  77. package/src/sdk-storage-compatibility.mjs +63 -3
  78. package/src/source-prep.mjs +37 -7
  79. package/src/stage-record.mjs +356 -36
  80. package/src/theme-gate.mjs +3 -3
@@ -104,11 +104,72 @@ function selectedShipping(page) {
104
104
  }
105
105
 
106
106
  // One predicate for "this checkout row is an order bump": the spec marks a
107
- // bump with `is_upsell: true` on a non-upsell page. Every consumer that must
107
+ // bump with `is_order_bump: true` (the schema's and authoring guide's flag) or
108
+ // `is_upsell: true` on a non-upsell page. Every consumer that must
108
109
  // tell a bump from a main selector row (parity scenarios, bump deltas, the QA
109
- // selector-tier planner) reads this, so a marker change lands in one place.
110
+ // selector-tier planner, `next`'s order-bump QA command) reads this, so a
111
+ // marker change lands in one place.
110
112
  export function isBumpRow(row) {
111
- return Boolean(row?.is_upsell);
113
+ return Boolean(row?.is_order_bump || row?.is_upsell);
114
+ }
115
+
116
+ // A checkout package row's ref, with the doctor's specPackageRecords
117
+ // tolerance (ref_id, then package_id, then id).
118
+ function checkoutRowRef(pkg) {
119
+ return [pkg.ref_id, pkg.package_id, pkg.id]
120
+ .map((value) => (value == null ? "" : String(value).trim()))
121
+ .find(Boolean);
122
+ }
123
+
124
+ // Order bumps declared on a checkout page (isBumpRow rows), as refs in
125
+ // declaration order. They are add-ons to a selected tier, not tiers, so the
126
+ // QA tier planner reports and skips them and `next` names the --cart run that
127
+ // toggles them.
128
+ export function declaredOrderBumps(checkoutPage) {
129
+ const refs = [];
130
+ for (const pkg of array(checkoutPage?.packages)) {
131
+ if (!pkg || typeof pkg !== "object" || !isBumpRow(pkg)) continue;
132
+ const ref = checkoutRowRef(pkg);
133
+ if (ref && !refs.includes(ref)) refs.push(ref);
134
+ }
135
+ return refs;
136
+ }
137
+
138
+ // Selector tiers are the packages a spec declares on a checkout page, in
139
+ // declaration order. Order-bump rows are add-ons offered alongside the
140
+ // selected tier, not tiers of their own: they never become a tier.
141
+ export function declaredSelectorTiers(checkoutPage) {
142
+ const records = [];
143
+ const quantitiesByRef = new Map();
144
+ for (const pkg of array(checkoutPage?.packages)) {
145
+ if (!pkg || typeof pkg !== "object" || isBumpRow(pkg)) continue;
146
+ const ref = checkoutRowRef(pkg);
147
+ const declaredQuantity = Number(pkg.qty ?? pkg.quantity ?? 1);
148
+ if (!ref || !Number.isInteger(declaredQuantity) || declaredQuantity < 1) continue;
149
+ records.push({ pkg, ref, declaredQuantity });
150
+ if (!quantitiesByRef.has(ref)) quantitiesByRef.set(ref, new Set());
151
+ quantitiesByRef.get(ref).add(declaredQuantity);
152
+ }
153
+
154
+ const tiers = [];
155
+ const seen = new Set();
156
+ for (const { pkg, ref, declaredQuantity } of records) {
157
+ // A unique ref is a catalog package bought once, even when that package's
158
+ // own composition is 3x. Only repeated declarations of the SAME ref at
159
+ // different quantities express shopper purchase multipliers (e.g. a 1x
160
+ // and a 2x package).
161
+ const quantity = quantitiesByRef.get(ref).size > 1 ? declaredQuantity : 1;
162
+ const identity = `${ref}:${quantity}`;
163
+ if (seen.has(identity)) continue;
164
+ seen.add(identity);
165
+ const label = [pkg.name, pkg.title].find((value) => typeof value === "string" && value.trim());
166
+ tiers.push({
167
+ ref,
168
+ quantity,
169
+ declared_by: label ? label.trim() : undefined,
170
+ });
171
+ }
172
+ return tiers;
112
173
  }
113
174
 
114
175
  function lineForRow(row) {
@@ -932,7 +993,7 @@ function makePage(scenarios, catalog) {
932
993
  row_index: row.row_index,
933
994
  package_id: row.package_id,
934
995
  quantity: row.quantity,
935
- ...(row.is_upsell ? { is_upsell: true } : {}),
996
+ ...(isBumpRow(row) ? { is_upsell: true } : {}),
936
997
  name: row.name,
937
998
  state: PricingState.Unresolved,
938
999
  reason: pageReason,
@@ -607,7 +607,12 @@ function partitionCaptures(capturesValue, { countsOnly = false } = {}) {
607
607
 
608
608
  function priceComparisonClaims(capture, page) {
609
609
  const claims = array(capture?.price_claims);
610
- if (!exactMoneyFact(page?.representative_total)) {
610
+ // A voucher on the page that the plan cannot price (no calculated pair for
611
+ // its code) can move the shown price away from the planned total, so a
612
+ // difference there says nothing about the page. Its price claims stay
613
+ // unresolved, which already keeps coverage incomplete, rather than being
614
+ // reported as mismatches against the list-price total.
615
+ if (!exactMoneyFact(page?.representative_total) || voucherComparisonClaims(capture, page).unresolved > 0) {
611
616
  return { compared: [], unresolved: claims.length };
612
617
  }
613
618
  const bindings = [...new Set(claims.map((claim) => String(claim.binding)))];
@@ -1,7 +1,7 @@
1
1
  // Doctor checks: the check registries, validatePacket and the validators they run.
2
2
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
3
  import { withHtmlScanSnapshot, readHtmlScanText } from "../html-scan.mjs";
4
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { accessSync, constants as fsConstants, existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
5
5
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
6
6
  import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
7
7
  import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthsDisagree } from "../proof-policy.mjs";
@@ -44,6 +44,7 @@ import {
44
44
  LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
45
45
  LOCAL_PROOF_NEVER_EDIT_RULE,
46
46
  LOCAL_PROOF_PARITY_COMMAND,
47
+ LOCAL_PROOF_RECORD_BUILD_COMMAND,
47
48
  LOCAL_PROOF_PARITY_FIELD,
48
49
  LOCAL_PROOF_PARITY_SCOPE,
49
50
  recordedBuildEnvironment,
@@ -84,6 +85,9 @@ import {
84
85
  import { CAMPAIGN_IDENTITY, evaluateCampaignIdentity, externalScriptSources } from "../campaign-identity.mjs";
85
86
  import { SDK_MARKUP, evaluateSdkMarkup } from "../sdk-markup.mjs";
86
87
  import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSyntax } from "../built-script-syntax.mjs";
88
+ import { CART_PLACEHOLDERS, CART_PLACEHOLDERS_LIMITS, evaluateCartPlaceholders, isFileReadFailure } from "../cart-placeholders.mjs";
89
+ import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, isBuiltPageReadFailure, pageScriptSources, parseBuiltPage, realPathOf } from "../built-smoke-qc.mjs";
90
+ import { recordQcResults } from "../qc-results.mjs";
87
91
  import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
88
92
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
89
93
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
@@ -104,6 +108,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
104
108
  // so a fresh install (including the git-ref consumer) always has dist.
105
109
  import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
106
110
  import { cmd, asInvocation } from "../install-invocation.mjs";
111
+ import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
107
112
  import {
108
113
  isObject,
109
114
  isNonEmptyString,
@@ -428,7 +433,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
428
433
  {
429
434
  id: CAMPAIGN_IDENTITY,
430
435
  phase: "built-output",
431
- run: ({ packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived),
436
+ run: ({ spec, packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived, spec),
432
437
  },
433
438
  {
434
439
  id: SDK_MARKUP,
@@ -440,6 +445,16 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
440
445
  phase: "built-output",
441
446
  run: ({ packet, errors, warnings, ready, derived }) => validateBuiltScriptSyntax(packet, errors, warnings, ready, derived),
442
447
  },
448
+ {
449
+ id: CART_PLACEHOLDERS,
450
+ phase: "built-output",
451
+ run: ({ packet, warnings, ready, derived }) => validateCartPlaceholders(packet, warnings, ready, derived),
452
+ },
453
+ {
454
+ id: SMOKE_QC,
455
+ phase: "built-output",
456
+ run: ({ packet, warnings, ready, derived, buildState }) => validateSmokeQc(packet, warnings, ready, derived, buildState),
457
+ },
443
458
  {
444
459
  id: "built_output.sdk_meta_tags",
445
460
  phase: "built-output",
@@ -724,6 +739,16 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
724
739
  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" });
725
740
  }
726
741
  ready.push("Local CampaignSpec parsed");
742
+ // QA refuses a local-spec run whose spec no longer has the material hash
743
+ // prepare-build bound on the Assembly Report; doctor (and next, which
744
+ // prints doctor's warnings) name it at the first read instead.
745
+ const boundMaterialHash = buildState.report?.identity?.spec_material_hash;
746
+ if (packet.spec?.local_spec_id != null && isNonEmptyString(boundMaterialHash)) {
747
+ const currentMaterialHash = specMaterialHash(spec);
748
+ if (!specHashesMatch(boundMaterialHash, currentMaterialHash)) {
749
+ addIssue(warnings, "spec.material_stale", `The CampaignSpec at ${localSpecPath} changed materially since prepare-build bound it (Assembly Report identity.spec_material_hash ${boundMaterialHash}; the spec now hashes to ${currentMaterialHash}). The build and its recorded evidence predate the edit, and QA refuses a stale spec. Re-run ${cmd("prepare-build")} from the edited spec before building on it further.`, { spec_path: localSpecPath, bound_material_hash: boundMaterialHash, current_material_hash: currentMaterialHash });
750
+ }
751
+ }
727
752
  runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
728
753
  } else {
729
754
  runDoctorChecks(
@@ -852,7 +877,7 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
852
877
  }
853
878
 
854
879
  const brief = readJson(resolvedPath);
855
- const result = validateCampaignBuildBriefArtifact(brief, { spec });
880
+ const result = validateCampaignBuildBriefArtifact(brief, { spec, normalizedPath });
856
881
  for (const issue of result.errors) errors.push(issue);
857
882
  for (const issue of result.warnings) warnings.push(issue);
858
883
  ready.push(...result.ready);
@@ -1129,7 +1154,7 @@ export function isLocalhostDevelopmentOrigin(value) {
1129
1154
  // Both facts are read from the assembly stage's free-form evidence.
1130
1155
  function validateLocalProof(packet, report, errors, warnings, ready) {
1131
1156
  if (!stageIsTerminal(report?.stages?.assembly?.status)) {
1132
- 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.`);
1157
+ ready.push(`Local proof mode: the build stage renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}; polish capture and QA run against that served output, and ${asInvocation(LOCAL_PROOF_PARITY_COMMAND)} proves the production render before commit.`);
1133
1158
  return;
1134
1159
  }
1135
1160
  const environment = recordedBuildEnvironment(report);
@@ -1137,8 +1162,8 @@ function validateLocalProof(packet, report, errors, warnings, ready) {
1137
1162
  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.`);
1138
1163
  } else {
1139
1164
  addIssue(warnings, LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE, environment
1140
- ? `${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}`
1141
- : `${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}`);
1165
+ ? `${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 it with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}, and recapture. ${LOCAL_PROOF_NEVER_EDIT_RULE}`
1166
+ : `${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 with ${asInvocation(LOCAL_PROOF_RECORD_BUILD_COMMAND)}; 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}`);
1142
1167
  }
1143
1168
  const parity = recordedProductionParity(report);
1144
1169
  const parityCommand = asInvocation(LOCAL_PROOF_PARITY_COMMAND);
@@ -1983,17 +2008,30 @@ function recordUpsellSelectorScopeGate({ subject, pages, waivers, errors, warnin
1983
2008
  // carries another funnel's key or tag arrives in a later edit round as often
1984
2009
  // as at first assembly. Enumerates from the filesystem so both doctor paths
1985
2010
  // scan the same pages, and stays blocking regardless of stage status.
1986
- function validateCampaignIdentity(packet, errors, ready, derived) {
2011
+ //
2012
+ // With a CampaignSpec, each built page is marked by whether some active spec
2013
+ // page builds to it (the same path route drift claims), so a finding on a
2014
+ // file no spec page produces names it as leftover output.
2015
+ export function validateCampaignIdentity(packet, errors, ready, derived, spec = null) {
1987
2016
  const targetRepo = derived.target_repo;
1988
2017
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1989
2018
  const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1990
2019
  const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
2020
+ const pages = scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [];
2021
+ const specPages = isObject(spec) ? activeSpecPages(spec) : [];
2022
+ if (specPages.length) {
2023
+ const claimed = new Set(specPages
2024
+ .map((page) => builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived))
2025
+ .filter(Boolean)
2026
+ .map((path) => resolve(path)));
2027
+ for (const page of pages) page.spec_page = claimed.has(resolve(targetRepo, page.file));
2028
+ }
1991
2029
  recordCampaignIdentityGate({
1992
2030
  subject: {
1993
2031
  public_route_slug: publicRouteSlug || null,
1994
2032
  site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
1995
2033
  },
1996
- pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [],
2034
+ pages,
1997
2035
  errors,
1998
2036
  ready,
1999
2037
  derived,
@@ -2009,7 +2047,24 @@ function validateCampaignIdentity(packet, errors, ready, derived) {
2009
2047
  // `/<slug>/config.js`), then the campaign directory (a root-served campaign
2010
2048
  // emits `/config.js`); relative srcs resolve against the page. Remote and
2011
2049
  // missing scripts contribute nothing.
2012
- function collectBuiltPageIdentityInputs(scope, targetRepo) {
2050
+ //
2051
+ // The bounded form ({ pages, bounds }) takes pages the caller already read
2052
+ // ({file, content?}, as collectCartPlaceholderPages gives them) and adds each
2053
+ // page's `scripts` as the smoke check's anchor script hint reads them
2054
+ // (boundedPageScripts), listed from the page's bounded parse5 tree
2055
+ // (pageScriptSources: any attribute quoting, spacing or case; never a
2056
+ // non-JavaScript type or a script in <template> or <noscript>). A page with
2057
+ // no content, or one that cannot be parsed, is returned as given. The smoke
2058
+ // check itself hands boundedPageScripts the srcs its own parse lists, so a
2059
+ // page is parsed once.
2060
+ function collectBuiltPageIdentityInputs(scope, targetRepo, { pages = null, bounds = null } = {}) {
2061
+ if (pages && bounds) {
2062
+ const scriptsOf = boundedPageScripts(scope.site_root, targetRepo, bounds);
2063
+ return pages.map((page) => {
2064
+ const sources = typeof page?.content === "string" ? boundedScriptSources(page.content) : null;
2065
+ return sources ? { ...page, scripts: scriptsOf(sources, join(targetRepo, page.file)) } : page;
2066
+ });
2067
+ }
2013
2068
  const scriptCache = new Map();
2014
2069
  const readScript = (path) => {
2015
2070
  if (!scriptCache.has(path)) {
@@ -2023,23 +2078,11 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
2023
2078
  }
2024
2079
  return scriptCache.get(path);
2025
2080
  };
2026
- const resolveLocalScript = (src, builtPath) => {
2027
- const raw = String(src || "").trim();
2028
- if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
2029
- const clean = raw.replace(/[?#].*$/, "");
2030
- if (!clean) return null;
2031
- if (clean.startsWith("/")) {
2032
- const rel = clean.replace(/^\/+/, "");
2033
- const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
2034
- return candidates.find((candidate) => existsSync(candidate)) || null;
2035
- }
2036
- return resolve(dirname(builtPath), clean);
2037
- };
2038
2081
  return scope.pages.map((page) => {
2039
2082
  const content = readFileSync(page.built_path, "utf8");
2040
2083
  const scripts = [];
2041
2084
  for (const src of externalScriptSources(content)) {
2042
- const path = resolveLocalScript(src, page.built_path);
2085
+ const path = builtLocalScriptPath(scope, src, page.built_path);
2043
2086
  const scriptContent = path ? readScript(path) : null;
2044
2087
  if (scriptContent == null) continue;
2045
2088
  scripts.push({ src, file: relFromDir(targetRepo, path), content: scriptContent });
@@ -2054,6 +2097,84 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
2054
2097
  });
2055
2098
  }
2056
2099
 
2100
+ // Where a page's local `<script src>` lives, or null for a remote, data: or
2101
+ // empty src. An absolute src is the first of its site-root and campaign
2102
+ // candidates that exists, otherwise null.
2103
+ function builtLocalScriptPath(scope, src, builtPath) {
2104
+ const raw = String(src || "").trim();
2105
+ if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
2106
+ const clean = raw.replace(/[?#].*$/, "");
2107
+ if (!clean) return null;
2108
+ if (clean.startsWith("/")) {
2109
+ const rel = clean.replace(/^\/+/, "");
2110
+ const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
2111
+ return candidates.find((candidate) => existsSync(candidate)) || null;
2112
+ }
2113
+ return resolve(dirname(builtPath), clean);
2114
+ }
2115
+
2116
+ // The srcs of the scripts a page loads, or null when it cannot be parsed.
2117
+ function boundedScriptSources(content) {
2118
+ try {
2119
+ return pageScriptSources(parseBuiltPage(content));
2120
+ } catch (error) {
2121
+ if (!isBuiltPageReadFailure(error)) throw error;
2122
+ return null;
2123
+ }
2124
+ }
2125
+
2126
+ // The smoke check's script reader: given the srcs a page loads and the
2127
+ // page's file, every local script, read or not. The first `bounds.scripts`
2128
+ // are read when their real path lies inside the site root and they hold at
2129
+ // most `bounds.script_bytes` bytes ({src, file, content}); any other reads
2130
+ // {src, file, unread} with `missing`, `outside_site`, `too_large`,
2131
+ // `unreadable` or `script_cap`, or {src, unread: "unmappable"} when its src
2132
+ // names no file. Each src maps to its file through builtFileOf: the URL
2133
+ // parser resolves it against the page's URL under the site root (a
2134
+ // root-relative src starts at the site root, never the campaign directory),
2135
+ // and its path segments are percent-decoded. Each script file is read at most
2136
+ // once per run.
2137
+ function boundedPageScripts(siteRoot, targetRepo, bounds) {
2138
+ const realSiteRoot = realPathOf(siteRoot);
2139
+ const cache = new Map();
2140
+ const read = (path) => {
2141
+ if (!cache.has(path)) cache.set(path, readBoundedScript(realSiteRoot, path, bounds.script_bytes));
2142
+ return cache.get(path);
2143
+ };
2144
+ return (sources, builtPath) => {
2145
+ const scripts = [];
2146
+ for (const src of sources) {
2147
+ const mapped = builtFileOf(src, builtPath, siteRoot);
2148
+ if (!mapped) continue;
2149
+ if (mapped.unmappable) {
2150
+ scripts.push({ src, unread: "unmappable" });
2151
+ continue;
2152
+ }
2153
+ const path = mapped.path;
2154
+ const file = relFromDir(targetRepo, path);
2155
+ scripts.push(scripts.length < bounds.scripts ? { src, file, ...read(path) } : { src, file, unread: "script_cap" });
2156
+ }
2157
+ return scripts;
2158
+ };
2159
+ }
2160
+
2161
+ // { content } or { unread }. A file-system read failure reads unread; any
2162
+ // other error is a defect and throws.
2163
+ function readBoundedScript(realSiteRoot, path, maxBytes) {
2164
+ try {
2165
+ const real = realpathSync(path);
2166
+ if (!realSiteRoot || !insideRoot(realSiteRoot, real)) return { unread: "outside_site" };
2167
+ const stat = statSync(real);
2168
+ if (!stat.isFile()) return { unread: "unreadable" };
2169
+ if (stat.size > maxBytes) return { unread: "too_large" };
2170
+ const bytes = readFileSync(real);
2171
+ return bytes.length > maxBytes ? { unread: "too_large" } : { content: bytes.toString("utf8") };
2172
+ } catch (error) {
2173
+ if (!isFileReadFailure(error)) throw error;
2174
+ return { unread: error.code === "ENOENT" || error.code === "ENOTDIR" ? "missing" : "unreadable" };
2175
+ }
2176
+ }
2177
+
2057
2178
  function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
2058
2179
  const gate = evaluateCampaignIdentity({ subject, pages });
2059
2180
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
@@ -2143,6 +2264,148 @@ function recordScriptSyntaxGate({ subject, inputs, errors, warnings, ready, deri
2143
2264
  return gate;
2144
2265
  }
2145
2266
 
2267
+ // Raw cart placeholders. Every doctor invocation, both entry
2268
+ // points, filesystem enumeration, like the gates above; but a QC check, not a
2269
+ // checkpoint gate: its results land in derived.qc_results (warning and review
2270
+ // also in warnings[]), never in errors[] or derived.checkpoint_gates, so no
2271
+ // blocker elsewhere can withhold them and checkpoint waive never sees them.
2272
+ function validateCartPlaceholders(packet, warnings, ready, derived) {
2273
+ const targetRepo = derived.target_repo;
2274
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2275
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
2276
+ recordCartPlaceholders({
2277
+ subject: {
2278
+ public_route_slug: publicRouteSlug || null,
2279
+ site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
2280
+ },
2281
+ pages: siteRoot && existsSync(siteRoot) ? collectCartPlaceholderPages(targetRepo, publicRouteSlug) : [],
2282
+ warnings,
2283
+ ready,
2284
+ derived,
2285
+ });
2286
+ }
2287
+
2288
+ // Every built page under the campaign directory, in path order, including
2289
+ // every symbolic link that may hold a page (an .html link, a directory link,
2290
+ // or a link whose target cannot be inspected; each one entry, read as
2291
+ // unreadable, never followed). Pages within the page cap carry their HTML,
2292
+ // read one page at a time, unless they are over the size cap or cannot be
2293
+ // read; pages past it carry only their path.
2294
+ function collectCartPlaceholderPages(targetRepo, slug) {
2295
+ const scope = resolveBuiltSiteScope(targetRepo, { slug, includeLinkedPages: true });
2296
+ const linked = new Set((scope.linked_pages || []).map((page) => page.built_path));
2297
+ const all = [...(scope.ok ? scope.pages : []), ...(scope.linked_pages || [])]
2298
+ .sort((a, b) => (a.built_path < b.built_path ? -1 : a.built_path > b.built_path ? 1 : 0));
2299
+ const fileOf = (page) => builtPageFile(targetRepo, page.built_path);
2300
+ return [
2301
+ ...all.slice(0, CART_PLACEHOLDERS_LIMITS.pages).map((page) => readCartPlaceholderPage(page.built_path, fileOf(page), linked.has(page.built_path))),
2302
+ ...all.slice(CART_PLACEHOLDERS_LIMITS.pages).map((page) => ({ file: fileOf(page) })),
2303
+ ];
2304
+ }
2305
+
2306
+ // One page within the page cap: its HTML, its size when over the size cap, or
2307
+ // unreadable. A file-system read failure, before or after the readability
2308
+ // precheck (EIO, EACCES, EISDIR ...), reads it unreadable; any other error is
2309
+ // a defect and throws.
2310
+ function readCartPlaceholderPage(path, file, linked) {
2311
+ if (linked) return { file, unreadable: true };
2312
+ try {
2313
+ const bytes = statSync(path).size;
2314
+ if (!isReadableFile(path)) return { file, unreadable: true };
2315
+ if (bytes > CART_PLACEHOLDERS_LIMITS.page_bytes) return { file, bytes };
2316
+ return { file, content: readFileSync(path, "utf8") };
2317
+ } catch (error) {
2318
+ if (!isFileReadFailure(error)) throw error;
2319
+ return { file, unreadable: true };
2320
+ }
2321
+ }
2322
+
2323
+ // A built page's path relative to the doctor target, "/"-separated and with no
2324
+ // "./" prefix ("_site/<slug>/index.html"). The directory is resolved, the file
2325
+ // name never is, so a symlinked page keeps its own name.
2326
+ function builtPageFile(targetRepo, path) {
2327
+ const dir = relFromDir(targetRepo, dirname(path)).replace(/^\.(?:\/|$)/, "");
2328
+ return [...(dir ? dir.split(sep) : []), basename(path)].join("/");
2329
+ }
2330
+
2331
+ // Whether the page can be opened for reading. Only a file-system read failure
2332
+ // says it cannot; any other error is a defect and throws.
2333
+ function isReadableFile(path) {
2334
+ try {
2335
+ accessSync(path, fsConstants.R_OK);
2336
+ return true;
2337
+ } catch (error) {
2338
+ if (!isFileReadFailure(error)) throw error;
2339
+ return false;
2340
+ }
2341
+ }
2342
+
2343
+ function recordCartPlaceholders({ subject, pages, warnings, ready, derived }) {
2344
+ const results = evaluateCartPlaceholders({ subject, pages });
2345
+ recordQcResults({ derived, warnings, results });
2346
+ if (!pages.length) {
2347
+ ready.push("Cart placeholder check not applicable: no built page to scan yet.");
2348
+ return results;
2349
+ }
2350
+ // A ready line only when no page went unexercised.
2351
+ if (results.some((row) => row.result === "unexercised")) return results;
2352
+ const passed = results.filter((row) => row.result === "pass").length;
2353
+ const flagged = results.filter((row) => row.result === "warning" || row.result === "review").length;
2354
+ ready.push(`Cart placeholder check on ${pages.length} built page(s): ${passed} page(s) pass, ${flagged} warning or review result(s)`);
2355
+ return results;
2356
+ }
2357
+
2358
+ // Built-output smoke checks. Same placement and the same QC wiring as the
2359
+ // cart placeholder check above, over the same built pages. The build
2360
+ // environment is the one the Assembly Report records (nothing measures it),
2361
+ // and the deploy URLs give the base an absolute og:image maps from.
2362
+ function validateSmokeQc(packet, warnings, ready, derived, buildState = {}) {
2363
+ const targetRepo = derived.target_repo;
2364
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2365
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
2366
+ recordSmokeQc({
2367
+ subject: {
2368
+ public_route_slug: publicRouteSlug || null,
2369
+ site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
2370
+ },
2371
+ targetRepo,
2372
+ pages: siteRoot && existsSync(siteRoot) ? collectCartPlaceholderPages(targetRepo, publicRouteSlug) : [],
2373
+ environment: recordedBuildEnvironment(buildState?.report),
2374
+ deployBase: [packet?.deploy?.preview_url, packet?.deploy?.production_url],
2375
+ warnings,
2376
+ ready,
2377
+ derived,
2378
+ });
2379
+ }
2380
+
2381
+ function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, warnings, ready, derived }) {
2382
+ // The site root is the scope's, whichever form of target doctor was given
2383
+ // (the repo, its _site/ or a campaign directory). The anchor script hint
2384
+ // reads each page's local scripts through boundedPageScripts, from the srcs
2385
+ // the gate's own parse lists. With no scope to resolve them in, a page's
2386
+ // local scripts read unread.
2387
+ const scope = targetRepo && pages.length ? resolveBuiltSiteScope(targetRepo, { slug: subject?.public_route_slug || null }) : null;
2388
+ const siteRoot = scope?.site_root || null;
2389
+ const results = evaluateSmokeQc({
2390
+ pages,
2391
+ environment,
2392
+ siteRoot,
2393
+ targetDir: targetRepo || null,
2394
+ readScripts: siteRoot ? boundedPageScripts(siteRoot, targetRepo, SMOKE_QC_LIMITS) : null,
2395
+ deployBase,
2396
+ });
2397
+ recordQcResults({ derived, warnings, results });
2398
+ if (!pages.length) {
2399
+ ready.push("Smoke checks not applicable: no built page to scan yet.");
2400
+ return results;
2401
+ }
2402
+ // A ready line only when no result went unexercised (contract 1.0).
2403
+ if (results.some((row) => row.result === "unexercised")) return results;
2404
+ const count = (...values) => results.filter((row) => values.includes(row.result)).length;
2405
+ ready.push(`Smoke checks on ${pages.length} built page(s): ${count("pass")} pass, ${count("warning", "review")} warning or review result(s)`);
2406
+ return results;
2407
+ }
2408
+
2146
2409
  function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
2147
2410
  const gate = evaluateSdkMarkup({ subject, pages });
2148
2411
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
@@ -2565,7 +2828,7 @@ function pageKitAssetPathViolation(reference, publicRouteSlug) {
2565
2828
  };
2566
2829
  }
2567
2830
 
2568
- function resolveBuiltAssetPath(src, builtPath, targetRepo) {
2831
+ export function resolveBuiltAssetPath(src, builtPath, targetRepo) {
2569
2832
  if (!isNonEmptyString(src)) return null;
2570
2833
  const raw = src.trim();
2571
2834
  if (raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
@@ -3090,7 +3353,7 @@ function coverageErrorMessage(page, { figmaGate = false } = {}) {
3090
3353
  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. ${sourceManifestHint(page, figmaGate)} See docs/entry-points.md for the AI-generated entry point contract.`;
3091
3354
  }
3092
3355
  if (!fileUrl) {
3093
- 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.`;
3356
+ 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, or, for hand-written or template HTML, remove design_source from the page and map it in the source-html manifest. ${sourceManifestHint(page, figmaGate)}`;
3094
3357
  }
3095
3358
  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. ${sourceManifestHint(page, figmaGate)}`;
3096
3359
  }
@@ -3329,6 +3592,7 @@ function validateSourcePreparation(packet, packetPath, errors, warnings, ready,
3329
3592
  sourceRoot,
3330
3593
  pages,
3331
3594
  wrapperPolicy: packet.source_html?.adapter_contract?.wrapper_policy,
3595
+ targetRoot: resolveFromFile(packetPath, packet.assembly?.target_repo),
3332
3596
  });
3333
3597
  derived.source_preparation = {
3334
3598
  checked_page_count: result.checked_page_count,
@@ -4636,6 +4900,9 @@ export {
4636
4900
  recordCampaignIdentityGate,
4637
4901
  recordScriptSyntaxGate,
4638
4902
  recordSdkMarkupGate,
4903
+ recordCartPlaceholders,
4904
+ collectCartPlaceholderPages,
4905
+ recordSmokeQc,
4639
4906
  summarizeCopyMatches,
4640
4907
  resolveBrandContract,
4641
4908
  resolveBrandContractOnce,