@nextcommerce/campaigns-os 1.43.2 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +798 -5103
  3. package/README.md +33 -12
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/compatibility.json +1 -1
  14. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  15. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  16. package/contracts/commerce-surface-catalog.json +1204 -129
  17. package/contracts/effects.v1.json +1179 -116
  18. package/contracts/orientation-reason-codes.v1.json +7 -0
  19. package/contracts/release-ledger.json +2515 -5919
  20. package/contracts/supported-surface.json +7 -4
  21. package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
  22. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  23. package/docs/brand-theme-bridge.md +81 -0
  24. package/docs/build-packet.md +180 -23
  25. package/docs/campaigns-os-build-flow.md +3 -3
  26. package/docs/design-source-package.md +73 -0
  27. package/docs/effects.md +50 -8
  28. package/docs/gateway-login.md +3 -0
  29. package/docs/local-setup.md +7 -4
  30. package/docs/orientation-contract-reference.md +42 -2
  31. package/docs/polish-evidence.md +74 -0
  32. package/docs/qa-and-test-orders.md +118 -14
  33. package/docs/release-ledger-authoring-guide.md +64 -4
  34. package/docs/runtime-readiness.md +1 -1
  35. package/docs/sdk-storage-compatibility.md +1 -1
  36. package/docs/skills-revision.md +10 -10
  37. package/docs/supported-surface.md +2 -2
  38. package/docs/versioning.md +4 -1
  39. package/package.json +1 -1
  40. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  41. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  42. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  43. package/skills/campaign-readback-classification/SKILL.md +3 -3
  44. package/skills/campaign-run-evidence/SKILL.md +7 -6
  45. package/skills/contribution-intake/SKILL.md +3 -3
  46. package/skills/next-campaigns-build/SKILL.md +7 -6
  47. package/skills/next-campaigns-os/SKILL.md +7 -7
  48. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  49. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  50. package/skills/next-campaigns-polish/SKILL.md +28 -9
  51. package/skills/next-campaigns-qa/SKILL.md +7 -4
  52. package/skills.json +10 -10
  53. package/src/brand-theme.mjs +320 -20
  54. package/src/built-script-syntax.mjs +116 -15
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/cli.mjs +280 -46
  57. package/src/commercial-parity.mjs +48 -2
  58. package/src/deviation.mjs +13 -1
  59. package/src/diagnostic.mjs +6 -2
  60. package/src/doctor/checks.mjs +319 -81
  61. package/src/doctor/inspect.mjs +55 -13
  62. package/src/doctor/source-provenance.mjs +184 -0
  63. package/src/invocation.mjs +4 -0
  64. package/src/live-campaign-refs.mjs +466 -0
  65. package/src/login.mjs +2 -2
  66. package/src/page-kit-store-profile.mjs +69 -12
  67. package/src/page-kit-sync.mjs +31 -12
  68. package/src/progress-node.mjs +3 -1
  69. package/src/qa-analytics-parity.mjs +37 -2
  70. package/src/qa-binding-evidence.mjs +4 -2
  71. package/src/qa-browser.mjs +612 -40
  72. package/src/qa-commercial-parity.mjs +48 -5
  73. package/src/qa-node.mjs +122 -7
  74. package/src/qa-test-order-topology.mjs +148 -0
  75. package/src/sdk-markup.mjs +32 -7
  76. package/src/sdk-storage-compatibility.mjs +3 -2
  77. package/src/source-html-intake.mjs +116 -0
  78. package/src/stage-record.mjs +551 -0
  79. package/src/tooling-setup.mjs +9 -0
  80. package/src/upsell-selector-scope.mjs +112 -2
