@nextcommerce/campaigns-os 1.46.0 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +153 -3
  2. package/README.md +1 -1
  3. package/compatibility.json +1 -1
  4. package/contracts/commerce-surface-catalog.json +1204 -129
  5. package/contracts/effects.v1.json +3 -3
  6. package/contracts/release-ledger.json +338 -0
  7. package/contracts/supported-surface.json +2 -2
  8. package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
  9. package/docs/build-packet.md +22 -2
  10. package/docs/local-setup.md +7 -4
  11. package/docs/orientation-contract-reference.md +1 -1
  12. package/docs/qa-and-test-orders.md +19 -1
  13. package/docs/runtime-readiness.md +1 -1
  14. package/docs/sdk-storage-compatibility.md +1 -1
  15. package/docs/skills-revision.md +10 -10
  16. package/package.json +1 -1
  17. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  18. package/skills/campaign-readback-classification/SKILL.md +3 -3
  19. package/skills/campaign-run-evidence/SKILL.md +3 -3
  20. package/skills/contribution-intake/SKILL.md +3 -3
  21. package/skills/next-campaigns-build/SKILL.md +4 -4
  22. package/skills/next-campaigns-os/SKILL.md +3 -3
  23. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  24. package/skills/next-campaigns-polish/SKILL.md +3 -3
  25. package/skills/next-campaigns-qa/SKILL.md +4 -4
  26. package/skills.json +10 -10
  27. package/src/built-script-syntax.mjs +116 -15
  28. package/src/diagnostic.mjs +2 -1
  29. package/src/doctor/checks.mjs +0 -1
  30. package/src/qa-analytics-parity.mjs +37 -2
  31. package/src/qa-binding-evidence.mjs +4 -2
  32. package/src/qa-browser.mjs +74 -12
  33. package/src/sdk-markup.mjs +6 -45
  34. package/src/sdk-storage-compatibility.mjs +3 -2
  35. package/src/tooling-setup.mjs +9 -0
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-readback-classification
3
- version: 1.0.17
3
+ version: 1.0.19
4
4
  description: Classify a selected campaign from the readback projection's v2 fields and write a read-only handoff without turning diagnosis into permission.
5
5
  ---
6
6
 
7
- Bundle revision: 1.46.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.46.0+skills.1`
7
+ Bundle revision: 1.47.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.47.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: campaign-run-evidence
3
- version: 1.0.17
3
+ version: 1.0.19
4
4
  description: Interpret existing Campaigns OS doctor, QA and proof-depth evidence without claiming more proof than the artifacts contain.
5
5
  ---
6
6
 
7
- Bundle revision: 1.46.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.46.0+skills.1`
7
+ Bundle revision: 1.47.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.47.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: contribution-intake
3
- version: 1.0.17
3
+ version: 1.0.19
4
4
  description: Turn a suggestion about the agent surface into a classified, evidence-checked proposal and, only with attended approval, one issue on this repository's tracker.
5
5
  ---
6
6
 
7
- Bundle revision: 1.46.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.46.0+skills.1`
7
+ Bundle revision: 1.47.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.47.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-build
3
- version: 1.0.22
3
+ version: 1.0.24
4
4
  description: Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts.
5
5
  ---
6
6
 
7
- Bundle revision: 1.46.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.46.0+skills.1`
7
+ Bundle revision: 1.47.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.47.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -87,7 +87,7 @@ Build rules:
87
87
  - If `doctor` (tier `A`: inspection that writes nothing, plus one read-only live campaign request once the site is built and a public campaign key resolves; `--write` is also tier `A` and writes doctor output; `--built` is tier `B`) reports `derived.scope.mode = "partial"`, build the pages listed in `derived.scope.built_pages` from their prepared source. Keep every out-of-scope route unbuilt by default, including pages marked `template_stock: true`; never publish placeholder presell/landing pages that remain on another host. Materialize a stock page only with explicit per-page operator opt-in (including a required pre-checkout `select` stand-in). For opted-in pages use the locked family's own page for that role with its dependent `_includes/`, `_layouts/`, and assets, wire it from CampaignSpec, and build a required select step first. Do not attest stock screenshots as design source. Built stock pages rejoin preview QA once their HTML exists. Carry remaining `skip_reason` declarations into the report and label the preview route/visual-testable rather than full-funnel launch-ready.
