@nextcommerce/campaigns-os 1.46.0 → 1.48.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 (52) hide show
  1. package/CHANGELOG.md +347 -3
  2. package/README.md +31 -4
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +1 -0
  5. package/agents/copilot/copilot-instructions.md +1 -0
  6. package/agents/cursor/campaigns-os.mdc +1 -1
  7. package/compatibility.json +1 -1
  8. package/contracts/commerce-surface-catalog.json +1204 -129
  9. package/contracts/effects.v1.json +3 -3
  10. package/contracts/release-ledger.json +803 -0
  11. package/contracts/supported-surface.json +2 -2
  12. package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
  13. package/docs/build-packet.md +23 -3
  14. package/docs/campaign-build-brief.md +25 -28
  15. package/docs/local-setup.md +13 -5
  16. package/docs/orientation-contract-reference.md +1 -1
  17. package/docs/qa-and-test-orders.md +68 -3
  18. package/docs/runtime-readiness.md +1 -1
  19. package/docs/sdk-storage-compatibility.md +1 -1
  20. package/docs/skills-revision.md +10 -10
  21. package/package.json +1 -1
  22. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  23. package/skills/campaign-readback-classification/SKILL.md +3 -3
  24. package/skills/campaign-run-evidence/SKILL.md +9 -4
  25. package/skills/contribution-intake/SKILL.md +3 -3
  26. package/skills/next-campaigns-build/SKILL.md +5 -4
  27. package/skills/next-campaigns-os/SKILL.md +3 -3
  28. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  29. package/skills/next-campaigns-polish/SKILL.md +4 -4
  30. package/skills/next-campaigns-qa/SKILL.md +4 -4
  31. package/skills.json +10 -10
  32. package/src/built-script-syntax.mjs +116 -15
  33. package/src/cli.mjs +6 -3
  34. package/src/content-residue.mjs +18 -90
  35. package/src/diagnostic.mjs +2 -1
  36. package/src/doctor/checks.mjs +21 -30
  37. package/src/doctor/inspect.mjs +13 -3
  38. package/src/doctor/next-step.mjs +4 -0
  39. package/src/gate-actions.mjs +8 -0
  40. package/src/local-preview-policy.mjs +92 -0
  41. package/src/page-kit-sdk-version.mjs +8 -1
  42. package/src/polish-node.mjs +26 -2
  43. package/src/progress-node.mjs +5 -1
  44. package/src/qa-analytics-parity.mjs +37 -2
  45. package/src/qa-binding-evidence.mjs +5 -3
  46. package/src/qa-browser.mjs +136 -25
  47. package/src/qa-node.mjs +33 -4
  48. package/src/readback.mjs +19 -10
  49. package/src/sdk-markup.mjs +6 -45
  50. package/src/sdk-storage-compatibility.mjs +3 -2
  51. package/src/source-prep.mjs +1 -1
  52. package/src/tooling-setup.mjs +9 -0
@@ -1,5 +1,6 @@
1
1
  // Doctor entry points: the doctor command, packet inspection and built-output inspection.
2
2
  import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
3
+ import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
3
4
  import { withHtmlScanSnapshot } from "../html-scan.mjs";
4
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
6
  import { dirname, join, resolve } from "node:path";
@@ -506,16 +507,23 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
506
507
  }
507
508
  runPricingCssHideCheck({ packet, derived, warnings, ready, report });
508
509
 
509
- const polishCheckpointGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
510
+ // The local preview policy (local-preview-policy.mjs) carries missing
511
+ // polish and page-load evidence forward as warnings on a local-serve packet.
512
+ const polishCheckpointGate = applyLocalPreviewToCheckpoint(
513
+ evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
514
+ { packet, report },
515
+ );
510
516
  derived.polish_checkpoint_gate = polishCheckpointGate;
511
- const polishGate = evaluatePolishGate({
517
+ const polishGate = applyLocalPreviewToPolishGate(evaluatePolishGate({
512
518
  report,
513
519
  hiddenEagerMediaGate: polishCheckpointGate,
514
520
  currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
515
- });
521
+ }), { packet, checkpointGate: polishCheckpointGate });
516
522
  derived.polish_gate = polishGate;
517
523
  if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
518
524
  pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
