@nextcommerce/campaigns-os 1.41.2 → 1.43.2

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 (83) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +629 -0
  3. package/README.md +8 -6
  4. package/agents/claude/CLAUDE.md +5 -1
  5. package/campaign-spec/dist/types.d.ts +2 -0
  6. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  7. package/contracts/effects.v1.json +118 -25
  8. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  9. package/contracts/release-ledger.json +1424 -0
  10. package/contracts/supported-surface.json +12 -11
  11. package/docs/build-packet.md +120 -8
  12. package/docs/campaigns-os-build-flow.md +3 -2
  13. package/docs/design-source-package.md +89 -15
  14. package/docs/effects.md +83 -2
  15. package/docs/local-setup.md +51 -0
  16. package/docs/migration-sidecar-bundle.md +6 -1
  17. package/docs/orientation-contract-reference.md +1 -1
  18. package/docs/progress-snapshots.md +16 -6
  19. package/docs/qa-and-test-orders.md +157 -17
  20. package/docs/release-ledger-authoring-guide.md +6 -4
  21. package/docs/runtime-readiness.md +1 -1
  22. package/docs/skills-revision.md +10 -10
  23. package/package.json +3 -2
  24. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  25. package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
  26. package/schemas/campaign-spec.v4.schema.json +4 -0
  27. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  28. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  29. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  30. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +8 -6
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +17 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +3 -3
  40. package/skills/next-campaigns-qa/SKILL.md +10 -9
  41. package/skills.json +11 -11
  42. package/src/build-brief.mjs +6 -4
  43. package/src/built-script-syntax.mjs +480 -0
  44. package/src/campaigns-api-key.mjs +99 -0
  45. package/src/cli-helpers.mjs +118 -0
  46. package/src/cli.mjs +796 -6963
  47. package/src/design-source-package.mjs +1 -1
  48. package/src/design-source-publication.mjs +898 -0
  49. package/src/diagnostic.mjs +2 -1
  50. package/src/directory-lock.mjs +270 -0
  51. package/src/doctor/checks.mjs +4415 -0
  52. package/src/doctor/inspect.mjs +636 -0
  53. package/src/doctor/next-step.mjs +731 -0
  54. package/src/finding-cause.mjs +14 -10
  55. package/src/install-invocation.mjs +29 -0
  56. package/src/invocation.mjs +179 -0
  57. package/src/lifecycle.mjs +5 -4
  58. package/src/polish-node.mjs +5 -2
  59. package/src/private-template-source.mjs +1 -1
  60. package/src/progress-node.mjs +9 -37
  61. package/src/progress.mjs +5 -3
  62. package/src/proof-policy.mjs +1 -1
  63. package/src/qa-analytics-correctness.mjs +3 -0
  64. package/src/qa-binding-evidence.mjs +76 -11
  65. package/src/qa-browser.mjs +778 -77
  66. package/src/qa-build-scope.mjs +47 -0
  67. package/src/qa-node.mjs +276 -39
  68. package/src/qa-publish.mjs +4 -0
  69. package/src/qa-sidecar.mjs +2 -0
  70. package/src/qa-verdict-discovery.mjs +11 -0
  71. package/src/qa-verdict-publish.mjs +1 -0
  72. package/src/qa-verdict.mjs +8 -1
  73. package/src/readback.mjs +2 -1
  74. package/src/run-record-closeout.mjs +3 -4
  75. package/src/run-record.mjs +4 -0
  76. package/src/sidecar-bundle.mjs +21 -0
  77. package/src/source-html-intake.mjs +1 -1
  78. package/src/source-html-manifest.mjs +9 -2
  79. package/src/spec-source-identity.mjs +44 -0
  80. package/src/stage-ledger.mjs +32 -1
  81. package/src/target-lock.mjs +54 -0
  82. package/src/template-brand-contract.mjs +17 -1
  83. package/src/tooling-setup.mjs +160 -0