@@ -14,7 +14,12 @@ import {
14
14
  import { createDoctorCheckRegistry, runDoctorCheckRegistry } from "../doctor-check-registry.mjs";
15
15
  import { evaluateSourcePreparation } from "../source-prep.mjs";
16
16
  import { isLoopbackHostname } from "../remit.mjs";
17
- import { publicRouteForPage } from "../source-html-intake.mjs";
17
+ import {
18
+ HOST_STRIPPED_CODE,
19
+ parseHostPrefixedRoute,
20
+ publicRouteForPage,
21
+ SDK_ROUTING_META_TAGS,
22
+ } from "../source-html-intake.mjs";
18
23
  import {
19
24
  readSourceHtmlManifestFile,
20
25
  SOURCE_HASH_PATTERN,
@@ -72,12 +77,14 @@ import { assessTemplateFreshness, defaultSdkSupportPolicy, renderTemplateFreshne
72
77
  import { computeBuildFingerprint, resolveBuiltSiteScope } from "../built-site-scope.mjs";
73
78
  import {
74
79
  UPSELL_SELECTOR_SCOPE,
80
+ builtPageTypeOverRouteGuess,
75
81
  evaluateUpsellSelectorScope,
76
82
  isPostPurchasePageType,
77
83
  } from "../upsell-selector-scope.mjs";
78
84
  import { CAMPAIGN_IDENTITY, evaluateCampaignIdentity, externalScriptSources } from "../campaign-identity.mjs";
79
85
  import { SDK_MARKUP, evaluateSdkMarkup } from "../sdk-markup.mjs";
80
86
  import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSyntax } from "../built-script-syntax.mjs";
87
+ import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
81
88
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
82
89
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
83
90
  import {
@@ -86,7 +93,7 @@ import {
86
93
  currentBuildFingerprint,
87
94
  } from "../polish-gate.mjs";
88
95
  import { loadPageKitCampaignEntry, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
89
- import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE } from "../page-kit-store-profile.mjs";
96
+ import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
90
97
  import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
91
98
  // ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
92
99
  // campaign authoring UI (e.g. a Map Builder bundle) import the same rules, so a
@@ -110,6 +117,16 @@ import {
110
117
  addIssue,
111
118
  } from "../cli-helpers.mjs";
112
119
  import { resolveCampaignsApiKeySource, describeCampaignKeyRejection } from "../campaigns-api-key.mjs";
120
+ import {
121
+ LIVE_REF_CODES,
122
+ campaignDriftMessage,
123
+ evaluateLiveCampaignRefs,
124
+ extractRenderedPackageRefs,
125
+ extractRenderedRefs,
126
+ extractRenderedShippingRefs,
127
+ liveRefFindingMessage,
128
+ liveRefsNotRunMessage,
129
+ } from "../live-campaign-refs.mjs";
113
130
 
114
131
  const PACKET_SCHEMA = "campaign-runtime-build-packet/v0";
115
132
  const CONTEXT_SCHEMA = "campaign-runtime-build-context/v0";
@@ -213,12 +230,6 @@ const US_MARKET_COPY_PATTERNS = [
213
230
  const HARDCODED_CURRENCY_REGEX = /\$\s?\d[\d,]*(?:\.\d+)?(?:\/[A-Za-z]+)?/g;
214
231
  const HARDCODED_PHONE_REGEX = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g;
215
232
 
216
- const SDK_ROUTING_META_TAGS = [
217
- "next-success-url",
218
- "next-upsell-accept-url",
219
- "next-upsell-decline-url",
220
- ];
221
-
222
233
  function normalizeFunnels(spec) {
223
234
  if (Array.isArray(spec?.funnels)) return spec.funnels;
224
235
  if (Array.isArray(spec?.funnel_pages)) {
@@ -369,6 +380,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
369
380
  phase: "spec",
370
381
  run: ({ spec, packet, warnings, ready, derived, buildState }) => validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived, buildState),
371
382
  },
383
+ {
384
+ id: "spec.routing_host_prefix",
385
+ phase: "spec",
386
+ run: ({ spec, packet, errors, ready, derived, buildState }) => validateSpecHostPrefixedRoutes(spec, packet, errors, ready, derived, buildState),
387
+ },
372
388
  {
373
389
  id: "source_html.coverage",
374
390
  phase: "source",
@@ -399,6 +415,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
399
415
  phase: "built-output",
400
416
  run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuiltOutputPages(spec, packet, errors, warnings, ready, derived, buildState),
401
417
  },
418
+ {
419
+ id: "built_output.live_campaign_refs",
420
+ phase: "built-output",
421
+ run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateBuiltLiveCampaignRefs(spec, packet, errors, warnings, ready, derived, buildState),
422
+ },
402
423
  {
403
424
  id: UPSELL_SELECTOR_SCOPE,
404
425
  phase: "built-output",
@@ -884,7 +905,10 @@ export function validateSpecStoreProfile(spec, errors, warnings, ready, { packet
884
905
  addIssue(
885
906
  errors,
886
907
  "spec.store_profile",
887
- `CampaignSpec campaign is missing required Store Profile field for page-kit campaigns.json: ${missing.join(", ")}. Add ${missing.map((field) => `campaign.${field}`).join(", ")} to the CampaignSpec Store Profile, then rerun start/prepare-build. Campaigns OS does not infer or silently mutate these storefront/legal values.`,
908
+ `CampaignSpec campaign is missing required Store Profile field for page-kit campaigns.json: ${missing.join(", ")}. Add ${missing.map((field) => `campaign.${field}`).join(", ")} to the CampaignSpec Store Profile, then rerun start/prepare-build. Campaigns OS does not infer or silently mutate these storefront/legal values.`
909
+ + (missing.some((field) => typeof campaign[field] === "string")
910
+ ? ` An explicit "" does not mark a required field as having none; it does so only for the eight optional Store Profile fields.`
911
+ : ""),
888
912
  {
889
913
  missing_fields: missing.map((field) => `campaign.${field}`),
890
914
  repair: {
@@ -1229,7 +1253,9 @@ function validateTargetStoreProfile(spec, errors, warnings, ready, derived, buil
1229
1253
  addIssue(warnings, gate.code, gate.reason, { checkpoint_gate: gate });
1230
1254
  return;
1231
1255
  }
1232
- ready.push("Target campaigns.json Store Profile matches the CampaignSpec across all nine governed fields.");
1256
+ const intentionallyEmpty = gate.matrix.filter((row) => row.kind === "intentionally_empty").map((row) => row.field);
1257
+ ready.push("Target campaigns.json Store Profile matches the CampaignSpec across all nine governed fields."
1258
+ + storeProfileIntentionallyEmptyNote(intentionallyEmpty));
1233
1259
  }
1234
1260
 
1235
1261
  function validateSpecShippingCountries(spec, warnings, ready) {
@@ -1284,11 +1310,11 @@ function specPackageRecords(spec) {
1284
1310
  return records;
1285
1311
  }
1286
1312
 
1287
- function specPackageRefs(spec) {
1313
+ export function specPackageRefs(spec) {
1288
1314
  return new Set(specPackageRecords(spec).map((record) => String(record.ref)));
1289
1315
  }
1290
1316
 
1291
- function specShippingRefs(spec) {
1317
+ export function specShippingRefs(spec) {
1292
1318
  const refs = new Set();
1293
1319
  const add = (method) => {
1294
1320
  const ref = firstCommerceRef(method?.ref_id, method?.id, method?.shipping_method_id);
@@ -1449,26 +1475,31 @@ export function validateBuiltOutputTargetRoot(packet, errors, warnings, ready, d
1449
1475
  );
1450
1476
  }
1451
1477
 
1478
+ // R2-B2: the spec only carries unrooted routing-meta *hints*; the page-kit
1479
+ // build roots them when it renders _site/<slug>/. Once that built output
1480
+ // exists and assembly is complete, validateBuiltSdkMetaTags checks the actual
1481
+ // rendered values authoritatively. Re-warning on the spec literal would just
1482
+ // repeat a "fix before QA" message the build already satisfied (browser QA
1483
+ // later proved the deployed output correct), so the spec-literal checks defer
1484
+ // to the built-output check instead of double-flagging.
1485
+ function specRoutingMetaDeferred(publicRouteSlug, derived, buildState) {
1486
+ const targetRepo = derived.target_repo;
1487
+ const siteRoot = targetRepo ? join(targetRepo, "_site", publicRouteSlug) : null;
1488
+ return Boolean(isStageComplete(buildState.report, "assembly") && siteRoot && existsSync(siteRoot));
1489
+ }
1490
+
1452
1491
  export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived = {}, buildState = {}) {
1453
1492
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1454
1493
  if (!publicRouteSlug) return;
1455
1494
  const routeRoot = campaignRouteRoot(packet);
1456
1495
 
1457
- // R2-B2: the spec only carries unrooted routing-meta *hints*; the
1458
- // page-kit build roots them when it renders _site/<slug>/. Once that built
1459
- // output exists and assembly is complete, validateBuiltSdkMetaTags checks the
1460
- // actual rendered values authoritatively. Re-warning on the spec literal here
1461
- // would just repeat a "fix before QA" message the build already satisfied
1462
- // (browser QA later proved the deployed output correct), so defer to the
1463
- // built-output check instead of double-flagging.
1464
- const targetRepo = derived.target_repo;
1465
- const siteRoot = targetRepo ? join(targetRepo, "_site", publicRouteSlug) : null;
1466
- if (isStageComplete(buildState.report, "assembly") && siteRoot && existsSync(siteRoot)) {
1496
+ if (specRoutingMetaDeferred(publicRouteSlug, derived, buildState)) {
1467
1497
  ready.push(`CampaignSpec routing meta deferred to built-output verification (_site/${publicRouteSlug}/).`);
1468
1498
  return;
1469
1499
  }
1470
1500
 
1471
1501
  const hits = [];
1502
+ let hostPrefixed = 0;
1472
1503
  for (const page of activeSpecPages(spec)) {
1473
1504
  const metaTags = page.sdk_hints?.meta_tags;
1474
1505
  if (!isObject(metaTags)) continue;
@@ -1478,11 +1509,18 @@ export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, deriv
1478
1509
  if (!isNonEmptyString(value)) continue;
1479
1510
  const route = value.trim();
1480
1511
  if (isRuntimeRootedRoutingMeta(route, publicRouteSlug, routeRoot)) continue;
1512
+ // A host in front of the path is validateSpecHostPrefixedRoutes'
1513
+ // blocker, not this warning.
1514
+ if (parseHostPrefixedRoute(route, { keepAbsolute: true })) {
1515
+ hostPrefixed += 1;
1516
+ continue;
1517
+ }
1481
1518
  hits.push(`${page.id}:${tag}=${route}`);
1482
1519
  }
1483
1520
  }
1484
1521
 
1485
1522
  if (!hits.length) {
1523
+ if (hostPrefixed) return;
1486
1524
  ready.push(`CampaignSpec SDK routing meta tags are runtime-rooted for ${routeRoot}`);
1487
1525
  return;
1488
1526
  }
@@ -1496,6 +1534,43 @@ export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, deriv
1496
1534
  );
1497
1535
  }
1498
1536
 
1537
+ // #531: a route with a host in front of its path ("shop.example.com/route/x/")
1538
+ // is nested under the campaign root by every stage that composes a URL
1539
+ // (polish capture requested "/route/shop.example.com/route/x/"), so it blocks.
1540
+ // prepare-build strips the host from a fresh --map-id fetch at intake but
1541
+ // never rewrites a local --spec file or a copy reused with --cached-spec; this
1542
+ // catches one that still reaches doctor. An absolute http(s) URL is accepted
1543
+ // as before (projection converts a page_url to its path; it is a valid SDK
1544
+ // target), so only the bare and protocol-relative forms block. page_url is
1545
+ // read by polish and QA whether or not the site is built, so it is always
1546
+ // checked; routing meta values follow the spec-literal deferral above.
1547
+ export function validateSpecHostPrefixedRoutes(spec, packet, errors, ready, derived = {}, buildState = {}) {
1548
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1549
+ const metaDeferred = publicRouteSlug ? specRoutingMetaDeferred(publicRouteSlug, derived, buildState) : false;
1550
+ const hits = [];
1551
+ for (const page of activeSpecPages(spec)) {
1552
+ const pageUrl = parseHostPrefixedRoute(page.page_url, { keepAbsolute: true });
1553
+ if (pageUrl) hits.push({ page_id: page.id, field: "page_url", value: pageUrl.from, rooted: pageUrl.to });
1554
+ const metaTags = page.sdk_hints?.meta_tags;
1555
+ if (metaDeferred || !isObject(metaTags)) continue;
1556
+ for (const tag of SDK_ROUTING_META_TAGS) {
1557
+ const meta = parseHostPrefixedRoute(metaTags[tag], { keepAbsolute: true });
1558
+ if (meta) hits.push({ page_id: page.id, field: `sdk_hints.meta_tags.${tag}`, value: meta.from, rooted: meta.to });
1559
+ }
1560
+ }
1561
+ if (!hits.length) return;
1562
+
1563
+ const sample = hits.slice(0, 5).map((hit) => `${hit.page_id}:${hit.field} ${JSON.stringify(hit.value)} -> ${JSON.stringify(hit.rooted)}`).join("; ");
1564
+ const more = hits.length > 5 ? `; plus ${hits.length - 5} more` : "";
1565
+ addIssue(
1566
+ errors,
1567
+ "routing_meta.host_prefixed",
1568
+ `CampaignSpec route value(s) carry a host in front of the path, so every page URL built from them nests the host inside the campaign route: ${sample}${more}. `
1569
+ + `Use the rooted form shown after each arrow: edit a local CampaignSpec file to that value (intake never rewrites it); for a saved Map, re-run prepare-build (or start) with --map-id and without --cached-spec so the Map is fetched and normalised (the host is stripped and recorded as ${HOST_STRIPPED_CODE} on the assembly report), or correct the value in the Map.`,
1570
+ { routes: hits }
1571
+ );
1572
+ }
1573
+
1499
1574
  // Pages declared out of source scope (#238/#239: a manifest skip_reason entry
1500
1575
  // or CampaignSpec build_scope "partial") assemble from the template family and
1501
1576
  // are not built by a partial-scope build, so their absence from _site/ is the
@@ -1703,6 +1778,94 @@ function validateBuiltOutputPages(spec, packet, errors, warnings, ready, derived
1703
1778
  if (checked > 0) ready.push(`Built HTML structure and commerce refs checked in _site/${publicRouteSlug}/ for ${checked} page(s)`);
1704
1779
  }
1705
1780
 
1781
+ // A path from path.relative as the "/"-separated form findings name: on
1782
+ // Windows path.relative answers with backslashes, and a page id or file must
1783
+ // read the same whichever machine ran doctor. A no-op on POSIX.
1784
+ function posixPath(path) {
1785
+ return path.split(sep).join("/");
1786
+ }
1787
+
1788
+ // Built output under _site/<route>/ that is not a funnel page: the route's
1789
+ // 404.html, and anything under a directory whose name starts with "_" or "."
1790
+ // (build scratch, hidden directories). The route-root index.html is the
1791
+ // landing page and is kept.
1792
+ function isLiveRefBuildNoise(routePath) {
1793
+ const segments = routePath.split("/");
1794
+ if (segments.length === 1 && segments[0].toLowerCase() === "404.html") return true;
1795
+ return segments.slice(0, -1).some((segment) => segment.startsWith("_") || segment.startsWith("."));
1796
+ }
1797
+
1798
+ // Built page refs against the live campaign (#533). The CampaignSpec check
1799
+ // above cannot see a campaign that changed after the Map was saved, and skips
1800
+ // entirely when the spec lists no shipping methods; this one compares every
1801
+ // built page's rendered refs with what the live campaign serves, whatever the
1802
+ // spec lists. The read itself happens before doctor runs (it is async and
1803
+ // leaves the machine) and arrives as buildState.liveCampaign; without it the
1804
+ // check is recorded not_run with its reason, never passed. Page-level misses
1805
+ // block at any stage — a page pointing at a method the campaign no longer
1806
+ // serves charges the wrong price whether or not assembly is recorded.
1807
+ // Unlike the CampaignSpec check, whose scope stays the spec's pages, this one
1808
+ // covers every built .html page under _site/<route>/ except build noise that
1809
+ // never serves a funnel step: the route's 404.html and anything under a path
1810
+ // directory starting with "_" or "." (node_modules is never walked). A page the
1811
+ // Map does not list still ships, so it is compared too and named by its path.
1812
+ function validateBuiltLiveCampaignRefs(spec, packet, errors, warnings, ready, derived, buildState = {}) {
1813
+ const targetRepo = derived.target_repo;
1814
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1815
+ const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
1816
+ const pages = [];
1817
+ if (siteRoot && existsSync(siteRoot)) {
1818
+ const covered = new Set();
1819
+ for (const page of activeSpecPages(spec)) {
1820
+ const builtPath = builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived);
1821
+ if (!builtPath || !existsSync(builtPath)) continue;
1822
+ covered.add(resolve(builtPath));
1823
+ pages.push({
1824
+ page_id: page.id,
1825
+ file: posixPath(relFromDir(targetRepo, builtPath)),
1826
+ ...extractRenderedRefs(readFileSync(builtPath, "utf8")),
1827
+ });
1828
+ }
1829
+ for (const html of collectHtmlFiles(siteRoot)) {
1830
+ const builtPath = resolve(siteRoot, html.path);
1831
+ const routePath = posixPath(html.path);
1832
+ if (covered.has(builtPath) || isLiveRefBuildNoise(routePath)) continue;
1833
+ pages.push({
1834
+ page_id: routePath,
1835
+ file: posixPath(relFromDir(targetRepo, builtPath)),
1836
+ in_spec: false,
1837
+ ...extractRenderedRefs(readFileSync(builtPath, "utf8")),
1838
+ });
1839
+ }
1840
+ }
1841
+ if (pages.length === 0) {
1842
+ derived.live_campaign_refs = { status: "not_run", reason_code: "no_built_pages", reason: "No built page to compare against the live campaign.", checked_pages: 0 };
1843
+ return;
1844
+ }
1845
+ const result = evaluateLiveCampaignRefs({
1846
+ pages,
1847
+ map: { package_refs: specPackageRefs(spec), shipping_refs: specShippingRefs(spec) },
1848
+ live: buildState.liveCampaign,
1849
+ });
1850
+ derived.live_campaign_refs = {
1851
+ status: result.status,
1852
+ ...(result.reason_code ? { reason_code: result.reason_code, reason: result.reason } : {}),
1853
+ ...(buildState.liveCampaign?.key_source ? { key_source: buildState.liveCampaign.key_source } : {}),
1854
+ checked_pages: result.checked_pages,
1855
+ page_findings: result.page_findings,
1856
+ drift: result.drift,
1857
+ };
1858
+ if (result.status === "not_run") {
1859
+ if (result.attempted) addIssue(warnings, LIVE_REF_CODES.notRun, liveRefsNotRunMessage(result), { reason_code: result.reason_code, ...(result.http_status !== undefined ? { http_status: result.http_status } : {}) });
1860
+ return;
1861
+ }
1862
+ for (const finding of result.page_findings) {
1863
+ addIssue(errors, finding.code, liveRefFindingMessage(finding), { page_id: finding.page_id, file: finding.file, ...(finding.in_spec === false ? { in_spec: false } : {}), refs: finding.refs });
1864
+ }
1865
+ if (result.drift) addIssue(warnings, LIVE_REF_CODES.drift, campaignDriftMessage(result.drift), { drift: result.drift });
1866
+ if (result.status === "pass") ready.push(`Built page shipping and package refs are served by the live campaign (${result.checked_pages} page(s))`);
1867
+ }
1868
+
1706
1869
  // Upsell selector scope (#270). Every doctor invocation, deliberately — not
1707
1870
  // only the one that follows assembly. The real-world instance was introduced by
1708
1871
  // a LATER human review round that layered a correctly-scoped selector on top of
@@ -1733,15 +1896,21 @@ function validateUpsellSelectorScope(spec, packet, errors, warnings, ready, deri
1733
1896
  for (const builtPage of (scope.ok ? scope.pages : [])) {
1734
1897
  const declared = declaredByPath.get(resolve(builtPage.built_path)) || null;
1735
1898
  // Declared type wins only when it is the post-purchase answer; otherwise
1736
- // the route-inferred type stands. Same fail-closed rule the evaluator
1737
- // applies between a declared type and the page's own next-page-type meta:
1738
- // any signal saying "post-purchase" is enough.
1899
+ // the route-inferred type stands, unless the route is ambiguous (an
1900
+ // "oto" route, never an explicit upsell/downsell one) and the page's
1901
+ // own next-page-type meta declares it a checkout (#529). Same
1902
+ // fail-closed rule the evaluator applies between a declared type and the
1903
+ // page's own next-page-type meta: any declaration saying "post-purchase"
1904
+ // is enough.
1739
1905
  const declaredType = declared?.type || null;
1906
+ const content = readFileSync(builtPage.built_path, "utf8");
1740
1907
  pages.push({
1741
1908
  page_id: declared?.id || builtPage.page_id,
1742
- page_type: isPostPurchasePageType(declaredType) ? declaredType : builtPage.page_type,
1909
+ page_type: isPostPurchasePageType(declaredType)
1910
+ ? declaredType
1911
+ : builtPageTypeOverRouteGuess({ route: builtPage.route, route_type: builtPage.page_type, content }),
1743
1912
  file: relFromDir(targetRepo, builtPage.built_path),
1744
- content: readFileSync(builtPage.built_path, "utf8"),
1913
+ content,
1745
1914
  });
1746
1915
  }
1747
1916
  }
@@ -2137,7 +2306,7 @@ function validateBuiltHtmlStructure(content, builtPath, targetRepo, page, spec,
2137
2306
  // leg: where QA confirms a content param FIRES on a live page, this confirms the
2138
2307
  // built page even HAS a handler for it — catching the gap before QA runs.
2139
2308
  //
2140
- // Specifically the "?reviews=n with no handler" case from the Chamelo Shield
2309
+ // Specifically the "?reviews=n with no handler" case from a production
2141
2310
  // build: the spec (or a synthesized one) declares a content param, but the
2142
2311
  // built page never wired `data-next-hide="param.<name>=='n'"`, so the param
2143
2312
  // silently no-ops. Only fires when the spec declares `analytics.params.content`;
@@ -2168,7 +2337,7 @@ export function validateBuiltAnalyticsContract(content, builtPath, targetRepo, p
2168
2337
  addIssue(
2169
2338
  issueTarget,
2170
2339
  "analytics_contract.content_param_no_handler",
2171
- `Built page "${page.id}" declares analytics content param "?${name}" but has no data-next-hide/show="param.${name}…" handler. The param will silently no-op — the Chamelo Shield "?reviews=n with no handler" gap.`,
2340
+ `Built page "${page.id}" declares analytics content param "?${name}" but has no data-next-hide/show="param.${name}…" handler. The param will silently no-op — the "?reviews=n with no handler" gap.`,
2172
2341
  { page_id: page.id, file: relPath, param: name },
2173
2342
  );
2174
2343
  }
@@ -2426,30 +2595,6 @@ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, i
2426
2595
  }
2427
2596
  }
2428
2597
 
2429
- function extractRenderedPackageRefs(content) {
2430
- const refs = new Set();
2431
- for (const match of content.matchAll(/\bdata-next-package-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
2432
- for (const match of content.matchAll(/\bdata-package-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
2433
- for (const match of content.matchAll(/["']?packageId["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gi)) {
2434
- addRenderedRef(refs, match[1] || match[2] || match[3]);
2435
- }
2436
- return refs;
2437
- }
2438
-
2439
- function extractRenderedShippingRefs(content) {
2440
- const refs = new Set();
2441
- for (const match of content.matchAll(/\bdata-next-shipping-id=["']([^"']+)["']/gi)) addRenderedRef(refs, match[1]);
2442
- for (const match of content.matchAll(/["']?shippingId["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gi)) {
2443
- addRenderedRef(refs, match[1] || match[2] || match[3]);
2444
- }
2445
- return refs;
2446
- }
2447
-
2448
- function addRenderedRef(refs, value) {
2449
- const ref = String(value || "").trim();
2450
- if (/^[A-Za-z0-9_-]+$/.test(ref)) refs.add(ref);
2451
- }
2452
-
2453
2598
  function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
2454
2599
  if (!targetRepo || !publicRouteSlug) return null;
2455
2600
  const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
@@ -2921,25 +3066,43 @@ function summarizeScopePages(pages) {
2921
3066
  * HTML for this page" message so unknown types don't crash the operator's UX.
2922
3067
  *
2923
3068
  * @param {object} page Active spec page (may carry `design_source`).
3069
+ * @param {{ figmaGate?: boolean }} [options] figmaGate: some active page's design_source is
3070
+ * Figma, so the manifest must pass the Figma producer-provenance gate.
2924
3071
  * @returns {string}
2925
3072
  */
2926
- function coverageErrorMessage(page) {
3073
+ function coverageErrorMessage(page, { figmaGate = false } = {}) {
2927
3074
  const designSource = page && isObject(page.design_source) ? page.design_source : null;
2928
3075
  if (designSource) {
2929
3076
  const fileUrl = optionalString(designSource.file_url);
2930
3077
  if (designSource.type === "figma" && fileUrl) {
2931
- return `Active CampaignSpec page "${page.id}" has no source mapping. Design is in Figma at ${fileUrl}; supply the source-html manifest for the page (see docs/design-source-package.md) — the exporter that produced the design emits it — then rerun prepare-build.`;
3078
+ // A Figma page's manifest must pass the producer-provenance gate
3079
+ // (validateSourceProducerProvenance): the exporter's manifest does, and a
3080
+ // hand-written one needs a recorded named-human waiver.
3081
+ return `Active CampaignSpec page "${page.id}" has no source mapping. Design is in Figma at ${fileUrl}. The Figma provenance gate (source_html.producer_provenance) needs the exporter's handoff manifest (the exporter that produced the design emits it): re-run the figma-sections-export handoff so it writes <source-root>/.campaigns-os/source-html-manifest.json with this page mapped (see docs/design-source-package.md), then rerun prepare-build. If the approved source is hand-written HTML and the Figma file only renders it, write the manifest by hand and record a named-human waiver for the Figma provenance check: campaigns-os checkpoint waive --packet <packet> --gate ${SOURCE_PROVENANCE_SCOPE} --page ${page.id} --reason "<reason>" --waived-by "<named human>" --expires-at <ISO>.`;
2932
3082
  }
2933
3083
  if (designSource.type === "ai-generated") {
2934
3084
  const fileUrlHint = fileUrl ? ` (design reference: ${fileUrl})` : "";
2935
- return `Active CampaignSpec page "${page.id}" has no source mapping. design_source.type="ai-generated"${fileUrlHint} — re-run the producing agent so the source HTML and source-html manifest land in the source root, then rerun prepare-build. See docs/entry-points.md for the AI-generated entry point contract.`;
3085
+ 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.`;
2936
3086
  }
2937
3087
  if (!fileUrl) {
2938
3088
  return `Active CampaignSpec page "${page.id}" has no source mapping. design_source is set but file_url is missing — add file_url to the spec before requesting a build.`;
2939
3089
  }
2940
- return `Active CampaignSpec page "${page.id}" has no source mapping. design_source.type="${designSource.type}" at ${fileUrl}; produce the source HTML for this page (or update design_source.type to a recognized producer — see docs/entry-points.md) before rerunning prepare-build.`;
3090
+ 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)}`;
2941
3091
  }
2942
- return `Active CampaignSpec page "${page.id}" has no source mapping.`;
3092
+ return `Active CampaignSpec page "${page.id}" has no source mapping. ${sourceManifestHint(page, figmaGate)} Then rerun prepare-build.`;
3093
+ }
3094
+
3095
+ // Where a page without a Figma design_source is mapped. Hand-written HTML has
3096
+ // no exporter, so the hint gives the path, the schema and an entry to write by
3097
+ // hand, unless an active page's design_source is Figma (hasFigmaDesignSource):
3098
+ // then the whole manifest is held to the Figma provenance gate, which a
3099
+ // hand-written manifest cannot pass.
3100
+ function sourceManifestHint(page, figmaGate) {
3101
+ const location = "Map the page in the source-html manifest at <source-root>/.campaigns-os/source-html-manifest.json (schema: schemas/source-html-manifest.v0.schema.json; see docs/build-packet.md#source-html-manifest-auto-population).";
3102
+ if (figmaGate) {
3103
+ return `${location} The CampaignSpec has an active page whose design_source is Figma, so this manifest must also pass the Figma provenance gate (source_html.producer_provenance), which needs the exporter's handoff manifest.`;
3104
+ }
3105
+ return `${location} No exporter is required: for hand-written HTML, write the file yourself. A minimal one: {"schema_version": "source-html-manifest/v0", "pages": [{"page_id": "${page.id}", "path": "<file>.html"}]}; for standalone HTML documents kept whole, add "wrapper_policy": "preserve_document_wrappers".`;
2943
3106
  }
2944
3107
 
2945
3108
  /**
@@ -2992,6 +3155,8 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
2992
3155
  warnings,
2993
3156
  ready,
2994
3157
  manifestPath: recordedDesignManifestPath(packet, packetPath),
3158
+ waivers: buildState?.report?.waivers,
3159
+ derived,
2995
3160
  });
2996
3161
  const active = activeSpecPages(spec);
2997
3162
  const specPartialScope = spec?.build_scope?.mode === "partial";
@@ -3092,9 +3257,10 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3092
3257
  ready.push(`Template-stock page(s) materialised by the build stage: ${materialisedTemplateStock.map((entry) => `${entry.page_id} (${entry.family})`).join(", ")}`);
3093
3258
  }
3094
3259
 
3260
+ const figmaGate = active.some(hasFigmaDesignSource);
3095
3261
  for (const page of active) {
3096
3262
  if (!mappedIds.has(page.id)) {
3097
- addIssue(errors, "source_html.pages.coverage", coverageErrorMessage(page), coverageErrorDetail(page));
3263
+ addIssue(errors, "source_html.pages.coverage", coverageErrorMessage(page, { figmaGate }), coverageErrorDetail(page));
3098
3264
  }
3099
3265
  }
3100
3266
 
@@ -3197,7 +3363,7 @@ function recordedDesignManifestPath(packet, packetPath) {
3197
3363
  return recorded ? resolve(dirname(packagePath), recorded) : null;
3198
3364
  }
3199
3365
 
3200
- function validateSourceHtmlManifestAtRoot(sourceRoot, { spec, errors, warnings, ready, manifestPath = null } = {}) {
3366
+ function validateSourceHtmlManifestAtRoot(sourceRoot, { spec, errors, warnings, ready, manifestPath = null, waivers = null, derived = null } = {}) {
3201
3367
  if (!isNonEmptyString(sourceRoot) || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) return;
3202
3368
  const result = readSourceHtmlManifestFile(sourceRoot, { manifestPath });
3203
3369
  if (!result.path) return;
@@ -3213,49 +3379,123 @@ function validateSourceHtmlManifestAtRoot(sourceRoot, { spec, errors, warnings,
3213
3379
  for (const warning of result.warnings || []) {
3214
3380
  addIssue(warnings, "source_html.manifest", warning);
3215
3381
  }
3216
- validateSourceProducerProvenance(result.manifest, { spec, errors, warnings, ready });
3382
+ validateSourceProducerProvenance(result.manifest, { spec, errors, warnings, ready, waivers, derived });
3217
3383
  ready.push(`Source-html manifest ${SOURCE_HTML_MANIFEST_SCHEMA} validated`);
3218
3384
  }
3219
3385
 
3220
- function validateSourceProducerProvenance(manifest, { spec, errors, warnings, ready }) {
3386
+ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, ready, waivers = null, derived = null }) {
3221
3387
  const generator = optionalString(manifest?.generator) || "";
3222
3388
  const rawProvenance = manifest?.producer_provenance;
3223
3389
  const provenance = isObject(rawProvenance) ? rawProvenance : {};
3224
- const expectsFigma = generator.startsWith("figma-sections-export@") || activeSpecPages(spec).some(hasFigmaDesignSource);
3390
+ const generatorClaimsFigma = generatorClaimsFigmaExport(generator);
3391
+ const figmaPages = activeSpecPages(spec).filter(hasFigmaDesignSource);
3392
+ const expectsFigma = generatorClaimsFigma || figmaPages.length > 0;
3393
+ const findings = expectsFigma ? collectFigmaProvenanceFindings(manifest, provenance, rawProvenance) : [];
3394
+ const provenanceBlockers = findings.filter((finding) => finding.severity === "provenance");
3395
+
3396
+ // One source-provenance checkpoint per Figma-typed page (#534).
3397
+ const { gates, inert } = evaluateSourceProvenanceGates({
3398
+ pages: figmaPages.map((page) => ({
3399
+ page_id: page.id,
3400
+ design_source: {
3401
+ type: optionalString(page.design_source?.type) || null,
3402
+ file_url: optionalString(page.design_source?.file_url) || null,
3403
+ },
3404
+ })),
3405
+ blockingCodes: provenanceBlockers.map((finding) => finding.code),
3406
+ generatorClaimsExport: generatorClaimsFigma,
3407
+ waivers,
3408
+ });
3409
+ if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(...gates);
3410
+ const inertTotal = Object.values(inert.counts).reduce((sum, count) => sum + count, 0);
3411
+ if (inertTotal > 0) {
3412
+ addIssue(
3413
+ warnings,
3414
+ `${SOURCE_PROVENANCE_SCOPE}.waiver_inert`,
3415
+ `Source-provenance waiver history contains ${inertTotal} inert record(s); stale, foreign, malformed, and expired decisions, decisions for pages that no longer have a Figma design source${inert.pages.length ? ` (${inert.pages.join(", ")})` : ""}, and decisions under a manifest whose generator claims figma-sections-export never satisfy the current checkpoint.`,
3416
+ { counts: inert.counts, pages: inert.pages },
3417
+ );
3418
+ }
3225
3419
  if (!expectsFigma) return;
3226
3420
 
3421
+ // The waivable family (source_html.producer_provenance* and the export's
3422
+ // file inventory) is reported once per Figma-typed page that demands it:
3423
+ // as errors naming an unwaived page, and as warnings carrying `waived: true`
3424
+ // naming a waived page. When the manifest's own generator claims
3425
+ // figma-sections-export, the manifest demands them itself, so they are
3426
+ // reported once, manifest-wide, as errors no page waiver can clear. Every
3427
+ // other finding keeps its severity.
3428
+ const waived = gates.filter((gate) => gate.status === "waived");
3429
+ for (const finding of findings) {
3430
+ if (finding.severity === "warning") {
3431
+ addIssue(warnings, finding.code, finding.message);
3432
+ } else if (generatorClaimsFigma) {
3433
+ addIssue(errors, finding.code, finding.message, { generator, pages: figmaPages.map((page) => page.id) });
3434
+ } else {
3435
+ for (const gate of gates) {
3436
+ const pageId = gate.subject.page_id;
3437
+ if (gate.status === "waived") {
3438
+ addIssue(
3439
+ warnings,
3440
+ finding.code,
3441
+ `Page "${pageId}": ${finding.message} Waived by ${gate.waiver.waived_by}: the approved source is hand-written HTML.`,
3442
+ { waived: true, page_id: pageId, waiver: gate.waiver },
3443
+ );
3444
+ } else {
3445
+ addIssue(errors, finding.code, `Page "${pageId}" has a Figma design source: ${finding.message}`, { page_id: pageId });
3446
+ }
3447
+ }
3448
+ }
3449
+ }
3450
+
3451
+ if (!generatorClaimsFigma && waived.length > 0) {
3452
+ const waivedPageIds = waived.map((gate) => gate.subject.page_id);
3453
+ const waivedBy = [...new Set(waived.map((gate) => gate.waiver.waived_by))].join(", ");
3454
+ ready.push(`Figma producer provenance accepted under named-human exception for page(s) ${waivedPageIds.join(", ")} (${waivedBy}).`);
3455
+ }
3456
+ if (errors.every((issue) => !isSourceProvenanceCode(issue.code) && !FIGMA_EXPORT_FILE_CODES.includes(issue.code)) && waived.length === 0) {
3457
+ ready.push("Figma producer provenance gate passed: semantic_figma_export with package fingerprint");
3458
+ }
3459
+ }
3460
+
3461
+ // Returns every Figma-provenance finding in emission order, each tagged with
3462
+ // its severity: "provenance" (the waivable source_html.producer_provenance*
3463
+ // and source_html.files.partial/asset blockers) or "warning".
3464
+ function collectFigmaProvenanceFindings(manifest, provenance, rawProvenance) {
3465
+ const findings = [];
3466
+ const add = (severity, code, message) => findings.push({ severity, code, message });
3227
3467
  if (!rawProvenance) {
3228
- addIssue(
3229
- errors,
3468
+ add(
3469
+ "provenance",
3230
3470
  "source_html.producer_provenance",
3231
3471
  "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
3232
3472
  );
3233
3473
  }
3234
3474
 
3235
3475
  if (provenance.source_type !== "semantic_figma_export") {
3236
- addIssue(
3237
- errors,
3476
+ add(
3477
+ "provenance",
3238
3478
  "source_html.producer_provenance.source_type",
3239
3479
  `Figma source manifest source_type is "${provenance.source_type || "missing"}"; expected "semantic_figma_export". Screenshot or hand-authored fallback output cannot satisfy the Figma provenance gate.`
3240
3480
  );
3241
3481
  }
3242
3482
  if (provenance.screenshot_fallback_used !== false) {
3243
- addIssue(
3244
- errors,
3483
+ add(
3484
+ "provenance",
3245
3485
  "source_html.producer_provenance.screenshot_fallback_used",
3246
3486
  "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
3247
3487
  );
3248
3488
  }
3249
3489
  if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
3250
- addIssue(
3251
- errors,
3490
+ add(
3491
+ "provenance",
3252
3492
  "source_html.producer_provenance.semantic_section_count",
3253
3493
  "Figma source manifest must report semantic_section_count > 0."
3254
3494
  );
3255
3495
  }
3256
3496
  if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
3257
- addIssue(
3258
- errors,
3497
+ add(
3498
+ "provenance",
3259
3499
  "source_html.producer_provenance.material_fingerprint",
3260
3500
  "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
3261
3501
  );
@@ -3263,15 +3503,15 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
3263
3503
 
3264
3504
  const files = Array.isArray(manifest?.files) ? manifest.files : [];
3265
3505
  if (!files.some((entry) => entry.role === "partial")) {
3266
- addIssue(errors, "source_html.files.partial", "Figma source manifest files[] must include section partials.");
3506
+ add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
3267
3507
  }
3268
3508
  if (!files.some((entry) => entry.role === "asset")) {
3269
- addIssue(errors, "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
3509
+ add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
3270
3510
  }
3271
3511
 
3272
3512
  const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
3273
3513
  if (!sectionExports.length) {
3274
- addIssue(errors, "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
3514
+ add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
3275
3515
  } else {
3276
3516
  const withoutNodeIds = sectionExports
3277
3517
  .filter((entry) => {
@@ -3280,17 +3520,15 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
3280
3520
  })
3281
3521
  .map((entry) => entry?.section || "unknown");
3282
3522
  if (withoutNodeIds.length) {
3283
- addIssue(
3284
- warnings,
3523
+ add(
3524
+ "warning",
3285
3525
  "source_html.producer_provenance.section_exports.node_ids",
3286
3526
  `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
3287
3527
  );
3288
3528
  }
3289
3529
  }
3290
3530
 
3291
- if (errors.every((issue) => !String(issue.code || "").startsWith("source_html.producer_provenance") && !["source_html.files.partial", "source_html.files.asset"].includes(issue.code))) {
3292
- ready.push("Figma producer provenance gate passed: semantic_figma_export with package fingerprint");
3293
- }
3531
+ return findings;
3294
3532
  }
3295
3533
 
3296
3534
  function hasFigmaDesignSource(page) {