@nextcommerce/campaigns-os 1.43.1 → 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 (56) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +451 -0
  3. package/README.md +2 -2
  4. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  5. package/contracts/effects.v1.json +8 -8
  6. package/contracts/release-ledger.json +672 -0
  7. package/contracts/supported-surface.json +2 -2
  8. package/docs/build-packet.md +64 -2
  9. package/docs/campaigns-os-build-flow.md +1 -0
  10. package/docs/design-source-package.md +89 -15
  11. package/docs/effects.md +16 -4
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/progress-snapshots.md +10 -6
  15. package/docs/qa-and-test-orders.md +131 -7
  16. package/docs/release-ledger-authoring-guide.md +6 -4
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/skills-revision.md +10 -10
  19. package/package.json +1 -1
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +4 -4
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/build-brief.mjs +6 -4
  31. package/src/built-script-syntax.mjs +480 -0
  32. package/src/campaigns-api-key.mjs +99 -0
  33. package/src/cli-helpers.mjs +118 -0
  34. package/src/cli.mjs +1211 -7495
  35. package/src/design-source-package.mjs +1 -1
  36. package/src/design-source-publication.mjs +898 -0
  37. package/src/diagnostic.mjs +2 -1
  38. package/src/directory-lock.mjs +270 -0
  39. package/src/doctor/checks.mjs +4415 -0
  40. package/src/doctor/inspect.mjs +636 -0
  41. package/src/doctor/next-step.mjs +731 -0
  42. package/src/install-invocation.mjs +29 -0
  43. package/src/invocation.mjs +179 -0
  44. package/src/private-template-source.mjs +1 -1
  45. package/src/progress-node.mjs +6 -35
  46. package/src/proof-policy.mjs +1 -1
  47. package/src/qa-analytics-correctness.mjs +3 -0
  48. package/src/qa-binding-evidence.mjs +76 -11
  49. package/src/qa-browser.mjs +778 -77
  50. package/src/qa-build-scope.mjs +47 -0
  51. package/src/qa-node.mjs +218 -13
  52. package/src/source-html-intake.mjs +1 -1
  53. package/src/source-html-manifest.mjs +9 -2
  54. package/src/stage-ledger.mjs +28 -0
  55. package/src/target-lock.mjs +54 -0
  56. package/src/template-brand-contract.mjs +17 -1
@@ -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,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, 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.
@@ -455,12 +465,22 @@ async function resolveQaInputs(args, {
455
465
  hiddenEagerMediaGate,
456
466
  });
457
467
  const qaWaivers = resolveQaWaivers({ packetPath, report: checkpointPreflight?.runtimeReport });
468
+ const qaScope = applyQaBuildScope(topologies, {
469
+ packet, report: checkpointPreflight?.runtimeReport,
470
+ targetRepo: checkpointPreflight?.targetRepo, publicRouteSlug,
471
+ });
458
472
  const brandContract = loadBrandContract(templateFamily);
473
+ const localServeAnalytics = resolveLocalServeAnalytics({
474
+ packet,
475
+ report: checkpointPreflight?.runtimeReport,
476
+ captureUrl: analyticsCaptureTarget.url || baseUrl,
477
+ });
459
478
  return {
460
479
  themeGate,
461
480
  polishGate,
462
481
  qaWaivers,
463
482
  analyticsCaptureTarget,
483
+ localServeAnalytics,
464
484
  brandContract: brandContract.contract,
465
485
  brandContractStatus: brandContract.status,
466
486
  packetPath,
@@ -481,7 +501,8 @@ async function resolveQaInputs(args, {
481
501
  specHash,
482
502
  templateFamily,
483
503
  commerceStructureContract,
484
- topologies,
504
+ topologies: qaScope.topologies,
505
+ excludedPages: qaScope.excludedPages,
485
506
  checkpointGates: checkpointPreflight?.checkpointGates || nonPacketCheckpointGates(),
486
507
  // The report the checkpoint gates were evaluated on, and the target repo
487
508
  // whose default it may or may not be: what the printed remediation names.
@@ -2130,6 +2151,13 @@ async function runQa(args, options = {}) {
2130
2151
  // Fail-fast before anything resolves or launches. The authoritative check
2131
2152
  // lives on the creation budget itself, which every browser path builds.
2132
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
+ }
2133
2161
  const resolved = await resolveQaInputs(args);
2134
2162
  return runResolvedQa(args, resolved, options);
2135
2163
  }
@@ -2207,12 +2235,19 @@ async function runResolvedQa(args, resolved, { runSessionActive = false } = {})
2207
2235
 
2208
2236
  const assertions = [
2209
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
+ })),
2210
2245
  ...(polishGate?.owned_checkpoint_only ? [] : [polishGateAssertion(polishGate)]),
