@nextcommerce/campaigns-os 1.61.0 → 1.62.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 (127) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +9 -4
  5. package/agents/codex/AGENTS.md +8 -4
  6. package/agents/copilot/copilot-instructions.md +8 -4
  7. package/agents/cursor/campaigns-os.mdc +10 -4
  8. package/campaign-spec/README.md +7 -0
  9. package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
  10. package/campaign-spec/dist/analytics-vendors.js +44 -0
  11. package/campaign-spec/dist/checkout-flow.d.ts +18 -0
  12. package/campaign-spec/dist/checkout-flow.js +28 -0
  13. package/campaign-spec/dist/index.d.ts +4 -2
  14. package/campaign-spec/dist/index.js +5 -1
  15. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
  16. package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
  17. package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
  18. package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
  19. package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
  20. package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
  21. package/campaign-spec/dist/rules/index.d.ts +2 -0
  22. package/campaign-spec/dist/rules/index.js +6 -0
  23. package/campaign-spec/dist/types.d.ts +43 -9
  24. package/compatibility.json +1 -1
  25. package/contracts/commerce-surface-catalog.json +59 -53
  26. package/contracts/effects.v1.json +549 -165
  27. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  28. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  29. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  30. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  32. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  42. package/contracts/release-ledger.json +999 -0
  43. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  44. package/contracts/supported-surface.json +8 -8
  45. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  46. package/docs/build-packet.md +430 -37
  47. package/docs/campaign-standardization-report.md +4 -4
  48. package/docs/campaigns-os-build-flow.md +54 -3
  49. package/docs/campaignspec-authoring-examples.md +29 -4
  50. package/docs/design-source-package.md +18 -6
  51. package/docs/effects.md +17 -9
  52. package/docs/local-setup.md +1 -1
  53. package/docs/orientation-contract-reference.md +1 -1
  54. package/docs/polish-evidence.md +2 -0
  55. package/docs/progress-snapshots.md +7 -4
  56. package/docs/qa-and-test-orders.md +147 -1
  57. package/docs/readback.md +40 -5
  58. package/docs/runtime-readiness.md +6 -3
  59. package/docs/sdk-storage-compatibility.md +1 -1
  60. package/docs/skills-revision.md +10 -10
  61. package/package.json +1 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  64. package/schemas/campaign-spec.v4.schema.json +19 -3
  65. package/schemas/campaign-spec.v5.schema.json +19 -3
  66. package/schemas/campaigns-os-readback.v2.schema.json +62 -1
  67. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  68. package/skills/campaign-readback-classification/SKILL.md +3 -3
  69. package/skills/campaign-run-evidence/SKILL.md +3 -3
  70. package/skills/contribution-intake/SKILL.md +3 -3
  71. package/skills/next-campaigns-build/SKILL.md +4 -3
  72. package/skills/next-campaigns-os/SKILL.md +6 -4
  73. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  74. package/skills/next-campaigns-polish/SKILL.md +5 -4
  75. package/skills/next-campaigns-qa/SKILL.md +7 -4
  76. package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
  77. package/skills.json +11 -11
  78. package/src/brand-theme.mjs +2 -1
  79. package/src/build-brief.mjs +4 -1
  80. package/src/campaign-workspace.mjs +288 -15
  81. package/src/change-scope.mjs +284 -0
  82. package/src/cli.mjs +308 -86
  83. package/src/commercial-journey.mjs +16 -3
  84. package/src/commercial-parity.mjs +15 -7
  85. package/src/content-residue.mjs +4 -2
  86. package/src/design-source-package.mjs +36 -0
  87. package/src/design-source-publication.mjs +9 -6
  88. package/src/directory-lock.mjs +73 -19
  89. package/src/doctor/checks.mjs +79 -7
  90. package/src/doctor/ci.mjs +75 -10
  91. package/src/doctor/inspect.mjs +23 -6
  92. package/src/doctor/next-step.mjs +7 -0
  93. package/src/doctor-sidecar.mjs +3 -2
  94. package/src/evidence-debt.mjs +47 -7
  95. package/src/finding-cause.mjs +12 -11
  96. package/src/input-refresh.mjs +339 -32
  97. package/src/invocation.mjs +7 -1
  98. package/src/live-campaign-refs.mjs +7 -2
  99. package/src/local-preview-policy.mjs +35 -2
  100. package/src/page-kit-analytics-vendors.mjs +132 -0
  101. package/src/polish-browser.mjs +3 -1
  102. package/src/polish-readability.mjs +221 -9
  103. package/src/polish-visual.mjs +9 -2
  104. package/src/progress-node.mjs +3 -2
  105. package/src/qa-analytics-parity.mjs +17 -1
  106. package/src/qa-browser.mjs +40 -4
  107. package/src/qa-commercial-parity.mjs +50 -3
  108. package/src/qa-node.mjs +196 -16
  109. package/src/qa-publish.mjs +9 -8
  110. package/src/qa-sidecar.mjs +16 -4
  111. package/src/qa-vendor-beacons.mjs +232 -0
  112. package/src/qa-vendor-endpoints.mjs +306 -0
  113. package/src/qc-accept.mjs +29 -1
  114. package/src/qc-check-registry.mjs +5 -1
  115. package/src/qc-results.mjs +73 -26
  116. package/src/readback.mjs +58 -10
  117. package/src/route-identity.mjs +10 -0
  118. package/src/run-record.mjs +18 -10
  119. package/src/runtime-state-ignore.mjs +6 -2
  120. package/src/sdk-storage-compatibility.mjs +21 -2
  121. package/src/source-html-intake.mjs +15 -1
  122. package/src/spec-derive-campaign.mjs +216 -0
  123. package/src/spec-derive.mjs +473 -53
  124. package/src/spec-identity.mjs +14 -0
  125. package/src/stage-ledger.mjs +40 -1
  126. package/src/stage-record.mjs +58 -13
  127. package/src/target-lock.mjs +11 -1
