@nextcommerce/campaigns-os 1.60.0 → 1.62.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
@@ -14,6 +14,7 @@ import {
14
14
  } from "../adapter-decision-contract.mjs";
15
15
  import { createDoctorCheckRegistry, runDoctorCheckRegistry } from "../doctor-check-registry.mjs";
16
16
  import { evaluateSourcePreparation } from "../source-prep.mjs";
17
+ import { staleRoutingHints } from "../spec-derive.mjs";
17
18
  import { isLoopbackHostname } from "../remit.mjs";
18
19
  import {
19
20
  HOST_STRIPPED_CODE,
@@ -23,10 +24,10 @@ import {
23
24
  } from "../source-html-intake.mjs";
24
25
  import {
25
26
  readSourceHtmlManifestFile,
26
- SOURCE_HASH_PATTERN,
27
27
  SOURCE_HTML_MANIFEST_SCHEMA,
28
28
  } from "../source-html-manifest.mjs";
29
29
  import { validateAssemblyReportThemeBlock, validateThemeContextBlock } from "../brand-theme.mjs";
30
+ import { packetRuntimeRelPath } from "../campaign-workspace.mjs";
30
31
  import { singleLineDetail, singleLineField } from "../text-safety.mjs";
31
32
  import {
32
33
  campaignRouteRoot,
@@ -35,6 +36,7 @@ import {
35
36
  normalizePublicRouteSlug,
36
37
  packetRouteRoot,
37
38
  runtimeRelativeRouteForSpecValue,
39
+ specPublicRouteSlug,
38
40
  stripPublicRoutePrefix,
39
41
  } from "../route-identity.mjs";
40
42
  import { evaluatePageKitBuildSummary } from "../page-kit-build-summary.mjs";
@@ -89,7 +91,8 @@ import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSynta
89
91
  import { CART_PLACEHOLDERS, CART_PLACEHOLDERS_LIMITS, evaluateCartPlaceholders, isFileReadFailure } from "../cart-placeholders.mjs";
90
92
  import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, isBuiltPageReadFailure, pageScriptSources, parseBuiltPage, realPathOf } from "../built-smoke-qc.mjs";
91
93
  import { recordQcResults } from "../qc-results.mjs";
92
- import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
94
+ import { evaluateDesignAccess } from "../parity/design-access.mjs";
95
+ import { collectFigmaProvenanceFindings, FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
93
96
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
94
97
  import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
95
98
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
@@ -102,6 +105,7 @@ import {
102
105
  import { loadPageKitCampaignEntry, pageKitIgnoresAssetsHtml, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
103
106
  import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
104
107
  import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
108
+ import { evaluatePageKitAnalyticsVendors, PAGE_KIT_ANALYTICS_VENDORS_SCOPE } from "../page-kit-analytics-vendors.mjs";
105
109
  // ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
106
110
  // campaign authoring UI (e.g. a Map Builder bundle) import the same rules, so a
107
111
  // spec check is authored once and reaches internal teams and agencies alike.
@@ -382,6 +386,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
382
386
  phase: "target",
383
387
  run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetStoreProfile(spec, errors, warnings, ready, derived, buildState),
384
388
  },
389
+ {
390
+ id: PAGE_KIT_ANALYTICS_VENDORS_SCOPE,
391
+ phase: "target",
392
+ run: ({ spec, warnings, ready, buildState }) => validateTargetAnalyticsVendors(spec, warnings, ready, buildState),
393
+ },
385
394
  {
386
395
  id: "spec.shipping_countries",
387
396
  phase: "spec",
@@ -392,6 +401,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
392
401
  phase: "spec",
393
402
  run: ({ spec, packet, warnings, ready, derived, buildState }) => validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived, buildState),
394
403
  },
404
+ {
405
+ id: "spec.routing_hint_stale",
406
+ phase: "spec",
407
+ run: ({ spec, packet, errors, ready }) => validateSpecRoutingHintsCurrent(spec, packet, errors, ready),
408
+ },
395
409
  {
396
410
  id: "spec.routing_host_prefix",
397
411
  phase: "spec",
@@ -1416,6 +1430,18 @@ function validateTargetStoreProfile(spec, errors, warnings, ready, derived, buil
1416
1430
  + storeProfileIntentionallyEmptyNote(intentionallyEmpty));
1417
1431
  }
1418
1432
 
1433
+ // The starter-template vendor keys against analytics.vendors: advisory only,
1434
+ // one warning per vendor the two sides disagree on (#689).
1435
+ function validateTargetAnalyticsVendors(spec, warnings, ready, buildState) {
1436
+ const result = evaluatePageKitAnalyticsVendors({ spec, targetLoad: buildState?.pageKitCampaignConfig });
1437
+ for (const finding of result.findings) {
1438
+ addIssue(warnings, finding.code, finding.message, { vendor: finding.vendor, id_key: finding.id_key, repair: finding.repair });
1439
+ }
1440
+ if (result.status === "ok" && result.vendors.length) {
1441
+ ready.push(`Target campaigns.json vendor keys match the CampaignSpec analytics.vendors (${result.vendors.join(", ")}).`);
1442
+ }
1443
+ }
1444
+
1419
1445
  function validateSpecShippingCountries(spec, warnings, ready) {
1420
1446
  const countries = spec?.campaign?.available_shipping_countries;
1421
1447
  if (countries === "all" || (Array.isArray(countries) && countries.length === 0)) {
@@ -1557,11 +1583,7 @@ export function validateRouteSlugIdentity(spec, packet, errors, ready) {
1557
1583
  const mapId = optionalString(spec?.spec_identity?.map_id)
1558
1584
  || optionalString(spec?.map_id)
1559
1585
  || optionalString(packet?.spec?.map_id);
1560
- const specSlug = normalizePublicRouteSlug(
1561
- optionalString(spec?.spec_identity?.public_route_slug)
1562
- || optionalString(spec?.campaign?.slug)
1563
- || optionalString(spec?.campaign?.id)
1564
- );
1586
+ const specSlug = specPublicRouteSlug(spec);
1565
1587
  const specDeclaresMapIdSlug = Boolean(specSlug) && Boolean(mapId) && specSlug === mapId;
1566
1588
 
1567
1589
  if (mapId && packetSlug === mapId && !specDeclaresMapIdSlug) {
@@ -1692,6 +1714,31 @@ export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, deriv
1692
1714
  );
1693
1715
  }
1694
1716
 
1717
+ // #531: a routing hint (`sdk_hints.meta_tags` next-success-url and the upsell
1718
+ // accept/decline urls) that does not match the route of the page its routing
1719
+ // field names. Nothing else catches it once the build exists: the built meta
1720
+ // check compares the built page against the hint, and QA derives its
1721
+ // expectation from the hint, so a moved upsell with checkout still pointing
1722
+ // at the old path passes both and sends shoppers to a 404 after payment. It
1723
+ // blocks on every read, before and after the build, and record spec refuses a
1724
+ // routes-only bind on the same function (spec-derive.mjs staleRoutingHints).
1725
+ export function validateSpecRoutingHintsCurrent(spec, packet, errors, ready) {
1726
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1727
+ const stale = staleRoutingHints(spec, { publicRouteSlug });
1728
+ if (!stale.length) {
1729
+ if (activeSpecPages(spec).some((page) => isObject(page.sdk_hints?.meta_tags))) ready.push("CampaignSpec routing hints match the routes of the pages they name");
1730
+ return;
1731
+ }
1732
+ const sample = stale.slice(0, 5).map((hint) => `${hint.page_id}:${hint.tag} ${JSON.stringify(hint.value)} but page "${hint.target_page_id}" routes to ${JSON.stringify(hint.derived_route)}`).join("; ");
1733
+ const more = stale.length > 5 ? `; plus ${stale.length - 5} more` : "";
1734
+ addIssue(
1735
+ errors,
1736
+ "spec.routing_hint_stale",
1737
+ `CampaignSpec routing hint(s) name a route the page they point at no longer has: ${sample}${more}. The built meta tag follows the hint, so the SDK would send shoppers to the old path. Edit each hint to the route shown in the spec (or, while the Map is still the spec's source, re-save the Map, which regenerates them), then run record spec.`,
1738
+ { hints: stale },
1739
+ );
1740
+ }
1741
+
1695
1742
  // #531: a route with a host in front of its path ("shop.example.com/route/x/")
1696
1743
  // is nested under the campaign root by every stage that composes a URL
1697
1744
  // (polish capture requested "/route/shop.example.com/route/x/"), so it blocks.
@@ -3264,6 +3311,12 @@ function scanMarketSensitiveCopy(spec, warnings, ready, derived) {
3264
3311
  }
3265
3312
  }
3266
3313
 
3314
+ // CampaignSpec owns `campaign.available_currencies`: every currency the
3315
+ // campaign sells in, the default included (the CampaignCurrencies rule checks
3316
+ // its codes). `additional_currencies` is the Campaigns App API's name for the
3317
+ // same list without the default and is not a spec field; both market scopes
3318
+ // still read it, and its camelCase spelling, so a spec copied from an API body
3319
+ // gets the copy review it needs.
3267
3320
  function deriveMarketScope(spec) {
3268
3321
  const campaign = spec?.campaign || {};
3269
3322
  const defaultCurrency = normalizeCurrency(campaign.currency);
@@ -3674,6 +3727,12 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3674
3727
  derived,
3675
3728
  });
3676
3729
  const active = activeSpecPages(spec);
3730
+ const designPackagePath = resolveFromFile(packetPath, packet?.design_source_package?.path);
3731
+ let designSourcePackage = null;
3732
+ try { if (designPackagePath) designSourcePackage = JSON.parse(readFileSync(designPackagePath, "utf8")); } catch { /* Missing or unreadable package is checked separately. */ }
3733
+ const designManifest = isNonEmptyString(sourceRoot) && existsSync(sourceRoot) && statSync(sourceRoot).isDirectory()
3734
+ ? readSourceHtmlManifestFile(sourceRoot, { manifestPath: recordedDesignManifestPath(packet, packetPath) }).manifest : null;
3735
+ recordQcResults({ derived, warnings, results: evaluateDesignAccess({ activePages: active, mappings: pages, manifest: designManifest, designSourcePackage, measured_at: new Date().toISOString() }) });
3677
3736
  const specPartialScope = spec?.build_scope?.mode === "partial";
3678
3737
  const specPartialReasons = Array.isArray(spec?.build_scope?.reasons) ? spec.build_scope.reasons.filter(isNonEmptyString) : [];
3679
3738
  const activeIds = new Set(active.map((page) => page.id));
@@ -3706,6 +3765,25 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3706
3765
  if (!activeIds.has(page.page_id)) {
3707
3766
  addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
3708
3767
  }
3768
+ // A page added from the repo's page tree after handoff (#721): its source
3769
+ // is the target page file itself, so it is mapped and, once that file
3770
+ // exists, a build candidate like any other.
3771
+ if (page.origin === "repo") {
3772
+ const repoFile = optionalString(page.page_kit?.output_path) && derived.target_repo ? resolve(derived.target_repo, page.page_kit.output_path) : null;
3773
+ if (!repoFile || !existsSync(repoFile) || !statSync(repoFile).isFile()) {
3774
+ addIssue(errors, "source_html.pages.repo_page_missing", `Page "${page.page_id}" was added from the repo's page tree, but its page file ${page.page_kit?.output_path || "(no page_kit.output_path)"} is not in the target repo. Restore the file, or run spec derive and record spec to remove the page.`, { page_id: page.page_id, output_path: page.page_kit?.output_path || null });
3775
+ } else if (specPage) {
3776
+ builtPages.push({
3777
+ page_id: specPage.id,
3778
+ type: specPage.type || "page",
3779
+ role: pageRole(specPage.type),
3780
+ route: builtRoute,
3781
+ source_path: null,
3782
+ repo_source: page.page_kit.output_path,
3783
+ });
3784
+ }
3785
+ continue;
3786
+ }
3709
3787
  if (page.path) {
3710
3788
  const fullPath = resolve(sourceRoot, page.path);
3711
3789
  if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
@@ -3988,78 +4066,6 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
3988
4066
  }
3989
4067
  }
3990
4068
 
3991
- // Returns every Figma-provenance finding in emission order, each tagged with
3992
- // its severity: "provenance" (the waivable source_html.producer_provenance*
3993
- // and source_html.files.partial/asset blockers) or "warning".
3994
- function collectFigmaProvenanceFindings(manifest, provenance, rawProvenance) {
3995
- const findings = [];
3996
- const add = (severity, code, message) => findings.push({ severity, code, message });
3997
- if (!rawProvenance) {
3998
- add(
3999
- "provenance",
4000
- "source_html.producer_provenance",
4001
- "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
4002
- );
4003
- }
4004
-
4005
- if (provenance.source_type !== "semantic_figma_export") {
4006
- add(
4007
- "provenance",
4008
- "source_html.producer_provenance.source_type",
4009
- `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.`
4010
- );
4011
- }
4012
- if (provenance.screenshot_fallback_used !== false) {
4013
- add(
4014
- "provenance",
4015
- "source_html.producer_provenance.screenshot_fallback_used",
4016
- "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
4017
- );
4018
- }
4019
- if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
4020
- add(
4021
- "provenance",
4022
- "source_html.producer_provenance.semantic_section_count",
4023
- "Figma source manifest must report semantic_section_count > 0."
4024
- );
4025
- }
4026
- if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
4027
- add(
4028
- "provenance",
4029
- "source_html.producer_provenance.material_fingerprint",
4030
- "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
4031
- );
4032
- }
4033
-
4034
- const files = Array.isArray(manifest?.files) ? manifest.files : [];
4035
- if (!files.some((entry) => entry.role === "partial")) {
4036
- add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
4037
- }
4038
- if (!files.some((entry) => entry.role === "asset")) {
4039
- add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
4040
- }
4041
-
4042
- const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
4043
- if (!sectionExports.length) {
4044
- add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
4045
- } else {
4046
- const withoutNodeIds = sectionExports
4047
- .filter((entry) => {
4048
- const nodeIds = isObject(entry?.node_ids) ? entry.node_ids : null;
4049
- return !nodeIds || !Object.keys(nodeIds).length;
4050
- })
4051
- .map((entry) => entry?.section || "unknown");
4052
- if (withoutNodeIds.length) {
4053
- add(
4054
- "warning",
4055
- "source_html.producer_provenance.section_exports.node_ids",
4056
- `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
4057
- );
4058
- }
4059
- }
4060
-
4061
- return findings;
4062
- }
4063
4069
 
4064
4070
  function hasFigmaDesignSource(page) {
4065
4071
  const designSource = page && isObject(page.design_source) ? page.design_source : null;
@@ -4691,7 +4697,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
4691
4697
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
4692
4698
  const siteRoot = derived.target_repo && publicRouteSlug ? join(derived.target_repo, "_site", publicRouteSlug) : null;
4693
4699
  if (!siteRoot || !existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) return;
4694
- const brief = loadBriefPayload(derived.target_repo);
4700
+ const brief = loadBriefPayload(derived.target_repo, packetRuntimeRelPath(packet));
4695
4701
  const urgencyVerified = briefUrgencyVerified(brief?.payload);
4696
4702
  const findings = scanBuiltOutputContentResidue(siteRoot, { urgencyVerified });
4697
4703
  if (!findings.length) {
@@ -4760,7 +4766,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
4760
4766
  // non-shipped, non-usable assets stay warnings (the attestation queue), so a
4761
4767
  // producer that correctly excluded them is not blocked.
4762
4768
  export function validateProofAttestation(packet, errors, warnings, ready, derived, buildState = {}) {
4763
- const brief = loadBriefPayload(derived.target_repo);
4769
+ const brief = loadBriefPayload(derived.target_repo, packetRuntimeRelPath(packet));
4764
4770
  if (!brief) return; // not an AI-assembled run — no brief payload, no gate
4765
4771
  if (brief.error) {
4766
4772
  // Fail closed: the brief payload is the source of attestation state. If
@@ -0,0 +1,177 @@
1
+ import { worseChangeKind } from "../change-scope.mjs";
2
+ import { assessEvidenceDebt } from "../evidence-debt.mjs";
3
+ import { REFUSED_INVOCATION } from "../lifecycle.mjs";
4
+
5
+ export const DOCTOR_CI_EXIT_CODES = Object.freeze({ current: 0, owed: 3, unknown: 4, usage: 2 });
6
+
7
+ export function assessDoctorCi(inputs) {
8
+ const debt = assessEvidenceDebt(inputs);
9
+ // Advisory Polish (a page-only change) is reported, never required.
10
+ const stages = [debt.polish, debt.qa].filter((stage) => !stage.advisory);
11
+ // Any known debt is owed; otherwise incomplete evidence remains unknown.
12
+ const status = stages.some((stage) => stage.status === "owed") ? "owed"
13
+ : stages.some((stage) => stage.status === "unknown") ? "unknown" : "current";
14
+ return { status, exit_code: DOCTOR_CI_EXIT_CODES[status], ...debt };
15
+ }
16
+
17
+ // What the change owes, from the stages it left owed: "pages" when the only
18
+ // required stage is QA on the changed pages (Polish advisory or current),
19
+ // "full" when a full stage is owed, null when nothing is owed. `kind` is the
20
+ // worst change kind among the owed stages.
21
+ export function ciChangeScope(ci) {
22
+ const owed = ["polish", "qa"].filter((stage) => ci[stage]?.status === "owed");
23
+ if (!owed.length) return { scope: null, kind: null };
24
+ const kind = owed.map((stage) => ci[stage].change?.kind || "unknown").reduce(worseChangeKind, "none");
25
+ const required = owed.filter((stage) => !ci[stage].advisory);
26
+ const scope = required.every((stage) => stage === "qa" && ci.qa.scope === "pages") ? "pages" : "full";
27
+ return { scope, kind };
28
+ }
29
+
30
+ // Page ids come from built routes ([A-Za-z0-9_-]); anything else is encoded.
31
+ const pageArg = (id) => (/^[A-Za-z0-9_-]+$/.test(String(id)) ? String(id) : text(id));
32
+
33
+ const commerceReasonText = new Map([
34
+ ["unmapped_file", "a file outside any page changed"],
35
+ ["page_added", "a page was added"],
36
+ ["page_removed", "a page was removed"],
37
+ ["checkout_page", "a checkout page changed"],
38
+ ["commerce_markup_changed", "package, offer, shipping, upsell, route or script markup changed"],
39
+ ["commerce_signature_unavailable", "the recorded build has no commerce signature to compare against"],
40
+ ["campaign_config_changed", "the campaign's Page Kit config (SDK pin, analytics ids, store fields) changed"],
41
+ ["campaign_config_unavailable", "the campaign's Page Kit config entry could not be read"],
42
+ ["change_history_unavailable", "the rebuilds since the reviewed build were not kept"],
43
+ ]);
44
+
45
+ // Values can come from campaign files and will be rendered in a PR comment.
46
+ // Encode punctuation as well as HTML: diagnostics are plain text, and may
47
+ // contain Markdown, autolinks, code fences or line breaks from campaign files.
48
+ const text = (value) => String(value).replace(/\s/g, " ")
49
+ .replace(/[!-/:-@\[-`{-~]/g, (char) => `&#${char.charCodeAt(0)};`);
50
+ // Inline code already protects ordinary path punctuation; preserve those
51
+ // bytes for readable page paths and issue codes, while preventing breakouts.
52
+ const code = (value) => `\`${String(value).replace(/\s/g, " ")
53
+ .replace(/[!-/:-@\[-`{-~]/g, (char) => "./_-".includes(char) ? char : `&#${char.charCodeAt(0)};`)}\``;
54
+
55
+ const unavailablePageChanges = new Map([
56
+ ["packet_unavailable", "the build packet or its path is unavailable."],
57
+ ["build_output_location_unknown", "the built output location cannot be determined."],
58
+ ["build_output_missing", "the built output is missing."],
59
+ ["build_output_unreadable", "the built output could not be read."],
60
+ ["assembly_build_fingerprint_missing", "the assembly build fingerprint is missing."],
61
+ ["build_output_pages_unresolved", "the built output could not be mapped to pages."],
62
+ ["build_manifest_unavailable", "no usable assembly manifest is available."],
63
+ ]);
64
+
65
+ const requiredStageReasons = new Map([
66
+ ["stage_required_by_build", "the build requires this stage again"],
67
+ ["stage_required_by_brief", "the brief requires this stage again"],
68
+ ["stage_required_by_spec", "the spec requires this stage again"],
69
+ ["stage_required", "the lifecycle requires this stage again"],
70
+ ]);
71
+
72
+ export function doctorCiFailure(error, { usage = false, code = usage ? "doctor.ci.packet" : "doctor.ci.failed" } = {}) {
73
+ return {
74
+ ok: false, status: "blocked",
75
+ errors: [{ code, message: String(error?.message ?? error) }],
76
+ warnings: [], exit_code: usage ? DOCTOR_CI_EXIT_CODES.usage : DOCTOR_CI_EXIT_CODES.unknown,
77
+ };
78
+ }
79
+
80
+ // Presentation boundary only: invocation policy still decides session and
81
+ // lifecycle behavior. Setup can throw before doctor's dispatch branch runs.
82
+ export async function withDoctorCiReport(args, invoke) {
83
+ const command = args._[0] === "campaigns-os" ? args._[1] : args._[0];
84
+ try {
85
+ return await invoke(args);
86
+ } catch (error) {
87
+ if (command !== "doctor" || args.ci !== true) throw error;
88
+ // A flag refused up front (such as --packet=<path>) is a usage error.
89
+ const result = error?.code === REFUSED_INVOCATION ? doctorCiFailure(error, { usage: true, code: "doctor.ci.usage" }) : doctorCiFailure(error);
90
+ console.log(args.json ? JSON.stringify(result, null, 2) : formatDoctorCi(result));
91
+ process.exitCode = result.exit_code;
92
+ }
93
+ }
94
+
95
+ export function formatDoctorCi(result) {
96
+ const ci = result.ci;
97
+ if (!ci) {
98
+ return [
99
+ `## Campaign evidence: could not evaluate (exit ${result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage})`, "",
100
+ ...(result.errors || []).map((issue) => `- ${code(issue.code)}: ${text(issue.message)}`), "",
101
+ result.errors?.[0]?.code === "doctor.ci.usage"
102
+ ? "Remedy: correct the command's flags (each value as the next argument, `--flag value`) and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`."
103
+ : "Remedy: correct the packet or inspection failure and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`.",
104
+ ].join("\n");
105
+ }
106
+ const lines = [`## Campaign evidence: ${text(ci.status)} (exit ${ci.exit_code})`, ""];
107
+ const advisory = ["polish", "qa"].filter((stage) => ci[stage].status === "owed" && ci[stage].advisory);
108
+ const owed = ["polish", "qa"].filter((stage) => ci[stage].status === "owed" && !ci[stage].advisory);
109
+ const change = ciChangeScope(ci);
110
+ const pageScoped = change.scope === "pages";
111
+ if (pageScoped) {
112
+ lines.push(`Change found: page-only. ${owed.length ? `It owes QA on ${ci.qa.owed_pages.map((page) => code(page.page_id)).join(", ")}` : "QA is current"}; Polish is advisory.`);
113
+ } else if (change.scope === "full") {
114
+ const reasons = [...new Set((ci.polish.change?.reasons || []).concat(ci.qa.change?.reasons || [])
115
+ .map((reason) => commerceReasonText.get(reason.code)).filter(Boolean))];
116
+ const found = change.kind === "commerce_affecting" ? "commerce-affecting"
117
+ : change.kind === "page_only" ? "page-only, but an input change or a Polish or QA that did not complete requires the full stages" : "not classified";
118
+ // Name only the stages that count, each at the scope it is owed: an
119
+ // advisory Polish is not owed, and QA can still be owed on pages only.
120
+ const qaPages = ci.qa.scope === "pages" ? `QA on ${ci.qa.owed_pages.map((page) => code(page.page_id)).join(", ")}` : null;
121
+ const owes = owed.length === 2 ? (qaPages ? `the full Polish, and ${qaPages}` : "the full Polish and QA")
122
+ : owed[0] === "polish" ? "the full Polish" : qaPages || "a full QA run";
123
+ lines.push(`Change found: ${found}${reasons.length ? ` (${reasons.join("; ")})` : ""}. It owes ${owes}${advisory.includes("polish") ? "; Polish is advisory" : ""}.`);
124
+ }
125
+ lines.push(`Owed stages: ${owed.length ? owed.map((stage) => stage === "qa" && ci.qa.scope === "pages" ? "qa (changed pages only)" : stage).join(", ") : "none"}.`);
126
+ for (const stage of owed) {
127
+ const reason = ci[stage].reason === "build_fingerprint_changed"
128
+ ? "the recorded build fingerprint differs from the current build"
129
+ : requiredStageReasons.get(ci[stage].reason) ?? "the recorded stage evidence is owed";
130
+ lines.push(`- Owed ${stage}: ${reason}.`);
131
+ }
132
+ for (const stage of advisory) {
133
+ lines.push(`- Advisory ${stage}: the change since its last review is page-only, so it is reported and not required.`);
134
+ }
135
+ // The pages changed since the builds the owed stages reviewed, which
136
+ // record build keeps; without them, the drift from the assembly manifest.
137
+ const sincePages = new Map();
138
+ for (const stage of [...owed, ...advisory]) {
139
+ for (const page of ci[stage].change?.changed_pages || []) sincePages.set(page.built_path, page);
140
+ }
141
+ const changedPages = sincePages.size ? [...sincePages.values()] : ci.changes.changed_pages;
142
+ for (const page of changedPages) {
143
+ lines.push(`- Changed page: ${code(page.page_id)} (${code(page.built_path)})`);
144
+ }
145
+ if (!changedPages.length && (owed.length || ci.changes.status === "unknown")) {
146
+ lines.push(ci.changes.status === "unknown"
147
+ ? `Changed pages unavailable: ${unavailablePageChanges.get(ci.changes.reason) ?? "the page-change reason is unrecognized."}`
148
+ : "Changed page ids unavailable: the retained assembly manifest may already describe the rebuilt output.");
149
+ }
150
+ for (const stage of ["polish", "qa"]) {
151
+ if (ci[stage].status === "unknown") lines.push(`- Unknown ${stage}: ${code(ci[stage].reason)}`);
152
+ }
153
+ if (ci.changes.status === "unknown") lines.push(`- Unknown page changes: ${code(ci.changes.reason)}`);
154
+ if (ci.status === "owed" && pageScoped && owed.length) {
155
+ const pages = ci.qa.owed_pages.map((page) => ` --page ${pageArg(page.page_id)}`).join("");
156
+ const recorded = ci.changes.recorded_build_fingerprint && ci.changes.recorded_build_fingerprint === ci.changes.current_build_fingerprint;
157
+ lines.push("", `Remedy: ${recorded ? "" : "run `campaigns-os record build --packet <packet>` for the rebuilt output, then "}run \`campaigns-os qa run --packet <packet>${pages}\` against that build. Polish capture is optional for a page-only change.`);
158
+ } else if (ci.status === "owed" || ci.status === "unknown") {
159
+ const matchingRequired = owed.length > 0
160
+ && owed.every((stage) => requiredStageReasons.has(ci[stage].reason) && ci[stage].matches_current_build === true)
161
+ && ["polish", "qa"].every((stage) => ci[stage].status !== "unknown");
162
+ if (matchingRequired) {
163
+ const commands = [];
164
+ if (owed.includes("polish")) commands.push("`campaigns-os polish capture --packet <packet> --base-url <served-current-build-url>` for the whole route", "`campaigns-os record polish --packet <packet> --evidence <polish-evidence.json>`");
165
+ if (owed.includes("qa")) commands.push("`campaigns-os qa run --packet <packet>`");
166
+ lines.push("", `Remedy: re-run the required stages against the existing build: ${commands.join(", then ")}.`);
167
+ } else {
168
+ lines.push("", "Remedy: rebuild, then run `campaigns-os record build --packet <packet>`, `campaigns-os polish capture --packet <packet> --base-url <served-current-build-url>` for the whole route, and `campaigns-os record polish --packet <packet> --evidence <polish-evidence.json>`, in that order. When QA is owed, run `campaigns-os qa run --packet <packet>` against that build.");
169
+ }
170
+ }
171
+ lines.push("", `Doctor inspection: ${(result.errors || []).length} error(s), ${(result.warnings || []).length} warning(s); CI exit status depends only on evidence debt.`);
172
+ if (result.errors?.length) {
173
+ lines.push(`Doctor errors: ${[...new Set(result.errors.map((issue) => issue.code))].map(code).join(", ")}.`);
174
+ }
175
+ lines.push("Use ordinary `doctor` or `--ci --json` for full inspection details.");
176
+ return lines.join("\n");
177
+ }
@@ -1,6 +1,8 @@
1
1
  // Doctor entry points: the doctor command, packet inspection and built-output inspection.
