@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.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +648 -5103
- package/README.md +32 -11
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1176 -113
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2345 -6087
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +158 -21
- package/docs/campaigns-os-build-flow.md +3 -3
- package/docs/design-source-package.md +73 -0
- package/docs/effects.md +50 -8
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/qa-and-test-orders.md +99 -13
- package/docs/release-ledger-authoring-guide.md +64 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/built-site-scope.mjs +16 -4
- package/src/cli.mjs +280 -46
- package/src/commercial-parity.mjs +48 -2
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +5 -2
- package/src/doctor/checks.mjs +320 -81
- package/src/doctor/inspect.mjs +55 -13
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/invocation.mjs +4 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/progress-node.mjs +3 -1
- package/src/qa-browser.mjs +538 -28
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +122 -7
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +116 -0
- package/src/stage-record.mjs +551 -0
- package/src/upsell-selector-scope.mjs +112 -2
package/src/doctor/checks.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3229
|
-
|
|
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
|
-
|
|
3237
|
-
|
|
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
|
-
|
|
3244
|
-
|
|
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
|
-
|
|
3251
|
-
|
|
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
|
-
|
|
3258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3284
|
-
|
|
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
|
-
|
|
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) {
|