@nextcommerce/campaigns-os 1.43.1 → 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 (92) hide show
  1. package/AGENTS.md +9 -2
  2. package/CHANGELOG.md +1099 -5103
  3. package/README.md +34 -13
  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/agent-relevant-change-policy.v1.json +5 -0
  14. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  15. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  16. package/contracts/effects.v1.json +1184 -121
  17. package/contracts/orientation-reason-codes.v1.json +7 -0
  18. package/contracts/release-ledger.json +2190 -5260
  19. package/contracts/supported-surface.json +7 -4
  20. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  21. package/docs/brand-theme-bridge.md +81 -0
  22. package/docs/build-packet.md +222 -23
  23. package/docs/campaigns-os-build-flow.md +4 -3
  24. package/docs/design-source-package.md +162 -15
  25. package/docs/effects.md +66 -12
  26. package/docs/gateway-login.md +3 -0
  27. package/docs/local-setup.md +1 -1
  28. package/docs/orientation-contract-reference.md +42 -2
  29. package/docs/polish-evidence.md +74 -0
  30. package/docs/progress-snapshots.md +10 -6
  31. package/docs/qa-and-test-orders.md +230 -20
  32. package/docs/release-ledger-authoring-guide.md +70 -8
  33. package/docs/runtime-readiness.md +1 -1
  34. package/docs/sdk-storage-compatibility.md +1 -1
  35. package/docs/skills-revision.md +10 -10
  36. package/docs/supported-surface.md +2 -2
  37. package/docs/versioning.md +4 -1
  38. package/package.json +1 -1
  39. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  40. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  41. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  42. package/skills/campaign-readback-classification/SKILL.md +3 -3
  43. package/skills/campaign-run-evidence/SKILL.md +7 -6
  44. package/skills/contribution-intake/SKILL.md +3 -3
  45. package/skills/next-campaigns-build/SKILL.md +7 -6
  46. package/skills/next-campaigns-os/SKILL.md +7 -7
  47. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  48. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  49. package/skills/next-campaigns-polish/SKILL.md +28 -9
  50. package/skills/next-campaigns-qa/SKILL.md +7 -4
  51. package/skills.json +10 -10
  52. package/src/brand-theme.mjs +320 -20
  53. package/src/build-brief.mjs +6 -4
  54. package/src/built-script-syntax.mjs +480 -0
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/campaigns-api-key.mjs +99 -0
  57. package/src/cli-helpers.mjs +118 -0
  58. package/src/cli.mjs +1530 -7580
  59. package/src/commercial-parity.mjs +48 -2
  60. package/src/design-source-package.mjs +1 -1
  61. package/src/design-source-publication.mjs +898 -0
  62. package/src/deviation.mjs +13 -1
  63. package/src/diagnostic.mjs +6 -2
  64. package/src/directory-lock.mjs +270 -0
  65. package/src/doctor/checks.mjs +4654 -0
  66. package/src/doctor/inspect.mjs +678 -0
  67. package/src/doctor/next-step.mjs +731 -0
  68. package/src/doctor/source-provenance.mjs +184 -0
  69. package/src/install-invocation.mjs +29 -0
  70. package/src/invocation.mjs +183 -0
  71. package/src/live-campaign-refs.mjs +466 -0
  72. package/src/login.mjs +2 -2
  73. package/src/page-kit-store-profile.mjs +69 -12
  74. package/src/page-kit-sync.mjs +31 -12
  75. package/src/private-template-source.mjs +1 -1
  76. package/src/progress-node.mjs +9 -36
  77. package/src/proof-policy.mjs +1 -1
  78. package/src/qa-analytics-correctness.mjs +3 -0
  79. package/src/qa-binding-evidence.mjs +76 -11
  80. package/src/qa-browser.mjs +1316 -105
  81. package/src/qa-build-scope.mjs +47 -0
  82. package/src/qa-commercial-parity.mjs +48 -5
  83. package/src/qa-node.mjs +339 -19
  84. package/src/qa-test-order-topology.mjs +148 -0
  85. package/src/sdk-markup.mjs +72 -8
  86. package/src/source-html-intake.mjs +117 -1
  87. package/src/source-html-manifest.mjs +9 -2
  88. package/src/stage-ledger.mjs +28 -0
  89. package/src/stage-record.mjs +551 -0
  90. package/src/target-lock.mjs +54 -0
  91. package/src/template-brand-contract.mjs +17 -1
  92. package/src/upsell-selector-scope.mjs +112 -2
package/src/qa-node.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
2
- import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion } from './qa-binding-evidence.mjs';
2
+ import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion, scriptParseAssertion } from './qa-binding-evidence.mjs';
3
3
  import { shellToken } from "./shell-token.mjs";
