@nextcommerce/campaigns-os 1.43.1 → 1.43.2

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 (56) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +451 -0
  3. package/README.md +2 -2
  4. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  5. package/contracts/effects.v1.json +8 -8
  6. package/contracts/release-ledger.json +672 -0
  7. package/contracts/supported-surface.json +2 -2
  8. package/docs/build-packet.md +64 -2
  9. package/docs/campaigns-os-build-flow.md +1 -0
  10. package/docs/design-source-package.md +89 -15
  11. package/docs/effects.md +16 -4
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/progress-snapshots.md +10 -6
  15. package/docs/qa-and-test-orders.md +131 -7
  16. package/docs/release-ledger-authoring-guide.md +6 -4
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/skills-revision.md +10 -10
  19. package/package.json +1 -1
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +4 -4
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/build-brief.mjs +6 -4
  31. package/src/built-script-syntax.mjs +480 -0
  32. package/src/campaigns-api-key.mjs +99 -0
  33. package/src/cli-helpers.mjs +118 -0
  34. package/src/cli.mjs +1211 -7495
  35. package/src/design-source-package.mjs +1 -1
  36. package/src/design-source-publication.mjs +898 -0
  37. package/src/diagnostic.mjs +2 -1
  38. package/src/directory-lock.mjs +270 -0
  39. package/src/doctor/checks.mjs +4415 -0
  40. package/src/doctor/inspect.mjs +636 -0
  41. package/src/doctor/next-step.mjs +731 -0
  42. package/src/install-invocation.mjs +29 -0
  43. package/src/invocation.mjs +179 -0
  44. package/src/private-template-source.mjs +1 -1
  45. package/src/progress-node.mjs +6 -35
  46. package/src/proof-policy.mjs +1 -1
  47. package/src/qa-analytics-correctness.mjs +3 -0
  48. package/src/qa-binding-evidence.mjs +76 -11
  49. package/src/qa-browser.mjs +778 -77
  50. package/src/qa-build-scope.mjs +47 -0
  51. package/src/qa-node.mjs +218 -13
  52. package/src/source-html-intake.mjs +1 -1
  53. package/src/source-html-manifest.mjs +9 -2
  54. package/src/stage-ledger.mjs +28 -0
  55. package/src/target-lock.mjs +54 -0
  56. package/src/template-brand-contract.mjs +17 -1