88
88
  - For `landing` and `presell` pages, prefer the prepared source HTML when `source_html.pages[].path` points at a real standalone page. Preserve the design/content through a passthrough page-kit layout, inject the SDK loader/config as needed, and repoint CTAs into the CampaignSpec flow. Treat `source_html.pages[].path` and `context.page_map[].source_path` as source provenance. Treat `source_html.pages[].page_kit`, `context.page_map[].page_kit`, and `context.page_map[].output_path` as the Page Kit target file, route, CPK `page_type`, and frontmatter projection.
89
89
  - Prepared source HTML means page-kit-ready markup, not a wholesale Liquid rewrite. Standalone HTML mockups that are meant to stay whole (their `source_screenshot` proof is of the full document) keep their document wrappers: the source-html manifest records `wrapper_policy: preserve_document_wrappers` (or `--wrapper-policy preserve_document_wrappers` on `start` / `prepare-build`), and doctor reports the wrappers as a warning. Otherwise, standalone AI/exported HTML should keep page-owned body markup, remove document wrappers, add YAML frontmatter, move shared CSS/assets into the campaign structure, and use Liquid helpers only where page-kit needs campaign-rooted links/assets/includes.
90
- - For `checkout`, `upsell`, `downsell`, and `receipt` pages, treat the selected starter-template commerce surface as the SDK contract reference: preserve required `data-next-*` controls, hidden fields, payment/address/totals/submit wiring, and `next_dont_touch` regions. The surrounding HTML wrapper, page composition, imagery, copy hierarchy, and brand layer are campaign/source-owned. Do not carry starter visual chrome forward when prepared source design should own that surface. QA checks what the checkout does, not family class names: do not add family shell classes (`.checkout-wrapper`, `.checkout-layout__left/right`) to the campaign's own grid or swap source-owned field markup for family includes to satisfy QA. Keep the checkout working instead: a `<form data-next-checkout="form">`, the contact and shipping fields bound with `data-next-checkout-field` inside it, and a visible cart total.
90
+ - For `checkout`, `upsell`, `downsell`, and `receipt` pages, treat the selected starter-template commerce surface as the SDK contract reference: preserve required `data-next-*` controls, hidden fields, payment/address/totals/submit wiring, and `next_dont_touch` regions. The surrounding HTML wrapper, page composition, imagery, copy hierarchy, and brand layer are campaign/source-owned. Do not carry starter visual chrome forward when prepared source design should own that surface. QA checks what the checkout does, not family class names: do not add family shell classes (`.checkout-wrapper`, `.checkout-layout__left/right`) to the campaign's own grid or swap source-owned field markup for family includes to satisfy QA. Keep the checkout working instead: a `<form data-next-checkout="form">`, the contact and shipping fields bound with `data-next-checkout-field` inside it, and a visible cart total. A required field bound only on a `type="hidden"` input, a disabled control, a read-only input or read-only textarea, or a control with `aria-disabled="true"` does not count as bound: QA reports it in `fields_bound.missing`.
91
91
  - Read `context.theme` and `.campaign-runtime/theme/theme-report.json` when present. If a fresh `brand-theme.css` artifact exists, copy it into the campaign asset tree and load it after `next-core.css` on checkout, upsell, downsell, and receipt pages. If policy is `inspect_only`, either run `campaigns-os theme generate` (tier `B`: writes the theme artifacts and doctor output under the target; `--force` is tier `C`) or record an explicit skipped reason before applying a new brand layer.
92
92
  - Generated brand-theme v0 is root-variable-only. It may skin commerce pages through next-core custom properties, but it is not permission to edit SDK-owned selectors, package controls, payment fields, totals, submit controls, receipt templates, route meta tags, or SDK JavaScript.
