@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
@@ -30,6 +30,19 @@ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
30
30
  ...(entry?.kind === "terminal" ? [entry.terminal] : []),
31
31
  ...terminalPaths.map((candidate) => candidate.terminal),
32
32
  ]);
33
+ // Every declared offer page with the offer page each action leads to, so a
34
+ // planned path that stops short of a terminal can still be walked to the
35
+ // controls it clicks.
36
+ const offerEdges = [];
37
+ for (const [key, page] of pagesByUrl) {
38
+ if (!OFFER_PAGE_TYPES.has(pageType(page))) continue;
39
+ const edge = { key, page_id: page.page_id || null, page_type: page.page_type || null, url: page.url };
40
+ for (const [action, field] of ACTIONS) {
41
+ const target = targetNode(page?.[field], pagesByUrl, topologyOrigin);
42
+ edge[`${action}_key`] = target?.kind === "offer" ? canonicalHttpUrl(target.page.url) : null;
43
+ }
44
+ offerEdges.push(edge);
45
+ }
33
46
 
34
47
  return {
35
48
  topology_id: topology?.funnel_id || "default",
@@ -48,6 +61,8 @@ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
48
61
  terminal_paths: terminalPaths,
49
62
  invalid_paths: invalidPaths,
50
63
  recognized_terminals: recognizedTerminals,
64
+ entry_offer_key: entry?.kind === "offer" ? canonicalHttpUrl(entry.page.url) : null,
65
+ offer_edges: offerEdges,
51
66
  };
52
67
  }
53
68
 
@@ -95,6 +110,139 @@ export function commonTestOrderPaths(resolvedTopology) {
95
110
  return paths;
96
111
  }
97
112
 