2
2
  import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
3
- import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
3
+ import { assessDoctorCi, doctorCiFailure } from "./ci.mjs";
4
+ import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, applyPageOnlyChangeToCheckpoint, applyPageOnlyChangeToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
5
+ import { assessEvidenceDebt } from "../evidence-debt.mjs";
4
6
  import { withHtmlScanSnapshot } from "../html-scan.mjs";
5
7
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
8
  import { dirname, join, resolve } from "node:path";
@@ -10,7 +12,7 @@ import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledge
10
12
  import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
11
13
  import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
12
14
  import { recordQcResults } from "../qc-results.mjs";
13
- import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
15
+ import { packetRuntimeRelPath, resolveCampaignWorkspace } from "../campaign-workspace.mjs";
14
16
  import { normalizePublicRouteSlug } from "../route-identity.mjs";
15
17
  import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
16
18
  import { disabledLiveRead, liveRefsDisabled, readLiveCampaignForPacket } from "../live-campaign-refs.mjs";
@@ -132,6 +134,13 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
132
134
  }
133
135
 
134
136
  export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
137
+ if (args.ci === true) {
138
+ try {
139
+ JSON.parse(readFileSync(resolve(requireArg(args, "packet")), "utf8"));
140
+ } catch (error) {
141
+ return doctorCiFailure(error, { usage: true });
142
+ }
143
+ }
135
144
  // Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