93
93
  - Payment, express checkout, bundle selectors, and order bumps must start from the selected family's canonical component DOM/classes, not from raw custom/source HTML with `data-next-*` added afterward. For payment specifically, preserve the family payment-method wrapper, hosted field classes, and iframe geometry assumptions (for example `input-flds spreedly-field` in shop-style templates). Skin these components with campaign tokens; do not rebuild Spreedly/card fields as arbitrary divs.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os
3
- version: 1.0.37
3
+ version: 1.0.39
4
4
  description: Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth.
5
5
  ---
6
6
 
7
- Bundle revision: 1.46.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.46.0+skills.1`
7
+ Bundle revision: 1.47.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.47.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-os-setup
3
- version: 2.0.20
3
+ version: 2.0.22
4
4
  description: Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly installed as next-campaigns-setup; renamed 2026-08 to stop colliding with the published NextCommerceCo/skills scaffolder of that name.
5
5
  ---
6
6
 
7
- Bundle revision: 1.46.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.46.0+skills.1`
7
+ Bundle revision: 1.47.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.47.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-polish
3
- version: 1.1.21
3
+ version: 1.1.23
4
4
  description: Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign.
5
5
  ---
6
6
 
7
- Bundle revision: 1.46.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.46.0+skills.1`
7
+ Bundle revision: 1.47.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.47.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: next-campaigns-qa
3
- version: 1.3.21
3
+ version: 1.3.23
4
4
  description: Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof.
5
5
  ---
6
6
 
7
- Bundle revision: 1.46.0+skills.1
8
- Run `npx --no-install campaigns-os tooling status --skills-revision 1.46.0+skills.1`
7
+ Bundle revision: 1.47.0+skills.1
8
+ Run `npx --no-install campaigns-os tooling status --skills-revision 1.47.0+skills.1`
9
9
  from the campaign's Page Kit folder, where it runs the project's pinned copy and
10
10
  never installs one, at the start of each task. Start a fresh session if it
11
11
  reports `mismatch`: this text is already in your context and is never re-read
@@ -83,7 +83,7 @@ Rules:
83
83
  - Template residue is a QA dimension, not advice: promoted starter families must have a brand/residue/pricing contract (`contracts/template-brand-contract.<family>.v0.json`). Browser QA inspects computed styles on commerce surfaces and fails pages that still render starter defaults (`#3c7dff`/`#0a265c`, starter `next-logo.png`, paypal/klarna chrome absent from the spec).
84
84
  - Pricing visibility is a blocker: an upsell/downsell offer with zero visible price rows fails QA. Pricing surfaces render via template pricing modes (`full_price`, `compare_at_current`, `unit_price_plus_total`, `savings_badge_amount`, `code_discounted_post_checkout`), never via campaign CSS `display:none` on price wrappers.
85
85
  - The upsell and checkout bundle price checks count the SDK's `data-next-bundle-display` price as a price row when it is visible and not empty.