@@ -0,0 +1,47 @@
1
+ import { resolveBuiltSiteScope } from "./built-site-scope.mjs";
2
+ import { runtimeRelativeRouteForSpecValue } from "./route-identity.mjs";
3
+
4
+ // Use the recorded declaration, not a doctor's cached derived scope: failed
5
+ // intake also writes skip_reason mappings, which must never hide missing pages.
6
+ export function applyQaBuildScope(topologies, { packet, report, targetRepo, publicRouteSlug } = {}) {
7
+ const declared = report?.stages?.prepare_build?.declared_out_of_scope;
8
+ if (!Array.isArray(declared) || !packet) return { topologies, excludedPages: [] };
9
+ const declaredIds = new Set(declared.map(page => page?.page_id));
10
+ const skippedIds = new Set((packet.source_html?.pages || [])
11
+ .filter(page => declaredIds.has(page.page_id) && !page.path && typeof page.skip_reason === "string" && page.skip_reason.trim())
12
+ .map(page => page.page_id));
13
+ if (!skippedIds.size) return { topologies, excludedPages: [] };
14
+ const built = targetRepo ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
15
+ const normalizeRoute = route => runtimeRelativeRouteForSpecValue(route, publicRouteSlug).replace(/^\/+|\/+$/g, "").replace(/(?:^|\/)index\.html$/, "").replace(/\/$/, "");
16
+ const builtRoutes = new Set((built?.pages || []).map(page => normalizeRoute(page.route)));
17
+ const excludedPages = [];
18
+ const scoped = topologies.map(topology => ({
19
+ ...topology,
20
+ partial_build_scope: topology.pages.some(page => skippedIds.has(page.page_id)),
21
+ pages: topology.pages.filter(page => {
22
+ if (!skippedIds.has(page.page_id)) return true;
23
+ // Without a resolved URL we cannot prove which output file represents
24
+ // this page. Keep it in QA so unresolved/materialized pages are not
25
+ // silently hidden as unbuilt declarations.
26
+ if (!page.url) return true;
27
+ // An explicitly materialized stock page rejoins QA. A declaration alone
28
+ // is not an instruction to build it, nor proof that it exists.
29
+ let route;
30
+ try { route = new URL(page.url).pathname; } catch { return true; }
31
+ if (builtRoutes.has(normalizeRoute(route))) return true;
32
+ excludedPages.push(page);
33
+ return false;
34
+ }),
35
+ }));
36
+ return { topologies: scoped, excludedPages };
37
+ }
38
+
39
+ export function specForQaScope(spec, excludedPages = []) {
40
+ const excluded = new Set(excludedPages.map(page => page.page_id));
41
+ if (Array.isArray(spec.funnel_pages) && !spec.funnels?.length) {
42
+ return { ...spec, funnel_pages: spec.funnel_pages.filter(page => !excluded.has(page.id)) };
43
+ }
44
+ return { ...spec, funnels: (spec.funnels || []).map(funnel => ({
45
+ ...funnel, pages: (funnel.pages || []).filter(page => !excluded.has(page.id)),
46
+ })) };
47
+ }
package/src/qa-node.mjs CHANGED
@@ -1,5 +1,7 @@
1
- import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion } from './qa-binding-evidence.mjs';
1
+ import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
2
+ import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
2
3
  import { shellToken } from "./shell-token.mjs";
4
+ import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
3
5
  import { requiredActionText } from "./gate-actions.mjs";
4
6
  import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