525
+ } else if (polishGate.status === CARRIED_FORWARD && !polishGate.owned_checkpoint_only) {
526
+ addIssue(warnings, polishGate.code, carriedForwardMessage(polishGate), { polish_gate: polishGate });
519
527
  } else if (polishGate.status === "waived" && !polishGate.owned_checkpoint_only) {
520
528
  ready.push(`Polish gate passed under waiver: ${polishGate.waiver?.reason || "(no reason recorded)"}`);
521
529
  } else if (polishGate.status === "pass") {
@@ -524,6 +532,8 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
524
532
 
525
533
  if (polishCheckpointGate.status === "blocked") {
526
534
  pushGateIssue({ errors, warnings }, gateIssue("polish_checkpoint_gate", polishCheckpointGate));
535
+ } else if (polishCheckpointGate.status === CARRIED_FORWARD) {
536
+ addIssue(warnings, polishCheckpointGate.code, carriedForwardMessage(polishCheckpointGate), { polish_checkpoint_gate: polishCheckpointGate });
527
537
  } else if (polishCheckpointGate.status === "waived") {
528
538
  addIssue(
529
539
  warnings,
@@ -1,5 +1,6 @@
1
1
  // The next step doctor recommends, and the gate issues `next` reads from doctor.
2
2
  import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
+ import { CARRIED_FORWARD } from "../local-preview-policy.mjs";
3
4
  import { resolve } from "node:path";
4
5
  import { orderPathDepthDriftText } from "../proof-policy.mjs";
5
6
  import { anyAssemblyReportStageBlocked } from "../stage-ledger.mjs";
@@ -462,6 +463,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
462
463
  }
463
464
 
464
465
  for (const cliStage of NEXT_STAGE_ORDER) {
466
+ // On the local preview, missing polish is carried forward as a warning
467
+ // (local-preview-policy.mjs): the ladder moves on to deploy and QA.
468
+ if (cliStage === "polish" && polishGate.status === CARRIED_FORWARD) continue;
465
469
  const reportKey = reportKeyForCliStage(cliStage);
466
470
  const stage = report.stages[reportKey];
467
471
  if (!stage) {
@@ -62,6 +62,14 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
62
62
  command: null,
63
63
  description: localProofRebuildText(),
64
64
  }),
65
+ // Every mapped page is template stock: capture has no design route, so
66
+ // neither a recapture nor an authority repair can produce the evidence.
67
+ map_design_route: Object.freeze({
68
+ id: "polish.hidden_eager_media.map_design_route",
69
+ kind: "manual",
70
+ command: null,
71
+ description: "Every mapped page is template stock, so polish capture has nothing to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or prove the campaign on the local preview (deploy.target local-serve served from localhost), where this missing evidence is carried forward as a warning.",
72
+ }),
65
73
  });
66
74
 
67
75
  const PACKET_PLACEHOLDER = "--packet <packet>";
