@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
@@ -7,7 +7,8 @@
7
7
  // typed, and this command is the generator for the repo-derived ones: the
8
8
  // SDK pin from `_data/campaigns.json[<route>].sdk_version`, each page's public
9
9
  // route from the page tree under `src/<route>/`, and the analytics ids the
10
- // entry carries (`gtm_id`, `fb_pixel_id`). Doctor then compares generated
10
+ // entry carries (`gtm_id`, `fb_pixel_id`, and the starter-template vendor ids
11
+ // such as `ga4_id` or `triplewhale_name`). Doctor then compares generated
11
12
  // against generated instead of refereeing a human's typing against the repo.
12
13
  //
13
14
  // The repo is the authority for exactly those fields. A spec value that looks
@@ -17,14 +18,23 @@
17
18
  // authority: the store's Admin API) are planned by spec-derive-store.mjs
18
19
  // behind --from-store; their rows are applied here under the same guard.
19
20
  //
21
+ // After handoff (#721) the repo is also the authority for which pages exist:
22
+ // a page file the tree gains is added to the spec, a page whose built file is
23
+ // gone is removed, and the routing that links an added or removed page is read
24
+ // from the page files' frontmatter links. Pages are matched by id, never by
25
+ // position. The offer and package fields the spec mirrors from the Campaigns
26
+ // App are planned by spec-derive-campaign.mjs from the live campaign read and
27
+ // applied here under the same guard.
28
+ //
20
29
  // This module is pure: the CLI reads the packet, the spec, the entry and the
21
30
  // page tree, and does the writing and the printing.
