@nextcommerce/campaigns-os 1.50.0 → 1.53.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 (87) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/agents/claude/CLAUDE.md +4 -2
  3. package/agents/codex/AGENTS.md +3 -1
  4. package/agents/copilot/copilot-instructions.md +3 -1
  5. package/agents/cursor/campaigns-os.mdc +3 -1
  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 +282 -14
  13. package/contracts/release-ledger.json +1233 -0
  14. package/contracts/supported-surface.json +2 -2
  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 +2 -0
  18. package/docs/build-packet.md +148 -23
  19. package/docs/campaign-build-brief.md +57 -2
  20. package/docs/effects.md +14 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +12 -0
  24. package/docs/progress-snapshots.md +2 -4
  25. package/docs/qa-and-test-orders.md +47 -4
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +1 -1
  29. package/docs/skills-revision.md +10 -10
  30. package/package.json +1 -1
  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 +5 -3
  36. package/skills/next-campaigns-os/SKILL.md +11 -20
  37. package/skills/next-campaigns-os/references/session-intake.md +17 -9
  38. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  39. package/skills/next-campaigns-polish/SKILL.md +7 -5
  40. package/skills/next-campaigns-qa/SKILL.md +8 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +33 -20
  44. package/src/brief-summary.mjs +275 -0
  45. package/src/build-brief.mjs +229 -30
  46. package/src/built-site-scope.mjs +39 -6
  47. package/src/built-smoke-qc.mjs +1117 -0
  48. package/src/campaign-identity.mjs +36 -2
  49. package/src/cart-placeholders.mjs +730 -0
  50. package/src/cli.mjs +467 -143
  51. package/src/commercial-journey.mjs +65 -4
  52. package/src/commercial-parity.mjs +6 -1
  53. package/src/contrast.mjs +649 -0
  54. package/src/design-source-publication.mjs +110 -3
  55. package/src/doctor/checks.mjs +415 -33
  56. package/src/doctor/inspect.mjs +53 -2
  57. package/src/doctor/next-step.mjs +16 -7
  58. package/src/input-currency.mjs +394 -0
  59. package/src/input-refresh.mjs +708 -0
  60. package/src/invocation.mjs +5 -2
  61. package/src/local-preview-policy.mjs +1 -1
  62. package/src/local-proof.mjs +4 -1
  63. package/src/polish-browser.mjs +660 -141
  64. package/src/polish-capture.mjs +1 -1
  65. package/src/polish-deadline.mjs +7 -0
  66. package/src/polish-media-weight.mjs +492 -0
  67. package/src/polish-node.mjs +400 -28
  68. package/src/polish-readability.mjs +828 -0
  69. package/src/progress-node.mjs +19 -3
  70. package/src/qa-browser.mjs +439 -180
  71. package/src/qa-content-params.mjs +889 -0
  72. package/src/qa-node.mjs +116 -13
  73. package/src/qa-order-bump.mjs +22 -1
  74. package/src/qa-policy-links.mjs +1019 -0
  75. package/src/qa-tracking-params.mjs +1389 -0
  76. package/src/qa-url-privacy.mjs +168 -0
  77. package/src/qc-accept.mjs +482 -0
  78. package/src/qc-check-registry.mjs +91 -0
  79. package/src/qc-results.mjs +1341 -0
  80. package/src/readback.mjs +58 -10
  81. package/src/sdk-attribute-index.mjs +71 -0
  82. package/src/sdk-markup.mjs +2 -2
  83. package/src/sdk-storage-compatibility.mjs +63 -3
  84. package/src/source-prep.mjs +37 -7
  85. package/src/stage-ledger.mjs +235 -44
  86. package/src/stage-record.mjs +165 -33
  87. package/src/tooling-setup.mjs +1 -1
@@ -1,7 +1,8 @@
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 { createHash } from "node:crypto";
5
+ import { accessSync, constants as fsConstants, existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
5
6
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
6
7
  import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
7
8
  import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthsDisagree } from "../proof-policy.mjs";
@@ -44,6 +45,7 @@ import {
44
45
  LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
45
46
  LOCAL_PROOF_NEVER_EDIT_RULE,
46
47
  LOCAL_PROOF_PARITY_COMMAND,
48
+ LOCAL_PROOF_RECORD_BUILD_COMMAND,
47
49
  LOCAL_PROOF_PARITY_FIELD,
48
50
  LOCAL_PROOF_PARITY_SCOPE,
49
51
  recordedBuildEnvironment,
@@ -84,8 +86,12 @@ import {
84
86
  import { CAMPAIGN_IDENTITY, evaluateCampaignIdentity, externalScriptSources } from "../campaign-identity.mjs";
85
87
  import { SDK_MARKUP, evaluateSdkMarkup } from "../sdk-markup.mjs";
86
88
  import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSyntax } from "../built-script-syntax.mjs";
89
+ import { CART_PLACEHOLDERS, CART_PLACEHOLDERS_LIMITS, evaluateCartPlaceholders, isFileReadFailure } from "../cart-placeholders.mjs";
90
+ import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, isBuiltPageReadFailure, pageScriptSources, parseBuiltPage, realPathOf } from "../built-smoke-qc.mjs";
91
+ import { recordQcResults } from "../qc-results.mjs";
87
92
  import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
