@nextcommerce/campaigns-os 1.48.0 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/agents/claude/CLAUDE.md +6 -5
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +254 -2
  13. package/contracts/release-ledger.json +1239 -0
  14. package/contracts/supported-surface.json +4 -4
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +12 -6
  18. package/docs/build-packet.md +101 -12
  19. package/docs/campaign-build-brief.md +25 -1
  20. package/docs/effects.md +6 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +10 -0
  24. package/docs/qa-and-test-orders.md +66 -11
  25. package/docs/runtime-readiness.md +1 -1
  26. package/docs/sdk-storage-compatibility.md +1 -1
  27. package/docs/skills-revision.md +10 -10
  28. package/package.json +1 -1
  29. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  30. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +4 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +5 -4
  40. package/skills/next-campaigns-qa/SKILL.md +6 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +25 -2
  44. package/src/build-brief.mjs +68 -21
  45. package/src/built-site-scope.mjs +39 -6
  46. package/src/built-smoke-qc.mjs +1117 -0
  47. package/src/campaign-identity.mjs +36 -2
  48. package/src/cart-placeholders.mjs +730 -0
  49. package/src/cli.mjs +320 -42
  50. package/src/commercial-journey.mjs +65 -4
  51. package/src/commercial-parity.mjs +6 -1
  52. package/src/doctor/checks.mjs +291 -24
  53. package/src/doctor/inspect.mjs +53 -2
  54. package/src/doctor/next-step.mjs +1 -1
  55. package/src/install-mode.mjs +0 -8
  56. package/src/invocation.mjs +5 -2
  57. package/src/local-preview-policy.mjs +1 -1
  58. package/src/local-proof.mjs +4 -1
  59. package/src/polish-browser.mjs +218 -1
  60. package/src/polish-capture.mjs +1 -1
  61. package/src/polish-media-weight.mjs +492 -0
  62. package/src/polish-node.mjs +96 -4
  63. package/src/progress-node.mjs +5 -1
  64. package/src/qa-binding-evidence.mjs +21 -0
  65. package/src/qa-browser.mjs +338 -97
  66. package/src/qa-content-params.mjs +889 -0
  67. package/src/qa-node.mjs +114 -14
  68. package/src/qa-order-bump.mjs +22 -1
  69. package/src/qa-policy-links.mjs +1019 -0
  70. package/src/qa-tracking-params.mjs +1389 -0
  71. package/src/qa-url-privacy.mjs +168 -0
  72. package/src/qc-accept.mjs +446 -0
  73. package/src/qc-check-registry.mjs +83 -0
  74. package/src/qc-results.mjs +1049 -0
  75. package/src/sdk-attribute-index.mjs +71 -0
  76. package/src/sdk-markup.mjs +2 -2
  77. package/src/sdk-storage-compatibility.mjs +63 -3
  78. package/src/source-prep.mjs +37 -7
  79. package/src/stage-record.mjs +356 -36
  80. package/src/theme-gate.mjs +3 -3
