@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.
- package/AGENTS.md +9 -2
- package/CHANGELOG.md +1099 -5103
- package/README.md +34 -13
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1184 -121
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2190 -5260
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +222 -23
- package/docs/campaigns-os-build-flow.md +4 -3
- package/docs/design-source-package.md +162 -15
- package/docs/effects.md +66 -12
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/progress-snapshots.md +10 -6
- package/docs/qa-and-test-orders.md +230 -20
- package/docs/release-ledger-authoring-guide.md +70 -8
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/built-site-scope.mjs +16 -4
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +1530 -7580
- package/src/commercial-parity.mjs +48 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +6 -2
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4654 -0
- package/src/doctor/inspect.mjs +678 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +183 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -36
- 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 +1316 -105
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +339 -19
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +117 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/stage-ledger.mjs +28 -0
- package/src/stage-record.mjs +551 -0
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- package/src/upsell-selector-scope.mjs +112 -2
|
@@ -30,6 +30,19 @@ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
|
|
|
30
30
|
...(entry?.kind === "terminal" ? [entry.terminal] : []),
|
|
31
31
|
...terminalPaths.map((candidate) => candidate.terminal),
|
|
32
32
|
]);
|
|
33
|
+
// Every declared offer page with the offer page each action leads to, so a
|
|
34
|
+
// planned path that stops short of a terminal can still be walked to the
|
|
35
|
+
// controls it clicks.
|
|
36
|
+
const offerEdges = [];
|
|
37
|
+
for (const [key, page] of pagesByUrl) {
|
|
38
|
+
if (!OFFER_PAGE_TYPES.has(pageType(page))) continue;
|
|
39
|
+
const edge = { key, page_id: page.page_id || null, page_type: page.page_type || null, url: page.url };
|
|
40
|
+
for (const [action, field] of ACTIONS) {
|
|
41
|
+
const target = targetNode(page?.[field], pagesByUrl, topologyOrigin);
|
|
42
|
+
edge[`${action}_key`] = target?.kind === "offer" ? canonicalHttpUrl(target.page.url) : null;
|
|
43
|
+
}
|
|
44
|
+
offerEdges.push(edge);
|
|
45
|
+
}
|
|
33
46
|
|
|
34
47
|
return {
|
|
35
48
|
topology_id: topology?.funnel_id || "default",
|
|
@@ -48,6 +61,8 @@ export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
|
|
|
48
61
|
terminal_paths: terminalPaths,
|
|
49
62
|
invalid_paths: invalidPaths,
|
|
50
63
|
recognized_terminals: recognizedTerminals,
|
|
64
|
+
entry_offer_key: entry?.kind === "offer" ? canonicalHttpUrl(entry.page.url) : null,
|
|
65
|
+
offer_edges: offerEdges,
|
|
51
66
|
};
|
|
52
67
|
}
|
|
53
68
|
|
|
@@ -95,6 +110,139 @@ export function commonTestOrderPaths(resolvedTopology) {
|
|
|
95
110
|
return paths;
|
|
96
111
|
}
|
|
97
112
|
|
|
113
|
+
// The default `common` depth (#530). The sample above never reached a
|
|
114
|
+
// downsell's decline, so a broken decline link passed QA. When every actual
|
|
115
|
+
// terminal path fits under the cap, `common` runs them all. Above the cap it
|
|
116
|
+
// keeps the sample and adds, for each offer page whose decline no planned path
|
|
117
|
+
// clicks yet, the shortest path that clicks it, until the cap is reached. A
|
|
118
|
+
// page counts as covered only when its decline is clicked: arriving at it, or
|
|
119
|
+
// clicking its accept, does not. Pages still uncovered are returned, not
|
|
120
|
+
// dropped. The sample is never trimmed, so a cap below it is still refused by
|
|
121
|
+
// the flood guard exactly as before.
|
|
122
|
+
export function commonTestOrderPlan(resolvedTopology, { cap } = {}) {
|
|
123
|
+
const baseline = commonTestOrderPaths(resolvedTopology);
|
|
124
|
+
let full = null;
|
|
125
|
+
try {
|
|
126
|
+
full = fullTestOrderPaths(resolvedTopology);
|
|
127
|
+
} catch {
|
|
128
|
+
full = null;
|
|
129
|
+
}
|
|
130
|
+
const plan = {
|
|
131
|
+
requested_depth: "common",
|
|
132
|
+
cap,
|
|
133
|
+
full_path_count: full ? full.length : null,
|
|
134
|
+
baseline_paths: baseline,
|
|
135
|
+
};
|
|
136
|
+
if (full && full.length <= cap) {
|
|
137
|
+
// Same set as `full`; the sample's paths keep their place at the front.
|
|
138
|
+
const paths = [...baseline.filter((path) => full.includes(path)), ...full.filter((path) => !baseline.includes(path))];
|
|
139
|
+
return { ...plan, effective_depth: "full", reason: "under_cap", paths, coverage_paths: [], uncovered_pages: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const paths = baseline.slice();
|
|
143
|
+
const coveragePaths = [];
|
|
144
|
+
const offers = offerPagesByReach(resolvedTopology);
|
|
145
|
+
for (const offer of offers) {
|
|
146
|
+
if (paths.length >= cap) break;
|
|
147
|
+
const declined = declinedOfferKeys(resolvedTopology, paths);
|
|
148
|
+
if (declined.has(offer.key) || !offer.reach) continue;
|
|
149
|
+
const candidate = shortestDeclinePath(resolvedTopology, offer, declined);
|
|
150
|
+
if (!candidate || paths.includes(candidate)) continue;
|
|
151
|
+
paths.push(candidate);
|
|
152
|
+
coveragePaths.push(candidate);
|
|
153
|
+
}
|
|
154
|
+
const declined = declinedOfferKeys(resolvedTopology, paths);
|
|
155
|
+
const uncovered = offers
|
|
156
|
+
.filter((offer) => !declined.has(offer.key))
|
|
157
|
+
.map((offer) => ({
|
|
158
|
+
page_id: offer.page_id,
|
|
159
|
+
page_type: offer.page_type,
|
|
160
|
+
reason: offer.reach ? "cap" : "unreachable",
|
|
161
|
+
}));
|
|
162
|
+
return {
|
|
163
|
+
...plan,
|
|
164
|
+
effective_depth: "common",
|
|
165
|
+
reason: full ? "over_cap" : "full_not_enumerable",
|
|
166
|
+
paths,
|
|
167
|
+
coverage_paths: coveragePaths,
|
|
168
|
+
uncovered_pages: uncovered,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The offer pages a planned path clicks, in click order: `checkout` clicks
|
|
173
|
+
// none, `decline-accept` clicks the entry offer's decline and then the accept
|
|
174
|
+
// on whichever offer that decline leads to. A walk stops where the topology
|
|
175
|
+
// leaves the offer graph, as the runner does.
|
|
176
|
+
export function testOrderPathClicks(resolvedTopology, path) {
|
|
177
|
+
const normalized = String(path || "").toLowerCase();
|
|
178
|
+
const steps = !normalized || normalized === "checkout" ? [] : normalized.split("-");
|
|
179
|
+
const edges = new Map((resolvedTopology?.offer_edges || []).map((edge) => [edge.key, edge]));
|
|
180
|
+
const clicks = [];
|
|
181
|
+
let current = edges.get(resolvedTopology?.entry_offer_key) || null;
|
|
182
|
+
for (const step of steps) {
|
|
183
|
+
if (!current) break;
|
|
184
|
+
clicks.push({ key: current.key, page_id: current.page_id, action: step });
|
|
185
|
+
current = edges.get(current[`${step}_key`]) || null;
|
|
186
|
+
}
|
|
187
|
+
return clicks;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function declinedOfferKeys(resolvedTopology, paths) {
|
|
191
|
+
const keys = new Set();
|
|
192
|
+
for (const path of paths) {
|
|
193
|
+
for (const click of testOrderPathClicks(resolvedTopology, path)) {
|
|
194
|
+
if (click.action === "decline") keys.add(click.key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return keys;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Declared offer pages, shallowest first, each with the shortest action
|
|
201
|
+
// sequence that reaches it (accept before decline on a tie) or null when no
|
|
202
|
+
// path from the checkout reaches it.
|
|
203
|
+
function offerPagesByReach(resolvedTopology) {
|
|
204
|
+
const edges = resolvedTopology?.offer_edges || [];
|
|
205
|
+
const byKey = new Map(edges.map((edge) => [edge.key, edge]));
|
|
206
|
+
const reach = new Map();
|
|
207
|
+
const entry = resolvedTopology?.entry_offer_key;
|
|
208
|
+
if (entry && byKey.has(entry)) {
|
|
209
|
+
reach.set(entry, []);
|
|
210
|
+
const queue = [entry];
|
|
211
|
+
while (queue.length) {
|
|
212
|
+
const key = queue.shift();
|
|
213
|
+
for (const action of ["accept", "decline"]) {
|
|
214
|
+
const next = byKey.get(key)?.[`${action}_key`];
|
|
215
|
+
if (!next || reach.has(next) || !byKey.has(next)) continue;
|
|
216
|
+
reach.set(next, [...reach.get(key), action]);
|
|
217
|
+
queue.push(next);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const reached = [...reach.keys()].map((key) => ({ ...byKey.get(key), reach: reach.get(key) }));
|
|
222
|
+
const unreached = edges.filter((edge) => !reach.has(edge.key)).map((edge) => ({ ...edge, reach: null }));
|
|
223
|
+
return [...reached, ...unreached];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The shortest actual terminal path that clicks this offer's decline. Among
|
|
227
|
+
// equally short paths, the one that also clicks the most still-undeclined
|
|
228
|
+
// offers wins, then accept before decline. Where no terminal path clicks it
|
|
229
|
+
// (the graph beyond is not enumerable), the path that reaches the offer and
|
|
230
|
+
// declines it.
|
|
231
|
+
function shortestDeclinePath(resolvedTopology, offer, declined) {
|
|
232
|
+
const newlyDeclined = (path) => new Set(testOrderPathClicks(resolvedTopology, path)
|
|
233
|
+
.filter((click) => click.action === "decline" && !declined.has(click.key))
|
|
234
|
+
.map((click) => click.key)).size;
|
|
235
|
+
const candidates = (resolvedTopology?.terminal_paths || [])
|
|
236
|
+
.filter((candidate) => testOrderPathClicks(resolvedTopology, candidate.path)
|
|
237
|
+
.some((click) => click.key === offer.key && click.action === "decline"))
|
|
238
|
+
.map((candidate) => ({ ...candidate, gain: newlyDeclined(candidate.path) }))
|
|
239
|
+
.sort((left, right) => (left.steps.length - right.steps.length)
|
|
240
|
+
|| (right.gain - left.gain)
|
|
241
|
+
|| compareActionSteps(left.steps, right.steps));
|
|
242
|
+
if (candidates.length) return candidates[0].path;
|
|
243
|
+
return [...offer.reach, "decline"].join("-");
|
|
244
|
+
}
|
|
245
|
+
|
|
98
246
|
function compareCommonReceiptPaths(left, right) {
|
|
99
247
|
const lengthDelta = (left?.steps?.length || 0) - (right?.steps?.length || 0);
|
|
100
248
|
if (lengthDelta) return lengthDelta;
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `built_output.upsell_selector_scope` catches one shape of built markup that
|
|
4
4
|
// the Campaign Cart SDK binds without complaint and that then does the wrong
|
|
5
|
-
// thing to a shopper. This module is the rest of that family:
|
|
5
|
+
// thing to a shopper. This module is the rest of that family: seven more
|
|
6
6
|
// shapes, each a static read of built HTML, each producing either a silent
|
|
7
7
|
// no-op (the shopper fills a field that never reaches the order; a button
|
|
8
8
|
// that never enables) or a double cart write. A partner agency's Campaign
|
|
9
|
-
// Cart skill kit listed
|
|
10
|
-
// two vocabularies line up.
|
|
9
|
+
// Cart skill kit listed the first six as stable lint codes; the codes are
|
|
10
|
+
// kept so the two vocabularies line up. ORPHANED_UPSELL_ACTION is ours.
|
|
11
11
|
//
|
|
12
12
|
// Blockers (not waivable — the markup provably does not do what it says)
|
|
13
13
|
// SWAP_WITH_ADD_TO_CART a bundle selector in swap mode (explicit, or
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
// MISSING_SELECTOR_ID_MATCH an add-to-cart button whose data-next-selector-id
|
|
27
27
|
// names no selector on the page. The button waits
|
|
28
28
|
// for a selection that can never arrive.
|
|
29
|
+
// ORPHANED_UPSELL_ACTION data-next-upsell-action with no ancestor carrying
|
|
30
|
+
// data-next-upsell (#529). The upsell enhancer
|
|
31
|
+
// binds only the actions inside its container; an
|
|
32
|
+
// action outside it is a plain link, so a "No
|
|
33
|
+
// thanks" goes nowhere and the shopper is stuck.
|
|
34
|
+
// Every page type, not only upsell pages.
|
|
29
35
|
//
|
|
30
36
|
// Warnings (advisory)
|
|
31
37
|
// DOUBLE_SELECTED more than one data-next-selected="true" card in
|
|
@@ -34,6 +40,12 @@
|
|
|
34
40
|
// TEMPLATE_DOUBLE_BRACE `{{` inside an SDK-owned <template>. SDK tokens
|
|
35
41
|
// are single-brace and conditions are no-brace;
|
|
36
42
|
// a double brace renders literally.
|
|
43
|
+
// CHECKOUT_BUMP_IS_UPSELL data-next-is-upsell="true" on a checkout page
|
|
44
|
+
// (#535). A checkout bump is a pre-purchase
|
|
45
|
+
// add-on; the flag puts it on the initial order
|
|
46
|
+
// as an upsell line. The page type is a live,
|
|
47
|
+
// unambiguous next-page-type meta, since it is
|
|
48
|
+
// what the SDK reads; otherwise the route type.
|
|
37
49
|
//
|
|
38
50
|
// Info (advisory, one note per campaign, no code)
|
|
39
51
|
// unknown_attributes[] a data-next-* name the pinned SDK's attribute
|
|
@@ -43,10 +55,11 @@
|
|
|
43
55
|
// own data-next-* hooks, which is why it informs
|
|
44
56
|
// rather than warns.
|
|
45
57
|
//
|
|
46
|
-
// Parsed with parse5 rather than regex because
|
|
47
|
-
// containment (a card inside a selector, a field inside a form,
|
|
48
|
-
// content), and HTML nesting is not
|
|
49
|
-
// walked too: the SDK clones it into
|
|
58
|
+
// Parsed with parse5 rather than regex because five of the seven turn on
|
|
59
|
+
// containment (a card inside a selector, a field inside a form, an action
|
|
60
|
+
// inside its upsell container, a template's content), and HTML nesting is not
|
|
61
|
+
// a regular language. Template content is walked too: the SDK clones it into
|
|
62
|
+
// the live DOM.
|
|
50
63
|
//
|
|
51
64
|
// Pure: callers hand in built HTML. Both doctor entry points drive it.
|
|
52
65
|
|
|
@@ -57,6 +70,9 @@ import {
|
|
|
57
70
|
isIndexedSdkAttribute,
|
|
58
71
|
isKnownCheckoutFieldName,
|
|
59
72
|
} from "./sdk-attribute-index.mjs";
|
|
73
|
+
import { builtPageTypeMeta } from "./upsell-selector-scope.mjs";
|
|
74
|
+
|
|
75
|
+
const normalizedPageTypeValue = (type) => (type == null ? null : String(type).trim().toLowerCase());
|
|
60
76
|
|
|
61
77
|
export const SDK_MARKUP = "built_output.sdk_markup";
|
|
62
78
|
|
|
@@ -65,8 +81,10 @@ export const SDK_MARKUP_CODES = Object.freeze({
|
|
|
65
81
|
CHECKOUT_NOT_FORM: { code: `${SDK_MARKUP}.checkout_not_form`, severity: "error" },
|
|
66
82
|
WRONG_FIELD_NAME: { code: `${SDK_MARKUP}.wrong_field_name`, severity: "error" },
|
|
67
83
|
MISSING_SELECTOR_ID_MATCH: { code: `${SDK_MARKUP}.missing_selector_id_match`, severity: "error" },
|
|
84
|
+
ORPHANED_UPSELL_ACTION: { code: `${SDK_MARKUP}.orphaned_upsell_action`, severity: "error" },
|
|
68
85
|
DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
|
|
69
86
|
TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
|
|
87
|
+
CHECKOUT_BUMP_IS_UPSELL: { code: `${SDK_MARKUP}.checkout_bump_is_upsell`, severity: "warning" },
|
|
70
88
|
// Unknown data-next-* names are not a finding and carry no code: they are
|
|
71
89
|
// information on the gate (unknown_attributes[]) and one advisory ready line.
|
|
72
90
|
});
|
|
@@ -142,11 +160,22 @@ function describe(entry) {
|
|
|
142
160
|
return `${bits.join("")}>`;
|
|
143
161
|
}
|
|
144
162
|
|
|
163
|
+
function describeBump(entry) {
|
|
164
|
+
const id = entry.attrs.get("id");
|
|
165
|
+
const packageId = entry.attrs.get("data-next-package-id");
|
|
166
|
+
const bits = [`<${entry.tag}`];
|
|
167
|
+
if (id) bits.push(` id="${id}"`);
|
|
168
|
+
if (packageId) bits.push(` data-next-package-id="${packageId}"`);
|
|
169
|
+
return `${bits.join("")}>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
145
172
|
/**
|
|
146
173
|
* Scan one built page. Returns findings with { code_name, code, severity,
|
|
147
174
|
* page_id, file, message, detail } and the set of unknown data-next-* names.
|
|
175
|
+
* `page_type` is the route-inferred type, used only when the page declares no
|
|
176
|
+
* live, unambiguous next-page-type meta (builtPageTypeMeta).
|
|
148
177
|
*/
|
|
149
|
-
export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
178
|
+
export function scanPageMarkup({ page_id, file = null, content = "", page_type = null }) {
|
|
150
179
|
const document = parse(String(content || ""));
|
|
151
180
|
const where = file || page_id;
|
|
152
181
|
const findings = [];
|
|
@@ -157,10 +186,13 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
157
186
|
const selectorIds = new Set(); // ids of elements that are themselves a selector
|
|
158
187
|
const templates = []; // { entry, sdkOwned }
|
|
159
188
|
const referencedTemplateIds = new Set();
|
|
189
|
+
const upsellFlagged = []; // entries carrying data-next-is-upsell="true"
|
|
160
190
|
|
|
161
191
|
walkElements(document, (entry) => {
|
|
162
192
|
const { tag, attrs: a, ancestors } = entry;
|
|
163
193
|
|
|
194
|
+
if ((a.get("data-next-is-upsell") || "").trim().toLowerCase() === "true") upsellFlagged.push(entry);
|
|
195
|
+
|
|
164
196
|
for (const [name] of a) {
|
|
165
197
|
if (name.startsWith("data-next-") && !isIndexedSdkAttribute(name)) unknown.add(name);
|
|
166
198
|
if (TEMPLATE_ID_ATTRIBUTES.test(name) && a.get(name)) referencedTemplateIds.add(a.get(name).trim());
|
|
@@ -181,6 +213,18 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
181
213
|
}
|
|
182
214
|
}
|
|
183
215
|
|
|
216
|
+
// An ancestor, not the element itself: the enhancer looks for actions
|
|
217
|
+
// inside the container it binds.
|
|
218
|
+
if (a.has("data-next-upsell-action") && !ancestors.some((anc) => anc.attrs.has("data-next-upsell"))) {
|
|
219
|
+
const value = a.get("data-next-upsell-action") ?? "";
|
|
220
|
+
// The SDK reads add/accept as accepting the offer and skip/decline as
|
|
221
|
+
// declining it; any other value does nothing even inside a container.
|
|
222
|
+
const verb = { add: "accept", accept: "accept", skip: "decline", decline: "decline" }[value.trim().toLowerCase()] || "act on";
|
|
223
|
+
findings.push(finding("ORPHANED_UPSELL_ACTION", page_id, where,
|
|
224
|
+
`${describe(entry)} data-next-upsell-action="${value}" on ${where} has no ancestor carrying data-next-upsell. The SDK binds upsell actions only inside that container, so this one never fires and the shopper cannot ${verb} the offer. Move it inside the data-next-upsell container it belongs to.`,
|
|
225
|
+
{ tag, action: value }));
|
|
226
|
+
}
|
|
227
|
+
|
|
184
228
|
const action = (a.get("data-next-action") || "").trim().toLowerCase();
|
|
185
229
|
if (action === "add-to-cart") {
|
|
186
230
|
addToCartButtons.push({ entry, selectorId: (a.get("data-next-selector-id") || "").trim() || null });
|
|
@@ -263,6 +307,26 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
263
307
|
{ template_id: id || null, snippet }));
|
|
264
308
|
}
|
|
265
309
|
|
|
310
|
+
// One finding per page, naming every flagged element: the repair is the
|
|
311
|
+
// same for each (drop the flag from the bump include's markup; several
|
|
312
|
+
// starter includes write it unconditionally, so an is_upsell=false argument
|
|
313
|
+
// does not always clear it).
|
|
314
|
+
// The page type is read only when a flag is present, so a page without one
|
|
315
|
+
// is not parsed a second time.
|
|
316
|
+
const pageTypeMeta = upsellFlagged.length ? builtPageTypeMeta(content) : null;
|
|
317
|
+
// The live, unambiguous meta wins because it is what the SDK reads; the
|
|
318
|
+
// upsell gate's narrower oto-route rule (builtPageTypeOverRouteGuess) does
|
|
319
|
+
// not apply to a checkout bump.
|
|
320
|
+
const effectivePageType = upsellFlagged.length
|
|
321
|
+
? normalizedPageTypeValue(pageTypeMeta ?? page_type)
|
|
322
|
+
: null;
|
|
323
|
+
if (effectivePageType === "checkout") {
|
|
324
|
+
const elements = upsellFlagged.map(describeBump);
|
|
325
|
+
findings.push(finding("CHECKOUT_BUMP_IS_UPSELL", page_id, where,
|
|
326
|
+
`${elements.length > 1 ? `${elements.length} order bumps` : "An order bump"} on checkout page ${where} (${elements.join(", ")}) ${elements.length > 1 ? "carry" : "carries"} data-next-is-upsell="true". A checkout bump is a pre-purchase add-on; the flag puts it on the initial order as an upsell line. The flag comes from the bump include's markup, and several starter bump includes write it unconditionally, so remove data-next-is-upsell="true" from the include in this campaign unless the line really should be billed as an upsell.`,
|
|
327
|
+
{ page_type: effectivePageType, page_type_source: pageTypeMeta !== null ? "next-page-type" : "route", elements }));
|
|
328
|
+
}
|
|
329
|
+
|
|
266
330
|
return { page_id, file, findings, unknown_attributes: [...unknown].sort() };
|
|
267
331
|
}
|
|
268
332
|
|
|
@@ -106,7 +106,7 @@ function declaredScopeSkip(page, { skipEntry = null, buildScope = null, manifest
|
|
|
106
106
|
id: `dec_page_scope_${page.id}`,
|
|
107
107
|
stage: "prepare_build",
|
|
108
108
|
decision_type: "deterministic_derivation",
|
|
109
|
-
decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); the
|
|
109
|
+
decision: `recorded CampaignSpec page "${page.id}" as template stock, declared out of source scope (${skipEntry ? "explicit source-html manifest skip entry" : 'CampaignSpec build_scope mode "partial"'}); keep the route unbuilt unless the operator opts in to materialising it from ${familyLabel}'s stock page; intake demands no design source for it`,
|
|
110
110
|
confidence: "high",
|
|
111
111
|
template_stock: true,
|
|
112
112
|
template_family: family,
|
|
@@ -138,6 +138,122 @@ function pageRouteForPageKit(value) {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// The SDK meta tags whose content is a route. Doctor checks the same three.
|
|
142
|
+
export const SDK_ROUTING_META_TAGS = [
|
|
143
|
+
"next-success-url",
|
|
144
|
+
"next-upsell-accept-url",
|
|
145
|
+
"next-upsell-decline-url",
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
export const HOST_STRIPPED_CODE = "routing_meta.host_stripped";
|
|
149
|
+
|
|
150
|
+
// A route value that carries a host in front of its path (#531): an older
|
|
151
|
+
// saved Map stored `shop.example.com/route/upsell/` where the route is
|
|
152
|
+
// `/route/upsell/`, and every stage that roots a route then nested the host
|
|
153
|
+
// inside the campaign path. Returns `{ host, from, to }` — `to` is the rooted
|
|
154
|
+
// path with any query and fragment kept — or null when the value is not
|
|
155
|
+
// host-prefixed.
|
|
156
|
+
//
|
|
157
|
+
// Read as host-prefixed:
|
|
158
|
+
// - `http://` or `https://` URLs (any host);
|
|
159
|
+
// - protocol-relative `//<host>/...`;
|
|
160
|
+
// - bare `<host>/...`, where the first segment is followed by "/" and is
|
|
161
|
+
// `localhost`, a valid IPv4 address (each octet 0-255), any name with a
|
|
162
|
+
// `:port`, or a dotted name whose last label is 2-63 letters and not a
|
|
163
|
+
// page or script extension (ROUTE_FILE_EXTENSION: `html`, `htm`,
|
|
164
|
+
// `shtml`, `php`, `asp`, `aspx`, `jsp`, `cgi`; not `pl`, which is also
|
|
165
|
+
// Poland's country-code domain), such as
|
|
166
|
+
// `shop.example.com`. `//<host>/...` takes the same hosts.
|
|
167
|
+
// Everything else is a route and is left to the existing route checks: a
|
|
168
|
+
// rooted `/...` value, a first segment with no dot (`route/x/`), a dotted
|
|
169
|
+
// segment whose last label is not all letters (`v1.2/offer/`), a dotted
|
|
170
|
+
// quad with an octet over 255 (`300.1.2.3/offer/`), a page or script
|
|
171
|
+
// filename (`checkout.html`, `index.php/checkout/`), and a bare host with no
|
|
172
|
+
// path after it.
|
|
173
|
+
// `keepAbsolute: true` leaves an absolute http(s) URL alone: it is a valid
|
|
174
|
+
// SDK routing meta target, and projection already converts an absolute
|
|
175
|
+
// page_url to its path, so doctor accepts both; only the bare and
|
|
176
|
+
// protocol-relative forms count.
|
|
177
|
+
export function parseHostPrefixedRoute(value, { keepAbsolute = false } = {}) {
|
|
178
|
+
if (typeof value !== "string") return null;
|
|
179
|
+
const raw = value.trim();
|
|
180
|
+
if (!raw || (raw.startsWith("/") && !raw.startsWith("//"))) return null;
|
|
181
|
+
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(raw);
|
|
182
|
+
if (scheme) {
|
|
183
|
+
if (keepAbsolute || !/^https?$/i.test(scheme[1])) return null;
|
|
184
|
+
return splitHostPrefix(value, raw.slice(scheme[0].length), { requireHostShape: false });
|
|
185
|
+
}
|
|
186
|
+
if (raw.startsWith("//")) return splitHostPrefix(value, raw.slice(2), { requireHostShape: true });
|
|
187
|
+
return splitHostPrefix(value, raw, { requireHostShape: true, requirePath: true });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function splitHostPrefix(from, rest, { requireHostShape, requirePath = false }) {
|
|
191
|
+
const end = rest.search(/[/?#]/);
|
|
192
|
+
const host = end === -1 ? rest : rest.slice(0, end);
|
|
193
|
+
const tail = end === -1 ? "" : rest.slice(end);
|
|
194
|
+
if (!host || /\s/.test(host)) return null;
|
|
195
|
+
if (requirePath && !tail.startsWith("/")) return null;
|
|
196
|
+
if (requireHostShape && !looksLikeHost(host)) return null;
|
|
197
|
+
return { host, from, to: tail.startsWith("/") ? tail : `/${tail}` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A dotted first segment ending in one of these is a page or script filename
|
|
201
|
+
// (`index.php/checkout/`), not a host.
|
|
202
|
+
const ROUTE_FILE_EXTENSION = /^(?:html?|shtml|php|aspx?|jsp|cgi)$/i;
|
|
203
|
+
|
|
204
|
+
function looksLikeHost(segment) {
|
|
205
|
+
if (/^localhost(?::\d+)?$/i.test(segment)) return true;
|
|
206
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(segment) && segment.split(".").every((octet) => Number(octet) <= 255)) return true;
|
|
207
|
+
if (/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*:\d+$/.test(segment)) return true;
|
|
208
|
+
const labels = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/.test(segment)
|
|
209
|
+
? segment.split(".")
|
|
210
|
+
: null;
|
|
211
|
+
if (!labels) return false;
|
|
212
|
+
const last = labels[labels.length - 1];
|
|
213
|
+
return /^[A-Za-z]{2,63}$/.test(last) && !ROUTE_FILE_EXTENSION.test(last);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Intake normalisation (#531): every host-prefixed page_url, and every
|
|
217
|
+
// host-prefixed SDK routing meta tag value, in funnels[].pages[] and the
|
|
218
|
+
// funnel_pages[] mirror, reduced to its rooted path before anything reads the
|
|
219
|
+
// spec. Returns the spec unchanged (the same object) with no evidence when
|
|
220
|
+
// nothing carries a host; otherwise a copy and one evidence record per changed
|
|
221
|
+
// value, `{ code, page_id, field, from, to }`, where `from` is the value
|
|
222
|
+
// exactly as the Map or file held it.
|
|
223
|
+
export function stripHostPrefixedRoutes(spec) {
|
|
224
|
+
const evidence = [];
|
|
225
|
+
if (!spec || typeof spec !== "object") return { spec, evidence };
|
|
226
|
+
const copy = JSON.parse(JSON.stringify(spec));
|
|
227
|
+
const lists = [];
|
|
228
|
+
if (Array.isArray(copy.funnels)) {
|
|
229
|
+
copy.funnels.forEach((funnel, funnelIndex) => {
|
|
230
|
+
if (Array.isArray(funnel?.pages)) lists.push([`funnels[${funnelIndex}].pages`, funnel.pages]);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (Array.isArray(copy.funnel_pages)) lists.push(["funnel_pages", copy.funnel_pages]);
|
|
234
|
+
for (const [label, pages] of lists) {
|
|
235
|
+
pages.forEach((page, pageIndex) => {
|
|
236
|
+
if (!page || typeof page !== "object") return;
|
|
237
|
+
const at = `${label}[${pageIndex}]`;
|
|
238
|
+
const pageId = typeof page.id === "string" ? page.id : null;
|
|
239
|
+
const stripped = parseHostPrefixedRoute(page.page_url);
|
|
240
|
+
if (stripped) {
|
|
241
|
+
page.page_url = stripped.to;
|
|
242
|
+
evidence.push({ code: HOST_STRIPPED_CODE, page_id: pageId, field: `${at}.page_url`, from: stripped.from, to: stripped.to });
|
|
243
|
+
}
|
|
244
|
+
const metaTags = page.sdk_hints?.meta_tags;
|
|
245
|
+
if (!metaTags || typeof metaTags !== "object" || Array.isArray(metaTags)) return;
|
|
246
|
+
for (const tag of SDK_ROUTING_META_TAGS) {
|
|
247
|
+
const meta = parseHostPrefixedRoute(metaTags[tag], { keepAbsolute: true });
|
|
248
|
+
if (!meta) continue;
|
|
249
|
+
metaTags[tag] = meta.to;
|
|
250
|
+
evidence.push({ code: HOST_STRIPPED_CODE, page_id: pageId, field: `${at}.sdk_hints.meta_tags.${tag}`, from: meta.from, to: meta.to });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return evidence.length ? { spec: copy, evidence } : { spec, evidence };
|
|
255
|
+
}
|
|
256
|
+
|
|
141
257
|
function applyManifestToPages(specPages, manifest, manifestPath, { buildScope = null, templateFamily = null } = {}) {
|
|
142
258
|
const mappings = [];
|
|
143
259
|
const prompts = [];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
|
|
@@ -95,10 +96,16 @@ export function readSourceHtmlManifestFile(sourceRoot, { manifestPath = null } =
|
|
|
95
96
|
return { ...readManifestAt(resolvedPath), explicit: Boolean(explicit) };
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// One read: the manifest is parsed from, and hashed over, the same bytes. An
|
|
100
|
+
// edit that lands on disk after the read changes neither, so the sha256 a
|
|
101
|
+
// consumer records always describes what was parsed (#501).
|
|
98
102
|
function readManifestAt(manifestPath) {
|
|
99
103
|
let manifest;
|
|
104
|
+
let sha256;
|
|
100
105
|
try {
|
|
101
|
-
|
|
106
|
+
const bytes = readFileSync(manifestPath);
|
|
107
|
+
sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
108
|
+
manifest = JSON.parse(bytes.toString("utf8"));
|
|
102
109
|
} catch (error) {
|
|
103
110
|
return {
|
|
104
111
|
manifest: null,
|
|
@@ -125,7 +132,7 @@ function readManifestAt(manifestPath) {
|
|
|
125
132
|
const warnings = (validation.warnings || []).map(
|
|
126
133
|
(entry) => `Source-html manifest at ${manifestPath}: [${entry.code}] ${entry.message}`,
|
|
127
134
|
);
|
|
128
|
-
return { manifest, path: manifestPath, warning: null, warnings, validation };
|
|
135
|
+
return { manifest, path: manifestPath, sha256, warning: null, warnings, validation };
|
|
129
136
|
}
|
|
130
137
|
|
|
131
138
|
function validateManifestPage(entry, index, add, addWarning = () => {}) {
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
4
4
|
import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
|
|
5
5
|
import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
6
|
+
import { withTargetLockSync } from "./target-lock.mjs";
|
|
6
7
|
import {
|
|
7
8
|
ASSEMBLY_REPORT_STAGE_KEYS,
|
|
8
9
|
NEXT_STAGE_CONTRACTS,
|
|
@@ -425,6 +426,16 @@ export function assemblyReportMatchesPacket(report, packet) {
|
|
|
425
426
|
* (waivers, evidence merges) pass no `stage`: they require the report to
|
|
426
427
|
* exist and bind its identity themselves.
|
|
427
428
|
*
|
|
429
|
+
* The read-modify-write runs under the per-target writer lock that
|
|
430
|
+
* prepare-build holds (src/target-lock.mjs, #501), so a stage producer's edit
|
|
431
|
+
* never lands between prepare-build's pre-publish evidence re-check and its
|
|
432
|
+
* publication; inside prepare-build's own critical section it enters
|
|
433
|
+
* directly. A workspace without `targetRepo` (only possible with an explicit
|
|
434
|
+
* refreshDoctor and no stale stamp) names no target to lock and runs as is.
|
|
435
|
+
* `lockBudgetMs` bounds the wait (default: the target lock budget). A caller
|
|
436
|
+
* whose mutate always returns null (a preview) passes `lock: false`: it
|
|
437
|
+
* writes nothing, so it takes no lock and creates no lock files.
|
|
438
|
+
*
|
|
428
439
|
* Returns `{ written, skipped, report, reportPath, doctorOutPath }` where
|
|
429
440
|
* `skipped` is `null`, `"absent"`, `"identity"` or `"unchanged"` and `report`
|
|
430
441
|
* is what is now on disk (the mutated report when written, else the one read,
|
|
@@ -435,6 +446,10 @@ export function commitAssemblyReport(workspace, mutate, {
|
|
|
435
446
|
staleReason = null,
|
|
436
447
|
command = null,
|
|
437
448
|
stage = null,
|
|
449
|
+
lockBudgetMs,
|
|
450
|
+
// lock: false skips the target lock entirely; only for callers that write
|
|
451
|
+
// nothing (the waiver dry-run preview). A real commit must take the lock.
|
|
452
|
+
lock = true,
|
|
438
453
|
} = {}) {
|
|
439
454
|
const hasRefresh = typeof refreshDoctor === "function";
|
|
440
455
|
const hasStale = typeof staleReason === "string" && staleReason.trim();
|
|
@@ -453,6 +468,19 @@ export function commitAssemblyReport(workspace, mutate, {
|
|
|
453
468
|
if (hasRefresh && !doctorOutPath) throw new TypeError("commitAssemblyReport requires a workspace with doctorOutPath to refresh the doctor sidecar.");
|
|
454
469
|
if (hasStale && !targetRepo) throw new TypeError("commitAssemblyReport requires a workspace with targetRepo to stamp the doctor sidecar stale.");
|
|
455
470
|
|
|
471
|
+
const commit = () => commitAssemblyReportUnderLock(workspace, mutate, {
|
|
472
|
+
refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
|
|
473
|
+
});
|
|
474
|
+
if (!targetRepo || lock === false) return commit();
|
|
475
|
+
return withTargetLockSync(targetRepo, commit, {
|
|
476
|
+
command: command.trim(),
|
|
477
|
+
...(lockBudgetMs === undefined ? {} : { budgetMs: lockBudgetMs }),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
482
|
+
refreshDoctor, staleReason, command, stage, hasRefresh, reportPath, doctorOutPath, targetRepo,
|
|
483
|
+
}) {
|
|
456
484
|
const outcome = { written: false, skipped: null, report: null, reportPath, doctorOutPath };
|
|
457
485
|
const finish = () => {
|
|
458
486
|
if (hasRefresh) {
|