88
93
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
94
+ import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
89
95
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
90
96
  import {
91
97
  assemblySourcePackageFingerprintMissing,
@@ -104,6 +110,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
104
110
  // so a fresh install (including the git-ref consumer) always has dist.
105
111
  import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
106
112
  import { cmd, asInvocation } from "../install-invocation.mjs";
113
+ import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
107
114
  import {
108
115
  isObject,
109
116
  isNonEmptyString,
@@ -428,7 +435,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
428
435
  {
429
436
  id: CAMPAIGN_IDENTITY,
430
437
  phase: "built-output",
431
- run: ({ packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived),
438
+ run: ({ spec, packet, errors, ready, derived }) => validateCampaignIdentity(packet, errors, ready, derived, spec),
432
439
  },
433
440
  {
434
441
  id: SDK_MARKUP,
@@ -440,6 +447,16 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
440
447
  phase: "built-output",
441
448
  run: ({ packet, errors, warnings, ready, derived }) => validateBuiltScriptSyntax(packet, errors, warnings, ready, derived),
442
449
  },
450
+ {
451
+ id: CART_PLACEHOLDERS,
452
+ phase: "built-output",
453
+ run: ({ packet, warnings, ready, derived }) => validateCartPlaceholders(packet, warnings, ready, derived),
454
+ },
455
+ {
456
+ id: SMOKE_QC,
457
+ phase: "built-output",
458
+ run: ({ packet, warnings, ready, derived, buildState }) => validateSmokeQc(packet, warnings, ready, derived, buildState),
459
+ },
443
460
  {
444
461
  id: "built_output.sdk_meta_tags",
445
462
  phase: "built-output",
@@ -496,7 +513,10 @@ const PACKET_DOCTOR_CHECKS = createDoctorCheckRegistry([
496
513
  {
497
514
  id: "build_brief.artifact",
498
515
  phase: "brief",
499
- run: ({ packet, packetPath, spec, context, errors, warnings, ready }) => validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready),
516
+ run: ({ packet, packetPath, spec, context, report, errors, warnings, ready, derived }) => {
517
+ validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready);
518
+ validateInputCurrency({ packet, packetPath, spec, report, warnings, derived });
519
+ },
500
520
  },
501
521
  ], { registryId: "packet.always" });
502
522
 
@@ -716,14 +736,22 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
716
736
  }
717
737
  buildState.specStatus = specStatus;
718
738
  if (specStatus === "ok") {
719
- const specMapId = spec.spec_identity?.map_id || spec.map_id;
720
- if ((specMapId && specMapId !== packet.spec.map_id)
721
- || ((spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null)
722
- && !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
723
- const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
724
- 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" });
739
+ const identityKind = specIdentityMismatch(spec, packet.spec);
740
+ if (identityKind) {
741
+ addIssue(errors, identityKind === "local_spec" ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: identityKind });
725
742
  }
726
743
  ready.push("Local CampaignSpec parsed");
744
+ // The spec no longer has the material hash the Assembly Report binds:
745
+ // a local spec, or the copy a saved-Map or gateway packet fetched.
746
+ // Doctor (and next, which prints doctor's warnings) name it at the
747
+ // first read.
748
+ const boundMaterialHash = buildState.report?.identity?.spec_material_hash;
749
+ if (isNonEmptyString(boundMaterialHash)) {
750
+ const currentMaterialHash = specMaterialHash(spec);
751
+ if (!specHashesMatch(boundMaterialHash, currentMaterialHash)) {
752
+ addIssue(warnings, "spec.material_stale", SPEC_MATERIAL_STALE_MESSAGE, { spec_path: localSpecPath, bound_material_hash: boundMaterialHash, current_material_hash: currentMaterialHash });
753
+ }
754
+ }
727
755
  runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
728
756
  } else {
729
757
  runDoctorChecks(
@@ -737,6 +765,9 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
737
765
  if (synthesizedBuiltSite) {
738
766
  ready.push("Packet source/proof checks skipped for synthesized built-site packet");
739
767
  } else {
768
+ // Computed once, before the checks that read it (the QA gate pass the
769
+ // built-output residue scan defers to) and the input warnings below.
770
+ derived.input_currency = deriveInputCurrency({ packet, packetPath, report: buildState.report, spec: isObject(spec) ? spec : null });
740
771
  runDoctorChecks(PACKET_DOCTOR_CHECKS, { packet, packetPath, spec, context: buildState.context, report: buildState.report, errors, warnings, ready, derived, buildState });
741
772
  }
742
773
 
@@ -756,6 +787,25 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
756
787
  // stage progression.
757
788
  }
758
789
 
790
+ // A stage's spec stamp, as record build, record polish and the QA write store it.
791
+ const SPEC_STAMP = /^sha256:[0-9a-f]{64}$/;
792
+ const SPEC_MATERIAL_STALE_MESSAGE = "CampaignSpec material changed since it was bound; run `record spec` (keeps history). `prepare-build --force` resets every stage and archives their records.";
793
+
794
+ /**
795
+ * Whether a parsed CampaignSpec names another campaign than `bound` (the
796
+ * packet's spec block, or an Assembly Report identity): null when they
797
+ * agree, otherwise the kind of identity that differs ("local_spec" when
798
+ * either side carries a local spec id, else "saved_map").
799
+ */
800
+ export function specIdentityMismatch(spec, bound) {
801
+ const specMapId = spec?.spec_identity?.map_id || spec?.map_id;
802
+ const localIdentity = spec?.spec_identity?.local_spec_id != null || bound?.local_spec_id != null;
803
+ if ((specMapId && specMapId !== bound?.map_id) || (localIdentity && !campaignIdentitiesMatch(campaignSpecIdentity(spec), bound))) {
804
+ return localIdentity ? "local_spec" : "saved_map";
805
+ }
806
+ return null;
807
+ }
808
+
759
809
  function validateCampaignSpecRuleRegistry(spec, errors, warnings) {
760
810
  // ADR-003: run the shared campaign-spec rule registry — the single public
761
811
  // source of CampaignSpec validation. specOnlyRules is the right preset here:
@@ -851,8 +901,18 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
851
901
  return;
852
902
  }
853
903
 
854
- const brief = readJson(resolvedPath);
855
- const result = validateCampaignBuildBriefArtifact(brief, { spec });
904
+ let brief;
905
+ try {
906
+ brief = readJson(resolvedPath);
907
+ } catch {
908
+ addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is not valid JSON: ${normalizedPath}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`);
909
+ return;
910
+ }
911
+ if (!isObject(brief)) {
912
+ addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is not a valid brief object: ${normalizedPath}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`);
913
+ return;
914
+ }
915
+ const result = validateCampaignBuildBriefArtifact(brief, { spec, normalizedPath });
856
916
  for (const issue of result.errors) errors.push(issue);
857
917
  for (const issue of result.warnings) warnings.push(issue);
858
918
  ready.push(...result.ready);
@@ -862,6 +922,86 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
862
922
  }
863
923
  }