136
145
  // campaign that has only a built _site/ and no full Build Packet. Resolves
137
146
  // scope from the built output and runs the built-output residue/text/
@@ -153,6 +162,16 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
153
162
  ...(qcStandIns !== undefined ? { qcStandIns } : {}),
154
163
  };
155
164
  const result = runDoctor(packetPath, doctorOptions);
165
+ if (args.ci === true) {
166
+ // Use precisely the context/report selection that inspection used,
167
+ // including explicit sidecar suppression and the context's report binding.
168
+ const workspace = resolveCampaignWorkspace(packetPath, {
169
+ contextPath: doctorOptions.contextPath,
170
+ reportPath: doctorOptions.reportPath,
171
+ followContextPointer: true,
172
+ });
173
+ result.ci = assessDoctorCi({ packet: workspace.packet, packetPath, report: readJsonIfExists(workspace.reportPath) });
174
+ }
156
175
  // Inspection and recording are separate operations. A laptop's untracked
157
176
  // built output can be stale while the delivered build's evidence is valid.
158
177
  // Only an explicit producer action may replace the retained proof artifacts;
@@ -440,6 +459,16 @@ export function doctorBuiltOutput(args) {
440
459
  };
441
460
  }
442
461
 
462
+ // Inspection already refused a packet whose runtime directory is unreadable;
463
+ // the history lookup then falls back to the project-level directory.
464
+ function packetRuntimeRelPathOrDefault(packetPath) {
465
+ try {
466
+ return packetRuntimeRelPath(JSON.parse(readFileSync(resolve(packetPath), "utf8")));
467
+ } catch {
468
+ return undefined;
469
+ }
470
+ }
471
+
443
472
  export function doctorPacket(packetPath, options = {}) {
444
473
  const result = withHtmlScanSnapshot(() => inspectDoctorPacket(packetPath, options));
445
474
  // Per-finding cause classification lives HERE, at the single production
@@ -469,6 +498,7 @@ export function doctorPacket(packetPath, options = {}) {
469
498
  errors: result.errors,
470
499
  warnings: result.warnings,
471
500
  baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
501
+ runtimeRelPath: comparableIdentity ? packetRuntimeRelPathOrDefault(packetPath) : undefined,
472
502
  mapId: result.derived?.map_id || null,
473
503
  localSpecId: result.derived?.local_spec_id || null,
474
504
  });
