@nextcommerce/campaigns-os 1.43.2 → 1.47.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.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +798 -5103
- package/README.md +33 -12
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/compatibility.json +1 -1
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/commerce-surface-catalog.json +1204 -129
- package/contracts/effects.v1.json +1179 -116
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2515 -5919
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +180 -23
- package/docs/campaigns-os-build-flow.md +3 -3
- package/docs/design-source-package.md +73 -0
- package/docs/effects.md +50 -8
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +7 -4
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/qa-and-test-orders.md +118 -14
- package/docs/release-ledger-authoring-guide.md +64 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/built-script-syntax.mjs +116 -15
- package/src/built-site-scope.mjs +16 -4
- package/src/cli.mjs +280 -46
- package/src/commercial-parity.mjs +48 -2
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +6 -2
- package/src/doctor/checks.mjs +319 -81
- package/src/doctor/inspect.mjs +55 -13
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/invocation.mjs +4 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/progress-node.mjs +3 -1
- package/src/qa-analytics-parity.mjs +37 -2
- package/src/qa-binding-evidence.mjs +4 -2
- package/src/qa-browser.mjs +612 -40
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +122 -7
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +32 -7
- package/src/sdk-storage-compatibility.mjs +3 -2
- package/src/source-html-intake.mjs +116 -0
- package/src/stage-record.mjs +551 -0
- package/src/tooling-setup.mjs +9 -0
- package/src/upsell-selector-scope.mjs +112 -2
|
@@ -17,10 +17,17 @@
|
|
|
17
17
|
// nothing it would define runs, but whether the page needs it is not known
|
|
18
18
|
// here, so it does not block.
|
|
19
19
|
//
|
|
20
|
+
// Two shapes are not read and only warn (#515). A `<script>` left unclosed at
|
|
21
|
+
// the end of the file never runs: the browser does not prepare a script
|
|
22
|
+
// element whose end tag never arrives. And a script that is a symlink, or
|
|
23
|
+
// sits under a symlinked directory, resolving outside the site root: a static
|
|
24
|
+
// server would follow it, but its bytes are not part of the built output. A
|
|
25
|
+
// symlink that stays inside the site root is read where it points.
|
|
26
|
+
//
|
|
20
27
|
// Not waivable: a script that cannot be parsed cannot be intended to ship.
|
|
21
28
|
// Both doctor entry points drive it, like the other static built-output gates.
|
|
22
29
|
|
|
23
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
30
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
24
31
|
import { basename, isAbsolute, posix, relative, resolve, sep } from "node:path";
|
|
25
32
|
|
|
26
33
|
import { parse as parseJs } from "acorn";
|
|
@@ -29,6 +36,8 @@ import { parse as parseHtml } from "parse5";
|
|
|
29
36
|
export const SCRIPT_SYNTAX = "built_output.script_syntax";
|
|
30
37
|
export const SCRIPT_SYNTAX_PARSE_FAILURE = `${SCRIPT_SYNTAX}.parse_failure`;
|
|
31
38
|
export const SCRIPT_SYNTAX_MISSING_SCRIPT = `${SCRIPT_SYNTAX}.missing_script`;
|
|
39
|
+
export const SCRIPT_SYNTAX_UNCLOSED_SCRIPT = `${SCRIPT_SYNTAX}.unclosed_script`;
|
|
40
|
+
export const SCRIPT_SYNTAX_SYMLINK_OUTSIDE_SITE = `${SCRIPT_SYNTAX}.symlink_outside_site`;
|
|
32
41
|
|
|
33
42
|
// Classic script MIME types the browser executes. Anything else with a type
|
|
34
43
|
// attribute (JSON-LD, text/template, importmap) is a data block, not script.
|
|
@@ -200,6 +209,17 @@ export function baseInEffect(bases, scriptNode) {
|
|
|
200
209
|
return base ? base.href : null;
|
|
201
210
|
}
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Whether the file ended inside this parse5 element, before its end tag. The
|
|
214
|
+
* parser prepares a script at its end tag; at end of file it marks the element
|
|
215
|
+
* "already started" instead, so the browser never fetches or runs it.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} node a parse5 element parsed with `sourceCodeLocationInfo`
|
|
218
|
+
*/
|
|
219
|
+
export function endsUnclosed(node) {
|
|
220
|
+
return Boolean(node?.sourceCodeLocation) && !node.sourceCodeLocation.endTag;
|
|
221
|
+
}
|
|
222
|
+
|
|
203
223
|
/**
|
|
204
224
|
* `<script src>` references on a page, in document order, with whether each
|
|
205
225
|
* is a module and the `<base href>` in effect when the browser prepares it
|
|
@@ -215,16 +235,21 @@ export function baseInEffect(bases, scriptNode) {
|
|
|
215
235
|
* load there. A module script ignores `nomodule` and is kept (see scriptKind).
|
|
216
236
|
* Template content and noscript are inert and not walked.
|
|
217
237
|
*
|
|
238
|
+
* A script element the file ends inside (its end tag never arrives) is never
|
|
239
|
+
* prepared, so it never runs and is not a reference. It is returned in
|
|
240
|
+
* `unclosed` instead: its src, or null for an inline script.
|
|
241
|
+
*
|
|
218
242
|
* @param {string} html
|
|
219
|
-
* @returns {{ base: string | null, refs: Array<{ src: string, module: boolean, base: string | null }> }}
|
|
243
|
+
* @returns {{ base: string | null, refs: Array<{ src: string, module: boolean, base: string | null }>, unclosed: Array<{ src: string | null }> }}
|
|
220
244
|
*/
|
|
221
245
|
export function pageScriptDocument(html) {
|
|
222
246
|
const refs = [];
|
|
247
|
+
const unclosed = [];
|
|
223
248
|
let document;
|
|
224
249
|
try {
|
|
225
250
|
document = parseHtml(String(html ?? ""), { sourceCodeLocationInfo: true });
|
|
226
251
|
} catch {
|
|
227
|
-
return { base: null, refs };
|
|
252
|
+
return { base: null, refs, unclosed };
|
|
228
253
|
}
|
|
229
254
|
const bases = documentBases(document);
|
|
230
255
|
const walk = (node) => {
|
|
@@ -233,9 +258,12 @@ export function pageScriptDocument(html) {
|
|
|
233
258
|
// href / xlink:href, and a MathML "script" is not a script element.
|
|
234
259
|
if (node.tagName === "script" && node.namespaceURI === HTML_NAMESPACE) {
|
|
235
260
|
const kind = scriptKind(attrs);
|
|
261
|
+
const hasSrc = typeof attrs.src === "string" && attrs.src !== "";
|
|
262
|
+
if (kind && endsUnclosed(node)) {
|
|
263
|
+
unclosed.push({ src: hasSrc ? stripUrlSpace(attrs.src) : null });
|
|
236
264
|
// "prepare the script element" skips only an empty src; anything else,
|
|
237
265
|
// even whitespace, is parsed as a URL and fetched.
|
|
238
|
-
if (kind &&
|
|
266
|
+
} else if (kind && hasSrc) {
|
|
239
267
|
refs.push({ src: stripUrlSpace(attrs.src), module: kind === "module", base: baseInEffect(bases, node) });
|
|
240
268
|
}
|
|
241
269
|
}
|
|
@@ -243,7 +271,7 @@ export function pageScriptDocument(html) {
|
|
|
243
271
|
for (const child of node.childNodes || []) walk(child);
|
|
244
272
|
};
|
|
245
273
|
walk(document);
|
|
246
|
-
return { base: bases[0]?.href ?? null, refs };
|
|
274
|
+
return { base: bases[0]?.href ?? null, refs, unclosed };
|
|
247
275
|
}
|
|
248
276
|
|
|
249
277
|
/**
|
|
@@ -317,6 +345,15 @@ function isRemote(src) {
|
|
|
317
345
|
return /^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//");
|
|
318
346
|
}
|
|
319
347
|
|
|
348
|
+
// The real path of a file, or null when it cannot be resolved.
|
|
349
|
+
function realPathOf(path) {
|
|
350
|
+
try {
|
|
351
|
+
return realpathSync(path);
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
320
357
|
function relFrom(root, path) {
|
|
321
358
|
const rel = relative(root, path);
|
|
322
359
|
return rel && !rel.startsWith("..") ? rel.split(sep).join("/") : path;
|
|
@@ -334,13 +371,23 @@ function relFrom(root, path) {
|
|
|
334
371
|
* emits `/js/...`), never outside either. A base or src on another origin is
|
|
335
372
|
* remote and not read.
|
|
336
373
|
*
|
|
374
|
+
* Missing scripts are keyed by the URL the browser resolves, so two spellings
|
|
375
|
+
* of one URL (`check	out.js`, `checkout.js`) are one entry, under the first
|
|
376
|
+
* spelling met. A path whose real path leaves the site root (a symlinked file
|
|
377
|
+
* or directory pointing elsewhere) is not read and is listed in
|
|
378
|
+
* `outside_site` by the link's own path. A page that ends inside a script
|
|
379
|
+
* element is listed in `unclosed`.
|
|
380
|
+
*
|
|
337
381
|
* @param {{ site_root: string, campaign_dir: string, pages: Array<{ page_id: string, built_path: string }> }} scope
|
|
338
382
|
* @param {string} targetRepo
|
|
339
383
|
*/
|
|
340
384
|
export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
341
385
|
const scripts = new Map();
|
|
342
386
|
const unresolved = new Map();
|
|
387
|
+
const outsideSite = new Map();
|
|
388
|
+
const unclosed = [];
|
|
343
389
|
const pages = Array.isArray(scope?.pages) ? scope.pages : [];
|
|
390
|
+
const siteRootReal = scope?.site_root ? realPathOf(scope.site_root) : null;
|
|
344
391
|
for (const page of pages) {
|
|
345
392
|
let html;
|
|
346
393
|
try {
|
|
@@ -348,9 +395,12 @@ export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
|
348
395
|
} catch {
|
|
349
396
|
continue;
|
|
350
397
|
}
|
|
351
|
-
const
|
|
398
|
+
const document = pageScriptDocument(html);
|
|
399
|
+
// One entry per page: the file ends inside at most one script, and the
|
|
400
|
+
// warning is about the page's truncated output.
|
|
401
|
+
if (document.unclosed.length) unclosed.push({ src: document.unclosed[0].src, pages: [page.page_id] });
|
|
352
402
|
const pageUrl = pageUrlFor(scope.site_root, page.built_path);
|
|
353
|
-
for (const ref of refs) {
|
|
403
|
+
for (const ref of document.refs) {
|
|
354
404
|
if (isRemote(ref.src)) continue;
|
|
355
405
|
const { remote, pathname } = resolveScriptUrl(ref.src, ref.base, pageUrl);
|
|
356
406
|
if (remote) continue;
|
|
@@ -366,9 +416,21 @@ export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
|
366
416
|
isFile = false;
|
|
367
417
|
}
|
|
368
418
|
if (!isFile) {
|
|
369
|
-
const
|
|
419
|
+
const urlKey = pathname ?? `\u0000${ref.src}`;
|
|
420
|
+
const entry = unresolved.get(urlKey) || { src: ref.src, pages: [] };
|
|
370
421
|
if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
|
|
371
|
-
unresolved.set(
|
|
422
|
+
unresolved.set(urlKey, entry);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
// Read where a static server would serve it, but only while the real
|
|
426
|
+
// path stays inside the site root. When either real path is unknown the
|
|
427
|
+
// check is undecidable, and the script is read as before.
|
|
428
|
+
const real = realPathOf(path);
|
|
429
|
+
if (siteRootReal && real && real !== siteRootReal && !real.startsWith(`${siteRootReal}${sep}`)) {
|
|
430
|
+
const file = relFrom(targetRepo, resolve(path));
|
|
431
|
+
const entry = outsideSite.get(file) || { file, src: ref.src, pages: [] };
|
|
432
|
+
if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
|
|
433
|
+
outsideSite.set(file, entry);
|
|
372
434
|
continue;
|
|
373
435
|
}
|
|
374
436
|
const key = `${resolve(path)}\u0000${ref.module ? "module" : "script"}`;
|
|
@@ -386,7 +448,13 @@ export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
|
386
448
|
if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
|
|
387
449
|
}
|
|
388
450
|
}
|
|
389
|
-
return {
|
|
451
|
+
return {
|
|
452
|
+
pages_scanned: pages.length,
|
|
453
|
+
scripts: [...scripts.values()],
|
|
454
|
+
unresolved: [...unresolved.values()],
|
|
455
|
+
outside_site: [...outsideSite.values()],
|
|
456
|
+
unclosed,
|
|
457
|
+
};
|
|
390
458
|
}
|
|
391
459
|
|
|
392
460
|
function gateBase(subject) {
|
|
@@ -405,11 +473,16 @@ function gateBase(subject) {
|
|
|
405
473
|
*
|
|
406
474
|
* @param {{ subject?: object, pages_scanned?: number,
|
|
407
475
|
* scripts?: Array<{ file: string, module?: boolean, content: string, pages?: string[] }>,
|
|
408
|
-
* unresolved?: Array<{ src: string, pages: string[] }
|
|
476
|
+
* unresolved?: Array<{ src: string, pages: string[] }>,
|
|
477
|
+
* outside_site?: Array<{ file: string, src: string, pages: string[] }>,
|
|
478
|
+
* unclosed?: Array<{ src: string | null, pages: string[] }> }} input
|
|
409
479
|
*/
|
|
410
|
-
export function evaluateBuiltScriptSyntax({ subject, pages_scanned: pagesScanned = 0, scripts = [], unresolved = [] } = {}) {
|
|
480
|
+
export function evaluateBuiltScriptSyntax({ subject, pages_scanned: pagesScanned = 0, scripts = [], unresolved = [], outside_site: outsideSite = [], unclosed = [] } = {}) {
|
|
411
481
|
const list = Array.isArray(scripts) ? scripts : [];
|
|
412
482
|
const missing = Array.isArray(unresolved) ? unresolved : [];
|
|
483
|
+
const outside = Array.isArray(outsideSite) ? outsideSite : [];
|
|
484
|
+
const open = Array.isArray(unclosed) ? unclosed : [];
|
|
485
|
+
const onPages = (pages) => (pages.length ? ` on ${pages.join(", ")}` : "");
|
|
413
486
|
// A local script the page loads that is not in the built output: a 404 at
|
|
414
487
|
// runtime. A warning, not a blocker (#502).
|
|
415
488
|
const warned = missing.map((entry) => {
|
|
@@ -418,11 +491,39 @@ export function evaluateBuiltScriptSyntax({ subject, pages_scanned: pagesScanned
|
|
|
418
491
|
code: SCRIPT_SYNTAX_MISSING_SCRIPT,
|
|
419
492
|
src: entry.src,
|
|
420
493
|
pages,
|
|
421
|
-
message: `${entry.src} is loaded by a local <script src>${pages
|
|
494
|
+
message: `${entry.src} is loaded by a local <script src>${onPages(pages)} 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
495
|
};
|
|
423
496
|
});
|
|
424
|
-
|
|
425
|
-
|
|
497
|
+
// A script symlink whose target is outside the site root (#515): not read,
|
|
498
|
+
// and named by the link, never by where it points.
|
|
499
|
+
for (const entry of outside) {
|
|
500
|
+
const pages = Array.isArray(entry.pages) ? entry.pages : [];
|
|
501
|
+
warned.push({
|
|
502
|
+
code: SCRIPT_SYNTAX_SYMLINK_OUTSIDE_SITE,
|
|
503
|
+
file: entry.file,
|
|
504
|
+
src: entry.src,
|
|
505
|
+
pages,
|
|
506
|
+
message: `${entry.file} is loaded by a local <script src>${onPages(pages)} but is a symlink whose target is outside the site root, so its syntax was not checked. A static server may still serve it. Copy the script into the build output instead of linking to it.`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
// A page that ends inside a script element (#515): the browser never runs
|
|
510
|
+
// that script, so it is not parsed. The page output is probably truncated.
|
|
511
|
+
for (const entry of open) {
|
|
512
|
+
const pages = Array.isArray(entry.pages) ? entry.pages : [];
|
|
513
|
+
warned.push({
|
|
514
|
+
code: SCRIPT_SYNTAX_UNCLOSED_SCRIPT,
|
|
515
|
+
src: entry.src ?? null,
|
|
516
|
+
pages,
|
|
517
|
+
message: `${entry.src ? `The <script src="${entry.src}">` : "An inline <script>"}${onPages(pages)} is never closed: the page ends before its </script>. The browser does not run a script element whose end tag never arrives, so it was not parsed. Check the page for truncated output and rebuild.`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
const notes = [
|
|
521
|
+
missing.length ? ` ${missing.length} referenced local script(s) are not in the built output.` : "",
|
|
522
|
+
outside.length ? ` ${outside.length} script symlink(s) resolve outside the site root and were not read.` : "",
|
|
523
|
+
open.length ? ` ${open.length} page(s) end inside an unclosed <script>.` : "",
|
|
524
|
+
];
|
|
525
|
+
const missingNote = notes.join("");
|
|
526
|
+
const common = { scripts_unresolved: missing, scripts_outside_site: outside, warned, pages_scanned: pagesScanned };
|
|
426
527
|
if (list.length === 0) {
|
|
427
528
|
return {
|
|
428
529
|
...gateBase(subject),
|
package/src/built-site-scope.mjs
CHANGED
|
@@ -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 (
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
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
|