864
924
 
925
+ // derived.input_currency, computed once per doctor read, and the warnings it
926
+ // gives: a stage owed on the brief or on the CampaignSpec, a completed stage
927
+ // with no valid brief or spec stamp, or read against a brief or CampaignSpec
928
+ // that cannot be read, a brief file edited (or unreadable) since it was saved,
929
+ // and the build replay after an input change (owed, or kept by the
930
+ // operator's recorded decision). None is an error; `next` routes to the owed
931
+ // stage itself.
932
+ function validateInputCurrency({ packet, packetPath, spec, report, warnings, derived }) {
933
+ const currency = derived.input_currency || deriveInputCurrency({ packet, packetPath, report, spec: isObject(spec) ? spec : null });
934
+ derived.input_currency = currency;
935
+ if (!isObject(report?.stages)) return;
936
+ // A stage stamped with other spec content than the file holds now, while
937
+ // the report identity already names the file's content (an identity edited
938
+ // by hand): the same warning the identity comparison gives.
939
+ const owedOnSpec = Object.keys(currency.reasons).filter((key) => currency.reasons[key] === "spec_material_changed");
940
+ if (owedOnSpec.length && !warnings.some((issue) => issue.code === "spec.material_stale")) {
941
+ addIssue(warnings, "spec.material_stale", SPEC_MATERIAL_STALE_MESSAGE, { stages: owedOnSpec, bound_material_hash: currency.spec.bound, current_material_hash: currency.spec.current });
942
+ }
943
+ const specUnstamped = Object.keys(currency.stages).filter((key) => currency.reasons[key] === "input_binding_unknown" && !SPEC_STAMP.test(String(report.stages[key]?.source_spec_material_hash ?? "")));
944
+ if (specUnstamped.length) {
945
+ addIssue(warnings, "spec.binding_unknown", `Recorded ${specUnstamped.join(", ")} ${specUnstamped.length === 1 ? "does" : "do"} not say which CampaignSpec content ${specUnstamped.length === 1 ? "it was" : "they were"} made against, so ${specUnstamped.length === 1 ? "it reads" : "they read"} unconfirmed, not current. Record ${specUnstamped.join(", ")} again (${specUnstamped.map((key) => (key === "assembly" ? "record build" : key === "polish" ? "record polish" : "qa run")).join(", ")}).`, { stages: specUnstamped });
946
+ }
947
+ const owedOnBrief = Object.keys(currency.reasons).filter((key) => currency.reasons[key] === "brief_material_changed");
948
+ if (owedOnBrief.length) {
949
+ addIssue(warnings, "build_brief.material_changed", `The Campaign Build Brief's content changed since ${owedOnBrief.join(", ")} ${owedOnBrief.length === 1 ? "was" : "were"} recorded, so ${owedOnBrief.length === 1 ? "it is" : "they are"} owed again. Save the brief with ${cmd("record")} brief --packet <packet> (it keeps stage history), then record ${owedOnBrief.join(", ")} again.`, { stages: owedOnBrief });
950
+ }
951
+ const unstamped = Object.keys(currency.stages).filter((key) => {
952
+ const stage = report.stages[key];
953
+ if (currency.reasons[key] !== "input_binding_unknown") return false;
954
+ const stamp = stage?.source_brief_material;
955
+ return !wellFormedBriefMaterial(stamp);
956
+ });
957
+ if (unstamped.length) {
958
+ addIssue(warnings, "build_brief.binding_unknown", `Recorded ${unstamped.join(", ")} ${unstamped.length === 1 ? "does" : "do"} not say which Campaign Build Brief content ${unstamped.length === 1 ? "it was" : "they were"} made against, so ${unstamped.length === 1 ? "it reads" : "they read"} unconfirmed, not current. Record ${unstamped.join(", ")} again (${unstamped.map((key) => (key === "assembly" ? "record build" : key === "polish" ? "record polish" : "qa run")).join(", ")}).`, { stages: unstamped });
959
+ }
960
+ // A completed stage with a well-formed stamp also reads unknown when the
961
+ // current brief or CampaignSpec cannot be read: there is nothing to compare
962
+ // that stamp with. The warning names the file to restore; a stage whose
963
+ // stamp is missing or malformed is named by the stamp warning above only.
964
+ const unknownStages = Object.keys(currency.stages).filter((key) => currency.reasons[key] === "input_binding_unknown");
965
+ const unconfirmed = (stages) => `recorded ${stages.join(", ")} ${stages.length === 1 ? "reads" : "read"} unconfirmed, not current`;
966
+ const briefUnreadable = unknownStages.filter((key) => !unstamped.includes(key));
967
+ if (briefUnreadable.length && currency.brief.current === null) {
968
+ const normalizedPath = optionalString(packet?.build_brief?.normalized_path);
969
+ addIssue(warnings, "build_brief.binding_unknown", `${normalizedPath ? `The normalized Campaign Build Brief ${normalizedPath} cannot be read as a brief object` : "The Build Packet names no build_brief.normalized_path, so the current Campaign Build Brief cannot be read"}, so ${unconfirmed(briefUnreadable)}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`, { stages: briefUnreadable, ...(normalizedPath ? { normalized_path: normalizedPath } : {}) });
970
+ }
971
+ const specPath = optionalString(packet?.spec?.local_path);
972
+ const specUnreadable = unknownStages.filter((key) => !specUnstamped.includes(key));
973
+ if (specUnreadable.length && currency.spec.current === null && specPath) {
974
+ addIssue(warnings, "spec.binding_unknown", `The CampaignSpec ${specPath} cannot be read as a JSON object, so ${unconfirmed(specUnreadable)}. Restore it, then run doctor again.`, { stages: specUnreadable, spec_path: specPath });
975
+ }
976
+ const inputPath = optionalString(packet?.build_brief?.input_path);
977
+ const savedSha = optionalString(report.build_brief?.input_sha256);
978
+ const briefFile = inputPath ? resolveFromFile(packetPath, inputPath) : null;
979
+ if (briefFile && savedSha && existsSync(briefFile)) {
980
+ // Read only a readable regular file: a pipe would block, a directory throws.
981
+ const unusable = briefFileUnusable(briefFile);
982
+ let bytes = null;
983
+ if (!unusable) {
984
+ try {
985
+ bytes = `sha256:${createHash("sha256").update(readFileSync(briefFile)).digest("hex")}`;
986
+ } catch {
987
+ bytes = null;
988
+ }
989
+ }
990
+ if (bytes === null) {
991
+ addIssue(warnings, "build_brief.input_unsaved", `The brief file ${inputPath} ${unusable || "cannot be read"}, so doctor cannot tell whether it changed since it was saved; the build reads the saved brief. Restore it, or save a readable brief file with ${cmd("record")} brief --packet <packet> --brief <file>.`, { input_path: inputPath });
992
+ } else if (bytes !== savedSha) {
993
+ addIssue(warnings, "build_brief.input_unsaved", `The brief file ${inputPath} changed since it was saved. Save it with ${cmd("record")} brief --packet <packet>; until then the build reads the saved brief.`, { input_path: inputPath });
994
+ }
995
+ }
996
+ if (currency.reasons.assembly === "output_unchanged_after_input_change") {
997
+ addIssue(warnings, "assembly.output_unchanged_after_input_change", `The build output is unchanged since the brief or CampaignSpec change was recorded, so the change has not reached the pages and build is owed again. Rebuild, or, only on the operator's explicit decision that the change needs no output change, record build with --deviation-reason "<the operator's reason>".`);
998
+ }
999
+ const keptReason = report.stages.assembly?.unchanged_output_reason;
1000
+ if (currency.stages.assembly === "current" && isObject(keptReason) && typeof keptReason.text === "string") {
1001
+ addIssue(warnings, "assembly.output_unchanged_by_operator_decision", `The build output was kept unchanged after an input change by the operator's recorded decision: "${keptReason.text}".`, { reason: keptReason.text });
1002
+ }
1003
+ }
1004
+
865
1005
  function validateProofPolicyObject(policy, location, warnings, ready, { requireBrowserQa = false } = {}) {
866
1006
  if (!isObject(policy)) {
867
1007
  addIssue(warnings, location, `${location} must be an object when present.`);
@@ -1129,7 +1269,7 @@ export function isLocalhostDevelopmentOrigin(value) {
1129
1269
  // Both facts are read from the assembly stage's free-form evidence.
1130
1270
  function validateLocalProof(packet, report, errors, warnings, ready) {
1131
1271
  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.`);
1272
+ 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
1273
  return;
1134
1274
  }
1135
1275
  const environment = recordedBuildEnvironment(report);
@@ -1137,8 +1277,8 @@ function validateLocalProof(packet, report, errors, warnings, ready) {
1137
1277
  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
1278
  } else {
1139
1279
  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}`);
1280
+ ? `${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}`
1281
+ : `${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
1282
  }
1143
1283
  const parity = recordedProductionParity(report);
1144
1284
  const parityCommand = asInvocation(LOCAL_PROOF_PARITY_COMMAND);
@@ -1983,17 +2123,30 @@ function recordUpsellSelectorScopeGate({ subject, pages, waivers, errors, warnin
1983
2123
  // carries another funnel's key or tag arrives in a later edit round as often
1984
2124
  // as at first assembly. Enumerates from the filesystem so both doctor paths
1985
2125
  // scan the same pages, and stays blocking regardless of stage status.
1986
- function validateCampaignIdentity(packet, errors, ready, derived) {
2126
+ //
2127
+ // With a CampaignSpec, each built page is marked by whether some active spec
2128
+ // page builds to it (the same path route drift claims), so a finding on a
2129
+ // file no spec page produces names it as leftover output.
2130
+ export function validateCampaignIdentity(packet, errors, ready, derived, spec = null) {
1987
2131
  const targetRepo = derived.target_repo;
1988
2132
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1989
2133
  const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1990
2134
  const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
2135
+ const pages = scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [];
2136
+ const specPages = isObject(spec) ? activeSpecPages(spec) : [];
2137
+ if (specPages.length) {
2138
+ const claimed = new Set(specPages
2139
+ .map((page) => builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived))
2140
+ .filter(Boolean)
2141
+ .map((path) => resolve(path)));
2142
+ for (const page of pages) page.spec_page = claimed.has(resolve(targetRepo, page.file));
2143
+ }
1991
2144
  recordCampaignIdentityGate({
1992
2145
  subject: {
1993
2146
  public_route_slug: publicRouteSlug || null,
1994
2147
  site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
1995
2148
  },
1996
- pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [],
2149
+ pages,
1997
2150
  errors,
1998
2151
  ready,
1999
2152
  derived,
@@ -2009,7 +2162,24 @@ function validateCampaignIdentity(packet, errors, ready, derived) {
2009
2162
  // `/<slug>/config.js`), then the campaign directory (a root-served campaign
2010
2163
  // emits `/config.js`); relative srcs resolve against the page. Remote and
2011
2164
  // missing scripts contribute nothing.
2012
- function collectBuiltPageIdentityInputs(scope, targetRepo) {
2165
+ //
2166
+ // The bounded form ({ pages, bounds }) takes pages the caller already read
2167
+ // ({file, content?}, as collectCartPlaceholderPages gives them) and adds each
2168
+ // page's `scripts` as the smoke check's anchor script hint reads them
2169
+ // (boundedPageScripts), listed from the page's bounded parse5 tree
2170
+ // (pageScriptSources: any attribute quoting, spacing or case; never a
2171
+ // non-JavaScript type or a script in <template> or <noscript>). A page with
2172
+ // no content, or one that cannot be parsed, is returned as given. The smoke
2173
+ // check itself hands boundedPageScripts the srcs its own parse lists, so a
2174
+ // page is parsed once.
2175
+ function collectBuiltPageIdentityInputs(scope, targetRepo, { pages = null, bounds = null } = {}) {
2176
+ if (pages && bounds) {
2177
+ const scriptsOf = boundedPageScripts(scope.site_root, targetRepo, bounds);
2178
+ return pages.map((page) => {
2179
+ const sources = typeof page?.content === "string" ? boundedScriptSources(page.content) : null;
2180
+ return sources ? { ...page, scripts: scriptsOf(sources, join(targetRepo, page.file)) } : page;
2181
+ });
2182
+ }
2013
2183
  const scriptCache = new Map();
2014
2184
  const readScript = (path) => {
2015
2185
  if (!scriptCache.has(path)) {
@@ -2023,23 +2193,11 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
2023
2193
  }
2024
2194
  return scriptCache.get(path);
2025
2195
  };
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
2196
  return scope.pages.map((page) => {
2039
2197
  const content = readFileSync(page.built_path, "utf8");
2040
2198
  const scripts = [];
2041
2199
  for (const src of externalScriptSources(content)) {
2042
- const path = resolveLocalScript(src, page.built_path);
2200
+ const path = builtLocalScriptPath(scope, src, page.built_path);
2043
2201
  const scriptContent = path ? readScript(path) : null;
2044
2202
  if (scriptContent == null) continue;
2045
2203
  scripts.push({ src, file: relFromDir(targetRepo, path), content: scriptContent });
@@ -2054,6 +2212,84 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
2054
2212
  });
2055
2213
  }
2056
2214
 
2215
+ // Where a page's local `<script src>` lives, or null for a remote, data: or
2216
+ // empty src. An absolute src is the first of its site-root and campaign
2217
+ // candidates that exists, otherwise null.
2218
+ function builtLocalScriptPath(scope, src, builtPath) {
2219
+ const raw = String(src || "").trim();
2220
+ if (!raw || raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:")) return null;
2221
+ const clean = raw.replace(/[?#].*$/, "");
2222
+ if (!clean) return null;
2223
+ if (clean.startsWith("/")) {
2224
+ const rel = clean.replace(/^\/+/, "");
2225
+ const candidates = [join(scope.site_root, rel), join(scope.campaign_dir, rel)];
2226
+ return candidates.find((candidate) => existsSync(candidate)) || null;
2227
+ }
2228
+ return resolve(dirname(builtPath), clean);
2229
+ }
2230
+
2231
+ // The srcs of the scripts a page loads, or null when it cannot be parsed.
2232
+ function boundedScriptSources(content) {
2233
+ try {
2234
+ return pageScriptSources(parseBuiltPage(content));
2235
+ } catch (error) {
2236
+ if (!isBuiltPageReadFailure(error)) throw error;
2237
+ return null;
2238
+ }
2239
+ }
2240
+
2241
+ // The smoke check's script reader: given the srcs a page loads and the
2242
+ // page's file, every local script, read or not. The first `bounds.scripts`
2243
+ // are read when their real path lies inside the site root and they hold at
2244
+ // most `bounds.script_bytes` bytes ({src, file, content}); any other reads
2245
+ // {src, file, unread} with `missing`, `outside_site`, `too_large`,
2246
+ // `unreadable` or `script_cap`, or {src, unread: "unmappable"} when its src
2247
+ // names no file. Each src maps to its file through builtFileOf: the URL
2248
+ // parser resolves it against the page's URL under the site root (a
2249
+ // root-relative src starts at the site root, never the campaign directory),
2250
+ // and its path segments are percent-decoded. Each script file is read at most
2251
+ // once per run.
2252
+ function boundedPageScripts(siteRoot, targetRepo, bounds) {
2253
+ const realSiteRoot = realPathOf(siteRoot);
2254
+ const cache = new Map();
2255
+ const read = (path) => {
2256
+ if (!cache.has(path)) cache.set(path, readBoundedScript(realSiteRoot, path, bounds.script_bytes));
2257
+ return cache.get(path);
2258
+ };
2259
+ return (sources, builtPath) => {
2260
+ const scripts = [];
2261
+ for (const src of sources) {
2262
+ const mapped = builtFileOf(src, builtPath, siteRoot);
2263
+ if (!mapped) continue;
2264
+ if (mapped.unmappable) {
2265
+ scripts.push({ src, unread: "unmappable" });
2266
+ continue;
2267
+ }
2268
+ const path = mapped.path;
2269
+ const file = relFromDir(targetRepo, path);
2270
+ scripts.push(scripts.length < bounds.scripts ? { src, file, ...read(path) } : { src, file, unread: "script_cap" });
2271
+ }
2272
+ return scripts;
2273
+ };
2274
+ }
2275
+
2276
+ // { content } or { unread }. A file-system read failure reads unread; any
2277
+ // other error is a defect and throws.
2278
+ function readBoundedScript(realSiteRoot, path, maxBytes) {
2279
+ try {
2280
+ const real = realpathSync(path);
2281
+ if (!realSiteRoot || !insideRoot(realSiteRoot, real)) return { unread: "outside_site" };
2282
+ const stat = statSync(real);
2283
+ if (!stat.isFile()) return { unread: "unreadable" };
2284
+ if (stat.size > maxBytes) return { unread: "too_large" };
2285
+ const bytes = readFileSync(real);
2286
+ return bytes.length > maxBytes ? { unread: "too_large" } : { content: bytes.toString("utf8") };
2287
+ } catch (error) {
2288
+ if (!isFileReadFailure(error)) throw error;
2289
+ return { unread: error.code === "ENOENT" || error.code === "ENOTDIR" ? "missing" : "unreadable" };
2290
+ }
2291
+ }
2292
+
2057
2293
  function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
2058
2294
  const gate = evaluateCampaignIdentity({ subject, pages });
2059
2295
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
@@ -2143,6 +2379,148 @@ function recordScriptSyntaxGate({ subject, inputs, errors, warnings, ready, deri
2143
2379
  return gate;
2144
2380
  }
2145
2381
 
2382
+ // Raw cart placeholders. Every doctor invocation, both entry
2383
+ // points, filesystem enumeration, like the gates above; but a QC check, not a
2384
+ // checkpoint gate: its results land in derived.qc_results (warning and review
2385
+ // also in warnings[]), never in errors[] or derived.checkpoint_gates, so no
2386
+ // blocker elsewhere can withhold them and checkpoint waive never sees them.
2387
+ function validateCartPlaceholders(packet, warnings, ready, derived) {
2388
+ const targetRepo = derived.target_repo;
2389
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2390
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
2391
+ recordCartPlaceholders({
2392
+ subject: {
2393
+ public_route_slug: publicRouteSlug || null,
2394
+ site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
2395
+ },
2396
+ pages: siteRoot && existsSync(siteRoot) ? collectCartPlaceholderPages(targetRepo, publicRouteSlug) : [],
2397
+ warnings,
2398
+ ready,
2399
+ derived,
2400
+ });
2401
+ }
2402
+
2403
+ // Every built page under the campaign directory, in path order, including
2404
+ // every symbolic link that may hold a page (an .html link, a directory link,
2405
+ // or a link whose target cannot be inspected; each one entry, read as
2406
+ // unreadable, never followed). Pages within the page cap carry their HTML,
2407
+ // read one page at a time, unless they are over the size cap or cannot be
2408
+ // read; pages past it carry only their path.
2409
+ function collectCartPlaceholderPages(targetRepo, slug) {
2410
+ const scope = resolveBuiltSiteScope(targetRepo, { slug, includeLinkedPages: true });
2411
+ const linked = new Set((scope.linked_pages || []).map((page) => page.built_path));
2412
+ const all = [...(scope.ok ? scope.pages : []), ...(scope.linked_pages || [])]
2413
+ .sort((a, b) => (a.built_path < b.built_path ? -1 : a.built_path > b.built_path ? 1 : 0));
2414
+ const fileOf = (page) => builtPageFile(targetRepo, page.built_path);
2415
+ return [
2416
+ ...all.slice(0, CART_PLACEHOLDERS_LIMITS.pages).map((page) => readCartPlaceholderPage(page.built_path, fileOf(page), linked.has(page.built_path))),
2417
+ ...all.slice(CART_PLACEHOLDERS_LIMITS.pages).map((page) => ({ file: fileOf(page) })),
2418
+ ];
2419
+ }
2420
+
2421
+ // One page within the page cap: its HTML, its size when over the size cap, or
2422
+ // unreadable. A file-system read failure, before or after the readability
2423
+ // precheck (EIO, EACCES, EISDIR ...), reads it unreadable; any other error is
2424
+ // a defect and throws.
2425
+ function readCartPlaceholderPage(path, file, linked) {
2426
+ if (linked) return { file, unreadable: true };
2427
+ try {
2428
+ const bytes = statSync(path).size;
2429
+ if (!isReadableFile(path)) return { file, unreadable: true };
2430
+ if (bytes > CART_PLACEHOLDERS_LIMITS.page_bytes) return { file, bytes };
2431
+ return { file, content: readFileSync(path, "utf8") };
2432
+ } catch (error) {
2433
+ if (!isFileReadFailure(error)) throw error;
2434
+ return { file, unreadable: true };
2435
+ }
2436
+ }
2437
+
2438
+ // A built page's path relative to the doctor target, "/"-separated and with no
2439
+ // "./" prefix ("_site/<slug>/index.html"). The directory is resolved, the file
2440
+ // name never is, so a symlinked page keeps its own name.
2441
+ function builtPageFile(targetRepo, path) {
2442
+ const dir = relFromDir(targetRepo, dirname(path)).replace(/^\.(?:\/|$)/, "");
2443
+ return [...(dir ? dir.split(sep) : []), basename(path)].join("/");
2444
+ }
2445
+
2446
+ // Whether the page can be opened for reading. Only a file-system read failure
2447
+ // says it cannot; any other error is a defect and throws.
2448
+ function isReadableFile(path) {
2449
+ try {
2450
+ accessSync(path, fsConstants.R_OK);
2451
+ return true;
2452
+ } catch (error) {
2453
+ if (!isFileReadFailure(error)) throw error;
2454
+ return false;
2455
+ }
2456
+ }
2457
+
2458
+ function recordCartPlaceholders({ subject, pages, warnings, ready, derived }) {
2459
+ const results = evaluateCartPlaceholders({ subject, pages });
2460
+ recordQcResults({ derived, warnings, results });
2461
+ if (!pages.length) {
2462
+ ready.push("Cart placeholder check not applicable: no built page to scan yet.");
2463
+ return results;
2464
+ }
2465
+ // A ready line only when no page went unexercised.
2466
+ if (results.some((row) => row.result === "unexercised")) return results;
2467
+ const passed = results.filter((row) => row.result === "pass").length;
2468
+ const flagged = results.filter((row) => row.result === "warning" || row.result === "review").length;
2469
+ ready.push(`Cart placeholder check on ${pages.length} built page(s): ${passed} page(s) pass, ${flagged} warning or review result(s)`);
2470
+ return results;
2471
+ }
2472
+
2473
+ // Built-output smoke checks. Same placement and the same QC wiring as the
2474
+ // cart placeholder check above, over the same built pages. The build
2475
+ // environment is the one the Assembly Report records (nothing measures it),
2476
+ // and the deploy URLs give the base an absolute og:image maps from.
2477
+ function validateSmokeQc(packet, warnings, ready, derived, buildState = {}) {
2478
+ const targetRepo = derived.target_repo;
2479
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2480
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
2481
+ recordSmokeQc({
2482
+ subject: {
2483
+ public_route_slug: publicRouteSlug || null,
2484
+ site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
2485
+ },
2486
+ targetRepo,
2487
+ pages: siteRoot && existsSync(siteRoot) ? collectCartPlaceholderPages(targetRepo, publicRouteSlug) : [],
2488
+ environment: recordedBuildEnvironment(buildState?.report),
2489
+ deployBase: [packet?.deploy?.preview_url, packet?.deploy?.production_url],
2490
+ warnings,
2491
+ ready,
2492
+ derived,
2493
+ });
2494
+ }
2495
+
2496
+ function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, warnings, ready, derived }) {
2497
+ // The site root is the scope's, whichever form of target doctor was given
2498
+ // (the repo, its _site/ or a campaign directory). The anchor script hint
2499
+ // reads each page's local scripts through boundedPageScripts, from the srcs
2500
+ // the gate's own parse lists. With no scope to resolve them in, a page's
2501
+ // local scripts read unread.
2502
+ const scope = targetRepo && pages.length ? resolveBuiltSiteScope(targetRepo, { slug: subject?.public_route_slug || null }) : null;
2503
+ const siteRoot = scope?.site_root || null;
2504
+ const results = evaluateSmokeQc({
2505
+ pages,
2506
+ environment,
2507
+ siteRoot,
2508
+ targetDir: targetRepo || null,
2509
+ readScripts: siteRoot ? boundedPageScripts(siteRoot, targetRepo, SMOKE_QC_LIMITS) : null,
2510
+ deployBase,
2511
+ });
2512
+ recordQcResults({ derived, warnings, results });
2513
+ if (!pages.length) {
2514
+ ready.push("Smoke checks not applicable: no built page to scan yet.");
2515
+ return results;
2516
+ }
2517
+ // A ready line only when no result went unexercised (contract 1.0).
2518
+ if (results.some((row) => row.result === "unexercised")) return results;
2519
+ const count = (...values) => results.filter((row) => values.includes(row.result)).length;
2520
+ ready.push(`Smoke checks on ${pages.length} built page(s): ${count("pass")} pass, ${count("warning", "review")} warning or review result(s)`);
2521
+ return results;
2522
+ }
2523
+
2146
2524
  function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
2147
2525
  const gate = evaluateSdkMarkup({ subject, pages });
2148
2526
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
@@ -2565,7 +2943,7 @@ function pageKitAssetPathViolation(reference, publicRouteSlug) {
2565
2943
  };
2566
2944
  }
2567
2945
 
2568
- function resolveBuiltAssetPath(src, builtPath, targetRepo) {
2946
+ export function resolveBuiltAssetPath(src, builtPath, targetRepo) {
2569
2947
  if (!isNonEmptyString(src)) return null;
2570
2948
  const raw = src.trim();
2571
2949
  if (raw.startsWith("//") || isAbsoluteHttpUrl(raw) || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) return null;
@@ -3090,7 +3468,7 @@ function coverageErrorMessage(page, { figmaGate = false } = {}) {
3090
3468
  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
3469
  }
3092
3470
  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.`;
3471
+ 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
3472
  }
3095
3473
  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
3474
  }
@@ -3329,6 +3707,7 @@ function validateSourcePreparation(packet, packetPath, errors, warnings, ready,
3329
3707
  sourceRoot,
3330
3708
  pages,
3331
3709
  wrapperPolicy: packet.source_html?.adapter_contract?.wrapper_policy,
3710
+ targetRoot: resolveFromFile(packetPath, packet.assembly?.target_repo),
3332
3711
  });
3333
3712
  derived.source_preparation = {
3334
3713
  checked_page_count: result.checked_page_count,
@@ -3993,7 +4372,7 @@ export function validateBuiltPlaceholderTextResidue(brandContract, warnings, rea
3993
4372
  return;
3994
4373
  }
3995
4374
  const terms = [...new Set(hits.map((hit) => hit.label))].join(", ");
3996
- if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report) })) {
4375
+ if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report), qaCurrency: derived.input_currency?.stages?.qa ?? "unknown" })) {
3997
4376
  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.`);
3998
4377
  return;
3999
4378
  }
@@ -4636,6 +5015,9 @@ export {
4636
5015
  recordCampaignIdentityGate,
4637
5016
  recordScriptSyntaxGate,
4638
5017
  recordSdkMarkupGate,
5018
+ recordCartPlaceholders,
5019
+ collectCartPlaceholderPages,
5020
+ recordSmokeQc,
4639
5021
  summarizeCopyMatches,
4640
5022
  resolveBrandContract,
4641
5023
  resolveBrandContractOnce,