@nextcommerce/campaigns-os 1.43.2 → 1.46.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 (72) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +648 -5103
  3. package/README.md +32 -11
  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/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  14. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  15. package/contracts/effects.v1.json +1176 -113
  16. package/contracts/orientation-reason-codes.v1.json +7 -0
  17. package/contracts/release-ledger.json +2345 -6087
  18. package/contracts/supported-surface.json +7 -4
  19. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  20. package/docs/brand-theme-bridge.md +81 -0
  21. package/docs/build-packet.md +158 -21
  22. package/docs/campaigns-os-build-flow.md +3 -3
  23. package/docs/design-source-package.md +73 -0
  24. package/docs/effects.md +50 -8
  25. package/docs/gateway-login.md +3 -0
  26. package/docs/local-setup.md +1 -1
  27. package/docs/orientation-contract-reference.md +42 -2
  28. package/docs/polish-evidence.md +74 -0
  29. package/docs/qa-and-test-orders.md +99 -13
  30. package/docs/release-ledger-authoring-guide.md +64 -4
  31. package/docs/runtime-readiness.md +1 -1
  32. package/docs/sdk-storage-compatibility.md +1 -1
  33. package/docs/skills-revision.md +10 -10
  34. package/docs/supported-surface.md +2 -2
  35. package/docs/versioning.md +4 -1
  36. package/package.json +1 -1
  37. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  38. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  39. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  40. package/skills/campaign-readback-classification/SKILL.md +3 -3
  41. package/skills/campaign-run-evidence/SKILL.md +7 -6
  42. package/skills/contribution-intake/SKILL.md +3 -3
  43. package/skills/next-campaigns-build/SKILL.md +7 -6
  44. package/skills/next-campaigns-os/SKILL.md +7 -7
  45. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  46. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  47. package/skills/next-campaigns-polish/SKILL.md +28 -9
  48. package/skills/next-campaigns-qa/SKILL.md +7 -4
  49. package/skills.json +10 -10
  50. package/src/brand-theme.mjs +320 -20
  51. package/src/built-site-scope.mjs +16 -4
  52. package/src/cli.mjs +280 -46
  53. package/src/commercial-parity.mjs +48 -2
  54. package/src/deviation.mjs +13 -1
  55. package/src/diagnostic.mjs +5 -2
  56. package/src/doctor/checks.mjs +320 -81
  57. package/src/doctor/inspect.mjs +55 -13
  58. package/src/doctor/source-provenance.mjs +184 -0
  59. package/src/invocation.mjs +4 -0
  60. package/src/live-campaign-refs.mjs +466 -0
  61. package/src/login.mjs +2 -2
  62. package/src/page-kit-store-profile.mjs +69 -12
  63. package/src/page-kit-sync.mjs +31 -12
  64. package/src/progress-node.mjs +3 -1
  65. package/src/qa-browser.mjs +538 -28
  66. package/src/qa-commercial-parity.mjs +48 -5
  67. package/src/qa-node.mjs +122 -7
  68. package/src/qa-test-order-topology.mjs +148 -0
  69. package/src/sdk-markup.mjs +72 -8
  70. package/src/source-html-intake.mjs +116 -0
  71. package/src/stage-record.mjs +551 -0
  72. 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
  }
@@ -1872,6 +2041,7 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
1872
2041
  }