@@ -109,7 +109,11 @@ function selectedShipping(page) {
109
109
  // `is_upsell: true` on a non-upsell page. Every consumer that must
110
110
  // tell a bump from a main selector row (parity scenarios, bump deltas, the QA
111
111
  // selector-tier planner, `next`'s order-bump QA command) reads this, so a
112
- // marker change lands in one place.
112
+ // marker change lands in one place. The page-type half of the contract
113
+ // (campaigns-os#710, stated on the schema's pagePackage.is_upsell) sits with
114
+ // the callers: on an upsell or downsell page every row is the page's offer,
115
+ // so planScenarios puts all of them in mainRows, and the tier and bump readers
116
+ // are only given the select, checkout_step and checkout pages of a path.
113
117
  export function isBumpRow(row) {
114
118
  return Boolean(row?.is_order_bump || row?.is_upsell);
115
119
  }
@@ -262,7 +266,9 @@ function descriptor(page, mapDoc, role, lines, options = {}) {
262
266
  const body = { lines: lines.map((line) => ({ ...line })) };
263
267
  if (array(options.vouchers).length) body.vouchers = [...options.vouchers];
264
268
 
265
- const currency = mapDoc?.campaign?.currency ?? mapDoc?.currency;
269
+ // A run currency (`qa run --currency`) prices every scenario in that
270
+ // currency; otherwise the Map's campaign currency is asked for.
271
+ const currency = present(options.currency) ? options.currency : mapDoc?.campaign?.currency ?? mapDoc?.currency;
266
272
  if (present(currency)) body.currency = String(currency);
267
273
  if (present(options.shipping_method)) body.shipping_method = options.shipping_method;
268
274
  return { ...result, body };
@@ -270,6 +276,8 @@ function descriptor(page, mapDoc, role, lines, options = {}) {
270
276
 
271
277
  /**
272
278
  * Convert one authored page into deterministic calculate request descriptors.
279
+ * `options.currency`, when given, is the currency every request asks for in
280
+ * place of the Map's campaign currency.
273
281
  */
274
282
  export function planScenarios(page, mapDoc = {}, options = {}) {
275
283
  const rows = array(page?.packages);
@@ -310,7 +318,12 @@ export function planScenarios(page, mapDoc = {}, options = {}) {
310
318
  !pageIsUpsell || !carriedVouchers.includes(String(value.offer_code)),
311
319
  );
312
320
 
313
- const shared = { attached_offers: attached, known_offer_codes: knownOfferCodes, page_rows: rows };
321
+ const shared = {
322
+ attached_offers: attached,
323
+ known_offer_codes: knownOfferCodes,
324
+ page_rows: rows,
325
+ ...(present(options.currency) ? { currency: String(options.currency) } : {}),
326
+ };
314
327
  const plan = [];
315
328
  const add = (value) => {
316
329
  if (plan.length >= maxScenarios) throw new CommercialJourneyLimitError("scenarios", maxScenarios);
@@ -714,10 +714,14 @@ function findingPage(capture, page) {
714
714
 
715
715
  /**
716
716
  * Pure, Exact-only diff. Unresolved, Stale, unbound, and malformed facts are
717
- * deliberately silent.
717
+ * deliberately silent. `options.unresolvedMoneyClaims`, a reason code, skips
718
+ * the price and recurrence comparisons: the captured amounts are in another
719
+ * currency than the calculated totals, so neither a match nor a mismatch
720
+ * means anything.
718
721
  */
719
- export function diffCommercialParity(capturesValue, journey) {
722
+ export function diffCommercialParity(capturesValue, journey, options = {}) {
720
723
  const captures = partitionCaptures(capturesValue).valid;
724
+ const compareMoney = !present(options.unresolvedMoneyClaims);
721
725
  const findings = [];
722
726
 
723
727
  captures.forEach((capture) => {
@@ -725,7 +729,7 @@ export function diffCommercialParity(capturesValue, journey) {
725
729
  if (!page) return;
726
730
  const pageId = findingPage(capture, page);
727
731
  const source = present(capture.url) ? { url: String(capture.url) } : {};
728
- const representative = exactMoneyFact(page.representative_total);
732
+ const representative = compareMoney ? exactMoneyFact(page.representative_total) : null;
729
733
  if (representative) {
730
734
  priceComparisonClaims(capture, page).compared.forEach((claim) => {
731
735
  const claimed = normalizeMoney(claim?.value);
@@ -741,7 +745,7 @@ export function diffCommercialParity(capturesValue, journey) {
741
745
  });
742
746
  }
743
747
 
744
- recurrenceComparisonClaims(capture, page).compared.forEach(({ claim, truth }) => {
748
+ (compareMoney ? recurrenceComparisonClaims(capture, page).compared : []).forEach(({ claim, truth }) => {
745
749
  const packageId = positiveInteger(claim?.package_id);
746
750
  const claimed = {
747
751
  amount: normalizeMoney(claim?.amount),
@@ -929,8 +933,9 @@ export function recurringClaimAbsentAssertions(absences, { maxAssertions = Numbe
929
933
  */
930
934
  export function createCommercialParityReport(capturesValue, journey, options = {}) {
931
935
  const countsOnly = options.countsOnly === true;
936
+ const moneyReason = present(options.unresolvedMoneyClaims) ? String(options.unresolvedMoneyClaims) : null;
932
937
  const { valid: captures, invalid: invalidCaptures } = partitionCaptures(capturesValue, { countsOnly });
933
- const findings = countsOnly ? [] : diffCommercialParity(captures, journey);
938
+ const findings = countsOnly ? [] : diffCommercialParity(captures, journey, { unresolvedMoneyClaims: moneyReason });
934
939
  const { assertions, omittedFindingCount } = countsOnly
935
940
  ? { assertions: [], omittedFindingCount: 0 }
936
941
  : serializeCommercialFindings(findings, options);
@@ -958,7 +963,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
958
963
  (sum, { capture }) => sum + capture.vouchers.length,
959
964
  0,
960
965
  );
961
- const priceComparisonCounts = countsOnly
966
+ const priceComparisonCounts = countsOnly || moneyReason
962
967
  ? { compared: 0, unresolved: extractedPriceClaims }
963
968
  : matched.reduce((counts, { capture, page }) => {
964
969
  const comparison = priceComparisonClaims(capture, page);
@@ -966,7 +971,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
966
971
  counts.unresolved += comparison.unresolved;
967
972
  return counts;
968
973
  }, { compared: 0, unresolved: 0 });
969
- const recurrenceComparisonCounts = countsOnly
974
+ const recurrenceComparisonCounts = countsOnly || moneyReason
970
975
  ? { compared: 0, unresolved: extractedRecurrenceClaims }
971
976
  : matched.reduce((counts, { capture, page }) => {
972
977
  const comparison = recurrenceComparisonClaims(capture, page);
@@ -1008,6 +1013,9 @@ export function createCommercialParityReport(capturesValue, journey, options = {
1008
1013
  extracted_voucher_claims: extractedVoucherClaims,
1009
1014
  compared_voucher_claims: voucherComparisonCounts.compared,
1010
1015
  unresolved_voucher_claims: voucherComparisonCounts.unresolved,
1016
+ ...(moneyReason && extractedPriceClaims + extractedRecurrenceClaims > 0
1017
+ ? { unresolved_money_claims_reason: moneyReason }
1018
+ : {}),
1011
1019
  serialized_assertion_count: assertions.length,
1012
1020
  omitted_assertion_count: omittedFindingCount,
1013
1021
  findings,
@@ -189,9 +189,11 @@ export function scanBuiltOutputContentResidue(outputDir, { urgencyVerified = fal
189
189
  // seeds non-usable candidates to test exactly that.
190
190
  export const BRIEF_PAYLOAD_REL_PATH = ".campaign-runtime/input/brief-payload.json";
191
191
 
192
- export function loadBriefPayload(targetRepo) {
192
+ // `runtimeRelPath` is the packet's runtime directory (packetRuntimeRelPath in
193
+ // campaign-workspace.mjs), so a project's second funnel reads its own payload.
194
+ export function loadBriefPayload(targetRepo, runtimeRelPath = ".campaign-runtime") {
193
195
  if (!targetRepo) return null;
194
- const path = join(targetRepo, BRIEF_PAYLOAD_REL_PATH);
196
+ const path = join(targetRepo, runtimeRelPath, relative(".campaign-runtime", BRIEF_PAYLOAD_REL_PATH));
195
197
  if (!existsSync(path)) return null;
196
198
  try {
197
199
  return { path, payload: JSON.parse(readFileSync(path, "utf8")) };
@@ -930,6 +930,42 @@ export function designSourcePackageMaterialProjection(value) {
930
930
  };
931
931
  }
932
932
 
933
+ /**
934
+ * The package with each kept page's Surface Identity re-stated from the
935
+ * current CampaignSpec page and its re-projected page-kit target (#531):
936
+ * label, aliases and mappings come out as intake would write them for that
937
+ * page now, the surface keeps its id, and the source fields intake recorded
938
+ * (source_path, producer_page_type) are carried over. Surfaces of pages no
939
+ * longer active, and contributions, gaps and the rest, are untouched; the
940
+ * material fingerprint is recomputed. `projections` maps a page id to its
941
+ * page-kit projection; a page with none keeps the page_kit it had.
942
+ */
943
+ export function rebindDesignSourcePackagePages(value, { activePages = [], projections = new Map() } = {}) {
944
+ const next = JSON.parse(JSON.stringify(value));
945
+ const pageById = new Map(arrayOrEmpty(activePages).filter((page) => isObject(page) && isNonEmptyString(page.id)).map((page) => [page.id, page]));
946
+ for (const surface of arrayOrEmpty(next.surface_identity)) {
947
+ if (!isObject(surface) || surface.kind !== "page" || !isObject(surface.mappings)) continue;
948
+ const pageId = optionalString(surface.mappings.campaign_spec_page_id);
949
+ if (!pageId || !pageById.has(pageId)) continue;
950
+ const projection = projections instanceof Map ? projections.get(pageId) : null;
951
+ const mapping = optionalString(surface.mappings.source_page_id)
952
+ ? {
953
+ page_id: pageId,
954
+ path: surface.mappings.source_path ?? null,
955
+ page_type: surface.mappings.producer_page_type ?? null,
956
+ page_kit: isObject(surface.mappings.page_kit) && isObject(projection) ? projection : surface.mappings.page_kit ?? null,
957
+ }
958
+ : null;
959
+ const expected = createSurfaceCatalog(collectPageInputs([pageById.get(pageId)], mapping ? [mapping] : []), {}).surfaces.find((entry) => entry.kind === "page");
960
+ if (!expected) continue;
961
+ surface.label = expected.label;
962
+ surface.aliases = expected.aliases;
963
+ surface.mappings = expected.mappings;
964
+ }
965
+ next.material_fingerprint = computeDesignSourcePackageMaterialFingerprint(next);
966
+ return next;
967
+ }
968
+
933
969
  export function computeDesignSourcePackageMaterialFingerprint(value) {
934
970
  const canonical = canonicalJson(designSourcePackageMaterialProjection(value));
935
971
  return `${MATERIAL_FINGERPRINT_PREFIX}${createHash("sha256").update(canonical).digest("hex")}`;
@@ -413,7 +413,7 @@ function assertValidPreparedDesignSourcePackage(value, path, currentPageScope, c
413
413
  // hash if the run adopts another writer's package instead of publishing, and
414
414
  // is removed once a report has recorded the package. A retry that finds it
415
415
  // still accepts only bytes that hash to one of its entries.
416
- function readPriorDesignSourceProvenance(reportPath, packagePath) {
416
+ export function readPriorDesignSourceProvenance(reportPath, packagePath) {
417
417
  const synthesized = [];
418
418
  const pending = readJsonIfExistsQuietly(pendingDesignSourceProvenancePath(packagePath));
419
419
  // All or nothing: a record with any entry that is not a digest (a torn or
@@ -452,7 +452,7 @@ function pendingDesignSourceProvenancePath(designSourcePackagePath) {
452
452
  // Written (staged, then renamed into place) before a synthesized package is
453
453
  // published, so the claim survives a crash between that publication and the
454
454
  // report's. An empty list removes the record.
455
- function recordPendingDesignSourceProvenance(designSourcePackagePath, sha256) {
455
+ export function recordPendingDesignSourceProvenance(designSourcePackagePath, sha256) {
456
456
  const pendingPath = pendingDesignSourceProvenancePath(designSourcePackagePath);
457
457
  if (!sha256.length) {
458
458
  rmSync(pendingPath, { force: true });
@@ -852,9 +852,12 @@ const PUBLICATION_STEPS = ["package", "writeTheme", "publish", "publishDoctorOut
852
852
 
853
853
  // Runs `fn(publication)` inside the target lock; the handle's steps run in
854
854
  // PUBLICATION_STEPS order, and `package` and `publish` run once.
855
- export async function withDesignSourcePublication({ targetRepo, outputs, force = false, publishSpec = null }, fn) {
855
+ // `runtimeDir` is the funnel's runtime directory (src/campaign-workspace.mjs):
856
+ // the Design Source Package and the theme output go under it. The target lock
857
+ // stays project-wide, so funnels of one project still take turns.
858
+ export async function withDesignSourcePublication({ targetRepo, runtimeDir = resolve(targetRepo, ".campaign-runtime"), outputs, force = false, publishSpec = null }, fn) {
856
859
  const { packetPath, contextPath, reportPath, doctorOutPath, briefPath } = outputs;
857
- const designSourcePackagePath = resolve(targetRepo, DESIGN_SOURCE_PACKAGE_REL_PATH);
860
+ const designSourcePackagePath = resolve(runtimeDir, relative(".campaign-runtime", DESIGN_SOURCE_PACKAGE_REL_PATH));
858
861
  const paths = Object.freeze({ packetPath, contextPath, reportPath, doctorOutPath, briefPath, designSourcePackagePath });
859
862
  const prepareBuildOutputPaths = [
860
863
  ["Build Packet", packetPath],
@@ -864,8 +867,8 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
864
867
  ["Campaign Build Brief", briefPath],
865
868
  ];
866
869
  const prepareBuildThemeOutputPaths = [
867
- ["Theme Report", resolve(targetRepo, ".campaign-runtime/theme/theme-report.json")],
868
- ["Brand Theme CSS", resolve(targetRepo, ".campaign-runtime/theme/brand-theme.css")],
870
+ ["Theme Report", resolve(runtimeDir, "theme/theme-report.json")],
871
+ ["Brand Theme CSS", resolve(runtimeDir, "theme/brand-theme.css")],
869
872
  ];
870
873
  const prepareBuildCollisionPaths = [
871
874
  ...prepareBuildOutputPaths,
@@ -17,14 +17,19 @@
17
17
  // record is never taken over: this module cannot produce one, so it belongs
18
18
  // to an older writer that may still be alive between its mkdir and its owner
19
19
  // write (#501). A waiter refuses it after a short grace, leaving it for the
20
- // documented offline procedure. (See publishStagedDirectory for the one
21
- // mixed-version race this cannot close, tracked in #514.) Anything else at the
22
- // lock path, a file or a directory holding other entries, is not a lock at
23
- // all: it is refused by name and never removed (#514).
20
+ // documented offline procedure. Anything else at the lock path, a file, a
21
+ // directory this process cannot read or a directory holding other entries, is
22
+ // not a lock at all: it is refused by name and never removed (#514).
24
23
  //
25
- // Staging and released siblings a dead process left beside the lock are swept
26
- // by the next holder, under the lock; a sibling whose owner is alive or
27
- // unreadable is left alone (#514).
24
+ // Every owner record this module writes carries LOCK_FORMAT. A lock held by a
25
+ // live process whose record has no lock format, or a different one, belongs
26
+ // to another release, and a waiter refuses it at once instead of waiting to
27
+ // race that release for the next acquisition. That narrows the mixed-version
28
+ // race publishStagedDirectory describes; it cannot close it (#514).
29
+ //
30
+ // Staging, recovery, released and abandoned siblings a dead process left
31
+ // beside the lock are swept by the next holder, under the lock; a sibling
32
+ // whose owner is alive or unreadable is left alone (#514).
28
33
  //
29
34
  // The lock is reentrant for its holder: code running inside `fn` (in the
30
35
  // same async context) that asks for the same lock enters directly instead of
@@ -37,6 +42,11 @@ import { basename, dirname, join, resolve } from "node:path";
37
42
 
38
43
  const heldLocks = new AsyncLocalStorage();
39
44
 
45
+ // The lock format this release records in every owner record it writes.
46
+ // Records without one come from earlier releases, including those before
47
+ // 1.43.2 that acquired with mkdir and wrote their owner afterwards (#501).
48
+ export const LOCK_FORMAT = 2;
49
+
40
50
  const readOwner = (path) => {
41
51
  try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
42
52
  };
@@ -56,9 +66,11 @@ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
66
 
57
67
  // The siblings this module creates beside a lock and normally removes itself:
58
68
  // a crash leaves staging and recovery-staging ones, a release whose tomb could
59
- // not be put back leaves a released one. Each carries an owner.json.
69
+ // not be put back leaves a released one, and a recovery whose removal of the
70
+ // abandoned lock was cut short leaves an abandoned one. Each carries an
71
+ // owner.json; an abandoned one carries the dead holder's.
60
72
  function siblingPattern(path) {
61
- return new RegExp(`^${escapeRegExp(basename(path))}\\.(?:staging|recovery-staging|released)-[0-9a-f]{32}$`);
73
+ return new RegExp(`^${escapeRegExp(basename(path))}\\.(?:staging|recovery-staging|released|abandoned)-[0-9a-f]{32}$`);
62
74
  }
63
75
 
64
76
  // Called only by the holder of the lock, so no two sweeps race, and a
@@ -85,6 +97,9 @@ function sweepDeadSiblings(path) {
85
97
  // What a lock directory may hold besides its owner record: the recovery
86
98
  // claim. A directory without owner.json that holds anything else is not a lock.
87
99
  const LOCK_ENTRIES = new Set(["owner.json", ".recovery"]);
100
+ // A directory at the lock path that answers these cannot be told apart from
101
+ // a lock whose owner record is merely hidden from this process.
102
+ const UNREADABLE_CODES = new Set(["EACCES", "EPERM"]);
88
103
 
89
104
  // The reentrancy key names the lock directory through its real parent, so a
90
105
  // holder that reached the target through a symlink still recognizes itself.
@@ -119,10 +134,13 @@ function publishStagedDirectory(stagingPath, dest) {
119
134
  try {
120
135
  // rename(2) replaces an EMPTY destination directory, so never rename over
121
136
  // an existing entry. This module never leaves an empty directory at a
122
- // lock path; only an older release does, for the instant between its
123
- // mkdir and its owner write. A new writer's check-then-rename can land in
124
- // that instant and replace it, so running an older release and this one
125
- // on the same target at the same moment is not safe (#501; tracked in #514).
137
+ // lock path; only a release before #501 does, for the instant between its
138
+ // mkdir and its owner write. A writer's check-then-rename can land in that
139
+ // instant and replace it, and Node has no rename that refuses to replace.
140
+ // The waiter's lock-format check (otherRelease) refuses such a release
141
+ // whenever it is seen holding the lock, but two first attempts that meet
142
+ // in that instant can still both enter, so running a release before #501
143
+ // and this one on the same target at the same moment stays unsafe (#514).
126
144
  if (exists(dest)) return false;
127
145
  try {
128
146
  renameSync(stagingPath, dest);
@@ -145,7 +163,7 @@ const OWNERLESS_GRACE_MS = 1000;
145
163
  function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGraceMs = OWNERLESS_GRACE_MS }) {
146
164
  const token = randomBytes(16).toString("hex");
147
165
  const start = now();
148
- const owner = { pid: process.pid, token };
166
+ const owner = { pid: process.pid, token, lock_format: LOCK_FORMAT };
149
167
  const ownerPath = join(path, "owner.json");
150
168
  const stagingPath = `${path}.staging-${token}`;
151
169
 
@@ -224,17 +242,49 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
224
242
  return current - start >= budgetMs;
225
243
  };
226
244
  const fail = (error) => unavailable(error);
245
+ // The refusal for a lock held by a live process of another release, else
246
+ // null. A record without this release's lock format was written by another
247
+ // release: waiting for it to release would put this waiter's rename in a
248
+ // race with that release's next mkdir. A dead holder is recovered as usual,
249
+ // and a record naming this process was written by this release.
250
+ const otherRelease = () => {
251
+ try {
252
+ const stat = lstatSync(path);
253
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
254
+ } catch {
255
+ return null;
256
+ }
257
+ const holder = readOwner(ownerPath);
258
+ if (!(Number.isInteger(holder?.pid) && holder.pid > 0 && typeof holder.token === "string")) return null;
259
+ if (holder.lock_format === LOCK_FORMAT || holder.pid === process.pid || ownerIsDead(holder)) return null;
260
+ const format = holder.lock_format === undefined ? "no lock format" : `lock format ${JSON.stringify(holder.lock_format)}`;
261
+ return Object.assign(
262
+ new Error(`Lock is held by process ${holder.pid} of another campaigns-os release (${format}; this release writes lock format ${LOCK_FORMAT}): ${path}`),
263
+ { code: "ELOCKFORMAT", holderPid: holder.pid, lockFormat: holder.lock_format ?? null },
264
+ );
265
+ };
227
266
  // Names what occupies the lock path when it is not a lock, else null. A
228
- // file there, or a directory with no owner record that holds entries a lock
229
- // never has, will not clear by waiting and is never removed here.
267
+ // file there, a directory this process cannot read, or a directory with no
268
+ // owner record that holds entries a lock never has, will not clear by
269
+ // waiting and is never removed here.
230
270
  const obstruction = () => {
231
271
  let stat;
232
272
  try { stat = lstatSync(path); } catch { return null; }
233
273
  if (stat.isSymbolicLink()) return "a symbolic link, not a lock";
234
274
  if (!stat.isDirectory()) return stat.isFile() ? "a regular file, not a lock" : "a special file, not a lock";
235
- if (exists(ownerPath)) return null;
275
+ const unreadable = (error) => `a directory this process cannot read (${error.code}), so it cannot be checked for an owner record`;
276
+ try {
277
+ lstatSync(ownerPath);
278
+ return null;
279
+ } catch (error) {
280
+ if (UNREADABLE_CODES.has(error?.code)) return unreadable(error);
281
+ }
236
282
  let foreign;
237
- try { foreign = readdirSync(path).filter((entry) => !LOCK_ENTRIES.has(entry)).sort(); } catch { return null; }
283
+ try {
284
+ foreign = readdirSync(path).filter((entry) => !LOCK_ENTRIES.has(entry)).sort();
285
+ } catch (error) {
286
+ return UNREADABLE_CODES.has(error?.code) ? unreadable(error) : null;
287
+ }
238
288
  if (foreign.length === 0) return null;
239
289
  const shown = foreign.slice(0, 3).join(", ") + (foreign.length > 3 ? `, and ${foreign.length - 3} more` : "");
240
290
  return `a directory that is not a lock (no owner.json; it holds ${shown})`;
@@ -263,7 +313,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
263
313
  }
264
314
  };
265
315
 
266
- return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, sweep, release };
316
+ return { token, stage, publish, recover, heldBySelfProcess, otherRelease, expired, fail, contended, sweep, release };
267
317
  }
268
318
 
269
319
  function reentrantKey(path) {
@@ -297,6 +347,8 @@ export async function withDirectoryLock(path, fn, options) {
297
347
  throw lock.fail(error);
298
348
  }
299
349
  if (acquired) break;
350
+ const mixed = lock.otherRelease();
351
+ if (mixed) throw lock.fail(mixed);
300
352
  if (lock.expired()) throw lock.fail(lock.contended());
301
353
  lock.recover();
302
354
  await sleep(20);
@@ -329,6 +381,8 @@ export function withDirectoryLockSync(path, fn, options) {
329
381
  throw lock.fail(error);
330
382
  }
331
383
  if (acquired) break;
384
+ const mixed = lock.otherRelease();
385
+ if (mixed) throw lock.fail(mixed);
332
386
  if (lock.expired() || lock.heldBySelfProcess()) throw lock.fail(lock.contended());
333
387
  lock.recover();
334
388
  (options.sleep ?? sleepSync)(20);
@@ -14,6 +14,7 @@ import {
14
14
  } from "../adapter-decision-contract.mjs";
15
15
  import { createDoctorCheckRegistry, runDoctorCheckRegistry } from "../doctor-check-registry.mjs";
16
16
  import { evaluateSourcePreparation } from "../source-prep.mjs";
17
+ import { staleRoutingHints } from "../spec-derive.mjs";
17
18
  import { isLoopbackHostname } from "../remit.mjs";
18
19
  import {
19
20
  HOST_STRIPPED_CODE,
@@ -26,6 +27,7 @@ import {
26
27
  SOURCE_HTML_MANIFEST_SCHEMA,
27
28
  } from "../source-html-manifest.mjs";
28
29
  import { validateAssemblyReportThemeBlock, validateThemeContextBlock } from "../brand-theme.mjs";
30
+ import { packetRuntimeRelPath } from "../campaign-workspace.mjs";
29
31
  import { singleLineDetail, singleLineField } from "../text-safety.mjs";
30
32
  import {
31
33
  campaignRouteRoot,
@@ -34,6 +36,7 @@ import {
34
36
  normalizePublicRouteSlug,
35
37
  packetRouteRoot,
36
38
  runtimeRelativeRouteForSpecValue,
39
+ specPublicRouteSlug,
37
40
  stripPublicRoutePrefix,
38
41
  } from "../route-identity.mjs";
39
42
  import { evaluatePageKitBuildSummary } from "../page-kit-build-summary.mjs";
@@ -102,6 +105,7 @@ import {
102
105
  import { loadPageKitCampaignEntry, pageKitIgnoresAssetsHtml, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
103
106
  import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
104
107
  import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
108
+ import { evaluatePageKitAnalyticsVendors, PAGE_KIT_ANALYTICS_VENDORS_SCOPE } from "../page-kit-analytics-vendors.mjs";
105
109
  // ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
106
110
  // campaign authoring UI (e.g. a Map Builder bundle) import the same rules, so a
107
111
  // spec check is authored once and reaches internal teams and agencies alike.
@@ -382,6 +386,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
382
386
  phase: "target",
383
387
  run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetStoreProfile(spec, errors, warnings, ready, derived, buildState),
384
388
  },
389
+ {
390
+ id: PAGE_KIT_ANALYTICS_VENDORS_SCOPE,
391
+ phase: "target",
392
+ run: ({ spec, warnings, ready, buildState }) => validateTargetAnalyticsVendors(spec, warnings, ready, buildState),
393
+ },
385
394
  {
386
395
  id: "spec.shipping_countries",
387
396
  phase: "spec",
@@ -392,6 +401,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
392
401
  phase: "spec",
393
402
  run: ({ spec, packet, warnings, ready, derived, buildState }) => validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived, buildState),
394
403
  },
404
+ {
405
+ id: "spec.routing_hint_stale",
406
+ phase: "spec",
407
+ run: ({ spec, packet, errors, ready }) => validateSpecRoutingHintsCurrent(spec, packet, errors, ready),
408
+ },
395
409
  {
396
410
  id: "spec.routing_host_prefix",
397
411
  phase: "spec",
@@ -1416,6 +1430,18 @@ function validateTargetStoreProfile(spec, errors, warnings, ready, derived, buil
1416
1430
  + storeProfileIntentionallyEmptyNote(intentionallyEmpty));
1417
1431
  }
1418
1432
 
1433
+ // The starter-template vendor keys against analytics.vendors: advisory only,
1434
+ // one warning per vendor the two sides disagree on (#689).
1435
+ function validateTargetAnalyticsVendors(spec, warnings, ready, buildState) {
1436
+ const result = evaluatePageKitAnalyticsVendors({ spec, targetLoad: buildState?.pageKitCampaignConfig });
1437
+ for (const finding of result.findings) {
1438
+ addIssue(warnings, finding.code, finding.message, { vendor: finding.vendor, id_key: finding.id_key, repair: finding.repair });
1439
+ }
1440
+ if (result.status === "ok" && result.vendors.length) {
1441
+ ready.push(`Target campaigns.json vendor keys match the CampaignSpec analytics.vendors (${result.vendors.join(", ")}).`);
1442
+ }
1443
+ }
1444
+
1419
1445
  function validateSpecShippingCountries(spec, warnings, ready) {
1420
1446
  const countries = spec?.campaign?.available_shipping_countries;
1421
1447
  if (countries === "all" || (Array.isArray(countries) && countries.length === 0)) {
@@ -1557,11 +1583,7 @@ export function validateRouteSlugIdentity(spec, packet, errors, ready) {
1557
1583
  const mapId = optionalString(spec?.spec_identity?.map_id)
1558
1584
  || optionalString(spec?.map_id)
1559
1585
  || optionalString(packet?.spec?.map_id);
1560
- const specSlug = normalizePublicRouteSlug(
1561
- optionalString(spec?.spec_identity?.public_route_slug)
1562
- || optionalString(spec?.campaign?.slug)
1563
- || optionalString(spec?.campaign?.id)
1564
- );
1586
+ const specSlug = specPublicRouteSlug(spec);
1565
1587
  const specDeclaresMapIdSlug = Boolean(specSlug) && Boolean(mapId) && specSlug === mapId;
1566
1588
 
1567
1589
  if (mapId && packetSlug === mapId && !specDeclaresMapIdSlug) {
@@ -1692,6 +1714,31 @@ export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, deriv
1692
1714
  );
1693
1715
  }
1694
1716
 
1717
+ // #531: a routing hint (`sdk_hints.meta_tags` next-success-url and the upsell
1718
+ // accept/decline urls) that does not match the route of the page its routing
1719
+ // field names. Nothing else catches it once the build exists: the built meta
1720
+ // check compares the built page against the hint, and QA derives its
1721
+ // expectation from the hint, so a moved upsell with checkout still pointing
1722
+ // at the old path passes both and sends shoppers to a 404 after payment. It
1723
+ // blocks on every read, before and after the build, and record spec refuses a
1724
+ // routes-only bind on the same function (spec-derive.mjs staleRoutingHints).
1725
+ export function validateSpecRoutingHintsCurrent(spec, packet, errors, ready) {
1726
+ const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
1727
+ const stale = staleRoutingHints(spec, { publicRouteSlug });
1728
+ if (!stale.length) {
1729
+ if (activeSpecPages(spec).some((page) => isObject(page.sdk_hints?.meta_tags))) ready.push("CampaignSpec routing hints match the routes of the pages they name");
1730
+ return;
1731
+ }
1732
+ const sample = stale.slice(0, 5).map((hint) => `${hint.page_id}:${hint.tag} ${JSON.stringify(hint.value)} but page "${hint.target_page_id}" routes to ${JSON.stringify(hint.derived_route)}`).join("; ");
1733
+ const more = stale.length > 5 ? `; plus ${stale.length - 5} more` : "";
1734
+ addIssue(
1735
+ errors,
1736
+ "spec.routing_hint_stale",
1737
+ `CampaignSpec routing hint(s) name a route the page they point at no longer has: ${sample}${more}. The built meta tag follows the hint, so the SDK would send shoppers to the old path. Edit each hint to the route shown in the spec (or, while the Map is still the spec's source, re-save the Map, which regenerates them), then run record spec.`,
1738
+ { hints: stale },
1739
+ );
1740
+ }
1741
+
1695
1742
  // #531: a route with a host in front of its path ("shop.example.com/route/x/")
1696
1743
  // is nested under the campaign root by every stage that composes a URL
1697
1744
  // (polish capture requested "/route/shop.example.com/route/x/"), so it blocks.
@@ -3264,6 +3311,12 @@ function scanMarketSensitiveCopy(spec, warnings, ready, derived) {
3264
3311
  }
3265
3312
  }
3266
3313
 
3314
+ // CampaignSpec owns `campaign.available_currencies`: every currency the
3315
+ // campaign sells in, the default included (the CampaignCurrencies rule checks
3316
+ // its codes). `additional_currencies` is the Campaigns App API's name for the
3317
+ // same list without the default and is not a spec field; both market scopes
3318
+ // still read it, and its camelCase spelling, so a spec copied from an API body
3319
+ // gets the copy review it needs.
3267
3320
  function deriveMarketScope(spec) {
3268
3321
  const campaign = spec?.campaign || {};
3269
3322
  const defaultCurrency = normalizeCurrency(campaign.currency);
@@ -3712,6 +3765,25 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3712
3765
  if (!activeIds.has(page.page_id)) {
3713
3766
  addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
3714
3767
  }
3768
+ // A page added from the repo's page tree after handoff (#721): its source
3769
+ // is the target page file itself, so it is mapped and, once that file
3770
+ // exists, a build candidate like any other.
3771
+ if (page.origin === "repo") {
3772
+ const repoFile = optionalString(page.page_kit?.output_path) && derived.target_repo ? resolve(derived.target_repo, page.page_kit.output_path) : null;
3773
+ if (!repoFile || !existsSync(repoFile) || !statSync(repoFile).isFile()) {
3774
+ addIssue(errors, "source_html.pages.repo_page_missing", `Page "${page.page_id}" was added from the repo's page tree, but its page file ${page.page_kit?.output_path || "(no page_kit.output_path)"} is not in the target repo. Restore the file, or run spec derive and record spec to remove the page.`, { page_id: page.page_id, output_path: page.page_kit?.output_path || null });
3775
+ } else if (specPage) {
3776
+ builtPages.push({
3777
+ page_id: specPage.id,
3778
+ type: specPage.type || "page",
3779
+ role: pageRole(specPage.type),
3780
+ route: builtRoute,
3781
+ source_path: null,
3782
+ repo_source: page.page_kit.output_path,
3783
+ });
3784
+ }
3785
+ continue;
3786
+ }
3715
3787
  if (page.path) {
3716
3788
  const fullPath = resolve(sourceRoot, page.path);
3717
3789
  if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
@@ -4625,7 +4697,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
4625
4697
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
4626
4698
  const siteRoot = derived.target_repo && publicRouteSlug ? join(derived.target_repo, "_site", publicRouteSlug) : null;
4627
4699
  if (!siteRoot || !existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) return;
4628
- const brief = loadBriefPayload(derived.target_repo);
4700
+ const brief = loadBriefPayload(derived.target_repo, packetRuntimeRelPath(packet));
4629
4701
  const urgencyVerified = briefUrgencyVerified(brief?.payload);
4630
4702
  const findings = scanBuiltOutputContentResidue(siteRoot, { urgencyVerified });
4631
4703
  if (!findings.length) {
@@ -4694,7 +4766,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
4694
4766
  // non-shipped, non-usable assets stay warnings (the attestation queue), so a
4695
4767
  // producer that correctly excluded them is not blocked.
4696
4768
  export function validateProofAttestation(packet, errors, warnings, ready, derived, buildState = {}) {
4697
- const brief = loadBriefPayload(derived.target_repo);
4769
+ const brief = loadBriefPayload(derived.target_repo, packetRuntimeRelPath(packet));
4698
4770
  if (!brief) return; // not an AI-assembled run — no brief payload, no gate
4699
4771
  if (brief.error) {
4700
4772
  // Fail closed: the brief payload is the source of attestation state. If