@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
@@ -14,6 +14,7 @@
14
14
  // every other file are left as they are. This module is pure: the CLI does
15
15
  // the reading, the writing, and the printing.
16
16
  import {
17
+ isAuthoritativeEmptyStoreProfileValue,
17
18
  isDemoResidue,
18
19
  normalizeStoreProfileValue,
19
20
  PAGE_KIT_STORE_PROFILE_FIELDS,
@@ -25,17 +26,23 @@ export const PAGE_KIT_SYNC_FIELDS = Object.freeze([...PAGE_KIT_STORE_PROFILE_FIE
25
26
 
26
27
  // The field-by-field plan: what the entry holds, what the spec says, and
27
28
  // whether a write is owed. `changes` are the fields whose value will move,
28
- // `unchanged` already match, `not_in_spec` are governed fields the spec does
29
- // not carry (left as they are; doctor's `target_only` warning still applies),
30
- // and `not_synced` are fields the target cannot be made authoritative for: an
31
- // invalid or conflicting spec SDK pin, a spec value of the wrong type or
32
- // shape (or the demo value itself), or starter demo residue in a field the
33
- // spec does not carry. Absent, null and blank spec values are "not carried".
29
+ // `unchanged` already match, `not_in_spec` are governed fields left as they
30
+ // are because the spec does not carry them or because its "" was not applied
31
+ // (doctor's `target_only` warning still applies), and `not_synced` are fields
32
+ // the target cannot be made authoritative for: an invalid or conflicting spec
33
+ // SDK pin, a spec value of the wrong type or shape (or the demo value itself),
34
+ // or starter demo residue in a field the spec does not carry. Absent and null
35
+ // spec values are "not carried"; an explicit empty (or whitespace-only) string
36
+ // blanks the starter demo value and otherwise leaves the target as it is.
37
+ // `spec_empty_not_applied` names that last case, the not_in_spec fields the
38
+ // spec sets to "" over a real, non-demo target value, which only a hand edit
39
+ // removes.
34
40
  export function planPageKitSync({ spec, entry, waivedGates = [] } = {}) {
35
41
  const target = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : {};
36
42
  const changes = [];
37
43
  const unchanged = [];
38
44
  const notInSpec = [];
45
+ const specEmptyNotApplied = [];
39
46
  const notSynced = [];
40
47
  // A gate under an ACTIVE named-human waiver recorded a human accepting the
41
48
  // target's current values; sync must not silently reverse that decision.
@@ -61,8 +68,15 @@ export function planPageKitSync({ spec, entry, waivedGates = [] } = {}) {
61
68
  for (const field of PAGE_KIT_STORE_PROFILE_FIELDS) {
62
69
  const raw = spec?.campaign?.[field];
63
70
  const current = Object.hasOwn(target, field) ? target[field] : undefined;
64
- const carried = raw !== undefined && raw !== null && !(typeof raw === "string" && !raw.trim());
65
- if (!carried) {
71
+ // An explicit empty value blanks only the starter demo value, and confirms
72
+ // a target that already reads as empty. Any other target value is left as
73
+ // it is, as for a field the spec does not carry (doctor's target_only
74
+ // warning): Maps saved "" for every cleared store field before "" meant
75
+ // empty, so it never wipes a value someone entered.
76
+ const authoritativeEmpty = isAuthoritativeEmptyStoreProfileValue(field, raw);
77
+ const targetBlankOrDemo = current === undefined || current === null
78
+ || (typeof current === "string" && (!normalizeStoreProfileValue(current) || isDemoResidue(field, normalizeStoreProfileValue(current))));
79
+ if (raw === undefined || raw === null || (authoritativeEmpty && !targetBlankOrDemo)) {
66
80
  // Starter demo residue in a field the spec does not carry is the one
67
81
  // state sync cannot end: doctor blocks on it without a waiver and there
68
82
  // is no spec value to write over it. Say so instead of reporting a
@@ -75,13 +89,16 @@ export function planPageKitSync({ spec, entry, waivedGates = [] } = {}) {
75
89
  });
76
90
  } else {
77
91
  notInSpec.push(field);
92
+ if (authoritativeEmpty) specEmptyNotApplied.push(field);
78
93
  }
79
94
  continue;
80
95
  }
81
96
  // A carried value the target cannot be made authoritative for: the wrong
82
97
  // type (doctor's spec_invalid_type), the demo value itself, or a shape a
83
98
  // template would put into an href unescaped.
84
- const problem = typeof raw !== "string" ? "spec_invalid_type" : storeProfileSpecValueProblem(field, raw);
99
+ // An explicit empty value reaching here is written as "" over the starter
100
+ // demo value.
101
+ const problem = typeof raw !== "string" ? "spec_invalid_type" : authoritativeEmpty ? null : storeProfileSpecValueProblem(field, raw);
85
102
  if (problem) {
86
103
  notSynced.push({ field, reason: problem, detail: NOT_SYNCED_DETAIL[problem](field, raw) });
87
104
  continue;
@@ -91,9 +108,11 @@ export function planPageKitSync({ spec, entry, waivedGates = [] } = {}) {
91
108
  const row = { field, before, after, source: `campaign.${field}` };
92
109
  // The gate compares normalized forms, so a target that differs only in
93
110
  // surrounding whitespace or Unicode normalization already passes; a
94
- // rewrite would report a change doctor never saw.
111
+ // rewrite would report a change doctor never saw. For the same reason an
112
+ // absent or null target already agrees with an explicit empty value.
95
113
  const alreadyMatches = before === after
96
- || (typeof before === "string" && normalizeStoreProfileValue(before) === after);
114
+ || (typeof before === "string" && normalizeStoreProfileValue(before) === after)
115
+ || (authoritativeEmpty && (before === undefined || before === null));
97
116
  if (alreadyMatches) unchanged.push(row);
98
117
  else if (storeProfileWaiver) notSynced.push({ field, reason: "waived", detail: waivedDetail(field, storeProfileWaiver, "page_kit.store_profile") });
99
118
  else changes.push(row);
@@ -130,7 +149,7 @@ export function planPageKitSync({ spec, entry, waivedGates = [] } = {}) {
130
149
  });
131
150
  }
132
151
 
133
- return { changes, unchanged, not_in_spec: notInSpec, not_synced: notSynced };
152
+ return { changes, unchanged, not_in_spec: notInSpec, spec_empty_not_applied: specEmptyNotApplied, not_synced: notSynced };
134
153
  }
135
154
 
136
155
  // Apply a plan to the parsed campaigns.json document. Mutates ONLY the
@@ -83,7 +83,7 @@ function privateTemplateSourcesPath() {
83
83
  }
84
84
 
85
85
  // No caching, recomputed per call — matches certifiedTemplateFamilies()'s
86
- // existing convention (cli.mjs) so a long-lived process never serves a stale
86
+ // existing convention (src/doctor/checks.mjs) so a long-lived process never serves a stale
87
87
  // allowlist after an edit.
88
88
  export function loadPrivateTemplateSources() {
89
89
  const path = privateTemplateSourcesPath();
@@ -1,15 +1,18 @@
1
1
  import { campaignSpecIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
2
2
  // Best-effort producer adapter. Sanitized immutable bytes are durable before delivery.
3
3
  import {randomBytes,createHash} from 'node:crypto';
4
- import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync,lstatSync} from 'node:fs';
4
+ import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync} from 'node:fs';
5
5
  import {join,resolve,dirname} from 'node:path';
6
6
  import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
7
7
  import {specMaterialHash} from './spec-identity.mjs';
8
8
  import {sameFile} from './fs-identity.mjs';
9
+ import {withDirectoryLock} from './directory-lock.mjs';
9
10
  import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
10
11
  import {boundedResponseText,isLoopbackHostname} from './remit.mjs';
11
12
  export const PROGRESS_OBSERVATION = Symbol('canonical progress observation');
12
13
  export const PROGRESS_ENDPOINT = '/api/progress';
14
+ // A projection keeps the first PROGRESS_GATE_LIMIT continuation gates (the snapshot schema's gates maxItems).
15
+ export const PROGRESS_GATE_LIMIT = 16;
13
16
  const accepted = (value,values,fallback='unknown')=>values.includes(value)?value:fallback;
14
17
  const hash = value=>typeof value==='string'&&/^(?:sha256:)?[0-9a-f]{64}$/i.test(value)?`sha256:${value.replace(/^sha256:/i,'').toLowerCase()}`:null;
15
18
  const id = value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(value)?value:null;
@@ -55,7 +58,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
55
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',
56
59
  publish_state:accepted(qaResult?.qa_verdict_publish?.state,['skipped','ok','failed']),
57
60
  }:null;
58
- const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,16).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
+ 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'}));
59
62
  const actions=[...new Set((Array.isArray(continuation?.next_actions)?continuation.next_actions:[]).slice(0,64).map(action=>accepted(action?.id,PROGRESS_ACTION_IDS)))];
60
63
  const stage=accepted(continuation?.stage,PROGRESS_CONTINUATIONS);
61
64
  const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
@@ -71,39 +74,9 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
71
74
  continuation:{stage,blocked:continuation?.ok!==true||(Array.isArray(continuation?.divergences)&&continuation.divergences.length>0)||stage==='unknown'||gates.some(gate=>['blocked','unknown'].includes(gate.state))||actions.includes('unknown'),divergent:Array.isArray(continuation?.divergences)&&continuation.divergences.length>0,action_ids:actions,gates},qa,
72
75
  };
73
76
  }
