@nextcommerce/campaigns-os 1.46.0 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +347 -3
- package/README.md +31 -4
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +1 -0
- package/agents/copilot/copilot-instructions.md +1 -0
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +1204 -129
- package/contracts/effects.v1.json +3 -3
- package/contracts/release-ledger.json +803 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
- package/docs/build-packet.md +23 -3
- package/docs/campaign-build-brief.md +25 -28
- package/docs/local-setup.md +13 -5
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +68 -3
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.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 +9 -4
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -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 +4 -4
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/built-script-syntax.mjs +116 -15
- package/src/cli.mjs +6 -3
- package/src/content-residue.mjs +18 -90
- package/src/diagnostic.mjs +2 -1
- package/src/doctor/checks.mjs +21 -30
- package/src/doctor/inspect.mjs +13 -3
- package/src/doctor/next-step.mjs +4 -0
- package/src/gate-actions.mjs +8 -0
- package/src/local-preview-policy.mjs +92 -0
- package/src/page-kit-sdk-version.mjs +8 -1
- package/src/polish-node.mjs +26 -2
- package/src/progress-node.mjs +5 -1
- package/src/qa-analytics-parity.mjs +37 -2
- package/src/qa-binding-evidence.mjs +5 -3
- package/src/qa-browser.mjs +136 -25
- package/src/qa-node.mjs +33 -4
- package/src/readback.mjs +19 -10
- package/src/sdk-markup.mjs +6 -45
- package/src/sdk-storage-compatibility.mjs +3 -2
- package/src/source-prep.mjs +1 -1
- package/src/tooling-setup.mjs +9 -0
package/src/doctor/inspect.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Doctor entry points: the doctor command, packet inspection and built-output inspection.
|
|
2
2
|
import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
|
|
3
|
+
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
|
|
3
4
|
import { withHtmlScanSnapshot } from "../html-scan.mjs";
|
|
4
5
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -506,16 +507,23 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
506
507
|
}
|
|
507
508
|
runPricingCssHideCheck({ packet, derived, warnings, ready, report });
|
|
508
509
|
|
|
509
|
-
|
|
510
|
+
// The local preview policy (local-preview-policy.mjs) carries missing
|
|
511
|
+
// polish and page-load evidence forward as warnings on a local-serve packet.
|
|
512
|
+
const polishCheckpointGate = applyLocalPreviewToCheckpoint(
|
|
513
|
+
evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
|
|
514
|
+
{ packet, report },
|
|
515
|
+
);
|
|
510
516
|
derived.polish_checkpoint_gate = polishCheckpointGate;
|
|
511
|
-
const polishGate = evaluatePolishGate({
|
|
517
|
+
const polishGate = applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
512
518
|
report,
|
|
513
519
|
hiddenEagerMediaGate: polishCheckpointGate,
|
|
514
520
|
currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
|
|
515
|
-
});
|
|
521
|
+
}), { packet, checkpointGate: polishCheckpointGate });
|
|
516
522
|
derived.polish_gate = polishGate;
|
|
517
523
|
if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
|
|
518
524
|
pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
|
|
525
|
+
} else if (polishGate.status === CARRIED_FORWARD && !polishGate.owned_checkpoint_only) {
|
|
526
|
+
addIssue(warnings, polishGate.code, carriedForwardMessage(polishGate), { polish_gate: polishGate });
|
|
519
527
|
} else if (polishGate.status === "waived" && !polishGate.owned_checkpoint_only) {
|
|
520
528
|
ready.push(`Polish gate passed under waiver: ${polishGate.waiver?.reason || "(no reason recorded)"}`);
|
|
521
529
|
} else if (polishGate.status === "pass") {
|
|
@@ -524,6 +532,8 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
524
532
|
|
|
525
533
|
if (polishCheckpointGate.status === "blocked") {
|
|
526
534
|
pushGateIssue({ errors, warnings }, gateIssue("polish_checkpoint_gate", polishCheckpointGate));
|
|
535
|
+
} else if (polishCheckpointGate.status === CARRIED_FORWARD) {
|
|
536
|
+
addIssue(warnings, polishCheckpointGate.code, carriedForwardMessage(polishCheckpointGate), { polish_checkpoint_gate: polishCheckpointGate });
|
|
527
537
|
} else if (polishCheckpointGate.status === "waived") {
|
|
528
538
|
addIssue(
|
|
529
539
|
warnings,
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The next step doctor recommends, and the gate issues `next` reads from doctor.
|
|
2
2
|
import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
|
|
3
|
+
import { CARRIED_FORWARD } from "../local-preview-policy.mjs";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import { orderPathDepthDriftText } from "../proof-policy.mjs";
|
|
5
6
|
import { anyAssemblyReportStageBlocked } from "../stage-ledger.mjs";
|
|
@@ -462,6 +463,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
462
463
|
}
|
|
463
464
|
|
|
464
465
|
for (const cliStage of NEXT_STAGE_ORDER) {
|
|
466
|
+
// On the local preview, missing polish is carried forward as a warning
|
|
467
|
+
// (local-preview-policy.mjs): the ladder moves on to deploy and QA.
|
|
468
|
+
if (cliStage === "polish" && polishGate.status === CARRIED_FORWARD) continue;
|
|
465
469
|
const reportKey = reportKeyForCliStage(cliStage);
|
|
466
470
|
const stage = report.stages[reportKey];
|
|
467
471
|
if (!stage) {
|
package/src/gate-actions.mjs
CHANGED
|
@@ -62,6 +62,14 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
|
|
|
62
62
|
command: null,
|
|
63
63
|
description: localProofRebuildText(),
|
|
64
64
|
}),
|
|
65
|
+
// Every mapped page is template stock: capture has no design route, so
|
|
66
|
+
// neither a recapture nor an authority repair can produce the evidence.
|
|
67
|
+
map_design_route: Object.freeze({
|
|
68
|
+
id: "polish.hidden_eager_media.map_design_route",
|
|
69
|
+
kind: "manual",
|
|
70
|
+
command: null,
|
|
71
|
+
description: "Every mapped page is template stock, so polish capture has nothing to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or prove the campaign on the local preview (deploy.target local-serve served from localhost), where this missing evidence is carried forward as a warning.",
|
|
72
|
+
}),
|
|
65
73
|
});
|
|
66
74
|
|
|
67
75
|
const PACKET_PLACEHOLDER = "--packet <packet>";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The local preview policy: one place that decides which missing evidence a
|
|
2
|
+
// localhost preview carries forward as a warning instead of a blocker.
|
|
3
|
+
//
|
|
4
|
+
// The local preview is a packet whose deploy target is `local-serve`, served
|
|
5
|
+
// from a loopback host. There a campaign must still prove its commerce (store
|
|
6
|
+
// and campaign binding, routes, SDK loading, prices, a typed-card order), but
|
|
7
|
+
// evidence that only design work or launch produces may be missing. Missing
|
|
8
|
+
// evidence is reported as missing, never as passed: a carried-forward gate is
|
|
9
|
+
// a warning in doctor, `next` does not stop on it, and QA records it as a
|
|
10
|
+
// `warn` row, so the verdict is at best `ready_with_exceptions`. A hosted
|
|
11
|
+
// preview or production packet never takes this path, and every check not
|
|
12
|
+
// named below keeps its meaning on every path.
|
|
13
|
+
//
|
|
14
|
+
// Doctor and QA apply this to the gates they evaluate; `next` reads doctor's
|
|
15
|
+
// gates. Recording a stage (`record polish`) and the waiver commands keep the
|
|
16
|
+
// strict gates.
|
|
17
|
+
import { isLocalServePacket } from "./local-proof.mjs";
|
|
18
|
+
import { isLoopbackHostname } from "./remit.mjs";
|
|
19
|
+
|
|
20
|
+
export const LOCAL_PREVIEW_POLICY = "local_preview";
|
|
21
|
+
export const CARRIED_FORWARD = "carried_forward";
|
|
22
|
+
|
|
23
|
+
// The checks carried forward, one by one. Each is evidence the campaign does
|
|
24
|
+
// not have yet, not evidence that something is wrong:
|
|
25
|
+
// - polish was never recorded for this build;
|
|
26
|
+
// - no page-load capture exists, either because polish capture never ran or
|
|
27
|
+
// because every mapped page is template stock and there is no design route
|
|
28
|
+
// to capture.
|
|
29
|
+
const CARRIED_POLISH_CODES = Object.freeze(new Set([
|
|
30
|
+
"polish.report_missing",
|
|
31
|
+
"polish.evidence_missing",
|
|
32
|
+
]));
|
|
33
|
+
export const NO_CAPTURABLE_ROUTES_CODE = "polish.hidden_eager_media.no_capturable_routes";
|
|
34
|
+
const MISSING_CAPTURE_CODE = "polish.hidden_eager_media.capture_malformed";
|
|
35
|
+
|
|
36
|
+
// With no generatable brand theme the starter template is the design, so its
|
|
37
|
+
// palette and chrome are expected on the local preview: residue findings are
|
|
38
|
+
// warnings there, as they are under a waived theme gate.
|
|
39
|
+
const STARTER_IS_THE_DESIGN_THEME_CODE = "theme_gate.nothing_generatable";
|
|
40
|
+
|
|
41
|
+
function loopbackOrAbsent(url) {
|
|
42
|
+
if (url === undefined || url === null || url === "") return true;
|
|
43
|
+
try {
|
|
44
|
+
return isLoopbackHostname(new URL(String(url)).hostname);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isLocalPreview(packet, { baseUrl = null } = {}) {
|
|
51
|
+
return isLocalServePacket(packet)
|
|
52
|
+
&& loopbackOrAbsent(packet?.deploy?.preview_url)
|
|
53
|
+
&& loopbackOrAbsent(baseUrl);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pageLoadRecorded(report) {
|
|
57
|
+
return report?.stages?.polish?.evidence?.visual_review?.page_load != null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function carried(gate) {
|
|
61
|
+
return {
|
|
62
|
+
...gate,
|
|
63
|
+
status: CARRIED_FORWARD,
|
|
64
|
+
carried_forward: { policy: LOCAL_PREVIEW_POLICY, from_status: gate.status, evidence: "missing" },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The hidden eager-media checkpoint, evaluated on its own.
|
|
69
|
+
export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl = null } = {}) {
|
|
70
|
+
if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
|
|
71
|
+
const missing = gate.code === NO_CAPTURABLE_ROUTES_CODE
|
|
72
|
+
|| (gate.code === MISSING_CAPTURE_CODE && !pageLoadRecorded(report));
|
|
73
|
+
return missing ? carried(gate) : gate;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The polish gate folds the checkpoint in, so it is carried forward only when
|
|
77
|
+
// whatever it owns from the checkpoint was carried forward too.
|
|
78
|
+
export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
|
|
79
|
+
if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
|
|
80
|
+
const checkpointCarried = checkpointGate?.status === CARRIED_FORWARD;
|
|
81
|
+
if (gate.owned_checkpoint_status === "blocked" && !checkpointCarried) return gate;
|
|
82
|
+
if (gate.owned_checkpoint_only) return checkpointCarried ? carried(gate) : gate;
|
|
83
|
+
return CARRIED_POLISH_CODES.has(gate.code) ? carried(gate) : gate;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function starterResidueIsExpected(themeGate, { packet, baseUrl = null } = {}) {
|
|
87
|
+
return themeGate?.code === STARTER_IS_THE_DESIGN_THEME_CODE && isLocalPreview(packet, { baseUrl });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function carriedForwardMessage(gate) {
|
|
91
|
+
return `Carried forward on the local preview: ${gate.reason} This evidence is missing, not passed; a hosted preview or production run requires it.`;
|
|
92
|
+
}
|
|
@@ -92,6 +92,10 @@ export function evaluatePageKitSdkVersion({
|
|
|
92
92
|
waivers = [],
|
|
93
93
|
required = true,
|
|
94
94
|
now = new Date().toISOString(),
|
|
95
|
+
// { family, sdk_version } of the selected certified template family, when
|
|
96
|
+
// the caller knows it: the missing-pin reason names that verified SDK so the
|
|
97
|
+
// pin is not chosen by searching docs.
|
|
98
|
+
familyVerification = null,
|
|
95
99
|
} = {}) {
|
|
96
100
|
const pin = resolveSpecSdkPin(spec);
|
|
97
101
|
const { has_canonical: hasCanonical, has_alias: hasAlias } = pin;
|
|
@@ -139,7 +143,10 @@ export function evaluatePageKitSdkVersion({
|
|
|
139
143
|
scope: PAGE_KIT_SDK_VERSION_SCOPE,
|
|
140
144
|
status: "blocked",
|
|
141
145
|
code: "page_kit.sdk_version.spec_missing",
|
|
142
|
-
reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA."
|
|
146
|
+
reason: "CampaignSpec is missing global_config.sdk_version (and the runtime.sdk_version alias); add an explicit released SDK pin before build or QA."
|
|
147
|
+
+ (isReleasedSdkVersion(familyVerification?.sdk_version) && typeof familyVerification?.family === "string"
|
|
148
|
+
? ` The "${familyVerification.family}" template family was last verified against ${familyVerification.sdk_version}.`
|
|
149
|
+
: ""),
|
|
143
150
|
waivable: false,
|
|
144
151
|
subject,
|
|
145
152
|
state: { spec_status: "missing" },
|
package/src/polish-node.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
2
|
+
import { NO_CAPTURABLE_ROUTES_CODE } from "./local-preview-policy.mjs";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
|
|
4
5
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -93,6 +94,8 @@ function mappedSpecRoute(value, pageId) {
|
|
|
93
94
|
return route;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
const NO_CAPTURABLE_ROUTES_ERROR = "no_capturable_routes";
|
|
98
|
+
|
|
96
99
|
export function planPolishCapture({ packet, baseUrl } = {}) {
|
|
97
100
|
if (!isPlainObject(packet) || !Array.isArray(packet?.source_html?.pages) || packet.source_html.pages.length === 0) {
|
|
98
101
|
throw new Error("polish capture requires packet.source_html.pages mappings.");
|
|
@@ -129,7 +132,14 @@ export function planPolishCapture({ packet, baseUrl } = {}) {
|
|
|
129
132
|
});
|
|
130
133
|
}
|
|
131
134
|
|
|
132
|
-
if (routes.length === 0)
|
|
135
|
+
if (routes.length === 0) {
|
|
136
|
+
const error = new Error(
|
|
137
|
+
"polish capture has no mapped non-skipped routes to capture: every mapped page is template stock (skip_reason), so there is no design route to compare."
|
|
138
|
+
+ " On a local-serve preview, missing polish evidence is carried forward as a warning; run `next` for the next stage.",
|
|
139
|
+
);
|
|
140
|
+
error.code = NO_CAPTURABLE_ROUTES_ERROR;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
133
143
|
routes.sort((a, b) => a.requested_route.localeCompare(b.requested_route) || a.page_id.localeCompare(b.page_id));
|
|
134
144
|
for (let index = 1; index < routes.length; index += 1) {
|
|
135
145
|
if (routes[index - 1].requested_route === routes[index].requested_route) {
|
|
@@ -189,6 +199,19 @@ function recordedAuthorityBlock({ packet, report, plan = null, now } = {}) {
|
|
|
189
199
|
};
|
|
190
200
|
}
|
|
191
201
|
|
|
202
|
+
// Every mapped page is template stock: there is nothing for polish capture to
|
|
203
|
+
// measure, which is missing evidence rather than a malformed packet. It stays
|
|
204
|
+
// a non-waivable block; the local preview policy carries it forward.
|
|
205
|
+
function noCapturableRoutesBlock({ packet, report, now } = {}) {
|
|
206
|
+
const block = recordedAuthorityBlock({ packet, report, now });
|
|
207
|
+
return {
|
|
208
|
+
...block,
|
|
209
|
+
code: NO_CAPTURABLE_ROUTES_CODE,
|
|
210
|
+
reason: "Every mapped page is template stock (skip_reason), so polish capture has no design route to capture and this build has no page-load evidence.",
|
|
211
|
+
required_actions: [HIDDEN_EAGER_MEDIA_ACTIONS.map_design_route],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
192
215
|
function recordedCheckpointNotApplicable() {
|
|
193
216
|
return {
|
|
194
217
|
id: HIDDEN_EAGER_MEDIA_SCOPE,
|
|
@@ -219,7 +242,8 @@ export function evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report, now
|
|
|
219
242
|
let plan;
|
|
220
243
|
try {
|
|
221
244
|
plan = planPolishCapture({ packet, baseUrl: "https://polish-capture.invalid" });
|
|
222
|
-
} catch {
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error?.code === NO_CAPTURABLE_ROUTES_ERROR) return noCapturableRoutesBlock({ packet, report, now });
|
|
223
247
|
return recordedAuthorityBlock({ packet, report, now });
|
|
224
248
|
}
|
|
225
249
|
|
package/src/progress-node.mjs
CHANGED
|
@@ -58,7 +58,11 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
58
58
|
binding:reportBound&&id(verdict.run_id)&&id(verdict.run_id)===id(report?.stages?.qa?.verdict_run_id)&&build&&qaSource===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'&&hash(verdict.spec_hash)===localHash?'matching':'unconfirmed',
|
|
59
59
|
publish_state:accepted(qaResult?.qa_verdict_publish?.state,['skipped','ok','failed']),
|
|
60
60
|
}:null;
|
|
61
|
-
|
|
61
|
+
// The snapshot schema has no carried-forward state: a gate the local preview
|
|
62
|
+
// policy carried forward does not apply on this path, so it is not_applicable
|
|
63
|
+
// here (never pass).
|
|
64
|
+
const gateState=status=>accepted(status==='carried_forward'?'not_applicable':status,['pass','blocked','waived','not_applicable']);
|
|
65
|
+
const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,PROGRESS_GATE_LIMIT).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?gateState(gate?.status):'unknown'}));
|
|
62
66
|
const actions=[...new Set((Array.isArray(continuation?.next_actions)?continuation.next_actions:[]).slice(0,64).map(action=>accepted(action?.id,PROGRESS_ACTION_IDS)))];
|
|
63
67
|
const stage=accepted(continuation?.stage,PROGRESS_CONTINUATIONS);
|
|
64
68
|
const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
|
|
@@ -547,6 +547,10 @@ function valuesEqual(a, b) {
|
|
|
547
547
|
// flag carried-over-tag regressions for human review.
|
|
548
548
|
// `options.url` is the candidate URL that was captured (the resolved capture
|
|
549
549
|
// target) — stamped on every emitted assertion, pass and fail alike.
|
|
550
|
+
// `options.candidatePage` describes the page the candidate was captured on when
|
|
551
|
+
// the leg picked it automatically (#512): `{ receipt, source, page_type }`.
|
|
552
|
+
// An explicit --analytics-candidate passes none and is treated as the receipt
|
|
553
|
+
// the operator named.
|
|
550
554
|
export function diffAnalyticsParity(baseline, candidate, options = {}) {
|
|
551
555
|
const assertions = [];
|
|
552
556
|
const auditedUrl = (typeof options.url === "string" && options.url.trim()) ? options.url.trim() : null;
|
|
@@ -561,13 +565,44 @@ export function diffAnalyticsParity(baseline, candidate, options = {}) {
|
|
|
561
565
|
const cEff = effectivePurchase(c);
|
|
562
566
|
|
|
563
567
|
// 1. Purchase present on candidate — the highest-value blocking check.
|
|
564
|
-
|
|
568
|
+
// #512: Purchase fires only on a receipt. When the candidate is known not to
|
|
569
|
+
// be one (the automatic capture page is the campaign root or a built entry),
|
|
570
|
+
// a missing Purchase is a page mismatch, not a regression, so it goes to
|
|
571
|
+
// manual review naming the mismatch. A receipt candidate, or one the
|
|
572
|
+
// operator named, still blocks.
|
|
573
|
+
const candidatePage = options.candidatePage && typeof options.candidatePage === "object" ? options.candidatePage : null;
|
|
574
|
+
if (!cEff.fired && candidatePage?.receipt === false) {
|
|
575
|
+
const baselineFired = effectivePurchase(b).fired;
|
|
576
|
+
assertions.push(emit({
|
|
577
|
+
id: "analytics-parity:purchase-present",
|
|
578
|
+
status: STATUS.MANUAL_REVIEW,
|
|
579
|
+
severity: SEVERITY.WARN,
|
|
580
|
+
expected: "a receipt candidate to check Purchase on; the automatic candidate is not a receipt, so pair receipts with --analytics-candidate",
|
|
581
|
+
actual: baselineFired
|
|
582
|
+
? "baseline fired a Purchase but the automatic candidate is not a receipt page; pass --analytics-candidate <candidate receipt url> to compare receipts"
|
|
583
|
+
: "neither page fired a Purchase and the automatic candidate is not a receipt page; pass receipt URLs to --analytics-baseline and --analytics-candidate to compare Purchase",
|
|
584
|
+
evidence: {
|
|
585
|
+
via: cEff.via,
|
|
586
|
+
candidate_events: c.eventNames || [],
|
|
587
|
+
baseline_purchase: bp,
|
|
588
|
+
page_mismatch: {
|
|
589
|
+
reason: baselineFired ? "receipt_baseline_non_receipt_candidate" : "candidate_not_receipt",
|
|
590
|
+
baseline_fired_purchase: baselineFired,
|
|
591
|
+
candidate_receipt: candidatePage.receipt,
|
|
592
|
+
candidate_source: candidatePage.source ?? null,
|
|
593
|
+
candidate_page_type: candidatePage.page_type ?? null,
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
}));
|
|
597
|
+
} else assertions.push(emit({
|
|
565
598
|
id: "analytics-parity:purchase-present",
|
|
566
599
|
status: cEff.fired ? STATUS.PASS : STATUS.FAIL,
|
|
567
600
|
severity: SEVERITY.BLOCKER,
|
|
568
601
|
expected: "candidate fires a Purchase (dl_purchase, or Meta/GA4 pixel if the SDK event is blocked)",
|
|
569
602
|
actual: cEff.fired ? `purchase fired via ${cEff.via}` : "no purchase fire captured on candidate (dataLayer, Meta, or GA4)",
|
|
570
|
-
|
|
603
|
+
// An automatic candidate's page is recorded even when it fired, so a pass on
|
|
604
|
+
// a non-receipt page reads as such.
|
|
605
|
+
evidence: { via: cEff.via, candidate_events: c.eventNames || [], candidate_signals: c.purchaseSignals || {}, baseline_purchase: bp, ...(candidatePage ? { candidate_page: candidatePage } : {}) },
|
|
571
606
|
}));
|
|
572
607
|
|
|
573
608
|
if (cEff.fired) {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { parse as parseHtml } from 'parse5';
|
|
2
2
|
import { parse as parseJs } from 'acorn';
|
|
3
3
|
import { createPageSourceLoader, resolveCommercialApiKey } from './qa-commercial-parity.mjs';
|
|
4
|
-
import { HTML_NAMESPACE, baseInEffect, documentBases, frozenBaseUrl, parseFailureDiagnostic, scriptKind } from './built-script-syntax.mjs';
|
|
4
|
+
import { HTML_NAMESPACE, baseInEffect, documentBases, endsUnclosed, frozenBaseUrl, parseFailureDiagnostic, scriptKind } from './built-script-syntax.mjs';
|
|
5
5
|
|
|
6
6
|
export const BINDING_SCHEMA = 'campaigns-os-page-binding/v0';
|
|
7
7
|
export const BINDING_LIMITS = Object.freeze({ scripts_per_page: 6, scripts_per_run: 24, script_bytes: 262144, timeout_ms: 5000 });
|
|
8
|
-
const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/index\.js|public\/loader\.js)(?:\?[^#]*)?$/;
|
|
8
|
+
const SDK = /^https:\/\/cdn\.jsdelivr\.net\/gh\/NextCommerceCo\/campaign-cart@[^/]+\/(?:dist\/(?:index|loader)\.js|public\/loader\.js)(?:\?[^#]*)?$/;
|
|
9
9
|
const str = value => typeof value === 'string' && value.length ? value : null;
|
|
10
10
|
|
|
11
11
|
// The expected credential uses the existing producer resolver. Disagreement
|
|
@@ -124,8 +124,10 @@ export async function observeBinding({ source, page, expected, scriptLoader, par
|
|
|
124
124
|
// Only an HTML-namespace <script> loads `src`. An SVG script runs from
|
|
125
125
|
// href / xlink:href or its inline text, which this static read does not
|
|
126
126
|
// model: it is not fetched and leaves the binding dynamic.
|
|
127
|
+
// A script the file ends inside never reaches its end tag, so the parser
|
|
128
|
+
// never prepares it: the browser neither fetches nor runs it (#515).
|
|
127
129
|
if (node.tagName === 'script' && node.namespaceURI !== HTML_NAMESPACE) dynamic = true;
|
|
128
|
-
else if (node.tagName === 'script') scripts.push({ attrs, base: baseInEffect(bases, node), text: (node.childNodes || []).map(n => n.value || '').join('') });
|
|
130
|
+
else if (node.tagName === 'script' && !endsUnclosed(node)) scripts.push({ attrs, base: baseInEffect(bases, node), text: (node.childNodes || []).map(n => n.value || '').join('') });
|
|
129
131
|
// parse5 keeps template content separate; it is inert, as is noscript at boot.
|
|
130
132
|
if (node.tagName !== 'noscript') for (const child of node.childNodes || []) walk(child);
|
|
131
133
|
};
|