86
- - `browser-commerce-structure` checks what the checkout does, not family class names; the checkout wrapper and page composition are source-owned. A missing family shell selector (only `.checkout-wrapper`, `.checkout-layout__left/right`, `.checkout__layout`, `.checkout__column--left/right` and the shipping field row marker) is `warn` when the checkout passes the behaviour checks in `evidence.behaviour`: a `<form data-next-checkout="form">`, `email`/`fname`/`lname`/`country`/`address1`/`city`/`province`/`postal` bound with `data-next-checkout-field` on an input, select or textarea inside it that is not `type="hidden"` or disabled (it may be hidden until a country is chosen), and a visible cart total. It stays `fail` when any of those fails or no checkout form is found, and any other missing selector (SDK attributes, payment field classes) is always `fail`. Do not ask the build to add family classes to clear a `warn`; fix the failing behaviour check.
86
+ - `browser-commerce-structure` checks what the checkout does, not family class names; the checkout wrapper and page composition are source-owned. A missing family shell selector (only `.checkout-wrapper`, `.checkout-layout__left/right`, `.checkout__layout`, `.checkout__column--left/right` and the shipping field row marker) is `warn` when the checkout passes the behaviour checks in `evidence.behaviour`: a `<form data-next-checkout="form">`, `email`/`fname`/`lname`/`country`/`address1`/`city`/`province`/`postal` bound with `data-next-checkout-field` on an input, select or textarea inside it that is not `type="hidden"` or disabled (it may be hidden until a country is chosen), and a visible cart total. A required field bound only on a `type="hidden"` input, a disabled control, a read-only input or read-only textarea, or a control with `aria-disabled="true"` does not count as bound: QA reports it in `fields_bound.missing`. It stays `fail` when any of those fails or no checkout form is found, and any other missing selector (SDK attributes, payment field classes) is always `fail`. Do not ask the build to add family classes to clear a `warn`; fix the failing behaviour check.
87
87
  - Exit-pop widgets are governed offer surfaces. If the selected family ships or copies a default exit-pop and CampaignSpec has no checkout `exit_intent` or `promo_code_input`, QA/doctor must report it as residue; strip it or wire the mapped offer/code through the SDK coupon path.
88
88
  - Typed-card runs emit a per-step ladder (`[qa:test-order] step=... status=...`) with bounded per-step and per-path timeouts, and always produce a verdict — a hung or crashed path is a blocked verdict with the step ladder as evidence, not a silent exit. Read the last completed step before re-running.
89
89
  - A typed-card path that fails is classified by **what it did to the store** before the runner decides what to do about it. A failure the runner can prove happened before submit (`not_created`) is **re-run once, if the creation budget has a slot no still-unrun planned path needs** — so a transient miss is not reported as a defect in the build, without an early path eating budget the last planned paths need. Under the default budget a path whose submit was *rejected* has already spent its own slot, so it is not re-run and records `evidence.order_creation.rerun_skipped` instead. When the re-run does happen, both attempts appear in `test_orders[]` and `evidence.retry` names the first attempt's error and ref id. A failure that happened **after** the order was created (`created` — most often a receipt that did not render) is **never resubmitted**: the runner reloads that order's receipt and re-runs only the read-only checks, and `evidence.recovery` carries the original failure, the checks re-run, and whether it cleared. Read `evidence.order_creation` for two separate counts: `submissions_reserved` (platform-side creation slots charged to this path — reserved before a submit click, or charged for a hosted-checkout redirect where no submit click happens — which stand even when the create then failed) and `orders_confirmed_created` (creates the platform was observed to accept) — a spent slot with no confirmed order is the ambiguous case, not an order to reconcile. Recovery clears only on persisted evidence it re-read on that pass: a failed or absent order read-back stops it honestly rather than re-deciding against the original attempt's numbers. An outcome it cannot prove either way (`ambiguous` — an unusable read-back, a lost create response, a network-failed create, a 4xx after an earlier 2xx) stops the path and names the operator check instead of buying again. A pass that only came back after recovery is never indistinguishable from a first-attempt pass, and a failure that survives recovery still blocks.
package/skills.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "campaigns-os-skills",
3
3
  "description": "Skills bundled with Campaigns OS. `skills.sh` installs these into the shared agent skill directories (~/.claude/skills, ~/.codex/skills), so each one is a versioned package: bump the version whenever a package changes. `bundle_revision` identifies the bundle as a whole — `<package version>+skills.<n>`, where the prefix is this package's version and `<n>` counts the skill-text revisions published against it. It advances whenever ANY bundled skill changes, every SKILL.md states it on its first body line, and `campaigns-os tooling status --skills-revision <value>` compares the value an agent read from a skill against the bundle on disk. See docs/skills-revision.md.",
4
- "bundle_revision": "1.46.0+skills.1",
4
+ "bundle_revision": "1.47.0+skills.1",
5
5
  "homepage": "https://github.com/NextCommerceCo/campaigns-os",