74
- async function lock(dir,fn,{budgetMs=1500}={}) {
75
- const path=join(dir,'.allocation-lock'); const start=Date.now();const token=randomBytes(16).toString('hex');
76
- const abandoned=(unownedMtime=null)=>{
77
- try {
78
- const stat=lstatSync(path);if(!stat.isDirectory()||stat.isSymbolicLink())return false;
79
- const owner=read(join(path,'owner.json'));
80
- if(Number.isInteger(owner?.pid)&&owner.pid>0&&typeof owner.token==='string') {
81
- try {process.kill(owner.pid,0);return false;}catch(error){return error.code==='ESRCH';}
82
- }
83
- // A killed process can leave the directory before writing its owner.
84
- // Give a live allocator ample time to finish that tiny synchronous gap.
85
- return Date.now()-(unownedMtime??stat.mtimeMs)>10000;
86
- }catch{return false;}
87
- };
88
- const recover=()=>{
89
- if(!abandoned())return;
90
- let originalMtime;try{originalMtime=lstatSync(path).mtimeMs;}catch{return;}
91
- const claim=join(path,'.recovery');
92
- // Only this exclusive claimant can rename the old lock. An interrupted
93
- // recovery claim fails closed for explicit offline recovery; recursively
94
- // stealing recovery claims would reintroduce a check/rename race.
95
- try {mkdirSync(claim);atomic(join(claim,'owner.json'),{pid:process.pid,token});}catch{return;}
96
- let moved=false;
97
- try {
98
- if(!abandoned(originalMtime))return;
99
- const tomb=`${path}.abandoned-${token}`;
100
- renameSync(path,tomb);moved=true;rmSync(tomb,{recursive:true,force:true});
101
- }catch{}finally{if(!moved&&read(join(claim,'owner.json'))?.token===token){try{rmSync(claim,{recursive:true,force:true});}catch{}}}
102
- };
103
- while (true) {
104
- try {mkdirSync(path);atomic(join(path,'owner.json'),{pid:process.pid,token});break;}catch(error){if(error.code!=='EEXIST'||Date.now()-start>=budgetMs)throw new Error('progress.lock_unavailable');recover();await new Promise(resolve=>setTimeout(resolve,20));}
105
- }
106
- try{return await fn();}finally{if(read(join(path,'owner.json'))?.token===token)rmSync(path,{recursive:true,force:true});}
77
+ function lock(dir,fn,{budgetMs=1500}={}) {
78
+ const lockPath=join(dir,'.allocation-lock');
79
+ return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:()=>Object.assign(new Error('progress.lock_unavailable'),{lockPath})});
107
80
  }
