@nextcommerce/campaigns-os 1.60.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 (161) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +86 -438
  4. package/agents/claude/CLAUDE.md +20 -0
  5. package/agents/codex/AGENTS.md +19 -0
  6. package/agents/copilot/copilot-instructions.md +19 -0
  7. package/agents/cursor/campaigns-os.mdc +21 -0
  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/brand-theme-target-tokens.next-core.v0.json +95 -25
  26. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  27. package/contracts/commerce-surface-catalog.json +68 -61
  28. package/contracts/effects.v1.json +549 -165
  29. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
  30. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
  31. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  32. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
  33. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
  34. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
  35. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
  36. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
  37. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
  38. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
  42. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
  44. package/contracts/release-ledger.json +1767 -0
  45. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
  46. package/contracts/supported-surface.json +10 -10
  47. package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
  48. package/docs/access-model.md +20 -0
  49. package/docs/brand-theme-bridge.md +62 -3
  50. package/docs/build-packet.md +456 -36
  51. package/docs/campaign-build-brief.md +16 -0
  52. package/docs/campaign-standardization-report.md +4 -4
  53. package/docs/campaigns-os-build-flow.md +112 -2
  54. package/docs/campaignspec-authoring-examples.md +141 -0
  55. package/docs/design-source-package.md +64 -8
  56. package/docs/effects.md +17 -9
  57. package/docs/entry-points.md +188 -0
  58. package/docs/local-setup.md +1 -1
  59. package/docs/orientation-contract-reference.md +1 -1
  60. package/docs/polish-evidence.md +42 -2
  61. package/docs/progress-snapshots.md +7 -4
  62. package/docs/qa-and-test-orders.md +197 -3
  63. package/docs/quickstart.md +438 -0
  64. package/docs/readback.md +154 -6
  65. package/docs/runtime-readiness.md +6 -3
  66. package/docs/sdk-storage-compatibility.md +4 -2
  67. package/docs/skills-revision.md +10 -10
  68. package/docs/source-adapters.md +213 -0
  69. package/package.json +10 -3
  70. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  71. package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
  72. package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
  73. package/schemas/campaign-spec.v4.schema.json +19 -3
  74. package/schemas/campaign-spec.v5.schema.json +19 -3
  75. package/schemas/campaigns-os-readback.v2.schema.json +238 -1
  76. package/schemas/source-html-manifest.v0.schema.json +3 -0
  77. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  78. package/skills/campaign-readback-classification/SKILL.md +3 -3
  79. package/skills/campaign-run-evidence/SKILL.md +3 -3
  80. package/skills/contribution-intake/SKILL.md +3 -3
  81. package/skills/next-campaigns-build/SKILL.md +5 -4
  82. package/skills/next-campaigns-os/SKILL.md +7 -4
  83. package/skills/next-campaigns-os-setup/SKILL.md +5 -3
  84. package/skills/next-campaigns-polish/SKILL.md +8 -10
  85. package/skills/next-campaigns-qa/SKILL.md +7 -4
  86. package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
  87. package/skills.json +11 -11
  88. package/src/brand-theme.mjs +415 -9
  89. package/src/brief-summary.mjs +6 -2
  90. package/src/browser-launch.mjs +2 -1
  91. package/src/build-brief.mjs +13 -3
  92. package/src/built-site-scope.mjs +2 -2
  93. package/src/campaign-workspace.mjs +288 -15
  94. package/src/change-scope.mjs +284 -0
  95. package/src/cli.mjs +358 -97
  96. package/src/commercial-journey.mjs +16 -3
  97. package/src/commercial-parity.mjs +15 -7
  98. package/src/content-residue.mjs +4 -2
  99. package/src/design-file-reference.mjs +93 -0
  100. package/src/design-source-package.mjs +91 -1
  101. package/src/design-source-publication.mjs +9 -6
  102. package/src/directory-lock.mjs +73 -19
  103. package/src/doctor/checks.mjs +87 -81
  104. package/src/doctor/ci.mjs +177 -0
  105. package/src/doctor/inspect.mjs +41 -6
  106. package/src/doctor/next-step.mjs +7 -0
  107. package/src/doctor/source-provenance.mjs +74 -0
  108. package/src/doctor-sidecar.mjs +3 -2
  109. package/src/evidence-debt.mjs +159 -0
  110. package/src/finding-cause.mjs +12 -11
  111. package/src/input-refresh.mjs +339 -32
  112. package/src/invocation.mjs +7 -1
  113. package/src/live-campaign-refs.mjs +7 -2
  114. package/src/local-preview-policy.mjs +35 -2
  115. package/src/page-kit-analytics-vendors.mjs +132 -0
  116. package/src/parity/compare.mjs +316 -0
  117. package/src/parity/design-access.mjs +54 -0
  118. package/src/parity/element-parity-record.mjs +569 -0
  119. package/src/parity/probe.mjs +205 -0
  120. package/src/parity/snapshot.mjs +110 -0
  121. package/src/png-header.mjs +87 -0
  122. package/src/polish-browser.mjs +189 -10
  123. package/src/polish-captures-record.mjs +52 -0
  124. package/src/polish-deadline.mjs +14 -0
  125. package/src/polish-gate.mjs +25 -6
  126. package/src/polish-node.mjs +81 -7
  127. package/src/polish-readability.mjs +221 -9
  128. package/src/polish-visual-fixtures.mjs +74 -0
  129. package/src/polish-visual.mjs +613 -0
  130. package/src/progress-node.mjs +3 -2
  131. package/src/qa-analytics-correctness.mjs +14 -2
  132. package/src/qa-analytics-parity.mjs +111 -6
  133. package/src/qa-browser.mjs +40 -4
  134. package/src/qa-commercial-parity.mjs +50 -3
  135. package/src/qa-node.mjs +235 -22
  136. package/src/qa-publish.mjs +9 -8
  137. package/src/qa-sidecar.mjs +16 -4
  138. package/src/qa-vendor-beacons.mjs +232 -0
  139. package/src/qa-vendor-endpoints.mjs +306 -0
  140. package/src/qc-accept.mjs +176 -6
  141. package/src/qc-check-registry.mjs +42 -2
  142. package/src/qc-results.mjs +477 -42
  143. package/src/readback.mjs +113 -21
  144. package/src/route-identity.mjs +10 -0
  145. package/src/run-record.mjs +18 -10
  146. package/src/runtime-state-ignore.mjs +6 -2
  147. package/src/sdk-attribute-index.mjs +11 -11
  148. package/src/sdk-markup.mjs +77 -2
  149. package/src/sdk-storage-compatibility.mjs +115 -6
  150. package/src/source-html-intake.mjs +15 -1
  151. package/src/source-html-manifest.mjs +7 -0
  152. package/src/source-html-server.mjs +129 -0
  153. package/src/spec-derive-campaign.mjs +216 -0
  154. package/src/spec-derive.mjs +473 -53
  155. package/src/spec-identity.mjs +14 -0
  156. package/src/stage-ledger.mjs +40 -1
  157. package/src/stage-record.mjs +100 -17
  158. package/src/target-lock.mjs +11 -1
  159. package/src/theme-gate.mjs +2 -2
  160. package/src/visual-diff.mjs +296 -0
  161. package/src/visual-reference.mjs +126 -0