113
+ // The default `common` depth (#530). The sample above never reached a
114
+ // downsell's decline, so a broken decline link passed QA. When every actual
115
+ // terminal path fits under the cap, `common` runs them all. Above the cap it
116
+ // keeps the sample and adds, for each offer page whose decline no planned path
117
+ // clicks yet, the shortest path that clicks it, until the cap is reached. A
118
+ // page counts as covered only when its decline is clicked: arriving at it, or
119
+ // clicking its accept, does not. Pages still uncovered are returned, not
120
+ // dropped. The sample is never trimmed, so a cap below it is still refused by
121
+ // the flood guard exactly as before.
122
+ export function commonTestOrderPlan(resolvedTopology, { cap } = {}) {
123
+ const baseline = commonTestOrderPaths(resolvedTopology);
124
+ let full = null;
125
+ try {
126
+ full = fullTestOrderPaths(resolvedTopology);
127
+ } catch {
128
+ full = null;
129
+ }
130
+ const plan = {
131
+ requested_depth: "common",
132
+ cap,
133
+ full_path_count: full ? full.length : null,
134
+ baseline_paths: baseline,
135
+ };
136
+ if (full && full.length <= cap) {
137
+ // Same set as `full`; the sample's paths keep their place at the front.
138
+ const paths = [...baseline.filter((path) => full.includes(path)), ...full.filter((path) => !baseline.includes(path))];
139
+ return { ...plan, effective_depth: "full", reason: "under_cap", paths, coverage_paths: [], uncovered_pages: [] };
140
+ }
141
+
142
+ const paths = baseline.slice();
143
+ const coveragePaths = [];
144
+ const offers = offerPagesByReach(resolvedTopology);
145
+ for (const offer of offers) {
146
+ if (paths.length >= cap) break;
147
+ const declined = declinedOfferKeys(resolvedTopology, paths);
148
+ if (declined.has(offer.key) || !offer.reach) continue;
149
+ const candidate = shortestDeclinePath(resolvedTopology, offer, declined);
150
+ if (!candidate || paths.includes(candidate)) continue;
151
+ paths.push(candidate);
152
+ coveragePaths.push(candidate);
153
+ }
154
+ const declined = declinedOfferKeys(resolvedTopology, paths);
155
+ const uncovered = offers
156
+ .filter((offer) => !declined.has(offer.key))
157
+ .map((offer) => ({
158
+ page_id: offer.page_id,
159
+ page_type: offer.page_type,
160
+ reason: offer.reach ? "cap" : "unreachable",
161
+ }));
162
+ return {
163
+ ...plan,
164
+ effective_depth: "common",
165
+ reason: full ? "over_cap" : "full_not_enumerable",
166
+ paths,
167
+ coverage_paths: coveragePaths,
168
+ uncovered_pages: uncovered,
169
+ };
170
+ }
171
+
172
+ // The offer pages a planned path clicks, in click order: `checkout` clicks
173
+ // none, `decline-accept` clicks the entry offer's decline and then the accept
174
+ // on whichever offer that decline leads to. A walk stops where the topology
175
+ // leaves the offer graph, as the runner does.
176
+ export function testOrderPathClicks(resolvedTopology, path) {
177
+ const normalized = String(path || "").toLowerCase();
178
+ const steps = !normalized || normalized === "checkout" ? [] : normalized.split("-");
179
+ const edges = new Map((resolvedTopology?.offer_edges || []).map((edge) => [edge.key, edge]));
180
+ const clicks = [];
181
+ let current = edges.get(resolvedTopology?.entry_offer_key) || null;
182
+ for (const step of steps) {
183
+ if (!current) break;
184
+ clicks.push({ key: current.key, page_id: current.page_id, action: step });
185
+ current = edges.get(current[`${step}_key`]) || null;
186
+ }
187
+ return clicks;
188
+ }
189
+
190
+ function declinedOfferKeys(resolvedTopology, paths) {
191
+ const keys = new Set();
192
+ for (const path of paths) {
193
+ for (const click of testOrderPathClicks(resolvedTopology, path)) {
194
+ if (click.action === "decline") keys.add(click.key);
195
+ }
196
+ }
197
+ return keys;
198
+ }
199
+
200
+ // Declared offer pages, shallowest first, each with the shortest action
201
+ // sequence that reaches it (accept before decline on a tie) or null when no
202
+ // path from the checkout reaches it.
203
+ function offerPagesByReach(resolvedTopology) {
204
+ const edges = resolvedTopology?.offer_edges || [];
205
+ const byKey = new Map(edges.map((edge) => [edge.key, edge]));
206
+ const reach = new Map();
207
+ const entry = resolvedTopology?.entry_offer_key;
208
+ if (entry && byKey.has(entry)) {
209
+ reach.set(entry, []);
210
+ const queue = [entry];
211
+ while (queue.length) {
212
+ const key = queue.shift();
213
+ for (const action of ["accept", "decline"]) {
214
+ const next = byKey.get(key)?.[`${action}_key`];
215
+ if (!next || reach.has(next) || !byKey.has(next)) continue;
216
+ reach.set(next, [...reach.get(key), action]);
217
+ queue.push(next);
218
+ }
219
+ }
220
+ }
221
+ const reached = [...reach.keys()].map((key) => ({ ...byKey.get(key), reach: reach.get(key) }));
222
+ const unreached = edges.filter((edge) => !reach.has(edge.key)).map((edge) => ({ ...edge, reach: null }));
223
+ return [...reached, ...unreached];
224
+ }
225
+
226
+ // The shortest actual terminal path that clicks this offer's decline. Among
227
+ // equally short paths, the one that also clicks the most still-undeclined
228
+ // offers wins, then accept before decline. Where no terminal path clicks it
229
+ // (the graph beyond is not enumerable), the path that reaches the offer and
230
+ // declines it.
231
+ function shortestDeclinePath(resolvedTopology, offer, declined) {
232
+ const newlyDeclined = (path) => new Set(testOrderPathClicks(resolvedTopology, path)
233
+ .filter((click) => click.action === "decline" && !declined.has(click.key))
234
+ .map((click) => click.key)).size;
235
+ const candidates = (resolvedTopology?.terminal_paths || [])
236
+ .filter((candidate) => testOrderPathClicks(resolvedTopology, candidate.path)
237
+ .some((click) => click.key === offer.key && click.action === "decline"))
238
+ .map((candidate) => ({ ...candidate, gain: newlyDeclined(candidate.path) }))
239
+ .sort((left, right) => (left.steps.length - right.steps.length)
240
+ || (right.gain - left.gain)
241
+ || compareActionSteps(left.steps, right.steps));
242
+ if (candidates.length) return candidates[0].path;
243
+ return [...offer.reach, "decline"].join("-");
244
+ }
245
+
98
246
  function compareCommonReceiptPaths(left, right) {
99
247
  const lengthDelta = (left?.steps?.length || 0) - (right?.steps?.length || 0);
100
248
  if (lengthDelta) return lengthDelta;
@@ -2,12 +2,12 @@
2
2
  //
3
3
  // `built_output.upsell_selector_scope` catches one shape of built markup that
4
4
  // the Campaign Cart SDK binds without complaint and that then does the wrong
5
- // thing to a shopper. This module is the rest of that family: six more
5
+ // thing to a shopper. This module is the rest of that family: seven more
6
6
  // shapes, each a static read of built HTML, each producing either a silent
7
7
  // no-op (the shopper fills a field that never reaches the order; a button
8
8
  // that never enables) or a double cart write. A partner agency's Campaign
9
- // Cart skill kit listed them as stable lint codes; the codes are kept so the
10
- // two vocabularies line up.
9
+ // Cart skill kit listed the first six as stable lint codes; the codes are
10
+ // kept so the two vocabularies line up. ORPHANED_UPSELL_ACTION is ours.
11
11
  //
12
12
  // Blockers (not waivable — the markup provably does not do what it says)
13
13
  // SWAP_WITH_ADD_TO_CART a bundle selector in swap mode (explicit, or
@@ -26,6 +26,12 @@
26
26
  // MISSING_SELECTOR_ID_MATCH an add-to-cart button whose data-next-selector-id
27
27
  // names no selector on the page. The button waits
28
28
  // for a selection that can never arrive.
29
+ // ORPHANED_UPSELL_ACTION data-next-upsell-action with no ancestor carrying
30
+ // data-next-upsell (#529). The upsell enhancer
31
+ // binds only the actions inside its container; an
32
+ // action outside it is a plain link, so a "No
33
+ // thanks" goes nowhere and the shopper is stuck.
34
+ // Every page type, not only upsell pages.
29
35
  //
30
36
  // Warnings (advisory)
31
37
  // DOUBLE_SELECTED more than one data-next-selected="true" card in
@@ -34,6 +40,12 @@
34
40
  // TEMPLATE_DOUBLE_BRACE `{{` inside an SDK-owned <template>. SDK tokens
35
41
  // are single-brace and conditions are no-brace;
36
42
  // a double brace renders literally.
43
+ // CHECKOUT_BUMP_IS_UPSELL data-next-is-upsell="true" on a checkout page
44
+ // (#535). A checkout bump is a pre-purchase
45
+ // add-on; the flag puts it on the initial order
46
+ // as an upsell line. The page type is a live,
47
+ // unambiguous next-page-type meta, since it is
48
+ // what the SDK reads; otherwise the route type.
37
49
  //
38
50
  // Info (advisory, one note per campaign, no code)
39
51
  // unknown_attributes[] a data-next-* name the pinned SDK's attribute
@@ -43,10 +55,11 @@
43
55
  // own data-next-* hooks, which is why it informs
44
56
  // rather than warns.
45
57
  //
46
- // Parsed with parse5 rather than regex because four of the six turn on
47
- // containment (a card inside a selector, a field inside a form, a template's
48
- // content), and HTML nesting is not a regular language. Template content is
49
- // walked too: the SDK clones it into the live DOM.
58
+ // Parsed with parse5 rather than regex because five of the seven turn on
59
+ // containment (a card inside a selector, a field inside a form, an action
60
+ // inside its upsell container, a template's content), and HTML nesting is not
61
+ // a regular language. Template content is walked too: the SDK clones it into
62
+ // the live DOM.
50
63
  //
51
64
  // Pure: callers hand in built HTML. Both doctor entry points drive it.
52
65
 
@@ -57,6 +70,9 @@ import {
57
70
  isIndexedSdkAttribute,
58
71
  isKnownCheckoutFieldName,
59
72
  } from "./sdk-attribute-index.mjs";
73
+ import { builtPageTypeMeta } from "./upsell-selector-scope.mjs";
74
+
75
+ const normalizedPageTypeValue = (type) => (type == null ? null : String(type).trim().toLowerCase());
60
76
 
61
77
  export const SDK_MARKUP = "built_output.sdk_markup";
62
78
 
@@ -65,8 +81,10 @@ export const SDK_MARKUP_CODES = Object.freeze({
65
81
  CHECKOUT_NOT_FORM: { code: `${SDK_MARKUP}.checkout_not_form`, severity: "error" },
66
82
  WRONG_FIELD_NAME: { code: `${SDK_MARKUP}.wrong_field_name`, severity: "error" },
67
83
  MISSING_SELECTOR_ID_MATCH: { code: `${SDK_MARKUP}.missing_selector_id_match`, severity: "error" },
84
+ ORPHANED_UPSELL_ACTION: { code: `${SDK_MARKUP}.orphaned_upsell_action`, severity: "error" },
68
85
  DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
69
86
  TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
87
+ CHECKOUT_BUMP_IS_UPSELL: { code: `${SDK_MARKUP}.checkout_bump_is_upsell`, severity: "warning" },
70
88
  // Unknown data-next-* names are not a finding and carry no code: they are
71
89
  // information on the gate (unknown_attributes[]) and one advisory ready line.
72
90
  });
@@ -142,11 +160,22 @@ function describe(entry) {
142
160
  return `${bits.join("")}>`;
143
161
  }
144
162
 
163
+ function describeBump(entry) {
164
+ const id = entry.attrs.get("id");
165
+ const packageId = entry.attrs.get("data-next-package-id");
166
+ const bits = [`<${entry.tag}`];
167
+ if (id) bits.push(` id="${id}"`);
168
+ if (packageId) bits.push(` data-next-package-id="${packageId}"`);
169
+ return `${bits.join("")}>`;
170
+ }
171
+
145
172
  /**
146
173
  * Scan one built page. Returns findings with { code_name, code, severity,
147
174
  * page_id, file, message, detail } and the set of unknown data-next-* names.
175
+ * `page_type` is the route-inferred type, used only when the page declares no
176
+ * live, unambiguous next-page-type meta (builtPageTypeMeta).
148
177
  */
149
- export function scanPageMarkup({ page_id, file = null, content = "" }) {
178
+ export function scanPageMarkup({ page_id, file = null, content = "", page_type = null }) {
150
179
  const document = parse(String(content || ""));
151
180
  const where = file || page_id;
152
181
  const findings = [];
@@ -157,10 +186,13 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
157
186
  const selectorIds = new Set(); // ids of elements that are themselves a selector
158
187
  const templates = []; // { entry, sdkOwned }
159
188
  const referencedTemplateIds = new Set();
189
+ const upsellFlagged = []; // entries carrying data-next-is-upsell="true"
160
190
 
161
191
  walkElements(document, (entry) => {
162
192
  const { tag, attrs: a, ancestors } = entry;
163
193
 
194
+ if ((a.get("data-next-is-upsell") || "").trim().toLowerCase() === "true") upsellFlagged.push(entry);
195
+
164
196
  for (const [name] of a) {
165
197
  if (name.startsWith("data-next-") && !isIndexedSdkAttribute(name)) unknown.add(name);
166
198
  if (TEMPLATE_ID_ATTRIBUTES.test(name) && a.get(name)) referencedTemplateIds.add(a.get(name).trim());
@@ -181,6 +213,18 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
181
213
  }
182
214
  }
183
215
 
216
+ // An ancestor, not the element itself: the enhancer looks for actions
217
+ // inside the container it binds.
218
+ if (a.has("data-next-upsell-action") && !ancestors.some((anc) => anc.attrs.has("data-next-upsell"))) {
219
+ const value = a.get("data-next-upsell-action") ?? "";
220
+ // The SDK reads add/accept as accepting the offer and skip/decline as
221
+ // declining it; any other value does nothing even inside a container.
222
+ const verb = { add: "accept", accept: "accept", skip: "decline", decline: "decline" }[value.trim().toLowerCase()] || "act on";
223
+ findings.push(finding("ORPHANED_UPSELL_ACTION", page_id, where,
224
+ `${describe(entry)} data-next-upsell-action="${value}" on ${where} has no ancestor carrying data-next-upsell. The SDK binds upsell actions only inside that container, so this one never fires and the shopper cannot ${verb} the offer. Move it inside the data-next-upsell container it belongs to.`,
225
+ { tag, action: value }));
226
+ }
227
+
184
228
  const action = (a.get("data-next-action") || "").trim().toLowerCase();
185
229
  if (action === "add-to-cart") {
186
230
  addToCartButtons.push({ entry, selectorId: (a.get("data-next-selector-id") || "").trim() || null });
@@ -263,6 +307,26 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
263
307
  { template_id: id || null, snippet }));
264
308
  }
265
309
 
310
+ // One finding per page, naming every flagged element: the repair is the
311
+ // same for each (drop the flag from the bump include's markup; several
312
+ // starter includes write it unconditionally, so an is_upsell=false argument
313
+ // does not always clear it).
314
+ // The page type is read only when a flag is present, so a page without one
315
+ // is not parsed a second time.
316
+ const pageTypeMeta = upsellFlagged.length ? builtPageTypeMeta(content) : null;
317
+ // The live, unambiguous meta wins because it is what the SDK reads; the
318
+ // upsell gate's narrower oto-route rule (builtPageTypeOverRouteGuess) does
319
+ // not apply to a checkout bump.
320
+ const effectivePageType = upsellFlagged.length
321
+ ? normalizedPageTypeValue(pageTypeMeta ?? page_type)
322
+ : null;
323
+ if (effectivePageType === "checkout") {
324
+ const elements = upsellFlagged.map(describeBump);
325
+ findings.push(finding("CHECKOUT_BUMP_IS_UPSELL", page_id, where,
326
+ `${elements.length > 1 ? `${elements.length} order bumps` : "An order bump"} on checkout page ${where} (${elements.join(", ")}) ${elements.length > 1 ? "carry" : "carries"} data-next-is-upsell="true". A checkout bump is a pre-purchase add-on; the flag puts it on the initial order as an upsell line. The flag comes from the bump include's markup, and several starter bump includes write it unconditionally, so remove data-next-is-upsell="true" from the include in this campaign unless the line really should be billed as an upsell.`,
327
+ { page_type: effectivePageType, page_type_source: pageTypeMeta !== null ? "next-page-type" : "route", elements }));
328
+ }
329
+
266
330
  return { page_id, file, findings, unknown_attributes: [...unknown].sort() };
267
331
  }
268
332
 
@@ -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,
@@ -138,6 +138,122 @@ function pageRouteForPageKit(value) {
138
138
  }
139
139
  }