108
81
  export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
109
82
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -171,7 +144,7 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
171
144
  if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
172
145
  return {...remit,snapshot_id:snapshot.snapshot_id,reused};
173
146
  } catch(error) {
174
- if(error?.message==='progress.lock_unavailable')warn('[campaigns-os] Progress allocation lock occupied; wait for the current writer. For abandoned or interrupted recovery, stop target writers and follow the offline lock recovery in docs/progress-snapshots.md. Lifecycle result is unchanged.');
147
+ if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
175
148
  else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
176
149
  return {state:'failed',reason:'capture_unavailable'};
177
150
  }
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // `qa.proof_policy.order_path_depth` is seeded by prepare-build/start and
6
6
  // mirrored into `report.proof_policy` at the same moment. The two are compared
7
- // by `assessPurchaseProofCoverage` (cli.mjs): a disagreement is `unknown`,
7
+ // by `assessPurchaseProofCoverage` (src/doctor/next-step.mjs): a disagreement is `unknown`,
8
8
  // never one side's value. Doctor, `next` and the coverage reason all describe
9
9
  // that state through the single action below, so the command an operator is
10
10
  // told to run is spelled once. A leaf: gate-actions only.
@@ -228,6 +228,9 @@ export function assessReceiptPurchase(receiptAnalytics = {}, options = {}) {
228
228
  receipts.push({
229
229
  plan_id: planId,
230
230
  receipt_url: redactUrlQuery(attempt.receiptUrl),
231
+ // The settled document location the receipt capture was read from
232
+ // (#500); null when it was not recorded.
233
+ receipt_document_url: redactUrlQuery(attempt.receiptDocumentUrl) || null,
231
234
  measured,
232
235
  scope: measured ? scope : null,
233
236
  purchase_fired: measured && !!effective.fired,
@@ -1,6 +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
5
 
5
6
  export const BINDING_SCHEMA = 'campaigns-os-page-binding/v0';
6
7
  export const BINDING_LIMITS = Object.freeze({ scripts_per_page: 6, scripts_per_run: 24, script_bytes: 262144, timeout_ms: 5000 });
@@ -55,12 +56,15 @@ function literalObject(node) {
55
56
  if (node?.type === 'ObjectExpression') return node.properties.every(p => p.type === 'Property' && !p.computed && p.kind === 'init' && !p.method && !p.shorthand && literalObject(p.value));
56
57
  return false;
57
58
  }
58
- function declarations(text) {
59
+ function declarations(text, { module = false } = {}) {
59
60
  // Only whole, unconditional literal assignments are accepted. No evaluation,
60
61
  // constant propagation, getters, spreads, callbacks, aliases, or branch guesses.
62
+ // A script that does not parse is not dynamic: the browser throws on it and
63
+ // nothing in it runs. It is its own state, reported with its position (#480).
61
64
  let ast;
62
- try { ast = parseJs(text, { ecmaVersion: 2022, sourceType: 'script' }); }
63
- catch { return { values: [], dynamic: true }; }
65
+ try { ast = parseJs(text, { ecmaVersion: 'latest', sourceType: module ? 'module' : 'script', locations: true }); }
66
+ // The message is a fixed category: acorn echoes source text into some.
67
+ catch (error) { return { values: [], dynamic: false, unparsable: parseFailureDiagnostic(error) }; }
64
68
  const values = [];
65
69
  let dynamic = false;
66
70
  for (const statement of ast.body) {
@@ -78,7 +82,23 @@ function declarations(text) {
78
82
  return { values, dynamic };
79
83
  }
80
84
 
81
- export async function observeBinding({ source, page, expected, scriptLoader }) {
85
+ // A path for a script, for findings: never a full URL, query or fragment. A
86
+ // script served from another origin keeps its host, so same-named files on two
87
+ // CDNs stay distinguishable.
88
+ function scriptPath(src, pageUrl) {
89
+ try {
90
+ const url = new URL(src, pageUrl);
91
+ let pageOrigin = null;
92
+ try { pageOrigin = new URL(pageUrl).origin; } catch {}
93
+ return pageOrigin && url.origin !== pageOrigin ? `${url.host}${url.pathname}` : url.pathname;
94
+ } catch { return null; }
95
+ }
96
+
97
+ // `parseFailures`, when given, receives one record per page script that does
98
+ // not parse ({ source_kind, script, line, column, message }). It is kept off
99
+ // the page-binding evidence, whose shape is a closed contract; the caller
100
+ // reports it as its own assertion.
101
+ export async function observeBinding({ source, page, expected, scriptLoader, parseFailures = null }) {
82
102
  const kinds = new Set();
83
103
  const result = (outcome, reason) => ({ schema_version: BINDING_SCHEMA, observation: 'static_declaration',
84
104
  outcome, reason, source_kinds: [...kinds].sort(), identity: 'not_verified' });
@@ -94,33 +114,68 @@ export async function observeBinding({ source, page, expected, scriptLoader }) {
94
114
  const values = [];
95
115
  const scripts = [];
96
116
  let dynamic = false;
117
+ let bases = [];
97
118
  const walk = node => {
98
119
  const attrs = Object.fromEntries((node.attrs || []).map(a => [a.name, a.value]));
99
120
  if (node.tagName === 'base' || Object.entries(attrs).some(([name, value]) => /^on/i.test(name) || /^\s*javascript:/i.test(value))) dynamic = true;
100
121
  if (node.tagName === 'meta' && attrs.name === 'next-api-key') { values.push(attrs.content ?? ''); kinds.add('meta'); }
101
- if (node.tagName === 'script') scripts.push({ attrs, text: (node.childNodes || []).map(n => n.value || '').join('') });
122
+ // Each script keeps the <base href> in effect when the parser prepares it
123
+ // at its end tag (see baseInEffect; #502).
124
+ // Only an HTML-namespace <script> loads `src`. An SVG script runs from
125
+ // href / xlink:href or its inline text, which this static read does not
126
+ // model: it is not fetched and leaves the binding dynamic.
127
+ 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('') });
102
129
  // parse5 keeps template content separate; it is inert, as is noscript at boot.
103
130
  if (node.tagName !== 'noscript') for (const child of node.childNodes || []) walk(child);
104
131
  };
105
- try { walk(parseHtml(source.html)); } catch { return result('unknown', 'dynamic_unresolved'); }
132
+ try {
133
+ const document = parseHtml(source.html, { sourceCodeLocationInfo: true });
134
+ bases = documentBases(document);
135
+ walk(document);
136
+ } catch { return result('unknown', 'dynamic_unresolved'); }
137
+ // A script src resolves against the base in effect when the script is
138
+ // prepared: the frozen base URL of that <base href>, where a
139
+ // data:, javascript: or unparsable base falls back to the page URL (HTML
140
+ // "set the frozen base URL"), else the page URL. The loader still scopes
141
+ // the result to the page origin, so a cross-origin base leaves those
142
+ // scripts unavailable.
143
+ const scriptRef = (src, base) => { try { return new URL(src, frozenBaseUrl(base, pageUrl)).href; } catch { return null; } };
106
144
  let count = 0, unavailable = false;
107
145
  for (const script of scripts) {
108
146
  const { attrs } = script;
147
+ // Classified as the browser does (type trimmed of ASCII whitespace,
148
+ // case-insensitive). A module script ignores nomodule; a classic nomodule
149
+ // script is never fetched or run by a module-capable browser, so it cannot
150
+ // fail on load there: not fetched, not parsed, still not static.
151
+ const scriptType = scriptKind(attrs);
152
+ const { nomodule: _nomodule, ...withoutNomodule } = attrs;
153
+ const classicNomodule = scriptType === null && 'nomodule' in attrs && scriptKind(withoutNomodule) === 'classic';
109
154
  // Data-block types (e.g. JSON-LD) are not fetched and consume no config-request budget.
110
- if (attrs.type && !['text/javascript', 'application/javascript', 'module'].includes(attrs.type.toLowerCase())) continue;
111
- if (attrs.src && SDK.test(attrs.src)) continue;
155
+ if (scriptType === null && !classicNomodule) continue;
156
+ // Matched on the URL as the parser reads it (tab and newline removed,
157
+ // host case-folded), against the base in effect for this script.
158
+ if (attrs.src && SDK.test(scriptRef(attrs.src, script.base) ?? attrs.src)) continue;
159
+ if (classicNomodule) { dynamic = true; continue; }
112
160
  let text = script.text;
113
161
  let kind = 'inline';
114
162
  if (attrs.src) {
115
163
  kind = 'config_script';
116
164
  if (++count > BINDING_LIMITS.scripts_per_page) { unavailable = true; continue; }
117
- const loaded = await scriptLoader(attrs.src, pageUrl);
165
+ const ref = script.base === null ? attrs.src : scriptRef(attrs.src, script.base);
166
+ const loaded = ref ? await scriptLoader(ref, pageUrl) : { ok: false };
118
167
  if (!loaded.ok) { unavailable = true; continue; }
119
168
  text = loaded.html;
120
169
  }
121
- const found = declarations(text);
170
+ const found = declarations(text, { module: scriptType === 'module' });
171
+ if (found.unparsable) {
172
+ // The declarations in an unparsable script are unavailable, not dynamic.
173
+ unavailable = true;
174
+ if (Array.isArray(parseFailures)) parseFailures.push({ source_kind: kind, script: attrs.src ? scriptPath(scriptRef(attrs.src, script.base) ?? attrs.src, pageUrl) : null, ...found.unparsable });
175
+ continue;
176
+ }
122
177
  if (found.values.length) { kinds.add(kind); values.push(...found.values); }
123
- if (found.dynamic || 'async' in attrs || 'nomodule' in attrs || attrs.type === 'module') dynamic = true;
178
+ if (found.dynamic || 'async' in attrs || scriptType === 'module') dynamic = true;
124
179
  }
125
180
  if (new Set(values).size > 1) return result('unknown', 'conflicting_declarations');
126
181
  if (expected?.conflict) return result('unknown', 'conflicting_expected');
@@ -138,3 +193,13 @@ export function bindingAssertion(page, evidence) {
138
193
  ...(evidence.outcome === 'match' ? {} : { severity: evidence.outcome === 'mismatch' ? 'blocker' : 'warn' }),
139
194
  expected: 'expected credential declaration', actual: evidence.outcome, evidence };
140
195
  }
196
+
197
+ // A page script that does not parse throws a SyntaxError on every load (#480).
198
+ // One blocker per page, naming each script and the parse position.
199
+ export function scriptParseAssertion(page, failures) {
200
+ if (!Array.isArray(failures) || failures.length === 0) return null;
201
+ const where = failures.map(f => `${f.script || 'inline script'}:${f.line}:${f.column}`).join(', ');
202
+ return { id: `script-parse:${page.page_id}`, family: 'api-metadata', page: page.page_id, status: 'fail', severity: 'blocker',
203
+ expected: 'every page script parses', actual: `unparsable: ${where}`,
204
+ evidence: { observation: 'static_parse', scripts: failures.map(f => ({ source_kind: f.source_kind, script: f.script, line: f.line, column: f.column, message: f.message })) } };
205
+ }