@nextcommerce/campaigns-os 1.54.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 +84 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -12
- package/compatibility.json +1 -1
- package/contracts/release-ledger.json +441 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +35 -19
- 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 +55 -11
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -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-identity.mjs +33 -4
- package/src/cli.mjs +36 -59
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +16 -6
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +4 -3
- 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/qa-browser.mjs +521 -92
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +144 -39
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-markup.mjs +41 -1
- package/src/stage-ledger.mjs +14 -2
- package/src/stage-record.mjs +50 -13
package/src/qa-cart-entry.mjs
CHANGED
|
@@ -70,12 +70,21 @@ export const CART_ENTRY_ROUTE_ATTRIBUTE = "data-next-url";
|
|
|
70
70
|
// preventDefault() unconditionally, so its href never navigates, and it
|
|
71
71
|
// resolves the attribute against the origin (campaign-cart url-utils), not the
|
|
72
72
|
// document base. Without the attribute such a control adds to the cart and
|
|
73
|
-
// stays put — no route.
|
|
73
|
+
// stays put — no route. A control matching one of `declaredRoutes` (each
|
|
74
|
+
// `{ selector, url }`, a route the page declares rather than one the control
|
|
75
|
+
// carries: the SDK checkout form's submit button and an upsell's accept /
|
|
76
|
+
// decline actions) goes to that url and nothing else, because the SDK
|
|
77
|
+
// handles the submission or the click itself, so an href="#" or the form's
|
|
78
|
+
// action never navigates. Anywhere else the SDK attribute has no navigation
|
|
74
79
|
// semantics: an HTML anchor's own resolved href (native, so a <base href> is
|
|
75
80
|
// honoured), then an href attribute, then `data-href`, then a wrapping form's
|
|
76
|
-
// action
|
|
77
|
-
// the SDK
|
|
78
|
-
|
|
81
|
+
// action — for that form's submit button only, and never inside a form
|
|
82
|
+
// matching `checkoutFormSelector`: the SDK checkout form, which the SDK
|
|
83
|
+
// submits itself, or, on a checkout or upsell / downsell page, every form,
|
|
84
|
+
// since there the only form-borne route is the one in `declaredRoutes`. Only
|
|
85
|
+
// spellings with real navigation semantics count — an attribute the SDK does
|
|
86
|
+
// not declare is not a route.
|
|
87
|
+
export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAttribute, declaredRoutes = [], checkoutFormSelector = null, origin, baseHref }) {
|
|
79
88
|
if (!element || typeof element.getAttribute !== "function") return null;
|
|
80
89
|
if (typeof element.matches === "function" && element.matches(cartEntrySelector)) {
|
|
81
90
|
const sdkRoute = String(element.getAttribute(cartEntryRouteAttribute) || "").trim();
|
|
@@ -88,12 +97,26 @@ export function cartEntryHrefFor(element, { cartEntrySelector, cartEntryRouteAtt
|
|
|
88
97
|
return null;
|
|
89
98
|
}
|
|
90
99
|
}
|
|
100
|
+
const declared = typeof element.matches === "function"
|
|
101
|
+
? (declaredRoutes || []).find((route) => route?.selector && element.matches(route.selector))
|
|
102
|
+
: null;
|
|
103
|
+
if (declared) {
|
|
104
|
+
try {
|
|
105
|
+
return new URL(declared.url, baseHref).href;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
91
110
|
// An HTML anchor resolves its own href (an SVG <a> exposes an object, not a
|
|
92
111
|
// string, and falls through to the attribute reading).
|
|
93
|
-
|
|
112
|
+
const tag = String(element.tagName || "").toUpperCase();
|
|
113
|
+
if (tag === "A" && typeof element.href === "string" && element.href) return element.href;
|
|
114
|
+
// The reflected type, so a <button> with no type attribute submits too.
|
|
115
|
+
const submits = (tag === "BUTTON" || tag === "INPUT") && String(element.type || "").toLowerCase() === "submit";
|
|
116
|
+
const insideRoutelessForm = Boolean(checkoutFormSelector) && typeof element.closest === "function" && Boolean(element.closest(checkoutFormSelector));
|
|
94
117
|
const attr = element.getAttribute("href")
|
|
95
118
|
|| element.getAttribute("data-href")
|
|
96
|
-
|| (typeof element.closest === "function" ? element.closest("form")?.getAttribute("action") : null);
|
|
119
|
+
|| (submits && !insideRoutelessForm && typeof element.closest === "function" ? element.closest("form")?.getAttribute("action") : null);
|
|
97
120
|
if (!attr) return null;
|
|
98
121
|
try {
|
|
99
122
|
return new URL(attr, baseHref).href;
|
package/src/qa-node.mjs
CHANGED
|
@@ -32,7 +32,7 @@ const PACKAGE_ROOT = installModeResolve(installModeDirname(installModeFileUrl(im
|
|
|
32
32
|
function cmd(verb, rest = "") {
|
|
33
33
|
return `${invocationPrefixFor(PACKAGE_ROOT)} ${verb}${rest ? ` ${rest}` : ""}`;
|
|
34
34
|
}
|
|
35
|
-
import { isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
35
|
+
import { QA_RUN_CURRENCY, isSameAnalyticsCapturePage, runAnalyticsCorrectnessChecks, runAnalyticsParityChecks, runBrowserChecks, runBrowserTestOrders, testEmail, UPSELL_ACTION_SPELLINGS, upsellActionCoverageWithoutOrders, validatedOrderCreationLimit } from "./qa-browser.mjs";
|
|
36
36
|
import { assessReceiptPurchase } from "./qa-analytics-correctness.mjs";
|
|
37
37
|
import { trackingQaAssertion, trackingRunScopeRows } from "./qa-tracking-params.mjs";
|
|
38
38
|
import { contentParamNotRequestedRows, contentParamQaAssertion } from "./qa-content-params.mjs";
|
|
@@ -128,7 +128,7 @@ const HELP = `campaigns-os qa — Node/npm spec-aware QA
|
|
|
128
128
|
Usage:
|
|
129
129
|
campaigns-os qa parity --fixture <parity-fixture.json> --scenario <scenario-id> [--base-url <override>] [--baseline <url>] [--parity-order-json <file>] [--no-post-verdict]
|
|
130
130
|
campaigns-os qa resolve --packet <campaign-runtime.build.json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json]
|
|
131
|
-
campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
|
|
131
|
+
campaigns-os qa run --packet <campaign-runtime.build.json> [--base-url <url>] [--currency <code>] [--output-dir <dir>] [--no-remit] [--no-live-refs] [--json]
|
|
132
132
|
campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
|
|
133
133
|
campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
|
|
134
134
|
campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
|
|
@@ -189,6 +189,10 @@ Options:
|
|
|
189
189
|
--no-live-refs qa run: skip the one read-only GET of <proxy-base>/api/campaign that checks each
|
|
190
190
|
served page's shipping and package refs against the live campaign; the verdict
|
|
191
191
|
records the check not_run with reason disabled, never a pass.
|
|
192
|
+
--currency <code> qa run: enter each funnel's entry page with ?currency=<CODE> (three letters, e.g. GBP),
|
|
193
|
+
keeping its existing query. The flag takes one code per run (given twice, the
|
|
194
|
+
last one is used); the verdict records it as evidence.currency on the entry
|
|
195
|
+
page's http:<page> row. Not supported by qa parity or --legacy-api-test-order.
|
|
192
196
|
--auth-cookie <cookie> Cookie header for protected previews.
|
|
193
197
|
--browser Run Playwright-rendered browser checks after static Node checks.
|
|
194
198
|
Requires one-time setup: campaigns-os qa install-browser
|
|
@@ -199,8 +203,9 @@ Options:
|
|
|
199
203
|
--browser-timeout <ms> Browser navigation timeout. Default: 30000.
|
|
200
204
|
--test-order <off|common|checkout|accept|decline|both|full|tiers[:checkout|common|full]|accept-decline[-accept...]>
|
|
201
205
|
Create Playwright typed-card test orders through the tested checkout page.
|
|
202
|
-
Test cards bypass the gateway and create no transactions,
|
|
203
|
-
|
|
206
|
+
Test cards bypass the gateway and create no transactions, but each creates a real
|
|
207
|
+
store order record. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing orders.
|
|
208
|
+
No permission flag or packet policy is needed. Default mode (bare
|
|
204
209
|
--test-order, or "common") runs every actual terminal path when they fit under
|
|
205
210
|
the cap (--max-test-orders, default 6). Above the cap it runs checkout,
|
|
206
211
|
first-offer accept/decline, and a deduplicated shortest real receipt path, then
|
|
@@ -1861,7 +1866,7 @@ function updateQaPolicy(args) {
|
|
|
1861
1866
|
// the packet and before the packet write: refusals, so they journal nothing.
|
|
1862
1867
|
const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
|
|
1863
1868
|
if (removedFlags.length) {
|
|
1864
|
-
throw refused(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders
|
|
1869
|
+
throw refused(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders use --test-order <mode>, with no permission flag; unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing these real store order records). Drop the flag${removedFlags.length > 1 ? "s" : ""}. Accepted: --allowed-domains-confirmed, --deploy-target, --preview-url, --production-url, --order-path-depth.`);
|
|
1865
1870
|
}
|
|
1866
1871
|
// Validated with the other argv checks, before anything is written: a
|
|
1867
1872
|
// refusal at this call site, like the removed-flag check above.
|
|
@@ -2136,11 +2141,34 @@ function parityReplayEvidence(bundle) {
|
|
|
2136
2141
|
// validator's own.
|
|
2137
2142
|
const refuseBadOrderCreationLimit = (args) => refusing(() => validatedOrderCreationLimit(args));
|
|
2138
2143
|
|
|
2144
|
+
// `qa run --currency <code>`: one run proves one currency. The code is three
|
|
2145
|
+
// ASCII letters, upper-cased; anything else (no value, a list, a symbol) is
|
|
2146
|
+
// refused here, before anything is resolved, fetched or launched. So is a run
|
|
2147
|
+
// whose orders are legacy direct API orders: that request carries no
|
|
2148
|
+
// currency, so the order would be placed in the default one.
|
|
2149
|
+
function qaRunCurrency(args) {
|
|
2150
|
+
if (!Object.hasOwn(args, "currency")) return null;
|
|
2151
|
+
const value = args.currency;
|
|
2152
|
+
if (typeof value !== "string" || !/^[A-Za-z]{3}$/.test(value)) {
|
|
2153
|
+
const got = typeof value === "string" && value !== "" ? JSON.stringify(value) : "no value";
|
|
2154
|
+
throw refused(`--currency takes one three-letter currency code, such as GBP or EUR (got ${got}). Run qa run once per currency.`);
|
|
2155
|
+
}
|
|
2156
|
+
const browserMode = String(args["test-order"] || "off").toLowerCase();
|
|
2157
|
+
const legacyMode = String(args["legacy-api-test-order"] || "off").toLowerCase();
|
|
2158
|
+
if (browserMode === "off" && legacyMode !== "off") {
|
|
2159
|
+
throw refused("--currency is not supported with --legacy-api-test-order: the direct API order carries no currency and would be placed in the default one. Use --test-order <mode> for orders in that currency, or drop --currency.");
|
|
2160
|
+
}
|
|
2161
|
+
return value.toUpperCase();
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2139
2164
|
async function runParityQa(args) {
|
|
2140
2165
|
// Checked here as well as on the budget itself: the budget is built after a
|
|
2141
2166
|
// browser has launched, and a flag the operator typed wrong should cost them
|
|
2142
2167
|
// nothing. The budget stays the authority — this is fail-fast, not the gate.
|
|
2143
2168
|
refuseBadOrderCreationLimit(args);
|
|
2169
|
+
if (Object.hasOwn(args, "currency")) {
|
|
2170
|
+
throw refused("qa parity does not support --currency: the scenario's fixture sets the currency it checks. Use qa run --currency <code> to prove a currency.");
|
|
2171
|
+
}
|
|
2144
2172
|
const fixturePath = stringArg(args.fixture);
|
|
2145
2173
|
const scenarioId = stringArg(args.scenario) || stringArg(args._[2]);
|
|
2146
2174
|
if (!fixturePath) throw refused("QA parity requires --fixture <parity-fixture.json>.");
|
|
@@ -2211,6 +2239,7 @@ async function runQa(args, options = {}) {
|
|
|
2211
2239
|
// Fail-fast before anything resolves or launches. The authoritative check
|
|
2212
2240
|
// lives on the creation budget itself, which every browser path builds.
|
|
2213
2241
|
refuseBadOrderCreationLimit(args);
|
|
2242
|
+
qaRunCurrency(args);
|
|
2214
2243
|
// Match dispatch: an active browser mode takes precedence over legacy API
|
|
2215
2244
|
// diagnostics. Only the selected legacy path requires a cart and API mode.
|
|
2216
2245
|
const browserMode = String(args["test-order"] || "off").toLowerCase();
|
|
@@ -2225,7 +2254,15 @@ async function runQa(args, options = {}) {
|
|
|
2225
2254
|
// `runSessionActive` is threaded in from the CLI's single ambient-session read
|
|
2226
2255
|
// rather than re-discovered here, so the closeout command this run prints and
|
|
2227
2256
|
// the run_id the session will close under come from the same observation.
|
|
2228
|
-
async function runResolvedQa(args,
|
|
2257
|
+
async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, liveCampaign = undefined, liveCampaignFetch = globalThis.fetch } = {}) {
|
|
2258
|
+
// --currency: every leg below (HTTP rows, browser loads, test orders) reads
|
|
2259
|
+
// the entry page's URL from these topologies, so it is tagged once here;
|
|
2260
|
+
// the validated code rides on args, under QA_RUN_CURRENCY, for the order
|
|
2261
|
+
// runner's own first load of each path.
|
|
2262
|
+
const currency = qaRunCurrency(args);
|
|
2263
|
+
if (currency) args = { ...args, [QA_RUN_CURRENCY]: currency };
|
|
2264
|
+
const entryCurrency = withEntryCurrency(resolvedInputs.topologies, currency);
|
|
2265
|
+
const resolved = currency ? { ...resolvedInputs, topologies: entryCurrency.topologies } : resolvedInputs;
|
|
2229
2266
|
const startedAt = new Date().toISOString();
|
|
2230
2267
|
const runId = generateRunId();
|
|
2231
2268
|
const gate = resolved.themeGate;
|
|
@@ -2317,7 +2354,14 @@ async function runResolvedQa(args, resolved, { runSessionActive = false, liveCam
|
|
|
2317
2354
|
const bindingScriptLoader = createBindingScriptLoader();
|
|
2318
2355
|
const pages = resolved.topologies.flatMap(topology => topology.pages);
|
|
2319
2356
|
const pageResults = await mapConcurrent(pages, COMMERCIAL_QA_LIMITS.concurrency, page =>
|
|
2320
|
-
runPageChecks(page, args, {
|
|
2357
|
+
runPageChecks(page, args, {
|
|
2358
|
+
sourceLoader,
|
|
2359
|
+
bindingExpected,
|
|
2360
|
+
bindingScriptLoader,
|
|
2361
|
+
captureCommercial: commercialIds.has(String(page.page_id)),
|
|
2362
|
+
currency: entryCurrency.entryPages.has(page) ? currency : null,
|
|
2363
|
+
currencyNotApplied: entryCurrency.notApplied.get(page) || null,
|
|
2364
|
+
}));
|
|
2321
2365
|
const livePages = new Map();
|
|
2322
2366
|
for (const [index, page] of pages.entries()) {
|
|
2323
2367
|
const pageResult = pageResults[index];
|
|
@@ -2706,7 +2750,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2706
2750
|
// The classified outcome (attempted, ok, error, endpoint, result,
|
|
2707
2751
|
// http_status, base_kind) and the Run Record block derived from it.
|
|
2708
2752
|
publish: { ...publishOutcome, response: undefined },
|
|
2709
|
-
qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString() }),
|
|
2753
|
+
qa_verdict_publish: qaVerdictPublishBlock(publishOutcome, { verdictRunId: verdict.run_id, publisher: QA_VERDICT_PUBLISHERS.run, publishedAt: new Date().toISOString(), reason: publishDecision.reason }),
|
|
2710
2754
|
publish_decision: { ...publishDecision, destination: publishDestination, consent_state: consent?.state ?? null },
|
|
2711
2755
|
counts: countAssertions(verdict.assertions),
|
|
2712
2756
|
theme_gate: themeGateSummary(resolved.themeGate),
|
|
@@ -2765,14 +2809,50 @@ const ENTRY_PAGE_TYPES = new Set([
|
|
|
2765
2809
|
"review",
|
|
2766
2810
|
]);
|
|
2767
2811
|
|
|
2812
|
+
function entryPageOf(topology) {
|
|
2813
|
+
const pages = Array.isArray(topology?.pages) ? topology.pages.filter((page) => page?.url) : [];
|
|
2814
|
+
if (!pages.length) return null;
|
|
2815
|
+
// #482: a partial build enters at its first in-scope page, which can be
|
|
2816
|
+
// an opted-in select or checkout rather than a later landing/presell.
|
|
2817
|
+
return topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// Each funnel's entry page gets ?currency=<CODE> through the URL API, so the
|
|
2821
|
+
// query string and tracking params it already carries are kept. Returns the
|
|
2822
|
+
// topologies unchanged without a currency; `entryPages` holds the tagged pages
|
|
2823
|
+
// and `notApplied` the entry pages whose URL did not parse, with the reason
|
|
2824
|
+
// their http row records.
|
|
2825
|
+
function withEntryCurrency(topologies, currency) {
|
|
2826
|
+
const entryPages = new Set();
|
|
2827
|
+
const notApplied = new Map();
|
|
2828
|
+
if (!currency) return { topologies, entryPages, notApplied };
|
|
2829
|
+
const tagged = topologyList(topologies).map((topology) => {
|
|
2830
|
+
const entry = entryPageOf(topology);
|
|
2831
|
+
// No URL at all: the page gets the route-url row, not an http row.
|
|
2832
|
+
if (!entry?.url) return topology;
|
|
2833
|
+
let url = null;
|
|
2834
|
+
try {
|
|
2835
|
+
url = new URL(entry.url);
|
|
2836
|
+
} catch {
|
|
2837
|
+
url = null;
|
|
2838
|
+
}
|
|
2839
|
+
if (!url) {
|
|
2840
|
+
notApplied.set(entry, { currency, reason: "entry URL does not parse as an absolute URL" });
|
|
2841
|
+
return topology;
|
|
2842
|
+
}
|
|
2843
|
+
url.searchParams.set("currency", currency);
|
|
2844
|
+
const page = { ...entry, url: url.toString() };
|
|
2845
|
+
entryPages.add(page);
|
|
2846
|
+
return { ...topology, pages: topology.pages.map((item) => (item === entry ? page : item)) };
|
|
2847
|
+
});
|
|
2848
|
+
return { topologies: tagged, entryPages, notApplied };
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2768
2851
|
function deriveEntryUrls(topologies) {
|
|
2769
2852
|
const entries = [];
|
|
2770
2853
|
for (const topology of topologyList(topologies)) {
|
|
2771
|
-
const
|
|
2772
|
-
if (!
|
|
2773
|
-
// #482: a partial build enters at its first in-scope page, which can be
|
|
2774
|
-
// an opted-in select or checkout rather than a later landing/presell.
|
|
2775
|
-
const page = topology.partial_build_scope ? pages[0] : pages.find(isEntryLikePage) || pages[0];
|
|
2854
|
+
const page = entryPageOf(topology);
|
|
2855
|
+
if (!page) continue;
|
|
2776
2856
|
entries.push({
|
|
2777
2857
|
funnel_id: topology.funnel_id || "default",
|
|
2778
2858
|
funnel_name: topology.funnel_name || topology.funnel_id || "default",
|
|
@@ -2843,8 +2923,17 @@ async function runPageChecks(page, args, {
|
|
|
2843
2923
|
captureCommercial = false,
|
|
2844
2924
|
bindingExpected = { value: null },
|
|
2845
2925
|
bindingScriptLoader = createBindingScriptLoader(),
|
|
2926
|
+
currency = null,
|
|
2927
|
+
currencyNotApplied = null,
|
|
2846
2928
|
} = {}) {
|
|
2847
2929
|
const assertions = [];
|
|
2930
|
+
// --currency is recorded on the entry page's HTTP row: the persisted
|
|
2931
|
+
// verdict drops every URL query, so the fetched URL cannot carry it. An
|
|
2932
|
+
// entry URL the currency could not be added to says so on the same row.
|
|
2933
|
+
const currencyEvidence = {
|
|
2934
|
+
...(currency ? { currency } : {}),
|
|
2935
|
+
...(currencyNotApplied ? { currency_not_applied: currencyNotApplied } : {}),
|
|
2936
|
+
};
|
|
2848
2937
|
if (!page.url) {
|
|
2849
2938
|
assertions.push(bindingAssertion(page, await observeBinding({ source: null, page, expected: bindingExpected, scriptLoader: bindingScriptLoader })));
|
|
2850
2939
|
assertions.push(assertion({
|
|
@@ -2880,7 +2969,7 @@ async function runPageChecks(page, args, {
|
|
|
2880
2969
|
severity: SEVERITY.BLOCKER,
|
|
2881
2970
|
expected: isHttpStatus ? "2xx HTTP response" : "fetchable bounded deployed page",
|
|
2882
2971
|
actual: isHttpStatus ? `${source.status} ${source.status_text}`.trim() : null,
|
|
2883
|
-
evidence: { transport_error: { code: source.error_code, message: source.error } },
|
|
2972
|
+
evidence: { transport_error: { code: source.error_code, message: source.error }, ...currencyEvidence },
|
|
2884
2973
|
}));
|
|
2885
2974
|
return {
|
|
2886
2975
|
assertions,
|
|
@@ -2898,6 +2987,7 @@ async function runPageChecks(page, args, {
|
|
|
2898
2987
|
status: STATUS.PASS,
|
|
2899
2988
|
expected: "2xx HTTP response",
|
|
2900
2989
|
actual: `${source.status} ${source.status_text}`.trim(),
|
|
2990
|
+
evidence: Object.keys(currencyEvidence).length ? currencyEvidence : undefined,
|
|
2901
2991
|
}));
|
|
2902
2992
|
|
|
2903
2993
|
const expectedMeta = page.expected_meta_tags || {};
|
|
@@ -3117,9 +3207,9 @@ async function maybeRunTestOrders(
|
|
|
3117
3207
|
return { orders: [], receiptAnalytics: emptyReceiptAnalytics(), qc_results: notRequestedRows() };
|
|
3118
3208
|
}
|
|
3119
3209
|
if (mode && mode !== "off") {
|
|
3120
|
-
// Test Orders use global test cards: they bypass the payment gateway
|
|
3121
|
-
// no transactions,
|
|
3122
|
-
// <mode>`
|
|
3210
|
+
// Test Orders use global test cards: they bypass the payment gateway and create
|
|
3211
|
+
// no transactions, but each leaves a real store order record. Ask the operator
|
|
3212
|
+
// once in the first turn; `--test-order <mode>` has no permission flag or packet policy gate.
|
|
3123
3213
|
const result = await operations.runBrowser(resolved.topologies, args, runId, { captureAnalytics, spec: resolved.spec });
|
|
3124
3214
|
assertions.push(...result.assertions);
|
|
3125
3215
|
return {
|
|
@@ -3462,6 +3552,9 @@ function output(value, args) {
|
|
|
3462
3552
|
printEntryUrlLines(value.entry_urls);
|
|
3463
3553
|
console.log(`Run ID: ${value.run_id}`);
|
|
3464
3554
|
console.log(`Disposition: ${value.verdict.disposition}`);
|
|
3555
|
+
if (value.verdict.disposition === "ready_with_exceptions") {
|
|
3556
|
+
console.log("This is a passing proof with exceptions. Report these exceptions to the operator; do not clear or waive them, or change markup just to make them pass.");
|
|
3557
|
+
}
|
|
3465
3558
|
console.log(`Counts: ${Object.entries(value.counts).map(([status, count]) => `${count} ${status}`).join(", ")}`);
|
|
3466
3559
|
printCauseLines(value.verdict);
|
|
3467
3560
|
printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
|
|
@@ -3584,14 +3677,21 @@ function printCauseLines(verdict) {
|
|
|
3584
3677
|
// One formatter, shared with the doctor report: a prior record that exists
|
|
3585
3678
|
// but has no usable QA verdict is not the same state as no prior record, and
|
|
3586
3679
|
// the two commands must not describe it differently.
|
|
3587
|
-
|
|
3680
|
+
if (verdict?.disposition !== "ready_with_exceptions") {
|
|
3681
|
+
for (const line of formatCauseReportLines(verdict?.cause_summary)) console.log(line);
|
|
3682
|
+
}
|
|
3588
3683
|
const exceptions = Array.isArray(verdict.exceptions) ? verdict.exceptions : [];
|
|
3589
3684
|
if (!exceptions.length) return;
|
|
3590
3685
|
console.log("Findings:");
|
|
3591
3686
|
for (const exception of exceptions) {
|
|
3592
|
-
const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
|
|
3593
3687
|
const tag = formatCauseTag(exception);
|
|
3594
|
-
|
|
3688
|
+
if (verdict.disposition === "ready_with_exceptions") {
|
|
3689
|
+
const severity = exception.severity ? `; severity: ${exception.severity}` : "";
|
|
3690
|
+
console.log(`- id: ${exception.id || "(none)"}; page: ${exception.page || "(none)"} (${exception.status || "unknown"}${severity})${tag ? ` ${tag}` : ""}`);
|
|
3691
|
+
} else {
|
|
3692
|
+
const identity = [exception.id, exception.page].filter(Boolean).join(" @ ") || "(unidentified finding)";
|
|
3693
|
+
console.log(`- ${identity} (${exception.status || "unknown"})${tag ? ` ${tag}` : ""}`);
|
|
3694
|
+
}
|
|
3595
3695
|
}
|
|
3596
3696
|
}
|
|
3597
3697
|
|
|
@@ -3606,7 +3706,7 @@ export function themeGateLines(themeGate, packetPath = null, reportPath = null)
|
|
|
3606
3706
|
for (const action of themeGate.required_actions || []) {
|
|
3607
3707
|
lines.push(` - ${requiredActionText(action, { packetPath, reportPath })}`);
|
|
3608
3708
|
}
|
|
3609
|
-
lines.push(
|
|
3709
|
+
lines.push('Or rerun once with --theme-waive "<reason>"; this waiver applies only to that run. A recorded theme waive requires --waived-by "<named human>".');
|
|
3610
3710
|
return lines;
|
|
3611
3711
|
}
|
|
3612
3712
|
|
|
@@ -3644,8 +3744,8 @@ export function qaResolveNextProofLines(value) {
|
|
|
3644
3744
|
`Next expected proof: ${qaRunCommandFromResolve(value)}`,
|
|
3645
3745
|
`Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
|
|
3646
3746
|
value.local_spec_id
|
|
3647
|
-
? "Typed-card test
|
|
3648
|
-
: "Typed-card test
|
|
3747
|
+
? "Typed-card test cards create no transactions but leave real store order records. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing them; there is no permission flag. Local-spec QA stays in the repository."
|
|
3748
|
+
: "Typed-card test cards create no transactions but leave real store order records. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing them; there is no permission flag. QA publishes to the portal by default.",
|
|
3649
3749
|
];
|
|
3650
3750
|
}
|
|
3651
3751
|
|
|
@@ -4023,16 +4123,23 @@ function htmlIncludesRouteReference(html, expectedUrl) {
|
|
|
4023
4123
|
return html.includes(expectedUrl) || html.includes(path);
|
|
4024
4124
|
}
|
|
4025
4125
|
|
|
4026
|
-
// A data-upsell-proxy button forwards its click to the SDK action
|
|
4027
|
-
// offer. With no such action, the route's URL can still sit
|
|
4028
|
-
// tags, so the reference match would pass a control that
|
|
4126
|
+
// A data-upsell-proxy button forwards its click to the SDK action of its own
|
|
4127
|
+
// spelling inside the offer. With no such action, the route's URL can still sit
|
|
4128
|
+
// in the page's meta tags, so the reference match would pass a control that
|
|
4129
|
+
// does nothing.
|
|
4029
4130
|
function findDeadUpsellProxy(html, kind, page) {
|
|
4030
4131
|
if (page.page_type !== "upsell" || kind === "next") return null;
|
|
4031
|
-
const action
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4132
|
+
for (const action of UPSELL_ACTION_SPELLINGS[kind]) {
|
|
4133
|
+
if (!new RegExp(`\\bdata-upsell-proxy\\s*=\\s*["']${action}["']`, "i").test(html)) continue;
|
|
4134
|
+
const target = upsellActionPattern(action);
|
|
4135
|
+
if (upsellOfferElements(html).some((offer) => target.test(offer))) continue;
|
|
4136
|
+
return `data-upsell-proxy="${action}" with no data-next-upsell-action="${action}" inside the offer to forward to`;
|
|
4137
|
+
}
|
|
4138
|
+
return null;
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
function upsellActionPattern(action) {
|
|
4142
|
+
return new RegExp(`\\bdata-next-upsell-action\\s*=\\s*["']${action}["']`, "i");
|
|
4036
4143
|
}
|
|
4037
4144
|
|
|
4038
4145
|
// The markup of each [data-next-upsell="offer"] element, read to its closing
|
|
@@ -4061,14 +4168,9 @@ function upsellOfferElements(html) {
|
|
|
4061
4168
|
}
|
|
4062
4169
|
|
|
4063
4170
|
function findSdkRouteAction(html, kind, page) {
|
|
4064
|
-
if (page.page_type !== "upsell") return null;
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
}
|
|
4068
|
-
if (kind === "decline" && /\bdata-next-upsell-action\s*=\s*["']skip["']/i.test(html)) {
|
|
4069
|
-
return 'SDK upsell decline control: data-next-upsell-action="skip"';
|
|
4070
|
-
}
|
|
4071
|
-
return null;
|
|
4171
|
+
if (page.page_type !== "upsell" || kind === "next") return null;
|
|
4172
|
+
const action = UPSELL_ACTION_SPELLINGS[kind].find((spelling) => upsellActionPattern(spelling).test(html));
|
|
4173
|
+
return action ? `SDK upsell ${kind} control: data-next-upsell-action="${action}"` : null;
|
|
4072
4174
|
}
|
|
4073
4175
|
|
|
4074
4176
|
function decodeHtml(value) {
|
|
@@ -4128,6 +4230,7 @@ function extractApiError(raw) {
|
|
|
4128
4230
|
}
|
|
4129
4231
|
|
|
4130
4232
|
export const __qaNodeTestHooks = Object.freeze({
|
|
4233
|
+
output,
|
|
4131
4234
|
extractTopologies,
|
|
4132
4235
|
validatedOrderCreationLimit,
|
|
4133
4236
|
resolveQaInputs,
|
|
@@ -4152,6 +4255,8 @@ export const __qaNodeTestHooks = Object.freeze({
|
|
|
4152
4255
|
themeGateSummary,
|
|
4153
4256
|
templateBrandContractAssertion,
|
|
4154
4257
|
deriveEntryUrls,
|
|
4258
|
+
qaRunCurrency,
|
|
4259
|
+
withEntryCurrency,
|
|
4155
4260
|
derivePageUrls,
|
|
4156
4261
|
deriveTestedUrlsFromAssertions,
|
|
4157
4262
|
resolvePayload,
|
|
@@ -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;
|