@@ -0,0 +1,730 @@
1
+ // Raw cart placeholders (built_output.cart_placeholders).
2
+ //
3
+ // Flags a known Campaign Cart template placeholder ({item.name}, {subtotal},
4
+ // {package.name} ...) that sits in live built HTML outside every SDK-owned
5
+ // scope, where the shopper sees it as raw text. A sibling doctor gate, kept
6
+ // out of sdk_markup (which withholds warnings while its blockers stand) and
7
+ // out of validateBuiltHtmlStructure (which escalates warnings to errors), so
8
+ // a placeholder is always a warning and never a blocker.
9
+ //
10
+ // Ported from the public starter-template lint:
11
+ // NextCommerceCo/campaign-cart-starter-templates@1bfa99b:
12
+ // scripts/lib/sdk-template-token-lint.mjs:24-60 (findLiveSdkTemplateTokens)
13
+ // Differences from the starter: it reads built HTML instead of Liquid source;
14
+ // it knows every renderer namespace (item, line, discount, property, package,
15
+ // bundle, toggle); live item lists, their declared row templates and the
16
+ // SDK-substituted quantity tokens are owned; only text nodes and the
17
+ // attributes that render or are announced as text are scanned; brace strings
18
+ // that are not known placeholders are review results; and every page reads
19
+ // its SDK pin from its own loader, with page, size, candidate and result caps.
20
+ //
21
+ // Results (one per page and token or shape; doctor issue code
22
+ // built_output.cart_placeholders.<reason_code>):
23
+ // warning live_token known placeholder, unowned
24
+ // review unknown_brace unknown brace shape, unowned
25
+ // review template_selector_unresolved unowned candidate on a page
26
+ // whose item template selector
27
+ // is not an #id
28
+ // pass (page) parsed in full, no unowned
29
+ // candidate, verified pin
30
+ // unexercised (page) sdk_pin_unverified, sdk_pin_unknown, page_unreadable
31
+ // (also nesting deeper than a browser parser builds),
32
+ // page_too_large, page_cap_reached, candidate_cap_reached,
33
+ // finding_cap_reached
34
+ // A page that reached a cap or could not be read never passes, and every
35
+ // result kept on a capped page carries the capped-page member, so none of
36
+ // them is accept-eligible. Unknown brace strings are kept only as a sha256.
37
+ //
38
+ // Pure: callers hand in built HTML. Both doctor entry points drive it.
39
+
40
+ import { createHash } from "node:crypto";
41
+
42
+ import { defaultTreeAdapter, parse } from "parse5";
43
+
44
+ import { aggregateQcResults, buildQcResult } from "./qc-results.mjs";
45
+ import { SDK_TEMPLATE_PLACEHOLDERS, SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS } from "./sdk-attribute-index.mjs";
46
+ import { TEMPLATE_CONTAINER_ATTRIBUTES, TEMPLATE_ID_ATTRIBUTES } from "./sdk-markup.mjs";
47
+
48
+ export const CART_PLACEHOLDERS = "built_output.cart_placeholders";
49
+ export const CART_PLACEHOLDERS_CHECK = "cart_placeholders";
50
+ export const CART_PLACEHOLDERS_PAGE_KEY = "page";
51
+
52
+ // Shared doctor bounds.
53
+ export const CART_PLACEHOLDERS_LIMITS = Object.freeze({
54
+ pages: 500,
55
+ page_bytes: 5 * 1024 * 1024,
56
+ candidates: 2000,
57
+ results: 50,
58
+ });
59
+
60
+ export const CART_PLACEHOLDERS_REASONS = Object.freeze({
61
+ LIVE_TOKEN: "live_token",
62
+ UNKNOWN_BRACE: "unknown_brace",
63
+ TEMPLATE_SELECTOR_UNRESOLVED: "template_selector_unresolved",
64
+ SDK_PIN_UNVERIFIED: "sdk_pin_unverified",
65
+ SDK_PIN_UNKNOWN: "sdk_pin_unknown",
66
+ PAGE_UNREADABLE: "page_unreadable",
67
+ PAGE_TOO_LARGE: "page_too_large",
68
+ PAGE_CAP_REACHED: "page_cap_reached",
69
+ CANDIDATE_CAP_REACHED: "candidate_cap_reached",
70
+ FINDING_CAP_REACHED: "finding_cap_reached",
71
+ });
72
+ const R = CART_PLACEHOLDERS_REASONS;
73
+
74
+ // The token grammar:
75
+ // known a bare cart-summary var or live token ({subtotal}, {quantity},
76
+ // {step}), a {qty} form ({qty}, {qty*2}, {qty+1}, {qty-1}), or
77
+ // {<namespace>.<field>[.<field>...]} at any depth in one of the
78
+ // seven namespaces (every namespaced renderer takes any key path)
79
+ // unknown {name} or {name.field} that is not known
80
+ // Any other brace string is not a candidate. A field is any run of
81
+ // characters other than a brace or the `.` separator, whitespace included:
82
+ // the renderers read every key as /\{([^}]+)\}/ and the package, bundle and
83
+ // toggle keys come from arbitrary JSON keys ({toggle.first-name},
84
+ // {package.product title}).
85
+ // Every single-brace pair is a candidate whatever surrounds it ({item.name}},
86
+ // }{item.name}, {{item.name}, {{{item.name}}, {{{item.name}}}); only the
87
+ // inner pair of an exactly balanced `{{...}}` (one `{` before, one `}` after,
88
+ // and no further brace next to either) is not, being left to sdk_markup's
89
+ // double-brace check.
90
+ const QTY_FORMS = SDK_TEMPLATE_PLACEHOLDERS.quantity_text.qty_forms;
91
+ const FIELD = "[^{}.]+";
92
+ const CANDIDATE = new RegExp(`\\{([A-Za-z_][A-Za-z0-9_]*(?:\\.${FIELD})*|${QTY_FORMS})\\}`, "g");
93
+ const NAMESPACED = new RegExp(`^([A-Za-z_][A-Za-z0-9_]*)(?:\\.${FIELD})+$`);
94
+ const UNKNOWN_SHAPE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)?$/;
95
+ const QTY = new RegExp(`^(?:${QTY_FORMS})$`);
96
+
97
+ const KNOWN_BARE = new Set([...SDK_TEMPLATE_PLACEHOLDERS.cart_summary_vars, ...SDK_TEMPLATE_PLACEHOLDERS.live_tokens]);
98
+ const KNOWN_NAMESPACES = new Set(SDK_TEMPLATE_PLACEHOLDERS.namespaces);
99
+ const VERIFIED_PINS = new Set(SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS);
100
+
101
+ // Attributes whose values render or are announced as text. `value` counts
102
+ // only on a button-like input.
103
+ const TEXT_ATTRIBUTES = new Set(["alt", "title", "placeholder", "aria-label"]);
104
+ const BUTTON_INPUT_TYPES = new Set(["button", "submit", "reset"]);
105
+ // Never painted, never scanned.
106
+ const UNSCANNED_ELEMENTS = new Set(["script", "style"]);
107
+
108
+ // The ownership attributes. An element owns only when its attribute value is
109
+ // exactly one of `values` (case and whitespace included: INCREASE or
110
+ // " increase " owns nothing); with no `values` the SDK selects the attribute
111
+ // by presence ([data-next-remove-item]), so any value owns. The row template
112
+ // an item list reads is resolved separately (indexLiveDocument).
113
+ const P = SDK_TEMPLATE_PLACEHOLDERS;
114
+ const ITEM_TEMPLATE_ID = "data-item-template-id";
115
+ const ITEM_LISTS = P.item_list_containers.map((attribute) => ({ attribute }));
116
+ const owns = (attrs, { attribute, values }) => attrs.has(attribute) && (values == null || values.includes(attrs.get(attribute)));
117
+ const ownsItemList = (attrs) => ITEM_LISTS.some((ownership) => owns(attrs, ownership));
118
+
119
+ // The loader is read only from a <script> a browser runs as a classic or
120
+ // module script (HTML "prepare the script element"): an HTML-namespace
121
+ // <script> outside <template>, <noscript>, SVG and MathML whose type, ASCII
122
+ // whitespace stripped, is absent or empty, a JavaScript MIME type essence or
123
+ // `module` (ASCII case-insensitive). A classic script with `nomodule` does
124
+ // not run, nor does one with both `for` and `event` unless `for` is `window`
125
+ // and `event` is `onload` or `onload()` (whitespace stripped, ASCII
126
+ // case-insensitive). Anything else (application/json, text/plain, importmap,
127
+ // speculationrules, an SVG <script> ...) is no loader.
128
+ const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
129
+ const SCRIPT_BARRIERS = new Set(["template", "noscript"]);
130
+ // MIME Sniffing, "JavaScript MIME type".
131
+ const JAVASCRIPT_MIME_TYPES = new Set([
132
+ "application/ecmascript",
133
+ "application/javascript",
134
+ "application/x-ecmascript",
135
+ "application/x-javascript",
136
+ "text/ecmascript",
137
+ "text/javascript",
138
+ "text/javascript1.0",
139
+ "text/javascript1.1",
140
+ "text/javascript1.2",
141
+ "text/javascript1.3",
142
+ "text/javascript1.4",
143
+ "text/javascript1.5",
144
+ "text/jscript",
145
+ "text/livescript",
146
+ "text/x-ecmascript",
147
+ "text/x-javascript",
148
+ ]);
149
+ const ASCII_WHITESPACE_EDGES = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g;
150
+ const asciiLowercase = (value) => value.replace(/[A-Z]/g, (c) => c.toLowerCase());
151
+
152
+ // The loader is a script fetched over http(s) whose URL path ends in
153
+ // `campaign-cart[@<spec>]/dist/loader.js`: the package segment is the whole
154
+ // path segment (`not-campaign-cart@...` is another package), and an npm scope
155
+ // in front of it, if any, is the SDK's own. Its version is exact only as
156
+ // `<major>.<minor>.<patch>`, with or without a leading `v`.
157
+ const LOADER_PACKAGE = /^campaign-cart(?:@(.*))?$/;
158
+ const LOADER_SCOPES = new Set(["@nextcommerce"]);
159
+ const LOADER_PROTOCOLS = new Set(["http:", "https:"]);
160
+ const EXACT_VERSION = /^v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/;
161
+
162
+ // Nesting deeper than a browser's HTML parser builds (Blink stops nesting at
163
+ // 512) is not the DOM a shopper gets, so such a page is not read.
164
+ export const MAX_ELEMENT_DEPTH = 512;
165
+
166
+ export function isKnownCartPlaceholder(name) {
167
+ const value = String(name ?? "");
168
+ if (KNOWN_BARE.has(value) || QTY.test(value)) return true;
169
+ const namespaced = value.match(NAMESPACED);
170
+ return Boolean(namespaced) && KNOWN_NAMESPACES.has(namespaced[1]);
171
+ }
172
+
173
+ const isCandidate = (name) => isKnownCartPlaceholder(name) || UNKNOWN_SHAPE.test(name);
174
+
175
+ // Whether the brace pair at `index` is the inner pair of an exactly balanced
176
+ // `{{...}}`: one `{` right before it, one `}` right after it, and no other
177
+ // brace of either kind beyond those.
178
+ const isBrace = (char) => char === "{" || char === "}";
179
+ const isDoubleBraced = (text, index, length) =>
180
+ text[index - 1] === "{" && text[index + length] === "}" && !isBrace(text[index - 2]) && !isBrace(text[index + length + 1]);
181
+
182
+ // The page's own URL is not known here; an http(s) stand-in takes its place.
183
+ const PAGE_URL = "https://base.invalid/";
184
+
185
+ // The document base URL (HTML "document base URL"): the frozen base URL of
186
+ // the first HTML <base> in tree order that has an href attribute (template
187
+ // content is not in the tree), else the page URL. That href resolves against
188
+ // the page URL; one that fails to parse, or names a data: or javascript: URL,
189
+ // leaves the page URL. A later <base>, or one without href, is not read.
190
+ export function documentBaseUrl(document) {
191
+ const base = liveElements(document).find((node) => node.namespaceURI === HTML_NAMESPACE && node.tagName === "base" && attrsOf(node).has("href"));
192
+ if (!base) return PAGE_URL;
193
+ let url;
194
+ try {
195
+ url = new URL(attrsOf(base).get("href"), PAGE_URL);
196
+ } catch {
197
+ return PAGE_URL;
198
+ }
199
+ return url.protocol === "data:" || url.protocol === "javascript:" ? PAGE_URL : url.href;
200
+ }
201
+
202
+ // Whether a <script src> is the Campaign Cart loader: `{ version }` (the
203
+ // exact version, or null when it names none) or null when it is not. The URL
204
+ // resolves as the browser resolves it, against the document base URL, so a
205
+ // relative or protocol-relative src lands where the page's <base> puts it, an
206
+ // src that cannot resolve against that base is no loader, and an opaque one
207
+ // (data:, javascript:, blob: ...) keeps its own scheme and is never the
208
+ // loader. Path segments are compared decoded.
209
+ export function campaignCartLoader(src, baseUrl = PAGE_URL) {
210
+ let url;
211
+ try {
212
+ url = new URL(String(src), baseUrl);
213
+ } catch {
214
+ return null;
215
+ }
216
+ if (!LOADER_PROTOCOLS.has(url.protocol)) return null;
217
+ const segments = url.pathname.split("/").map((segment) => {
218
+ try {
219
+ return decodeURIComponent(segment);
220
+ } catch {
221
+ return segment;
222
+ }
223
+ });
224
+ const [pkg, dist, file] = segments.slice(-3);
225
+ if (segments.length < 4 || dist !== "dist" || file !== "loader.js") return null;
226
+ const match = pkg.match(LOADER_PACKAGE);
227
+ if (!match) return null;
228
+ const scope = segments[segments.length - 4];
229
+ if (scope.startsWith("@") && !LOADER_SCOPES.has(scope)) return null;
230
+ const exact = match[1] == null ? null : match[1].match(EXACT_VERSION);
231
+ return { version: exact ? exact[1] : null };
232
+ }
233
+
234
+ // Every element under `root` in document order (template content excluded,
235
+ // it is not live DOM), without recursion.
236
+ function liveElements(root) {
237
+ const out = [];
238
+ const stack = [...(root.childNodes || [])].reverse();
239
+ while (stack.length) {
240
+ const node = stack.pop();
241
+ if (!node.tagName) continue;
242
+ out.push(node);
243
+ if (node.tagName !== "template") for (let i = (node.childNodes || []).length - 1; i >= 0; i -= 1) stack.push(node.childNodes[i]);
244
+ }
245
+ return out;
246
+ }
247
+
248
+ class TooDeep extends Error {}
249
+
250
+ // parse5 with a tree adapter that stops the parse once an element would sit
251
+ // deeper than MAX_ELEMENT_DEPTH (template content counts from its
252
+ // <template>). parse5's own cost grows with the square of the nesting, so the
253
+ // bound has to hold during the parse, not after it. Its error is one
254
+ // isPageReadFailure reads as a page that cannot be read.
255
+ export function parseBounded(html) {
256
+ const templateOf = new WeakMap();
257
+ const check = (parent, node) => {
258
+ if (!node.tagName) return;
259
+ let depth = 1;
260
+ for (let at = parent; at; at = at.parentNode || templateOf.get(at)) {
261
+ if (at.tagName) depth += 1;
262
+ if (depth > MAX_ELEMENT_DEPTH) throw new TooDeep();
263
+ }
264
+ };
265
+ const treeAdapter = {
266
+ ...defaultTreeAdapter,
267
+ appendChild(parent, node) {
268
+ check(parent, node);
269
+ defaultTreeAdapter.appendChild(parent, node);
270
+ },
271
+ insertBefore(parent, node, reference) {
272
+ check(parent, node);
273
+ defaultTreeAdapter.insertBefore(parent, node, reference);
274
+ },
275
+ setTemplateContent(template, content) {
276
+ templateOf.set(content, template);
277
+ defaultTreeAdapter.setTemplateContent(template, content);
278
+ },
279
+ };
280
+ return parse(html, { sourceCodeLocationInfo: true, treeAdapter });
281
+ }
282
+
283
+ // The deepest element nesting, template content included, without recursion.
284
+ function elementDepth(root) {
285
+ let max = 0;
286
+ const stack = [[root, 0]];
287
+ while (stack.length) {
288
+ const [node, depth] = stack.pop();
289
+ if (depth > max) max = depth;
290
+ for (const child of childrenOf(node)) if (child.tagName) stack.push([child, depth + 1]);
291
+ }
292
+ return max;
293
+ }
294
+
295
+ export function cartPlaceholderShapeKey(text) {
296
+ return `sha256:${createHash("sha256").update(String(text)).digest("hex")}`;
297
+ }
298
+
299
+ const attrsOf = (node) => {
300
+ const map = new Map();
301
+ for (const attr of node.attrs || []) map.set(attr.name.toLowerCase(), attr.value);
302
+ return map;
303
+ };
304
+
305
+ const childrenOf = (node) => (node.tagName === "template" && node.content ? node.content.childNodes : node.childNodes) || [];
306
+
307
+ // "classic", "module" or null: how a browser runs a <script> with these
308
+ // attributes, from its type (or, with no type, its language).
309
+ export function scriptKind(attrs) {
310
+ const type = attrs.get("type");
311
+ const language = attrs.get("language");
312
+ let kind;
313
+ if (type === "" || (type == null && (language == null || language === ""))) kind = "text/javascript";
314
+ else if (type != null) kind = type.replace(ASCII_WHITESPACE_EDGES, "");
315
+ else kind = `text/${language}`;
316
+ kind = asciiLowercase(kind);
317
+ if (JAVASCRIPT_MIME_TYPES.has(kind)) return attrs.has("nomodule") || !classicForEventRuns(attrs) ? null : "classic";
318
+ return kind === "module" ? "module" : null;
319
+ }
320
+
321
+ // HTML "prepare the script element": a classic script with both `for` and
322
+ // `event` runs only as the window's onload handler.
323
+ function classicForEventRuns(attrs) {
324
+ if (!attrs.has("for") || !attrs.has("event")) return true;
325
+ const stripped = (name) => asciiLowercase(attrs.get(name).replace(ASCII_WHITESPACE_EDGES, ""));
326
+ return stripped("for") === "window" && (stripped("event") === "onload" || stripped("event") === "onload()");
327
+ }
328
+
329
+ // The <script> elements a browser runs, in document order, without
330
+ // recursion: never below a <template>, a <noscript> or a non-HTML element.
331
+ function executableScripts(document) {
332
+ const out = [];
333
+ const stack = [...(document.childNodes || [])].reverse();
334
+ while (stack.length) {
335
+ const node = stack.pop();
336
+ if (!node.tagName || node.namespaceURI !== HTML_NAMESPACE) continue;
337
+ if (node.tagName === "script") {
338
+ if (scriptKind(attrsOf(node))) out.push(node);
339
+ continue;
340
+ }
341
+ if (SCRIPT_BARRIERS.has(node.tagName)) continue;
342
+ for (let i = (node.childNodes || []).length - 1; i >= 0; i -= 1) stack.push(node.childNodes[i]);
343
+ }
344
+ return out;
345
+ }
346
+
347
+ // The page's SDK pin, read from its own loader <script src> only, among the
348
+ // scripts a browser runs, each src resolved against the document base URL.
349
+ // No loader, any loader with no exact version (none, @latest, a range), or
350
+ // two loaders that disagree give no pin.
351
+ export function readLoaderPin(document) {
352
+ const baseUrl = documentBaseUrl(document);
353
+ const versions = new Set();
354
+ let unpinned = false;
355
+ for (const element of executableScripts(document)) {
356
+ const src = attrsOf(element).get("src");
357
+ const loader = typeof src === "string" ? campaignCartLoader(src, baseUrl) : null;
358
+ if (!loader) continue;
359
+ if (loader.version) versions.add(loader.version);
360
+ else unpinned = true;
361
+ }
362
+ if (unpinned || versions.size !== 1) return null;
363
+ return [...versions][0];
364
+ }
365
+
366
+ // A selector resolved statically: `#` and a CSS identifier (CSS Syntax 3
367
+ // §4.3: non-ASCII code points and escapes included, so `#résumé` and
368
+ // `#\31 row` name the ids `résumé` and `1row`), exact. Returns the id, or
369
+ // null for anything else: a padded selector, `#1row` (no valid selector; the
370
+ // SDK's querySelector throws), or any other selector form.
371
+ const CSS_HEX_DIGIT = /^[0-9A-Fa-f]$/;
372
+ const CSS_WHITESPACE = new Set([" ", "\t", "\n"]);
373
+ const isIdentStart = (char) => char != null && (/^[A-Za-z_]$/.test(char) || char.codePointAt(0) >= 0x80);
374
+ const isIdentChar = (char) => isIdentStart(char) || (char != null && /^[0-9-]$/.test(char));
375
+ const isValidEscape = (first, second) => first === "\\" && second !== "\n";
376
+
377
+ function idOfSelector(selector) {
378
+ // CSS input preprocessing: CR, FF and CR LF are newlines, NUL is U+FFFD.
379
+ const chars = [...String(selector).replace(/\r\n?|\f/g, "\n").replaceAll("\0", "�")];
380
+ if (chars[0] !== "#") return null;
381
+ const [a, b, c] = chars.slice(1, 4);
382
+ const startsIdent = a === "-" ? isIdentStart(b) || b === "-" || isValidEscape(b, c) : isIdentStart(a) || isValidEscape(a, b);
383
+ if (!startsIdent) return null;
384
+ let id = "";
385
+ let at = 1;
386
+ while (at < chars.length) {
387
+ const char = chars[at];
388
+ if (isIdentChar(char)) {
389
+ id += char;
390
+ at += 1;
391
+ } else if (isValidEscape(char, chars[at + 1])) {
392
+ at += 1;
393
+ if (at === chars.length) {
394
+ id += "�";
395
+ break;
396
+ }
397
+ if (!CSS_HEX_DIGIT.test(chars[at])) {
398
+ id += chars[at];
399
+ at += 1;
400
+ continue;
401
+ }
402
+ let hex = "";
403
+ while (hex.length < 6 && at < chars.length && CSS_HEX_DIGIT.test(chars[at])) hex += chars[at++];
404
+ if (CSS_WHITESPACE.has(chars[at])) at += 1;
405
+ const code = Number.parseInt(hex, 16);
406
+ id += code === 0 || (code >= 0xd800 && code <= 0xdfff) || code > 0x10ffff ? "�" : String.fromCodePoint(code);
407
+ } else {
408
+ return null;
409
+ }
410
+ }
411
+ return id;
412
+ }
413
+
414
+ // The live element ids, first in document order wins (getElementById), and
415
+ // the row template each item list reads, as the SDK picks it
416
+ // (cart-item-list.enhancer.ts:21-35, order-item-list.enhancer.ts:26-36): a
417
+ // non-empty data-item-template-id names it by id and the selector is never
418
+ // read; otherwise a non-empty data-item-template-selector names it.
419
+ // Template content is not live DOM.
420
+ function indexLiveDocument(document) {
421
+ const ids = new Map();
422
+ const templateIds = new Map();
423
+ const rowTemplates = [];
424
+ for (const element of liveElements(document)) {
425
+ const attrs = attrsOf(element);
426
+ const id = attrs.get("id");
427
+ if (id && !ids.has(id)) ids.set(id, element);
428
+ for (const [name, value] of attrs) {
429
+ if (TEMPLATE_ID_ATTRIBUTES.test(name) && value && !templateIds.has(value)) templateIds.set(value, name);
430
+ }
431
+ if (!ownsItemList(attrs)) continue;
432
+ if (attrs.get(ITEM_TEMPLATE_ID)) rowTemplates.push({ id: attrs.get(ITEM_TEMPLATE_ID) });
433
+ else if (attrs.get(P.item_template_selector)) rowTemplates.push({ selector: attrs.get(P.item_template_selector) });
434
+ }
435
+ const owned = new Set();
436
+ let unresolved = false;
437
+ for (const { id, selector } of rowTemplates) {
438
+ const selectorId = selector == null ? null : idOfSelector(selector);
439
+ if (selector != null && selectorId == null) {
440
+ unresolved = true;
441
+ continue;
442
+ }
443
+ const target = ids.get(id ?? selectorId);
444
+ if (target) owned.add(target);
445
+ }
446
+ const idTemplates = new Map();
447
+ for (const [id, attribute] of templateIds) {
448
+ const target = ids.get(id);
449
+ if (target) idTemplates.set(target, attribute);
450
+ }
451
+ return { rowTemplates: owned, selectorUnresolved: unresolved, idTemplates };
452
+ }
453
+
454
+ // The SDK template owner nearest an unowned occurrence, for the detail: a
455
+ // container whose direct <template> the SDK clones, or an element a
456
+ // *-template-id attribute names. The placeholder belongs inside that template.
457
+ function sdkOwnerOf(attrs, node, idTemplates) {
458
+ const container = TEMPLATE_CONTAINER_ATTRIBUTES.find((name) => attrs.has(name));
459
+ if (container) return container;
460
+ return idTemplates.get(node) || null;
461
+ }
462
+
463
+ class CandidateCapReached extends Error {}
464
+
465
+ // Scans one parsed page, handing every candidate in a rendered location to
466
+ // `onCandidate` in document order, marked owned or not. Iterative, so page
467
+ // depth never reaches the call stack.
468
+ function scanPage(document, { rowTemplates, idTemplates }, onCandidate) {
469
+ const emit = (text, where, line, ctx, elementPath) => {
470
+ for (const match of text.matchAll(CANDIDATE)) {
471
+ const name = match[1];
472
+ if (!isCandidate(name) || isDoubleBraced(text, match.index, match[0].length)) continue;
473
+ const owned = ctx.template || ctx.itemList || ctx.allowed.has(name) || (ctx.quantityText && QTY.test(name));
474
+ const prefix = text.slice(0, match.index);
475
+ const lineOf = line == null ? null : line + (where === "text" ? prefix.split("\n").length - 1 : 0);
476
+ onCandidate({ text: match[0], name, owned, where, line: lineOf, element_path: elementPath, sdk_owner: ctx.owner });
477
+ }
478
+ };
479
+
480
+ // The scope an element sets up. `self` is the part that covers the
481
+ // element's own attributes too: only a <template> and the row template an
482
+ // item list reads. An item list (innerHTML), quantity text (textContent), a
483
+ // quantity control and a remove-item button (innerHTML) rewrite only what
484
+ // is inside them, so their tokens are owned inside them, never in their own
485
+ // attributes.
486
+ const scopes = (ctx, node, tag, attrs) => {
487
+ const self = { ...ctx };
488
+ if (tag === "template") self.template = true;
489
+ if (rowTemplates.has(node)) self.itemList = true;
490
+ const inner = { ...self };
491
+ if (ownsItemList(attrs)) inner.itemList = true;
492
+ if (owns(attrs, P.quantity_text)) inner.quantityText = true;
493
+ const allowed = [];
494
+ if (owns(attrs, P.quantity_control)) allowed.push(...P.quantity_control.tokens);
495
+ if (owns(attrs, P.remove_item)) allowed.push(...P.remove_item.tokens);
496
+ if (allowed.length) inner.allowed = new Set([...ctx.allowed, ...allowed]);
497
+ inner.owner = sdkOwnerOf(attrs, node, idTemplates) || ctx.owner;
498
+ return { self, inner };
499
+ };
500
+
501
+ // Frames are pushed in reverse so they pop in document order.
502
+ const pushChildren = (stack, node, ctx, path) => {
503
+ const counts = new Map();
504
+ const frames = [];
505
+ for (const child of childrenOf(node)) {
506
+ if (child.nodeName === "#text") {
507
+ frames.push({ node: child, ctx, path });
508
+ continue;
509
+ }
510
+ if (!child.tagName) continue; // comments, doctype
511
+ const tag = child.tagName.toLowerCase();
512
+ const index = (counts.get(tag) || 0) + 1;
513
+ counts.set(tag, index);
514
+ frames.push({ node: child, ctx, path: `${path ? `${path}/` : ""}${tag}[${index}]`, tag });
515
+ }
516
+ for (let i = frames.length - 1; i >= 0; i -= 1) stack.push(frames[i]);
517
+ };
518
+
519
+ const stack = [];
520
+ pushChildren(stack, document, { template: false, itemList: false, quantityText: false, allowed: new Set(), owner: null }, "");
521
+ while (stack.length) {
522
+ const { node, ctx, path, tag } = stack.pop();
523
+ if (!tag) {
524
+ emit(node.value || "", "text", node.sourceCodeLocation?.startLine ?? null, ctx, path);
525
+ continue;
526
+ }
527
+ if (UNSCANNED_ELEMENTS.has(tag)) continue;
528
+ const attrs = attrsOf(node);
529
+ const { self, inner } = scopes(ctx, node, tag, attrs);
530
+ const type = String(attrs.get("type") || "").trim().toLowerCase();
531
+ for (const attr of node.attrs || []) {
532
+ const name = attr.name.toLowerCase();
533
+ const rendered = TEXT_ATTRIBUTES.has(name) || (name === "value" && tag === "input" && BUTTON_INPUT_TYPES.has(type));
534
+ if (!rendered) continue;
535
+ emit(attr.value || "", `attr:${name}`, node.sourceCodeLocation?.attrs?.[attr.name]?.startLine ?? node.sourceCodeLocation?.startLine ?? null, self, path);
536
+ }
537
+ pushChildren(stack, node, inner, path);
538
+ }
539
+ }
540
+
541
+ // The file-system error codes that mean a page cannot be read.
542
+ const READ_ERROR_CODES = new Set([
543
+ "EACCES", "EAGAIN", "EBADF", "EBUSY", "EIO", "EISDIR", "ELOOP", "EMFILE", "ENAMETOOLONG",
544
+ "ENFILE", "ENODEV", "ENOENT", "ENOTDIR", "ENXIO", "EOVERFLOW", "EPERM", "ESTALE", "ETIMEDOUT",
545
+ "ERR_FS_FILE_TOO_LARGE", "ERR_STRING_TOO_LONG",
546
+ ]);
547
+ // The engine's own size and depth bounds (V8 messages).
548
+ const BOUND_RANGE_ERRORS = /^(?:Maximum call stack size exceeded|Invalid string length)$/;
549
+
550
+ // Whether an error is a file-system read failure: one of READ_ERROR_CODES.
551
+ // What probes the file system for the page collection catches only these.
552
+ export function isFileReadFailure(error) {
553
+ return typeof error?.code === "string" && READ_ERROR_CODES.has(error.code);
554
+ }
555
+
556
+ // Whether an error means the page cannot be read or parsed: a file-system
557
+ // error, nesting past MAX_ELEMENT_DEPTH, or the engine's size or depth bound.
558
+ // Anything else (a TypeError, a ReferenceError, an assertion) is a defect and
559
+ // is not one of these.
560
+ export function isPageReadFailure(error) {
561
+ if (error instanceof TooDeep) return true;
562
+ if (error instanceof RangeError) return BOUND_RANGE_ERRORS.test(error.message);
563
+ return isFileReadFailure(error);
564
+ }
565
+
566
+ // One page's raw observation: the pin, and either a page-level outcome or the
567
+ // unowned candidates grouped per token or shape, in first-seen order. A read
568
+ // or parse failure reads the page unreadable; any other error is a defect and
569
+ // throws.
570
+ function observePage(page) {
571
+ try {
572
+ return observeReadablePage(page);
573
+ } catch (error) {
574
+ if (!isPageReadFailure(error)) throw error;
575
+ return { pin: null, outcome: R.PAGE_UNREADABLE };
576
+ }
577
+ }
578
+
579
+ function observeReadablePage(page) {
580
+ if (page.unreadable) return { pin: null, outcome: R.PAGE_UNREADABLE };
581
+ // A page over the size cap may come without its HTML.
582
+ const bytes = Number.isFinite(page.bytes) ? page.bytes : typeof page.content === "string" ? Buffer.byteLength(page.content, "utf8") : null;
583
+ if (bytes != null && bytes > CART_PLACEHOLDERS_LIMITS.page_bytes) return { pin: null, outcome: R.PAGE_TOO_LARGE, bytes };
584
+ if (typeof page.content !== "string") return { pin: null, outcome: R.PAGE_UNREADABLE };
585
+
586
+ let document;
587
+ try {
588
+ document = parseBounded(page.content);
589
+ } catch (error) {
590
+ if (!isPageReadFailure(error)) throw error;
591
+ return { pin: null, outcome: R.PAGE_UNREADABLE, bytes };
592
+ }
593
+ // A backstop for nesting the parser reached by moving nodes.
594
+ if (elementDepth(document) > MAX_ELEMENT_DEPTH) return { pin: null, outcome: R.PAGE_UNREADABLE, bytes };
595
+ const pin = readLoaderPin(document);
596
+ const index = indexLiveDocument(document);
597
+ const groups = new Map();
598
+ const caps = [];
599
+ const unowned = [];
600
+ let candidates = 0;
601
+ // Past the candidate cap the walk stops; what it examined is kept. A page
602
+ // the walk cannot hold (an engine bound) is not read.
603
+ try {
604
+ scanPage(document, index, (candidate) => {
605
+ if (candidates === CART_PLACEHOLDERS_LIMITS.candidates) throw new CandidateCapReached();
606
+ candidates += 1;
607
+ if (!candidate.owned) unowned.push(candidate);
608
+ });
609
+ } catch (error) {
610
+ if (isPageReadFailure(error)) return { pin: null, outcome: R.PAGE_UNREADABLE, bytes };
611
+ if (!(error instanceof CandidateCapReached)) throw error;
612
+ caps.push(R.CANDIDATE_CAP_REACHED);
613
+ }
614
+
615
+ for (const candidate of unowned) {
616
+ const known = isKnownCartPlaceholder(candidate.name);
617
+ const key = known ? candidate.text : cartPlaceholderShapeKey(candidate.text);
618
+ if (!groups.has(key)) {
619
+ if (groups.size >= CART_PLACEHOLDERS_LIMITS.results) {
620
+ if (!caps.includes(R.FINDING_CAP_REACHED)) caps.push(R.FINDING_CAP_REACHED);
621
+ continue;
622
+ }
623
+ const reason = index.selectorUnresolved ? R.TEMPLATE_SELECTOR_UNRESOLVED : known ? R.LIVE_TOKEN : R.UNKNOWN_BRACE;
624
+ groups.set(key, { key, known, token: known ? candidate.text : null, shape_sha256: known ? null : key, reason, occurrences: [] });
625
+ }
626
+ groups.get(key).occurrences.push({ element_path: candidate.element_path, where: candidate.where, line: candidate.line, sdk_owner: candidate.sdk_owner });
627
+ }
628
+ return { pin, bytes, candidates, caps, groups: [...groups.values()] };
629
+ }
630
+
631
+ const capMembersFor = (reasons) => reasons.flatMap((reason) => aggregateQcResults([], { capReason: reason }).members);
632
+
633
+ /**
634
+ * Evaluate the raw cart placeholder check.
635
+ *
636
+ * @param {{
637
+ * subject?: object,
638
+ * pages: Array<{ file: string, content?: string, bytes?: number, unreadable?: boolean }>,
639
+ * measuredAt?: string,
640
+ * }} input `pages` in a stable order; `file` is the page path relative to
641
+ * the doctor target. Pages past the page cap may omit `content`. `subject`
642
+ * names the scanned site for the caller; every result carries its own
643
+ * {check, page, key} subject.
644
+ * @returns {object[]} QC results (src/qc-results.mjs buildQcResult).
645
+ */
646
+ export function evaluateCartPlaceholders({ subject = null, pages = [], measuredAt = new Date().toISOString() } = {}) {
647
+ const results = [];
648
+ const check = CART_PLACEHOLDERS_CHECK;
649
+ const row = ({ page, key, result, reason_code = null, pin = null, observation, occurrences = [], members = [], coverage }) => buildQcResult({
650
+ check,
651
+ leg: "doctor",
652
+ subject: { check, page, key },
653
+ result,
654
+ reason_code,
655
+ state: {
656
+ reason_code: result === "pass" ? null : reason_code,
657
+ sdk_pin: pin,
658
+ occurrences: occurrences.map(({ element_path, where }) => ({ element_path, where })),
659
+ members,
660
+ },
661
+ observation,
662
+ members,
663
+ coverage,
664
+ measured_at: measuredAt,
665
+ });
666
+ const pageObservation = (page, pin, extra = {}) => ({
667
+ page,
668
+ sdk_pin: pin,
669
+ sdk_pin_source: pin ? "loader" : null,
670
+ token: null,
671
+ shape_sha256: null,
672
+ known: false,
673
+ occurrences: [],
674
+ ...extra,
675
+ });
676
+ const pageRow = (page, result, reasonCode, pin, extra, limits = reasonCode && result === "unexercised" ? [reasonCode] : []) => row({
677
+ page,
678
+ key: CART_PLACEHOLDERS_PAGE_KEY,
679
+ result,
680
+ reason_code: reasonCode,
681
+ pin,
682
+ observation: pageObservation(page, pin, extra),
683
+ coverage: limits.length ? { observed: 0, expected: 1, limits } : { observed: 1, expected: 1, limits: [] },
684
+ });
685
+
686
+ (Array.isArray(pages) ? pages : []).forEach((page, index) => {
687
+ const file = String(page?.file ?? "");
688
+ if (index >= CART_PLACEHOLDERS_LIMITS.pages) {
689
+ results.push(pageRow(file, "unexercised", R.PAGE_CAP_REACHED, null, {}));
690
+ return;
691
+ }
692
+ const observed = observePage(page || {});
693
+ if (observed.outcome) {
694
+ results.push(pageRow(file, "unexercised", observed.outcome, observed.pin, observed.bytes == null ? {} : { bytes: observed.bytes }));
695
+ return;
696
+ }
697
+ const { pin, caps, groups } = observed;
698
+ for (const group of groups) {
699
+ const result = group.reason === R.LIVE_TOKEN ? "warning" : "review";
700
+ results.push(row({
701
+ page: file,
702
+ key: group.key,
703
+ result,
704
+ reason_code: group.reason,
705
+ pin,
706
+ occurrences: group.occurrences,
707
+ members: capMembersFor(caps),
708
+ observation: {
709
+ page: file,
710
+ sdk_pin: pin,
711
+ sdk_pin_source: pin ? "loader" : null,
712
+ token: group.token,
713
+ shape_sha256: group.shape_sha256,
714
+ known: group.known,
715
+ occurrences: group.occurrences,
716
+ },
717
+ coverage: caps.length ? { observed: observed.candidates, expected: null, limits: [...caps] } : undefined,
718
+ }));
719
+ }
720
+ const extra = { bytes: observed.bytes, candidates: observed.candidates };
721
+ if (caps.length) {
722
+ results.push(pageRow(file, "unexercised", caps[0], pin, extra, [...caps]));
723
+ } else if (!groups.length) {
724
+ if (pin == null) results.push(pageRow(file, "unexercised", R.SDK_PIN_UNKNOWN, pin, extra));
725
+ else if (!VERIFIED_PINS.has(pin)) results.push(pageRow(file, "unexercised", R.SDK_PIN_UNVERIFIED, pin, extra));
726
+ else results.push(pageRow(file, "pass", null, pin, extra));
727
+ }
728
+ });
729
+ return results;
730
+ }