@@ -96,14 +96,14 @@ function listHtmlFiles(root, links = null) {
96
96
  return files.sort();
97
97
  }
98
98
 
99
- function routeForFile(campaignDir, file) {
99
+ export function routeForFile(campaignDir, file) {
100
100
  const rel = relative(campaignDir, file).split(sep).join("/");
101
101
  if (rel === "index.html") return "";
102
102
  if (rel.endsWith("/index.html")) return rel.slice(0, -"/index.html".length);
103
103
  return rel.replace(/\.html$/i, "");
104
104
  }
105
105
 
106
- function pageIdForRoute(route) {
106
+ export function pageIdForRoute(route) {
107
107
  if (!route) return "index";
108
108
  return route.replace(/\//g, "-").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "index";
109
109
  }
@@ -2,21 +2,86 @@
2
2
  //
3
3
  // Two roots matter. The TARGET REPO (`packet.assembly.target_repo` resolved
4
4
  // against the packet's directory, else that directory) carries the build
5
- // sidecars under `.campaign-runtime/` and the local QA verdicts under
6
- // `qa-output/`; the packet's own directory (`baseDir`) carries what is written
7
- // beside the packet (run records, the committed QA verdict sidecar). They
8
- // coincide for a packet at the target root and diverge for `prepare-build
9
- // --out` elsewhere — exactly when a stage spelling the rule for itself drifts.
5
+ // sidecars under the funnel's runtime directory and the local QA verdicts
6
+ // under `qa-output/`; the packet's own directory (`baseDir`) carries what is
7
+ // written beside the packet (run records, the committed QA verdict sidecar).
8
+ // They coincide for a packet at the target root and diverge for
9
+ // `prepare-build --out` elsewhere — exactly when a stage spelling the rule for
10
+ // itself drifts.
11
+ //
12
+ // One Page Kit project can hold several funnels. Each funnel has its own
13
+ // packet and its own runtime directory, so setting one up never touches
14
+ // another's records (#719). The first funnel set up in a project keeps the
15
+ // project-level `.campaign-runtime/`, which is also where every packet that
16
+ // records no `assembly.runtime_dir` (all packets before per-funnel records)
17
+ // keeps its records. Every later funnel gets `.campaign-runtime/funnels/<public
18
+ // route slug>/`, recorded on its packet as `assembly.runtime_dir`, and its
19
+ // packet is named `campaign-runtime-<public route slug>.build.json` beside the
20
+ // default `campaign-runtime.build.json`.
10
21
  // A leaf: node built-ins and the sidecar leaves only.
11
22
 
12
- import { readFileSync } from "node:fs";
13
- import { dirname, join, resolve } from "node:path";
23
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { basename, dirname, join, resolve } from "node:path";
14
25
  import { DOCTOR_SIDECAR_REL_PATH } from "./doctor-sidecar.mjs";
15
26
  import { absentOrMalformed, sameFile } from "./fs-identity.mjs";
27
+ import { normalizePublicRouteSlug } from "./route-identity.mjs";
16
28
 
29
+ export const RUNTIME_DIR_REL_PATH = ".campaign-runtime";
30
+ export const FUNNEL_RUNTIME_PARENT_REL_PATH = ".campaign-runtime/funnels";
17
31
  export const BUILD_CONTEXT_REL_PATH = ".campaign-runtime/build-context.json";
18
32
  export const ASSEMBLY_REPORT_REL_PATH = ".campaign-runtime/assembly-report.json";
19
33
  export const QA_OUTPUT_REL_PATH = "qa-output";
34
+ export const DEFAULT_PACKET_FILENAME = "campaign-runtime.build.json";
35
+ // A project's other root-level packets, one per additional funnel. The same
36
+ // shape readback discovers (src/readback.mjs PACKET_CANDIDATE_PATTERN).
37
+ export const FUNNEL_PACKET_PATTERN = /^campaign-runtime-.+\.build\.json$/;
38
+
39
+ // A route slug that can name a directory and a file: the characters the packet
40
+ // schema allows in route_root, not starting with a dot (so never `.` or `..`).
41
+ const ROUTE_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
42
+
43
+ function routeComponent(routeSlug) {
44
+ const slug = normalizePublicRouteSlug(routeSlug);
45
+ if (!ROUTE_COMPONENT.test(slug)) {
46
+ throw new Error(
47
+ `Public route slug ${JSON.stringify(routeSlug)} cannot name a funnel's runtime directory or packet file: `
48
+ + "use letters, digits, dots, underscores and hyphens, not starting with a dot.",
49
+ );
50
+ }
51
+ return slug;
52
+ }
53
+
54
+ // `.campaign-runtime/funnels/<route>`: a funnel's runtime directory when it is
55
+ // not the project's first, relative to the target repo.
56
+ export function funnelRuntimeRelPath(routeSlug) {
57
+ return `${FUNNEL_RUNTIME_PARENT_REL_PATH}/${routeComponent(routeSlug)}`;
58
+ }
59
+
60
+ // `campaign-runtime-<route>.build.json`: the packet of a funnel that is not
61
+ // the project's first.
62
+ export function funnelPacketFilename(routeSlug) {
63
+ return `campaign-runtime-${routeComponent(routeSlug)}.build.json`;
64
+ }
65
+
66
+ // The runtime directory a packet's records live under, relative to its target
67
+ // repo (and, for the QA verdict sidecar and Run Records, to the packet's own
68
+ // directory). A packet that records no `assembly.runtime_dir` uses the
69
+ // project-level `.campaign-runtime`, as every packet did before per-funnel
70
+ // records, so an existing project reads its records where they already are.
71
+ // A recorded value must be one of the two shapes for this packet's route:
72
+ // anything else could point one funnel at another's records, so it is refused
73
+ // rather than followed.
74
+ export function packetRuntimeRelPath(packet) {
75
+ const declared = packet?.assembly?.runtime_dir;
76
+ if (declared === undefined || declared === null || declared === RUNTIME_DIR_REL_PATH) return RUNTIME_DIR_REL_PATH;
77
+ const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
78
+ if (typeof declared === "string" && ROUTE_COMPONENT.test(slug) && declared === `${FUNNEL_RUNTIME_PARENT_REL_PATH}/${slug}`) return declared;
79
+ throw new Error(
80
+ `Build Packet assembly.runtime_dir ${JSON.stringify(declared)} is not this funnel's runtime directory: `
81
+ + `it must be "${RUNTIME_DIR_REL_PATH}" or "${FUNNEL_RUNTIME_PARENT_REL_PATH}/<campaign.public_route_slug>"`
82
+ + `${slug ? ` ("${FUNNEL_RUNTIME_PARENT_REL_PATH}/${slug}")` : ""}. Restore the value prepare-build wrote, or re-run prepare-build.`,
83
+ );
84
+ }
20
85
 
21
86
  // The target repo a packet builds into.
22
87
  export function targetRepoFor(packetPath, packet) {
@@ -25,12 +90,16 @@ export function targetRepoFor(packetPath, packet) {
25
90
  return typeof declared === "string" && declared.trim() ? resolve(packetDir, declared) : packetDir;
26
91
  }
27
92
 
28
- // The default location of every build sidecar a target repo carries.
29
- export function campaignSidecarPaths(targetRepo) {
93
+ // The default location of every build sidecar a funnel carries: under the
94
+ // target repo's project-level `.campaign-runtime/` unless the packet records
95
+ // its own runtime directory (packetRuntimeRelPath).
96
+ export function campaignSidecarPaths(targetRepo, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
97
+ const runtimeDir = join(targetRepo, runtimeRelPath);
30
98
  return {
31
- contextPath: join(targetRepo, BUILD_CONTEXT_REL_PATH),
32
- reportPath: join(targetRepo, ASSEMBLY_REPORT_REL_PATH),
33
- doctorOutPath: join(targetRepo, DOCTOR_SIDECAR_REL_PATH),
99
+ runtimeDir,
100
+ contextPath: join(runtimeDir, basename(BUILD_CONTEXT_REL_PATH)),
101
+ reportPath: join(runtimeDir, basename(ASSEMBLY_REPORT_REL_PATH)),
102
+ doctorOutPath: join(runtimeDir, basename(DOCTOR_SIDECAR_REL_PATH)),
34
103
  qaOutputDir: join(targetRepo, QA_OUTPUT_REL_PATH),
35
104
  };
36
105
  }
@@ -44,9 +113,11 @@ export function campaignSidecarPaths(targetRepo) {
44
113
  // report IS that default, so the printed command stays short; the path when
45
114
  // it is not, or when the target repo is unknown and the default cannot be
46
115
  // ruled out. Doctor's report and the QA runner's both print through this.
47
- export function explicitReportPath(reportPath, targetRepo) {
116
+ // `runtimeRelPath` is the packet's runtime directory (packetRuntimeRelPath),
117
+ // since that is where those commands infer the report for that packet.
118
+ export function explicitReportPath(reportPath, targetRepo, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
48
119
  if (typeof reportPath !== "string" || !reportPath) return null;
49
- const inferred = typeof targetRepo === "string" && targetRepo ? campaignSidecarPaths(targetRepo).reportPath : null;
120
+ const inferred = typeof targetRepo === "string" && targetRepo ? campaignSidecarPaths(targetRepo, runtimeRelPath).reportPath : null;
50
121
  return reportPath !== inferred ? reportPath : null;
51
122
  }
52
123
 
@@ -99,7 +170,8 @@ export function resolveCampaignWorkspace(packetPath, {
99
170
  const loadedPacket = packet === undefined ? JSON.parse(readFileSync(absolutePacketPath, "utf8")) : packet;
100
171
  const baseDir = dirname(absolutePacketPath);
101
172
  const targetRepo = targetRepoFor(absolutePacketPath, loadedPacket);
102
- const defaults = campaignSidecarPaths(targetRepo);
173
+ const runtimeRelPath = packetRuntimeRelPath(loadedPacket);
174
+ const defaults = campaignSidecarPaths(targetRepo, runtimeRelPath);
103
175
  const resolvedContextPath = contextPath === undefined ? defaults.contextPath : contextPath;
104
176
  const context = followContextPointer ? readContextForBinding(resolvedContextPath) : null;
105
177
  const recorded = typeof context?.report_path === "string" && context.report_path.trim() ? context.report_path.trim() : null;
@@ -118,6 +190,11 @@ export function resolveCampaignWorkspace(packetPath, {
118
190
  packetPath: absolutePacketPath,
119
191
  baseDir,
120
192
  targetRepo,
193
+ // The funnel's runtime directory under the target repo, and the same
194
+ // directory beside the packet (QA verdict sidecar, Run Records).
195
+ runtimeRelPath,
196
+ runtimeDir: defaults.runtimeDir,
197
+ packetRuntimeDir: join(baseDir, runtimeRelPath),
121
198
  contextPath: resolvedContextPath,
122
199
  reportPath: resolvedReportPath,
123
200
  defaultReportPath: defaults.reportPath,
@@ -125,3 +202,199 @@ export function resolveCampaignWorkspace(packetPath, {
125
202
  qaOutputDir: defaults.qaOutputDir,
126
203
  };
127
204
  }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Several funnels in one project.
208
+
209
+ function readJsonState(path) {
210
+ try {
211
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
212
+ return { state: "present", value: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null };
213
+ } catch (error) {
214
+ if (error?.code === "ENOENT") return { state: "absent", value: null };
215
+ if (absentOrMalformed(error)) return { state: "present", value: null };
216
+ throw error;
217
+ }
218
+ }
219
+
220
+ function routeOf(packet) {
221
+ return normalizePublicRouteSlug(packet?.campaign?.public_route_slug) || null;
222
+ }
223
+
224
+ // The root-level Build Packets of a project, default name first, then one per
225
+ // additional funnel by name, each with the route it names (null when the
226
+ // packet names none), its generated_at instant in milliseconds (null when it
227
+ // records none it can parse) and whether it could be read at all.
228
+ export function projectFunnelPackets(root) {
229
+ const projectRoot = resolve(root);
230
+ let names = [];
231
+ try {
232
+ names = readdirSync(projectRoot);
233
+ } catch {
234
+ names = [];
235
+ }
236
+ const ordered = [
237
+ ...(names.includes(DEFAULT_PACKET_FILENAME) ? [DEFAULT_PACKET_FILENAME] : []),
238
+ ...names.filter((name) => FUNNEL_PACKET_PATTERN.test(name)).sort(),
239
+ ];
240
+ const packets = [];
241
+ for (const name of ordered) {
242
+ const path = join(projectRoot, name);
243
+ try {
244
+ if (!statSync(path).isFile()) continue;
245
+ } catch {
246
+ continue;
247
+ }
248
+ const { value } = readJsonState(path);
249
+ const generated = typeof value?.generated_at === "string" ? Date.parse(value.generated_at) : Number.NaN;
250
+ packets.push({ name, path, route: routeOf(value), readable: Boolean(value), generatedAt: Number.isFinite(generated) ? generated : null });
251
+ }
252
+ return packets;
253
+ }
254
+
255
+ // The packet a command acts on when it was given no --packet, chosen the way
256
+ // readback discovers one: null when there is none, the only readable packet,
257
+ // or among packets that all name one route (later runs of one funnel) the
258
+ // newest by generated_at, the default name first on a tie. Packets naming
259
+ // different routes are different funnels, and picking one would read or write
260
+ // another funnel's records, so that is refused with every packet named.
261
+ export function soleProjectPacket(root, { command = "this command" } = {}) {
262
+ const packets = projectFunnelPackets(root);
263
+ const readable = packets.filter((packet) => packet.readable);
264
+ if (!readable.length) return packets[0]?.path || null;
265
+ if (new Set(readable.map((packet) => packet.route)).size > 1) {
266
+ const listed = readable.map((packet) => `${packet.name} (route ${packet.route || "unnamed"})`).join(", ");
267
+ throw Object.assign(new Error(
268
+ `${resolve(root)} holds Build Packets for ${new Set(readable.map((packet) => packet.route)).size} funnels: ${listed}. `
269
+ + `Pass --packet <packet> to ${command} to name the funnel to act on.`,
270
+ ), { code: "MULTIPLE_FUNNEL_PACKETS", packets: readable.map((packet) => packet.path) });
271
+ }
272
+ const newest = readable.reduce((best, packet) => ((packet.generatedAt ?? -Infinity) > (best.generatedAt ?? -Infinity) ? packet : best));
273
+ return newest.path;
274
+ }
275
+
276
+ // The route the project-level records belong to: undefined when there are
277
+ // none, the route the Assembly Report (else the Build Context's packet) names,
278
+ // or null when records exist but name no route anyone can read.
279
+ function projectRecordsRoute(targetRepo) {
280
+ const defaults = campaignSidecarPaths(targetRepo);
281
+ const report = readJsonState(defaults.reportPath);
282
+ const context = readJsonState(defaults.contextPath);
283
+ if (report.state === "absent" && context.state === "absent") return undefined;
284
+ const reported = normalizePublicRouteSlug(report.value?.identity?.public_route_slug);
285
+ if (reported) return reported;
286
+ const named = typeof context.value?.packet_path === "string" && context.value.packet_path.trim()
287
+ ? resolve(targetRepo, context.value.packet_path.trim())
288
+ : null;
289
+ return named ? routeOf(readJsonState(named).value) : null;
290
+ }
291
+
292
+ /**
293
+ * Where prepare-build puts one funnel's packet and records.
294
+ *
295
+ * Packet: an explicit `--out` wins. Otherwise the default
296
+ * `campaign-runtime.build.json` when it is this route's, else the funnel's own
297
+ * `campaign-runtime-<route>.build.json` when it already exists, else the
298
+ * default when it is absent, else the funnel's own name. A Build Packet that
299
+ * exists at the chosen path for another route, or one whose route cannot be
300
+ * read, is replaced only with `force`: that is the operator saying this
301
+ * funnel takes the other's place (renaming a funnel's route), never a side
302
+ * effect of naming a path.
303
+ *
304
+ * Records: a packet already at that path for this route keeps the runtime
305
+ * directory it records (the project-level one when it records none and those
306
+ * records are this route's or absent). A packet replaced with `force` whose records are the
307
+ * project-level ones hands those to this funnel, so renaming a project's first
308
+ * funnel keeps the default layout. Otherwise the funnel's own directory when
309
+ * it already exists, else the project-level `.campaign-runtime` when it holds
310
+ * no records or this route's, else the funnel's own directory. Records another
311
+ * route owns, or records whose route cannot be read, are never replaced.
312
+ *
313
+ * Returns `{ packetPath, runtimeRelPath, scoped, addedBeside }`; `scoped` is
314
+ * true when the packet must record `assembly.runtime_dir`, and `addedBeside`
315
+ * names the default packet and its route when this run sets the funnel up as
316
+ * a new one beside it (null otherwise).
317
+ */
318
+ export function planFunnelWorkspace(targetRepo, routeSlug, { packetPath: explicitPacketPath = null, force = false } = {}) {
319
+ const repo = resolve(targetRepo);
320
+ const slug = normalizePublicRouteSlug(routeSlug);
321
+ const nameable = ROUTE_COMPONENT.test(slug);
322
+ const rootPacket = join(repo, DEFAULT_PACKET_FILENAME);
323
+ const ownPacket = nameable ? join(repo, funnelPacketFilename(slug)) : null;
324
+ const rootPacketState = readJsonState(rootPacket);
325
+ const rootPacketRoute = routeOf(rootPacketState.value);
326
+
327
+ let packetPath = explicitPacketPath ? resolve(explicitPacketPath) : null;
328
+ if (!packetPath) {
329
+ const rootPacketFree = rootPacketState.state === "absent" || rootPacketRoute === slug;
330
+ packetPath = rootPacketRoute === slug ? rootPacket
331
+ : ownPacket && existsSync(ownPacket) ? ownPacket
332
+ : rootPacketFree || !ownPacket ? rootPacket
333
+ : ownPacket;
334
+ if (!rootPacketFree && packetPath === rootPacket && !force) {
335
+ throw new Error(
336
+ `${rootPacket} belongs to route ${rootPacketRoute || "(unreadable)"}, and route ${JSON.stringify(slug)} cannot name a packet of its own. `
337
+ + "Pass --out <packet> to keep this funnel's packet elsewhere, or set a public route slug of letters, digits, dots, underscores and hyphens.",
338
+ );
339
+ }
340
+ }
341
+
342
+ const existingState = readJsonState(packetPath);
343
+ const existing = existingState.value;
344
+ const existingRoute = routeOf(existing);
345
+ const replacing = isBuildPacketAt(packetPath, existingState) && existingRoute !== slug;
346
+ if (replacing && !force) {
347
+ throw new Error(
348
+ `${packetPath} is the Build Packet of route ${existingRoute || "(unreadable)"}, not ${JSON.stringify(slug)}; replacing it would leave that funnel's records with no packet. `
349
+ + "Pass a different --out to set this funnel up beside it, or --force to replace that funnel with this one (renaming its route).",
350
+ );
351
+ }
352
+ const addedBeside = !replacing && existingState.state === "absent" && packetPath !== rootPacket
353
+ && rootPacketState.state === "present" && rootPacketRoute !== slug
354
+ ? { packetPath: rootPacket, route: rootPacketRoute }
355
+ : null;
356
+ const plan = (runtimeRelPath) => ({ packetPath, runtimeRelPath, scoped: runtimeRelPath !== RUNTIME_DIR_REL_PATH, addedBeside });
357
+
358
+ const owner = projectRecordsRoute(repo);
359
+ if (existing && existingRoute === slug) {
360
+ if (existing.assembly?.runtime_dir != null) return plan(packetRuntimeRelPath(existing));
361
+ if (owner === undefined || owner === slug) return plan(RUNTIME_DIR_REL_PATH);
362
+ }
363
+ if (replacing && existing && replacedRuntimeRelPath(existing) === RUNTIME_DIR_REL_PATH
364
+ && (owner === undefined || owner === null || owner === existingRoute || owner === slug)) {
365
+ return plan(RUNTIME_DIR_REL_PATH);
366
+ }
367
+ const ownRuntime = nameable ? funnelRuntimeRelPath(slug) : null;
368
+ if (ownRuntime && existsSync(join(repo, ownRuntime))) return plan(ownRuntime);
369
+ if (owner === undefined || owner === slug) return plan(RUNTIME_DIR_REL_PATH);
370
+ if (!ownRuntime) {
371
+ throw new Error(
372
+ `${join(repo, RUNTIME_DIR_REL_PATH)} holds the records of route ${owner || "(unreadable)"}, and route ${JSON.stringify(slug)} cannot name a runtime directory of its own. `
373
+ + "Set a public route slug of letters, digits, dots, underscores and hyphens.",
374
+ );
375
+ }
376
+ return plan(ownRuntime);
377
+ }
378
+
379
+ // Whether the file at `path` is a Build Packet another funnel may own: a
380
+ // packet by its contents, or a file at a packet's name that cannot be read.
381
+ // Any other file there is left to prepare-build's output collision checks.
382
+ function isBuildPacketAt(path, { state, value }) {
383
+ if (state !== "present") return false;
384
+ if (value) {
385
+ return (typeof value.schema_version === "string" && value.schema_version.startsWith("campaign-runtime-build-packet/"))
386
+ || (value.campaign !== null && typeof value.campaign === "object" && !Array.isArray(value.campaign));
387
+ }
388
+ const name = basename(path);
389
+ return name === DEFAULT_PACKET_FILENAME || FUNNEL_PACKET_PATTERN.test(name);
390
+ }
391
+
392
+ // The runtime directory a packet being replaced keeps its records in, or null
393
+ // when its recorded value is not one prepare-build writes.
394
+ function replacedRuntimeRelPath(packet) {
395
+ try {
396
+ return packetRuntimeRelPath(packet);
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
@@ -0,0 +1,284 @@
1
+ // Change scope: what a change to built output owes.
2
+ //
3
+ // A change that touches only some pages' copy, images or styling owes QA on
4
+ // those pages; Polish is advisory for it. A change that touches commerce owes
5
+ // the full Polish and QA. Commerce here is what a page sends to the Campaign
6
+ // Cart SDK or the funnel: the campaign's Page Kit config entry (SDK pin,
7
+ // analytics ids, store fields), package, offer and shipping refs, the upsell
8
+ // order and routes, and scripts. A file that cannot be mapped to one page
9
+ // counts as commerce, and so does any change this module cannot classify.
10
+ //
11
+ // `record build` keeps a commerce signature beside the build manifest: one
12
+ // hash per built page over the page's commerce-bearing markup, plus one hash
13
+ // of the campaign's config entry. A later change compares the pages that
14
+ // differ against those hashes. It also keeps `build_changes`, one entry per
15
+ // recorded rebuild, so a stage that reviewed an older build can still read
16
+ // which pages changed since then after the manifest has moved on.
17
+ import { createHash } from "node:crypto";
18
+ import { readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ import { parse } from "parse5";
22
+
23
+ import { inferPageType, pageIdForRoute, routeForFile } from "./built-site-scope.mjs";
24
+ import { loadPageKitCampaignEntry } from "./page-kit-campaign-config.mjs";
25
+
26
+ export const COMMERCE_SIGNATURE_ALGORITHM = "commerce-signature/v1";
27
+ export const CHANGE_KINDS = Object.freeze(["none", "page_only", "commerce_affecting", "unknown"]);
28
+ // The reasons a change counts as commerce, closed so readers can switch on them.
29
+ export const COMMERCE_REASONS = Object.freeze([
30
+ "unmapped_file",
31
+ "page_added",
32
+ "page_removed",
33
+ "checkout_page",
34
+ "commerce_markup_changed",
35
+ "commerce_signature_unavailable",
36
+ "campaign_config_changed",
37
+ "campaign_config_unavailable",
38
+ "change_history_unavailable",
39
+ ]);
40
+ // How many rebuilds `record build` keeps in build_changes, and how many paths
41
+ // one entry lists per list. A stage that reviewed a build older than the
42
+ // kept history reads its change as unknown, and so owes the full stages.
43
+ export const BUILD_CHANGES_LIMIT = 25;
44
+ export const CHANGE_PATH_LIMIT = 20;
45
+ // CPK page types that take payment or render the checkout surface.
46
+ const CHECKOUT_PAGE_TYPES = new Set(["checkout", "checkout_step", "select"]);
47
+ // Attribute names that carry package, offer, shipping, upsell or checkout
48
+ // identity on built pages: data-next-*, the older os-checkout-* bindings and
49
+ // other data-* names for those refs. Inline event handlers (onclick and the
50
+ // like) can call the SDK, so they count too.
51
+ const COMMERCE_ATTRIBUTE = /^data-next-|^data-[a-z0-9-]*(?:package|offer|shipping|upsell|voucher|coupon)[a-z0-9-]*$|^data-(?:next-)?[a-z0-9-]*template-id$|^os-checkout(?:-|$)|^on[a-z]+$/;
52
+ // Attributes that render the checkout surface or take payment. A page that
53
+ // carries one is a checkout page whatever its type or route says.
54
+ const CHECKOUT_ATTRIBUTE = /^data-next-(?:checkout|payment|express-checkout)(?:-|$)|^os-checkout(?:-|$)/;
55
+
56
+ const sha256 = (value) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
57
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
58
+
59
+ function textOf(node) {
60
+ const children = node.content ? node.content.childNodes : node.childNodes;
61
+ return (children || []).map((child) => (child.nodeName === "#text" ? child.value || "" : "")).join("");
62
+ }
63
+
64
+ // The commerce-bearing facts of one built page, in document order: every
65
+ // SDK attribute and inline event handler, every next-* meta tag, every
66
+ // script (src, or the inline body), every link target and form action. Copy,
67
+ // images and styling are left out, so a copy edit keeps the signature.
68
+ // `checkout` is true when the page carries checkout or payment markup.
69
+ export function pageCommerceFacts(html) {
70
+ const facts = [];
71
+ let pageType = null;
72
+ let checkout = false;
73
+ const walk = (node) => {
74
+ const children = node.content ? node.content.childNodes : node.childNodes;
75
+ for (const child of children || []) {
76
+ if (child.nodeName && !child.nodeName.startsWith("#")) {
77
+ const tag = child.nodeName.toLowerCase();
78
+ const attrs = new Map((child.attrs || []).map((attr) => [attr.name.toLowerCase(), attr.value]));
79
+ for (const [name, value] of attrs) {
80
+ if (COMMERCE_ATTRIBUTE.test(name)) facts.push(`${tag}[${name}]=${value}`);
81
+ if (CHECKOUT_ATTRIBUTE.test(name)) checkout = true;
82
+ }
83
+ const metaName = String(attrs.get("name") || "").trim().toLowerCase();
84
+ if (tag === "meta" && metaName.startsWith("next-")) {
85
+ facts.push(`meta[${metaName}]=${attrs.get("content") ?? ""}`);
86
+ if (metaName === "next-page-type") pageType = String(attrs.get("content") || "").trim().toLowerCase() || null;
87
+ }
88
+ if (tag === "script") facts.push(attrs.has("src") ? `script[src]=${attrs.get("src")}` : `script=${sha256(textOf(child))}`);
89
+ if (tag === "a" && attrs.has("href")) facts.push(`a[href]=${attrs.get("href")}`);
90
+ if (tag === "form" && attrs.has("action")) facts.push(`form[action]=${attrs.get("action")}`);
91
+ }
92
+ walk(child);
93
+ }
94
+ };
95
+ walk(parse(String(html)));
96
+ return { signature: sha256(facts.join("\n")), page_type: pageType, checkout };
97
+ }
98
+
99
+ // The campaign's own Page Kit config entry, hashed; a missing or unreadable
100
+ // entry is its status, so a change from present to absent still differs.
101
+ export function campaignConfigFingerprint(targetRepo, publicRouteSlug) {
102
+ const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
103
+ if (load.status !== "ok") return `unavailable:${load.status}`;
104
+ const canonical = (value) => Array.isArray(value) ? value.map(canonical)
105
+ : isObject(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) : value;
106
+ return sha256(JSON.stringify(canonical(load.entry)));
107
+ }
108
+
109
+ /**
110
+ * The signature `record build` keeps on stages.assembly.commerce_signature:
111
+ * `{ algorithm, campaign_config, pages: { <built path>: sha256 } }` for the
112
+ * pages of a resolved built-site scope (resolveBuiltSiteScope). Null when the
113
+ * scope did not resolve; a page that cannot be read is left out, so a later
114
+ * change to it reads as unclassifiable.
115
+ */
116
+ export function buildCommerceSignature({ scope, outputRoot, targetRepo, publicRouteSlug }) {
117
+ if (!scope?.ok) return null;
118
+ const pages = {};
119
+ for (const page of scope.pages) {
120
+ const path = builtPath(outputRoot, page.built_path);
121
+ try {
122
+ pages[path] = pageCommerceFacts(readFileSync(page.built_path, "utf8")).signature;
123
+ } catch { /* left out: an unreadable page cannot be classified later */ }
124
+ }
125
+ return { algorithm: COMMERCE_SIGNATURE_ALGORITHM, campaign_config: campaignConfigFingerprint(targetRepo, publicRouteSlug), pages };
126
+ }
127
+
128
+ function builtPath(outputRoot, file) {
129
+ const relativePath = file.startsWith(outputRoot) ? file.slice(outputRoot.length).replace(/^[\\/]+/, "") : file;
130
+ return relativePath.split(/[\\/]/).join("/");
131
+ }
132
+
133
+ function recordedSignature(assembly) {
134
+ const signature = assembly?.commerce_signature;
135
+ return isObject(signature) && signature.algorithm === COMMERCE_SIGNATURE_ALGORITHM && isObject(signature.pages)
136
+ && typeof signature.campaign_config === "string" ? signature : null;
137
+ }
138
+
139
+ const capped = (items) => items.slice(0, CHANGE_PATH_LIMIT);
140
+
141
+ /**
142
+ * Classify the drift between a recorded build and the output on disk.
143
+ * `changes` is assessEvidenceDebt's known change list (changed_pages,
144
+ * unmapped_files, extra, missing). `baseline` is the recorded
145
+ * stages.assembly. Returns `{ kind, reasons }`, `kind` one of CHANGE_KINDS
146
+ * and `reasons` the commerce reasons found, each `{ code, path? }`.
147
+ */
148
+ export function classifyDrift({ changes, baseline, outputRoot, targetRepo, publicRouteSlug }) {
149
+ if (changes?.status !== "known") return { kind: "unknown", reasons: [] };
150
+ if (!changes.changed_pages.length && !changes.unmapped_files.length) return { kind: "none", reasons: [] };
151
+ const reasons = [];
152
+ for (const path of changes.unmapped_files) reasons.push({ code: "unmapped_file", path });
153
+ const signature = recordedSignature(baseline);
154
+ if (!signature) reasons.push({ code: "commerce_signature_unavailable" });
155
+ else if (signature.campaign_config.startsWith("unavailable:")
156
+ || campaignConfigFingerprint(targetRepo, publicRouteSlug) !== signature.campaign_config) {
157
+ reasons.push({ code: signature.campaign_config.startsWith("unavailable:") ? "campaign_config_unavailable" : "campaign_config_changed" });
158
+ }
159
+ for (const page of changes.changed_pages) {
160
+ if (changes.extra.includes(page.built_path)) { reasons.push({ code: "page_added", path: page.built_path }); continue; }
161
+ if (changes.missing.includes(page.built_path)) { reasons.push({ code: "page_removed", path: page.built_path }); continue; }
162
+ let current;
163
+ try {
164
+ current = pageCommerceFacts(readFileSync(join(outputRoot, page.built_path), "utf8"));
165
+ } catch {
166
+ reasons.push({ code: "commerce_signature_unavailable", path: page.built_path });
167
+ continue;
168
+ }
169
+ const type = current.page_type || inferPageType(routeForFile(outputRoot, join(outputRoot, page.built_path)));
170
+ if (CHECKOUT_PAGE_TYPES.has(type) || current.checkout) reasons.push({ code: "checkout_page", path: page.built_path });
171
+ else if (!signature || !Object.hasOwn(signature.pages, page.built_path)) {
172
+ if (signature) reasons.push({ code: "commerce_signature_unavailable", path: page.built_path });
173
+ } else if (signature.pages[page.built_path] !== current.signature) {
174
+ reasons.push({ code: "commerce_markup_changed", path: page.built_path });
175
+ }
176
+ }
177
+ return { kind: reasons.length ? "commerce_affecting" : "page_only", reasons: capped(reasons) };
178
+ }
179
+
180
+ /**
181
+ * One build_changes entry for a rebuild from `from` to `to`, from the debt
182
+ * assessment of the new output against the previous record.
183
+ */
184
+ export function buildChangeEntry({ from, to, at, changes, classification }) {
185
+ return {
186
+ from_build_fingerprint: from,
187
+ build_fingerprint: to,
188
+ recorded_at: at,
189
+ kind: classification.kind,
190
+ changed_pages: changes.status === "known" ? changes.changed_pages.map((page) => ({ page_id: page.page_id, built_path: page.built_path })) : [],
191
+ unmapped_files: changes.status === "known" ? capped(changes.unmapped_files) : [],
192
+ unmapped_file_count: changes.status === "known" ? changes.unmapped_files.length : 0,
193
+ reasons: classification.reasons,
194
+ };
195
+ }
196
+
197
+ function validEntry(entry) {
198
+ return isObject(entry) && typeof entry.from_build_fingerprint === "string" && typeof entry.build_fingerprint === "string"
199
+ && CHANGE_KINDS.includes(entry.kind) && Array.isArray(entry.changed_pages) && Array.isArray(entry.reasons);
200
+ }
201
+
202
+ /**
203
+ * The kept rebuild history after recording `entry`: the previous history
204
+ * with this rebuild appended, trimmed to the entries a stage anchored at one
205
+ * of `anchors` can still walk, and to BUILD_CHANGES_LIMIT.
206
+ */
207
+ export function nextBuildChanges(previous, entry, anchors = []) {
208
+ const history = [...(Array.isArray(previous) ? previous.filter(validEntry) : []), entry];
209
+ const wanted = new Set(anchors.filter((anchor) => typeof anchor === "string" && anchor));
210
+ const kept = [];
211
+ let at = entry.build_fingerprint;
212
+ for (let index = history.length - 1; index >= 0 && wanted.size; index -= 1) {
213
+ if (history[index].build_fingerprint !== at) break;
214
+ kept.unshift(history[index]);
215
+ at = history[index].from_build_fingerprint;
216
+ wanted.delete(at);
217
+ }
218
+ return kept.slice(-BUILD_CHANGES_LIMIT);
219
+ }
220
+
221
+ const KIND_RANK = Object.freeze({ none: 0, page_only: 1, commerce_affecting: 2, unknown: 3 });
222
+ // The worse of two change kinds; an unrecognized kind reads as unknown.
223
+ const rankOf = (kind) => KIND_RANK[kind] ?? KIND_RANK.unknown;
224
+ export function worseChangeKind(a, b) {
225
+ const kind = rankOf(a) >= rankOf(b) ? a : b;
226
+ return Object.hasOwn(KIND_RANK, kind) ? kind : "unknown";
227
+ }
228
+
229
+ /**
230
+ * The change a stage that reviewed `anchor` owes for: the recorded rebuilds
231
+ * from `anchor` to the recorded build, then the drift from the recorded build
232
+ * to the output on disk. Returns `{ kind, since_build_fingerprint,
233
+ * changed_pages, unmapped_files, reasons }`; `kind` is unknown when the
234
+ * history does not reach `anchor` or the drift cannot be read.
235
+ */
236
+ export function changeSince(anchor, { assembly, drift, driftClassification }) {
237
+ const recorded = typeof assembly?.build_fingerprint === "string" ? assembly.build_fingerprint : null;
238
+ const result = { kind: "none", since_build_fingerprint: anchor || null, changed_pages: [], unmapped_files: [], reasons: [] };
239
+ if (!anchor || !recorded) return { ...result, kind: "unknown" };
240
+ const segments = [];
241
+ if (anchor !== recorded) {
242
+ const history = Array.isArray(assembly.build_changes) ? assembly.build_changes.filter(validEntry) : [];
243
+ let at = recorded;
244
+ for (let index = history.length - 1; index >= 0 && at !== anchor; index -= 1) {
245
+ if (history[index].build_fingerprint !== at) break;
246
+ segments.unshift(history[index]);
247
+ at = history[index].from_build_fingerprint;
248
+ }
249
+ if (at !== anchor) return { ...result, kind: "unknown", reasons: [{ code: "change_history_unavailable" }] };
250
+ }
251
+ const pages = new Map();
252
+ const unmapped = new Set();
253
+ let kind = "none";
254
+ const add = (part, partKind, reasons) => {
255
+ kind = worseChangeKind(kind, partKind);
256
+ for (const page of part.changed_pages || []) pages.set(page.built_path, { page_id: page.page_id, built_path: page.built_path });
257
+ for (const path of part.unmapped_files || []) unmapped.add(path);
258
+ result.reasons.push(...(reasons || []));
259
+ };
260
+ for (const segment of segments) add(segment, segment.kind, segment.reasons);
261
+ if (drift?.status !== "known") kind = "unknown";
262
+ else add(drift, driftClassification?.kind || "unknown", driftClassification?.reasons);
263
+ return {
264
+ ...result,
265
+ kind,
266
+ changed_pages: [...pages.values()].sort((a, b) => a.built_path.localeCompare(b.built_path)),
267
+ unmapped_files: capped([...unmapped].sort()),
268
+ reasons: capped(result.reasons),
269
+ };
270
+ }
271
+
272
+ // The page id a QA topology page has in the change lists: the id of its
273
+ // route under the campaign base, as built-site scope derives it.
274
+ export function routePageIdForUrl(url, baseUrl) {
275
+ try {
276
+ const base = new URL(baseUrl).pathname.replace(/\/+$/, "");
277
+ const path = new URL(url).pathname;
278
+ const route = (path.startsWith(`${base}/`) || path === base ? path.slice(base.length) : path)
279
+ .replace(/^\/+/, "").replace(/(?:^|\/)index\.html$/, "").replace(/\.html$/i, "").replace(/\/+$/, "");
280
+ return pageIdForRoute(route);
281
+ } catch {
282
+ return null;
283
+ }
284
+ }