1873
2042
  return {
1874
2043
  page_id: page.page_id,
2044
+ page_type: page.page_type,
1875
2045
  route: page.route,
1876
2046
  file: relFromDir(targetRepo, page.built_path),
1877
2047
  content,
@@ -2137,7 +2307,7 @@ function validateBuiltHtmlStructure(content, builtPath, targetRepo, page, spec,
2137
2307
  // leg: where QA confirms a content param FIRES on a live page, this confirms the
2138
2308
  // built page even HAS a handler for it — catching the gap before QA runs.
2139
2309
  //
2140
- // Specifically the "?reviews=n with no handler" case from the Chamelo Shield
2310
+ // Specifically the "?reviews=n with no handler" case from a production
2141
2311
  // build: the spec (or a synthesized one) declares a content param, but the
2142
2312
  // built page never wired `data-next-hide="param.<name>=='n'"`, so the param
2143
2313
  // silently no-ops. Only fires when the spec declares `analytics.params.content`;
@@ -2168,7 +2338,7 @@ export function validateBuiltAnalyticsContract(content, builtPath, targetRepo, p
2168
2338
  addIssue(
2169
2339
  issueTarget,
2170
2340
  "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.`,
2341
+ `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
2342
  { page_id: page.id, file: relPath, param: name },
2173
2343
  );
2174
2344
  }
@@ -2426,30 +2596,6 @@ function validateBuiltCommerceRefs(content, builtPath, targetRepo, page, spec, i
2426
2596
  }
2427
2597
  }
2428
2598
 
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
2599
  function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
2454
2600
  if (!targetRepo || !publicRouteSlug) return null;
2455
2601
  const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
@@ -2921,25 +3067,43 @@ function summarizeScopePages(pages) {
2921
3067
  * HTML for this page" message so unknown types don't crash the operator's UX.
2922
3068
  *
2923
3069
  * @param {object} page Active spec page (may carry `design_source`).
3070
+ * @param {{ figmaGate?: boolean }} [options] figmaGate: some active page's design_source is
3071
+ * Figma, so the manifest must pass the Figma producer-provenance gate.
2924
3072
  * @returns {string}
2925
3073
  */
2926
- function coverageErrorMessage(page) {
3074
+ function coverageErrorMessage(page, { figmaGate = false } = {}) {
2927
3075
  const designSource = page && isObject(page.design_source) ? page.design_source : null;
2928
3076
  if (designSource) {
2929
3077
  const fileUrl = optionalString(designSource.file_url);
2930
3078
  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.`;
3079
+ // A Figma page's manifest must pass the producer-provenance gate
3080
+ // (validateSourceProducerProvenance): the exporter's manifest does, and a
3081
+ // hand-written one needs a recorded named-human waiver.
3082
+ 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
3083
  }
2933
3084
  if (designSource.type === "ai-generated") {
2934
3085
  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.`;
3086
+ 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
3087
  }
2937
3088
  if (!fileUrl) {
2938
3089
  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
3090
  }
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.`;
3091
+ 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
3092
  }
2942
- return `Active CampaignSpec page "${page.id}" has no source mapping.`;
3093
+ return `Active CampaignSpec page "${page.id}" has no source mapping. ${sourceManifestHint(page, figmaGate)} Then rerun prepare-build.`;
3094
+ }
3095
+
3096
+ // Where a page without a Figma design_source is mapped. Hand-written HTML has
3097
+ // no exporter, so the hint gives the path, the schema and an entry to write by
3098
+ // hand, unless an active page's design_source is Figma (hasFigmaDesignSource):
3099
+ // then the whole manifest is held to the Figma provenance gate, which a
3100
+ // hand-written manifest cannot pass.
3101
+ function sourceManifestHint(page, figmaGate) {
3102
+ 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).";
3103
+ if (figmaGate) {
3104
+ 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.`;
3105
+ }
3106
+ 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
3107
  }
2944
3108
 
2945
3109
  /**
@@ -2992,6 +3156,8 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
2992
3156
  warnings,
2993
3157
  ready,
2994
3158
  manifestPath: recordedDesignManifestPath(packet, packetPath),
3159
+ waivers: buildState?.report?.waivers,
3160
+ derived,
2995
3161
  });
2996
3162
  const active = activeSpecPages(spec);
2997
3163
  const specPartialScope = spec?.build_scope?.mode === "partial";
@@ -3092,9 +3258,10 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3092
3258
  ready.push(`Template-stock page(s) materialised by the build stage: ${materialisedTemplateStock.map((entry) => `${entry.page_id} (${entry.family})`).join(", ")}`);
3093
3259
  }
3094
3260
 
3261
+ const figmaGate = active.some(hasFigmaDesignSource);
3095
3262
  for (const page of active) {
3096
3263
  if (!mappedIds.has(page.id)) {
3097
- addIssue(errors, "source_html.pages.coverage", coverageErrorMessage(page), coverageErrorDetail(page));
3264
+ addIssue(errors, "source_html.pages.coverage", coverageErrorMessage(page, { figmaGate }), coverageErrorDetail(page));
3098
3265
  }
3099
3266
  }
3100
3267
 
@@ -3197,7 +3364,7 @@ function recordedDesignManifestPath(packet, packetPath) {
3197
3364
  return recorded ? resolve(dirname(packagePath), recorded) : null;
3198
3365
  }
3199
3366
 
3200
- function validateSourceHtmlManifestAtRoot(sourceRoot, { spec, errors, warnings, ready, manifestPath = null } = {}) {
3367
+ function validateSourceHtmlManifestAtRoot(sourceRoot, { spec, errors, warnings, ready, manifestPath = null, waivers = null, derived = null } = {}) {
3201
3368
  if (!isNonEmptyString(sourceRoot) || !existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) return;
3202
3369
  const result = readSourceHtmlManifestFile(sourceRoot, { manifestPath });
3203
3370
  if (!result.path) return;
@@ -3213,49 +3380,123 @@ function validateSourceHtmlManifestAtRoot(sourceRoot, { spec, errors, warnings,
3213
3380
  for (const warning of result.warnings || []) {
3214
3381
  addIssue(warnings, "source_html.manifest", warning);
3215
3382
  }
3216
- validateSourceProducerProvenance(result.manifest, { spec, errors, warnings, ready });
3383
+ validateSourceProducerProvenance(result.manifest, { spec, errors, warnings, ready, waivers, derived });
3217
3384
  ready.push(`Source-html manifest ${SOURCE_HTML_MANIFEST_SCHEMA} validated`);
3218
3385
  }
3219
3386
 
3220
- function validateSourceProducerProvenance(manifest, { spec, errors, warnings, ready }) {
3387
+ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, ready, waivers = null, derived = null }) {
3221
3388
  const generator = optionalString(manifest?.generator) || "";
3222
3389
  const rawProvenance = manifest?.producer_provenance;
3223
3390
  const provenance = isObject(rawProvenance) ? rawProvenance : {};
3224
- const expectsFigma = generator.startsWith("figma-sections-export@") || activeSpecPages(spec).some(hasFigmaDesignSource);
3391
+ const generatorClaimsFigma = generatorClaimsFigmaExport(generator);
3392
+ const figmaPages = activeSpecPages(spec).filter(hasFigmaDesignSource);
3393
+ const expectsFigma = generatorClaimsFigma || figmaPages.length > 0;
3394
+ const findings = expectsFigma ? collectFigmaProvenanceFindings(manifest, provenance, rawProvenance) : [];
3395
+ const provenanceBlockers = findings.filter((finding) => finding.severity === "provenance");
3396
+
3397
+ // One source-provenance checkpoint per Figma-typed page (#534).
3398
+ const { gates, inert } = evaluateSourceProvenanceGates({
3399
+ pages: figmaPages.map((page) => ({
3400
+ page_id: page.id,
3401
+ design_source: {
3402
+ type: optionalString(page.design_source?.type) || null,
3403
+ file_url: optionalString(page.design_source?.file_url) || null,
3404
+ },
3405
+ })),
3406
+ blockingCodes: provenanceBlockers.map((finding) => finding.code),
3407
+ generatorClaimsExport: generatorClaimsFigma,
3408
+ waivers,
3409
+ });
3410
+ if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(...gates);
3411
+ const inertTotal = Object.values(inert.counts).reduce((sum, count) => sum + count, 0);
3412
+ if (inertTotal > 0) {
3413
+ addIssue(
3414
+ warnings,
3415
+ `${SOURCE_PROVENANCE_SCOPE}.waiver_inert`,
3416
+ `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.`,
3417
+ { counts: inert.counts, pages: inert.pages },
3418
+ );
3419
+ }
3225
3420
  if (!expectsFigma) return;
3226
3421
 
3422
+ // The waivable family (source_html.producer_provenance* and the export's
3423
+ // file inventory) is reported once per Figma-typed page that demands it:
3424
+ // as errors naming an unwaived page, and as warnings carrying `waived: true`
3425
+ // naming a waived page. When the manifest's own generator claims
3426
+ // figma-sections-export, the manifest demands them itself, so they are
3427
+ // reported once, manifest-wide, as errors no page waiver can clear. Every
3428
+ // other finding keeps its severity.
3429
+ const waived = gates.filter((gate) => gate.status === "waived");
3430
+ for (const finding of findings) {
3431
+ if (finding.severity === "warning") {
3432
+ addIssue(warnings, finding.code, finding.message);
3433
+ } else if (generatorClaimsFigma) {
3434
+ addIssue(errors, finding.code, finding.message, { generator, pages: figmaPages.map((page) => page.id) });
3435
+ } else {
3436
+ for (const gate of gates) {
3437
+ const pageId = gate.subject.page_id;
3438
+ if (gate.status === "waived") {
3439
+ addIssue(
3440
+ warnings,
3441
+ finding.code,
3442
+ `Page "${pageId}": ${finding.message} Waived by ${gate.waiver.waived_by}: the approved source is hand-written HTML.`,
3443
+ { waived: true, page_id: pageId, waiver: gate.waiver },
3444
+ );
3445
+ } else {
3446
+ addIssue(errors, finding.code, `Page "${pageId}" has a Figma design source: ${finding.message}`, { page_id: pageId });
3447
+ }
3448
+ }
3449
+ }
3450
+ }
3451
+
3452
+ if (!generatorClaimsFigma && waived.length > 0) {
3453
+ const waivedPageIds = waived.map((gate) => gate.subject.page_id);
3454
+ const waivedBy = [...new Set(waived.map((gate) => gate.waiver.waived_by))].join(", ");
3455
+ ready.push(`Figma producer provenance accepted under named-human exception for page(s) ${waivedPageIds.join(", ")} (${waivedBy}).`);
3456
+ }
3457
+ if (errors.every((issue) => !isSourceProvenanceCode(issue.code) && !FIGMA_EXPORT_FILE_CODES.includes(issue.code)) && waived.length === 0) {
3458
+ ready.push("Figma producer provenance gate passed: semantic_figma_export with package fingerprint");
3459
+ }
3460
+ }
3461
+
3462
+ // Returns every Figma-provenance finding in emission order, each tagged with
3463
+ // its severity: "provenance" (the waivable source_html.producer_provenance*
3464
+ // and source_html.files.partial/asset blockers) or "warning".
3465
+ function collectFigmaProvenanceFindings(manifest, provenance, rawProvenance) {
3466
+ const findings = [];
3467
+ const add = (severity, code, message) => findings.push({ severity, code, message });
3227
3468
  if (!rawProvenance) {
3228
- addIssue(
3229
- errors,
3469
+ add(
3470
+ "provenance",
3230
3471
  "source_html.producer_provenance",
3231
3472
  "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
3232
3473
  );
3233
3474
  }
3234
3475
 
3235
3476
  if (provenance.source_type !== "semantic_figma_export") {
3236
- addIssue(
3237
- errors,
3477
+ add(
3478
+ "provenance",
3238
3479
  "source_html.producer_provenance.source_type",
3239
3480
  `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
3481
  );
3241
3482
  }
3242
3483
  if (provenance.screenshot_fallback_used !== false) {
3243
- addIssue(
3244
- errors,
3484
+ add(
3485
+ "provenance",
3245
3486
  "source_html.producer_provenance.screenshot_fallback_used",
3246
3487
  "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
3247
3488
  );
3248
3489
  }
3249
3490
  if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
3250
- addIssue(
3251
- errors,
3491
+ add(
3492
+ "provenance",
3252
3493
  "source_html.producer_provenance.semantic_section_count",
3253
3494
  "Figma source manifest must report semantic_section_count > 0."
3254
3495
  );
3255
3496
  }
3256
3497
  if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
3257
- addIssue(
3258
- errors,
3498
+ add(
3499
+ "provenance",
3259
3500
  "source_html.producer_provenance.material_fingerprint",
3260
3501
  "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
3261
3502
  );
@@ -3263,15 +3504,15 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
3263
3504
 
3264
3505
  const files = Array.isArray(manifest?.files) ? manifest.files : [];
3265
3506
  if (!files.some((entry) => entry.role === "partial")) {
3266
- addIssue(errors, "source_html.files.partial", "Figma source manifest files[] must include section partials.");
3507
+ add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
3267
3508
  }
3268
3509
  if (!files.some((entry) => entry.role === "asset")) {
3269
- addIssue(errors, "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
3510
+ add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
3270
3511
  }
3271
3512
 
3272
3513
  const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
3273
3514
  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.");
3515
+ add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
3275
3516
  } else {
3276
3517
  const withoutNodeIds = sectionExports
3277
3518
  .filter((entry) => {
@@ -3280,17 +3521,15 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
3280
3521
  })
3281
3522
  .map((entry) => entry?.section || "unknown");
3282
3523
  if (withoutNodeIds.length) {
3283
- addIssue(
3284
- warnings,
3524
+ add(
3525
+ "warning",
3285
3526
  "source_html.producer_provenance.section_exports.node_ids",
3286
3527
  `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
3287
3528
  );
3288
3529
  }
3289
3530
  }
3290
3531
 
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
- }
3532
+ return findings;
3294
3533
  }
3295
3534
 
3296
3535
  function hasFigmaDesignSource(page) {