@nextcommerce/campaigns-os 1.53.0 → 1.55.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 +145 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -11
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +96 -37
- package/contracts/release-ledger.json +774 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +45 -22
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +4 -6
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +3 -1
- package/docs/qa-and-test-orders.md +83 -14
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +26 -12
- package/src/brand-theme.mjs +44 -8
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/campaign-identity.mjs +33 -4
- package/src/cli.mjs +58 -62
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +28 -8
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +7 -4
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +6 -0
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +6 -1
- package/src/polish-readability.mjs +2 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +595 -95
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +177 -43
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +89 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +15 -3
- package/src/stage-record.mjs +85 -17
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
package/src/run-record.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { randomBytes } from "node:crypto";
|
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, join, resolve } from "node:path";
|
|
16
16
|
import { ADAPTER_DECISION_STRATEGY_FIELDS } from "./adapter-decision-contract.mjs";
|
|
17
|
+
import { currentBuildFingerprint } from "./polish-gate.mjs";
|
|
18
|
+
import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
|
|
17
19
|
|
|
18
20
|
export const RUN_RECORD_SCHEMA = "campaigns-os-run-record/v0";
|
|
19
21
|
export const RUN_RECORDS_DIR_REL_PATH = ".campaign-runtime/run-records";
|
|
@@ -60,6 +62,14 @@ export const RUN_RECORD_REMIT_BASE_KINDS = ["canonical", "loopback", "proxy"];
|
|
|
60
62
|
// to refuse re-posting a verdict the portal already holds.
|
|
61
63
|
export const RUN_RECORD_QA_VERDICT_PUBLISH_STATES = ["skipped", "ok", "failed"];
|
|
62
64
|
export const RUN_RECORD_QA_VERDICT_PUBLISHERS = ["qa run", "qa publish"];
|
|
65
|
+
export const RUN_RECORD_QA_VERDICT_PUBLISH_REASONS = ["loopback_base_url", "portal_managed_default", "consent_off", "flag_opt_out", "flag_opt_in", "default", "local_spec"];
|
|
66
|
+
export const RUN_RECORD_CLOSERS = ["run_end", "qa_auto_end", "stale_sweep", "manual"];
|
|
67
|
+
const RUN_RECORD_FIELDS = new Set([
|
|
68
|
+
"schema_version", "run_id", "package_version", "surface_version", "toolkit_commit", "command", "argv_shape", "created_at",
|
|
69
|
+
"consent_state", "consent_source", "remit_attempted", "remit_ok", "remit_error", "remit_endpoint", "remit_state",
|
|
70
|
+
"remit_result", "remit_base_kind", "qa_verdict_publish", "closed_by", "identity", "artifacts", "observations",
|
|
71
|
+
"surfaces", "primary_surface", "surface_confidence", "lifecycle",
|
|
72
|
+
]);
|
|
63
73
|
|
|
64
74
|
// Required core. Strict here; permissive about optional sub-structures (the
|
|
65
75
|
// validator checks shapes, not nested artifact bodies — those are referenced
|
|
@@ -105,6 +115,12 @@ export function validateRunRecord(record) {
|
|
|
105
115
|
if (record.schema_version != null && record.schema_version !== RUN_RECORD_SCHEMA) {
|
|
106
116
|
add("record.schema_version", `Expected schema_version "${RUN_RECORD_SCHEMA}".`);
|
|
107
117
|
}
|
|
118
|
+
for (const key of Object.keys(record)) {
|
|
119
|
+
if (!RUN_RECORD_FIELDS.has(key)) add(`record.${key}`, `unknown Run Record field "${key}".`);
|
|
120
|
+
}
|
|
121
|
+
if (record.closed_by !== undefined && !RUN_RECORD_CLOSERS.includes(record.closed_by)) {
|
|
122
|
+
add("record.closed_by", `closed_by must be one of: ${RUN_RECORD_CLOSERS.join(", ")}.`);
|
|
123
|
+
}
|
|
108
124
|
|
|
109
125
|
if (record.argv_shape == null || !isStringArray(record.argv_shape)) {
|
|
110
126
|
add("record.argv_shape", "argv_shape is required and must be an array of strings (flag names, never values).");
|
|
@@ -215,25 +231,16 @@ export function validateRunRecord(record) {
|
|
|
215
231
|
const q = obs.qa;
|
|
216
232
|
if (typeof q !== "object" || Array.isArray(q)) {
|
|
217
233
|
add("record.observations.qa", "qa must be an object.");
|
|
218
|
-
} else
|
|
219
|
-
add("record.observations.qa.gap_classes", "gap_classes must be an array of strings.");
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const usage = record.agent_usage;
|
|
227
|
-
if (typeof usage !== "object" || Array.isArray(usage)) {
|
|
228
|
-
add("record.agent_usage", "agent_usage must be an object when present.");
|
|
229
|
-
} else {
|
|
230
|
-
for (const field of ["input_tokens", "output_tokens", "tool_output_tokens", "total_tokens", "elapsed_ms"]) {
|
|
231
|
-
if (usage[field] != null && (!Number.isInteger(usage[field]) || usage[field] < 0)) {
|
|
232
|
-
add(`record.agent_usage.${field}`, `${field} must be a non-negative integer when present.`);
|
|
234
|
+
} else {
|
|
235
|
+
if (q.gap_classes != null && !isStringArray(q.gap_classes)) add("record.observations.qa.gap_classes", "gap_classes must be an array of strings.");
|
|
236
|
+
if (q.verdict_run_id != null && !isNonEmptyString(q.verdict_run_id)) add("record.observations.qa.verdict_run_id", "verdict_run_id must be a non-empty string or null.");
|
|
237
|
+
if (q.build_fingerprint != null && !isNonEmptyString(q.build_fingerprint)) add("record.observations.qa.build_fingerprint", "build_fingerprint must be a non-empty string or null.");
|
|
238
|
+
if (q.stale != null && typeof q.stale !== "boolean") add("record.observations.qa.stale", "stale must be a boolean or null.");
|
|
239
|
+
for (const key of Object.keys(q)) {
|
|
240
|
+
if (!["disposition", "gap_classes", "verdict_run_id", "build_fingerprint", "stale"].includes(key)) add(`record.observations.qa.${key}`, `unknown qa observation field "${key}".`);
|
|
241
|
+
}
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
|
-
if (usage.model != null && typeof usage.model !== "string") add("record.agent_usage.model", "model must be a string when present.");
|
|
236
|
-
if (usage.source != null && typeof usage.source !== "string") add("record.agent_usage.source", "source must be a string when present.");
|
|
237
244
|
}
|
|
238
245
|
}
|
|
239
246
|
|
|
@@ -301,7 +308,10 @@ export function validateQaVerdictPublish(block) {
|
|
|
301
308
|
if (block.published_at != null && !isNonEmptyString(block.published_at)) {
|
|
302
309
|
add("record.qa_verdict_publish.published_at", "published_at must be a non-empty string or null.");
|
|
303
310
|
}
|
|
304
|
-
|
|
311
|
+
if (block.reason != null && !RUN_RECORD_QA_VERDICT_PUBLISH_REASONS.includes(block.reason)) {
|
|
312
|
+
add("record.qa_verdict_publish.reason", `reason must be one of: ${RUN_RECORD_QA_VERDICT_PUBLISH_REASONS.join(", ")} (or null).`);
|
|
313
|
+
}
|
|
314
|
+
const allowed = new Set(["verdict_run_id", "publisher", "attempted", "ok", "error", "endpoint", "state", "result", "base_kind", "published_at", "reason"]);
|
|
305
315
|
for (const key of Object.keys(block)) {
|
|
306
316
|
if (!allowed.has(key)) add(`record.qa_verdict_publish.${key}`, `unknown qa_verdict_publish field "${key}".`);
|
|
307
317
|
}
|
|
@@ -324,6 +334,9 @@ export function validateRunRecordLifecycle(lc) {
|
|
|
324
334
|
if (lc.duration_ms != null && typeof lc.duration_ms !== "number") add("record.lifecycle.duration_ms", "duration_ms must be a number or null.");
|
|
325
335
|
if (lc.wall_clock_duration_ms != null && typeof lc.wall_clock_duration_ms !== "number") add("record.lifecycle.wall_clock_duration_ms", "wall_clock_duration_ms must be a number or null.");
|
|
326
336
|
if (lc.repair_loop_count != null && !Number.isInteger(lc.repair_loop_count)) add("record.lifecycle.repair_loop_count", "repair_loop_count must be an integer or null.");
|
|
337
|
+
for (const field of ["needs_input_count", "failure_count"]) {
|
|
338
|
+
if (lc[field] !== undefined && (!Number.isInteger(lc[field]) || lc[field] < 0)) add(`record.lifecycle.${field}`, `${field} must be a non-negative integer.`);
|
|
339
|
+
}
|
|
327
340
|
if (lc.stages != null) {
|
|
328
341
|
if (!Array.isArray(lc.stages)) {
|
|
329
342
|
add("record.lifecycle.stages", "stages must be an array when present.");
|
|
@@ -334,6 +347,8 @@ export function validateRunRecordLifecycle(lc) {
|
|
|
334
347
|
} else {
|
|
335
348
|
if (stage.duration_ms != null && typeof stage.duration_ms !== "number") add(`record.lifecycle.stages[${index}].duration_ms`, "stage duration_ms must be a number or null.");
|
|
336
349
|
if (stage.exit_status != null && !Number.isInteger(stage.exit_status)) add(`record.lifecycle.stages[${index}].exit_status`, "stage exit_status must be an integer or null.");
|
|
350
|
+
if (stage.wait_ms != null && (!Number.isInteger(stage.wait_ms) || stage.wait_ms < 0)) add(`record.lifecycle.stages[${index}].wait_ms`, "stage wait_ms must be a non-negative integer or null.");
|
|
351
|
+
if (stage.finding_codes !== undefined && (!isStringArray(stage.finding_codes) || stage.finding_codes.length > 5 || new Set(stage.finding_codes).size !== stage.finding_codes.length || !Number.isInteger(stage.exit_status) || stage.exit_status === 0)) add(`record.lifecycle.stages[${index}].finding_codes`, "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
|
|
337
352
|
}
|
|
338
353
|
});
|
|
339
354
|
}
|
|
@@ -395,8 +410,14 @@ function extractSpecValidationRuleIds(doctor) {
|
|
|
395
410
|
return [...new Set(ids)];
|
|
396
411
|
}
|
|
397
412
|
|
|
398
|
-
function extractQaObservations(verdict) {
|
|
413
|
+
function extractQaObservations(verdict, report) {
|
|
399
414
|
if (!verdict || typeof verdict !== "object") return null;
|
|
415
|
+
const verdictRunId = isNonEmptyString(verdict.run_id) ? verdict.run_id : null;
|
|
416
|
+
// The verdict owns the disposition. The report owns QA's build stamp, so
|
|
417
|
+
// only bind the two when its QA stage names this exact verdict.
|
|
418
|
+
const sameVerdict = verdictRunId !== null && report?.stages?.qa?.verdict_run_id === verdictRunId;
|
|
419
|
+
const recorded = sameVerdict ? qaRecordedBuildFingerprint(report) : null;
|
|
420
|
+
const current = currentBuildFingerprint(report);
|
|
400
421
|
const families = new Set();
|
|
401
422
|
for (const exception of Array.isArray(verdict.exceptions) ? verdict.exceptions : []) {
|
|
402
423
|
if (typeof exception?.family === "string") families.add(exception.family);
|
|
@@ -404,6 +425,9 @@ function extractQaObservations(verdict) {
|
|
|
404
425
|
return {
|
|
405
426
|
disposition: typeof verdict.disposition === "string" ? verdict.disposition : null,
|
|
406
427
|
gap_classes: [...families],
|
|
428
|
+
verdict_run_id: verdictRunId,
|
|
429
|
+
build_fingerprint: recorded,
|
|
430
|
+
stale: recorded && current ? !qaRecordedForCurrentBuild(report, current) : null,
|
|
407
431
|
};
|
|
408
432
|
}
|
|
409
433
|
|
|
@@ -465,17 +489,6 @@ function normalizeRemitState(remit) {
|
|
|
465
489
|
return "skipped";
|
|
466
490
|
}
|
|
467
491
|
|
|
468
|
-
function normalizeAgentUsage(usage) {
|
|
469
|
-
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
|
|
470
|
-
const out = {};
|
|
471
|
-
for (const field of ["input_tokens", "output_tokens", "tool_output_tokens", "total_tokens", "elapsed_ms"]) {
|
|
472
|
-
if (Number.isInteger(usage[field]) && usage[field] >= 0) out[field] = usage[field];
|
|
473
|
-
}
|
|
474
|
-
if (isNonEmptyString(usage.model)) out.model = usage.model.trim();
|
|
475
|
-
if (isNonEmptyString(usage.source)) out.source = usage.source.trim();
|
|
476
|
-
return Object.keys(out).length ? out : null;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
492
|
const PRIMARY_SURFACE_PRIORITY = ["platform", "template", "design-source", "spec-rule", "cli", "skill", "docs"];
|
|
480
493
|
|
|
481
494
|
const ADAPTER_DECISION_SURFACE_BY_FIELD = {
|
|
@@ -667,7 +680,7 @@ export function assembleRunRecord({
|
|
|
667
680
|
primarySurface = null,
|
|
668
681
|
surfaceConfidence = null,
|
|
669
682
|
lifecycle = null,
|
|
670
|
-
|
|
683
|
+
closedBy = null,
|
|
671
684
|
qaVerdictPublish = null,
|
|
672
685
|
now = new Date(),
|
|
673
686
|
} = {}) {
|
|
@@ -679,7 +692,7 @@ export function assembleRunRecord({
|
|
|
679
692
|
}
|
|
680
693
|
const adapter = extractAdapterDecisions(selectAdapterDecisions({ packet, report, context }));
|
|
681
694
|
if (adapter) observations.adapter_decisions = adapter;
|
|
682
|
-
const qaObs = extractQaObservations(qaVerdict);
|
|
695
|
+
const qaObs = extractQaObservations(qaVerdict, report);
|
|
683
696
|
if (qaObs) observations.qa = qaObs;
|
|
684
697
|
observations.finding_ids = selectRunFindingIds(journal, runId);
|
|
685
698
|
const malformedJournalLines = Array.isArray(journal?.malformed)
|
|
@@ -732,8 +745,7 @@ export function assembleRunRecord({
|
|
|
732
745
|
}
|
|
733
746
|
if (surfaceConfidence) record.surface_confidence = surfaceConfidence;
|
|
734
747
|
if (lifecycle && typeof lifecycle === "object" && !Array.isArray(lifecycle)) record.lifecycle = lifecycle;
|
|
735
|
-
|
|
736
|
-
if (normalizedUsage) record.agent_usage = normalizedUsage;
|
|
748
|
+
if (RUN_RECORD_CLOSERS.includes(closedBy)) record.closed_by = closedBy;
|
|
737
749
|
// Present only when a publish outcome is known: an absent block reads as
|
|
738
750
|
// "nothing recorded", which is what every record before this field says.
|
|
739
751
|
if (qaVerdictPublish && typeof qaVerdictPublish === "object" && !Array.isArray(qaVerdictPublish)) record.qa_verdict_publish = qaVerdictPublish;
|
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
// The Campaign Cart SDK attribute index, vendored for the static SDK markup
|
|
2
2
|
// checks (#303).
|
|
3
3
|
//
|
|
4
|
-
// Source:
|
|
5
|
-
// v0.4.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// attribute
|
|
9
|
-
//
|
|
4
|
+
// Source: the SDK's own source in NextCommerceCo/campaign-cart at tag
|
|
5
|
+
// v0.4.41 (the SDK pin every certified starter family ships today): every
|
|
6
|
+
// data-next-* name the SDK's non-test TypeScript names as a string, plus every
|
|
7
|
+
// `dataset.next*` property it reads or writes, turned back into its
|
|
8
|
+
// attribute. Only the data-next-* names are kept: the checks that read this
|
|
9
|
+
// list ask "does the SDK read this data-next-* attribute at all", and the
|
|
10
|
+
// SDK's other attribute families (data-item-*, data-package-*, plain data-*)
|
|
11
|
+
// are not what an agent kit misspells.
|
|
10
12
|
//
|
|
11
13
|
// This is a hardcoded copy and says so, rather than restating the SDK in
|
|
12
14
|
// prose that then drifts (the partner kit's mistake): the list is regenerated
|
|
13
|
-
// whole from the
|
|
15
|
+
// whole from the source at a named tag, never edited by hand.
|
|
14
16
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
17
|
+
// paths=('src/**/*.ts' ':!src/**/*.test.ts' ':!src/**/tests/**' ':!src/docs/**')
|
|
18
|
+
// { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.41 -- "${paths[@]}"
|
|
19
|
+
// git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.41 -- "${paths[@]}" \
|
|
20
|
+
// | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
|
|
21
|
+
// } | sort -u
|
|
22
|
+
//
|
|
23
|
+
// Up to v0.4.40 the list came from docs/attribute-index.md, which the SDK
|
|
24
|
+
// generated from its feature manifests. v0.4.41 removed the manifests and the
|
|
25
|
+
// index (campaign-cart 5d81b4eb), and its hand-written attribute docs
|
|
26
|
+
// (docs/sdk-attributes.md, docs/guides/reference/data-attributes.md, each
|
|
27
|
+
// feature's guide/) leave out names the source still reads, such as
|
|
28
|
+
// data-next-package-selector and data-next-remove-item. Run at v0.4.40, the
|
|
29
|
+
// source recipe returns every name the v0.4.40 index listed, plus names the
|
|
30
|
+
// manifests never did (the address block's, data-next-i18n,
|
|
31
|
+
// data-next-bump-section, data-next-button-text, the variant option state).
|
|
32
|
+
// At v0.4.41 it returns every name of the v0.4.38 list this replaces, plus
|
|
33
|
+
// those and data-next-phone-e164 / data-next-phone-country.
|
|
18
34
|
//
|
|
19
35
|
// A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
|
|
20
36
|
// suffix.
|
|
21
37
|
|
|
22
|
-
|
|
38
|
+
import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
|
|
39
|
+
|
|
40
|
+
export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.41";
|
|
23
41
|
|
|
24
42
|
export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
25
43
|
"data-next-accordion",
|
|
@@ -28,8 +46,15 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
28
46
|
"data-next-accordion-trigger",
|
|
29
47
|
"data-next-action",
|
|
30
48
|
"data-next-active",
|
|
49
|
+
"data-next-address",
|
|
50
|
+
"data-next-address-api",
|
|
51
|
+
"data-next-address-field",
|
|
52
|
+
"data-next-address-lang",
|
|
53
|
+
"data-next-address-row",
|
|
54
|
+
"data-next-address-state",
|
|
31
55
|
"data-next-await",
|
|
32
56
|
"data-next-bump",
|
|
57
|
+
"data-next-bump-section",
|
|
33
58
|
"data-next-bundle-card",
|
|
34
59
|
"data-next-bundle-display",
|
|
35
60
|
"data-next-bundle-id",
|
|
@@ -47,6 +72,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
47
72
|
"data-next-bundle-template-id",
|
|
48
73
|
"data-next-bundle-vouchers",
|
|
49
74
|
"data-next-bundles",
|
|
75
|
+
"data-next-button-text",
|
|
50
76
|
"data-next-cart-item-id",
|
|
51
77
|
"data-next-cart-items",
|
|
52
78
|
"data-next-cart-selector",
|
|
@@ -74,6 +100,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
74
100
|
"data-next-fallback",
|
|
75
101
|
"data-next-format",
|
|
76
102
|
"data-next-hide",
|
|
103
|
+
"data-next-i18n",
|
|
77
104
|
"data-next-id",
|
|
78
105
|
"data-next-in-cart",
|
|
79
106
|
"data-next-include-shipping",
|
|
@@ -98,6 +125,8 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
98
125
|
"data-next-payment-form",
|
|
99
126
|
"data-next-payment-method",
|
|
100
127
|
"data-next-payment-state",
|
|
128
|
+
"data-next-phone-country",
|
|
129
|
+
"data-next-phone-e164",
|
|
101
130
|
"data-next-product-sync",
|
|
102
131
|
"data-next-property",
|
|
103
132
|
"data-next-property-container",
|
|
@@ -138,6 +167,7 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
138
167
|
"data-next-tooltip-offset",
|
|
139
168
|
"data-next-tooltip-placement",
|
|
140
169
|
"data-next-tracking-tag",
|
|
170
|
+
"data-next-unavailable",
|
|
141
171
|
"data-next-upsell",
|
|
142
172
|
"data-next-upsell-action",
|
|
143
173
|
"data-next-upsell-action-for",
|
|
@@ -152,17 +182,21 @@ export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
|
152
182
|
"data-next-url",
|
|
153
183
|
"data-next-validate",
|
|
154
184
|
"data-next-variant-code",
|
|
185
|
+
"data-next-variant-name",
|
|
155
186
|
"data-next-variant-option",
|
|
156
187
|
"data-next-variant-option-template-id",
|
|
157
188
|
"data-next-variant-options",
|
|
158
189
|
"data-next-variant-selector-template-id",
|
|
159
190
|
"data-next-variant-selectors",
|
|
191
|
+
"data-next-variant-value",
|
|
160
192
|
]);
|
|
161
193
|
|
|
162
|
-
// The fixed data-next-checkout-field names
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// v0.4.
|
|
194
|
+
// The fixed data-next-checkout-field names. The SDK maps a field to the order
|
|
195
|
+
// by this value, so an unlisted name is a silent no-op: the input renders, the
|
|
196
|
+
// shopper fills it, nothing reaches the order. Taken at v0.4.38 and unchanged
|
|
197
|
+
// through v0.4.41 (the README's "Standard field names" line is the same; the
|
|
198
|
+
// names v0.4.39 and v0.4.41 added are the version-gated aliases below).
|
|
199
|
+
// Sources: src/features/checkout/README.md ("Standard field names"), the
|
|
166
200
|
// checkout-form enhancer and its validation/persistence tables, the hosted
|
|
167
201
|
// payment field slots (cc-number, cvv, cc-month/cc-year and their exp-*
|
|
168
202
|
// spellings), payment-method, and accepts_marketing. Billing fields are the
|
|
@@ -188,7 +222,7 @@ export const SDK_CHECKOUT_FIELD_NAMES = Object.freeze([
|
|
|
188
222
|
"exp-month",
|
|
189
223
|
"exp-year",
|
|
190
224
|
"cvv",
|
|
191
|
-
// Legacy spellings the README at v0.4.
|
|
225
|
+
// Legacy spellings the README still lists at v0.4.41.
|
|
192
226
|
"card-number",
|
|
193
227
|
"card-expiry",
|
|
194
228
|
"card-cvv",
|
|
@@ -197,6 +231,53 @@ export const SDK_CHECKOUT_FIELD_NAMES = Object.freeze([
|
|
|
197
231
|
|
|
198
232
|
export const SDK_CHECKOUT_FIELD_PREFIXES = Object.freeze(["billing-"]);
|
|
199
233
|
|
|
234
|
+
// Field names a later SDK added, each with the first version that maps it.
|
|
235
|
+
// They are the orders API's own names, which the SDK reads as another
|
|
236
|
+
// spelling of its older one (src/utils/checkout-field-names.ts, SDK_NAME):
|
|
237
|
+
// first_name and last_name from v0.4.39 (v0.4.40 is the same SDK), and
|
|
238
|
+
// phone_number from v0.4.41, whose data-attributes reference ("Field names")
|
|
239
|
+
// makes these the names to write and says fname/lname/phone still work. The
|
|
240
|
+
// older names stay in SDK_CHECKOUT_FIELD_NAMES and are valid on every
|
|
241
|
+
// version. A page whose SDK version is unknown is judged as an earlier SDK:
|
|
242
|
+
// the older name works on every version, so asking for it is always safe,
|
|
243
|
+
// and accepting the newer name there could pass a field that never reaches
|
|
244
|
+
// the order. Kept in step with version_gated_aliases in
|
|
245
|
+
// contracts/campaign-cart-checkout-field-contract.v0.json (a test holds the
|
|
246
|
+
// two equal).
|
|
247
|
+
export const SDK_CHECKOUT_FIELD_NAMES_SINCE = Object.freeze({
|
|
248
|
+
first_name: Object.freeze({ since: "0.4.39", sdk_name: "fname" }),
|
|
249
|
+
last_name: Object.freeze({ since: "0.4.39", sdk_name: "lname" }),
|
|
250
|
+
phone_number: Object.freeze({ since: "0.4.41", sdk_name: "phone" }),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Whether `version` is an exact released SDK version at or after `since`.
|
|
254
|
+
// "Exact released" is campaign-spec's RELEASED_SDK_VERSION_PATTERN, the one
|
|
255
|
+
// doctor's campaigns.json pin check uses, so doctor and standardize agree.
|
|
256
|
+
// Anything else (null, a range, a prerelease, @latest, a v prefix) is not: a
|
|
257
|
+
// prerelease sorts before its release, so 0.4.39-beta.1 does not carry
|
|
258
|
+
// 0.4.39's names.
|
|
259
|
+
export function sdkVersionAtLeast(version, since) {
|
|
260
|
+
const have = typeof version === "string" ? RELEASED_SDK_VERSION_PATTERN.exec(version) : null;
|
|
261
|
+
const need = typeof since === "string" ? RELEASED_SDK_VERSION_PATTERN.exec(since) : null;
|
|
262
|
+
if (!have || !need) return false;
|
|
263
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
264
|
+
const diff = Number(have[index]) - Number(need[index]);
|
|
265
|
+
if (diff !== 0) return diff > 0;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The version-gated entry for a field name, billing- prefix included
|
|
271
|
+
// ("billing-first_name" → first_name's entry), or null.
|
|
272
|
+
export function checkoutFieldNameSince(value) {
|
|
273
|
+
let name = String(value ?? "");
|
|
274
|
+
const prefix = SDK_CHECKOUT_FIELD_PREFIXES.find((candidate) => name.startsWith(candidate));
|
|
275
|
+
if (prefix) name = name.slice(prefix.length);
|
|
276
|
+
if (!Object.hasOwn(SDK_CHECKOUT_FIELD_NAMES_SINCE, name)) return null;
|
|
277
|
+
const entry = SDK_CHECKOUT_FIELD_NAMES_SINCE[name];
|
|
278
|
+
return { name, since: entry.since, sdk_name: `${prefix || ""}${entry.sdk_name}` };
|
|
279
|
+
}
|
|
280
|
+
|
|
200
281
|
const exact = new Set(SDK_DATA_NEXT_ATTRIBUTES.filter((name) => !name.endsWith("-")));
|
|
201
282
|
const prefixes = SDK_DATA_NEXT_ATTRIBUTES.filter((name) => name.endsWith("-"));
|
|
202
283
|
|
|
@@ -205,15 +286,20 @@ export function isIndexedSdkAttribute(name) {
|
|
|
205
286
|
return exact.has(value) || prefixes.some((prefix) => value.startsWith(prefix) && value.length > prefix.length);
|
|
206
287
|
}
|
|
207
288
|
|
|
208
|
-
|
|
289
|
+
// Whether the SDK maps `value`. `sdkVersion` is the page's exact SDK
|
|
290
|
+
// version; without one, a version-gated name is not known (see
|
|
291
|
+
// SDK_CHECKOUT_FIELD_NAMES_SINCE).
|
|
292
|
+
export function isKnownCheckoutFieldName(value, sdkVersion = null) {
|
|
209
293
|
const name = String(value ?? "");
|
|
210
|
-
|
|
211
|
-
|| SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length);
|
|
294
|
+
if (SDK_CHECKOUT_FIELD_NAMES.includes(name)
|
|
295
|
+
|| SDK_CHECKOUT_FIELD_PREFIXES.some((prefix) => name.startsWith(prefix) && name.length > prefix.length)) return true;
|
|
296
|
+
const gated = checkoutFieldNameSince(name);
|
|
297
|
+
return Boolean(gated) && sdkVersionAtLeast(sdkVersion, gated.since);
|
|
212
298
|
}
|
|
213
299
|
|
|
214
300
|
// The Campaign Cart template placeholders, for the raw cart placeholder check
|
|
215
301
|
// (built_output.cart_placeholders). Generated from the SDK renderers at the
|
|
216
|
-
//
|
|
302
|
+
// v0.4.38 (line numbers are at that tag):
|
|
217
303
|
//
|
|
218
304
|
// src/features/cart/cart-summary/cart-summary.renderer.ts:46-83 (bare vars)
|
|
219
305
|
// src/features/cart/cart-summary/cart-summary.line-renderer.ts:150-173,
|
|
@@ -278,6 +364,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
|
|
|
278
364
|
});
|
|
279
365
|
|
|
280
366
|
// The SDK versions whose renderer files above were verified unchanged from
|
|
281
|
-
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39
|
|
282
|
-
//
|
|
283
|
-
|
|
367
|
+
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40 and v0.4.41, for
|
|
368
|
+
// the eight renderer files cited above plus cart-item-list.enhancer.ts and
|
|
369
|
+
// order-item-list.enhancer.ts). A page whose loader pins another version
|
|
370
|
+
// cannot pass the placeholder check.
|
|
371
|
+
export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41"]);
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -22,7 +22,12 @@
|
|
|
22
22
|
// WRONG_FIELD_NAME data-next-checkout-field with a value the SDK
|
|
23
23
|
// does not map (firstName, lastName, zip …). The
|
|
24
24
|
// input renders, the value never reaches the
|
|
25
|
-
// order.
|
|
25
|
+
// order. first_name, last_name and phone_number
|
|
26
|
+
// are judged against the page's SDK version
|
|
27
|
+
// (SDK_CHECKOUT_FIELD_NAMES_SINCE): the page's
|
|
28
|
+
// own exact loader pin, else the campaign's
|
|
29
|
+
// campaigns.json sdk_version, else unknown, which
|
|
30
|
+
// is judged as an earlier SDK.
|
|
26
31
|
// MISSING_SELECTOR_ID_MATCH an add-to-cart button whose data-next-selector-id
|
|
27
32
|
// names no selector on the page. The button waits
|
|
28
33
|
// for a selection that can never arrive.
|
|
@@ -64,8 +69,13 @@
|
|
|
64
69
|
|
|
65
70
|
import { parse } from "parse5";
|
|
66
71
|
|
|
72
|
+
// cart-placeholders.mjs imports this module's template constants as well;
|
|
73
|
+
// both sides read the other's bindings only inside functions, so the cycle
|
|
74
|
+
// is safe at load.
|
|
75
|
+
import { readLoaderPin } from "./cart-placeholders.mjs";
|
|
67
76
|
import {
|
|
68
77
|
SDK_ATTRIBUTE_INDEX_VERSION,
|
|
78
|
+
checkoutFieldNameSince,
|
|
69
79
|
isIndexedSdkAttribute,
|
|
70
80
|
isKnownCheckoutFieldName,
|
|
71
81
|
} from "./sdk-attribute-index.mjs";
|
|
@@ -78,6 +88,7 @@ export const SDK_MARKUP_CODES = Object.freeze({
|
|
|
78
88
|
WRONG_FIELD_NAME: { code: `${SDK_MARKUP}.wrong_field_name`, severity: "error" },
|
|
79
89
|
MISSING_SELECTOR_ID_MATCH: { code: `${SDK_MARKUP}.missing_selector_id_match`, severity: "error" },
|
|
80
90
|
ORPHANED_UPSELL_ACTION: { code: `${SDK_MARKUP}.orphaned_upsell_action`, severity: "error" },
|
|
91
|
+
RECEIPT_ORDER_ITEMS_CART_VISIBILITY: { code: `${SDK_MARKUP}.receipt_order_items_cart_visibility`, severity: "error" },
|
|
81
92
|
DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
|
|
82
93
|
TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
|
|
83
94
|
// Unknown data-next-* names are not a finding and carry no code: they are
|
|
@@ -112,6 +123,31 @@ export const TEMPLATE_CONTAINER_ATTRIBUTES = [
|
|
|
112
123
|
// Elements that ARE a selector an add-to-cart button can link to by id.
|
|
113
124
|
const SELECTOR_ATTRIBUTES = ["data-next-bundle-selector", "data-next-package-selector", "data-next-cart-selector", "data-next-upsell-selector"];
|
|
114
125
|
|
|
126
|
+
function cartVisibilityCondition(entry) {
|
|
127
|
+
return ["data-next-hide", "data-next-show"]
|
|
128
|
+
.find((name) => referencesCartState(entry.attrs.get(name))) || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function referencesCartState(condition) {
|
|
132
|
+
const source = String(condition || "");
|
|
133
|
+
let outside = "";
|
|
134
|
+
let quote = null;
|
|
135
|
+
for (let at = 0; at < source.length; at += 1) {
|
|
136
|
+
const char = source[at];
|
|
137
|
+
if (quote) {
|
|
138
|
+
if (char === "\\") at += 1;
|
|
139
|
+
else if (char === quote) quote = null;
|
|
140
|
+
outside += " ";
|
|
141
|
+
} else if (char === "'" || char === '"' || char === "`") {
|
|
142
|
+
quote = char;
|
|
143
|
+
outside += " ";
|
|
144
|
+
} else {
|
|
145
|
+
outside += char;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return /(?:^|[^\w.])cart\.[A-Za-z_$][\w$]*/i.test(outside);
|
|
149
|
+
}
|
|
150
|
+
|
|
115
151
|
function attrs(node) {
|
|
116
152
|
const map = new Map();
|
|
117
153
|
for (const attr of node.attrs || []) map.set(attr.name.toLowerCase(), attr.value);
|
|
@@ -158,10 +194,13 @@ function describe(entry) {
|
|
|
158
194
|
/**
|
|
159
195
|
* Scan one built page. Returns findings with { code_name, code, severity,
|
|
160
196
|
* page_id, file, message, detail } and the set of unknown data-next-* names.
|
|
197
|
+
* `sdk_version` is the campaign's SDK pin, used when the page carries no
|
|
198
|
+
* exact loader pin of its own.
|
|
161
199
|
*/
|
|
162
|
-
export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
200
|
+
export function scanPageMarkup({ page_id, file = null, content = "", sdk_version = null, page_type = null }) {
|
|
163
201
|
const document = parse(String(content || ""));
|
|
164
202
|
const where = file || page_id;
|
|
203
|
+
const sdk = resolvePageSdkVersion(document, sdk_version);
|
|
165
204
|
const findings = [];
|
|
166
205
|
const unknown = new Set();
|
|
167
206
|
|
|
@@ -170,6 +209,10 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
170
209
|
const selectorIds = new Set(); // ids of elements that are themselves a selector
|
|
171
210
|
const templates = []; // { entry, sdkOwned }
|
|
172
211
|
const referencedTemplateIds = new Set();
|
|
212
|
+
let receiptPage = String(page_type || "").trim().toLowerCase() === "receipt";
|
|
213
|
+
walkElements(document, ({ tag, attrs: a }) => {
|
|
214
|
+
if (tag === "meta" && a.get("name")?.toLowerCase() === "next-page-type" && a.get("content")?.trim().toLowerCase() === "receipt") receiptPage = true;
|
|
215
|
+
});
|
|
173
216
|
|
|
174
217
|
walkElements(document, (entry) => {
|
|
175
218
|
const { tag, attrs: a, ancestors } = entry;
|
|
@@ -187,10 +230,8 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
187
230
|
|
|
188
231
|
if (a.has("data-next-checkout-field")) {
|
|
189
232
|
const value = a.get("data-next-checkout-field") ?? "";
|
|
190
|
-
if (!isKnownCheckoutFieldName(value)) {
|
|
191
|
-
findings.push(
|
|
192
|
-
`data-next-checkout-field="${value}" on ${where} is not a field name the SDK ${SDK_ATTRIBUTE_INDEX_VERSION} maps (${suggestFieldName(value)}). The input renders and the value never reaches the order.`,
|
|
193
|
-
{ value, suggestion: suggestFieldName(value, true) }));
|
|
233
|
+
if (!isKnownCheckoutFieldName(value, sdk.version)) {
|
|
234
|
+
findings.push(wrongFieldName(page_id, where, value, sdk));
|
|
194
235
|
}
|
|
195
236
|
}
|
|
196
237
|
|
|
@@ -206,6 +247,16 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
206
247
|
{ tag, action: value }));
|
|
207
248
|
}
|
|
208
249
|
|
|
250
|
+
if (receiptPage && a.has("data-next-order-items")) {
|
|
251
|
+
const holder = [entry, ...ancestors].find(cartVisibilityCondition);
|
|
252
|
+
if (holder) {
|
|
253
|
+
const conditionAttribute = cartVisibilityCondition(holder);
|
|
254
|
+
findings.push(finding("RECEIPT_ORDER_ITEMS_CART_VISIBILITY", page_id, where,
|
|
255
|
+
`${describe(entry)} on ${where} ${holder === entry ? "carries" : `is inside ${describe(holder)} with`} ${conditionAttribute}="${holder.attrs.get(conditionAttribute)}". After checkout the cart can be empty, so receipt order lines disappear. Remove the cart-state visibility condition from the order-items element and its ancestors.`,
|
|
256
|
+
{ condition_attribute: conditionAttribute, condition: holder.attrs.get(conditionAttribute) }));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
209
260
|
const action = (a.get("data-next-action") || "").trim().toLowerCase();
|
|
210
261
|
if (action === "add-to-cart") {
|
|
211
262
|
addToCartButtons.push({ entry, selectorId: (a.get("data-next-selector-id") || "").trim() || null });
|
|
@@ -291,6 +342,32 @@ export function scanPageMarkup({ page_id, file = null, content = "" }) {
|
|
|
291
342
|
return { page_id, file, findings, unknown_attributes: [...unknown].sort() };
|
|
292
343
|
}
|
|
293
344
|
|
|
345
|
+
// The SDK version a page runs: its own exact loader pin, else the campaign
|
|
346
|
+
// pin handed in, else null (unknown).
|
|
347
|
+
function resolvePageSdkVersion(document, campaignVersion) {
|
|
348
|
+
const page = readLoaderPin(document);
|
|
349
|
+
if (page) return { version: page, source: "loader" };
|
|
350
|
+
const campaign = typeof campaignVersion === "string" && campaignVersion.trim() ? campaignVersion.trim() : null;
|
|
351
|
+
if (campaign) return { version: campaign, source: "campaigns_json" };
|
|
352
|
+
return { version: null, source: null };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function wrongFieldName(page_id, where, value, sdk) {
|
|
356
|
+
const gated = checkoutFieldNameSince(value);
|
|
357
|
+
const versionDetail = { sdk_version: sdk.version, sdk_version_source: sdk.source };
|
|
358
|
+
if (!gated) {
|
|
359
|
+
return finding("WRONG_FIELD_NAME", page_id, where,
|
|
360
|
+
`data-next-checkout-field="${value}" on ${where} is not a field name the SDK ${SDK_ATTRIBUTE_INDEX_VERSION} maps (${suggestFieldName(value)}). The input renders and the value never reaches the order.`,
|
|
361
|
+
{ value, suggestion: suggestFieldName(value, true), ...versionDetail });
|
|
362
|
+
}
|
|
363
|
+
const running = sdk.version
|
|
364
|
+
? `this page loads SDK ${sdk.version} (${sdk.source === "loader" ? "its loader pin" : "the campaigns.json sdk_version"})`
|
|
365
|
+
: "this page's SDK version could not be read (no exact loader pin on the page and no sdk_version in campaigns.json), so it is judged as an earlier SDK";
|
|
366
|
+
return finding("WRONG_FIELD_NAME", page_id, where,
|
|
367
|
+
`data-next-checkout-field="${value}" on ${where} is a field name the SDK maps only from ${gated.since}, and ${running}. The input renders and the value never reaches the order. Use "${gated.sdk_name}", which every SDK version maps, or pin the SDK to ${gated.since} or later.`,
|
|
368
|
+
{ value, suggestion: gated.sdk_name, since: gated.since, ...versionDetail });
|
|
369
|
+
}
|
|
370
|
+
|
|
294
371
|
function finding(codeName, page_id, file, message, detail = {}) {
|
|
295
372
|
const { code, severity } = SDK_MARKUP_CODES[codeName];
|
|
296
373
|
return { code_name: codeName, code, severity, page_id, file, message: `${codeName}: ${message}`, detail };
|
|
@@ -318,11 +395,13 @@ function suggestFieldName(value, bare = false) {
|
|
|
318
395
|
/**
|
|
319
396
|
* Evaluate the SDK markup family over built pages.
|
|
320
397
|
*
|
|
321
|
-
* @param {{ subject: object, pages: Array<{ page_id: string, file?: string|null, content: string }
|
|
398
|
+
* @param {{ subject: object, pages: Array<{ page_id: string, file?: string|null, content: string }>, sdkVersion?: string|null }} input
|
|
399
|
+
* `sdkVersion` is the campaign's SDK pin (campaigns.json sdk_version), the
|
|
400
|
+
* fallback for a page with no exact loader pin of its own.
|
|
322
401
|
*/
|
|
323
|
-
export function evaluateSdkMarkup({ subject, pages = [] } = {}) {
|
|
402
|
+
export function evaluateSdkMarkup({ subject, pages = [], sdkVersion = null } = {}) {
|
|
324
403
|
const resolvedSubject = subject && typeof subject === "object" ? subject : {};
|
|
325
|
-
const base = { id: SDK_MARKUP, scope: SDK_MARKUP, waivable: false, subject: resolvedSubject, waiver: null, sdk_attribute_index_version: SDK_ATTRIBUTE_INDEX_VERSION };
|
|
404
|
+
const base = { id: SDK_MARKUP, scope: SDK_MARKUP, waivable: false, subject: resolvedSubject, waiver: null, sdk_attribute_index_version: SDK_ATTRIBUTE_INDEX_VERSION, campaign_sdk_version: typeof sdkVersion === "string" && sdkVersion.trim() ? sdkVersion.trim() : null };
|
|
326
405
|
const list = Array.isArray(pages) ? pages : [];
|
|
327
406
|
|
|
328
407
|
if (list.length === 0) {
|
|
@@ -333,7 +412,7 @@ export function evaluateSdkMarkup({ subject, pages = [] } = {}) {
|
|
|
333
412
|
const warned = [];
|
|
334
413
|
const unknown = new Map();
|
|
335
414
|
for (const page of list) {
|
|
336
|
-
const scan = scanPageMarkup(page);
|
|
415
|
+
const scan = scanPageMarkup({ ...page, sdk_version: page?.sdk_version ?? sdkVersion });
|
|
337
416
|
for (const item of scan.findings) (item.severity === "error" ? findings : warned).push(item);
|
|
338
417
|
for (const name of scan.unknown_attributes) {
|
|
339
418
|
if (!unknown.has(name)) unknown.set(name, []);
|