2211
2246
  themeGateAssertion(gate),
2212
2247
  ];
2213
2248
  const contractAssertion = templateBrandContractAssertion(resolved);
2214
2249
  if (contractAssertion) assertions.push(contractAssertion);
2215
- const commercialPlanning = planCommercialParity(resolved.rawSpec || resolved.spec);
2250
+ const commercialPlanning = planCommercialParity(specForQaScope(resolved.rawSpec || resolved.spec, resolved.excludedPages));
2216
2251
  const sourceLoader = createPageSourceLoader({ authCookie: args["auth-cookie"] });
2217
2252
  const commercialIds = new Set(commercialPlanning.pages
2218
2253
  .filter((page) => page?.id !== undefined && page?.id !== null)
@@ -2297,13 +2332,17 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
2297
2332
  } else if (analyticsLeg === "run") {
2298
2333
  assertions.push(...await operations.runInventory(args, analyticsContract || {}, {
2299
2334
  target: resolved.analyticsCaptureTarget,
2335
+ ...analyticsCaptureScope(resolved),
2300
2336
  }));
2301
2337
  }
2302
2338
 
2303
- // Analytics parity is unchanged and remains opt-in between root inventory
2304
- // 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.
2305
2341
  if (stringArg(args["analytics-baseline"])) {
2306
- assertions.push(...await operations.runParity(args, { target: resolved.analyticsCaptureTarget }));
2342
+ assertions.push(...await operations.runParity(args, {
2343
+ target: resolved.analyticsCaptureTarget,
2344
+ ...analyticsCaptureScope(resolved),
2345
+ }));
2307
2346
  }
2308
2347
 
2309
2348
  const result = await operations.runOrders({
@@ -2316,9 +2355,130 @@ async function runAnalyticsOrderSequence({ args, resolved, runId, assertions },
2316
2355
  if (analyticsLeg === "run") {
2317
2356
  assertions.push(operations.assessReceipt(result.receiptAnalytics, { waivers: resolved.qaWaivers }));
2318
2357
  }
2358
+ applyLocalServeAnalyticsReview(assertions, resolved.localServeAnalytics);
2319
2359
  return result.orders;
2320
2360
  }
2321
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
+
2322
2482
  async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, testOrders, commercial = null, runSessionActive = false, browser = null }) {
2323
2483
  const entryUrls = deriveEntryUrls(resolved.topologies);
2324
2484
  const pageUrls = derivePageUrls(resolved.topologies);
@@ -2447,6 +2607,38 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
2447
2607
  };
2448
2608
  }
