@nextcommerce/campaigns-os 1.60.0 → 1.62.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 +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- 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 +5 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +189 -10
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/readback.mjs
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* usually subdirectories of their enclosing campaign repository — to locate the
|
|
11
11
|
* Git directory, and that directory's `logs/HEAD` reflog), plus the CampaignSpec
|
|
12
12
|
* and normalized Campaign Build Brief the Build Packet names, whose current
|
|
13
|
-
* content decides each stage's effective status. It
|
|
13
|
+
* content decides each stage's effective status. It also hashes built output
|
|
14
|
+
* and maps file drift against the recorded assembly manifest to evidence debt.
|
|
15
|
+
* It writes nothing,
|
|
14
16
|
* starts no process, and touches no network. That contract is why the CLI
|
|
15
17
|
* exempts `readback` from lifecycle-journal capture the way it exempts doctor
|
|
16
18
|
* inspection: a command declared read-only must not append a journal entry.
|
|
@@ -53,6 +55,9 @@ import { fileURLToPath } from "node:url";
|
|
|
53
55
|
|
|
54
56
|
import { assessInputCurrency, currentPacketInputs, effectiveStageStatus } from "./input-currency.mjs";
|
|
55
57
|
import { deriveAssemblyReportSummary } from "./stage-ledger.mjs";
|
|
58
|
+
import { assessEvidenceDebt } from "./evidence-debt.mjs";
|
|
59
|
+
import { RUNTIME_DIR_REL_PATH, packetRuntimeRelPath } from "./campaign-workspace.mjs";
|
|
60
|
+
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
56
61
|
|
|
57
62
|
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
58
63
|
export const MAX_GIT_METADATA_BYTES = 64 * 1024;
|
|
@@ -190,7 +195,9 @@ export function readReflogTail(path) {
|
|
|
190
195
|
|
|
191
196
|
// Artifact keys, in render order, with the default location of each artifact
|
|
192
197
|
// relative to the target repository root. The packet lives at the root; the
|
|
193
|
-
// sidecars live under .campaign-runtime
|
|
198
|
+
// sidecars live under .campaign-runtime/, or under the runtime directory the
|
|
199
|
+
// selected packet records (assembly.runtime_dir) when the project holds
|
|
200
|
+
// several funnels.
|
|
194
201
|
export const DEFAULT_RELATIVE_PATHS = {
|
|
195
202
|
packet: "campaign-runtime.build.json",
|
|
196
203
|
doctor: ".campaign-runtime/doctor-output.json",
|
|
@@ -218,8 +225,10 @@ export const RECOGNIZED_SCHEMA_VERSIONS = {
|
|
|
218
225
|
};
|
|
219
226
|
|
|
220
227
|
// Root-level Build Packets other than the default-named one. A second run in
|
|
221
|
-
// the same repository leaves its record under a suffixed name
|
|
222
|
-
//
|
|
228
|
+
// the same repository leaves its record under a suffixed name, and every
|
|
229
|
+
// funnel after a project's first has its packet at
|
|
230
|
+
// campaign-runtime-<route>.build.json; both shapes are discovery candidates
|
|
231
|
+
// when no `--packet` was given.
|
|
223
232
|
const PACKET_CANDIDATE_PATTERN = /^campaign-runtime-.*\.build\.json$/;
|
|
224
233
|
|
|
225
234
|
// Sidecar path some write-ends have used instead of the contracted root home.
|
|
@@ -942,7 +951,7 @@ function renderStaleness(staleness, lines) {
|
|
|
942
951
|
for (const key of staleness.stale_keys) {
|
|
943
952
|
lines.push(` ${ARTIFACT_TITLES[key]} (generated ${formatUtc(staleness.artifacts[key].generated_at)})`);
|
|
944
953
|
}
|
|
945
|
-
lines.push("
|
|
954
|
+
lines.push(" Recorded artifact sections describe the repository as it was when the artifacts");
|
|
946
955
|
lines.push(" were generated, not necessarily as it is now; a new run must regenerate");
|
|
947
956
|
lines.push(" them before this view is current.");
|
|
948
957
|
} else {
|
|
@@ -1284,6 +1293,43 @@ export function readbackInputs(views) {
|
|
|
1284
1293
|
return currentPacketInputs({ packet, packetPath: resolve(views.packet.path) });
|
|
1285
1294
|
}
|
|
1286
1295
|
|
|
1296
|
+
function readbackEvidenceDebt(views) {
|
|
1297
|
+
return assessEvidenceDebt({
|
|
1298
|
+
packet: artifactData(views, "packet"),
|
|
1299
|
+
packetPath: views.packet?.path,
|
|
1300
|
+
report: artifactData(views, "report"),
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function renderEvidenceDebt(debt, lines) {
|
|
1305
|
+
lines.push("EVIDENCE DEBT [the readback's own projection layer: recorded build bindings versus built output]");
|
|
1306
|
+
for (const name of ["polish", "qa"]) {
|
|
1307
|
+
const stage = debt[name];
|
|
1308
|
+
const explanation = stage.status === "current" ? "recorded build matches the current build"
|
|
1309
|
+
: stage.status === "owed" ? "evidence is owed for the current build"
|
|
1310
|
+
: `evidence currency is unknown (${stage.reason}); absence of proof is not no debt`;
|
|
1311
|
+
lines.push(` ${name}: ${stage.status} — ${explanation}`);
|
|
1312
|
+
lines.push(` recorded: ${stage.recorded_build_fingerprint ?? "unknown"}; current: ${stage.current_build_fingerprint ?? "unknown"}`);
|
|
1313
|
+
if (stage.change) {
|
|
1314
|
+
const owes = stage.advisory ? "advisory: reported, not required"
|
|
1315
|
+
: stage.scope === "pages" ? `owed on ${stage.owed_pages.map((page) => oneLine(page.page_id)).join(", ")} (qa run --page)` : "the full stage is owed";
|
|
1316
|
+
lines.push(` change since its build: ${stage.change.kind}; ${owes}`);
|
|
1317
|
+
for (const page of stage.change.changed_pages) lines.push(` changed page: ${oneLine(page.page_id)} (${oneLine(page.built_path)})`);
|
|
1318
|
+
for (const reason of stage.change.reasons) lines.push(` commerce: ${oneLine(reason.code)}${reason.path ? ` (${oneLine(reason.path)})` : ""}`);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const changes = debt.changes;
|
|
1322
|
+
lines.push(` changes against assembly manifest: ${changes.status}${changes.reason ? ` (${changes.reason})` : ""}`);
|
|
1323
|
+
lines.push(` recorded: ${changes.recorded_build_fingerprint ?? "unknown"}; current: ${changes.current_build_fingerprint ?? "unknown"}`);
|
|
1324
|
+
for (const page of changes.changed_pages) lines.push(` changed page: ${oneLine(page.page_id)} (${oneLine(page.built_path)})`);
|
|
1325
|
+
for (const path of changes.unmapped_files) lines.push(` unmapped file: ${oneLine(path)}`);
|
|
1326
|
+
for (const kind of ["extra", "missing", "changed"]) {
|
|
1327
|
+
if (changes[kind].length) lines.push(` ${kind} files: ${changes[kind].map(oneLine).join(", ")}`);
|
|
1328
|
+
}
|
|
1329
|
+
if (changes.status === "known" && !changes.changed_pages.length && !changes.unmapped_files.length) lines.push(" no files differ from the recorded assembly manifest");
|
|
1330
|
+
lines.push(" Page changes use the assembly baseline; older stage manifests are not reconstructed.", "");
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1287
1333
|
// Superseded records shown per stage, newest last (the report keeps five).
|
|
1288
1334
|
const HISTORY_RENDER_CAP = 5;
|
|
1289
1335
|
|
|
@@ -1544,7 +1590,7 @@ function renderFindings(views, lines) {
|
|
|
1544
1590
|
* `inputs` is readbackInputs(views); when omitted, every input reads as not
|
|
1545
1591
|
* readable, so a build, Polish or QA record reads unknown.
|
|
1546
1592
|
*/
|
|
1547
|
-
export function projectReadback(views, staleness = null, packetSelection = null, inputs = null) {
|
|
1593
|
+
export function projectReadback(views, staleness = null, packetSelection = null, inputs = null, evidenceDebt = assessEvidenceDebt()) {
|
|
1548
1594
|
const lines = [
|
|
1549
1595
|
"CAMPAIGNS OS RUN-ARTIFACT READBACK",
|
|
1550
1596
|
"A read-only projection of this run's emitted artifacts. Campaigns OS",
|
|
@@ -1557,6 +1603,7 @@ export function projectReadback(views, staleness = null, packetSelection = null,
|
|
|
1557
1603
|
renderArtifactTable(views, lines, packetSelection);
|
|
1558
1604
|
renderIdentity(views, lines);
|
|
1559
1605
|
renderStages(views, lines, inputs);
|
|
1606
|
+
renderEvidenceDebt(evidenceDebt, lines);
|
|
1560
1607
|
renderContext(views, lines);
|
|
1561
1608
|
renderDoctor(views, lines);
|
|
1562
1609
|
renderVerdict(views, lines);
|
|
@@ -1602,13 +1649,16 @@ export function serializeStaleness(staleness) {
|
|
|
1602
1649
|
/**
|
|
1603
1650
|
* Whether this projection shows nothing the readback can call wrong.
|
|
1604
1651
|
*
|
|
1605
|
-
* True only when all
|
|
1652
|
+
* True only when all six conditions hold: every artifact the readback found is
|
|
1606
1653
|
* loaded and recognized (absent artifacts are not counted against it — a run
|
|
1607
1654
|
* that emitted no findings export is not thereby unclean, while an unreadable
|
|
1608
1655
|
* or unrecognized one always is); the staleness comparison is computable and NO
|
|
1609
1656
|
* loaded artifact is stale; NO loaded artifact recorded a `generated_at` this
|
|
1610
1657
|
* readback could not parse; no cross-artifact divergence was found; and the
|
|
1611
|
-
* doctor output records zero errors
|
|
1658
|
+
* doctor output records zero errors; and no evidence-debt stage is owed
|
|
1659
|
+
* (advisory Polish for a page-only change does not count).
|
|
1660
|
+
* Unknown build binding does not independently change this integrity flag;
|
|
1661
|
+
* callers must read evidence_debt rather than treating clean as no debt.
|
|
1612
1662
|
*
|
|
1613
1663
|
* The fourth of those is the unknown-age rule: an artifact whose recorded age
|
|
1614
1664
|
* did not parse was never shown to be current, and `clean` states that the
|
|
@@ -1626,7 +1676,10 @@ export function serializeStaleness(staleness) {
|
|
|
1626
1676
|
* OS recorded. docs/readback.md states the rule and its limits for callers
|
|
1627
1677
|
* gating on it.
|
|
1628
1678
|
*/
|
|
1629
|
-
export function computeClean(views, staleness) {
|
|
1679
|
+
export function computeClean(views, staleness, evidenceDebt) {
|
|
1680
|
+
if (evidenceDebt == null) throw new TypeError("computeClean requires an evidence-debt assessment");
|
|
1681
|
+
// Advisory Polish (a page-only change) is reported, never required.
|
|
1682
|
+
if ([evidenceDebt?.polish, evidenceDebt?.qa].some((stage) => stage?.status === "owed" && !stage.advisory)) return false;
|
|
1630
1683
|
if (Object.values(views).some((view) => !CLEAN_ARTIFACT_STATES.has(view.state))) return false;
|
|
1631
1684
|
if (!staleness || !staleness.computable || staleness.stale) return false;
|
|
1632
1685
|
if (staleness.unparseable_keys.length) return false;
|
|
@@ -1661,8 +1714,12 @@ function artifactRowDetail(view, staleness) {
|
|
|
1661
1714
|
*
|
|
1662
1715
|
* `packet_selection` and `staleness` are `null` when a programmatic caller
|
|
1663
1716
|
* builds a payload without them; the CLI always supplies both.
|
|
1717
|
+
* `evidenceDebt` is projectTarget's or projectExample's assessment. The default
|
|
1718
|
+
* calls assessEvidenceDebt with no packet inputs, which returns unknown with
|
|
1719
|
+
* packet_unavailable before any filesystem access. Callers with loaded packet
|
|
1720
|
+
* data must supply their computed assessment; this serializer never hashes files.
|
|
1664
1721
|
*/
|
|
1665
|
-
export function buildJsonPayload(views, staleness = null, packetSelection = null) {
|
|
1722
|
+
export function buildJsonPayload(views, staleness = null, packetSelection = null, evidenceDebt = assessEvidenceDebt()) {
|
|
1666
1723
|
return {
|
|
1667
1724
|
schema_version: JSON_SCHEMA_VERSION,
|
|
1668
1725
|
artifacts: Object.values(views).map((view) => ({
|
|
@@ -1676,7 +1733,8 @@ export function buildJsonPayload(views, staleness = null, packetSelection = null
|
|
|
1676
1733
|
doctor: computeDoctorSummary(views),
|
|
1677
1734
|
skip_cascades: computeSkipCascades(views),
|
|
1678
1735
|
divergences: computeDivergences(views),
|
|
1679
|
-
|
|
1736
|
+
evidence_debt: evidenceDebt,
|
|
1737
|
+
clean: computeClean(views, staleness, evidenceDebt),
|
|
1680
1738
|
};
|
|
1681
1739
|
}
|
|
1682
1740
|
|
|
@@ -1792,6 +1850,18 @@ export function selectPacketPath(rootPath, override) {
|
|
|
1792
1850
|
|
|
1793
1851
|
if (considered.length === 1) return chose(considered[0], "sole_candidate");
|
|
1794
1852
|
|
|
1853
|
+
// Packets for different routes are different funnels, each with its own
|
|
1854
|
+
// records: freshness would only say which funnel was set up last, so the
|
|
1855
|
+
// caller names the one to project.
|
|
1856
|
+
const routes = new Map(considered.map((name) => [name, normalizePublicRouteSlug(views.get(name).data?.campaign?.public_route_slug) || null]));
|
|
1857
|
+
if (new Set(routes.values()).size > 1) {
|
|
1858
|
+
const detail = considered.map((name) => `${name} (route ${routes.get(name) || "unnamed"})`).join(", ");
|
|
1859
|
+
throw new ReadbackUsageError(
|
|
1860
|
+
`the target root holds Build Packets for more than one funnel: ${detail}. ` +
|
|
1861
|
+
"Pass --packet to name the funnel's Build Packet to project.",
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1795
1865
|
if (considered.some((name) => times.get(name) === null)) {
|
|
1796
1866
|
const detail = considered
|
|
1797
1867
|
.map((name) => {
|
|
@@ -1826,24 +1896,45 @@ export function selectPacketPath(rootPath, override) {
|
|
|
1826
1896
|
/**
|
|
1827
1897
|
* Return `{ paths, packetSelection, packetView }` for one target root.
|
|
1828
1898
|
*
|
|
1829
|
-
* `packetView` is the Build Packet view
|
|
1830
|
-
*
|
|
1831
|
-
*
|
|
1899
|
+
* `packetView` is the Build Packet view already loaded (by discovery, or here
|
|
1900
|
+
* for an explicit `--packet` that names a file), ready to hand to
|
|
1901
|
+
* loadArtifacts as `preloaded` so no artifact is read twice; it is null when
|
|
1902
|
+
* there is no packet file to read. The sidecar paths sit under the runtime
|
|
1903
|
+
* directory that packet records.
|
|
1832
1904
|
*/
|
|
1833
1905
|
export function resolveProjection(root, overrides = {}) {
|
|
1834
1906
|
const info = statOrNull(root);
|
|
1835
1907
|
if (!info || !info.isDirectory()) {
|
|
1836
1908
|
throw new ReadbackUsageError(`target repository root must name an existing directory: ${root}`);
|
|
1837
1909
|
}
|
|
1838
|
-
const
|
|
1910
|
+
const selected = selectPacketPath(root, overrides.packet);
|
|
1911
|
+
const { path: packetPath, selection } = selected;
|
|
1912
|
+
// The sidecars follow the packet: read it now when discovery did not, so
|
|
1913
|
+
// its runtime directory is known and it is still read only once.
|
|
1914
|
+
const view = selected.view || (isFilePath(packetPath) ? loadArtifact("packet", packetPath) : null);
|
|
1915
|
+
const runtime = packetRuntimeDirFor(view);
|
|
1839
1916
|
const paths = { packet: packetPath };
|
|
1840
1917
|
for (const [key, relative] of Object.entries(DEFAULT_RELATIVE_PATHS)) {
|
|
1841
1918
|
if (key === "packet") continue;
|
|
1842
|
-
paths[key] = overrides[key] ? String(overrides[key]) : join(root, relative);
|
|
1919
|
+
paths[key] = overrides[key] ? String(overrides[key]) : join(root, runtime, relative.slice(RUNTIME_DIR_REL_PATH.length + 1));
|
|
1843
1920
|
}
|
|
1844
1921
|
return { paths, packetSelection: selection, packetView: view };
|
|
1845
1922
|
}
|
|
1846
1923
|
|
|
1924
|
+
// The runtime directory the selected packet records, relative to the root;
|
|
1925
|
+
// the project-level .campaign-runtime when there is no readable packet or it
|
|
1926
|
+
// records none. A value the packet may not carry leaves no way to know whose
|
|
1927
|
+
// records to show, and showing the project-level ones could project another
|
|
1928
|
+
// funnel's, so the readback cannot form a projection.
|
|
1929
|
+
function packetRuntimeDirFor(view) {
|
|
1930
|
+
if (view?.state !== "loaded") return RUNTIME_DIR_REL_PATH;
|
|
1931
|
+
try {
|
|
1932
|
+
return packetRuntimeRelPath(view.data);
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
throw new ReadbackUsageError(`${view.path}: ${error.message}`);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1847
1938
|
/** The artifact paths to project; discovery result discarded. */
|
|
1848
1939
|
export function resolvePaths(root, overrides = {}) {
|
|
1849
1940
|
return resolveProjection(root, overrides).paths;
|
|
@@ -1853,7 +1944,7 @@ export function resolvePaths(root, overrides = {}) {
|
|
|
1853
1944
|
export function projectTarget(root, overrides = {}) {
|
|
1854
1945
|
const { paths, packetSelection, packetView } = resolveProjection(root, overrides);
|
|
1855
1946
|
const views = loadArtifacts(paths, { packet: packetView });
|
|
1856
|
-
return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views) };
|
|
1947
|
+
return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views), evidenceDebt: readbackEvidenceDebt(views) };
|
|
1857
1948
|
}
|
|
1858
1949
|
|
|
1859
1950
|
// The bundled synthetic sample `--example` projects. It is already on the
|
|
@@ -1885,6 +1976,7 @@ export function projectExample() {
|
|
|
1885
1976
|
const { paths, packetSelection, packetView } = resolveProjection(EXAMPLE_ROOT, {});
|
|
1886
1977
|
const views = loadArtifacts(paths, { packet: packetView });
|
|
1887
1978
|
const inputs = readbackInputs(views);
|
|
1979
|
+
const evidenceDebt = readbackEvidenceDebt(views);
|
|
1888
1980
|
for (const view of Object.values(views)) {
|
|
1889
1981
|
const within = view.path.slice(EXAMPLE_ROOT.length).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1890
1982
|
view.path = `${EXAMPLE_RELATIVE_ROOT}/${within}`;
|
|
@@ -1892,7 +1984,7 @@ export function projectExample() {
|
|
|
1892
1984
|
const staleness = assessStaleness(EXAMPLE_ROOT, views, {
|
|
1893
1985
|
headMovement: { time: null, detail: EXAMPLE_HEAD_DETAIL },
|
|
1894
1986
|
});
|
|
1895
|
-
return { views, staleness, packetSelection, inputs };
|
|
1987
|
+
return { views, staleness, packetSelection, inputs, evidenceDebt };
|
|
1896
1988
|
}
|
|
1897
1989
|
|
|
1898
1990
|
const OVERRIDE_FLAGS = {
|
|
@@ -1986,9 +2078,9 @@ export function runReadbackCommand(args) {
|
|
|
1986
2078
|
if (error instanceof ReadbackUsageError) return { exitCode: 2, text: `${error.message}\n` };
|
|
1987
2079
|
throw error;
|
|
1988
2080
|
}
|
|
1989
|
-
const { views, staleness, packetSelection, inputs } = projection;
|
|
2081
|
+
const { views, staleness, packetSelection, inputs, evidenceDebt } = projection;
|
|
1990
2082
|
const text = request.json
|
|
1991
|
-
? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection), null, 2)}\n`
|
|
1992
|
-
: projectReadback(views, staleness, packetSelection, inputs);
|
|
2083
|
+
? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection, evidenceDebt), null, 2)}\n`
|
|
2084
|
+
: projectReadback(views, staleness, packetSelection, inputs, evidenceDebt);
|
|
1993
2085
|
return { exitCode: 0, text };
|
|
1994
2086
|
}
|
package/src/route-identity.mjs
CHANGED
|
@@ -39,6 +39,16 @@ export function normalizePageKitRoute(value) {
|
|
|
39
39
|
return clean ? `${clean}/` : "";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// The public route slug a CampaignSpec names for itself, normalized:
|
|
43
|
+
// spec_identity.public_route_slug, else campaign.slug, else campaign.id. One
|
|
44
|
+
// reading for every command that checks a spec belongs to its packet.
|
|
45
|
+
export function specPublicRouteSlug(spec) {
|
|
46
|
+
for (const value of [spec?.spec_identity?.public_route_slug, spec?.campaign?.slug, spec?.campaign?.id]) {
|
|
47
|
+
if (typeof value === "string" && value.trim()) return normalizePublicRouteSlug(value);
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
// The route with the slug prefix removed, when it carries one: "slug/checkout/"
|
|
43
53
|
// → "checkout/", "slug/" → "", "checkout/" → "checkout/".
|
|
44
54
|
export function stripPublicRoutePrefix(route, publicRouteSlug) {
|
package/src/run-record.mjs
CHANGED
|
@@ -12,13 +12,14 @@ import { localSpecIdentityFields, resolveCampaignIdentity } from "./spec-source-
|
|
|
12
12
|
|
|
13
13
|
import { randomBytes } from "node:crypto";
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
16
16
|
import { ADAPTER_DECISION_STRATEGY_FIELDS } from "./adapter-decision-contract.mjs";
|
|
17
17
|
import { currentBuildFingerprint } from "./polish-gate.mjs";
|
|
18
18
|
import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
|
|
19
19
|
|
|
20
20
|
export const RUN_RECORD_SCHEMA = "campaigns-os-run-record/v0";
|
|
21
21
|
export const RUN_RECORDS_DIR_REL_PATH = ".campaign-runtime/run-records";
|
|
22
|
+
const RUNTIME_DIR_REL_PATH = ".campaign-runtime";
|
|
22
23
|
|
|
23
24
|
// Improvement-surface taxonomy: a list, not an enum — real signal is rarely
|
|
24
25
|
// one surface (see Improvement-Surface Taxonomy in the design).
|
|
@@ -377,12 +378,19 @@ function sanitizeRunId(runId) {
|
|
|
377
378
|
}
|
|
378
379
|
|
|
379
380
|
/**
|
|
380
|
-
* Resolve where a Run Record is written: <baseDir
|
|
381
|
-
*
|
|
382
|
-
*
|
|
381
|
+
* Resolve where a Run Record is written: <baseDir>/<runtime dir>/run-records/
|
|
382
|
+
* <run_id>.json. baseDir is the run root (the CLI passes the packet's
|
|
383
|
+
* directory) so the records sit next to the findings journal. The runtime
|
|
384
|
+
* directory is the packet's (packetRuntimeRelPath in campaign-workspace.mjs):
|
|
385
|
+
* `.campaign-runtime` unless the packet is one of several funnels in its
|
|
386
|
+
* project, so each funnel keeps its own records.
|
|
383
387
|
*/
|
|
384
|
-
export function resolveRunRecordPath(runId, baseDir = process.cwd()) {
|
|
385
|
-
return join(
|
|
388
|
+
export function resolveRunRecordPath(runId, baseDir = process.cwd(), runtimeRelPath = RUNTIME_DIR_REL_PATH) {
|
|
389
|
+
return join(runRecordsDir(baseDir, runtimeRelPath), `${sanitizeRunId(runId)}.json`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function runRecordsDir(baseDir, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
|
|
393
|
+
return join(resolve(baseDir), runtimeRelPath, relative(RUNTIME_DIR_REL_PATH, RUN_RECORDS_DIR_REL_PATH));
|
|
386
394
|
}
|
|
387
395
|
|
|
388
396
|
function extractDoctorObservations(doctor) {
|
|
@@ -758,13 +766,13 @@ export function assembleRunRecord({
|
|
|
758
766
|
* invalid record (a malformed manifest is a build-tooling bug, not run state
|
|
759
767
|
* to swallow). Returns the written path.
|
|
760
768
|
*/
|
|
761
|
-
export function writeRunRecord(record, { baseDir = process.cwd() } = {}) {
|
|
769
|
+
export function writeRunRecord(record, { baseDir = process.cwd(), runtimeRelPath = RUNTIME_DIR_REL_PATH } = {}) {
|
|
762
770
|
const validation = validateRunRecord(record);
|
|
763
771
|
if (!validation.ok) {
|
|
764
772
|
const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
|
|
765
773
|
throw new Error(`Run Record failed validation; refusing to write: ${detail}`);
|
|
766
774
|
}
|
|
767
|
-
const path = resolveRunRecordPath(record.run_id, baseDir);
|
|
775
|
+
const path = resolveRunRecordPath(record.run_id, baseDir, runtimeRelPath);
|
|
768
776
|
mkdirSync(dirname(path), { recursive: true });
|
|
769
777
|
const tmpPath = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
770
778
|
writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
@@ -831,9 +839,9 @@ export function orderRunRecordFileNames(names) {
|
|
|
831
839
|
});
|
|
832
840
|
}
|
|
833
841
|
|
|
834
|
-
export function readRunRecordsForTarget(baseDir) {
|
|
842
|
+
export function readRunRecordsForTarget(baseDir, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
|
|
835
843
|
try {
|
|
836
|
-
const dir =
|
|
844
|
+
const dir = runRecordsDir(baseDir, runtimeRelPath);
|
|
837
845
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
838
846
|
const names = orderRunRecordFileNames(readdirSync(dir).filter((name) => RUN_RECORD_FILE_NAME_PATTERN.test(name)))
|
|
839
847
|
.slice(0, RUN_RECORDS_SCAN_LIMIT);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// history, and which are not. Three kinds share the directory:
|
|
3
3
|
//
|
|
4
4
|
// 1. The readback bundle (build-context, assembly-report, doctor-output,
|
|
5
|
-
// qa-verdict
|
|
5
|
+
// qa-verdict, at the top level or under funnels/<route>/ for a project's
|
|
6
|
+
// second and later funnels) — current readback truth; docs/migration-sidecar-bundle.md
|
|
6
7
|
// names it and `bundle check` validates it. Committed by design.
|
|
7
8
|
// 2. Handoff inputs and agent context (input/, theme/, agent-context/,
|
|
8
9
|
// setup-handoff.json) — durable build handoff. Committed by design.
|
|
@@ -28,6 +29,9 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
28
29
|
".campaign-runtime/agent-deviations.jsonl",
|
|
29
30
|
".campaign-runtime/workflow-findings.jsonl",
|
|
30
31
|
".campaign-runtime/run-records/",
|
|
32
|
+
// A project's second and later funnels keep their Run Records under their
|
|
33
|
+
// own runtime directory (src/campaign-workspace.mjs).
|
|
34
|
+
".campaign-runtime/funnels/*/run-records/",
|
|
31
35
|
".campaign-runtime/fetched-specs/",
|
|
32
36
|
".campaign-runtime/progress/",
|
|
33
37
|
".campaign-runtime/polish-evidence/",
|
|
@@ -47,7 +51,7 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
47
51
|
// must gain it on the next setup command rather than on the next accidental
|
|
48
52
|
// commit. An entry an operator has placed anywhere else in the file counts as
|
|
49
53
|
// present.
|
|
50
|
-
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/"];
|
|
54
|
+
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/", ".campaign-runtime/funnels/*/run-records/"];
|
|
51
55
|
|
|
52
56
|
export const RUNTIME_STATE_IGNORE_BLOCK = [
|
|
53
57
|
RUNTIME_STATE_IGNORE_MARKER,
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// checks (#303).
|
|
3
3
|
//
|
|
4
4
|
// Source: the SDK's own source in NextCommerceCo/campaign-cart at tag
|
|
5
|
-
// v0.4.
|
|
6
|
-
// data-next-* name the SDK's non-test TypeScript names as a
|
|
7
|
-
// `dataset.next*` property it reads or writes, turned back
|
|
8
|
-
// attribute. Only the data-next-* names are kept: the checks that read this
|
|
5
|
+
// v0.4.43; the certified starter families and the SDK support policy still
|
|
6
|
+
// name 0.4.42, whose names are the same: every data-next-* name the SDK's non-test TypeScript names as a
|
|
7
|
+
// string, plus every `dataset.next*` property it reads or writes, turned back
|
|
8
|
+
// into its attribute. Only the data-next-* names are kept: the checks that read this
|
|
9
9
|
// list ask "does the SDK read this data-next-* attribute at all", and the
|
|
10
10
|
// SDK's other attribute families (data-item-*, data-package-*, plain data-*)
|
|
11
11
|
// are not what an agent kit misspells.
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
// whole from the source at a named tag, never edited by hand.
|
|
16
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.
|
|
19
|
-
// git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.
|
|
18
|
+
// { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.43 -- "${paths[@]}"
|
|
19
|
+
// git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.43 -- "${paths[@]}" \
|
|
20
20
|
// | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
|
|
21
21
|
// } | sort -u
|
|
22
22
|
//
|
|
@@ -31,14 +31,14 @@
|
|
|
31
31
|
// data-next-bump-section, data-next-button-text, the variant option state).
|
|
32
32
|
// At v0.4.41 it returns every name of the v0.4.38 list this replaces, plus
|
|
33
33
|
// those and data-next-phone-e164 / data-next-phone-country. At v0.4.42 it
|
|
34
|
-
// returns the same 149 names as at v0.4.41.
|
|
34
|
+
// returns the same 149 names as at v0.4.41, and at v0.4.43 the same again.
|
|
35
35
|
//
|
|
36
36
|
// A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
|
|
37
37
|
// suffix.
|
|
38
38
|
|
|
39
39
|
import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
|
|
40
40
|
|
|
41
|
-
export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.
|
|
41
|
+
export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.43";
|
|
42
42
|
|
|
43
43
|
export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
44
44
|
"data-next-accordion",
|
|
@@ -365,8 +365,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
|
|
|
365
365
|
});
|
|
366
366
|
|
|
367
367
|
// The SDK versions whose renderer files above were verified unchanged from
|
|
368
|
-
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41
|
|
369
|
-
// the eight renderer files cited above plus cart-item-list.enhancer.ts and
|
|
368
|
+
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41, v0.4.42 and
|
|
369
|
+
// v0.4.43, for the eight renderer files cited above plus cart-item-list.enhancer.ts and
|
|
370
370
|
// order-item-list.enhancer.ts). A page whose loader pins another version
|
|
371
371
|
// cannot pass the placeholder check.
|
|
372
|
-
export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42"]);
|
|
372
|
+
export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42", "0.4.43"]);
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -45,6 +45,18 @@
|
|
|
45
45
|
// TEMPLATE_DOUBLE_BRACE `{{` inside an SDK-owned <template>. SDK tokens
|
|
46
46
|
// are single-brace and conditions are no-brace;
|
|
47
47
|
// a double brace renders literally.
|
|
48
|
+
// PAGE_TYPE_ATTRIBUTE data-next-page-type on a page with no
|
|
49
|
+
// <meta name="next-page-type"> (campaigns-os#683).
|
|
50
|
+
// The SDK never reads the attribute. The meta tag
|
|
51
|
+
// is the source every reader uses; a
|
|
52
|
+
// window.nextConfig.pageType reaches the config
|
|
53
|
+
// store only, not the upsell page view, so it
|
|
54
|
+
// does not stand in for the meta here.
|
|
55
|
+
// UPSELL_PAGE_TYPE_MISSING a data-next-upsell container on a page whose
|
|
56
|
+
// next-page-type meta is not "upsell". The SDK
|
|
57
|
+
// records the upsell page view only from that meta
|
|
58
|
+
// tag, so reports show the purchase with no upsell
|
|
59
|
+
// view before it.
|
|
48
60
|
//
|
|
49
61
|
// Not a finding: data-next-is-upsell="true" on a checkout order bump. The
|
|
50
62
|
// selected bump is a line item on the checkout order, tagged as an upsell so
|
|
@@ -91,10 +103,34 @@ export const SDK_MARKUP_CODES = Object.freeze({
|
|
|
91
103
|
RECEIPT_ORDER_ITEMS_CART_VISIBILITY: { code: `${SDK_MARKUP}.receipt_order_items_cart_visibility`, severity: "error" },
|
|
92
104
|
DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
|
|
93
105
|
TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
|
|
106
|
+
PAGE_TYPE_ATTRIBUTE: { code: `${SDK_MARKUP}.page_type_attribute`, severity: "warning" },
|
|
107
|
+
UPSELL_PAGE_TYPE_MISSING: { code: `${SDK_MARKUP}.upsell_page_type_missing`, severity: "warning" },
|
|
94
108
|
// Unknown data-next-* names are not a finding and carry no code: they are
|
|
95
109
|
// information on the gate (unknown_attributes[]) and one advisory ready line.
|
|
96
110
|
});
|
|
97
111
|
|
|
112
|
+
// The page types the SDK has (Campaign Cart v0.4.42 `PageType`), so a remedy
|
|
113
|
+
// never proposes a value the SDK does not have.
|
|
114
|
+
export const SDK_PAGE_TYPES = Object.freeze(["product", "cart", "checkout", "upsell", "receipt"]);
|
|
115
|
+
// Campaign page-type names this repository already reads as one of those, and
|
|
116
|
+
// only those; anything else is left for the operator to name. Sources:
|
|
117
|
+
// - downsell: a post-purchase page (POST_PURCHASE_PAGE_TYPES in
|
|
118
|
+
// upsell-selector-scope.mjs); the SDK records its page view only as upsell.
|
|
119
|
+
// - thank-you, thankyou, confirmation: the receipt role (normalizedPageRole in
|
|
120
|
+
// design-source-package.mjs).
|
|
121
|
+
// - presell, landing, advertorial: pre-checkout pages whose SDK page type is
|
|
122
|
+
// product (PRE_CHECKOUT_PAGE_TYPES in doctor/checks.mjs; normalizedPageRole).
|
|
123
|
+
export const SDK_PAGE_TYPE_ALIASES = Object.freeze({
|
|
124
|
+
downsell: "upsell",
|
|
125
|
+
"thank-you": "receipt", thankyou: "receipt", confirmation: "receipt",
|
|
126
|
+
presell: "product", landing: "product", advertorial: "product",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function sdkPageType(value) {
|
|
130
|
+
const key = String(value || "").trim().toLowerCase();
|
|
131
|
+
return SDK_PAGE_TYPES.includes(key) ? key : SDK_PAGE_TYPE_ALIASES[key] || null;
|
|
132
|
+
}
|
|
133
|
+
|
|
98
134
|
// Attributes whose value names a <template> by id. The SDK reads the template
|
|
99
135
|
// they point at, so that template is SDK-owned wherever it sits.
|
|
100
136
|
export const TEMPLATE_ID_ATTRIBUTES = /^data-(?:next-)?[a-z0-9-]*template-id$/;
|
|
@@ -210,9 +246,48 @@ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version
|
|
|
210
246
|
const templates = []; // { entry, sdkOwned }
|
|
211
247
|
const referencedTemplateIds = new Set();
|
|
212
248
|
let receiptPage = String(page_type || "").trim().toLowerCase() === "receipt";
|
|
213
|
-
|
|
214
|
-
|
|
249
|
+
// The page's own next-page-type meta (first one, as querySelector reads it),
|
|
250
|
+
// the first data-next-page-type element and the first upsell container.
|
|
251
|
+
let pageTypeMeta = null;
|
|
252
|
+
let pageTypeAttribute = null;
|
|
253
|
+
let upsellContainer = null;
|
|
254
|
+
walkElements(document, (entry) => {
|
|
255
|
+
const { tag, attrs: a } = entry;
|
|
256
|
+
if (tag === "meta" && a.get("name")?.trim().toLowerCase() === "next-page-type") {
|
|
257
|
+
if (a.get("content")?.trim().toLowerCase() === "receipt") receiptPage = true;
|
|
258
|
+
if (pageTypeMeta === null) pageTypeMeta = (a.get("content") || "").trim();
|
|
259
|
+
}
|
|
260
|
+
if (!pageTypeAttribute && a.has("data-next-page-type")) pageTypeAttribute = entry;
|
|
261
|
+
if (!upsellContainer && a.has("data-next-upsell")) upsellContainer = entry;
|
|
215
262
|
});
|
|
263
|
+
// The meta value the remedy names: a receipt is "receipt" and an upsell
|
|
264
|
+
// offer page "upsell", whatever the attribute says; otherwise the
|
|
265
|
+
// attribute's SDK page type, or none when it has no SDK name.
|
|
266
|
+
let attributeNamesUpsell = false;
|
|
267
|
+
if (pageTypeAttribute && pageTypeMeta === null) {
|
|
268
|
+
const value = (pageTypeAttribute.attrs.get("data-next-page-type") || "").trim();
|
|
269
|
+
const fromValue = sdkPageType(value);
|
|
270
|
+
const mapped = receiptPage ? "receipt" : upsellContainer ? "upsell" : fromValue;
|
|
271
|
+
attributeNamesUpsell = mapped === "upsell";
|
|
272
|
+
const note = !mapped || !value || value.toLowerCase() === mapped
|
|
273
|
+
? ""
|
|
274
|
+
: fromValue === mapped
|
|
275
|
+
? ` (the SDK's name for "${value}" is "${mapped}")`
|
|
276
|
+
: ` (this page is ${receiptPage ? "a receipt" : "an upsell offer, with a data-next-upsell container"}, so its SDK page type is "${mapped}")`;
|
|
277
|
+
const remedy = mapped
|
|
278
|
+
? `Add <meta name="next-page-type" content="${mapped}"> to the page head${note}.`
|
|
279
|
+
: `Add <meta name="next-page-type"> to the page head with the SDK page type this page is: one of ${SDK_PAGE_TYPES.join(", ")}.`;
|
|
280
|
+
findings.push(finding("PAGE_TYPE_ATTRIBUTE", page_id, where,
|
|
281
|
+
`${describe(pageTypeAttribute)} on ${where} carries data-next-page-type="${value}", which the SDK does not read, and the page has no <meta name="next-page-type">. The SDK takes the page type from that meta tag, so this page runs as the default "product" step${attributeNamesUpsell ? " and the upsell page view is never recorded" : ""}. ${remedy}`,
|
|
282
|
+
{ value, suggested: mapped, element: pageTypeAttribute.tag }));
|
|
283
|
+
}
|
|
284
|
+
// One finding per root cause: skipped only when the attribute finding
|
|
285
|
+
// above already names the upsell meta.
|
|
286
|
+
if (upsellContainer && !attributeNamesUpsell && (pageTypeMeta || "").toLowerCase() !== "upsell" && !receiptPage) {
|
|
287
|
+
findings.push(finding("UPSELL_PAGE_TYPE_MISSING", page_id, where,
|
|
288
|
+
`${describe(upsellContainer)} on ${where} is an upsell offer (data-next-upsell), but the page's next-page-type meta is ${pageTypeMeta === null ? "missing" : `"${pageTypeMeta}"`}. The SDK records the upsell page view only from <meta name="next-page-type" content="upsell">, so reports show the purchase with no upsell view before it. Add that meta tag to the page head.`,
|
|
289
|
+
{ meta_content: pageTypeMeta }));
|
|
290
|
+
}
|
|
216
291
|
|
|
217
292
|
walkElements(document, (entry) => {
|
|
218
293
|
const { tag, attrs: a, ancestors } = entry;
|