140
140
 
141
+ // The SDK meta tags whose content is a route. Doctor checks the same three.
142
+ export const SDK_ROUTING_META_TAGS = [
143
+ "next-success-url",
144
+ "next-upsell-accept-url",
145
+ "next-upsell-decline-url",
146
+ ];
147
+
148
+ export const HOST_STRIPPED_CODE = "routing_meta.host_stripped";
149
+
150
+ // A route value that carries a host in front of its path (#531): an older
151
+ // saved Map stored `shop.example.com/route/upsell/` where the route is
152
+ // `/route/upsell/`, and every stage that roots a route then nested the host
153
+ // inside the campaign path. Returns `{ host, from, to }` — `to` is the rooted
154
+ // path with any query and fragment kept — or null when the value is not
155
+ // host-prefixed.
156
+ //
157
+ // Read as host-prefixed:
158
+ // - `http://` or `https://` URLs (any host);
159
+ // - protocol-relative `//<host>/...`;
160
+ // - bare `<host>/...`, where the first segment is followed by "/" and is
161
+ // `localhost`, a valid IPv4 address (each octet 0-255), any name with a
162
+ // `:port`, or a dotted name whose last label is 2-63 letters and not a
163
+ // page or script extension (ROUTE_FILE_EXTENSION: `html`, `htm`,
164
+ // `shtml`, `php`, `asp`, `aspx`, `jsp`, `cgi`; not `pl`, which is also
165
+ // Poland's country-code domain), such as
166
+ // `shop.example.com`. `//<host>/...` takes the same hosts.
167
+ // Everything else is a route and is left to the existing route checks: a
168
+ // rooted `/...` value, a first segment with no dot (`route/x/`), a dotted
169
+ // segment whose last label is not all letters (`v1.2/offer/`), a dotted
170
+ // quad with an octet over 255 (`300.1.2.3/offer/`), a page or script
171
+ // filename (`checkout.html`, `index.php/checkout/`), and a bare host with no
172
+ // path after it.
173
+ // `keepAbsolute: true` leaves an absolute http(s) URL alone: it is a valid
174
+ // SDK routing meta target, and projection already converts an absolute
175
+ // page_url to its path, so doctor accepts both; only the bare and
176
+ // protocol-relative forms count.
177
+ export function parseHostPrefixedRoute(value, { keepAbsolute = false } = {}) {
178
+ if (typeof value !== "string") return null;
179
+ const raw = value.trim();
180
+ if (!raw || (raw.startsWith("/") && !raw.startsWith("//"))) return null;
181
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(raw);
182
+ if (scheme) {
183
+ if (keepAbsolute || !/^https?$/i.test(scheme[1])) return null;
184
+ return splitHostPrefix(value, raw.slice(scheme[0].length), { requireHostShape: false });
185
+ }
186
+ if (raw.startsWith("//")) return splitHostPrefix(value, raw.slice(2), { requireHostShape: true });
187
+ return splitHostPrefix(value, raw, { requireHostShape: true, requirePath: true });
188
+ }
189
+
190
+ function splitHostPrefix(from, rest, { requireHostShape, requirePath = false }) {
191
+ const end = rest.search(/[/?#]/);
192
+ const host = end === -1 ? rest : rest.slice(0, end);
193
+ const tail = end === -1 ? "" : rest.slice(end);
194
+ if (!host || /\s/.test(host)) return null;
195
+ if (requirePath && !tail.startsWith("/")) return null;
196
+ if (requireHostShape && !looksLikeHost(host)) return null;
197
+ return { host, from, to: tail.startsWith("/") ? tail : `/${tail}` };
198
+ }
199
+
200
+ // A dotted first segment ending in one of these is a page or script filename
201
+ // (`index.php/checkout/`), not a host.
202
+ const ROUTE_FILE_EXTENSION = /^(?:html?|shtml|php|aspx?|jsp|cgi)$/i;
203
+
204
+ function looksLikeHost(segment) {
205
+ if (/^localhost(?::\d+)?$/i.test(segment)) return true;
206
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(segment) && segment.split(".").every((octet) => Number(octet) <= 255)) return true;
207
+ if (/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*:\d+$/.test(segment)) return true;
208
+ const labels = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/.test(segment)
209
+ ? segment.split(".")
210
+ : null;
211
+ if (!labels) return false;
212
+ const last = labels[labels.length - 1];
213
+ return /^[A-Za-z]{2,63}$/.test(last) && !ROUTE_FILE_EXTENSION.test(last);
214
+ }
215
+
216
+ // Intake normalisation (#531): every host-prefixed page_url, and every
217
+ // host-prefixed SDK routing meta tag value, in funnels[].pages[] and the
218
+ // funnel_pages[] mirror, reduced to its rooted path before anything reads the
219
+ // spec. Returns the spec unchanged (the same object) with no evidence when
220
+ // nothing carries a host; otherwise a copy and one evidence record per changed
221
+ // value, `{ code, page_id, field, from, to }`, where `from` is the value
222
+ // exactly as the Map or file held it.
223
+ export function stripHostPrefixedRoutes(spec) {
224
+ const evidence = [];
225
+ if (!spec || typeof spec !== "object") return { spec, evidence };
226
+ const copy = JSON.parse(JSON.stringify(spec));
227
+ const lists = [];
228
+ if (Array.isArray(copy.funnels)) {
229
+ copy.funnels.forEach((funnel, funnelIndex) => {
230
+ if (Array.isArray(funnel?.pages)) lists.push([`funnels[${funnelIndex}].pages`, funnel.pages]);
231
+ });
232
+ }
233
+ if (Array.isArray(copy.funnel_pages)) lists.push(["funnel_pages", copy.funnel_pages]);
234
+ for (const [label, pages] of lists) {
235
+ pages.forEach((page, pageIndex) => {
236
+ if (!page || typeof page !== "object") return;
237
+ const at = `${label}[${pageIndex}]`;
238
+ const pageId = typeof page.id === "string" ? page.id : null;
239
+ const stripped = parseHostPrefixedRoute(page.page_url);
240
+ if (stripped) {
241
+ page.page_url = stripped.to;
242
+ evidence.push({ code: HOST_STRIPPED_CODE, page_id: pageId, field: `${at}.page_url`, from: stripped.from, to: stripped.to });
243
+ }
244
+ const metaTags = page.sdk_hints?.meta_tags;
245
+ if (!metaTags || typeof metaTags !== "object" || Array.isArray(metaTags)) return;
246
+ for (const tag of SDK_ROUTING_META_TAGS) {
247
+ const meta = parseHostPrefixedRoute(metaTags[tag], { keepAbsolute: true });
248
+ if (!meta) continue;
249
+ metaTags[tag] = meta.to;
250
+ evidence.push({ code: HOST_STRIPPED_CODE, page_id: pageId, field: `${at}.sdk_hints.meta_tags.${tag}`, from: meta.from, to: meta.to });
251
+ }
252
+ });
253
+ }
254
+ return evidence.length ? { spec: copy, evidence } : { spec, evidence };
255
+ }
256
+
141
257
  function applyManifestToPages(specPages, manifest, manifestPath, { buildScope = null, templateFamily = null } = {}) {
142
258
  const mappings = [];
143
259
  const prompts = [];
@@ -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) {