4
+ import { applyQaBuildScope, specForQaScope } from "./qa-build-scope.mjs";
4
5
  import { requiredActionText } from "./gate-actions.mjs";
5
6
  import { parseOrderPathDepthFlag } from "./proof-policy.mjs";
6
7
  import {
@@ -29,13 +30,22 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
29
30
  function cmd(verb, rest = "") {
30
31
  return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
31
32
  }
32
- import { runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, validatedOrderCreationLimit } from "./qa-browser.mjs";
33
+ import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
33
34
  import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
34
35
  import { createVerdict, isFindingAssertion, QA_ASSERTION_FAMILY_VOCABULARY, SESSION_ENDING_DISPOSITIONS, SEVERITY, STATUS, validateVerdict } from "./qa-verdict.mjs";
35
36
  import { normalizeSdkMetaName, lookupSdkIgnoredMetaTag } from "./sdk-meta-tags.mjs";
36
37
  import { annotateQaAssertionCauses, formatCauseReportLines, formatCauseTag } from "./finding-cause.mjs";
37
38
  import { promoteQaVerdict, writeQaSidecar } from "./qa-sidecar.mjs";
38
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";
39
49
  import { publishStoredVerdict, qaPublishTextLines, QA_PUBLISH_EXIT_CODES } from "./qa-publish.mjs";
40
50
  // Shared outgoing-edge resolver, so QA expectations and build-time wiring
41
51
  // cannot drift on which declared routing field wins.
@@ -88,6 +98,18 @@ import {
88
98
  unavailableCommercialCapture,
89
99
  unavailableCommercialReport,
90
100
  } from "./qa-commercial-parity.mjs";
101
+ import {
102
+ LIVE_REF_CODES,
103
+ campaignDriftMessage,
104
+ disabledLiveRead,
105
+ evaluateLiveCampaignRefs,
106
+ extractRenderedRefs,
107
+ liveRefsDisabled,
108
+ liveRefFindingMessage,
109
+ liveRefsNotRunMessage,
110
+ readLiveCampaignForPacket,
111
+ } from "./live-campaign-refs.mjs";
112
+ import { specPackageRefs, specShippingRefs } from "./doctor/checks.mjs";
91
113
 
92
114
  // The producing runtime identity on every verdict. Read from package.json so
93
115
  // a verdict names the release that made it; a literal here outlived three
@@ -100,7 +122,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
100
122
  Usage:
101
123
  campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
102
124
  campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
103
- campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--json]
125
+ campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
104
126
  campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
105
127
  campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
106
128
  campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
@@ -120,7 +142,7 @@ Options:
120
142
  Requires --base-url and --family. No Map ID / CampaignSpec needed.
121
143
  --spec <path> Local exported CampaignSpec JSON for the non-packet Map ID flow.
122
144
  Packet QA always uses packet.spec.local_path and rejects this override.
123
- --proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, and verdict publishing.
145
+ --proxy-base <url> Campaign Map proxy base for /api/spec, /api/price-preview, /api/campaign, and verdict publishing.
124
146
  --base-url <url> Deployed campaign root. Packet deploy URL is used when omitted.
125
147
  Commercial pages are checked automatically against /api/price-preview;
126
148
  no commercial sidecar or extra catalog flag is required.
@@ -156,6 +178,9 @@ Options:
156
178
  Record is not stamped; a refusal still exits 2, a clean dry run exits 0
157
179
  (--json: dry_run, would_publish, would_post).
158
180
  --no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
181
+ --no-live-refs qa run: skip the one read-only GET of <proxy-base>/api/campaign that checks each
182
+ served page's shipping and package refs against the live campaign; the verdict
183
+ records the check not_run with reason disabled, never a pass.
159
184
  --auth-cookie <cookie> Cookie header for protected previews.
160
185
  --browser Run Playwright-rendered browser checks after static Node checks.
161
186
  Requires one-time setup: campaigns-os qa install-browser
@@ -168,8 +193,11 @@ Options:
168
193
  Create Playwright typed-card test orders through the tested checkout page.
169
194
  Test cards bypass the gateway and create no transactions, so no permission
170
195
  flags or packet policy are needed — just pick a mode. Default mode (bare
171
- --test-order, or "common") runs checkout, first-offer accept/decline, and a
172
- deduplicated shortest real receipt path when needed (at most 4 orders). "full" walks
196
+ --test-order, or "common") runs every actual terminal path when they fit under
197
+ the cap (--max-test-orders, default 6). Above the cap it runs checkout,
198
+ first-offer accept/decline, and a deduplicated shortest real receipt path, then
199
+ adds one decline path per offer/downsell page not yet declined, up to the cap,
200
+ and names any page left out. "full" walks
173
201
  every actual terminal path; cycles, missing routes, and reachable nonterminals