22
- import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
31
+ import { ANALYTICS_VENDORS, isPlaceholderVendorId, isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
23
32
  import { PAGE_KIT_CAMPAIGNS_REL_PATH } from "./page-kit-campaign-config.mjs";
24
33
  import { PAGE_KIT_STORE_PROFILE_FIELDS } from "./page-kit-store-profile.mjs";
25
34
  import { compareReleasedSdkVersions, entryInScaffoldState, resolveSpecSdkPin } from "./page-kit-sdk-version.mjs";
26
35
  import { isAbsoluteHttpUrl, normalizePageKitRoute, runtimeRelativeRouteForSpecValue, stripPublicRoutePrefix } from "./route-identity.mjs";
27
- import { publicRouteForPage } from "./source-html-intake.mjs";
36
+ import { cpkPageTypeForSpecType, publicRouteForPage } from "./source-html-intake.mjs";
37
+ import { MIRRORED_OFFER_FIELDS, MIRRORED_PACKAGE_FIELDS, MIRRORED_PAGE_OFFER_FIELDS } from "./spec-derive-campaign.mjs";
28
38
 
29
39
  // The spec fields this command may write, as path patterns. `applySpecDerive`
30
40
  // refuses any change whose path matches none of them, so a plan row can never
@@ -36,25 +46,64 @@ export const SPEC_DERIVE_FIELDS = Object.freeze([
36
46
  "funnel_pages[].page_url",
37
47
  "analytics.providers.gtm.containerId",
38
48
  "analytics.providers.facebook.pixelId",
49
+ ...ANALYTICS_VENDORS.map(({ key }) => `analytics.vendors.${key}.id`),
39
50
  ...PAGE_KIT_STORE_PROFILE_FIELDS.map((field) => `campaign.${field}`),
40
51
  ]);
41
52
 
42
- // campaigns.json analytics keys and the spec provider field each one derives.
43
- export const ANALYTICS_ID_FIELDS = Object.freeze([
44
- Object.freeze({ key: "gtm_id", provider: "gtm", property: "containerId", shape: /^GTM-[A-Z0-9]{4,}$/, describe: "a GTM container id (GTM-XXXXXXX)" }),
45
- Object.freeze({ key: "fb_pixel_id", provider: "facebook", property: "pixelId", shape: /^\d{5,20}$/, describe: "a Meta pixel id (digits only)" }),
46
- ]);
53
+ // The page routing fields derive writes after handoff, and only on an edge
54
+ // that names a page the tree added or removed (#721).
55
+ export const PAGE_ROUTING_FIELDS = Object.freeze(["next_page", "on_accept", "on_decline", "success_url"]);
56
+
57
+ // The starter-template vendor ids (analytics.vendors.<vendor>.id), one per
58
+ // vendor in the campaign-spec registry, keyed by the campaigns.json key that
59
+ // switches the vendor on. GA4 and Taboola have a known format (Taboola's id is
60
+ // injected unquoted, so anything but digits breaks the page); the others are
61
+ // one token with no whitespace or quotes, since the template writes the id
62
+ // into a script.
63
+ const VENDOR_ID_SHAPES = Object.freeze({
64
+ ga4: { shape: /^G-[A-Z0-9]{4,}$/, describe: "a GA4 measurement id (G-XXXXXXXXXX)" },
65
+ taboola: { shape: /^\d+$/, describe: "a Taboola account id (digits only)" },
66
+ });
67
+ const VENDOR_ID_TOKEN = { shape: /^[^\s"'<>\\`]+$/, describe: "one id token with no spaces or quotes" };
68
+ export const ANALYTICS_VENDOR_ID_FIELDS = Object.freeze(ANALYTICS_VENDORS.map(({ key, idKey, label }) => Object.freeze({
69
+ key: idKey,
70
+ vendor: key,
71
+ property: "id",
72
+ ...(VENDOR_ID_SHAPES[key] || { ...VENDOR_ID_TOKEN, describe: `a ${label} id (${VENDOR_ID_TOKEN.describe})` }),
73
+ })));
47
74
 
48
75
  // The SDK routing meta tags a page's `sdk_hints.meta_tags` may carry, and the
49
76
  // declared routing field (a page id) each one is the route of. A derived
50
- // route change on the referenced page leaves such a hint stale; the hint is a
51
- // spec projection the editor regenerates, so it is reported, not rewritten.
77
+ // route change on the referenced page leaves such a hint stale, and derive
78
+ // reports it. The one exception is an edge derive itself re-points after
79
+ // handoff (a page added or removed): there no editor will regenerate the
80
+ // hint, so derive writes it with the routing field it goes with.
52
81
  const ROUTING_HINT_FIELDS = Object.freeze({
53
82
  "next-success-url": "success_url",
54
83
  "next-upsell-accept-url": "on_accept",
55
84
  "next-upsell-decline-url": "on_decline",
56
85
  });
57
86
 
87
+ // The further paths a handed-off derive may write: whole pages added to or
88
+ // removed from the funnel the tree states (an element op), the routing
89
+ // fields of an edge that names such a page, and the offer and package fields
90
+ // the spec mirrors from the live campaign (spec-derive-campaign.mjs).
91
+ export const SPEC_DERIVE_ELEMENTS = Object.freeze(["funnels[].pages[]", "funnel_pages[]", "offers[]"]);
92
+ export const SPEC_DERIVE_MIRROR_FIELDS = Object.freeze([
93
+ ...PAGE_ROUTING_FIELDS.flatMap((field) => [`funnels[].pages[].${field}`, `funnel_pages[].${field}`]),
94
+ ...Object.keys(ROUTING_HINT_FIELDS).flatMap((tag) => [`funnels[].pages[].sdk_hints.meta_tags.${tag}`, `funnel_pages[].sdk_hints.meta_tags.${tag}`]),
95
+ ...MIRRORED_PACKAGE_FIELDS.flatMap((field) => [`funnels[].pages[].packages[].${field}`, `funnel_pages[].packages[].${field}`]),
96
+ "offers",
97
+ ...MIRRORED_OFFER_FIELDS.map((field) => `offers[].${field}`),
98
+ ...MIRRORED_PAGE_OFFER_FIELDS.flatMap((field) => [`funnels[].pages[].offers[].${field}`, `funnel_pages[].offers[].${field}`]),
99
+ ]);
100
+
101
+ // campaigns.json analytics keys and the spec provider field each one derives.
102
+ export const ANALYTICS_ID_FIELDS = Object.freeze([
103
+ Object.freeze({ key: "gtm_id", provider: "gtm", property: "containerId", shape: /^GTM-[A-Z0-9]{4,}$/, describe: "a GTM container id (GTM-XXXXXXX)" }),
104
+ Object.freeze({ key: "fb_pixel_id", provider: "facebook", property: "pixelId", shape: /^\d{5,20}$/, describe: "a Meta pixel id (digits only)" }),
105
+ ]);
106
+
58
107
  // The directories page-kit's own discovery ignores (`_layouts`, `_includes`;
59
108
  // on 0.2.x everything else under the campaign root, `assets/` and `_data/`
60
109
  // included, is rendered when it is .html), plus the two no build reads. The
@@ -200,14 +249,80 @@ export function normalizePermalinkValue(value) {
200
249
  // the block or the key is absent. A BOM and CRLF are read as page-kit's
201
250
  // frontmatter reader reads them.
202
251
  export function frontmatterPermalink(text) {
252
+ return frontmatterScalars(text, ["permalink"]).permalink;
253
+ }
254
+
255
+ // The frontmatter keys a page file states that derive reads after handoff:
256
+ // the page-kit page_type (which makes a file a funnel page) and the links
257
+ // prepare-build projects between pages (next_url, decline_url, next_step).
258
+ export const PAGE_FILE_FRONTMATTER_KEYS = Object.freeze(["permalink", "page_type", "next_url", "decline_url", "next_step", "step_number"]);
259
+
260
+ // The raw scalar of each named key in a page file's frontmatter (the first
261
+ // line of the block that declares it), read as frontmatterPermalink reads a
262
+ // permalink; a key absent from the block, or a file with no block, is null.
263
+ export function frontmatterScalars(text, keys = PAGE_FILE_FRONTMATTER_KEYS) {
264
+ const values = Object.fromEntries(keys.map((key) => [key, null]));
203
265
  const normalized = String(text ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
204
266
  const block = normalized.match(/^---\n([\s\S]*?)\n---/);
205
- if (!block) return null;
267
+ if (!block) return values;
206
268
  for (const line of block[1].split("\n")) {
207
- const match = line.match(/^permalink:\s*(.*?)\s*$/);
208
- if (match) return normalizePermalinkValue(match[1]);
269
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/);
270
+ if (!match || !Object.hasOwn(values, match[1]) || values[match[1]] !== null) continue;
271
+ values[match[1]] = normalizePermalinkValue(match[2]);
209
272
  }
210
- return null;
273
+ return values;
274
+ }
275
+
276
+ /**
277
+ * The routing hints whose value no longer matches the route of the page they
278
+ * name (#531). A page's `sdk_hints.meta_tags` routing tag is the route of the
279
+ * page its declared routing field names (next-success-url: success_url, and
280
+ * so on); compared as the SDK composes it (slug prefix stripped, terminal
281
+ * segment), a hint that differs sends the shopper somewhere the funnel no
282
+ * longer serves. With `routeByPageId` (the routes derive is about to write)
283
+ * only the pages it names are compared; without it every target page's
284
+ * current route is. spec derive warns with this, doctor blocks on it, and
285
+ * record spec refuses a routes-only bind while any hint is stale: one rule,
286
+ * one owner.
287
+ */
288
+ export function staleRoutingHints(spec, { publicRouteSlug = "", routeByPageId = null } = {}) {
289
+ const pages = activePagesWithPaths(spec).map(({ page }) => page);
290
+ const byId = new Map();
291
+ for (const page of pages) if (!byId.has(page.id)) byId.set(page.id, page);
292
+ const stale = [];
293
+ for (const page of pages) {
294
+ const metaTags = page.sdk_hints?.meta_tags;
295
+ if (!isPlainObject(metaTags)) continue;
296
+ for (const [tag, routingField] of Object.entries(ROUTING_HINT_FIELDS)) {
297
+ const value = metaTags[tag];
298
+ const targetId = page[routingField];
299
+ if (!isNonEmptyString(value) || !isNonEmptyString(targetId)) continue;
300
+ let route;
301
+ if (routeByPageId instanceof Map) {
302
+ if (!routeByPageId.has(targetId)) continue;
303
+ route = routeByPageId.get(targetId);
304
+ } else {
305
+ if (!byId.has(targetId)) continue;
306
+ route = publicRouteForPage(byId.get(targetId));
307
+ }
308
+ if (runtimeRelativeRouteForSpecValue(value, publicRouteSlug) === runtimeRelativeRouteForSpecValue(route, publicRouteSlug)) continue;
309
+ stale.push({ page_id: page.id, tag, value, target_page_id: targetId, derived_route: route });
310
+ }
311
+ }
312
+ return stale;
313
+ }
314
+
315
+ // A routing hint rewritten to `route`, spelled the way the hint it replaces
316
+ // was: the prefix in front of its runtime-relative route ("/slug/" in
317
+ // "/slug/receipt/", nothing in "receipt/") is kept, so a rooted hint stays
318
+ // rooted.
319
+ function routingHintValue(previous, route, publicRouteSlug) {
320
+ const relative = runtimeRelativeRouteForSpecValue(route, publicRouteSlug);
321
+ const raw = previous.trim().replace(/[?#].*$/, "");
322
+ const withSlash = raw.endsWith("/") ? raw : `${raw}/`;
323
+ const old = runtimeRelativeRouteForSpecValue(previous, publicRouteSlug);
324
+ const prefix = old && withSlash.endsWith(old) ? withSlash.slice(0, withSlash.length - old.length) : "";
325
+ return `${prefix}${relative}`;
211
326
  }
212
327
 
213
328
  function terminalSegment(route) {
@@ -262,6 +377,221 @@ function pathLabel(path) {
262
377
  return path.map((segment, index) => (typeof segment === "number" ? `[${segment}]` : `${index ? "." : ""}${segment}`)).join("");
263
378
  }
264
379
 
380
+ // The CampaignSpec page type a page-kit page_type stands for when the tree
381
+ // adds a page. page-kit's four runtime types are coarser than the spec's, so
382
+ // product reads as a landing page; a page that is something else (a
383
+ // presell, a downsell) has its type authored after the add. prepare-build
384
+ // writes next_step on every page before the Checkout and step_number only on a
385
+ // checkout_step, so a checkout file with next_step is a checkout_step when it
386
+ // has a step_number and a select page when it has none.
387
+ const SPEC_TYPE_FOR_PAGE_KIT_TYPE = Object.freeze({ product: "landing", checkout: "checkout", upsell: "upsell", receipt: "thankyou" });
388
+ function specTypeForPageFile(file) {
389
+ if (file.page_type === "checkout" && isNonEmptyString(file.next_step)) {
390
+ return file.step_number != null && String(file.step_number).trim() !== "" ? "checkout_step" : "select";
391
+ }
392
+ return Object.hasOwn(SPEC_TYPE_FOR_PAGE_KIT_TYPE, file.page_type ?? "") ? SPEC_TYPE_FOR_PAGE_KIT_TYPE[file.page_type] : undefined;
393
+ }
394
+
395
+ // checkout_step is a CampaignSpec v5 type. A v4 spec states a step as a
396
+ // checkout that forwards to another checkout (upgradeCampaignSpec retypes it
397
+ // on read), so that is how a step is added to one.
398
+ function writtenPageType(type, spec) {
399
+ const version = typeof spec?.schema_version === "string" ? spec.schema_version.trim() : "";
400
+ return type === "checkout_step" && !/^5(\.|$)/.test(version) ? "checkout" : type;
401
+ }
402
+
403
+ // The frontmatter link that states each routing field, by spec page type: the
404
+ // keys prepare-build projects (source-html-intake.mjs) read back the other
405
+ // way. A select or checkout_step page's next_url is the Checkout's
406
+ // destination, not its own forward link, so only next_step is read there.
407
+ function linkFieldsForType(type) {
408
+ if (type === "upsell" || type === "downsell") return [["next_url", "on_accept"], ["decline_url", "on_decline"]];
409
+ if (type === "checkout") return [["next_url", "success_url"]];
410
+ if (type === "select" || type === "checkout_step") return [["next_step", "next_page"]];
411
+ if (type === "thankyou" || type === "receipt") return [];
412
+ return [["next_url", "next_page"]];
413
+ }
414
+
415
+ const PAGE_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
416
+
417
+ // Which pages a handed-off tree adds and removes (#721), and the routing on
418
+ // the edges that name them. Matching is by page id, never by position:
419
+ // - added: a file with a page-kit page_type in its frontmatter that no spec
420
+ // page binds (and no page could be, ambiguously). Its id is the file's
421
+ // name, its type follows page_type, its route is the route the file
422
+ // builds to, and it is appended to the funnel's pages;
423
+ // - removed: a page whose file the packet's projection named
424
+ // (source_html.pages[].page_kit.target_path) and the tree no longer has.
425
+ // A page the build never wrote is still only "not found";
426
+ // - routing: the added page's own frontmatter links set its routing fields,
427
+ // and a kept page whose link now names an added page, or whose routing
428
+ // named a removed one, is re-pointed at the page its link names.
429
+ // Page order is not something the tree states: an added page is appended,
430
+ // and where the funnel's pages carry an `order` the new page's is reported
431
+ // as not derived. Before handoff nothing is added or removed; the files no
432
+ // page binds are listed as unclaimed.
433
+ function planPageScope({ spec, activePages, pageFiles, packetBindings, boundFiles, ambiguousFiles, notFound, derivedRouteByPageId, publicRouteSlug, handedOff }) {
434
+ const scope = { handed_off: Boolean(handedOff), added: [], removed: [], unclaimed: [], changes: [], not_derived: [] };
435
+ const unbound = pageFiles.filter((file) => !boundFiles.has(file.path) && !ambiguousFiles.has(file.path) && file.route !== null && !file.problem && isNonEmptyString(file.page_type));
436
+ if (!handedOff) {
437
+ scope.unclaimed = unbound.map((file) => file.path);
438
+ return scope;
439
+ }
440
+ const bindings = packetBindings instanceof Map ? packetBindings : new Map();
441
+ const gone = notFound.filter(({ page }) => isNonEmptyString(bindings.get(page.id)));
442
+ const funnels = Array.isArray(spec?.funnels) ? spec.funnels : null;
443
+ const container = funnels
444
+ ? (funnels.length === 1 && Array.isArray(funnels[0]?.pages) ? ["funnels", 0, "pages"] : null)
445
+ : Array.isArray(spec?.funnel_pages) ? ["funnel_pages"] : null;
446
+ const containerPages = container ? (container.length === 1 ? spec.funnel_pages : spec.funnels[0].pages) : [];
447
+ const mirror = funnels && Array.isArray(spec?.funnel_pages) ? spec.funnel_pages : null;
448
+ const mirrorIndexOf = (id) => (mirror ? mirror.findIndex((page) => isPlainObject(page) && page.id === id) : -1);
449
+
450
+ const takenIds = new Set();
451
+ for (const list of [...(funnels || []).map((funnel) => funnel?.pages), spec?.funnel_pages]) {
452
+ for (const page of Array.isArray(list) ? list : []) if (isPlainObject(page) && isNonEmptyString(page.id)) takenIds.add(page.id);
453
+ }
454
+ const added = [];
455
+ for (const file of unbound) {
456
+ const id = file.basename;
457
+ const label = `page file ${file.path}`;
458
+ const type = specTypeForPageFile(file);
459
+ if (!type) {
460
+ scope.not_derived.push({ field: label, page_id: id, reason: "page_type_unknown", detail: `${file.path} declares page_type ${quoteValue(file.page_type)}, which is not one of page-kit's product, checkout, upsell or receipt; the page was not added. Correct the frontmatter, then derive again.` });
461
+ continue;
462
+ }
463
+ if (!PAGE_ID_SHAPE.test(id) || id === "index" || takenIds.has(id)) {
464
+ scope.not_derived.push({ field: label, page_id: id, reason: "page_id_unusable", detail: `${file.path} would add page "${id}", but ${takenIds.has(id) ? "the spec already has a page with that id" : "that is not a usable page id (letters, digits, - and _, not the entry file)"}; rename the file, then derive again.` });
465
+ continue;
466
+ }
467
+ if (!container) {
468
+ scope.not_derived.push({ field: label, page_id: id, reason: "page_funnel_ambiguous", detail: `${file.path} is a page the spec does not have, but the spec has ${funnels ? `${funnels.length} funnels` : "no page list"}, so which funnel it joins is not something the tree states. Add the page to the right funnel by hand, then derive again.` });
469
+ continue;
470
+ }
471
+ takenIds.add(id);
472
+ added.push({ file, id, type });
473
+ }
474
+
475
+ // A page whose file is gone and a new file of the same page-kit page_type
476
+ // in one run is most likely a rename (upsell.html to upsell-1.html, or a
477
+ // downsell's downsell.html, which prepare-build writes as page_type upsell,
478
+ // to downsell-1.html). Applied as written, the remove would discard the
479
+ // page's authored record (its packages, offers, label and hints) and the
480
+ // add would start a bare one, so neither is applied: the pair is reported
481
+ // for a person to resolve. The comparison is in page-kit's types, the only
482
+ // type the new file states.
483
+ const goneKitTypes = new Set(gone.map(({ page }) => cpkPageTypeForSpecType(page.type)));
484
+ const renamedKitTypes = new Set(added.filter(({ file }) => goneKitTypes.has(file.page_type)).map(({ file }) => file.page_type));
485
+ const held = (kitType) => renamedKitTypes.has(kitType);
486
+ for (const { file, id } of added.filter((entry) => held(entry.file.page_type))) {
487
+ const was = gone.filter(({ page }) => cpkPageTypeForSpecType(page.type) === file.page_type).map(({ page }) => `"${page.id}"`).join(", ");
488
+ scope.not_derived.push({ field: `page file ${file.path}`, page_id: id, reason: "page_renamed", detail: `${file.path} is a new page_type ${file.page_type} page and the file of page ${was} (also page_type ${file.page_type}) is gone, which reads as a rename; derive applies neither, so the page's packages, offers and other authored fields are not lost. If it is a rename, set that page's page_url to ${JSON.stringify(file.route)} (it keeps its id), or rename the file back. If it is a new page, remove the old page from the spec by hand. Then derive again.` });
489
+ }
490
+ for (const { page, path } of gone) {
491
+ const kitType = cpkPageTypeForSpecType(page.type);
492
+ if (held(kitType)) {
493
+ scope.not_derived.push({ field: pathLabel(path), page_id: page.id, reason: "page_renamed", detail: `the file of page "${page.id}" (${bindings.get(page.id).replace(/^\/+/, "")}) is gone and the tree has a new page_type ${kitType} page file, which reads as a rename; derive did not remove the page. See the page_renamed row for the new file.` });
494
+ continue;
495
+ }
496
+ scope.removed.push({ page_id: page.id, file: bindings.get(page.id).replace(/^\/+/, "") });
497
+ scope.changes.push({ field: pathLabel(path), page_id: page.id, path, op: "remove", before: page, after: undefined, source: `${bindings.get(page.id).replace(/^\/+/, "")} (no longer in the page tree)` });
498
+ const at = mirrorIndexOf(page.id);
499
+ if (at !== -1) scope.changes.push({ field: `funnel_pages[${at}]`, page_id: page.id, path: ["funnel_pages", at], op: "remove", before: mirror[at], after: undefined, source: `mirror of ${pathLabel(path)}` });
500
+ }
501
+ if (renamedKitTypes.size) added.splice(0, added.length, ...added.filter((entry) => !held(entry.file.page_type)));
502
+
503
+ // The routes of the pages the spec will hold, for resolving links.
504
+ const finalRoutes = new Map([...derivedRouteByPageId].filter(([id]) => !scope.removed.some((removed) => removed.page_id === id)));
505
+ for (const { id, file } of added) finalRoutes.set(id, file.route);
506
+ const resolveLink = (value) => {
507
+ if (!isNonEmptyString(value) || value.trim().startsWith("#") || isAbsoluteHttpUrl(value.trim())) return null;
508
+ const wanted = runtimeRelativeRouteForSpecValue(value, publicRouteSlug);
509
+ const matches = [...finalRoutes].filter(([, route]) => runtimeRelativeRouteForSpecValue(route, publicRouteSlug) === wanted).map(([id]) => id);
510
+ return matches.length === 1 ? matches[0] : null;
511
+ };
512
+ const addedIds = new Set(added.map((entry) => entry.id));
513
+ const removedIds = new Set(scope.removed.map((entry) => entry.page_id));
514
+
515
+ const hasOrder = containerPages.some((page) => isPlainObject(page) && typeof page.order === "number");
516
+ added.forEach(({ file, id, type }, offset) => {
517
+ const path = [...container, containerPages.length + offset];
518
+ const page = { id, type: writtenPageType(type, spec), page_url: file.route };
519
+ for (const [key, field] of linkFieldsForType(type)) {
520
+ const target = resolveLink(file[key]);
521
+ if (target && target !== id) page[field] = target;
522
+ else {
523
+ scope.not_derived.push({ field: `${pathLabel(path)}.${field}`, page_id: id, reason: "routing_not_derived", detail: file[key]
524
+ ? `${file.path} links ${key} to ${quoteValue(file[key])}, which is not the route of exactly one page; set ${field} on page "${id}" by hand.`
525
+ : `${file.path} states no ${key}, so page "${id}"'s ${field} is not something the tree states; set it by hand.` });
526
+ }
527
+ }
528
+ scope.added.push({ page_id: id, file: file.path, type: page.type, route: file.route });
529
+ if (type !== "thankyou") {
530
+ scope.not_derived.push({ field: `${pathLabel(path)}.packages`, page_id: id, reason: "page_packages_not_derived", detail: `page "${id}" was added from ${file.path}; which packages it sells is authored, so set them by hand. Until each path into the Checkout passes a page that sells a main-cart package, and each upsell or downsell has its offer package, doctor blocks (CheckoutPathHasPackages, UpsellHasPackages).` });
531
+ }
532
+ scope.changes.push({ field: pathLabel(path), page_id: id, path, op: "insert", before: undefined, after: page, source: `${file.path} (page_type ${file.page_type})` });
533
+ if (mirror) {
534
+ const mirrorPath = ["funnel_pages", mirror.length + offset];
535
+ scope.changes.push({ field: pathLabel(mirrorPath), page_id: id, path: mirrorPath, op: "insert", before: undefined, after: { ...page }, source: `mirror of ${pathLabel(path)}` });
536
+ }
537
+ if (hasOrder) {
538
+ scope.not_derived.push({ field: `${pathLabel(path)}.order`, page_id: id, reason: "page_order_not_derived", detail: `page "${id}" was appended to the funnel's pages; the repo states its links, not its position, so its order is left for you to set.` });
539
+ }
540
+ });
541
+
542
+ // The kept pages' edges that name an added or a removed page. Re-pointing
543
+ // an edge also writes the routing hint that goes with it (when the page
544
+ // carries one) to the new target's route, and the legacy mirror's copy of
545
+ // both: after handoff no editor regenerates them, and a hint left on the
546
+ // old route is what doctor and record spec then refuse.
547
+ const fileByPageId = new Map();
548
+ for (const file of pageFiles) if (boundFiles.has(file.path)) fileByPageId.set(boundFiles.get(file.path), file);
549
+ const hintTagFor = new Map(Object.entries(ROUTING_HINT_FIELDS).map(([tag, field]) => [field, tag]));
550
+ const repoint = (page, path, field, before, after, source) => {
551
+ const rows = [{ field: `${pathLabel(path)}.${field}`, page_id: page.id, path: [...path, field], before, after, source }];
552
+ const tag = hintTagFor.get(field);
553
+ const hintRow = (record, recordPath) => {
554
+ const value = record?.sdk_hints?.meta_tags?.[tag];
555
+ if (!tag || !isNonEmptyString(value) || isAbsoluteHttpUrl(value.trim()) || !finalRoutes.has(after)) return null;
556
+ const next = routingHintValue(value, finalRoutes.get(after), publicRouteSlug);
557
+ if (next === value) return null;
558
+ return { field: `${pathLabel(recordPath)}.sdk_hints.meta_tags.${tag}`, page_id: page.id, path: [...recordPath, "sdk_hints", "meta_tags", tag], before: value, after: next, source: `route of page "${after}" (${source})` };
559
+ };
560
+ const own = hintRow(page, path);
561
+ if (own) rows.push(own);
562
+ const at = path[0] === "funnels" ? mirrorIndexOf(page.id) : -1;
563
+ if (at !== -1) {
564
+ const mirrorPath = ["funnel_pages", at];
565
+ if (isPlainObject(mirror[at]) && mirror[at][field] !== after) rows.push({ field: `${pathLabel(mirrorPath)}.${field}`, page_id: page.id, path: [...mirrorPath, field], before: mirror[at][field], after, source: `mirror of ${pathLabel(path)}.${field}` });
566
+ const mirrorHint = hintRow(mirror[at], mirrorPath);
567
+ if (mirrorHint) rows.push(mirrorHint);
568
+ }
569
+ scope.changes.push(...rows);
570
+ };
571
+ for (const { page, path } of activePages) {
572
+ if (removedIds.has(page.id)) continue;
573
+ const file = fileByPageId.get(page.id);
574
+ // A v4 spec states a step as a checkout; its file carries next_step,
575
+ // which only a page before the Checkout has, and that is its forward link.
576
+ const linkType = page.type === "checkout" && isNonEmptyString(file?.next_step) ? "checkout_step" : page.type;
577
+ for (const [key, linkField] of linkFieldsForType(linkType)) {
578
+ // A checkout with no success_url forwards through next_page (the
579
+ // generic fallback, campaign-spec/routing.ts), so that is the field
580
+ // its next_url re-points.
581
+ const field = linkField === "success_url" && !isNonEmptyString(page.success_url) && isNonEmptyString(page.next_page) ? "next_page" : linkField;
582
+ const current = isNonEmptyString(page[field]) ? page[field] : undefined;
583
+ const target = file ? resolveLink(file[key]) : null;
584
+ if (target && addedIds.has(target) && current !== target) {
585
+ repoint(page, path, field, current, target, `${file.path} frontmatter ${key}`);
586
+ } else if (current && removedIds.has(current)) {
587
+ if (target && target !== current) repoint(page, path, field, current, target, `${file.path} frontmatter ${key}`);
588
+ else scope.not_derived.push({ field: `${pathLabel(path)}.${field}`, page_id: page.id, reason: "routing_target_removed", detail: `page "${page.id}" routes ${field} to "${current}", which the tree removed, and ${file ? `${file.path}'s ${key} names no page the spec keeps` : "no page file states where it goes now"}; set ${field} by hand.` });
589
+ }
590
+ }
591
+ }
592
+ return scope;
593
+ }
594
+
265
595
  // The field-by-field plan. `changes` are the derived fields whose value will
266
596
  // move, `unchanged` already match, `not_derived` are derived fields the repo
267
597
  // cannot state for this campaign right now (with the reason), `not_in_target`
@@ -275,7 +605,7 @@ function pathLabel(path) {
275
605
  // does not exist); `packetBindings` as a Map of page id -> target file path
276
606
  // from the packet's page-kit projection; `waivedGates` as the checkpoint
277
607
  // gates an active named-human waiver currently covers.
278
- export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings = new Map(), waivedGates = [], waiversUnknown = false, publicRouteSlug = "" } = {}) {
608
+ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings = new Map(), waivedGates = [], waiversUnknown = false, publicRouteSlug = "", handedOff = false } = {}) {
279
609
  const target = isPlainObject(entry) ? entry : {};
280
610
  const changes = [];
281
611
  const unchanged = [];
@@ -348,8 +678,14 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
348
678
  for (const { page } of activePages) idCounts.set(page.id, (idCounts.get(page.id) || 0) + 1);
349
679
  // The route every bound page derives to, changed or not: the standing
350
680
  // check on routing hints reads it, so a hint left stale by an earlier run
351
- // keeps surfacing until the Map is re-saved.
681
+ // keeps surfacing until the hint is fixed.
352
682
  const derivedRouteByPageId = new Map();
683
+ // What the page-scope plan reads: the files a page binds, the files more
684
+ // than one page could be, and the pages no file binds.
685
+ const boundFiles = new Map();
686
+ const ambiguousFiles = new Set();
687
+ const notFound = [];
688
+ let pageScope = { handed_off: handedOff, added: [], removed: [], unclaimed: [], changes: [], not_derived: [] };
353
689
  if (pageFiles === null) {
354
690
  for (const { page, path } of activePages) {
355
691
  notDerived.push({ field: `${pathLabel(path)}.page_url`, page_id: page.id, reason: "page_tree_missing", detail: `the campaign's page tree does not exist in the target repo, so no route can be derived for page "${page.id}". Scaffold and build the campaign first, then derive.` });
@@ -369,11 +705,17 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
369
705
  }
370
706
  const binding = bindPageFile(page, pageFiles, packetBindings);
371
707
  if (!binding.file) {
372
- notDerived.push(binding.via === "ambiguous"
373
- ? { field, page_id: page.id, reason: "page_file_ambiguous", detail: `more than one page file could be page "${page.id}" (${binding.candidates.join(", ")}); add a permalink or rename so one file carries the route, then derive again.` }
374
- : { field, page_id: page.id, reason: "page_file_not_found", detail: `no page file in the page tree binds to page "${page.id}" (by the packet's page-kit projection, the page's current route ${JSON.stringify(publicRouteForPage(page))}, or a file named after the page id); the page has not been built, or was renamed past recognition. Build it, or name the file after the page, then derive again.` });
708
+ if (binding.via === "ambiguous") {
709
+ for (const candidate of binding.candidates) ambiguousFiles.add(candidate);
710
+ notDerived.push({ field, page_id: page.id, reason: "page_file_ambiguous", detail: `more than one page file could be page "${page.id}" (${binding.candidates.join(", ")}); add a permalink or rename so one file carries the route, then derive again.` });
711
+ } else {
712
+ const row = { field, page_id: page.id, reason: "page_file_not_found", detail: `no page file in the page tree binds to page "${page.id}" (by the packet's page-kit projection, the page's current route ${JSON.stringify(publicRouteForPage(page))}, or a file named after the page id); the page has not been built, or was renamed past recognition. Build it, or name the file after the page, then derive again.` };
713
+ notFound.push({ page, path, row });
714
+ notDerived.push(row);
715
+ }
375
716
  continue;
376
717
  }
718
+ boundFiles.set(binding.file.path, page.id);
377
719
  const after = binding.file.route;
378
720
  const before = Object.hasOwn(page, "page_url") ? page.page_url : undefined;
379
721
  const source = `${binding.file.path}${binding.file.permalink ? " (permalink)" : ""}`;
@@ -421,37 +763,55 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
421
763
  }
422
764
  }
423
765
  }
424
- // A routing hint that names a page whose derived route it does not match
425
- // is stale, whether the route moved in this run or an earlier one. Hints
426
- // are a spec projection the editor regenerates (doctor reads them as
427
- // build expectations), so they are reported, never rewritten.
428
- for (const { page } of activePages) {
429
- const metaTags = page.sdk_hints?.meta_tags;
430
- if (!isPlainObject(metaTags)) continue;
431
- for (const [tag, routingField] of Object.entries(ROUTING_HINT_FIELDS)) {
432
- const value = metaTags[tag];
433
- const targetId = page[routingField];
434
- if (!isNonEmptyString(value) || !isNonEmptyString(targetId) || !derivedRouteByPageId.has(targetId)) continue;
435
- const derived = derivedRouteByPageId.get(targetId);
436
- if (runtimeRelativeRouteForSpecValue(value, publicRouteSlug) === runtimeRelativeRouteForSpecValue(derived, publicRouteSlug)) continue;
437
- staleHints.push({ page_id: page.id, tag, value, target_page_id: targetId, derived_route: derived });
438
- }
766
+ // The pages the tree adds or removes (#721), matched by id.
767
+ pageScope = planPageScope({ spec, activePages, pageFiles, packetBindings, boundFiles, ambiguousFiles, notFound, derivedRouteByPageId, publicRouteSlug, handedOff });
768
+ changes.push(...pageScope.changes);
769
+ notDerived.push(...pageScope.not_derived);
770
+
771
+ // A routing hint that names a page whose route it does not match is
772
+ // stale, whether the route moved in this run or an earlier one. It is
773
+ // read off the spec as this plan leaves it, so a hint the plan rewrites
774
+ // is not reported and one an added or removed page leaves behind is:
775
+ // the same list doctor blocks on and record spec refuses after the
776
+ // write. Hints are a spec projection (doctor reads them as build
777
+ // expectations); derive rewrites only the hint on an edge it re-points.
778
+ const routesAfter = new Map([...derivedRouteByPageId].filter(([id]) => !pageScope.removed.some((entry) => entry.page_id === id)));
779
+ for (const entry of pageScope.added) routesAfter.set(entry.page_id, entry.route);
780
+ let planned = null;
781
+ try {
782
+ planned = applySpecDerive(structuredClone(spec), { changes });
783
+ } catch {
784
+ // A plan the guard would refuse is reported by the write; the hints
785
+ // are then read off the spec as it is.
439
786
  }
787
+ staleHints.push(...staleRoutingHints(planned || spec, { publicRouteSlug, routeByPageId: routesAfter }));
788
+ }
789
+ // A page the tree no longer builds is reported as not found unless this run
790
+ // removes it.
791
+ for (const removed of pageScope.removed) {
792
+ const at = notDerived.findIndex((row) => row.reason === "page_file_not_found" && row.page_id === removed.page_id);
793
+ if (at !== -1) notDerived.splice(at, 1);
440
794
  }
441
795
 
442
796
  // Analytics ids the entry carries. A usable id is written into the provider
443
- // block (created when the spec lacks it). An empty repo value never deletes
444
- // a spec id: the mismatch is reported so someone decides which side is
445
- // wrong.
446
- for (const { key, provider, property, shape, describe } of ANALYTICS_ID_FIELDS) {
447
- const field = `analytics.providers.${provider}.${property}`;
797
+ // or vendor block (created when the spec lacks it). An empty repo value
798
+ // never deletes a spec id: the mismatch is reported so someone decides
799
+ // which side is wrong. A vendor neither side carries is not listed under
800
+ // not_in_target: the template vendors are opt-in (absent means off), so
801
+ // eight "not in target" lines on every run would say nothing.
802
+ const analyticsIdRows = [
803
+ ...ANALYTICS_ID_FIELDS.map(({ key, provider, property, shape, describe }) => ({ key, block: "providers", name: provider, property, shape, describe, optIn: false })),
804
+ ...ANALYTICS_VENDOR_ID_FIELDS.map(({ key, vendor, property, shape, describe }) => ({ key, block: "vendors", name: vendor, property, shape, describe, optIn: true })),
805
+ ];
806
+ for (const { key, block, name, property, shape, describe, optIn } of analyticsIdRows) {
807
+ const field = `analytics.${block}.${name}.${property}`;
448
808
  const raw = Object.hasOwn(target, key) ? target[key] : undefined;
449
- const current = spec?.analytics?.providers?.[provider]?.[property];
809
+ const current = spec?.analytics?.[block]?.[name]?.[property];
450
810
  const source = entrySource(key);
451
811
  if (raw === undefined || raw === null || (typeof raw === "string" && !raw.trim())) {
452
812
  if (isNonEmptyString(current)) {
453
813
  notDerived.push({ field, reason: "target_empty", detail: `the target entry's ${key} is empty but the spec declares ${field} ${quoteValue(current)}; add the id to ${source} if the funnel should carry it, or remove it from the spec. Nothing was written.` });
454
- } else notInTarget.push(field);
814
+ } else if (!optIn) notInTarget.push(field);
455
815
  continue;
456
816
  }
457
817
  if (typeof raw !== "string" || hasControlCharacters(raw) || !shape.test(raw.trim())) {
@@ -459,11 +819,14 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
459
819
  continue;
460
820
  }
461
821
  const after = raw.trim();
462
- if (isPlaceholderId(after)) {
463
- notDerived.push({ field, reason: "target_invalid", detail: `the target entry's ${key} ${quoteValue(after)} is a placeholder, not a real id; writing it would declare an analytics contract QA then blocks on. Replace it in ${source} (or clear it), then derive again.` });
822
+ if (isPlaceholderId(after) || (block === "vendors" && isPlaceholderVendorId(after))) {
823
+ const consequence = block === "vendors"
824
+ ? "the template loads the vendor for any non-empty value, so the campaign is sending to a vendor account that does not exist"
825
+ : "writing it would declare an analytics contract QA then blocks on";
826
+ notDerived.push({ field, reason: "target_invalid", detail: `the target entry's ${key} ${quoteValue(after)} is a placeholder, not a real id; ${consequence}. Replace it in ${source} (or clear it), then derive again.` });
464
827
  continue;
465
828
  }
466
- const row = { field, path: ["analytics", "providers", provider, property], before: current, after, source };
829
+ const row = { field, path: ["analytics", block, name, property], before: current, after, source };
467
830
  const problem = containerProblem(spec, row.path);
468
831
  if (problem) notDerived.push({ field, reason: "spec_container_invalid", detail: `the spec's ${problem} is not an object, so ${field} cannot be written; repair the spec, then derive again.` });
469
832
  else if (current === after) unchanged.push(row);
@@ -475,8 +838,26 @@ export function planSpecDerive({ spec, entry, pageFiles = null, packetBindings =
475
838
  const createdBlocks = ANALYTICS_ID_FIELDS
476
839
  .filter(({ provider, property }) => changes.some((row) => row.field === `analytics.providers.${provider}.${property}`) && !isPlainObject(spec?.analytics?.providers?.[provider]))
477
840
  .map(({ provider }) => `analytics.providers.${provider}`);
841
+ // A vendor block derive will create declares the vendor: doctor then reads
842
+ // its campaigns.json key against the spec. When the spec had no analytics
843
+ // block at all, the new block also turns on QA's analytics leg.
844
+ const createdVendorBlocks = ANALYTICS_VENDOR_ID_FIELDS
845
+ .filter(({ vendor, property }) => changes.some((row) => row.field === `analytics.vendors.${vendor}.${property}`) && !isPlainObject(spec?.analytics?.vendors?.[vendor]))
846
+ .map(({ vendor }) => `analytics.vendors.${vendor}`);
847
+ const analyticsCreated = (createdBlocks.length > 0 || createdVendorBlocks.length > 0) && !isPlainObject(spec?.analytics);
478
848
 
479
- return { changes, unchanged, not_derived: notDerived, not_in_target: notInTarget, stale_hints: staleHints, created_blocks: [...new Set(createdBlocks)] };
849
+ const pageScopeSummary = { handed_off: pageScope.handed_off, added: pageScope.added, removed: pageScope.removed, unclaimed: pageScope.unclaimed };
850
+ return {
851
+ changes,
852
+ unchanged,
853
+ not_derived: notDerived,
854
+ not_in_target: notInTarget,
855
+ stale_hints: staleHints,
856
+ created_blocks: [...new Set(createdBlocks)],
857
+ created_vendor_blocks: createdVendorBlocks,
858
+ analytics_created: analyticsCreated,
859
+ page_scope: pageScopeSummary,
860
+ };
480
861
  }
481
862
 
482
863
  function pathMatchesPattern(path, pattern) {
@@ -487,16 +868,35 @@ function pathMatchesPattern(path, pattern) {
487
868
 
488
869
  // Apply a plan to the parsed spec. Mutates ONLY the derived field each change
489
870
  // names, in place, so key order and every other key survive; a missing
490
- // container object is created (`global_config`, `analytics.providers.gtm`
491
- // as `{ enabled: true }`), a missing array element is never invented. Returns
492
- // the same spec object.
871
+ // container object is created (`global_config`; a provider or vendor block
872
+ // such as `analytics.providers.gtm` or `analytics.vendors.ga4` as
873
+ // `{ enabled: true }`), a missing array element is never invented. A row
874
+ // with `op` adds ("insert", appended at its index) or removes ("remove") a
875
+ // whole element of a list derive governs (a page, an offer record): field
876
+ // writes go first, against the indices the plan read, then inserts, then
877
+ // removals from the highest index down, so no write lands on a shifted
878
+ // element. Returns the same spec object.
493
879
  export function applySpecDerive(spec, plan) {
494
880
  if (!isPlainObject(spec)) throw new Error("CampaignSpec must be a JSON object.");
881
+ const writable = [...SPEC_DERIVE_FIELDS, ...SPEC_DERIVE_MIRROR_FIELDS];
882
+ const fieldRows = [];
883
+ const inserts = [];
884
+ const removes = [];
495
885
  for (const change of plan.changes) {
496
886
  const path = Array.isArray(change.path) ? change.path : [];
497
- if (!SPEC_DERIVE_FIELDS.some((pattern) => pathMatchesPattern(path, pattern))) {
498
- throw new Error(`Refusing to write "${change.field}": spec derive governs only ${SPEC_DERIVE_FIELDS.join(", ")}.`);
887
+ if (change.op === "insert" || change.op === "remove") {
888
+ if (!SPEC_DERIVE_ELEMENTS.some((pattern) => pathMatchesPattern(path, pattern))) {
889
+ throw new Error(`Refusing to ${change.op === "insert" ? "add" : "remove"} "${change.field}": spec derive adds and removes only ${SPEC_DERIVE_ELEMENTS.join(", ")}.`);
890
+ }
891
+ (change.op === "insert" ? inserts : removes).push({ change, path });
892
+ continue;
499
893
  }
894
+ if (!writable.some((pattern) => pathMatchesPattern(path, pattern))) {
895
+ throw new Error(`Refusing to write "${change.field}": spec derive governs only ${SPEC_DERIVE_FIELDS.join(", ")} (and, after handoff or from the live campaign, ${SPEC_DERIVE_MIRROR_FIELDS.join(", ")}).`);
896
+ }
897
+ fieldRows.push({ change, path });
898
+ }
899
+ for (const { change, path } of fieldRows) {
500
900
  let node = spec;
501
901
  for (let index = 0; index < path.length - 1; index += 1) {
502
902
  const segment = path[index];
@@ -508,9 +908,9 @@ export function applySpecDerive(spec, plan) {
508
908
  const nextIsIndex = Number.isInteger(path[index + 1]);
509
909
  if (node[segment] === undefined || node[segment] === null) {
510
910
  if (nextIsIndex) throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not an array in the spec.`);
511
- // A provider block created by derive is enabled: an id the repo
512
- // carries is a tag the funnel fires, which is what QA then expects.
513
- node[segment] = path[index - 1] === "providers" ? { enabled: true } : {};
911
+ // A provider or vendor block created by derive is enabled: an id the
912
+ // repo carries is a tag the funnel fires, which is what QA then expects.
913
+ node[segment] = path[index - 1] === "providers" || path[index - 1] === "vendors" ? { enabled: true } : {};
514
914
  } else if (nextIsIndex ? !Array.isArray(node[segment]) : !isPlainObject(node[segment])) {
515
915
  throw new Error(`Refusing to write "${change.field}": ${pathLabel(path.slice(0, index + 1))} is not ${nextIsIndex ? "an array" : "an object"} in the spec.`);
516
916
  }
@@ -518,6 +918,26 @@ export function applySpecDerive(spec, plan) {
518
918
  }
519
919
  node[path[path.length - 1]] = change.after;
520
920
  }
921
+ const listAt = (path, change) => {
922
+ let node = spec;
923
+ for (const segment of path.slice(0, -1)) {
924
+ node = node?.[segment];
925
+ if (node === undefined || node === null) break;
926
+ }
927
+ if (!Array.isArray(node)) throw new Error(`Refusing to change "${change.field}": ${pathLabel(path.slice(0, -1))} is not an array in the spec.`);
928
+ return node;
929
+ };
930
+ for (const { change, path } of inserts.sort((a, b) => a.path[a.path.length - 1] - b.path[b.path.length - 1])) {
931
+ const list = listAt(path, change);
932
+ if (path[path.length - 1] !== list.length || !isPlainObject(change.after)) throw new Error(`Refusing to add "${change.field}": it is not the next element of ${pathLabel(path.slice(0, -1))}.`);
933
+ list.push(change.after);
934
+ }
935
+ for (const { change, path } of removes.sort((a, b) => b.path[b.path.length - 1] - a.path[a.path.length - 1])) {
936
+ const list = listAt(path, change);
937
+ const index = path[path.length - 1];
938
+ if (!isPlainObject(list[index]) || (isNonEmptyString(change.page_id) && list[index].id !== change.page_id)) throw new Error(`Refusing to remove "${change.field}": it is not page "${change.page_id}" in the spec.`);
939
+ list.splice(index, 1);
940
+ }
521
941
  return spec;
522
942
  }
523
943