@nextcommerce/campaigns-os 1.43.1 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +9 -2
  2. package/CHANGELOG.md +1099 -5103
  3. package/README.md +34 -13
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  14. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  15. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  16. package/contracts/effects.v1.json +1184 -121
  17. package/contracts/orientation-reason-codes.v1.json +7 -0
  18. package/contracts/release-ledger.json +2190 -5260
  19. package/contracts/supported-surface.json +7 -4
  20. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  21. package/docs/brand-theme-bridge.md +81 -0
  22. package/docs/build-packet.md +222 -23
  23. package/docs/campaigns-os-build-flow.md +4 -3
  24. package/docs/design-source-package.md +162 -15
  25. package/docs/effects.md +66 -12
  26. package/docs/gateway-login.md +3 -0
  27. package/docs/local-setup.md +1 -1
  28. package/docs/orientation-contract-reference.md +42 -2
  29. package/docs/polish-evidence.md +74 -0
  30. package/docs/progress-snapshots.md +10 -6
  31. package/docs/qa-and-test-orders.md +230 -20
  32. package/docs/release-ledger-authoring-guide.md +70 -8
  33. package/docs/runtime-readiness.md +1 -1
  34. package/docs/sdk-storage-compatibility.md +1 -1
  35. package/docs/skills-revision.md +10 -10
  36. package/docs/supported-surface.md +2 -2
  37. package/docs/versioning.md +4 -1
  38. package/package.json +1 -1
  39. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  40. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  41. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  42. package/skills/campaign-readback-classification/SKILL.md +3 -3
  43. package/skills/campaign-run-evidence/SKILL.md +7 -6
  44. package/skills/contribution-intake/SKILL.md +3 -3
  45. package/skills/next-campaigns-build/SKILL.md +7 -6
  46. package/skills/next-campaigns-os/SKILL.md +7 -7
  47. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  48. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  49. package/skills/next-campaigns-polish/SKILL.md +28 -9
  50. package/skills/next-campaigns-qa/SKILL.md +7 -4
  51. package/skills.json +10 -10
  52. package/src/brand-theme.mjs +320 -20
  53. package/src/build-brief.mjs +6 -4
  54. package/src/built-script-syntax.mjs +480 -0
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/campaigns-api-key.mjs +99 -0
  57. package/src/cli-helpers.mjs +118 -0
  58. package/src/cli.mjs +1530 -7580
  59. package/src/commercial-parity.mjs +48 -2
  60. package/src/design-source-package.mjs +1 -1
  61. package/src/design-source-publication.mjs +898 -0
  62. package/src/deviation.mjs +13 -1
  63. package/src/diagnostic.mjs +6 -2
  64. package/src/directory-lock.mjs +270 -0
  65. package/src/doctor/checks.mjs +4654 -0
  66. package/src/doctor/inspect.mjs +678 -0
  67. package/src/doctor/next-step.mjs +731 -0
  68. package/src/doctor/source-provenance.mjs +184 -0
  69. package/src/install-invocation.mjs +29 -0
  70. package/src/invocation.mjs +183 -0
  71. package/src/live-campaign-refs.mjs +466 -0
  72. package/src/login.mjs +2 -2
  73. package/src/page-kit-store-profile.mjs +69 -12
  74. package/src/page-kit-sync.mjs +31 -12
  75. package/src/private-template-source.mjs +1 -1
  76. package/src/progress-node.mjs +9 -36
  77. package/src/proof-policy.mjs +1 -1
  78. package/src/qa-analytics-correctness.mjs +3 -0
  79. package/src/qa-binding-evidence.mjs +76 -11
  80. package/src/qa-browser.mjs +1316 -105
  81. package/src/qa-build-scope.mjs +47 -0
  82. package/src/qa-commercial-parity.mjs +48 -5
  83. package/src/qa-node.mjs +339 -19
  84. package/src/qa-test-order-topology.mjs +148 -0
  85. package/src/sdk-markup.mjs +72 -8
  86. package/src/source-html-intake.mjs +117 -1
  87. package/src/source-html-manifest.mjs +9 -2
  88. package/src/stage-ledger.mjs +28 -0
  89. package/src/stage-record.mjs +551 -0
  90. package/src/target-lock.mjs +54 -0
  91. package/src/template-brand-contract.mjs +17 -1
  92. package/src/upsell-selector-scope.mjs +112 -2
