@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.
- package/AGENTS.md +4 -2
- package/CHANGELOG.md +451 -0
- package/README.md +2 -2
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/effects.v1.json +8 -8
- package/contracts/release-ledger.json +672 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/build-packet.md +64 -2
- package/docs/campaigns-os-build-flow.md +1 -0
- package/docs/design-source-package.md +89 -15
- package/docs/effects.md +16 -4
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +131 -7
- package/docs/release-ledger-authoring-guide.md +6 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills.json +10 -10
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +1211 -7495
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/diagnostic.mjs +2 -1
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4415 -0
- package/src/doctor/inspect.mjs +636 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +179 -0
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +6 -35
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +778 -77
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-node.mjs +218 -13
- package/src/source-html-intake.mjs +1 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/target-lock.mjs +54 -0
- 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 (
|
|
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();
|
package/src/progress-node.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
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(
|
|
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
|
}
|
package/src/proof-policy.mjs
CHANGED
|
@@ -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` (
|
|
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:
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
111
|
-
|
|
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
|
|
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 ||
|
|
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
|
+
}
|