174
202
  block before browser launch. The default cap is 6; overflow names the exact raise.
175
203
  "tiers" is spec-driven: one strict-selection order per selector tier the
@@ -455,12 +483,22 @@ async function resolveQaInputs(args, {
455
483
  hiddenEagerMediaGate,
456
484
  });
457
485
  const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
486
+ const qaScope = applyQaBuildScope(topologies, {
487
+ packet, report: checkpointPreflight?.runtimeReport,
488
+ targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
489
+ });
458
490
  const brandContract = loadBrandContract(templateFamily);
491
+ const localServeAnalytics = resolveLocalServeAnalytics({
492
+ packet,
493
+ report: checkpointPreflight?.runtimeReport,
494
+ captureUrl: analyticsCaptureTarget.url || baseUrl,
495
+ });
459
496
  return {
460
497
  themeGate,
461
498
  polishGate,
462
499
  qaWaivers,
463
500
  analyticsCaptureTarget,
501
+ localServeAnalytics,
464
502
  brandContract: brandContract.contract,
465
503
  brandContractStatus: brandContract.status,
466
504
  packetPath,
@@ -481,7 +519,8 @@ async function resolveQaInputs(args, {
481
519
  specHash,
482
520
  templateFamily,
483
521
  commerceStructureContract,
484
- topologies,
522
+ topologies: qaScope.topologies,
523
+ excludedPages: qaScope.excludedPages,
485
524
  checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
486
525
  // The report the checkpoint gates were evaluated on, and the target repo
487
526
  // whose default it may or may not be: what the printed remediation names.
@@ -2130,6 +2169,13 @@ async function runQa(args, options = {}) {
2130
2169
  // Fail-fast before anything resolves or launches. The authoritative check
2131
2170
  // lives on the creation budget itself, which every browser path builds.
2132
2171
  refuseBadOrderCreationLimit(args);
2172
+ // Match dispatch: an active browser mode takes precedence over legacy API
2173
+ // diagnostics. Only the selected legacy path requires a cart and API mode.
2174
+ const browserMode = String(args["test-order"] || "off").toLowerCase();
2175
+ const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
2176
+ if (browserMode === "off" && legacyMode !== "off") {
2177
+ refusing(() => legacyTestOrderInputs({ ...args, "test-order": legacyMode }));
2178
+ }
2133
2179
  const resolved = await resolveQaInputs(args);
2134
2180
  return runResolvedQa(args, resolved, options);
2135
2181
  }
@@ -2137,7 +2183,7 @@ async function runQa(args, options = {}) {
2137
2183
  // `runSessionActive` is threaded in from the CLI's single ambient-session read
2138
2184
  // rather than re-discovered here, so the closeout command this run prints and
2139
2185
  // the run_id the session will close under come from the same observation.
2140
- async function runResolvedQa(args, resolved, { runSessionActive = false } = {}) {
2186
+ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
2141
2187
  const startedAt = new Date().toISOString();
2142
2188
  const runId = generateRunId();
2143
2189
  const gate = resolved.themeGate;
@@ -2207,12 +2253,19 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
2207
2253
 
2208
2254
  const assertions = [
2209
2255
  ...checkpointAssertions,
2256
+ ...(resolved.excludedPages || []).map(page => assertion({
2257
+ id: `build-scope:${page.page_id}`, family: "funnel-flow", page,
2258
+ status: STATUS.SKIPPED,
2259
+ expected: "Only built routes are preview-QA targets",
2260
+ actual: "out_of_build_scope",
2261
+ evidence: { reason: "out_of_build_scope" },
2262
+ })),
2210
2263
  ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
2211
2264
  themeGateAssertion(gate),
2212
2265
  ];
2213
2266
  const contractAssertion = templateBrandContractAssertion(resolved);
2214
2267
  if (contractAssertion) assertions.push(contractAssertion);
2215
- const commercialPlanning = planCommercialParity(resolved.rawSpec || resolved.spec);
2268
+ const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, resolved.excludedPages));
2216
2269
  const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
2217
2270
  const commercialIds = new Set(commercialPlanning.pages
2218
2271
  .filter((page) => page?.id !== undefined && page?.id !== null)
@@ -2223,13 +2276,33 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
2223
2276
  const pages = resolved.topologies.flatMap(topology => topology.pages);
2224
2277
  const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
2225
2278
  runPageChecks(page, args, { sourceLoader, bindingExpected, bindingScriptLoader, captureCommercial: commercialIds.has(String(page.page_id)) }));
2279
+ const livePages = new Map();
2226
2280
  for (const [index, page] of pages.entries()) {
2227
2281
  const pageResult = pageResults[index];
2228
2282
  assertions.push(...pageResult.assertions);
2283
+ if (pageResult.renderedRefs && !livePages.has(String(page.page_id))) {
2284
+ livePages.set(String(page.page_id), { ...page, page_id: String(page.page_id), ...pageResult.renderedRefs });
2285
+ }
2229
2286
  if (commercialIds.has(String(page.page_id)) && pageResult.commercialCapture && !capturesByPageId.has(String(page.page_id))) {
2230
2287
  capturesByPageId.set(String(page.page_id), pageResult.commercialCapture);
2231
2288
  }
2232
2289
  }
2290
+ // The live campaign read (#533): one GET of {proxy-base}/api/campaign under
2291
+ // the public campaign key, made only when a served page was read to compare;
2292
+ // --no-live-refs sends nothing and records the check not_run (`disabled`).
2293
+ const liveSpec = resolved.rawSpec || resolved.spec;
2294
+ const liveRead = liveRefsDisabled(args)
2295
+ ? disabledLiveRead()
2296
+ : liveCampaign !== undefined || livePages.size === 0
2297
+ ? liveCampaign
2298
+ : await readLiveCampaignForPacket({
2299
+ packet: resolved.packet,
2300
+ packetPath: resolved.packetPath || null,
2301
+ spec: liveSpec,
2302
+ fetchImpl: liveCampaignFetch,
2303
+ proxyBase: resolved.proxyBase,
2304
+ });
2305
+ assertions.push(...liveCampaignRefAssertions({ pages: [...livePages.values()], spec: liveSpec, liveCampaign: liveRead }));
2233
2306
  if (args.browser === true) {
2234
2307
  assertions.push(...await runBrowserChecks(resolved.topologies, args, {
2235
2308
  brandContract: resolved.brandContract,
@@ -2297,13 +2370,17 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
2297
2370
  } else if (analyticsLeg === "run") {
2298
2371
  assertions.push(...await operations.runInventory(args, analyticsContract || {}, {
2299
2372
  target: resolved.analyticsCaptureTarget,
2373
+ ...analyticsCaptureScope(resolved),
2300
2374
  }));
2301
2375
  }
2302
2376
 
2303
- // Analytics parity is unchanged and remains opt-in between root inventory
2304
- // and typed-card receipt capture.
2377
+ // Analytics parity remains opt-in between root inventory and typed-card
2378
+ // receipt capture. #503: it gets the same partial-scope capture options.
2305
2379
  if (stringArg(args["analytics-baseline"])) {
2306
- assertions.push(...await operations.runParity(args, { target: resolved.analyticsCaptureTarget }));
2380
+ assertions.push(...await operations.runParity(args, {
2381
+ target: resolved.analyticsCaptureTarget,
2382
+ ...analyticsCaptureScope(resolved),
2383
+ }));
2307
2384
  }
2308
2385
 
2309
2386
  const result = await operations.runOrders({
@@ -2316,9 +2393,130 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
2316
2393
  if (analyticsLeg === "run") {
2317
2394
  assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
2318
2395
  }
2396
+ applyLocalServeAnalyticsReview(assertions, resolved.localServeAnalytics);
2319
2397
  return result.orders;
2320
2398
  }
2321
2399
 
2400
+ // #483: local proof mode (deploy.target local-serve) renders the DEVELOPMENT
2401
+ // environment on purpose, and the starter templates gate every vendor loader
2402
+ // on it. A pixel that did not fire on that render is the render's design, not
2403
+ // the campaign's defect, so fire-dependent analytics checks become manual
2404
+ // review there instead of blockers. Only a run whose capture is actually
2405
+ // served from loopback qualifies: the same packet QA'd against the PR preview
2406
+ // (--base-url <preview>) is a production render and keeps its blockers, which
2407
+ // is the follow-up every downgraded assertion names.
2408
+ // The render itself must be on record, too: the exception applies only when
2409
+ // stages.assembly.evidence.build_environment says "development". A production
2410
+ // build served on localhost, or a build whose environment was never recorded,
2411
+ // keeps its blockers; the build-environment preflight only warns about those
2412
+ // states, so this gate cannot lean on it.
2413
+ // data-layer-purchase is deliberately not downgraded: it counts the SDK's own
2414
+ // dl_purchase, which the development render still pushes, so a miss on
2415
+ // localhost can be a real defect and keeps blocking.
2416
+ // A failure that is a capture or runner error, not "did not fire", is never
2417
+ // downgraded either: the environment gate explains a silent pixel, not an
2418
+ // unmeasured one. tag:* and oob:* are only emitted from a completed capture (a
2419
+ // failed capture is analytics-correctness:runner, which is outside this set);
2420
+ // purchase-fires names its unmeasured receipts in capture_error_plan_ids.
2421
+ const LOCAL_SERVE_ANALYTICS_REASON = "local_serve_development_render";
2422
+ const FIRE_DEPENDENT_ANALYTICS_ID = /^analytics-correctness:(?:tag:|oob:|purchase-fires(?::|$))/;
2423
+
2424
+ function isCaptureErrorFailure(item) {
2425
+ const evidence = item?.evidence;
2426
+ if (evidence && typeof evidence === "object" && evidence.error_code) return true;
2427
+ if (/^analytics-correctness:purchase-fires(?::|$)/.test(String(item?.id || ""))) {
2428
+ // Downgrade only a Purchase reading that positively records no capture
2429
+ // error; a missing list is an unknown, not a clean measurement.
2430
+ const errors = evidence?.capture_error_plan_ids;
2431
+ return !Array.isArray(errors) || errors.length > 0;
2432
+ }
2433
+ return false;
2434
+ }
2435
+
2436
+ function isLoopbackUrl(value) {
2437
+ if (typeof value !== "string" || !value.trim()) return false;
2438
+ let hostname = null;
2439
+ try { hostname = new URL(value).hostname; } catch { hostname = null; }
2440
+ return !!hostname && isLoopbackHostname(hostname);
2441
+ }
2442
+
2443
+ // #500: run-level eligibility is computed from the campaign-root URL, but the
2444
+ // page a failing check measured is not always that root. The #493 inventory
2445
+ // fallback can capture a built entry whose page.url is a remote production
2446
+ // preview, and a localhost root can redirect to a production host. So each
2447
+ // failing check is downgraded only when the page it measured is on record as
2448
+ // loopback:
2449
+ // - tag:* / oob:* — the check's own url and the inventory capture's
2450
+ // capture_page.url (both the URL requested) AND the capture's final_url
2451
+ // (page.url() after redirects and settling) must all be loopback;
2452
+ // - purchase-fires — every judged receipt's receipt_url (the order's final
2453
+ // page URL, recorded before analytics settle) AND its receipt_document_url
2454
+ // (page.url() read after the receipt analytics settled and were collected)
2455
+ // must be loopback, and there must be one. A receipt that redirects to a
2456
+ // hosted page during the settle window measured that host, not loopback.
2457
+ // A measured location that is missing or unparseable keeps the blocker.
2458
+ function localServeMeasuredOnLoopback(item, assertions) {
2459
+ const id = String(item?.id || "");
2460
+ if (/^analytics-correctness:purchase-fires(?::|$)/.test(id)) {
2461
+ const receipts = item?.evidence?.receipts;
2462
+ return Array.isArray(receipts) && receipts.length > 0
2463
+ && receipts.every((receipt) => isLoopbackUrl(receipt?.receipt_url)
2464
+ && isLoopbackUrl(receipt?.receipt_document_url));
2465
+ }
2466
+ const ownUrl = item?.evidence?.url ?? item?.url;
2467
+ if (!isLoopbackUrl(ownUrl)) return false;
2468
+ const capture = assertions.find((entry) => entry?.id === "analytics-correctness:capture" && entry.status === STATUS.PASS);
2469
+ const capturePage = capture?.evidence?.capture_page;
2470
+ return isLoopbackUrl(capturePage?.url) && isLoopbackUrl(capture?.evidence?.final_url);
2471
+ }
2472
+
2473
+ function resolveLocalServeAnalytics({ packet, report, captureUrl }) {
2474
+ if (!isLocalServePacket(packet)) return null;
2475
+ if (!isLoopbackUrl(String(captureUrl ?? ""))) return null;
2476
+ if (recordedBuildEnvironment(report) !== LOCAL_PROOF_BUILD_ENVIRONMENT) return null;
2477
+ const parity = recordedProductionParity(report);
2478
+ return {
2479
+ deploy_target: "local-serve",
2480
+ build_environment: { field: LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD, value: LOCAL_PROOF_BUILD_ENVIRONMENT },
2481
+ production_parity: parity
2482
+ ? {
2483
+ field: LOCAL_PROOF_PARITY_FIELD,
2484
+ status: typeof parity.status === "string" ? parity.status : null,
2485
+ checked_at: typeof parity.checked_at === "string" ? parity.checked_at : null,
2486
+ page_count: Number.isFinite(parity.page_count) ? parity.page_count : null,
2487
+ gated_hosts: [...new Set((Array.isArray(parity.pages) ? parity.pages : [])
2488
+ .flatMap((page) => (Array.isArray(page?.gated_hosts) ? page.gated_hosts : []))
2489
+ .filter((host) => typeof host === "string"))].sort(),
2490
+ }
2491
+ : { field: LOCAL_PROOF_PARITY_FIELD, status: "not_recorded" },
2492
+ };
2493
+ }
2494
+
2495
+ function applyLocalServeAnalyticsReview(assertions, localServe) {
2496
+ if (!localServe) return;
2497
+ const parityPassed = localServe.production_parity?.status === "pass";
2498
+ for (const [index, item] of assertions.entries()) {
2499
+ if (item?.status !== STATUS.FAIL || !FIRE_DEPENDENT_ANALYTICS_ID.test(String(item.id || ""))) continue;
2500
+ if (isCaptureErrorFailure(item)) continue;
2501
+ if (!localServeMeasuredOnLoopback(item, assertions)) continue;
2502
+ assertions[index] = {
2503
+ ...item,
2504
+ status: STATUS.MANUAL_REVIEW,
2505
+ severity: SEVERITY.WARN,
2506
+ 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>`,
2507
+ evidence: {
2508
+ ...(item.evidence || {}),
2509
+ reason: LOCAL_SERVE_ANALYTICS_REASON,
2510
+ local_serve_status: STATUS.FAIL,
2511
+ build_environment: localServe.build_environment,
2512
+ follow_up: "Re-run qa run against the PR preview (a production render) with --base-url <preview-url>; that run gates these checks.",
2513
+ production_parity: localServe.production_parity,
2514
+ ...(parityPassed ? {} : { production_parity_note: "No passing page-kit parity is recorded, so nothing yet shows the production render carries these loaders." }),
2515
+ },
2516
+ };
2517
+ }
2518
+ }
2519
+
2322
2520
  async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
2323
2521
  const entryUrls = deriveEntryUrls(resolved.topologies);
2324
2522
  const pageUrls = derivePageUrls(resolved.topologies);
@@ -2447,6 +2645,38 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2447
2645
  };
2448
2646
  }
2449
2647
 
2648
+ // #493: a full build captures the campaign root. A partial build captures it
2649
+ // only when a built, in-scope page is served there; otherwise the root is
2650
+ // whatever the host answers (a directory index, a generic fallback) and must
2651
+ // not be measured. Either way the built entry pages (the same first in-scope
2652
+ // entry #482 selects) are the fallback when the root cannot be captured.
2653
+ // #503: "served there" is the page identity the capture leg deduplicates by
2654
+ // (isSameAnalyticsCapturePage): a query-routed page on the root's path
2655
+ // (`/campaign/?step=checkout`) is not the root, so it cannot put the root in
2656
+ // scope.
2657
+ function analyticsCaptureScope(resolved) {
2658
+ const rootUrl = rootCaptureUrl(resolved?.analyticsCaptureTarget?.url);
2659
+ const topologies = topologyList(resolved?.topologies);
2660
+ const partial = topologies.some((topology) => topology?.partial_build_scope)
2661
+ || (Array.isArray(resolved?.excludedPages) && resolved.excludedPages.length > 0);
2662
+ const rootInScope = !rootUrl || !partial || topologies.some((topology) =>
2663
+ (Array.isArray(topology?.pages) ? topology.pages : []).some((page) =>
2664
+ typeof page?.url === "string" && isSameAnalyticsCapturePage(page.url, rootUrl)));
2665
+ return {
2666
+ rootInScope,
2667
+ fallbackTargets: deriveEntryUrls(resolved?.topologies),
2668
+ };
2669
+ }
2670
+
2671
+ function rootCaptureUrl(value) {
2672
+ if (typeof value !== "string" || !value.trim()) return null;
2673
+ try {
2674
+ return new URL(value).toString();
2675
+ } catch {
2676
+ return null;
2677
+ }
2678
+ }
2679
+
2450
2680
  const ENTRY_PAGE_TYPES = new Set([
2451
2681
  "entry",
2452
2682
  "presell",
@@ -2464,7 +2694,9 @@ function deriveEntryUrls(topologies) {
2464
2694
  for (const topology of topologyList(topologies)) {
2465
2695
  const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2466
2696
  if (!pages.length) continue;
2467
- const page = pages.find(isEntryLikePage) || pages[0];
2697
+ // #482: a partial build enters at its first in-scope page, which can be
2698
+ // an opted-in select or checkout rather than a later landing/presell.
2699
+ const page = topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
2468
2700
  entries.push({
2469
2701
  funnel_id: topology.funnel_id || "default",
2470
2702
  funnel_name: topology.funnel_name || topology.funnel_id || "default",
@@ -2558,7 +2790,10 @@ async function runPageChecks(page, args, {
2558
2790
  }
2559
2791
 
2560
2792
  const source = await sourceLoader(page);
2561
- assertions.push(bindingAssertion(page, await observeBinding({ source, page, expected: bindingExpected, scriptLoader: bindingScriptLoader })));
2793
+ const scriptParseFailures = [];
2794
+ assertions.push(bindingAssertion(page, await observeBinding({ source, page, expected: bindingExpected, scriptLoader: bindingScriptLoader, parseFailures: scriptParseFailures })));
2795
+ const scriptParse = scriptParseAssertion(page, scriptParseFailures);
2796
+ if (scriptParse) assertions.push(assertion({ ...scriptParse, page }));
2562
2797
  if (!source.ok) {
2563
2798
  const isHttpStatus = source.error_code === "http_status";
2564
2799
  assertions.push(assertion({
@@ -2687,7 +2922,77 @@ async function runPageChecks(page, args, {
2687
2922
  }));
2688
2923
  }
2689
2924
 
2690
- return { assertions, commercialCapture };
2925
+ const rendered = extractRenderedRefs(html);
2926
+ return {
2927
+ assertions,
2928
+ commercialCapture,
2929
+ renderedRefs: { package_refs: [...rendered.package_refs], shipping_refs: [...rendered.shipping_refs] },
2930
+ };
2931
+ }
2932
+
2933
+ // The live campaign ref check (#533) as QA assertions: the comparison doctor
2934
+ // runs over built pages, run here over the served pages this attempt read,
2935
+ // under the same codes. `liveCampaign` is a readLiveCampaign result the
2936
+ // caller made; absent or failed, the check is not_run with its reason.
2937
+ function liveCampaignRefAssertions({ pages, spec, liveCampaign }) {
2938
+ const campaignAssertion = (fields) => assertion({ family: "api-metadata", page: { page_id: "campaign" }, ...fields });
2939
+ if (!pages.length && liveCampaign?.reason_code !== "disabled") {
2940
+ return [campaignAssertion({
2941
+ id: "live-campaign-refs",
2942
+ status: STATUS.SKIPPED,
2943
+ expected: "every served page's shipping and package refs are served by the live campaign",
2944
+ actual: "not_run",
2945
+ evidence: { code: LIVE_REF_CODES.notRun, reason_code: "no_pages", reason: "No served page source was read to compare against the live campaign." },
2946
+ })];
2947
+ }
2948
+ const result = evaluateLiveCampaignRefs({
2949
+ pages,
2950
+ map: { package_refs: specPackageRefs(spec), shipping_refs: specShippingRefs(spec) },
2951
+ live: liveCampaign,
2952
+ });
2953
+ const keySource = liveCampaign?.key_source ? { key_source: liveCampaign.key_source } : {};
2954
+ if (result.status === "not_run") {
2955
+ // --no-live-refs: the verdict says how many served pages went unchecked.
2956
+ const eligible = result.reason_code === "disabled" ? { pages_eligible: result.checked_pages } : {};
2957
+ return [campaignAssertion({
2958
+ id: result.attempted ? LIVE_REF_CODES.notRun : "live-campaign-refs",
2959
+ status: result.attempted ? STATUS.WARN : STATUS.SKIPPED,
2960
+ ...(result.attempted ? { severity: SEVERITY.WARN } : {}),
2961
+ expected: "every served page's shipping and package refs are served by the live campaign",
2962
+ actual: "not_run",
2963
+ evidence: { code: LIVE_REF_CODES.notRun, reason_code: result.reason_code, reason: result.attempted ? liveRefsNotRunMessage(result) : result.reason, ...eligible, ...keySource },
2964
+ })];
2965
+ }
2966
+ const out = result.page_findings.map((finding) => assertion({
2967
+ id: `${finding.code}:${finding.page_id}`,
2968
+ family: "api-metadata",
2969
+ page: pages.find((page) => String(page.page_id) === finding.page_id) || { page_id: finding.page_id },
2970
+ status: STATUS.FAIL,
2971
+ severity: SEVERITY.BLOCKER,
2972
+ expected: `every rendered ${finding.kind} ref is served by the live campaign`,
2973
+ actual: finding.refs.join(", "),
2974
+ evidence: { code: finding.code, refs: finding.refs, message: liveRefFindingMessage(finding) },
2975
+ }));
2976
+ if (result.drift) {
2977
+ out.push(campaignAssertion({
2978
+ id: LIVE_REF_CODES.drift,
2979
+ status: STATUS.WARN,
2980
+ severity: SEVERITY.WARN,
2981
+ expected: "the CampaignSpec lists the refs the live campaign serves",
2982
+ actual: "drift",
2983
+ evidence: { code: LIVE_REF_CODES.drift, drift: result.drift, message: campaignDriftMessage(result.drift) },
2984
+ }));
2985
+ }
2986
+ if (result.status === "pass") {
2987
+ out.push(campaignAssertion({
2988
+ id: "live-campaign-refs",
2989
+ status: STATUS.PASS,
2990
+ expected: "every served page's shipping and package refs are served by the live campaign",
2991
+ actual: `${result.checked_pages} page(s) checked`,
2992
+ evidence: { ...keySource },
2993
+ }));
2994
+ }
2995
+ return out;
2691
2996
  }
2692
2997
 
2693
2998
  async function maybeRunTestOrders(
@@ -2702,6 +3007,12 @@ async function maybeRunTestOrders(
2702
3007
  const mode = String(args["test-order"] || "off").toLowerCase();
2703
3008
  const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
2704
3009
  const emptyReceiptAnalytics = () => ({ plannedPlanIds: [], attempts: [] });
3010
+ if (!mode || mode === "off") {
3011
+ // No browser order clicks an upsell control, legacy API orders included,
3012
+ // so the coverage row says so instead of going missing (#530).
3013
+ const coverage = upsellActionCoverageWithoutOrders(resolved.topologies);
3014
+ if (coverage) assertions.push(coverage);
3015
+ }
2705
3016
  if ((!mode || mode === "off") && (!legacyMode || legacyMode === "off")) {
2706
3017
  return { orders: [], receiptAnalytics: emptyReceiptAnalytics() };
2707
3018
  }
@@ -2728,15 +3039,12 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
2728
3039
  const apiKey = stringArg(args["api-key"]) || process.env.QA_CAMPAIGNS_API_KEY;
2729
3040
  const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
2730
3041
  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.");
2731
- const cart = parseCart(args.cart);
2732
- if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
3042
+ const { cart, paths } = legacyTestOrderInputs(args);
2733
3043
  const checkout = findPage(resolved.topologies, "checkout");
2734
3044
  if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
2735
3045
  const upsell = findPage(resolved.topologies, "upsell");
2736
- const paths = mode === "both" ? ["accept", "decline"] : [mode];
2737
3046
  const orders = [];
2738
3047
  for (const path of paths) {
2739
- if (!["accept", "decline"].includes(path)) throw new Error(`Unknown --test-order mode: ${mode}`);
2740
3048
  const create = await createTestOrder({ apiBase, apiKey, cart, runId, successUrl: checkout.expected_next_url || upsell?.url || checkout.url, spec: resolved.spec, args });
2741
3049
  const verification = { expected_line_count: cart.length, actual_line_count: 0, diff: [], verified: false };
2742
3050
  if (!create.ok) {
@@ -3604,6 +3912,14 @@ function decodeHtml(value) {
3604
3912
  .replace(/&gt;/g, ">");
3605
3913
  }
3606
3914
 
3915
+ function legacyTestOrderInputs(args) {
3916
+ const cart = typeof args.cart === "string" ? parseCart(args.cart) : [];
3917
+ if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
3918
+ const mode = String(args["test-order"] || "off").toLowerCase();
3919
+ if (!["accept", "decline", "both"].includes(mode)) throw new Error(`Unknown --test-order mode: ${mode}`);
3920
+ return { cart, paths: mode === "both" ? ["accept", "decline"] : [mode] };
3921
+ }
3922
+
3607
3923
  function parseCart(value) {
3608
3924
  if (!value) return [];
3609
3925
  return String(value).split(",").map((part) => {
@@ -3649,6 +3965,9 @@ export const __qaNodeTestHooks = Object.freeze({
3649
3965
  resolveQaInputs,
3650
3966
  runResolvedQa,
3651
3967
  runPageChecks,
3968
+ analyticsCaptureScope,
3969
+ resolveLocalServeAnalytics,
3970
+ applyLocalServeAnalyticsReview,
3652
3971
  analyticsCorrectnessLegDecision,
3653
3972
  analyticsCorrectnessDisabledAssertion,
3654
3973
  runAnalyticsOrderSequence,
@@ -3683,6 +4002,7 @@ export const __qaNodeTestHooks = Object.freeze({
3683
4002
  isRoutingMetaTag,
3684
4003
  unsupportedSdkMetaHint,
3685
4004
  reportCommercialRunnerError,
4005
+ liveCampaignRefAssertions,
3686
4006
  browserSkippedByGate,
3687
4007
  reportBrowserSkippedByGate,
3688
4008
  gateClearingHint,