@@ -0,0 +1,92 @@
1
+ // The local preview policy: one place that decides which missing evidence a
2
+ // localhost preview carries forward as a warning instead of a blocker.
3
+ //
4
+ // The local preview is a packet whose deploy target is `local-serve`, served
5
+ // from a loopback host. There a campaign must still prove its commerce (store
6
+ // and campaign binding, routes, SDK loading, prices, a typed-card order), but
7
+ // evidence that only design work or launch produces may be missing. Missing
8
+ // evidence is reported as missing, never as passed: a carried-forward gate is
9
+ // a warning in doctor, `next` does not stop on it, and QA records it as a
10
+ // `warn` row, so the verdict is at best `ready_with_exceptions`. A hosted
11
+ // preview or production packet never takes this path, and every check not
12
+ // named below keeps its meaning on every path.
13
+ //
14
+ // Doctor and QA apply this to the gates they evaluate; `next` reads doctor's
15
+ // gates. Recording a stage (`record polish`) and the waiver commands keep the
16
+ // strict gates.
17
+ import { isLocalServePacket } from "./local-proof.mjs";
18
+ import { isLoopbackHostname } from "./remit.mjs";
19
+
20
+ export const LOCAL_PREVIEW_POLICY = "local_preview";
21
+ export const CARRIED_FORWARD = "carried_forward";
22
+
23
+ // The checks carried forward, one by one. Each is evidence the campaign does
24
+ // not have yet, not evidence that something is wrong:
25
+ // - polish was never recorded for this build;
26
+ // - no page-load capture exists, either because polish capture never ran or
27
+ // because every mapped page is template stock and there is no design route
28
+ // to capture.
29
+ const CARRIED_POLISH_CODES = Object.freeze(new Set([
30
+ "polish.report_missing",
31
+ "polish.evidence_missing",
32
+ ]));
33
+ export const NO_CAPTURABLE_ROUTES_CODE = "polish.hidden_eager_media.no_capturable_routes";
34
+ const MISSING_CAPTURE_CODE = "polish.hidden_eager_media.capture_malformed";
35
+
36
+ // With no generatable brand theme the starter template is the design, so its
37
+ // palette and chrome are expected on the local preview: residue findings are
38
+ // warnings there, as they are under a waived theme gate.
39
+ const STARTER_IS_THE_DESIGN_THEME_CODE = "theme_gate.nothing_generatable";
40
+
41
+ function loopbackOrAbsent(url) {
42
+ if (url === undefined || url === null || url === "") return true;
43
+ try {
44
+ return isLoopbackHostname(new URL(String(url)).hostname);
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export function isLocalPreview(packet, { baseUrl = null } = {}) {
51
+ return isLocalServePacket(packet)
52
+ && loopbackOrAbsent(packet?.deploy?.preview_url)
53
+ && loopbackOrAbsent(baseUrl);
54
+ }
55
+
56
+ function pageLoadRecorded(report) {
57
+ return report?.stages?.polish?.evidence?.visual_review?.page_load != null;
58
+ }
59
+
60
+ function carried(gate) {
61
+ return {
62
+ ...gate,
63
+ status: CARRIED_FORWARD,
64
+ carried_forward: { policy: LOCAL_PREVIEW_POLICY, from_status: gate.status, evidence: "missing" },
65
+ };
66
+ }
67
+
68
+ // The hidden eager-media checkpoint, evaluated on its own.
69
+ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl = null } = {}) {
70
+ if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
71
+ const missing = gate.code === NO_CAPTURABLE_ROUTES_CODE
72
+ || (gate.code === MISSING_CAPTURE_CODE && !pageLoadRecorded(report));
73
+ return missing ? carried(gate) : gate;
74
+ }
75
+
76
+ // The polish gate folds the checkpoint in, so it is carried forward only when
77
+ // whatever it owns from the checkpoint was carried forward too.
78
+ export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
79
+ if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
80
+ const checkpointCarried = checkpointGate?.status === CARRIED_FORWARD;
81
+ if (gate.owned_checkpoint_status === "blocked" && !checkpointCarried) return gate;
82
+ if (gate.owned_checkpoint_only) return checkpointCarried ? carried(gate) : gate;
83
+ return CARRIED_POLISH_CODES.has(gate.code) ? carried(gate) : gate;
84
+ }
85
+
86
+ export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } = {}) {
87
+ return themeGate?.code === STARTER_IS_THE_DESIGN_THEME_CODE && isLocalPreview(packet, { baseUrl });
88
+ }
89
+
90
+ export function carriedForwardMessage(gate) {
91
+ return `Carried forward on the local preview: ${gate.reason} This evidence is missing, not passed; a hosted preview or production run requires it.`;
92
+ }
@@ -92,6 +92,10 @@ export function evaluatePageKitSdkVersion({
92
92
  waivers = [],
93
93
  required = true,
94
94
  now = new Date().toISOString(),
95
+ // { family, sdk_version } of the selected certified template family, when
96
+ // the caller knows it: the missing-pin reason names that verified SDK so the
97
+ // pin is not chosen by searching docs.
98
+ familyVerification = null,
95
99
  } = {}) {
96
100
  const pin = resolveSpecSdkPin(spec);
97
101
  const { has_canonical: hasCanonical, has_alias: hasAlias } = pin;
@@ -139,7 +143,10 @@ export function evaluatePageKitSdkVersion({
139
143
  scope: PAGE_KIT_SDK_VERSION_SCOPE,
140
144
  status: "blocked",
141
145
  code: "page_kit.sdk_version.spec_missing",
142
- reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA.",
146
+ reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA."
147
+ + (isReleasedSdkVersion(familyVerification?.sdk_version) && typeof familyVerification?.family === "string"
148
+ ? ` The "${familyVerification.family}" template family was last verified against ${familyVerification.sdk_version}.`
149
+ : ""),
143
150
  waivable: false,
144
151
  subject,
145
152
  state: { spec_status: "missing" },
@@ -1,4 +1,5 @@
1
1
  import { campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
2
+ import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
2
3
  import { createHash } from "node:crypto";
3
4
  import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
4
5
  import { dirname, join, resolve } from "node:path";
@@ -93,6 +94,8 @@ function mappedSpecRoute(value, pageId) {
93
94
  return route;
94
95
  }
95
96
 
97
+ const NO_CAPTURABLE_ROUTES_ERROR = "no_capturable_routes";
98
+
96
99
  export function planPolishCapture({ packet, baseUrl } = {}) {
97
100
  if (!isPlainObject(packet) || !Array.isArray(packet?.source_html?.pages) || packet.source_html.pages.length === 0) {
98
101
  throw new Error("polish capture requires packet.source_html.pages mappings.");
@@ -129,7 +132,14 @@ export function planPolishCapture({ packet, baseUrl } = {}) {
129
132
  });
130
133
  }
131
134
 
132
- if (routes.length === 0) throw new Error("polish capture has no mapped non-skipped routes to capture.");
135
+ if (routes.length === 0) {
136
+ const error = new Error(
137
+ "polish capture has no mapped non-skipped routes to capture: every mapped page is template stock (skip_reason), so there is no design route to compare."
138
+ + " On a local-serve preview, missing polish evidence is carried forward as a warning; run `next` for the next stage.",
139
+ );
140
+ error.code = NO_CAPTURABLE_ROUTES_ERROR;
141
+ throw error;
142
+ }
133
143
  routes.sort((a, b) => a.requested_route.localeCompare(b.requested_route) || a.page_id.localeCompare(b.page_id));
134
144
  for (let index = 1; index < routes.length; index += 1) {
135
145
  if (routes[index - 1].requested_route === routes[index].requested_route) {
@@ -189,6 +199,19 @@ function recordedAuthorityBlock({ packet, report, plan = null, now } = {}) {
189
199
  };
190
200
  }
191
201
 
202
+ // Every mapped page is template stock: there is nothing for polish capture to
203
+ // measure, which is missing evidence rather than a malformed packet. It stays
204
+ // a non-waivable block; the local preview policy carries it forward.
205
+ function noCapturableRoutesBlock({ packet, report, now } = {}) {
206
+ const block = recordedAuthorityBlock({ packet, report, now });
207
+ return {
208
+ ...block,
209
+ code: NO_CAPTURABLE_ROUTES_CODE,
210
+ reason: "Every mapped page is template stock (skip_reason), so polish capture has no design route to capture and this build has no page-load evidence.",
211
+ required_actions: [HIDDEN_EAGER_MEDIA_ACTIONS.map_design_route],
212
+ };
213
+ }
214
+
192
215
  function recordedCheckpointNotApplicable() {
193
216
  return {
194
217
  id: HIDDEN_EAGER_MEDIA_SCOPE,
@@ -219,7 +242,8 @@ export function evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report, now
219
242
  let plan;
220
243
  try {
221
244
  plan = planPolishCapture({ packet, baseUrl: "https://polish-capture.invalid" });
222
- } catch {
245
+ } catch (error) {
246
+ if (error?.code === NO_CAPTURABLE_ROUTES_ERROR) return noCapturableRoutesBlock({ packet, report, now });
223
247
  return recordedAuthorityBlock({ packet, report, now });
224
248
  }
225
249
 
@@ -58,7 +58,11 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
58
58
  binding:reportBound&&id(verdict.run_id)&&id(verdict.run_id)===id(report?.stages?.qa?.verdict_run_id)&&build&&qaSource===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'&&hash(verdict.spec_hash)===localHash?'matching':'unconfirmed',
59
59
  publish_state:accepted(qaResult?.qa_verdict_publish?.state,['skipped','ok','failed']),
60
60
  }:null;
61
- const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,PROGRESS_GATE_LIMIT).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?accepted(gate?.status,['pass','blocked','waived','not_applicable']):'unknown'}));
61
+ // The snapshot schema has no carried-forward state: a gate the local preview
62
+ // policy carried forward does not apply on this path, so it is not_applicable
63
+ // here (never pass).
64
+ const gateState=status=>accepted(status==='carried_forward'?'not_applicable':status,['pass','blocked','waived','not_applicable']);
65
+ const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,PROGRESS_GATE_LIMIT).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?gateState(gate?.status):'unknown'}));
62
66
  const actions=[...new Set((Array.isArray(continuation?.next_actions)?continuation.next_actions:[]).slice(0,64).map(action=>accepted(action?.id,PROGRESS_ACTION_IDS)))];
63
67
  const stage=accepted(continuation?.stage,PROGRESS_CONTINUATIONS);
64
68
  const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
@@ -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,11 +1,11 @@
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 });
8
- const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/index\.js|public\/loader\.js)(?:\?[^#]*)?$/;
8
+ const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/(?:index|loader)\.js|public\/loader\.js)(?:\?[^#]*)?$/;
9
9
  const str = value => typeof value === 'string' && value.length ? value : null;
10
10
 
11
11
  // The expected credential uses the existing producer resolver. Disagreement
@@ -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
  };