@@ -569,16 +599,21 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
569
599
 
570
600
  // The local preview policy (local-preview-policy.mjs) carries missing
571
601
  // polish and page-load evidence forward as warnings on a local-serve packet.
572
- const polishCheckpointGate = applyLocalPreviewToCheckpoint(
602
+ // A page-only change since Polish last reviewed the campaign carries its
603
+ // stale evidence on any target: Polish is advisory for it (#720). The
604
+ // evidence debt is the one readback and doctor --ci assess.
605
+ const evidenceDebt = assessEvidenceDebt({ packet, packetPath, report });
606
+ derived.evidence_debt = evidenceDebt;
607
+ const polishCheckpointGate = applyPageOnlyChangeToCheckpoint(applyLocalPreviewToCheckpoint(
573
608
  evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
574
609
  { packet, report },
575
- );
610
+ ), evidenceDebt);
576
611
  derived.polish_checkpoint_gate = polishCheckpointGate;
577
- const polishGate = applyLocalPreviewToPolishGate(evaluatePolishGate({
612
+ const polishGate = applyPageOnlyChangeToPolishGate(applyLocalPreviewToPolishGate(evaluatePolishGate({
578
613
  report,
579
614
  hiddenEagerMediaGate: polishCheckpointGate,
580
615
  currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
581
- }), { packet, checkpointGate: polishCheckpointGate });
616
+ }), { packet, checkpointGate: polishCheckpointGate }), { debt: evidenceDebt, checkpointGate: polishCheckpointGate });
582
617
  derived.polish_gate = polishGate;