5
7
  import {
@@ -13,7 +15,7 @@ import {
13
15
  import { singleLineFragment } from "./text-safety.mjs";
14
16
  import { absentOrMalformed } from "./fs-identity.mjs";
15
17
  import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
16
- import { specMaterialHash } from "./spec-identity.mjs";
18
+ import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
17
19
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
18
20
  import { PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
19
21
  import { dirname, join, relative, resolve, isAbsolute } from "node:path";
@@ -28,13 +30,22 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
28
30
  function cmd(verb, rest = "") {
29
31
  return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
30
32
  }
31
- import { runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
33
+ import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
32
34
  import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
33
35
  import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
34
36
  import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
35
37
  import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
36
38
  import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
37
39
  import { publishQaVerdict, qaPortalUrl, qaVerdictPublishBlock, QA_VERDICT_PUBLISHERS, skippedQaVerdictPublish } from "./qa-verdict-publish.mjs";
40
+ import {
41
+ isLocalServePacket,
42
+ LOCAL_PROOF_BUILD_ENVIRONMENT,
43
+ LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD,
44
+ LOCAL_PROOF_PARITY_FIELD,
45
+ recordedBuildEnvironment,
46
+ recordedProductionParity,
47
+ } from "./local-proof.mjs";
48
+ import { isLoopbackHostname } from "./remit.mjs";
38
49
  import { publishStoredVerdict, qaPublishTextLines, QA_PUBLISH_EXIT_CODES } from "./qa-publish.mjs";
39
50
  // Shared outgoing-edge resolver, so QA expectations and build-time wiring
40
51
  // cannot drift on which declared routing field wins.
@@ -345,9 +356,20 @@ async function resolveQaInputs(args, {
345
356
  readJsonFile = readJson,
346
357
  loadCampaignEntry = loadPageKitCampaignEntry,
347
358
  } = {}) {
359
+ // Selector flags without a non-empty value are argv-only refusals for both
360
+ // qa run and qa resolve. Check them before checkpoint preflight or site reads.
361
+ for (const flag of ["packet", "site", "built", "map-id"]) {
362
+ if (Object.hasOwn(args, flag) && !stringArg(args[flag])) {
363
+ throw refused(`Missing value for --${flag}`);
364
+ }
365
+ }
348
366
  if (args.packet && args.spec) {
349
367
  throw refused("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
350
368
  }
369
+ // No non-empty campaign selector in argv is also decided before any read.
370
+ if (![args.packet, args.site, args.built, args._[2], args["map-id"]].some(stringArg)) {
371
+ throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
372
+ }
351
373
  // Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
352
374
  // that has only a built _site/ and a served URL — no Build Packet, no Map ID,
353
375
  // no CampaignSpec. Scope (pages + funnel types) is resolved from the built
@@ -371,15 +393,13 @@ async function resolveQaInputs(args, {
371
393
  const mapId = stringArg(args["map-id"])
372
394
  || stringArg(args._[2])
373
395
  || stringArg(packet?.spec?.map_id);
374
- // A refusal to identify a campaign, not a failure to QA one: with no
375
- // --packet, no --site/--built and no positional <map-id>, every read above
376
- // was skipped and `qa run` has nothing to work on. Tagged on both paths, not
377
- // just the empty one — the journal question is whether the CLI reached a
378
- // handler's WORK, and a missing-identity refusal decides that before any
379
- // spec is fetched, any browser launches and anything is written. (When a
380
- // packet WAS named, it has been read by here; the packet read is a lookup
381
- // for the same identity question, and one message cannot be two verdicts.)
382
- if (!mapId) throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
396
+ // A named packet has been read by checkpoint preflight. Its missing or
397
+ // conflicting identity is a handler failure, not an argv-only refusal.
398
+ const localSpecId = packet?.spec?.local_spec_id ?? null;
399
+ if (!mapId && !localSpecId) throw new Error("QA requires a Map ID or a local-spec packet. The named Build Packet has no campaign identity.");
400
+ if (packet && localSpecId != null && (!resolveCampaignIdentity(packet.spec) || mapId)) {
401
+ throw new Error("Local-spec packet QA cannot use a Map ID override or an ambiguous identity.");
402
+ }
383
403
 
384
404
  const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
385
405
  const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
@@ -404,6 +424,13 @@ async function resolveQaInputs(args, {
404
424
  specSource = `${proxyBase.replace(/\/+$/, "")}/api/spec/${encodeURIComponent(mapId)}`;
405
425
  }
406
426
 
427
+ if (!packet && rawSpec?.spec_identity?.local_spec_id != null) {
428
+ throw new Error("Local-spec QA requires --packet; a local spec cannot be published under a Map ID.");
429
+ }
430
+ if (packet && (localSpecId != null || rawSpec?.spec_identity?.local_spec_id != null)
431
+ && !campaignIdentitiesMatch(packet.spec, campaignSpecIdentity(rawSpec))) {
432
+ throw new Error("Packet local_spec_id does not match the local CampaignSpec identity. Re-run prepare-build from the intended spec.");
433
+ }
407
434
  const normalized = normalizeSpec(rawSpec);
408
435
  const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
409
436
  const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
@@ -438,17 +465,28 @@ async function resolveQaInputs(args, {
438
465
  hiddenEagerMediaGate,
439
466
  });
440
467
  const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
468
+ const qaScope = applyQaBuildScope(topologies, {
469
+ packet, report: checkpointPreflight?.runtimeReport,
470
+ targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
471
+ });
441
472
  const brandContract = loadBrandContract(templateFamily);
473
+ const localServeAnalytics = resolveLocalServeAnalytics({
474
+ packet,
475
+ report: checkpointPreflight?.runtimeReport,
476
+ captureUrl: analyticsCaptureTarget.url || baseUrl,
477
+ });
442
478
  return {
443
479
  themeGate,
444
480
  polishGate,
445
481
  qaWaivers,
446
482
  analyticsCaptureTarget,
483
+ localServeAnalytics,
447
484
  brandContract: brandContract.contract,
448
485
  brandContractStatus: brandContract.status,
449
486
  packetPath,
450
487
  packet,
451
488
  mapId,
489
+ localSpecId,
452
490
  publicRouteSlug,
453
491
  proxyBase,
454
492
  baseUrl,
@@ -463,7 +501,8 @@ async function resolveQaInputs(args, {
463
501
  specHash,
464
502
  templateFamily,
465
503
  commerceStructureContract,
466
- topologies,
504
+ topologies: qaScope.topologies,
505
+ excludedPages: qaScope.excludedPages,
467
506
  checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
468
507
  // The report the checkpoint gates were evaluated on, and the target repo
469
508
  // whose default it may or may not be: what the printed remediation names.
@@ -478,6 +517,10 @@ function resolvePacketCheckpointPreflight(args, {
478
517
  } = {}) {
479
518
  const packetPath = resolve(String(args.packet));
480
519
  const packet = readJsonFile(packetPath);
520
+ if (packet?.spec?.local_spec_id != null && (!resolveCampaignIdentity(packet.spec)
521
+ || stringArg(args["map-id"]) || stringArg(args._?.[2]))) {
522
+ throw new Error("Local-spec packet QA cannot use a Map ID override or an ambiguous identity.");
523
+ }
481
524
  const specPath = stringArg(args.spec)
482
525
  ? resolve(String(args.spec))
483
526
  : stringArg(packet?.spec?.local_path)
@@ -513,6 +556,10 @@ function resolvePacketCheckpointPreflight(args, {
513
556
  report = null;
514
557
  }
515
558
  }
559
+ if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
560
+ || (specStatus === "ok" && !specHashesMatch(report.identity?.spec_material_hash, computeSpecHash(rawSpec))))) {
561
+ throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Re-run prepare-build after a material revision; do not reuse foreign or stale proof.");
562
+ }
516
563
  const checkpointGates = [
517
564
  evaluatePageKitStoreProfile({
518
565
  specCampaign: rawSpec?.campaign || null,
@@ -548,12 +595,9 @@ function resolvePacketCheckpointPreflight(args, {
548
595
 
549
596
  function reportMatchesPacketIdentity(report, packet) {
550
597
  if (!isPlainObject(report) || !isPlainObject(report.identity)) return false;
551
- const packetMapId = stringArg(packet?.spec?.map_id);
552
- const reportMapId = stringArg(report.identity.map_id);
553
598
  const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
554
599
  const reportSlug = normalizePublicRouteSlug(report.identity.public_route_slug);
555
- return !!packetMapId
556
- && packetMapId === reportMapId
600
+ return campaignIdentitiesMatch(packet?.spec, report.identity)
557
601
  && !!packetSlug
558
602
  && packetSlug === reportSlug;
559
603
  }
@@ -640,7 +684,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
640
684
  brandContractStatus: "not_evaluated",
641
685
  packetPath: preflight.packetPath,
642
686
  packet: preflight.packet,
643
- mapId: stringArg(preflight.packet?.spec?.map_id) || "unknown-map",
687
+ mapId: stringArg(preflight.packet?.spec?.map_id) || (preflight.packet?.spec?.local_spec_id ? null : "unknown-map"),
688
+ localSpecId: preflight.packet?.spec?.local_spec_id ?? null,
644
689
  publicRouteSlug,
645
690
  proxyBase: stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE,
646
691
  baseUrl: inputBaseUrl,
@@ -666,18 +711,18 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
666
711
  // yields "not_applicable" rather than blocking, so browser QA still runs the
667
712
  // residue/placeholder/demo gates. Test orders are not attempted (no policy).
668
713
  export function resolveQaInputsFromSite(args) {
669
- const targetRepo = resolve(String(args.site || args.built));
670
- const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
671
- if (!scope.ok) {
672
- throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
673
- }
674
714
  const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
675
715
  if (!baseUrl) {
676
- throw new Error("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
716
+ throw refused("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
677
717
  }
678
718
  const templateFamily = stringArg(args.family);
679
719
  if (!templateFamily) {
680
- throw new Error("Non-packet site QA requires --family <template-family> so residue, placeholder, and demo-asset gates can load the family brand contract.");
720
+ throw refused("Non-packet site QA requires --family <template-family> so residue, placeholder, and demo-asset gates can load the family brand contract.");
721
+ }
722
+ const targetRepo = resolve(String(args.site || args.built));
723
+ const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
724
+ if (!scope.ok) {
725
+ throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
681
726
  }
682
727
  const brandContract = loadBrandContract(templateFamily);
683
728
  if (brandContract.status !== "loaded" || !brandContract.contract) {
@@ -1506,6 +1551,7 @@ function resolvePayload(resolved, { routeProbe = null } = {}) {
1506
1551
  ok: status !== "blocked" && status !== "routes_unresolved",
1507
1552
  status,
1508
1553
  map_id: resolved.mapId,
1554
+ ...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
1509
1555
  ...(resolved.packetPath ? { packet_path: resolved.packetPath } : {}),
1510
1556
  ...reportPathField(resolved),
1511
1557
  ...(resolved.proxyBase && resolved.proxyBase !== DEFAULT_PROXY_BASE ? { proxy_base: resolved.proxyBase } : {}),
@@ -2105,6 +2151,13 @@ async function runQa(args, options = {}) {
2105
2151
  // Fail-fast before anything resolves or launches. The authoritative check
2106
2152
  // lives on the creation budget itself, which every browser path builds.
2107
2153
  refuseBadOrderCreationLimit(args);
2154
+ // Match dispatch: an active browser mode takes precedence over legacy API
2155
+ // diagnostics. Only the selected legacy path requires a cart and API mode.
2156
+ const browserMode = String(args["test-order"] || "off").toLowerCase();
2157
+ const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
2158
+ if (browserMode === "off" && legacyMode !== "off") {
2159
+ refusing(() => legacyTestOrderInputs({ ...args, "test-order": legacyMode }));
2160
+ }
2108
2161
  const resolved = await resolveQaInputs(args);
2109
2162
  return runResolvedQa(args, resolved, options);
2110
2163
  }
@@ -2182,12 +2235,19 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
2182
2235
 
2183
2236
  const assertions = [
2184
2237
  ...checkpointAssertions,
2238
+ ...(resolved.excludedPages || []).map(page => assertion({
2239
+ id: `build-scope:${page.page_id}`, family: "funnel-flow", page,
2240
+ status: STATUS.SKIPPED,
2241
+ expected: "Only built routes are preview-QA targets",
2242
+ actual: "out_of_build_scope",
2243
+ evidence: { reason: "out_of_build_scope" },
2244
+ })),
2185
2245
  ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
2186
2246
  themeGateAssertion(gate),
2187
2247
  ];
2188
2248
  const contractAssertion = templateBrandContractAssertion(resolved);
2189
2249
  if (contractAssertion) assertions.push(contractAssertion);
2190
- const commercialPlanning = planCommercialParity(resolved.rawSpec || resolved.spec);
2250
+ const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, resolved.excludedPages));
2191
2251
  const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
2192
2252
  const commercialIds = new Set(commercialPlanning.pages
2193
2253
  .filter((page) => page?.id !== undefined && page?.id !== null)
@@ -2272,13 +2332,17 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
2272
2332
  } else if (analyticsLeg === "run") {
2273
2333
  assertions.push(...await operations.runInventory(args, analyticsContract || {}, {
2274
2334
  target: resolved.analyticsCaptureTarget,
2335
+ ...analyticsCaptureScope(resolved),
2275
2336
  }));
2276
2337
  }
2277
2338
 
2278
- // Analytics parity is unchanged and remains opt-in between root inventory
2279
- // and typed-card receipt capture.
2339
+ // Analytics parity remains opt-in between root inventory and typed-card
2340
+ // receipt capture. #503: it gets the same partial-scope capture options.
2280
2341
  if (stringArg(args["analytics-baseline"])) {
2281
- assertions.push(...await operations.runParity(args, { target: resolved.analyticsCaptureTarget }));
2342
+ assertions.push(...await operations.runParity(args, {
2343
+ target: resolved.analyticsCaptureTarget,
2344
+ ...analyticsCaptureScope(resolved),
2345
+ }));
2282
2346
  }
2283
2347
 
2284
2348
  const result = await operations.runOrders({
@@ -2291,9 +2355,130 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
2291
2355
  if (analyticsLeg === "run") {
2292
2356
  assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
2293
2357
  }
2358
+ applyLocalServeAnalyticsReview(assertions, resolved.localServeAnalytics);
2294
2359
  return result.orders;
2295
2360
  }
2296
2361
 
2362
+ // #483: local proof mode (deploy.target local-serve) renders the DEVELOPMENT
2363
+ // environment on purpose, and the starter templates gate every vendor loader
2364
+ // on it. A pixel that did not fire on that render is the render's design, not
2365
+ // the campaign's defect, so fire-dependent analytics checks become manual
2366
+ // review there instead of blockers. Only a run whose capture is actually
2367
+ // served from loopback qualifies: the same packet QA'd against the PR preview
2368
+ // (--base-url <preview>) is a production render and keeps its blockers, which
2369
+ // is the follow-up every downgraded assertion names.
2370
+ // The render itself must be on record, too: the exception applies only when
2371
+ // stages.assembly.evidence.build_environment says "development". A production
2372
+ // build served on localhost, or a build whose environment was never recorded,
2373
+ // keeps its blockers; the build-environment preflight only warns about those
2374
+ // states, so this gate cannot lean on it.
2375
+ // data-layer-purchase is deliberately not downgraded: it counts the SDK's own
2376
+ // dl_purchase, which the development render still pushes, so a miss on
2377
+ // localhost can be a real defect and keeps blocking.
2378
+ // A failure that is a capture or runner error, not "did not fire", is never
2379
+ // downgraded either: the environment gate explains a silent pixel, not an
2380
+ // unmeasured one. tag:* and oob:* are only emitted from a completed capture (a
2381
+ // failed capture is analytics-correctness:runner, which is outside this set);
2382
+ // purchase-fires names its unmeasured receipts in capture_error_plan_ids.
2383
+ const LOCAL_SERVE_ANALYTICS_REASON = "local_serve_development_render";
2384
+ const FIRE_DEPENDENT_ANALYTICS_ID = /^analytics-correctness:(?:tag:|oob:|purchase-fires(?::|$))/;
2385
+
2386
+ function isCaptureErrorFailure(item) {
2387
+ const evidence = item?.evidence;
2388
+ if (evidence && typeof evidence === "object" && evidence.error_code) return true;
2389
+ if (/^analytics-correctness:purchase-fires(?::|$)/.test(String(item?.id || ""))) {
2390
+ // Downgrade only a Purchase reading that positively records no capture
2391
+ // error; a missing list is an unknown, not a clean measurement.
2392
+ const errors = evidence?.capture_error_plan_ids;
2393
+ return !Array.isArray(errors) || errors.length > 0;
2394
+ }
2395
+ return false;
2396
+ }
2397
+
2398
+ function isLoopbackUrl(value) {
2399
+ if (typeof value !== "string" || !value.trim()) return false;
2400
+ let hostname = null;
2401
+ try { hostname = new URL(value).hostname; } catch { hostname = null; }
2402
+ return !!hostname && isLoopbackHostname(hostname);
2403
+ }
2404
+
2405
+ // #500: run-level eligibility is computed from the campaign-root URL, but the
2406
+ // page a failing check measured is not always that root. The #493 inventory
2407
+ // fallback can capture a built entry whose page.url is a remote production
2408
+ // preview, and a localhost root can redirect to a production host. So each
2409
+ // failing check is downgraded only when the page it measured is on record as
2410
+ // loopback:
2411
+ // - tag:* / oob:* — the check's own url and the inventory capture's
2412
+ // capture_page.url (both the URL requested) AND the capture's final_url
2413
+ // (page.url() after redirects and settling) must all be loopback;
2414
+ // - purchase-fires — every judged receipt's receipt_url (the order's final
2415
+ // page URL, recorded before analytics settle) AND its receipt_document_url
2416
+ // (page.url() read after the receipt analytics settled and were collected)
2417
+ // must be loopback, and there must be one. A receipt that redirects to a
2418
+ // hosted page during the settle window measured that host, not loopback.
2419
+ // A measured location that is missing or unparseable keeps the blocker.
2420
+ function localServeMeasuredOnLoopback(item, assertions) {
2421
+ const id = String(item?.id || "");
2422
+ if (/^analytics-correctness:purchase-fires(?::|$)/.test(id)) {
2423
+ const receipts = item?.evidence?.receipts;
2424
+ return Array.isArray(receipts) && receipts.length > 0
2425
+ && receipts.every((receipt) => isLoopbackUrl(receipt?.receipt_url)
2426
+ && isLoopbackUrl(receipt?.receipt_document_url));
2427
+ }
2428
+ const ownUrl = item?.evidence?.url ?? item?.url;
2429
+ if (!isLoopbackUrl(ownUrl)) return false;
2430
+ const capture = assertions.find((entry) => entry?.id === "analytics-correctness:capture" && entry.status === STATUS.PASS);
2431
+ const capturePage = capture?.evidence?.capture_page;
2432
+ return isLoopbackUrl(capturePage?.url) && isLoopbackUrl(capture?.evidence?.final_url);
2433
+ }
2434
+
2435
+ function resolveLocalServeAnalytics({ packet, report, captureUrl }) {
2436
+ if (!isLocalServePacket(packet)) return null;
2437
+ if (!isLoopbackUrl(String(captureUrl ?? ""))) return null;
2438
+ if (recordedBuildEnvironment(report) !== LOCAL_PROOF_BUILD_ENVIRONMENT) return null;
2439
+ const parity = recordedProductionParity(report);
2440
+ return {
2441
+ deploy_target: "local-serve",
2442
+ build_environment: { field: LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD, value: LOCAL_PROOF_BUILD_ENVIRONMENT },
2443
+ production_parity: parity
2444
+ ? {
2445
+ field: LOCAL_PROOF_PARITY_FIELD,
2446
+ status: typeof parity.status === "string" ? parity.status : null,
2447
+ checked_at: typeof parity.checked_at === "string" ? parity.checked_at : null,
2448
+ page_count: Number.isFinite(parity.page_count) ? parity.page_count : null,
2449
+ gated_hosts: [...new Set((Array.isArray(parity.pages) ? parity.pages : [])
2450
+ .flatMap((page) => (Array.isArray(page?.gated_hosts) ? page.gated_hosts : []))
2451
+ .filter((host) => typeof host === "string"))].sort(),
2452
+ }
2453
+ : { field: LOCAL_PROOF_PARITY_FIELD, status: "not_recorded" },
2454
+ };
2455
+ }
2456
+
2457
+ function applyLocalServeAnalyticsReview(assertions, localServe) {
2458
+ if (!localServe) return;
2459
+ const parityPassed = localServe.production_parity?.status === "pass";
2460
+ for (const [index, item] of assertions.entries()) {
2461
+ if (item?.status !== STATUS.FAIL || !FIRE_DEPENDENT_ANALYTICS_ID.test(String(item.id || ""))) continue;
2462
+ if (isCaptureErrorFailure(item)) continue;
2463
+ if (!localServeMeasuredOnLoopback(item, assertions)) continue;
2464
+ assertions[index] = {
2465
+ ...item,
2466
+ status: STATUS.MANUAL_REVIEW,
2467
+ severity: SEVERITY.WARN,
2468
+ actual: `${item.actual ?? "did not fire"}; local-serve development render gates vendor loaders out, so re-run against the PR preview with --base-url <preview-url>`,
2469
+ evidence: {
2470
+ ...(item.evidence || {}),
2471
+ reason: LOCAL_SERVE_ANALYTICS_REASON,
2472
+ local_serve_status: STATUS.FAIL,
2473
+ build_environment: localServe.build_environment,
2474
+ follow_up: "Re-run qa run against the PR preview (a production render) with --base-url <preview-url>; that run gates these checks.",
2475
+ production_parity: localServe.production_parity,
2476
+ ...(parityPassed ? {} : { production_parity_note: "No passing page-kit parity is recorded, so nothing yet shows the production render carries these loaders." }),
2477
+ },
2478
+ };
2479
+ }
2480
+ }
2481
+
2297
2482
  async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
2298
2483
  const entryUrls = deriveEntryUrls(resolved.topologies);
2299
2484
  const pageUrls = derivePageUrls(resolved.topologies);
@@ -2311,12 +2496,14 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2311
2496
  baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
2312
2497
  targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
2313
2498
  mapId: resolved.mapId,
2499
+ localSpecId: resolved.localSpecId,
2314
2500
  currentRunId: runId,
2315
2501
  isFinding: isFindingAssertion,
2316
2502
  });
2317
2503
  const verdict = createVerdict({
2318
2504
  runId,
2319
2505
  mapId: resolved.mapId,
2506
+ localSpecId: resolved.localSpecId,
2320
2507
  publicRouteSlug: resolved.publicRouteSlug || null,
2321
2508
  campaignRefId: resolved.spec.campaign?.ref_id || null,
2322
2509
  specVersion: resolved.specVersion,
@@ -2370,7 +2557,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2370
2557
  // config, or scope mismatch should be visible on the QA path too, not just
2371
2558
  // on remit (Kilo review, PR #177).
2372
2559
  const consent = resolveConsent({ proxyBase: resolved.proxyBase });
2373
- const publishDecision = decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
2560
+ const publishDecision = resolved.localSpecId
2561
+ ? { publish: false, reason: "local_spec", flag_invalid: false }
2562
+ : decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
2374
2563
  if (publishDecision.flag_invalid) {
2375
2564
  process.stderr.write(`[campaigns-os] --post-verdict "${args["post-verdict"]}" is not a recognized value (use true|1|yes|y|on or false|0|no|n|off); the flag was ignored and the default publish decision applied.\n`);
2376
2565
  }
@@ -2390,6 +2579,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2390
2579
  status: verdict.disposition,
2391
2580
  run_id: verdict.run_id,
2392
2581
  map_id: resolved.mapId,
2582
+ ...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
2393
2583
  public_route_slug: resolved.publicRouteSlug || null,
2394
2584
  ...reportPathField(resolved),
2395
2585
  base_url: resolved.baseUrl,
@@ -2417,6 +2607,38 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2417
2607
  };
2418
2608
  }
2419
2609
 
2610
+ // #493: a full build captures the campaign root. A partial build captures it
2611
+ // only when a built, in-scope page is served there; otherwise the root is
2612
+ // whatever the host answers (a directory index, a generic fallback) and must
2613
+ // not be measured. Either way the built entry pages (the same first in-scope
2614
+ // entry #482 selects) are the fallback when the root cannot be captured.
2615
+ // #503: "served there" is the page identity the capture leg deduplicates by
2616
+ // (isSameAnalyticsCapturePage): a query-routed page on the root's path
2617
+ // (`/campaign/?step=checkout`) is not the root, so it cannot put the root in
2618
+ // scope.
2619
+ function analyticsCaptureScope(resolved) {
2620
+ const rootUrl = rootCaptureUrl(resolved?.analyticsCaptureTarget?.url);
2621
+ const topologies = topologyList(resolved?.topologies);
2622
+ const partial = topologies.some((topology) => topology?.partial_build_scope)
2623
+ || (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0);
2624
+ const rootInScope = !rootUrl || !partial || topologies.some((topology) =>
2625
+ (Array.isArray(topology?.pages) ? topology.pages : []).some((page) =>
2626
+ typeof page?.url === "string" && isSameAnalyticsCapturePage(page.url, rootUrl)));
2627
+ return {
2628
+ rootInScope,
2629
+ fallbackTargets: deriveEntryUrls(resolved?.topologies),
2630
+ };
2631
+ }
2632
+
2633
+ function rootCaptureUrl(value) {
2634
+ if (typeof value !== "string" || !value.trim()) return null;
2635
+ try {
2636
+ return new URL(value).toString();
2637
+ } catch {
2638
+ return null;
2639
+ }
2640
+ }
2641
+
2420
2642
  const ENTRY_PAGE_TYPES = new Set([
2421
2643
  "entry",
2422
2644
  "presell",
@@ -2434,7 +2656,9 @@ function deriveEntryUrls(topologies) {
2434
2656
  for (const topology of topologyList(topologies)) {
2435
2657
  const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2436
2658
  if (!pages.length) continue;
2437
- const page = pages.find(isEntryLikePage) || pages[0];
2659
+ // #482: a partial build enters at its first in-scope page, which can be
2660
+ // an opted-in select or checkout rather than a later landing/presell.
2661
+ const page = topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
2438
2662
  entries.push({
2439
2663
  funnel_id: topology.funnel_id || "default",
2440
2664
  funnel_name: topology.funnel_name || topology.funnel_id || "default",
@@ -2528,7 +2752,10 @@ async function runPageChecks(page, args, {
2528
2752
  }
2529
2753
 
2530
2754
  const source = await sourceLoader(page);
2531
- assertions.push(bindingAssertion(page, await observeBinding({ source, page, expected: bindingExpected, scriptLoader: bindingScriptLoader })));
2755
+ const scriptParseFailures = [];
2756
+ assertions.push(bindingAssertion(page, await observeBinding({ source, page, expected: bindingExpected, scriptLoader: bindingScriptLoader, parseFailures: scriptParseFailures })));
2757
+ const scriptParse = scriptParseAssertion(page, scriptParseFailures);
2758
+ if (scriptParse) assertions.push(assertion({ ...scriptParse, page }));
2532
2759
  if (!source.ok) {
2533
2760
  const isHttpStatus = source.error_code === "http_status";
2534
2761
  assertions.push(assertion({
@@ -2698,15 +2925,12 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
2698
2925
  const apiKey = stringArg(args["api-key"]) || process.env.QA_CAMPAIGNS_API_KEY;
2699
2926
  const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
2700
2927
  if (!apiKey || !apiBase) throw new Error("Legacy direct API test orders require --api-key/QA_CAMPAIGNS_API_KEY and --campaigns-api-base/CAMPAIGNS_API_BASE.");
2701
- const cart = parseCart(args.cart);
2702
- if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
2928
+ const { cart, paths } = legacyTestOrderInputs(args);
2703
2929
  const checkout = findPage(resolved.topologies, "checkout");
2704
2930
  if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
2705
2931
  const upsell = findPage(resolved.topologies, "upsell");
2706
- const paths = mode === "both" ? ["accept", "decline"] : [mode];
2707
2932
  const orders = [];
2708
2933
  for (const path of paths) {
2709
- if (!["accept", "decline"].includes(path)) throw new Error(`Unknown --test-order mode: ${mode}`);
2710
2934
  const create = await createTestOrder({ apiBase, apiKey, cart, runId, successUrl: checkout.expected_next_url || upsell?.url || checkout.url, spec: resolved.spec, args });
2711
2935
  const verification = { expected_line_count: cart.length, actual_line_count: 0, diff: [], verified: false };
2712
2936
  if (!create.ok) {
@@ -3015,7 +3239,7 @@ function output(value, args) {
3015
3239
  }
3016
3240
  if (value.verdict) {
3017
3241
  console.log(`QA run complete.`);
3018
- console.log(`Map ID: ${value.map_id}`);
3242
+ console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
3019
3243
  console.log(`Base URL: ${value.base_url || "(missing)"}`);
3020
3244
  printEntryUrlLines(value.entry_urls);
3021
3245
  console.log(`Run ID: ${value.run_id}`);
@@ -3048,7 +3272,7 @@ function output(value, args) {
3048
3272
  }
3049
3273
  console.log(`QA resolve complete.`);
3050
3274
  console.log(`Status: ${value.status}`);
3051
- console.log(`Map ID: ${value.map_id}`);
3275
+ console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
3052
3276
  console.log(`Spec: ${value.spec_source}`);
3053
3277
  console.log(`Base URL: ${value.base_url || "(missing)"}`);
3054
3278
  printEntryUrlLines(value.entry_urls);
@@ -3198,7 +3422,9 @@ export function qaResolveNextProofLines(value) {
3198
3422
  return [
3199
3423
  `Next expected proof: ${qaRunCommandFromResolve(value)}`,
3200
3424
  `Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
3201
- "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
3425
+ value.local_spec_id
3426
+ ? "Typed-card test orders use global test cards (no transactions/no permission gate); local-spec QA stays in the repository."
3427
+ : "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
3202
3428
  ];
3203
3429
  }
3204
3430
 
@@ -3572,6 +3798,14 @@ function decodeHtml(value) {
3572
3798
  .replace(/&gt;/g, ">");
3573
3799
  }
3574
3800
 
3801
+ function legacyTestOrderInputs(args) {
3802
+ const cart = typeof args.cart === "string" ? parseCart(args.cart) : [];
3803
+ if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
3804
+ const mode = String(args["test-order"] || "off").toLowerCase();
3805
+ if (!["accept", "decline", "both"].includes(mode)) throw new Error(`Unknown --test-order mode: ${mode}`);
3806
+ return { cart, paths: mode === "both" ? ["accept", "decline"] : [mode] };
3807
+ }
3808
+
3575
3809
  function parseCart(value) {
3576
3810
  if (!value) return [];
3577
3811
  return String(value).split(",").map((part) => {
@@ -3617,6 +3851,9 @@ export const __qaNodeTestHooks = Object.freeze({
3617
3851
  resolveQaInputs,
3618
3852
  runResolvedQa,
3619
3853
  runPageChecks,
3854
+ analyticsCaptureScope,
3855
+ resolveLocalServeAnalytics,
3856
+ applyLocalServeAnalyticsReview,
3620
3857
  analyticsCorrectnessLegDecision,
3621
3858
  analyticsCorrectnessDisabledAssertion,
3622
3859
  runAnalyticsOrderSequence,
@@ -46,6 +46,7 @@ export const QA_PUBLISH_STATUSES = Object.freeze({
46
46
  // thrown error, so --json readers get a code to branch on.
47
47
  export const QA_PUBLISH_REFUSALS = Object.freeze({
48
48
  packet_required: "packet_required",
49
+ local_spec: "local_spec",
49
50
  order_flags_refused: "order_flags_refused",
50
51
  verdict_missing: "verdict_missing",
51
52
  verdict_unreadable: "verdict_unreadable",
@@ -218,6 +219,9 @@ async function attemptPublish(args, operations, dryRun) {
218
219
  return refusal(QA_PUBLISH_REFUSALS.packet_required, `Build Packet ${packetPath} is not readable (${error.code || error.message}).`);
219
220
  }
220
221
 
222
+ if (packet?.spec?.local_spec_id != null) {
223
+ return refusal(QA_PUBLISH_REFUSALS.local_spec, "Local-spec QA has no saved Map destination; keep its verdict in the repository. Portal publication requires a saved Map and fresh evidence.");
224
+ }
221
225
  const source = resolveStoredVerdictSource({ args, packetPath, packet, readJsonFile: ops.readJsonFile, exists: ops.exists });
222
226
  if (source.error) return source.error;
223
227
  let verdict;
@@ -1,3 +1,4 @@
1
+ import { localSpecIdentityFields } from "./spec-source-identity.mjs";
1
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { dirname, join, resolve } from "node:path";
@@ -77,6 +78,7 @@ export function projectVerdictForSidecar(verdict, { generatedAt }) {
77
78
  schema_version: verdict.schema_version,
78
79
  run_id: verdict.run_id,
79
80
  campaign_slug: verdict.campaign_slug,
81
+ ...localSpecIdentityFields(verdict),
80
82
  ...(verdict.public_route_slug != null ? { public_route_slug: verdict.public_route_slug } : {}),
81
83
  ...(verdict.campaign_ref_id != null ? { campaign_ref_id: verdict.campaign_ref_id } : {}),
82
84
  spec_version: verdict.spec_version,