@@ -0,0 +1,29 @@
1
+ // How this install spells the commands it prints. ROOT resolves `..` from this
2
+ // file, so the module must stay directly under src/ to name the package root.
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { invocationPrefixFor } from "./install-mode.mjs";
6
+
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+
9
+ // Every command this CLI PRODUCES for an operator or agent to copy is spelled
10
+ // once, here, for the install it runs from (see install-mode.mjs): bare
11
+ // `campaigns-os` from a checkout, `npx --no-install campaigns-os` from a
12
+ // campaign folder that pins the toolkit, `npx --yes <spec>` from an npx cache.
13
+ // Result payloads are never rewritten after the fact — a path, a quoted
14
+ // argument or a data value that happens to contain the words is left exactly
15
+ // as it is.
16
+ function cmd(verb, rest = "") {
17
+ const prefix = invocationPrefixFor(ROOT);
18
+ return `${prefix} ${verb}${rest ? ` ${rest}` : ""}`;
19
+ }
20
+
21
+ // A registry command (gate actions, checkpoint remediations) is stored in its
22
+ // canonical bare form so internal bookkeeping can match on it; this spells
23
+ // it for the current install at the moment it is emitted.
24
+ function asInvocation(command) {
25
+ if (typeof command !== "string" || !command.startsWith("campaigns-os ")) return command;
26
+ return `${invocationPrefixFor(ROOT)} ${command.slice("campaigns-os ".length)}`;
27
+ }
28
+
29
+ export { ROOT, cmd, asInvocation };
@@ -0,0 +1,179 @@
1
+ // Invocation policy: which cross-cutting steps run around a command, and in
2
+ // what order. One owner for rules that used to be restated at each step — the
3
+ // pre-dispatch bypasses, the stale-session sweep and its root, the ambient
4
+ // run-session read, the lifecycle wrapper, the journal exemptions, the
5
+ // commands that implement --dry-run, and the QA auto-end trigger.
6
+ //
7
+ // The declaration below is data: a class, a sweep root kind and a few flags
8
+ // per command, and per subcommand only where the subcommand differs from its
9
+ // command. It describes kernel behaviour and is never a permission
10
+ // interpreter: contracts/effects.v1.json is bound to it by a test, never read
11
+ // here. The steps themselves (the sweep, the ambient read, the journal append,
12
+ // the auto-end, every handler) stay in cli.mjs and are handed to
13
+ // runInvocation() as functions; this module decides only whether and when each
14
+ // runs. It imports nothing from cli.mjs.
15
+
16
+ import { runWithRefusalScope, withCommandLifecycle } from "./lifecycle.mjs";
17
+
18
+ // Frozen all the way down, so a caller handed a subcommand list cannot edit it.
19
+ const frozen = (value) => { if (value && typeof value === "object") Object.values(value).forEach(frozen); return Object.freeze(value); };
20
+
21
+ // The steps each pre-dispatch class runs. `auth` and `inline` commands run
22
+ // their handler in place: no sweep, no ambient read, no wrapper, no journal.
23
+ // `inspection` still resolves the ambient session and is wrapped, but never
24
+ // sweeps or journals. `projection` (`readback`) is wrapped only: its --packet
25
+ // is an override naming the Build Packet to project, not a session locator.
26
+ // Read as one, the named file was loaded whole past readback's own size bound,
27
+ // and a valid override exited 1 whenever some active session was bound to a
28
+ // different packet; neither belongs to a command declared read-only.
29
+ const CLASS_STEPS = frozen({
30
+ auth: [], inline: [],
31
+ inspection: ["wrapper", "ambient"],
32
+ projection: ["wrapper"],
33
+ standard: ["wrapper", "ambient", "sweep", "journal"],
34
+ });
35
+
36
+ // Every top-level command, in the order did-you-mean breaks ties. Fields:
37
+ // `class` (default "standard"); `sweepRoot` ("target": the --target directory,
38
+ // "session": the run-session root; default none); `dryRun` (the command
39
+ // implements --dry-run); `journalExempt`; `journalExemptWhen` (exempt when the
40
+ // `given` flag is set and the `unlessBare` flag is not bare: an inspection must
41
+ // not append to a delivered campaign's active run);
42
+ // `subcommands` (the only subcommand names that resolve; others inherit the
43
+ // command's entry and are refused by the handler).
44
+ //
45
+ // `dryRun` marks only the commands that IMPLEMENT the flag. It reaches every
46
+ // handler through a permissive parseArgs, and an exemption scoped to the flag
47
+ // alone once fired on commands that ignore it: `qa run --dry-run` placed orders
48
+ // while writing no journal entry, and carried the flag into its own auto-end,
49
+ // which assembled no Run Record and left the session open. A command without
50
+ // `dryRun` given --dry-run journals if it otherwise would, and is not refused.
51
+ const COMMANDS = frozen({
52
+ help: { journalExempt: true },
53
+ login: { class: "auth" },
54
+ logout: { class: "auth" },
55
+ demo: { class: "inline" },
56
+ tooling: { subcommands: ["diagnose", "setup", "status"] },
57
+ sdk: { subcommands: ["storage-check"] },
58
+ readback: { class: "projection" },
59
+ start: { sweepRoot: "target" },
60
+ "prepare-build": { sweepRoot: "target" },
61
+ build: { sweepRoot: "target" },
62
+ doctor: { journalExemptWhen: { given: "packet", unlessBare: "write" } },
63
+ bundle: { subcommands: ["check"] },
64
+ standardize: {},
65
+ theme: { subcommands: ["generate", "inspect", "waive"] },
66
+ checkpoint: { subcommands: ["waive"] },
67
+ polish: { subcommands: ["capture"] },
68
+ "validate-assembly-report": {},
69
+ "install-agent-context": { dryRun: true },
70
+ "install-skills": { dryRun: true },
71
+ "page-kit": { subcommands: ["parity", "sync"] },
72
+ spec: { subcommands: ["derive"] },
73
+ next: { subcommands: ["build", "deploy", "polish", "qa", "setup"] },
74
+ qa: { subcommands: ["install-browser", "parity", "policy", "promote", "publish", "resolve", "run", "waive"] },
75
+ findings: { subcommands: ["add", "export", "harvest", "list"] },
76
+ "run-record": { dryRun: true },
77
+ telemetry: { subcommands: ["list", "off", "on", "status"] },
78
+ run: { subcommands: ["end", "start", "status"] },
79
+ });
80
+
81
+ // Where a subcommand's policy differs from its command's entry. Matched on the
82
+ // explicit subcommand token only: bare `run` defaults to `status` inside its
83
+ // handler, but is not `run status` here, so it journals.
84
+ const SUBCOMMAND_OVERRIDES = frozen({
85
+ "tooling diagnose": { class: "inline" },
86
+ "tooling setup": { class: "inline" },
87
+ "sdk storage-check": { class: "inspection" },
88
+ "theme waive": { dryRun: true },
89
+ "checkpoint waive": { dryRun: true },
90
+ "page-kit sync": { dryRun: true },
91
+ "spec derive": { dryRun: true },
92
+ "qa publish": { dryRun: true },
93
+ "qa run": { autoEnd: true },
94
+ "run start": { sweepRoot: "session" },
95
+ "run end": { sweepRoot: "session", dryRun: true },
96
+ "run status": { journalExempt: true },
97
+ });
98
+
99
+ export const commandNames = () => Object.keys(COMMANDS);
100
+
101
+ export const subcommandNames = (command) => (Object.hasOwn(COMMANDS, command) && COMMANDS[command].subcommands) || frozen([]);
102
+
103
+ // A run opted out of sessions altogether: no stale sweep, and no intake
104
+ // auto-start (which reads this predicate from here).
105
+ export const optsOutOfRunSession = (args) => args["no-run-session"] === true;
106
+
107
+ // The policy for one invocation. Two --dry-run predicates are deliberate and
108
+ // differ: on a command implementing the flag, its PRESENCE suppresses the
109
+ // sweep (a valued flag the handler will refuse still does nothing first), while
110
+ // only a bare `--dry-run` exempts the journal (the install commands accept a
111
+ // valued flag as a dry run and still journal it). The sweep writes a Run
112
+ // Record, deletes the session file and (under consent) remits — every effect
113
+ // --dry-run promises not to have — so the stale session stays stale until a
114
+ // real invocation closes it. --no-write suppresses both too: inheriting it
115
+ // into the closeout suppressed the Run Record but still deleted the session
116
+ // file. A refused invocation never journals either; that rule is per outcome,
117
+ // not per argv, and stays with the journal append.
118
+ export function resolveInvocationPolicy(command, args) {
119
+ const subcommand = subcommandNames(command).includes(args._[1]) ? args._[1] : null;
120
+ const rule = { class: "standard", ...(Object.hasOwn(COMMANDS, command) && COMMANDS[command]), ...(subcommand && SUBCOMMAND_OVERRIDES[`${command} ${subcommand}`]) };
121
+ const steps = CLASS_STEPS[rule.class];
122
+ const noWrite = args["no-write"] === true;
123
+ const implementsDryRun = rule.dryRun === true;
124
+ const sweepSuppressed = optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
125
+ const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
126
+ return Object.freeze({
127
+ class: rule.class,
128
+ wrapper: steps.includes("wrapper"), ambient: steps.includes("ambient"),
129
+ sweepRoot: steps.includes("sweep") && !sweepSuppressed ? rule.sweepRoot || null : null,
130
+ journalExempt: !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
131
+ implementsDryRun, autoEnd: rule.autoEnd === true,
132
+ });
133
+ }
134
+
135
+ // The sequence main() delegates to. `steps` are cli.mjs mechanisms:
136
+ // dispatch(command, args, { recorder?, ambient?, sessionHolder? }?)
137
+ // closeOutStaleRunSessions(rootKind, args) -> the closed-out stale sessions
138
+ // ambientRunSession(args) -> the active session, or null
139
+ // lifecycleIdentity(args, ambient) -> { argvShape, runId }
140
+ // persistLifecycle(args, command, lifecycle, sessionHolder, thrown)
141
+ // autoEndAfterQa(args, sessionHolder, thrown, implementsDryRun)
142
+ // Order: normalise argv, open the refusal scope, run an unwrapped class in
143
+ // place, else sweep, read the ambient session, and wrap dispatch; on finish
144
+ // (success or error) journal unless exempt, then run the QA auto-end. The
145
+ // sweep precedes the ambient read so a stale session at the root is closed out
146
+ // (findRunSession ignores it) rather than abandoned with no Run Record. The
147
+ // wrapper re-throws unchanged, so the exit code is the command's own; a
148
+ // command that throws is recorded too. Persistence stays opt-in: with no
149
+ // --lifecycle-journal, CAMPAIGNS_OS_LIFECYCLE_LOG or active session nothing is
150
+ // written.
151
+ export async function runInvocation(args, steps) {
152
+ // `npx … campaigns-os <command>` hands the bin its own name as the first
153
+ // positional: it is the program name, not a command.
154
+ if (args._[0] === "campaigns-os") args._.shift();
155
+ const command = args._[0] || "help";
156
+ const policy = resolveInvocationPolicy(command, args);
157
+ // One refusal scope per invocation, so a refusal is visible only to this
158
+ // invocation's persistence step.
159
+ return runWithRefusalScope(async () => {
160
+ if (!policy.wrapper) return steps.dispatch(command, args);
161
+ const sweptStale = policy.sweepRoot ? await steps.closeOutStaleRunSessions(policy.sweepRoot, args) : [];
162
+ const ambient = policy.ambient ? steps.ambientRunSession(args) : null;
163
+ // Per invocation, never module state: an intake that opens or joins a run
164
+ // session mid-command publishes it here, so this command's own entry is
165
+ // persisted into it.
166
+ const sessionHolder = { current: ambient, autoStarted: false, adopted: false, qaResult: null, sweptStale };
167
+ await withCommandLifecycle(
168
+ {
169
+ command,
170
+ ...steps.lifecycleIdentity(args, ambient),
171
+ onFinish: async (lifecycle, thrown) => {
172
+ if (!policy.journalExempt) steps.persistLifecycle(args, command, lifecycle, sessionHolder, thrown);
173
+ if (policy.autoEnd) await steps.autoEndAfterQa(args, sessionHolder, thrown, policy.implementsDryRun);
174
+ },
175
+ },
176
+ (recorder) => steps.dispatch(command, args, { recorder, ambient, sessionHolder }),
177
+ );
178
+ });
179
+ }
@@ -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,11 +1,12 @@
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');
@@ -71,39 +72,9 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
71
72
  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
73
  };
73
74
  }
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});}
75
+ function lock(dir,fn,{budgetMs=1500}={}) {
76
+ const lockPath=join(dir,'.allocation-lock');
77
+ return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:()=>Object.assign(new Error('progress.lock_unavailable'),{lockPath})});
107
78
  }
108
79
  export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
109
80
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -171,7 +142,7 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
171
142
  if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
172
143
  return {...remit,snapshot_id:snapshot.snapshot_id,reused};
173
144
  } 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.');
145
+ 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
146
  else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
176
147
  return {state:'failed',reason:'capture_unavailable'};
177
148
  }
@@ -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
+ }