583
618
  if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
584
619
  pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
@@ -503,6 +503,13 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
503
503
  }
504
504
  if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
505
505
  && !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
506
+ // A page-only change since the build QA reviewed owes QA on the
507
+ // changed pages only (#720); qa run --page pays it.
508
+ const qaDebt = derived?.evidence_debt?.qa;
509
+ if (qaDebt?.scope === "pages" && qaDebt.owed_pages?.length) {
510
+ const pages = qaDebt.owed_pages.map((page) => `--page ${page.page_id}`).join(" ");
511
+ return { stage: "qa", reason: `QA was recorded for a different build, and the change since then is page-only: run QA on the changed pages with ${cmd("qa")} run --packet <packet> ${pages}.` };
512
+ }
506
513
  return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
507
514
  }
508
515
  // A terminal QA status is not the same claim as purchase proof. QA finalizes
@@ -18,6 +18,7 @@
18
18
  // validity, wrapper policy, screenshot proof) is evaluated elsewhere and is
19
19
  // untouched by this gate.
20
20
 
21
+ import { SOURCE_HASH_PATTERN } from "../source-html-manifest.mjs";
21
22
  import {
22
23
  assessCheckpointWaivers,
23
24
  checkpointStateFingerprint,
@@ -182,3 +183,76 @@ export function evaluateSourceProvenanceGates({ pages = [], blockingCodes = [],
182
183
 
183
184
  return { gates, inert: { counts, pages: inertPages } };
184
185
  }
186
+
187
+ // Returns every Figma-provenance finding in emission order, each tagged with
188
+ // its severity: "provenance" (the waivable source_html.producer_provenance*
189
+ // and source_html.files.partial/asset blockers) or "warning".
190
+ export function collectFigmaProvenanceFindings(manifest, provenance = manifest?.producer_provenance || {}, rawProvenance = manifest?.producer_provenance) {
191
+ const findings = [];
192
+ const add = (severity, code, message) => findings.push({ severity, code, message });
193
+ if (!rawProvenance) {
194
+ add(
195
+ "provenance",
196
+ "source_html.producer_provenance",
197
+ "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
198
+ );
199
+ }
200
+
201
+ if (provenance.source_type !== "semantic_figma_export") {
202
+ add(
203
+ "provenance",
204
+ "source_html.producer_provenance.source_type",
205
+ `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.`
206
+ );
207
+ }
208
+ if (provenance.screenshot_fallback_used !== false) {
209
+ add(
210
+ "provenance",
211
+ "source_html.producer_provenance.screenshot_fallback_used",
212
+ "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
213
+ );
214
+ }
215
+ if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
216
+ add(
217
+ "provenance",
218
+ "source_html.producer_provenance.semantic_section_count",
219
+ "Figma source manifest must report semantic_section_count > 0."
220
+ );
221
+ }
222
+ if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
223
+ add(
224
+ "provenance",
225
+ "source_html.producer_provenance.material_fingerprint",
226
+ "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
227
+ );
228
+ }
229
+
230
+ const files = Array.isArray(manifest?.files) ? manifest.files : [];
231
+ if (!files.some((entry) => entry.role === "partial")) {
232
+ add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
233
+ }
234
+ if (!files.some((entry) => entry.role === "asset")) {
235
+ add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
236
+ }
237
+
238
+ const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
239
+ if (!sectionExports.length) {
240
+ add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
241
+ } else {
242
+ const withoutNodeIds = sectionExports
243
+ .filter((entry) => {
244
+ const nodeIds = isPlainObject(entry?.node_ids) ? entry.node_ids : null;
245
+ return !nodeIds || !Object.keys(nodeIds).length;
246
+ })
247
+ .map((entry) => entry?.section || "unknown");
248
+ if (withoutNodeIds.length) {
249
+ add(
250
+ "warning",
251
+ "source_html.producer_provenance.section_exports.node_ids",
252
+ `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
253
+ );
254
+ }
255
+ }
256
+
257
+ return findings;
258
+ }