6
6
  "retired_skills": [
7
7
  {
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "id": "next-campaigns-os",
18
18
  "name": "Campaigns OS Lifecycle",
19
- "version": "1.0.37",
19
+ "version": "1.0.39",
20
20
  "path": "skills/next-campaigns-os/SKILL.md",
21
21
  "domain": "campaigns",
22
22
  "description": "Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth."
@@ -24,7 +24,7 @@
24
24
  {
25
25
  "id": "next-campaigns-os-setup",
26
26
  "name": "Campaigns OS Setup",
27
- "version": "2.0.20",
27
+ "version": "2.0.22",
28
28
  "path": "skills/next-campaigns-os-setup/SKILL.md",
29
29
  "domain": "campaigns",
30
30
  "description": "Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly next-campaigns-setup; renamed to release that name to the published NextCommerceCo/skills scaffolder."
@@ -32,7 +32,7 @@
32
32
  {
33
33
  "id": "next-campaigns-build",
34
34
  "name": "Campaign Build",
35
- "version": "1.0.22",
35
+ "version": "1.0.24",
36
36
  "path": "skills/next-campaigns-build/SKILL.md",
37
37
  "domain": "campaigns",
38
38
  "description": "Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts."
@@ -40,7 +40,7 @@
40
40
  {
41
41
  "id": "next-campaigns-polish",
42
42
  "name": "Campaign Polish",
43
- "version": "1.1.21",
43
+ "version": "1.1.23",
44
44
  "path": "skills/next-campaigns-polish/SKILL.md",
45
45
  "domain": "campaigns",
46
46
  "description": "Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign."
@@ -48,7 +48,7 @@
48
48
  {
49
49
  "id": "next-campaigns-qa",
50
50
  "name": "Campaign QA",
51
- "version": "1.3.21",
51
+ "version": "1.3.23",
52
52
  "path": "skills/next-campaigns-qa/SKILL.md",
53
53
  "domain": "campaigns",
54
54
  "description": "Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof."
@@ -56,7 +56,7 @@
56
56
  {
57
57
  "id": "campaign-lifecycle-orientation",
58
58
  "name": "Campaign Lifecycle Orientation",
59
- "version": "1.0.17",
59
+ "version": "1.0.19",
60
60
  "path": "skills/campaign-lifecycle-orientation/SKILL.md",
61
61
  "domain": "campaigns",
62
62
  "description": "Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing their state: the shared vocabulary, doctor as a gate, the stage record, and the store-theme/Page Kit two-worlds trap."
@@ -64,7 +64,7 @@
64
64
  {
65
65
  "id": "campaign-run-evidence",
66
66
  "name": "Campaign Run Evidence",
67
- "version": "1.0.17",
67
+ "version": "1.0.19",
68
68
  "path": "skills/campaign-run-evidence/SKILL.md",
69
69
  "domain": "campaigns",
70
70
  "description": "Interpret existing doctor, QA verdict and proof-depth evidence without claiming more proof than the artifacts contain, and keep funnel proof apart from merchant launch readiness."
@@ -72,7 +72,7 @@
72
72
  {
73
73
  "id": "campaign-readback-classification",
74
74
  "name": "Campaign Readback Classification",
75
- "version": "1.0.17",
75
+ "version": "1.0.19",
76
76
  "path": "skills/campaign-readback-classification/SKILL.md",
77
77
  "domain": "campaigns",
78
78
  "description": "Classify a selected campaign from the campaigns-os-readback/v2 fields (artifacts, staleness.stale_keys, clean, doctor, divergences, skip_cascades) and write a read-only handoff without turning diagnosis into permission."
@@ -80,7 +80,7 @@
80
80
  {
81
81
  "id": "contribution-intake",
82
82
  "name": "Contribution Intake",
83
- "version": "1.0.17",
83
+ "version": "1.0.19",
84
84
  "path": "skills/contribution-intake/SKILL.md",
85
85
  "domain": "campaigns",
86
86
  "description": "Turn a suggestion about the agent surface into a classified, evidence-checked and redacted proposal, filed on this repository tracker only with attended approval."
@@ -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 && typeof attrs.src === "string" && attrs.src !== "") {
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&#9;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 { refs } = pageScriptDocument(html);
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 entry = unresolved.get(ref.src) || { src: ref.src, pages: [] };
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(ref.src, entry);
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 { pages_scanned: pages.length, scripts: [...scripts.values()], unresolved: [...unresolved.values()] };
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[] }> }} input
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.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.`,
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
- 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 };
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),
@@ -20,7 +20,8 @@ const REASONS = new Set([
20
20
  "built_output.upsell_selector_scope", "built_output.sdk_markup.swap_with_add_to_cart",
21
21
  "built_output.sdk_markup.checkout_not_form", "built_output.sdk_markup.wrong_field_name",
22
22
  "built_output.sdk_markup.missing_selector_id_match", "built_output.script_syntax.parse_failure",
23
- "built_output.script_syntax.missing_script", "source_html.producer_provenance",
23
+ "built_output.script_syntax.missing_script", "built_output.script_syntax.unclosed_script",
24
+ "built_output.script_syntax.symlink_outside_site", "source_html.producer_provenance",
24
25
  "source_html.producer_provenance.source_type", "source_html.producer_provenance.screenshot_fallback_used",
25
26
  "source_html.producer_provenance.semantic_section_count", "source_html.producer_provenance.material_fingerprint",
26
27
  "source_html.producer_provenance.section_exports", "source_html.producer_provenance.waiver_inert",
@@ -2041,7 +2041,6 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
2041
2041
  }
2042
2042
  return {
2043
2043
  page_id: page.page_id,
2044
- page_type: page.page_type,
2045
2044
  route: page.route,
2046
2045
  file: relFromDir(targetRepo, page.built_path),
2047
2046
  content,
@@ -547,6 +547,10 @@ function valuesEqual(a, b) {
547
547
  // flag carried-over-tag regressions for human review.
548
548
  // `options.url` is the candidate URL that was captured (the resolved capture
549
549
  // target) — stamped on every emitted assertion, pass and fail alike.
550
+ // `options.candidatePage` describes the page the candidate was captured on when
551
+ // the leg picked it automatically (#512): `{ receipt, source, page_type }`.
552
+ // An explicit --analytics-candidate passes none and is treated as the receipt
553
+ // the operator named.
550
554
  export function diffAnalyticsParity(baseline, candidate, options = {}) {
551
555
  const assertions = [];
552
556
  const auditedUrl = (typeof options.url === "string" && options.url.trim()) ? options.url.trim() : null;
@@ -561,13 +565,44 @@ export function diffAnalyticsParity(baseline, candidate, options = {}) {
561
565
  const cEff = effectivePurchase(c);
562
566
 
563
567
  // 1. Purchase present on candidate — the highest-value blocking check.
564
- assertions.push(emit({
568
+ // #512: Purchase fires only on a receipt. When the candidate is known not to
569
+ // be one (the automatic capture page is the campaign root or a built entry),
570
+ // a missing Purchase is a page mismatch, not a regression, so it goes to
571
+ // manual review naming the mismatch. A receipt candidate, or one the
572
+ // operator named, still blocks.
573
+ const candidatePage = options.candidatePage && typeof options.candidatePage === "object" ? options.candidatePage : null;
574
+ if (!cEff.fired && candidatePage?.receipt === false) {
575
+ const baselineFired = effectivePurchase(b).fired;
576
+ assertions.push(emit({
577
+ id: "analytics-parity:purchase-present",
578
+ status: STATUS.MANUAL_REVIEW,
579
+ severity: SEVERITY.WARN,
580
+ expected: "a receipt candidate to check Purchase on; the automatic candidate is not a receipt, so pair receipts with --analytics-candidate",
581
+ actual: baselineFired
582
+ ? "baseline fired a Purchase but the automatic candidate is not a receipt page; pass --analytics-candidate <candidate receipt url> to compare receipts"
583
+ : "neither page fired a Purchase and the automatic candidate is not a receipt page; pass receipt URLs to --analytics-baseline and --analytics-candidate to compare Purchase",
584
+ evidence: {
585
+ via: cEff.via,
586
+ candidate_events: c.eventNames || [],
587
+ baseline_purchase: bp,
588
+ page_mismatch: {
589
+ reason: baselineFired ? "receipt_baseline_non_receipt_candidate" : "candidate_not_receipt",
590
+ baseline_fired_purchase: baselineFired,
591
+ candidate_receipt: candidatePage.receipt,
592
+ candidate_source: candidatePage.source ?? null,
593
+ candidate_page_type: candidatePage.page_type ?? null,
594
+ },
595
+ },
596
+ }));
597
+ } else assertions.push(emit({
565
598
  id: "analytics-parity:purchase-present",
566
599
  status: cEff.fired ? STATUS.PASS : STATUS.FAIL,
567
600
  severity: SEVERITY.BLOCKER,
568
601
  expected: "candidate fires a Purchase (dl_purchase, or Meta/GA4 pixel if the SDK event is blocked)",
569
602
  actual: cEff.fired ? `purchase fired via ${cEff.via}` : "no purchase fire captured on candidate (dataLayer, Meta, or GA4)",
570
- evidence: { via: cEff.via, candidate_events: c.eventNames || [], candidate_signals: c.purchaseSignals || {}, baseline_purchase: bp },
603
+ // An automatic candidate's page is recorded even when it fired, so a pass on
604
+ // a non-receipt page reads as such.
605
+ evidence: { via: cEff.via, candidate_events: c.eventNames || [], candidate_signals: c.purchaseSignals || {}, baseline_purchase: bp, ...(candidatePage ? { candidate_page: candidatePage } : {}) },
571
606
  }));
572
607
 
573
608
  if (cEff.fired) {
@@ -1,7 +1,7 @@
1
1
  import { parse as parseHtml } from 'parse5';
2
2
  import { parse as parseJs } from 'acorn';
3
3
  import { createPageSourceLoader, resolveCommercialApiKey } from './qa-commercial-parity.mjs';
4
- import { HTML_NAMESPACE, baseInEffect, documentBases, frozenBaseUrl, parseFailureDiagnostic, scriptKind } from './built-script-syntax.mjs';
4
+ import { HTML_NAMESPACE, baseInEffect, documentBases, endsUnclosed, frozenBaseUrl, parseFailureDiagnostic, scriptKind } from './built-script-syntax.mjs';
5
5
 
6
6
  export const BINDING_SCHEMA = 'campaigns-os-page-binding/v0';
7
7
  export const BINDING_LIMITS = Object.freeze({ scripts_per_page: 6, scripts_per_run: 24, script_bytes: 262144, timeout_ms: 5000 });
@@ -124,8 +124,10 @@ export async function observeBinding({ source, page, expected, scriptLoader, par
124
124
  // Only an HTML-namespace <script> loads `src`. An SVG script runs from
125
125
  // href / xlink:href or its inline text, which this static read does not
126
126
  // model: it is not fetched and leaves the binding dynamic.
127
+ // A script the file ends inside never reaches its end tag, so the parser
128
+ // never prepares it: the browser neither fetches nor runs it (#515).
127
129
  if (node.tagName === 'script' && node.namespaceURI !== HTML_NAMESPACE) dynamic = true;
128
- else if (node.tagName === 'script') scripts.push({ attrs, base: baseInEffect(bases, node), text: (node.childNodes || []).map(n => n.value || '').join('') });
130
+ else if (node.tagName === 'script' && !endsUnclosed(node)) scripts.push({ attrs, base: baseInEffect(bases, node), text: (node.childNodes || []).map(n => n.value || '').join('') });
129
131
  // parse5 keeps template content separate; it is inert, as is noscript at boot.
130
132
  if (node.tagName !== 'noscript') for (const child of node.childNodes || []) walk(child);
131
133
  };