@nextcommerce/campaigns-os 1.54.0 → 1.57.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 +148 -0
- package/CONTEXT.md +19 -5
- package/README.md +26 -12
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +90 -0
- package/campaign-spec/dist/checkout-flow.js +229 -0
- package/campaign-spec/dist/index.d.ts +1 -0
- package/campaign-spec/dist/index.js +6 -1
- package/campaign-spec/dist/normalize.d.ts +3 -2
- package/campaign-spec/dist/normalize.js +7 -5
- package/campaign-spec/dist/routing.d.ts +6 -3
- package/campaign-spec/dist/routing.js +6 -3
- package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
- package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
- package/campaign-spec/dist/rules/index.d.ts +4 -0
- package/campaign-spec/dist/rules/index.js +12 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
- package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
- package/campaign-spec/dist/rules/schema-version.js +10 -5
- package/campaign-spec/dist/types.d.ts +20 -3
- package/campaign-spec/dist/types.js +3 -2
- package/compatibility.json +2 -2
- package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
- package/contracts/release-ledger.json +1146 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +86 -26
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/effects.md +2 -5
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +133 -29
- package/docs/runtime-readiness.md +8 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/versioning.md +4 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +4 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
- 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 +6 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +21 -6
- package/skills/next-campaigns-qa/SKILL.md +5 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +27 -13
- package/src/brand-theme.mjs +44 -8
- package/src/build-brief.mjs +14 -5
- package/src/campaign-identity.mjs +33 -4
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +156 -111
- package/src/commercial-journey.mjs +25 -2
- package/src/consent.mjs +3 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +8 -8
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +212 -19
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +46 -16
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/invocation.mjs +4 -3
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +116 -12
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +9 -4
- package/src/polish-readability.mjs +2 -2
- package/src/qa-browser.mjs +741 -120
- package/src/qa-cart-entry.mjs +107 -20
- package/src/qa-node.mjs +179 -72
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-markup.mjs +41 -1
- package/src/source-html-intake.mjs +46 -4
- package/src/stage-ledger.mjs +18 -4
- package/src/stage-record.mjs +53 -16
|
@@ -5,9 +5,126 @@ const ACTIONS = Object.freeze([
|
|
|
5
5
|
["accept", "expected_accept_url"],
|
|
6
6
|
]);
|
|
7
7
|
|
|
8
|
+
// The page types of a checkout path, as pageType() spells them (lowercase, `_`
|
|
9
|
+
// and `-` dropped). `checkout` is the page that takes payment; `select` and
|
|
10
|
+
// `checkout_step` lead into it without placing an order (campaigns-os#641).
|
|
11
|
+
const PAYMENT_PAGE_TYPE = "checkout";
|
|
12
|
+
const PRE_PAYMENT_PAGE_TYPES = new Set(["select", "checkoutstep"]);
|
|
13
|
+
|
|
14
|
+
export function isPaymentTopologyPage(page) {
|
|
15
|
+
return pageType(page) === PAYMENT_PAGE_TYPE;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isCheckoutStepTopologyPage(page) {
|
|
19
|
+
return pageType(page) === "checkoutstep";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Follow forward links (expected_next_url) from `start` to the page that takes
|
|
23
|
+
// payment. Null when the walk leaves the topology, loops, or reaches any page
|
|
24
|
+
// that is neither a pre-payment page nor the Checkout.
|
|
25
|
+
export function paymentPageFrom(topology, start) {
|
|
26
|
+
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
let page = start || null;
|
|
29
|
+
while (page && !seen.has(page)) {
|
|
30
|
+
seen.add(page);
|
|
31
|
+
if (isPaymentTopologyPage(page)) return page;
|
|
32
|
+
const key = canonicalHttpUrl(page.expected_next_url);
|
|
33
|
+
const next = key ? pages.find((candidate) => canonicalHttpUrl(candidate?.url) === key) : null;
|
|
34
|
+
if (!next) return null;
|
|
35
|
+
if (!isPaymentTopologyPage(next) && !PRE_PAYMENT_PAGE_TYPES.has(pageType(next))) return null;
|
|
36
|
+
page = next;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The topology's entry pages: flagged is_entry, else those no page links to
|
|
42
|
+
// (offer and receipt pages excluded: an orphaned upsell is not where a shopper
|
|
43
|
+
// starts), else the first page. Never decided by page names.
|
|
44
|
+
function topologyEntryPages(pages) {
|
|
45
|
+
const flagged = pages.filter((page) => page?.is_entry === true);
|
|
46
|
+
if (flagged.length) return flagged;
|
|
47
|
+
const targeted = new Set();
|
|
48
|
+
for (const page of pages) {
|
|
49
|
+
for (const field of ["expected_next_url", "expected_accept_url", "expected_decline_url"]) {
|
|
50
|
+
const key = canonicalHttpUrl(page?.[field]);
|
|
51
|
+
if (key && key !== canonicalHttpUrl(page?.url)) targeted.add(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const roots = pages.filter((page) => {
|
|
55
|
+
const key = canonicalHttpUrl(page?.url);
|
|
56
|
+
const type = pageType(page);
|
|
57
|
+
return !(key && targeted.has(key)) && !OFFER_PAGE_TYPES.has(type) && !RECEIPT_PAGE_TYPES.has(type);
|
|
58
|
+
});
|
|
59
|
+
return roots.length ? roots : pages.slice(0, 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Every Checkout the topology's entry paths reach, in entry order, then any
|
|
63
|
+
// Checkout no entry path reaches (a landing CTA into checkout declares no
|
|
64
|
+
// forward field). "The first page typed checkout" is never the answer on its
|
|
65
|
+
// own: on a multi-step path it is a step, and with split-test paths there are
|
|
66
|
+
// several (campaigns-os#641).
|
|
67
|
+
export function paymentPagesForTopology(topology) {
|
|
68
|
+
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const entry of topologyEntryPages(pages)) {
|
|
71
|
+
const checkout = paymentPageFrom(topology, entry);
|
|
72
|
+
if (checkout && !out.includes(checkout)) out.push(checkout);
|
|
73
|
+
}
|
|
74
|
+
for (const page of pages) if (isPaymentTopologyPage(page) && !out.includes(page)) out.push(page);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The Checkout of the first entry path of the first topology that has one.
|
|
79
|
+
export function findPaymentPage(topologies = []) {
|
|
80
|
+
for (const topology of Array.isArray(topologies) ? topologies : []) {
|
|
81
|
+
const page = paymentPagesForTopology(topology)[0];
|
|
82
|
+
if (page) return page;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The pages a shopper passes through to reach `checkoutPage`, in order and
|
|
88
|
+
// ending with it: an optional select page, then each checkout_step, then the
|
|
89
|
+
// Checkout. Walks backwards over forward links, so it holds whichever path the
|
|
90
|
+
// Checkout sits on; where two pages lead into the same page a checkout_step is
|
|
91
|
+
// preferred over a select page, which can only start the chain.
|
|
92
|
+
export function checkoutPathPages(topology, checkoutPage) {
|
|
93
|
+
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
94
|
+
if (!checkoutPage) return [];
|
|
95
|
+
const chain = [checkoutPage];
|
|
96
|
+
const seen = new Set(chain);
|
|
97
|
+
let current = checkoutPage;
|
|
98
|
+
for (;;) {
|
|
99
|
+
const key = canonicalHttpUrl(current?.url);
|
|
100
|
+
if (!key) break;
|
|
101
|
+
const leading = pages.filter((page) => !seen.has(page)
|
|
102
|
+
&& PRE_PAYMENT_PAGE_TYPES.has(pageType(page))
|
|
103
|
+
&& canonicalHttpUrl(page?.expected_next_url) === key);
|
|
104
|
+
const previous = leading.find(isCheckoutStepTopologyPage) || leading[0] || null;
|
|
105
|
+
if (!previous) break;
|
|
106
|
+
chain.unshift(previous);
|
|
107
|
+
seen.add(previous);
|
|
108
|
+
if (!isCheckoutStepTopologyPage(previous)) break;
|
|
109
|
+
current = previous;
|
|
110
|
+
}
|
|
111
|
+
return chain;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only the checkout_step pages of that path, in order.
|
|
115
|
+
export function checkoutStepPages(topology, checkoutPage) {
|
|
116
|
+
return checkoutPathPages(topology, checkoutPage).filter(isCheckoutStepTopologyPage);
|
|
117
|
+
}
|
|
118
|
+
|
|
8
119
|
export function resolveTestOrderTopology(topology = {}, checkoutPage = null) {
|
|
9
120
|
const pages = Array.isArray(topology?.pages) ? topology.pages : [];
|
|
10
|
-
|
|
121
|
+
// A caller may hand in a select or checkout_step page; the path's Checkout is
|
|
122
|
+
// the page whose forward link leads to the offers. Starting from a step made
|
|
123
|
+
// its next step a `nonterminal_target` and hid every offer path.
|
|
124
|
+
const given = checkoutPage && !isPaymentTopologyPage(checkoutPage) && PRE_PAYMENT_PAGE_TYPES.has(pageType(checkoutPage))
|
|
125
|
+
? paymentPageFrom(topology, checkoutPage) || checkoutPage
|
|
126
|
+
: checkoutPage;
|
|
127
|
+
const checkout = given || paymentPagesForTopology(topology)[0] || null;
|
|
11
128
|
const pagesByUrl = new Map();
|
|
12
129
|
for (const page of pages) {
|
|
13
130
|
const key = canonicalHttpUrl(page?.url);
|
|
@@ -13,7 +13,7 @@ import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identi
|
|
|
13
13
|
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
17
|
import { QA_OUTPUT_REL_PATH } from "./campaign-workspace.mjs";
|
|
18
18
|
import { absentOrMalformed } from "./fs-identity.mjs";
|
|
19
19
|
import { isPlainObject as isObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
@@ -53,7 +53,8 @@ export function qaVerdictDir(root, identifier) {
|
|
|
53
53
|
|
|
54
54
|
// The verdict paths the Assembly Report's qa stage records (any `*.json`
|
|
55
55
|
// string under a path/file/verdict/output-named key, or inside outputs[] /
|
|
56
|
-
// artifacts[] / qa), relative to the
|
|
56
|
+
// artifacts[] / qa), relative to the target root unless absolute. Older
|
|
57
|
+
// report-relative hints remain readable.
|
|
57
58
|
export function qaVerdictPathHints(report) {
|
|
58
59
|
const paths = [];
|
|
59
60
|
const visit = (value) => {
|
|
@@ -90,7 +91,8 @@ function sha256File(path) {
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
// Every verdict candidate for a campaign: the files the report records
|
|
93
|
-
// (source "assembly_report", resolved against the report
|
|
94
|
+
// (source "assembly_report", resolved against the report first, then the target)
|
|
95
|
+
// and every `*.json`
|
|
94
96
|
// under `qa-output/<identifier>/` beneath each root (source "qa_output").
|
|
95
97
|
// { path, repoRelPath, source, verdict, sha256, mtimeMs, identityMatch, trusted }
|
|
96
98
|
// `verdict` is null for a recorded path that exists but is not a JSON object
|
|
@@ -141,8 +143,14 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
|
|
|
141
143
|
};
|
|
142
144
|
|
|
143
145
|
const reportBase = reportPath ? dirname(resolve(reportPath)) : null;
|
|
146
|
+
const targetRoot = uniqueTargetRoot(roots, reportBase);
|
|
144
147
|
for (const hint of qaVerdictPathHints(report)) {
|
|
145
|
-
const
|
|
148
|
+
const reportRelative = (isAbsolute(hint) || reportBase)
|
|
149
|
+
? candidate(isAbsolute(hint) ? hint : resolve(reportBase, hint), "assembly_report", null)
|
|
150
|
+
: null;
|
|
151
|
+
const found = reportRelative || (!isAbsolute(hint) && targetRoot
|
|
152
|
+
? candidate(resolve(targetRoot, hint), "assembly_report", null)
|
|
153
|
+
: null);
|
|
146
154
|
if (found) yield found;
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -172,6 +180,15 @@ export function* iterateQaVerdicts({ packet = null, report = null, reportPath =
|
|
|
172
180
|
}
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
function uniqueTargetRoot(roots, reportBase) {
|
|
184
|
+
for (let directory = reportBase; directory; directory = dirname(directory)) {
|
|
185
|
+
if (basename(directory) === ".campaign-runtime") return dirname(directory);
|
|
186
|
+
if (dirname(directory) === directory) break;
|
|
187
|
+
}
|
|
188
|
+
const candidates = [...new Set(roots.filter((root) => typeof root === "string" && root).map((root) => resolve(root)))];
|
|
189
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
175
192
|
export function discoverQaVerdicts(options = {}) {
|
|
176
193
|
return [...iterateQaVerdicts(options)];
|
|
177
194
|
}
|
|
@@ -83,7 +83,7 @@ export function skippedQaVerdictPublish() {
|
|
|
83
83
|
* remit (`ok` when the receiver's answer counts as stored, `failed` when it
|
|
84
84
|
* refused or never answered, `skipped` when nothing was sent).
|
|
85
85
|
*/
|
|
86
|
-
export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null }) {
|
|
86
|
+
export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null, reason = null }) {
|
|
87
87
|
const attempted = Boolean(outcome?.attempted);
|
|
88
88
|
const ok = attempted ? outcome.ok === true : null;
|
|
89
89
|
return {
|
|
@@ -94,6 +94,7 @@ export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publis
|
|
|
94
94
|
error: attempted && typeof outcome.error === "string" ? outcome.error : null,
|
|
95
95
|
endpoint: attempted ? outcome.endpoint ?? QA_VERDICT_PUBLISH_ENDPOINT : null,
|
|
96
96
|
state: attempted ? (ok ? "ok" : "failed") : "skipped",
|
|
97
|
+
reason: reason ?? outcome?.reason ?? null,
|
|
97
98
|
result: attempted ? outcome.result ?? null : null,
|
|
98
99
|
base_kind: attempted ? outcome.base_kind ?? null : null,
|
|
99
100
|
published_at: attempted && ok ? publishedAt : null,
|
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;
|
|
@@ -29,6 +29,7 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
29
29
|
".campaign-runtime/workflow-findings.jsonl",
|
|
30
30
|
".campaign-runtime/run-records/",
|
|
31
31
|
".campaign-runtime/fetched-specs/",
|
|
32
|
+
".campaign-runtime/progress/",
|
|
32
33
|
".campaign-runtime/polish-evidence/",
|
|
33
34
|
".campaign-runtime/evidence/",
|
|
34
35
|
// Full QA verdicts carry live storefront URLs and order references; the
|
|
@@ -41,11 +42,12 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
41
42
|
|
|
42
43
|
// Entries the toolkit adds to an existing managed block when they are
|
|
43
44
|
// missing from the file. The block is otherwise the operator's to edit, but
|
|
44
|
-
// these name directories the toolkit itself fills with
|
|
45
|
-
//
|
|
46
|
-
// next setup command rather than on the next accidental
|
|
47
|
-
// operator has placed anywhere else in the file counts as
|
|
48
|
-
|
|
45
|
+
// these name directories the toolkit itself fills with campaign observations,
|
|
46
|
+
// live URLs, or order references. A target set up before the entry existed
|
|
47
|
+
// must gain it on the next setup command rather than on the next accidental
|
|
48
|
+
// commit. An entry an operator has placed anywhere else in the file counts as
|
|
49
|
+
// present.
|
|
50
|
+
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/"];
|
|
49
51
|
|
|
50
52
|
export const RUNTIME_STATE_IGNORE_BLOCK = [
|
|
51
53
|
RUNTIME_STATE_IGNORE_MARKER,
|
package/src/sdk-markup.mjs
CHANGED
|
@@ -88,6 +88,7 @@ export const SDK_MARKUP_CODES = Object.freeze({
|
|
|
88
88
|
WRONG_FIELD_NAME: { code: `${SDK_MARKUP}.wrong_field_name`, severity: "error" },
|
|
89
89
|
MISSING_SELECTOR_ID_MATCH: { code: `${SDK_MARKUP}.missing_selector_id_match`, severity: "error" },
|
|
90
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" },
|
|
91
92
|
DOUBLE_SELECTED: { code: `${SDK_MARKUP}.double_selected`, severity: "warning" },
|
|
92
93
|
TEMPLATE_DOUBLE_BRACE: { code: `${SDK_MARKUP}.template_double_brace`, severity: "warning" },
|
|
93
94
|
// Unknown data-next-* names are not a finding and carry no code: they are
|
|
@@ -122,6 +123,31 @@ export const TEMPLATE_CONTAINER_ATTRIBUTES = [
|
|
|
122
123
|
// Elements that ARE a selector an add-to-cart button can link to by id.
|
|
123
124
|
const SELECTOR_ATTRIBUTES = ["data-next-bundle-selector", "data-next-package-selector", "data-next-cart-selector", "data-next-upsell-selector"];
|
|
124
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
|
+
|
|
125
151
|
function attrs(node) {
|
|
126
152
|
const map = new Map();
|
|
127
153
|
for (const attr of node.attrs || []) map.set(attr.name.toLowerCase(), attr.value);
|
|
@@ -171,7 +197,7 @@ function describe(entry) {
|
|
|
171
197
|
* `sdk_version` is the campaign's SDK pin, used when the page carries no
|
|
172
198
|
* exact loader pin of its own.
|
|
173
199
|
*/
|
|
174
|
-
export function scanPageMarkup({ page_id, file = null, content = "", sdk_version = null }) {
|
|
200
|
+
export function scanPageMarkup({ page_id, file = null, content = "", sdk_version = null, page_type = null }) {
|
|
175
201
|
const document = parse(String(content || ""));
|
|
176
202
|
const where = file || page_id;
|
|
177
203
|
const sdk = resolvePageSdkVersion(document, sdk_version);
|
|
@@ -183,6 +209,10 @@ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version
|
|
|
183
209
|
const selectorIds = new Set(); // ids of elements that are themselves a selector
|
|
184
210
|
const templates = []; // { entry, sdkOwned }
|
|
185
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
|
+
});
|
|
186
216
|
|
|
187
217
|
walkElements(document, (entry) => {
|
|
188
218
|
const { tag, attrs: a, ancestors } = entry;
|
|
@@ -217,6 +247,16 @@ export function scanPageMarkup({ page_id, file = null, content = "", sdk_version
|
|
|
217
247
|
{ tag, action: value }));
|
|
218
248
|
}
|
|
219
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
|
+
|
|
220
260
|
const action = (a.get("data-next-action") || "").trim().toLowerCase();
|
|
221
261
|
if (action === "add-to-cart") {
|
|
222
262
|
addToCartButtons.push({ entry, selectorId: (a.get("data-next-selector-id") || "").trim() || null });
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "./source-html-manifest.mjs";
|
|
5
5
|
// Built output of campaign-spec (same import shape as src/page-kit-sdk-version.mjs),
|
|
6
6
|
// so build-time wiring and spec-time analysis share one edge resolver.
|
|
7
|
-
import { declineRouteTarget, forwardRouteTarget } from "../campaign-spec/dist/index.js";
|
|
7
|
+
import { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage } from "../campaign-spec/dist/index.js";
|
|
8
8
|
import { isAbsoluteHttpUrl, normalizePageKitRoute, normalizePublicRouteSlug, stripPublicRoutePrefix } from "./route-identity.mjs";
|
|
9
9
|
|
|
10
10
|
const CPK_PAGE_TYPES = new Set(["product", "checkout", "upsell", "receipt"]);
|
|
@@ -30,7 +30,7 @@ export function createSourceHtmlIntake({
|
|
|
30
30
|
const mappings = matched.mappings.map((mapping) => {
|
|
31
31
|
const specPage = pageById.get(mapping.page_id);
|
|
32
32
|
if (!specPage || !mapping.path) return mapping;
|
|
33
|
-
const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir });
|
|
33
|
+
const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir, specPages: specPages || [] });
|
|
34
34
|
projectionDecisions.push({
|
|
35
35
|
id: `dec_page_kit_target_${specPage.id}`,
|
|
36
36
|
stage: "prepare_build",
|
|
@@ -559,7 +559,7 @@ function addSpecHints(mapping, page) {
|
|
|
559
559
|
if (variantLabels) mapping.variant_labels = variantLabels;
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }) {
|
|
562
|
+
function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
|
|
563
563
|
const specRoute = publicRouteForPage(page);
|
|
564
564
|
const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
|
|
565
565
|
const publicRoute = rootedCampaignRoute(relativeRoute, publicRouteSlug);
|
|
@@ -574,6 +574,7 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
|
|
|
574
574
|
if (nextUrl) frontmatter.next_url = nextUrl;
|
|
575
575
|
const declineUrl = declineUrlForPage(page, pageById, publicRouteSlug);
|
|
576
576
|
if (declineUrl && declineUrl !== nextUrl) frontmatter.decline_url = declineUrl;
|
|
577
|
+
Object.assign(frontmatter, checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }));
|
|
577
578
|
|
|
578
579
|
return {
|
|
579
580
|
target_path: targetPath,
|
|
@@ -586,6 +587,43 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
|
|
|
586
587
|
};
|
|
587
588
|
}
|
|
588
589
|
|
|
590
|
+
// Multi-step checkout wiring (campaigns-os#641), for any page that leads into a
|
|
591
|
+
// Checkout without being it: a select page, a checkout_step, or a landing page
|
|
592
|
+
// whose forward links reach a Checkout.
|
|
593
|
+
// success_url the Checkout's post-payment destination (its own next_url).
|
|
594
|
+
// The page's next-success-url meta tag carries it, because the
|
|
595
|
+
// SDK reads that tag for express orders placed on this page; an
|
|
596
|
+
// express order on step 1 must land on the first upsell, not on
|
|
597
|
+
// step 2. Forward navigation never uses that tag: a step form
|
|
598
|
+
// navigates by data-next-checkout-step, a select page by a link.
|
|
599
|
+
// step_number on a checkout_step only: its position among the steps of its
|
|
600
|
+
// path, from 1, for the form's data-next-step-number.
|
|
601
|
+
function checkoutFlowFrontmatter(page, { specPages, pageById, publicRouteSlug }) {
|
|
602
|
+
if (!page || page.type === "checkout") return {};
|
|
603
|
+
const path = checkoutPathFrom(specPages, page);
|
|
604
|
+
if (!path || path.length < 2) return {};
|
|
605
|
+
const out = {};
|
|
606
|
+
const successUrl = nextUrlForPage(path[path.length - 1], pageById, publicRouteSlug);
|
|
607
|
+
if (successUrl) out.success_url = successUrl;
|
|
608
|
+
if (isCheckoutStepPage(page)) out.step_number = checkoutStepNumber(page, specPages);
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function checkoutStepNumber(page, specPages) {
|
|
613
|
+
let number = 1;
|
|
614
|
+
const seen = new Set([page]);
|
|
615
|
+
let current = page;
|
|
616
|
+
for (;;) {
|
|
617
|
+
const previous = specPages.find((candidate) => !seen.has(candidate)
|
|
618
|
+
&& isCheckoutStepPage(candidate)
|
|
619
|
+
&& forwardTargetPage(specPages, candidate) === current);
|
|
620
|
+
if (!previous) return number;
|
|
621
|
+
number += 1;
|
|
622
|
+
seen.add(previous);
|
|
623
|
+
current = previous;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
589
627
|
function pageKitTargetPrompts(mappings) {
|
|
590
628
|
const prompts = [];
|
|
591
629
|
const byOutputPath = new Map();
|
|
@@ -664,12 +702,15 @@ function cpkPageTypeForSpecType(type) {
|
|
|
664
702
|
if (type === "presell" || type === "landing") return "product";
|
|
665
703
|
if (type === "thankyou" || type === "receipt") return "receipt";
|
|
666
704
|
if (type === "upsell" || type === "downsell") return "upsell";
|
|
667
|
-
|
|
705
|
+
// select, checkout_step and checkout all render the SDK checkout surface
|
|
706
|
+
// (campaigns-os#641); only the Checkout places the order.
|
|
707
|
+
if (type === "checkout" || type === "checkout_step" || type === "select") return "checkout";
|
|
668
708
|
return CPK_PAGE_TYPES.has(type) ? type : "product";
|
|
669
709
|
}
|
|
670
710
|
|
|
671
711
|
function defaultRouteForSpecType(type) {
|
|
672
712
|
if (type === "thankyou" || type === "receipt") return "receipt/";
|
|
713
|
+
if (type === "checkout_step") return "checkout-step/";
|
|
673
714
|
if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
|
|
674
715
|
return `${type || "page"}/`;
|
|
675
716
|
}
|
|
@@ -696,6 +737,7 @@ function pageMatchKeys(page, ordinal) {
|
|
|
696
737
|
}
|
|
697
738
|
if (page.type === "landing" || page.type === "presell") keys.add("index");
|
|
698
739
|
if (page.type === "checkout") keys.add("checkout");
|
|
740
|
+
if (page.type === "checkout_step") keys.add("checkout-step");
|
|
699
741
|
if (page.type === "upsell") keys.add("upsell");
|
|
700
742
|
if (page.type === "downsell") keys.add("downsell");
|
|
701
743
|
return [...keys];
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
|
|
|
5
5
|
import { assessInputCurrency, currentPacketInputs, effectiveStageStatus, effectiveStatusIsTerminal, wellFormedBriefMaterial } from "./input-currency.mjs";
|
|
6
6
|
import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
7
7
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
8
|
+
import { portableArtifactPaths } from "./cli-helpers.mjs";
|
|
8
9
|
import {
|
|
9
10
|
ASSEMBLY_REPORT_STAGE_KEYS,
|
|
10
11
|
NEXT_STAGE_CONTRACTS,
|
|
@@ -541,14 +542,26 @@ export function qaGateEvidence(report, gate) {
|
|
|
541
542
|
};
|
|
542
543
|
}
|
|
543
544
|
|
|
545
|
+
export function qaRecordedBuildFingerprint(report) {
|
|
546
|
+
return optionalString(report?.stages?.qa?.evidence?.qc_build_fingerprint)
|
|
547
|
+
|| optionalString(report?.stages?.qa?.evidence?.source_build_fingerprint);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function qaRecordedForCurrentBuild(report, buildFingerprint) {
|
|
551
|
+
const recorded = qaRecordedBuildFingerprint(report);
|
|
552
|
+
const current = optionalString(buildFingerprint);
|
|
553
|
+
return Boolean(recorded && current && recorded === current);
|
|
554
|
+
}
|
|
555
|
+
|
|
544
556
|
// `qaCurrency` is QA's read-time input currency
|
|
545
557
|
// (derived.input_currency.stages.qa): while QA is owed again or its inputs
|
|
546
558
|
// cannot be confirmed, no QA gate pass counts.
|
|
547
559
|
export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint, qaCurrency = null }) {
|
|
548
560
|
if (qaCurrency === "owed" || qaCurrency === "unknown") return false;
|
|
549
561
|
const outcome = qaGateEvidence(report, gate);
|
|
550
|
-
|
|
551
|
-
|
|
562
|
+
return Boolean(outcome && outcome.status === QA_STATUS.PASS
|
|
563
|
+
&& outcome.source_build_fingerprint === optionalString(buildFingerprint)
|
|
564
|
+
&& qaRecordedForCurrentBuild(report, buildFingerprint));
|
|
552
565
|
}
|
|
553
566
|
|
|
554
567
|
/**
|
|
@@ -675,7 +688,7 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
|
675
688
|
const finish = () => {
|
|
676
689
|
if (hasRefresh) {
|
|
677
690
|
const doctor = refreshDoctor(outcome);
|
|
678
|
-
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim() });
|
|
691
|
+
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim(), targetRepo });
|
|
679
692
|
} else if (outcome.written) {
|
|
680
693
|
markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
|
|
681
694
|
}
|
|
@@ -719,7 +732,8 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
|
719
732
|
// (every mutator in this repo returns a copy), so the restatement is in
|
|
720
733
|
// place rather than a second deep clone. Stages are read at their effective
|
|
721
734
|
// status against the inputs the workspace's packet names now.
|
|
722
|
-
const
|
|
735
|
+
const nextReport = applyDerivedAssemblyReportSummary(mutated, workspaceInputs(workspace));
|
|
736
|
+
const next = targetRepo ? portableArtifactPaths(nextReport, targetRepo, { artifactPath: reportPath }) : nextReport;
|
|
723
737
|
if (stage && producerStageOutcomeUnchanged(report, next, stage)) {
|
|
724
738
|
outcome.skipped = "unchanged";
|
|
725
739
|
return finish();
|