@@ -0,0 +1,480 @@
1
+ // Built-output script syntax gate (#480).
2
+ //
3
+ // A campaign-owned script that does not parse throws a SyntaxError on every
4
+ // load of every page that references it, and nothing it defines runs. The
5
+ // shape that shipped: a template-family checkout script copied and
6
+ // hand-edited, left with one closing `});` too many. Page Kit built it, every
7
+ // other doctor gate read the HTML and passed, and doctor reported the build
8
+ // ready.
9
+ //
10
+ // This gate parses every campaign-owned `.js` file a built page loads by a
11
+ // LOCAL `<script src>`, with acorn at the latest ecmaVersion: `sourceType:
12
+ // 'script'` for classic scripts and `'module'` for `type="module"`, which is
13
+ // how the browser reads each. A parse failure is an error naming the file, the
14
+ // line and the column. Remote scripts (CDN URLs, protocol-relative, data:)
15
+ // are not campaign-owned and are not read. A referenced local file that does
16
+ // not exist on disk is a warning (#502): the browser gets a 404 for it and
17
+ // nothing it would define runs, but whether the page needs it is not known
18
+ // here, so it does not block.
19
+ //
20
+ // Not waivable: a script that cannot be parsed cannot be intended to ship.
21
+ // Both doctor entry points drive it, like the other static built-output gates.
22
+
23
+ import { existsSync, readFileSync, statSync } from "node:fs";
24
+ import { basename, isAbsolute, posix, relative, resolve, sep } from "node:path";
25
+
26
+ import { parse as parseJs } from "acorn";
27
+ import { parse as parseHtml } from "parse5";
28
+
29
+ export const SCRIPT_SYNTAX = "built_output.script_syntax";
30
+ export const SCRIPT_SYNTAX_PARSE_FAILURE = `${SCRIPT_SYNTAX}.parse_failure`;
31
+ export const SCRIPT_SYNTAX_MISSING_SCRIPT = `${SCRIPT_SYNTAX}.missing_script`;
32
+
33
+ // Classic script MIME types the browser executes. Anything else with a type
34
+ // attribute (JSON-LD, text/template, importmap) is a data block, not script.
35
+ const CLASSIC_SCRIPT_TYPE = /^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^text\/(?:javascript1\.[0-5]|jscript|livescript)$/i;
36
+
37
+ /**
38
+ * How a module-capable browser treats a `<script>` element, from its
39
+ * attributes: "classic", "module", or null when it never runs it. Follows the
40
+ * HTML "prepare the script element" steps: an absent or empty type (or, with
41
+ * no type, an absent or empty language) is classic; otherwise the type has
42
+ * leading and trailing ASCII whitespace stripped and is compared
43
+ * ASCII-case-insensitively. `nomodule` stops only classic scripts; a module
44
+ * script ignores it.
45
+ *
46
+ * @param {Record<string, string>} attrs
47
+ * @returns {"classic" | "module" | null}
48
+ */
49
+ export function scriptKind(attrs) {
50
+ // The script block's type string, as the HTML steps build it: an empty
51
+ // type, or no type with an empty or absent language, is text/javascript; a
52
+ // type attribute is stripped of ASCII whitespace; otherwise "text/" plus the
53
+ // language attribute, unstripped.
54
+ const hasType = typeof attrs.type === "string";
55
+ const hasLanguage = typeof attrs.language === "string";
56
+ let type;
57
+ if (hasType ? attrs.type === "" : !hasLanguage || attrs.language === "") type = "text/javascript";
58
+ else if (hasType) type = attrs.type.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, "");
59
+ else type = `text/${attrs.language}`;
60
+ let kind = null;
61
+ if (type.toLowerCase() === "module") kind = "module";
62
+ else if (CLASSIC_SCRIPT_TYPE.test(type)) kind = "classic";
63
+ if (kind === "classic" && "nomodule" in attrs) return null;
64
+ return kind;
65
+ }
66
+
67
+ // Acorn messages that are fixed text. Anything else interpolates source text
68
+ // (an identifier, a regex body, a character) and is reduced to its category,
69
+ // so a token at the error site never reaches doctor or QA output.
70
+ const FIXED_PARSE_MESSAGES = new Set([
71
+ "Unexpected token", "Unterminated string constant", "Unterminated template", "Unterminated template literal",
72
+ "Unterminated regular expression", "Unterminated comment", "Invalid regular expression flag",
73
+ "Duplicate regular expression flag", "Invalid number", "Identifier directly after number", "Assigning to rvalue",
74
+ "'return' outside of function", "'import' and 'export' may appear only with 'sourceType: module'",
75
+ "'import' and 'export' may only appear at the top level", "Cannot use 'import.meta' outside a module",
76
+ "Cannot use keyword 'await' outside an async function", "'super' keyword outside a method",
77
+ "super() call outside constructor of a subclass", "Illegal newline after throw", "Missing catch or finally clause",
78
+ "Multiple default clauses", "Argument name clash", "Redefinition of property", "Redefinition of __proto__ property",
79
+ "Bad escape sequence in untagged template literal", "Invalid use of 'super'", "'with' in strict mode",
80
+ "Deleting local variable in strict mode", "let is disallowed as a lexically bound name",
81
+ "Shorthand property assignments are valid only in destructuring patterns",
82
+ "Complex binding patterns require an initialization value", "Comma is not permitted after the rest element",
83
+ "Binding member expression", "Binding parenthesized expression", "Not enough stack space to parse input",
84
+ "Optional chaining cannot appear in left-hand side",
85
+ "Logical expressions and coalesce expressions cannot be mixed. Wrap either by parentheses",
86
+ ]);
87
+ const PARSE_MESSAGE_CATEGORIES = [
88
+ [/^Invalid regular expression\b/, "Invalid regular expression"],
89
+ [/^Identifier .* has already been declared$/, "Identifier has already been declared"],
90
+ [/^Unexpected character\b/, "Unexpected character"],
91
+ [/^Unexpected keyword\b/, "Unexpected keyword"],
92
+ [/^The keyword .* is reserved$/, "Reserved keyword"],
93
+ [/^Label .* is already declared$/, "Duplicate label"],
94
+ [/^Duplicate export\b/, "Duplicate export"],
95
+ [/^Undefined export\b/, "Undefined export"],
96
+ [/^Unsyntactic (?:break|continue)$/, "Unsyntactic break or continue"],
97
+ [/^Escape sequence in keyword\b/, "Escape sequence in keyword"],
98
+ [/^Private field\b/, "Undeclared private field"],
99
+ [/^Expected number in radix\b/, "Invalid number"],
100
+ [/in strict mode$/, "Not allowed in strict mode"],
101
+ ];
102
+
103
+ /**
104
+ * A parser error as a fixed-vocabulary category plus a 1-based position. The
105
+ * message never carries source text: acorn interpolates identifiers, regex
106
+ * bodies and characters from the script into some messages.
107
+ *
108
+ * @param {unknown} error
109
+ * @returns {{ line: number, column: number, message: string }}
110
+ */
111
+ export function parseFailureDiagnostic(error) {
112
+ const line = Number.isInteger(error?.loc?.line) ? error.loc.line : 1;
113
+ const column = Number.isInteger(error?.loc?.column) ? error.loc.column + 1 : 1;
114
+ const raw = String(error?.message || "").replace(/\s*\(\d+:\d+\)$/, "");
115
+ let message = "Syntax error";
116
+ if (FIXED_PARSE_MESSAGES.has(raw)) message = raw;
117
+ else {
118
+ const category = PARSE_MESSAGE_CATEGORIES.find(([pattern]) => pattern.test(raw));
119
+ if (category) message = category[1];
120
+ }
121
+ return { line, column, message };
122
+ }
123
+
124
+ /**
125
+ * Parse one script the way the browser would read it.
126
+ *
127
+ * @param {string} source
128
+ * @param {{ module?: boolean }} [options]
129
+ * @returns {null | { line: number, column: number, message: string }}
130
+ * null when the source parses; otherwise the 1-based position and a
131
+ * source-free diagnostic category (see parseFailureDiagnostic).
132
+ */
133
+ export function parseScriptSyntax(source, { module = false } = {}) {
134
+ try {
135
+ parseJs(String(source ?? ""), {
136
+ ecmaVersion: "latest",
137
+ sourceType: module ? "module" : "script",
138
+ allowHashBang: true,
139
+ locations: true,
140
+ });
141
+ return null;
142
+ } catch (error) {
143
+ return parseFailureDiagnostic(error);
144
+ }
145
+ }
146
+
147
+ export const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
148
+
149
+ /**
150
+ * A URL attribute value as the URL parser reads it: leading and trailing C0
151
+ * controls and space removed, nothing else (not U+00A0 or other Unicode
152
+ * whitespace, which String#trim would also strip).
153
+ *
154
+ * @param {string} value
155
+ */
156
+ export function stripUrlSpace(value) {
157
+ return String(value).replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, "");
158
+ }
159
+
160
+ /**
161
+ * Every HTML-namespace `<base href>` in a parse5 document (parsed with
162
+ * `sourceCodeLocationInfo`), in tree order, with the source offset at which
163
+ * the parser inserted it. An SVG or MathML `base` is not a base element.
164
+ *
165
+ * @param {object} root a parse5 node
166
+ * @returns {Array<{ href: string, offset: number }>}
167
+ */
168
+ export function documentBases(root) {
169
+ const bases = [];
170
+ const walk = (node) => {
171
+ if (node.tagName === "base" && node.namespaceURI === HTML_NAMESPACE) {
172
+ const href = (node.attrs || []).find((attr) => attr.name === "href");
173
+ if (href) bases.push({ href: href.value, offset: node.sourceCodeLocation?.startOffset ?? -1 });
174
+ }
175
+ // Template content is a separate fragment in parse5 and never walked here.
176
+ for (const child of node.childNodes || []) walk(child);
177
+ };
178
+ walk(root);
179
+ return bases;
180
+ }
181
+
182
+ /**
183
+ * The `<base href>` in effect when the parser prepares a script element: the
184
+ * parser prepares it at its end tag, and "prepare the script element" parses
185
+ * `src` then, against the document base URL, which is the first base element
186
+ * with an href in tree order among those already in the document. Foster
187
+ * parenting can place a base parsed earlier after the script in tree order
188
+ * (it still counts) or one parsed later before it (it does not), so this
189
+ * compares source offsets rather than tree position. null when no base was
190
+ * in the document yet.
191
+ *
192
+ * @param {Array<{ href: string, offset: number }>} bases from documentBases
193
+ * @param {object} scriptNode a parse5 script element
194
+ * @returns {string | null}
195
+ */
196
+ export function baseInEffect(bases, scriptNode) {
197
+ const location = scriptNode?.sourceCodeLocation;
198
+ const preparedAt = location?.endTag?.startOffset ?? location?.endOffset ?? Infinity;
199
+ const base = bases.find((entry) => entry.offset < preparedAt);
200
+ return base ? base.href : null;
201
+ }
202
+
203
+ /**
204
+ * `<script src>` references on a page, in document order, with whether each
205
+ * is a module and the `<base href>` in effect when the browser prepares it
206
+ * (see baseInEffect), plus the document's first `<base href>` (null when
207
+ * none). A `<base>` parsed after a script does not move it, whether the
208
+ * script is async, deferred or a module: its URL is resolved when prepared,
209
+ * not when fetched.
210
+ *
211
+ * Only HTML-namespace script elements are references: an SVG `<script>`
212
+ * never loads a `src` attribute. Data-block types are dropped. Classic
213
+ * `nomodule` scripts are dropped: a
214
+ * module-capable browser never fetches or runs them, so they cannot fail on
215
+ * load there. A module script ignores `nomodule` and is kept (see scriptKind).
216
+ * Template content and noscript are inert and not walked.
217
+ *
218
+ * @param {string} html
219
+ * @returns {{ base: string | null, refs: Array<{ src: string, module: boolean, base: string | null }> }}
220
+ */
221
+ export function pageScriptDocument(html) {
222
+ const refs = [];
223
+ let document;
224
+ try {
225
+ document = parseHtml(String(html ?? ""), { sourceCodeLocationInfo: true });
226
+ } catch {
227
+ return { base: null, refs };
228
+ }
229
+ const bases = documentBases(document);
230
+ const walk = (node) => {
231
+ const attrs = node.tagName ? Object.fromEntries((node.attrs || []).map((attr) => [attr.name, attr.value])) : {};
232
+ // Only an HTML-namespace <script> loads its `src`; an SVG script reads
233
+ // href / xlink:href, and a MathML "script" is not a script element.
234
+ if (node.tagName === "script" && node.namespaceURI === HTML_NAMESPACE) {
235
+ const kind = scriptKind(attrs);
236
+ // "prepare the script element" skips only an empty src; anything else,
237
+ // even whitespace, is parsed as a URL and fetched.
238
+ if (kind && typeof attrs.src === "string" && attrs.src !== "") {
239
+ refs.push({ src: stripUrlSpace(attrs.src), module: kind === "module", base: baseInEffect(bases, node) });
240
+ }
241
+ }
242
+ if (node.tagName === "noscript") return;
243
+ for (const child of node.childNodes || []) walk(child);
244
+ };
245
+ walk(document);
246
+ return { base: bases[0]?.href ?? null, refs };
247
+ }
248
+
249
+ /**
250
+ * The frozen base URL of a `<base href>` (HTML "set the frozen base URL"):
251
+ * the href parsed against the document's fallback base URL (its own URL),
252
+ * falling back to that URL when the parse fails or yields a `data:` or
253
+ * `javascript:` URL, which the browser refuses as a base.
254
+ *
255
+ * @param {string | null} href the base element's href, or null for none
256
+ * @param {string} documentUrl
257
+ * @returns {string} the URL scripts resolve against
258
+ */
259
+ export function frozenBaseUrl(href, documentUrl) {
260
+ if (href === null || href === undefined) return documentUrl;
261
+ let url;
262
+ try {
263
+ url = new URL(href, documentUrl);
264
+ } catch {
265
+ return documentUrl;
266
+ }
267
+ return url.protocol === "data:" || url.protocol === "javascript:" ? documentUrl : url.href;
268
+ }
269
+
270
+ /** The `<script src>` references of pageScriptDocument, without any base. */
271
+ export function pageScriptReferences(html) {
272
+ return pageScriptDocument(html).refs.map(({ src, module }) => ({ src, module }));
273
+ }
274
+
275
+ // A synthetic origin standing in for wherever the built site is served. Page
276
+ // URLs are their path under the site root; a script URL on any other origin
277
+ // is not campaign-owned.
278
+ const BUILT_ORIGIN = "http://built-site.invalid";
279
+
280
+ function pageUrlFor(siteRoot, builtPath) {
281
+ const rel = relative(siteRoot, builtPath);
282
+ const segments = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel.split(sep) : [basename(builtPath)];
283
+ return `${BUILT_ORIGIN}/${segments.map(encodeURIComponent).join("/")}`;
284
+ }
285
+
286
+ // The browser's view of one reference: the URL it resolves to against the
287
+ // base in effect when the script was prepared. null when that URL is not on
288
+ // the built origin.
289
+ function resolveScriptUrl(src, base, pageUrl) {
290
+ let url;
291
+ try {
292
+ url = new URL(src, frozenBaseUrl(base, pageUrl));
293
+ } catch {
294
+ return { remote: false, pathname: null };
295
+ }
296
+ if (url.origin !== BUILT_ORIGIN) return { remote: true, pathname: null };
297
+ return { remote: false, pathname: url.pathname };
298
+ }
299
+
300
+ // Decode a URL path the way a static server does before it maps it onto the
301
+ // disk, then keep it inside `root`. null for a malformed escape, a NUL, or a
302
+ // path that escapes the root.
303
+ function fileUnder(root, pathname) {
304
+ let decoded;
305
+ try {
306
+ decoded = decodeURIComponent(pathname);
307
+ } catch {
308
+ return null;
309
+ }
310
+ if (decoded.includes("\0")) return null;
311
+ const rootAbs = resolve(root);
312
+ const path = resolve(rootAbs, `.${posix.normalize(`/${decoded.replace(/\\/g, "/")}`)}`);
313
+ return path === rootAbs || path.startsWith(`${rootAbs}${sep}`) ? path : null;
314
+ }
315
+
316
+ function isRemote(src) {
317
+ return /^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//");
318
+ }
319
+
320
+ function relFrom(root, path) {
321
+ const rel = relative(root, path);
322
+ return rel && !rel.startsWith("..") ? rel.split(sep).join("/") : path;
323
+ }
324
+
325
+ /**
326
+ * Resolve every built page's local script references against the filesystem.
327
+ * Each src resolves as the browser resolves it: against the base in effect
328
+ * when the script is prepared (the first `<base href>` before it in tree
329
+ * order, else the page URL; a `data:`, `javascript:` or unparsable base falls
330
+ * back to the page URL), with the page URL being its path under the site
331
+ * root. The resulting path is
332
+ * percent-decoded and mapped under the site root first (page-kit emits
333
+ * `/<slug>/js/...`), then the campaign directory (a root-served campaign
334
+ * emits `/js/...`), never outside either. A base or src on another origin is
335
+ * remote and not read.
336
+ *
337
+ * @param {{ site_root: string, campaign_dir: string, pages: Array<{ page_id: string, built_path: string }> }} scope
338
+ * @param {string} targetRepo
339
+ */
340
+ export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
341
+ const scripts = new Map();
342
+ const unresolved = new Map();
343
+ const pages = Array.isArray(scope?.pages) ? scope.pages : [];
344
+ for (const page of pages) {
345
+ let html;
346
+ try {
347
+ html = readFileSync(page.built_path, "utf8");
348
+ } catch {
349
+ continue;
350
+ }
351
+ const { refs } = pageScriptDocument(html);
352
+ const pageUrl = pageUrlFor(scope.site_root, page.built_path);
353
+ for (const ref of refs) {
354
+ if (isRemote(ref.src)) continue;
355
+ const { remote, pathname } = resolveScriptUrl(ref.src, ref.base, pageUrl);
356
+ if (remote) continue;
357
+ let path = null;
358
+ if (pathname) {
359
+ const candidates = [fileUnder(scope.site_root, pathname), fileUnder(scope.campaign_dir, pathname)].filter(Boolean);
360
+ path = candidates.find((candidate) => existsSync(candidate)) || null;
361
+ }
362
+ let isFile = false;
363
+ try {
364
+ isFile = Boolean(path) && existsSync(path) && statSync(path).isFile();
365
+ } catch {
366
+ isFile = false;
367
+ }
368
+ if (!isFile) {
369
+ const entry = unresolved.get(ref.src) || { src: ref.src, pages: [] };
370
+ if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
371
+ unresolved.set(ref.src, entry);
372
+ continue;
373
+ }
374
+ const key = `${resolve(path)}\u0000${ref.module ? "module" : "script"}`;
375
+ if (!scripts.has(key)) {
376
+ let content = null;
377
+ try {
378
+ content = readFileSync(path, "utf8");
379
+ } catch {
380
+ content = null;
381
+ }
382
+ if (content == null) continue;
383
+ scripts.set(key, { file: relFrom(targetRepo, resolve(path)), module: ref.module, content, pages: [] });
384
+ }
385
+ const entry = scripts.get(key);
386
+ if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
387
+ }
388
+ }
389
+ return { pages_scanned: pages.length, scripts: [...scripts.values()], unresolved: [...unresolved.values()] };
390
+ }
391
+
392
+ function gateBase(subject) {
393
+ return {
394
+ id: SCRIPT_SYNTAX,
395
+ scope: SCRIPT_SYNTAX,
396
+ waivable: false,
397
+ subject: subject && typeof subject === "object" ? subject : {},
398
+ waiver: null,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Evaluate the syntax of the campaign-owned scripts built pages load. Pure:
404
+ * the caller hands in script contents.
405
+ *
406
+ * @param {{ subject?: object, pages_scanned?: number,
407
+ * scripts?: Array<{ file: string, module?: boolean, content: string, pages?: string[] }>,
408
+ * unresolved?: Array<{ src: string, pages: string[] }> }} input
409
+ */
410
+ export function evaluateBuiltScriptSyntax({ subject, pages_scanned: pagesScanned = 0, scripts = [], unresolved = [] } = {}) {
411
+ const list = Array.isArray(scripts) ? scripts : [];
412
+ const missing = Array.isArray(unresolved) ? unresolved : [];
413
+ // A local script the page loads that is not in the built output: a 404 at
414
+ // runtime. A warning, not a blocker (#502).
415
+ const warned = missing.map((entry) => {
416
+ const pages = Array.isArray(entry.pages) ? entry.pages : [];
417
+ return {
418
+ code: SCRIPT_SYNTAX_MISSING_SCRIPT,
419
+ src: entry.src,
420
+ pages,
421
+ message: `${entry.src} is loaded by a local <script src>${pages.length ? ` on ${pages.join(", ")}` : ""} but is not in the built output. The browser gets a 404 for it and nothing it would define runs. Add the file to the build, or remove the reference if the page does not need it.`,
422
+ };
423
+ });
424
+ const missingNote = warned.length ? ` ${warned.length} referenced local script(s) are not in the built output.` : "";
425
+ const common = { scripts_unresolved: missing, warned, pages_scanned: pagesScanned };
426
+ if (list.length === 0) {
427
+ return {
428
+ ...gateBase(subject),
429
+ status: "not_applicable",
430
+ code: `${SCRIPT_SYNTAX}.not_applicable`,
431
+ reason: (pagesScanned
432
+ ? `No built page loads a campaign-owned script from disk (${pagesScanned} page(s) read).`
433
+ : "No built page was available to scan; script syntax is checked once pages are built.") + missingNote,
434
+ findings: [],
435
+ scripts_scanned: 0,
436
+ ...common,
437
+ required_actions: [],
438
+ };
439
+ }
440
+
441
+ const findings = [];
442
+ for (const script of list) {
443
+ const failure = parseScriptSyntax(script.content, { module: Boolean(script.module) });
444
+ if (!failure) continue;
445
+ const pages = Array.isArray(script.pages) ? script.pages : [];
446
+ findings.push({
447
+ code: SCRIPT_SYNTAX_PARSE_FAILURE,
448
+ file: script.file,
449
+ line: failure.line,
450
+ column: failure.column,
451
+ source_type: script.module ? "module" : "script",
452
+ pages,
453
+ message: `${script.file}:${failure.line}:${failure.column}: ${failure.message}. The browser throws a SyntaxError loading this ${script.module ? "module" : "script"}${pages.length ? ` on ${pages.join(", ")}` : ""}, and nothing in it runs. Fix the syntax at that position (a stray or missing bracket is the usual cause) and rebuild.`,
454
+ });
455
+ }
456
+
457
+ if (findings.length) {
458
+ return {
459
+ ...gateBase(subject),
460
+ status: "blocked",
461
+ code: SCRIPT_SYNTAX_PARSE_FAILURE,
462
+ reason: `${findings.length} of ${list.length} campaign-owned script(s) do not parse: ${findings.map((finding) => `${finding.file}:${finding.line}:${finding.column}`).join(", ")}.${missingNote}`,
463
+ findings,
464
+ scripts_scanned: list.length,
465
+ ...common,
466
+ required_actions: findings.map((finding) => `Fix the syntax error in ${finding.file} at line ${finding.line}, column ${finding.column}, then rebuild.`),
467
+ };
468
+ }
469
+
470
+ return {
471
+ ...gateBase(subject),
472
+ status: "pass",
473
+ code: `${SCRIPT_SYNTAX}.pass`,
474
+ reason: `All ${list.length} campaign-owned script(s) loaded by ${pagesScanned} built page(s) parse.${missingNote}`,
475
+ findings: [],
476
+ scripts_scanned: list.length,
477
+ ...common,
478
+ required_actions: [],
479
+ };
480
+ }
@@ -18,6 +18,18 @@ import { basename, join, relative, sep } from "node:path";
18
18
 
19
19
  const HTML_EXT = ".html";
20
20
 
21
+ // The route tokens inferPageType reads for the funnel roles, exported so a
22
+ // caller can tell which token produced a guess (#529: an explicit "upsell"
23
+ // word is a stronger signal than "oto"). Tested against the lower-cased,
24
+ // trimmed route.
25
+ export const ROUTE_TOKENS = Object.freeze({
26
+ downsell: /down[\s_/-]*sell/,
27
+ upsell: /up[\s_/-]*sell/,
28
+ one_time_offer: /(^|[\s_/-])oto([\s_/-]|\d|$)|one[\s_/-]*time[\s_/-]*offer/,
29
+ receipt: /thank|receipt|confirm(ation)?|order[\s_/-]*complete/,
30
+ checkout: /checkout|\bcart\b|\border\b/,
31
+ });
32
+
21
33
  // Funnel page-type inference from a built route or filename. Order matters:
22
34
  // downsell is tested before upsell, and the broad fallbacks (landing/page) run
23
35
  // last. Returns one of the page types QA understands; "page" for generic
@@ -25,10 +37,10 @@ const HTML_EXT = ".html";
25
37
  export function inferPageType(routeOrName) {
26
38
  const value = String(routeOrName || "").toLowerCase().trim();
27
39
  if (value === "" || value === "/" || value === "index") return "landing";
28
- if (/down[\s_/-]*sell/.test(value)) return "downsell";
29
- if (/up[\s_/-]*sell|(^|[\s_/-])oto([\s_/-]|\d|$)|one[\s_/-]*time[\s_/-]*offer/.test(value)) return "upsell";
30
- if (/thank|receipt|confirm(ation)?|order[\s_/-]*complete/.test(value)) return "receipt";
31
- if (/checkout|\bcart\b|\border\b/.test(value)) return "checkout";
40
+ if (ROUTE_TOKENS.downsell.test(value)) return "downsell";
41
+ if (ROUTE_TOKENS.upsell.test(value) || ROUTE_TOKENS.one_time_offer.test(value)) return "upsell";
42
+ if (ROUTE_TOKENS.receipt.test(value)) return "receipt";
43
+ if (ROUTE_TOKENS.checkout.test(value)) return "checkout";
32
44
  // The two-step bundle-selection step. Deliberately narrow, and anchored on
33
45
  // BOTH ends: the route must *be* about choosing a bundle, not merely contain
34
46
  // the words. An editorial "/our-choose-bundle-guide/" stays generic rather
@@ -0,0 +1,99 @@
1
+ // Where the Campaigns API key comes from, and why a candidate key was rejected.
2
+ import {
3
+ isNonEmptyString,
4
+ optionalString,
5
+ firstNonEmptyString,
6
+ readJsonIfExists,
7
+ resolveFromFile,
8
+ } from "./cli-helpers.mjs";
9
+
10
+ // The Campaigns API key VALUE for this packet, for the remit's X-Campaign-Key
11
+ // header (the receiver's tenant join). Same precedence as the doctor's
12
+ // presence check (resolveCampaignsApiKey): packet, then the packet-local
13
+ // CampaignSpec, then the declared env source. Campaign keys are
14
+ // public-by-design; the value is sent as a header, never written into the
15
+ // record. Best-effort: any read problem resolves to null (unscoped remit).
16
+ // A campaign key is an opaque token; the receiver hashes it. The shape gate
17
+ // below is about what is NOT a campaign key: whitespace, JSON, a URL, a
18
+ // multi-kilobyte blob. The env source is additionally restricted to variable
19
+ // names that name a campaign key, so a packet cannot point `api_key_source`
20
+ // at an arbitrary secret (`env:AWS_SECRET_ACCESS_KEY`) and have its value
21
+ // travel as a header.
22
+ const CAMPAIGN_KEY_SHAPE = /^[A-Za-z0-9._-]{8,256}$/;
23
+ // A variable name that names a campaign key: upper-case, starts with a letter,
24
+ // and contains CAMPAIGN anywhere — including at the start, so the documented
25
+ // default `CAMPAIGNS_API_KEY` qualifies (a leading `[A-Z]` that consumed the C
26
+ // used to refuse exactly that name and `CAMPAIGN_KEY`).
27
+ const CAMPAIGN_KEY_ENV_NAME = /^(?=[A-Z])[A-Z0-9_]*CAMPAIGN[A-Z0-9_]*$/;
28
+
29
+ function campaignKeyOrNull(value) {
30
+ const trimmed = typeof value === "string" ? value.trim() : "";
31
+ return CAMPAIGN_KEY_SHAPE.test(trimmed) ? trimmed : null;
32
+ }
33
+
34
+ // Resolve the key AND why a present value was refused. A value that is there
35
+ // but the wrong shape is a different operator problem from no value at all —
36
+ // a typo, a quoted key, a whole JSON blob pasted into the env var, the wrong
37
+ // secret exported under a campaign-key name — and the caller must be able to
38
+ // say which, naming the SOURCE (the env var, or the packet field) and never
39
+ // the value. `rejected` is null when nothing was refused.
40
+ export function resolveCampaignsApiKeySource(packet, packetPath, env = process.env, { spec: loadedSpec = undefined } = {}) {
41
+ const packetRaw = firstNonEmptyString(packet?.campaign?.campaigns_api_key, packet?.campaign?.api_key);
42
+ if (isNonEmptyString(packetRaw)) {
43
+ const packetKey = campaignKeyOrNull(packetRaw);
44
+ if (packetKey) return { key: packetKey, origin: packet?.campaign?.campaigns_api_key ? "packet.campaign.campaigns_api_key" : "packet.campaign.api_key", rejected: null };
45
+ return { key: null, origin: null, rejected: { kind: "malformed", source: packet?.campaign?.campaigns_api_key ? "packet.campaign.campaigns_api_key" : "packet.campaign.api_key" } };
46
+ }
47
+ try {
48
+ // A caller that already holds the packet-local CampaignSpec (doctor) hands
49
+ // it in; otherwise it is read from the packet's local_path.
50
+ const localSpecPath = packet?.spec?.local_path;
51
+ if (loadedSpec !== undefined || (isNonEmptyString(localSpecPath) && isNonEmptyString(packetPath))) {
52
+ const spec = loadedSpec !== undefined ? loadedSpec : readJsonIfExists(resolveFromFile(packetPath, localSpecPath));
53
+ // Name the field the value actually came from: a refused source the
54
+ // operator cannot find in their CampaignSpec is worse than no name.
55
+ const specField = [
56
+ ["campaign.campaigns_api_key", spec?.campaign?.campaigns_api_key],
57
+ ["campaigns_api_key", spec?.campaigns_api_key],
58
+ ["campaign.api_key", spec?.campaign?.api_key],
59
+ ].find(([, value]) => isNonEmptyString(value));
60
+ if (specField) {
61
+ const specKey = campaignKeyOrNull(specField[1]);
62
+ if (specKey) return { key: specKey, origin: `the packet-local CampaignSpec ${specField[0]}`, rejected: null };
63
+ return { key: null, origin: null, rejected: { kind: "malformed", source: `the packet-local CampaignSpec ${specField[0]}` } };
64
+ }
65
+ }
66
+ } catch {
67
+ // unreadable spec — fall through to env
68
+ }
69
+ const source = optionalString(packet?.campaign?.api_key_source);
70
+ if (source && source.startsWith("env:")) {
71
+ const envName = source.slice("env:".length).trim();
72
+ if (!CAMPAIGN_KEY_ENV_NAME.test(envName)) {
73
+ return { key: null, origin: null, rejected: { kind: "unsupported_env_name", source: `api_key_source "env:${envName}"` } };
74
+ }
75
+ const raw = env?.[envName];
76
+ if (isNonEmptyString(raw)) {
77
+ const envKey = campaignKeyOrNull(raw);
78
+ if (envKey) return { key: envKey, origin: `env:${envName}`, rejected: null };
79
+ return { key: null, origin: null, rejected: { kind: "malformed", source: `env:${envName}` } };
80
+ }
81
+ }
82
+ return { key: null, origin: null, rejected: null };
83
+ }
84
+
85
+ // Back-compat shape: the key value or null, for callers that only need the
86
+ // header value.
87
+ export function resolveCampaignsApiKeyValue(packet, packetPath, env = process.env) {
88
+ return resolveCampaignsApiKeySource(packet, packetPath, env).key;
89
+ }
90
+
91
+ // One sentence an operator can act on, naming the refused source and never
92
+ // its value. `null` when nothing was refused.
93
+ export function describeCampaignKeyRejection(rejected) {
94
+ if (!rejected) return null;
95
+ if (rejected.kind === "unsupported_env_name") {
96
+ return `${rejected.source} does not name a campaign key, so its value was not read (an api_key_source env var must match ${CAMPAIGN_KEY_ENV_NAME.source}). No credential was taken from it.`;
97
+ }
98
+ return `the Campaigns API key from ${rejected.source} is not a campaign-key shape (expected ${CAMPAIGN_KEY_SHAPE.source} — 8-256 chars of letters, digits, dot, dash, underscore, one line, no whitespace) and was refused before any request. Fix the value at that source; it is not printed here.`;
99
+ }