2449
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
+
2450
2642
  const ENTRY_PAGE_TYPES = new Set([
2451
2643
  "entry",
2452
2644
  "presell",
@@ -2464,7 +2656,9 @@ function deriveEntryUrls(topologies) {
2464
2656
  for (const topology of topologyList(topologies)) {
2465
2657
  const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
2466
2658
  if (!pages.length) continue;
2467
- 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];
2468
2662
  entries.push({
2469
2663
  funnel_id: topology.funnel_id || "default",
2470
2664
  funnel_name: topology.funnel_name || topology.funnel_id || "default",
@@ -2558,7 +2752,10 @@ async function runPageChecks(page, args, {
2558
2752
  }
2559
2753
 
2560
2754
  const source = await sourceLoader(page);
2561
- 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 }));
2562
2759
  if (!source.ok) {
2563
2760
  const isHttpStatus = source.error_code === "http_status";
2564
2761
  assertions.push(assertion({
@@ -2728,15 +2925,12 @@ async function maybeRunLegacyApiTestOrders({ args, resolved, runId, assertions }
2728
2925
  const apiKey = stringArg(args["api-key"]) || process.env.QA_CAMPAIGNS_API_KEY;
2729
2926
  const apiBase = stringArg(args["campaigns-api-base"]) || process.env.CAMPAIGNS_API_BASE;
2730
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.");
2731
- const cart = parseCart(args.cart);
2732
- if (!cart.length) throw new Error("--test-order requires --cart package_id:quantity pairs.");
2928
+ const { cart, paths } = legacyTestOrderInputs(args);
2733
2929
  const checkout = findPage(resolved.topologies, "checkout");
2734
2930
  if (!checkout?.url) throw new Error("--test-order requires a checkout page URL.");
2735
2931
  const upsell = findPage(resolved.topologies, "upsell");
2736
- const paths = mode === "both" ? ["accept", "decline"] : [mode];
2737
2932
  const orders = [];
2738
2933
  for (const path of paths) {
2739
- if (!["accept", "decline"].includes(path)) throw new Error(`Unknown --test-order mode: ${mode}`);
2740
2934
  const create = await createTestOrder({ apiBase, apiKey, cart, runId, successUrl: checkout.expected_next_url || upsell?.url || checkout.url, spec: resolved.spec, args });
2741
2935
  const verification = { expected_line_count: cart.length, actual_line_count: 0, diff: [], verified: false };
2742
2936
  if (!create.ok) {
@@ -3604,6 +3798,14 @@ function decodeHtml(value) {
3604
3798
  .replace(/&gt;/g, ">");
3605
3799
  }
3606
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
+
3607
3809
  function parseCart(value) {
3608
3810
  if (!value) return [];
3609
3811
  return String(value).split(",").map((part) => {
@@ -3649,6 +3851,9 @@ export const __qaNodeTestHooks = Object.freeze({
3649
3851
  resolveQaInputs,
3650
3852
  runResolvedQa,
3651
3853
  runPageChecks,
3854
+ analyticsCaptureScope,
3855
+ resolveLocalServeAnalytics,
3856
+ applyLocalServeAnalyticsReview,
3652
3857
  analyticsCorrectnessLegDecision,
3653
3858
  analyticsCorrectnessDisabledAssertion,
3654
3859
  runAnalyticsOrderSequence,
@@ -106,7 +106,7 @@ function declaredScopeSkip(page, { skipEntry = null, buildScope = null, manifest
106
106
  id: `dec_page_scope_${page.id}`,
107
107
  stage: "prepare_build",
108
108
  decision_type: "deterministic_derivation",
109
- decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); the build stage materialises the page from ${familyLabel}'s stock page, and intake demands no design source for it`,
109
+ decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); keep the route unbuilt unless the operator opts in to materialising it from ${familyLabel}'s stock page; intake demands no design source for it`,
110
110
  confidence: "high",
111
111
  template_stock: true,
112
112
  template_family: family,
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync, statSync } from "node:fs";
2
3
  import { resolve } from "node:path";
3
4
 
@@ -95,10 +96,16 @@ export function readSourceHtmlManifestFile(sourceRoot, { manifestPath = null } =
95
96
  return { ...readManifestAt(resolvedPath), explicit: Boolean(explicit) };
96
97
  }
97
98
 
99
+ // One read: the manifest is parsed from, and hashed over, the same bytes. An
100
+ // edit that lands on disk after the read changes neither, so the sha256 a
101
+ // consumer records always describes what was parsed (#501).
98
102
  function readManifestAt(manifestPath) {
99
103
  let manifest;
104
+ let sha256;
100
105
  try {
101
- manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
106
+ const bytes = readFileSync(manifestPath);
107
+ sha256 = createHash("sha256").update(bytes).digest("hex");
108
+ manifest = JSON.parse(bytes.toString("utf8"));
102
109
  } catch (error) {
103
110
  return {
104
111
  manifest: null,
@@ -125,7 +132,7 @@ function readManifestAt(manifestPath) {
125
132
  const warnings = (validation.warnings || []).map(
126
133
  (entry) => `Source-html manifest at ${manifestPath}: [${entry.code}] ${entry.message}`,
127
134
  );
128
- return { manifest, path: manifestPath, warning: null, warnings, validation };
135
+ return { manifest, path: manifestPath, sha256, warning: null, warnings, validation };
129
136
  }
130
137
 
131
138
  function validateManifestPage(entry, index, add, addWarning = () => {}) {
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
4
4
  import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
5
5
  import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
6
+ import { withTargetLockSync } from "./target-lock.mjs";
6
7
  import {
7
8
  ASSEMBLY_REPORT_STAGE_KEYS,
8
9
  NEXT_STAGE_CONTRACTS,
@@ -425,6 +426,16 @@ export function assemblyReportMatchesPacket(report, packet) {
425
426
  * (waivers, evidence merges) pass no `stage`: they require the report to
426
427
  * exist and bind its identity themselves.
427
428
  *
429
+ * The read-modify-write runs under the per-target writer lock that
430
+ * prepare-build holds (src/target-lock.mjs, #501), so a stage producer's edit
431
+ * never lands between prepare-build's pre-publish evidence re-check and its
432
+ * publication; inside prepare-build's own critical section it enters
433
+ * directly. A workspace without `targetRepo` (only possible with an explicit
434
+ * refreshDoctor and no stale stamp) names no target to lock and runs as is.
435
+ * `lockBudgetMs` bounds the wait (default: the target lock budget). A caller
436
+ * whose mutate always returns null (a preview) passes `lock: false`: it
437
+ * writes nothing, so it takes no lock and creates no lock files.
438
+ *
428
439
  * Returns `{ written, skipped, report, reportPath, doctorOutPath }` where
429
440
  * `skipped` is `null`, `"absent"`, `"identity"` or `"unchanged"` and `report`
430
441
  * is what is now on disk (the mutated report when written, else the one read,
@@ -435,6 +446,10 @@ export function commitAssemblyReport(workspace, mutate, {
435
446
  staleReason = null,
436
447
  command = null,
437
448
  stage = null,
449
+ lockBudgetMs,
450
+ // lock: false skips the target lock entirely; only for callers that write
451
+ // nothing (the waiver dry-run preview). A real commit must take the lock.
452
+ lock = true,
438
453
  } = {}) {
439
454
  const hasRefresh = typeof refreshDoctor === "function";
440
455
  const hasStale = typeof staleReason === "string" && staleReason.trim();
@@ -453,6 +468,19 @@ export function commitAssemblyReport(workspace, mutate, {
453
468
  if (hasRefresh && !doctorOutPath) throw new TypeError("commitAssemblyReport requires a workspace with doctorOutPath to refresh the doctor sidecar.");
454
469
  if (hasStale && !targetRepo) throw new TypeError("commitAssemblyReport requires a workspace with targetRepo to stamp the doctor sidecar stale.");
455
470
 
471
+ const commit = () => commitAssemblyReportUnderLock(workspace, mutate, {
472
+ refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
473
+ });
474
+ if (!targetRepo || lock === false) return commit();
475
+ return withTargetLockSync(targetRepo, commit, {
476
+ command: command.trim(),
477
+ ...(lockBudgetMs === undefined ? {} : { budgetMs: lockBudgetMs }),
478
+ });
479
+ }
480
+
481
+ function commitAssemblyReportUnderLock(workspace, mutate, {
482
+ refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
483
+ }) {
456
484
  const outcome = { written: false, skipped: null, report: null, reportPath, doctorOutPath };
457
485
  const finish = () => {
458
486
  if (hasRefresh) {
@@ -0,0 +1,54 @@
1
+ // The per-target writer lock (#496, #501). prepare-build holds it from reading
2
+ // its inputs through publishing the packet, context and report; the stage
3
+ // writers (every commitAssemblyReport) hold it for their read-modify-write of
4
+ // the Assembly Report, so no stage evidence lands between prepare-build's
5
+ // pre-publish re-check and its rename. It lives beside the Design Source
6
+ // Package, inside the input directory prepare-build's writes already cover.
7
+ import { existsSync, mkdirSync } from "node:fs";
8
+ import { basename, dirname, join, resolve } from "node:path";
9
+ import { DESIGN_SOURCE_PACKAGE_REL_PATH } from "./design-source-package.mjs";
10
+ import { withDirectoryLock, withDirectoryLockSync } from "./directory-lock.mjs";
11
+
12
+ // Generous: a live holder is doing ordinary local work, and a holder that
13
+ // died is recovered by pid.
14
+ export const TARGET_LOCK_BUDGET_MS = 60000;
15
+
16
+ export function targetLockPath(targetRepo) {
17
+ const designSourcePackagePath = resolve(targetRepo, DESIGN_SOURCE_PACKAGE_REL_PATH);
18
+ return join(dirname(designSourcePackagePath), `.${basename(designSourcePackagePath)}.lock`);
19
+ }
20
+
21
+ // `command` names the waiting command. The lock does not record which command
22
+ // holds it, only a pid, so the holder is described generically. A lock with
23
+ // no owner record is called out on its own: it is never taken over, and the
24
+ // operator needs to know it will not clear by waiting.
25
+ function unavailable(targetRepo, lockPath, command) {
26
+ return (error) => {
27
+ if (error?.code !== "EEXIST") {
28
+ return new Error(`${command} could not take the target lock at ${lockPath}${error?.code ? ` (${error.code})` : ""}: ${error?.message}`, { cause: error });
29
+ }
30
+ if (existsSync(lockPath) && !existsSync(join(lockPath, "owner.json"))) {
31
+ return new Error(
32
+ `${command}: the target lock at ${lockPath} has no owner record, so it is never taken over automatically `
33
+ + "(an older campaigns-os release or an interrupted run left it). "
34
+ + `Confirm no campaigns-os process is working on ${targetRepo}, then remove that lock directory and retry.`,
35
+ );
36
+ }
37
+ return new Error(
38
+ `${command}: another campaigns-os command is writing ${targetRepo} (lock ${lockPath}). `
39
+ + "Retry after it finishes. If a run was interrupted, confirm no campaigns-os process is working on this target before removing that lock directory.",
40
+ );
41
+ };
42
+ }
43
+
44
+ export function withTargetLock(targetRepo, fn, { command = "campaigns-os", budgetMs = TARGET_LOCK_BUDGET_MS } = {}) {
45
+ const lockPath = targetLockPath(targetRepo);
46
+ mkdirSync(dirname(lockPath), { recursive: true });
47
+ return withDirectoryLock(lockPath, fn, { budgetMs, unavailable: unavailable(targetRepo, lockPath, command) });
48
+ }
49
+
50
+ export function withTargetLockSync(targetRepo, fn, { command = "campaigns-os", budgetMs = TARGET_LOCK_BUDGET_MS } = {}) {
51
+ const lockPath = targetLockPath(targetRepo);
52
+ mkdirSync(dirname(lockPath), { recursive: true });
53
+ return withDirectoryLockSync(lockPath, fn, { budgetMs, unavailable: unavailable(targetRepo, lockPath, command) });
54
+ }
@@ -327,6 +327,22 @@ export function paymentChromeAssetHashes(chrome, { label = "template brand contr
327
327
  return byBasename;
328
328
  }
329
329
 
330
+ // The starter templates' payment-logos.html partial renders one
331
+ // <img data-payment-logo="<method>"> per method and keeps it `hidden` until the
332
+ // campaign offers that method (server render, then payment-logos.js on
333
+ // next:initialized; payment_flags.show_<method>: true forces it visible). A
334
+ // hidden logo is the template gating the method, not residue, so both the
335
+ // static scan and browser QA drop those tags before matching. A visible one
336
+ // (forced on, or revealed at runtime) stays in and is judged like any chrome.
337
+ const PAYMENT_LOGO_IMG_TAG = /<img\b[^>]*\sdata-payment-logo\s*=[^>]*>/gi;
338
+ // Boolean attribute: present with any value (hidden, hidden="", hidden="true", …) means hidden.
339
+ const HIDDEN_ATTRIBUTE = /\shidden(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?(?=[\s/>])/i;
340
+
341
+ export function withoutHiddenPaymentLogos(html) {
342
+ const text = typeof html === "string" ? html : "";
343
+ return text.replace(PAYMENT_LOGO_IMG_TAG, (tag) => (HIDDEN_ATTRIBUTE.test(tag) ? "" : tag));
344
+ }
345
+
330
346
  // Pure, static: the markers in rendered checkout HTML that say a payment method
331
347
  // shipped. Three sources, in order of authority: the SDK-owned
332
348
  // data-next-payment-method attribute every starter-template payment-methods
@@ -338,7 +354,7 @@ export function paymentChromeAssetHashes(chrome, { label = "template brand contr
338
354
  // browser QA, which fetches them to attribute the mark; a static scan cannot
339
355
  // tell a paypal strip from a card-only one by its filename.
340
356
  export function paymentMethodMarkupMatches(html, method, chrome = null) {
341
- const text = typeof html === "string" ? html : "";
357
+ const text = withoutHiddenPaymentLogos(html);
342
358
  const canonical = String(method || "").toLowerCase().replace(/[\s-]+/g, "_");
343
359
  if (!canonical) return [];
344
360
  const matches = [];