@nextcommerce/campaigns-os 1.63.0 → 1.66.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 +62 -0
- package/campaign-spec/dist/types.d.ts +1 -1
- package/compatibility.json +1 -1
- package/contracts/effects.v1.json +129 -1
- package/contracts/release-ledger.json +549 -0
- package/contracts/supported-surface.json +3 -3
- package/docs/build-packet.md +31 -5
- package/docs/campaigns-os-build-flow.md +38 -0
- package/docs/campaignspec-authoring-examples.md +3 -2
- package/docs/effects.md +8 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +44 -10
- package/docs/readback.md +8 -1
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
- 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/next-campaigns-sdk-bump/SKILL.md +3 -3
- package/skills.json +11 -11
- package/src/change-scope.mjs +25 -6
- package/src/cli.mjs +120 -7
- package/src/commercial-journey.mjs +3 -0
- package/src/commercial-parity.mjs +157 -7
- package/src/doctor/checks.mjs +28 -3
- package/src/doctor/ci.mjs +36 -1
- package/src/evidence-debt.mjs +9 -1
- package/src/input-currency.mjs +4 -2
- package/src/input-refresh.mjs +1 -1
- package/src/invocation.mjs +3 -2
- package/src/orchestration-stage-contract.mjs +3 -1
- package/src/polish-gate.mjs +10 -0
- package/src/qa-browser.mjs +46 -0
- package/src/qa-commercial-parity.mjs +173 -18
- package/src/qa-node.mjs +10 -5
- package/src/qa-verdict.mjs +3 -1
- package/src/readback.mjs +1 -0
- package/src/runtime-state-ignore.mjs +44 -19
- package/src/stage-ledger.mjs +35 -9
- package/src/stage-record.mjs +93 -14
package/src/doctor/checks.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { accessSync, constants as fsConstants, existsSync, readdirSync, readFile
|
|
|
6
6
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
|
|
8
8
|
import { ORDER_PATH_DEPTH_DRIFT_CODE, orderPathDepthDriftText, orderPathDepthsDisagree } from "../proof-policy.mjs";
|
|
9
|
-
import { QA_GATE_PLACEHOLDER_TEXT_RESIDUE, qaGatePassedForCurrentBuild } from "../stage-ledger.mjs";
|
|
9
|
+
import { QA_GATE_PLACEHOLDER_TEXT_RESIDUE, qaGatePassedForCurrentBuild, qaRestsOnBaseline } from "../stage-ledger.mjs";
|
|
10
10
|
import {
|
|
11
11
|
validateAdapterDecisionGates,
|
|
12
12
|
validateAdapterDecisionShape,
|
|
@@ -436,6 +436,15 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
436
436
|
phase: "built-output",
|
|
437
437
|
run: ({ packet, errors, warnings, ready, derived, buildState }) => validateBuildOutputFingerprint(packet, errors, warnings, ready, derived, buildState),
|
|
438
438
|
},
|
|
439
|
+
{
|
|
440
|
+
id: "qa.baseline_accepted",
|
|
441
|
+
phase: "built-output",
|
|
442
|
+
run: ({ warnings, buildState }) => {
|
|
443
|
+
const baseline = qaRestsOnBaseline(buildState.report);
|
|
444
|
+
if (!baseline) return;
|
|
445
|
+
addIssue(warnings, "qa.baseline_accepted", `QA rests on a baseline ${baseline.accepted_by} accepted at ${baseline.accepted_at}, not a test. No purchase has been tested since; a completed full \`campaigns-os qa run\` ends it.`, { accepted_by: baseline.accepted_by, accepted_at: baseline.accepted_at });
|
|
446
|
+
},
|
|
447
|
+
},
|
|
439
448
|
{
|
|
440
449
|
id: "built_output.pages",
|
|
441
450
|
phase: "built-output",
|
|
@@ -742,7 +751,10 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
742
751
|
} else if (!isNonEmptyString(localSpecPath)) {
|
|
743
752
|
addIssue(errors, "spec.local_path", "No local CampaignSpec path is present. Assembly must use a local exported CampaignSpec JSON so page coverage, routing, meta tags, and commerce refs are not guessed.");
|
|
744
753
|
} else if (!existsSync(specPath)) {
|
|
745
|
-
|
|
754
|
+
// A saved-Map copy is not on this machine: a checkout made before it was
|
|
755
|
+
// committed, or a machine that never ran the intake.
|
|
756
|
+
const restorable = isNonEmptyString(packet.spec?.map_id) && localSpecPath.includes(".campaign-runtime/fetched-specs/");
|
|
757
|
+
addIssue(errors, "spec.local_path", `CampaignSpec local_path does not exist: ${localSpecPath}${restorable ? `. Restore it from the saved Map with \`campaigns-os spec fetch --packet <packet>\`, then commit it.` : ""}`);
|
|
746
758
|
} else {
|
|
747
759
|
try {
|
|
748
760
|
const loaded = readJson(specPath);
|
|
@@ -1910,7 +1922,20 @@ export function validateBuildOutputFingerprint(packet, errors, warnings, ready,
|
|
|
1910
1922
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1911
1923
|
if (!targetRepo || !publicRouteSlug) return;
|
|
1912
1924
|
const siteRoot = join(targetRepo, "_site", publicRouteSlug);
|
|
1913
|
-
if (!existsSync(siteRoot) || !statSync(siteRoot).isDirectory())
|
|
1925
|
+
if (!existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) {
|
|
1926
|
+
// Nothing to compare, which is not the same as nothing changed: a fresh
|
|
1927
|
+
// checkout has committed records and no _site/, and would otherwise read
|
|
1928
|
+
// as finished however much source has moved since they were made.
|
|
1929
|
+
if (isStageComplete(buildState.report, "assembly")) {
|
|
1930
|
+
addIssue(
|
|
1931
|
+
warnings,
|
|
1932
|
+
"built_output.absent",
|
|
1933
|
+
`_site/${publicRouteSlug}/ is not built here, so the recorded build, Polish and QA were not compared with this checkout. Build it, then run this again.`,
|
|
1934
|
+
{ root: `_site/${publicRouteSlug}/` },
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1914
1939
|
|
|
1915
1940
|
const current = computeBuildFingerprint(siteRoot);
|
|
1916
1941
|
// The root was a directory a moment ago; if it is not one now (removed
|
package/src/doctor/ci.mjs
CHANGED
|
@@ -36,7 +36,7 @@ const commerceReasonText = new Map([
|
|
|
36
36
|
["page_removed", "a page was removed"],
|
|
37
37
|
["checkout_page", "a checkout page changed"],
|
|
38
38
|
["commerce_markup_changed", "package, offer, shipping, upsell, route or script markup changed"],
|
|
39
|
-
["commerce_signature_unavailable", "the recorded build
|
|
39
|
+
["commerce_signature_unavailable", "the recorded build predates the markup hashes page-only changes are read from (Campaigns OS 1.62), so this change cannot be shown to be page-only"],
|
|
40
40
|
["campaign_config_changed", "the campaign's Page Kit config (SDK pin, analytics ids, store fields) changed"],
|
|
41
41
|
["campaign_config_unavailable", "the campaign's Page Kit config entry could not be read"],
|
|
42
42
|
["change_history_unavailable", "the rebuilds since the reviewed build were not kept"],
|
|
@@ -62,6 +62,34 @@ const unavailablePageChanges = new Map([
|
|
|
62
62
|
["build_manifest_unavailable", "no usable assembly manifest is available."],
|
|
63
63
|
]);
|
|
64
64
|
|
|
65
|
+
// What an owed stage's change was read against, for a report that can disagree
|
|
66
|
+
// with the last `record build` because it counts every rebuild since that
|
|
67
|
+
// stage's own review.
|
|
68
|
+
const shortBuild = (value) => String(value).replace(/^sha256:/, "").slice(0, 12);
|
|
69
|
+
const stageNames = { polish: "Polish", qa: "QA" };
|
|
70
|
+
function changeBasisLine(stage, record) {
|
|
71
|
+
const since = record?.change?.since_build_fingerprint;
|
|
72
|
+
if (!since) return null;
|
|
73
|
+
const at = /^\d{4}-\d{2}-\d{2}/.exec(String(record.reviewed_at ?? ""))?.[0];
|
|
74
|
+
return `- ${stageNames[stage]} change is read against the build it last checked (${code(shortBuild(since))}${at ? `, ${at}` : ""}), counting every rebuild since.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Polish and QA read back along one chain of rebuilds, so when both have a
|
|
78
|
+
// commerce rebuild behind a page-only edit it is the same one.
|
|
79
|
+
function uncoveredRebuildLine(ci, stages) {
|
|
80
|
+
const behind = stages.filter((stage) => ci[stage].change?.commerce_rebuild?.latest_change_kind === "page_only");
|
|
81
|
+
if (!behind.length) return null;
|
|
82
|
+
const rebuild = ci[behind[0]].change.commerce_rebuild;
|
|
83
|
+
const at = /^\d{4}-\d{2}-\d{2}/.exec(String(rebuild.recorded_at ?? ""))?.[0];
|
|
84
|
+
const names = behind.map((stage) => stageNames[stage]).join(" and ");
|
|
85
|
+
return `- The latest change alone is page-only. ${names} still ${behind.length > 1 ? "owe" : "owes"} the full stage for an earlier rebuild${at ? ` recorded ${at}` : ""} (${code(shortBuild(rebuild.build_fingerprint))}) since that check.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function baselineLine(baseline) {
|
|
89
|
+
const at = /^\d{4}-\d{2}-\d{2}/.exec(String(baseline.accepted_at ?? ""))?.[0];
|
|
90
|
+
return `QA rests on a baseline ${text(baseline.accepted_by)} accepted${at ? ` on ${at}` : ""}, not a test. No purchase has been tested since.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
65
93
|
const requiredStageReasons = new Map([
|
|
66
94
|
["stage_required_by_build", "the build requires this stage again"],
|
|
67
95
|
["stage_required_by_brief", "the brief requires this stage again"],
|
|
@@ -132,6 +160,12 @@ export function formatDoctorCi(result) {
|
|
|
132
160
|
for (const stage of advisory) {
|
|
133
161
|
lines.push(`- Advisory ${stage}: the change since its last review is page-only, so it is reported and not required.`);
|
|
134
162
|
}
|
|
163
|
+
for (const stage of [...owed, ...advisory]) {
|
|
164
|
+
const basis = changeBasisLine(stage, ci[stage]);
|
|
165
|
+
if (basis) lines.push(basis);
|
|
166
|
+
}
|
|
167
|
+
const uncovered = uncoveredRebuildLine(ci, owed);
|
|
168
|
+
if (uncovered) lines.push(uncovered);
|
|
135
169
|
// The pages changed since the builds the owed stages reviewed, which
|
|
136
170
|
// record build keeps; without them, the drift from the assembly manifest.
|
|
137
171
|
const sincePages = new Map();
|
|
@@ -168,6 +202,7 @@ export function formatDoctorCi(result) {
|
|
|
168
202
|
lines.push("", "Remedy: rebuild, then run `campaigns-os record build --packet <packet>`, `campaigns-os polish capture --packet <packet> --base-url <served-current-build-url>` for the whole route, and `campaigns-os record polish --packet <packet> --evidence <polish-evidence.json>`, in that order. When QA is owed, run `campaigns-os qa run --packet <packet>` against that build.");
|
|
169
203
|
}
|
|
170
204
|
}
|
|
205
|
+
if (ci.qa.baseline) lines.push("", baselineLine(ci.qa.baseline));
|
|
171
206
|
lines.push("", `Doctor inspection: ${(result.errors || []).length} error(s), ${(result.warnings || []).length} warning(s); CI exit status depends only on evidence debt.`);
|
|
172
207
|
if (result.errors?.length) {
|
|
173
208
|
lines.push(`Doctor errors: ${[...new Set(result.errors.map((issue) => issue.code))].map(code).join(", ")}.`);
|
package/src/evidence-debt.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { changeSince, classifyDrift } from "./change-scope.mjs";
|
|
13
13
|
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
14
14
|
import { isFileReadFailure } from "./cart-placeholders.mjs";
|
|
15
|
-
import { polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
|
|
15
|
+
import { polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild, qaRestsOnBaseline } from "./stage-ledger.mjs";
|
|
16
16
|
|
|
17
17
|
export const EVIDENCE_DEBT_STATUSES = Object.freeze(["current", "owed", "unknown"]);
|
|
18
18
|
|
|
@@ -148,6 +148,9 @@ export function assessEvidenceDebt({ packet = null, packetPath = null, report =
|
|
|
148
148
|
if (qa.status === "owed") {
|
|
149
149
|
const extended = qaExtendedBuildFingerprint(report);
|
|
150
150
|
qa.change = changeFor(extended || qaBuild);
|
|
151
|
+
// Only a completed QA dates its own review: a blocked full run reviewed
|
|
152
|
+
// nothing, and a blocked page-scoped run is read from the QA it extended.
|
|
153
|
+
if (extended && extended === qaBuild && fingerprint(qaRecord?.checked_at)) qa.reviewed_at = qaRecord.checked_at.trim();
|
|
151
154
|
qa.scope = extended && qaRecord?.status !== "required" && qa.change.kind === "page_only" ? "pages" : "full";
|
|
152
155
|
qa.owed_pages = qa.scope === "pages" ? qa.change.changed_pages : [];
|
|
153
156
|
} else {
|
|
@@ -155,5 +158,10 @@ export function assessEvidenceDebt({ packet = null, packetPath = null, report =
|
|
|
155
158
|
qa.scope = null;
|
|
156
159
|
qa.owed_pages = [];
|
|
157
160
|
}
|
|
161
|
+
const baseline = qaRestsOnBaseline(report);
|
|
162
|
+
if (baseline) {
|
|
163
|
+
const { accepted_by, accepted_at, reason: acceptedFor, build_fingerprint } = baseline;
|
|
164
|
+
qa.baseline = { accepted_by, accepted_at, reason: acceptedFor, build_fingerprint };
|
|
165
|
+
}
|
|
158
166
|
return { polish, qa, changes };
|
|
159
167
|
}
|
package/src/input-currency.mjs
CHANGED
|
@@ -16,8 +16,10 @@ import { isObject, optionalString, resolveFromFile } from "./cli-helpers.mjs";
|
|
|
16
16
|
import { specMaterialHash } from "./spec-identity.mjs";
|
|
17
17
|
|
|
18
18
|
// Every raw stage status the lifecycle writes. Any other value reads unknown.
|
|
19
|
-
export const RECOGNIZED_STAGE_STATUSES = Object.freeze(["pending", "blocked", "required", "completed", "completed_with_warnings", "completed_partial", "skipped"]);
|
|
20
|
-
|
|
19
|
+
export const RECOGNIZED_STAGE_STATUSES = Object.freeze(["pending", "blocked", "required", "completed", "completed_with_warnings", "completed_partial", "skipped", "baseline_accepted"]);
|
|
20
|
+
// Read for currency, not for proof: a baseline carries input stamps, so a later
|
|
21
|
+
// spec or brief change owes the stage again.
|
|
22
|
+
const COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial", "baseline_accepted"]);
|
|
21
23
|
const NOT_APPLICABLE_STATUSES = Object.freeze(["pending", "blocked", "required", "skipped"]);
|
|
22
24
|
const TERMINAL_EFFECTIVE_STATUSES = Object.freeze([...COMPLETED_STATUSES, "skipped"]);
|
|
23
25
|
|
package/src/input-refresh.mjs
CHANGED
|
@@ -63,7 +63,7 @@ const BRIEF_MAX_BYTES = 1024 * 1024;
|
|
|
63
63
|
const BRIEF_MAX_DESIGN_AUTHORITY_ENTRIES = 512;
|
|
64
64
|
const BRIEF_PROMPT_PREFIX = "BUILD_BRIEF_";
|
|
65
65
|
const BRIEF_GATE_PREFIX = "build_brief.";
|
|
66
|
-
const COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial"]);
|
|
66
|
+
const COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial", "baseline_accepted"]);
|
|
67
67
|
|
|
68
68
|
const isBriefBlocker = (entry) => typeof entry?.code === "string" && (entry.code.startsWith(BRIEF_PROMPT_PREFIX) || entry.code.startsWith(BRIEF_GATE_PREFIX));
|
|
69
69
|
|
package/src/invocation.mjs
CHANGED
|
@@ -65,12 +65,12 @@ const COMMANDS = frozen({
|
|
|
65
65
|
theme: { subcommands: ["generate", "inspect", "waive"] },
|
|
66
66
|
checkpoint: { subcommands: ["accept", "waive"] },
|
|
67
67
|
polish: { subcommands: ["capture"] },
|
|
68
|
-
record: { subcommands: ["brief", "build", "deploy", "polish", "setup", "spec", "theme"] },
|
|
68
|
+
record: { subcommands: ["baseline", "brief", "build", "deploy", "polish", "setup", "spec", "theme"] },
|
|
69
69
|
"validate-assembly-report": {},
|
|
70
70
|
"install-agent-context": { dryRun: true },
|
|
71
71
|
"install-skills": { dryRun: true },
|
|
72
72
|
"page-kit": { subcommands: ["parity", "sync"] },
|
|
73
|
-
spec: { subcommands: ["derive"] },
|
|
73
|
+
spec: { subcommands: ["derive", "fetch"] },
|
|
74
74
|
next: { subcommands: ["build", "deploy", "polish", "qa", "setup"] },
|
|
75
75
|
qa: { subcommands: ["install-browser", "parity", "policy", "promote", "publish", "resolve", "run", "waive"] },
|
|
76
76
|
findings: { subcommands: ["add", "export", "harvest", "list"] },
|
|
@@ -100,6 +100,7 @@ const SUBCOMMAND_OVERRIDES = frozen({
|
|
|
100
100
|
"record polish": { dryRun: true },
|
|
101
101
|
"record setup": { dryRun: true },
|
|
102
102
|
"record spec": { dryRun: true },
|
|
103
|
+
"record baseline": { dryRun: true },
|
|
103
104
|
"record theme": { dryRun: true },
|
|
104
105
|
"qa run": { autoEnd: true },
|
|
105
106
|
"run start": { sweepRoot: "session" },
|
|
@@ -56,8 +56,10 @@ export function reportKeyForCliStage(cliStage) {
|
|
|
56
56
|
* decided on its effective status (src/input-currency.mjs), which the `next`
|
|
57
57
|
* picker and the Assembly Report's derived summary (src/stage-ledger.mjs)
|
|
58
58
|
* read: a stage owed again is not done, whatever status it records.
|
|
59
|
+
* "baseline_accepted" is terminal without being completed: a named person
|
|
60
|
+
* accepted the build (`record baseline`), and nothing tested it.
|
|
59
61
|
*/
|
|
60
|
-
export const STAGE_TERMINAL_STATUS_PREFIXES = Object.freeze(["completed", "skipped"]);
|
|
62
|
+
export const STAGE_TERMINAL_STATUS_PREFIXES = Object.freeze(["completed", "skipped", "baseline_accepted"]);
|
|
61
63
|
|
|
62
64
|
export function stageIsTerminal(status) {
|
|
63
65
|
const normalized = String(status || "");
|
package/src/polish-gate.mjs
CHANGED
|
@@ -393,6 +393,16 @@ function evaluateStructuredPolishGate({ report, required = false, now = Date.now
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
const buildFingerprint = currentBuildFingerprint(report);
|
|
396
|
+
// A baseline reviewed nothing, so it grants no pass; it only stands for its
|
|
397
|
+
// own build. The stale check below handles it once the build has moved on.
|
|
398
|
+
if (report.stages?.polish?.status === "baseline_accepted" && normalizeString(report.stages.polish.source_build_fingerprint) === buildFingerprint) {
|
|
399
|
+
return {
|
|
400
|
+
status: "not_applicable",
|
|
401
|
+
code: "polish.baseline_accepted",
|
|
402
|
+
reason: "Polish was not run for this build: a named person accepted it as a baseline.",
|
|
403
|
+
build_fingerprint: buildFingerprint,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
396
406
|
const currentSourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
397
407
|
const assemblySourcePackageFingerprint = assemblySourcePackageMaterialFingerprint(report);
|
|
398
408
|
const waiverAssessment = assessAssemblySourcePackageFreshnessWaivers(report, now);
|
package/src/qa-browser.mjs
CHANGED
|
@@ -1130,6 +1130,12 @@ async function runPageBrowserChecks(context, page, args, options = {}, policyLin
|
|
|
1130
1130
|
assertions.push(...await templatePlaceholderTextAssertions(browserPage, page, options));
|
|
1131
1131
|
assertions.push(...await templateDemoAssetAssertions(browserPage, page, options));
|
|
1132
1132
|
assertions.push(...await pricingVisibilityAssertions(browserPage, page, options));
|
|
1133
|
+
// Commercial parity compares these with calculated prices once the browser
|
|
1134
|
+
// pass is over; a page never read here stays unresolved there.
|
|
1135
|
+
if (options.renderedPackagePrices && !options.renderedPackagePrices.has(String(page.page_id))) {
|
|
1136
|
+
const rendered = await readRenderedPackagePrices(browserPage);
|
|
1137
|
+
if (rendered) options.renderedPackagePrices.set(String(page.page_id), rendered);
|
|
1138
|
+
}
|
|
1133
1139
|
observation.inspection_ms = elapsedMilliseconds(inspectionStarted);
|
|
1134
1140
|
const debuggerStarted = performance.now();
|
|
1135
1141
|
assertions.push(...await sdkDebuggerAssertions(context, page, args));
|
|
@@ -3104,6 +3110,45 @@ async function pricingVisibilityAssertions(browserPage, page, options = {}) {
|
|
|
3104
3110
|
return [];
|
|
3105
3111
|
}
|
|
3106
3112
|
|
|
3113
|
+
// Runs in the page. Every visible package.price node as the shopper reads it. The
|
|
3114
|
+
// SDK adds `display-visible` when it writes a node, and nothing else does, so a
|
|
3115
|
+
// node without it still holds the template's placeholder, whatever that says.
|
|
3116
|
+
// Same package scope as the SDK: the id in the path, then the nearest package id.
|
|
3117
|
+
// A price the page multiplies by a live quantity is not a unit price: it keeps no
|
|
3118
|
+
// text, so it stays unresolved instead of being compared.
|
|
3119
|
+
function collectRenderedPackagePrices() {
|
|
3120
|
+
const claims = new Map();
|
|
3121
|
+
for (const element of document.querySelectorAll('[data-next-display^="package."]')) {
|
|
3122
|
+
const binding = (element.getAttribute("data-next-display") || "").trim();
|
|
3123
|
+
const match = /^package\.(?:([1-9]\d*)\.)?price$/.exec(binding);
|
|
3124
|
+
if (!match) continue;
|
|
3125
|
+
const format = (element.getAttribute("data-next-format") || "currency").trim().toLowerCase();
|
|
3126
|
+
if (format !== "currency" || element.closest("del, s, strike")) continue;
|
|
3127
|
+
if (!element.checkVisibility({ checkVisibilityCSS: true })) continue;
|
|
3128
|
+
let packageId = match[1] ? Number(match[1]) : null;
|
|
3129
|
+
for (let current = element; packageId === null && current; current = current.parentElement) {
|
|
3130
|
+
for (const attribute of ["data-next-package-id", "data-next-package", "data-package-id"]) {
|
|
3131
|
+
const value = (current.getAttribute(attribute) || "").trim();
|
|
3132
|
+
if (packageId === null && /^[1-9]\d*$/.test(value)) packageId = Number(value);
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
const filled = element.classList.contains("display-visible");
|
|
3136
|
+
const multiplied = element.hasAttribute("data-next-multiply-quantity");
|
|
3137
|
+
const text = filled && !multiplied ? element.textContent.replace(/\s+/g, " ").trim() : null;
|
|
3138
|
+
claims.set(`${binding}|${packageId}|${filled}|${text}`, { binding, package_id: packageId, filled, text });
|
|
3139
|
+
}
|
|
3140
|
+
return [...claims.values()];
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
async function readRenderedPackagePrices(browserPage, { timeoutMs = DEFAULT_SETTLE_TIMEOUT_MS } = {}) {
|
|
3144
|
+
await browserPage.waitForFunction(
|
|
3145
|
+
`(${collectRenderedPackagePrices.toString()})().every((claim) => claim.filled)`,
|
|
3146
|
+
null,
|
|
3147
|
+
{ timeout: timeoutMs },
|
|
3148
|
+
).catch(() => {});
|
|
3149
|
+
return browserPage.evaluate(collectRenderedPackagePrices).catch(() => null);
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3107
3152
|
// The SDK renders a bundle or upsell price through data-next-bundle-display as
|
|
3108
3153
|
// well as data-next-display (#532). A contract surface that lists price rows
|
|
3109
3154
|
// also counts that attribute; an empty list stays empty so a contract that
|
|
@@ -9028,6 +9073,7 @@ export const __qaBrowserTestHooks = Object.freeze({
|
|
|
9028
9073
|
ASSET_FETCH_MAX_BYTES,
|
|
9029
9074
|
paymentChromeResidueAssertion,
|
|
9030
9075
|
pricingVisibilityAssertions,
|
|
9076
|
+
readRenderedPackagePrices,
|
|
9031
9077
|
upsellPriceVisibilityAssertion,
|
|
9032
9078
|
checkoutPriceVisibilityAssertion,
|
|
9033
9079
|
assessReceiptRendering,
|
|
@@ -9,8 +9,12 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
COMMERCIAL_PARITY_SCHEMA_VERSION,
|
|
11
11
|
CommercialParityLimitError,
|
|
12
|
+
PACKAGE_PRICE_NOT_FILLED,
|
|
13
|
+
PACKAGE_PRICE_NOT_RENDERED,
|
|
14
|
+
PACKAGE_PRICE_UNRESOLVED,
|
|
12
15
|
createCommercialParityReport,
|
|
13
16
|
extractCommercialClaims,
|
|
17
|
+
packagePriceClaimCount,
|
|
14
18
|
recurringClaimAbsences,
|
|
15
19
|
recurringClaimAbsentAssertions,
|
|
16
20
|
} from "./commercial-parity.mjs";
|
|
@@ -450,6 +454,7 @@ function catalogImportedAt(spec) {
|
|
|
450
454
|
|
|
451
455
|
function captureClaimCount(capture) {
|
|
452
456
|
return array(capture?.price_claims).length
|
|
457
|
+
+ packagePriceClaimCount(capture)
|
|
453
458
|
+ array(capture?.recurrence_claims).length
|
|
454
459
|
+ array(capture?.vouchers).length;
|
|
455
460
|
}
|
|
@@ -466,6 +471,108 @@ function captureIssues(captures) {
|
|
|
466
471
|
return issues;
|
|
467
472
|
}
|
|
468
473
|
|
|
474
|
+
// One reason a run's commercial proof stopped short: what was not compared and
|
|
475
|
+
// what to do about it, for an agent that reads only the exceptions list. Text
|
|
476
|
+
// only, never a key or a URL: it is persisted.
|
|
477
|
+
function cause(code, summary, fix, count = 1) {
|
|
478
|
+
return { code, count, summary, fix };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// What each reason a package.price went uncompared means for the operator.
|
|
482
|
+
const PACKAGE_PRICE_CAUSES = Object.freeze({
|
|
483
|
+
[PACKAGE_PRICE_NOT_RENDERED]: {
|
|
484
|
+
summary: (count) => `${count} package.price claim(s) in the page source were not read from a rendered page, so they were not compared.`,
|
|
485
|
+
fix: "Run qa run with --browser. If it did, check that the tested URL served this page; prices hidden on purpose need no repair.",
|
|
486
|
+
},
|
|
487
|
+
[PACKAGE_PRICE_NOT_FILLED]: {
|
|
488
|
+
summary: (count) => `The SDK never filled ${count} visible package.price node(s), so a shopper may be reading the template's placeholder price.`,
|
|
489
|
+
fix: "Check that the SDK loaded and that the package ref is on the campaign, then re-run qa run.",
|
|
490
|
+
},
|
|
491
|
+
[PACKAGE_PRICE_UNRESOLVED]: {
|
|
492
|
+
summary: (count) => `${count} package.price claim(s) could not be tied to one calculated price: no package id around the node, no single exact row for the package on the page, or text that is not a money amount.`,
|
|
493
|
+
fix: "Put data-next-package-id around the price, or check it by hand.",
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const OVER_LIMITS_FIX = "This funnel is over the check's limits, so no re-run will cover it: check these prices by hand and tell the operator.";
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The causes of an incomplete proof, the ones found while running first. Later
|
|
501
|
+
* causes stay quiet when an earlier one explains them, so a missing key does not
|
|
502
|
+
* also report every page as unpriced. Not every cause has a repair: a store
|
|
503
|
+
* coupon or a run in another currency only says what was not compared.
|
|
504
|
+
*/
|
|
505
|
+
function incompleteCauses(stopped, commercial, journey) {
|
|
506
|
+
const causes = [...stopped];
|
|
507
|
+
// A page whose capture was refused also counts as missing from the comparison.
|
|
508
|
+
const refused = new Set(array(commercial.invalid_captures).map((capture) => capture.page_id));
|
|
509
|
+
const unread = [
|
|
510
|
+
...array(commercial.invalid_captures),
|
|
511
|
+
...array(commercial.missing_pages).filter((page) => !refused.has(page.page_id)),
|
|
512
|
+
];
|
|
513
|
+
if (unread.length) {
|
|
514
|
+
causes.push(cause(
|
|
515
|
+
"commercial_pages_unread",
|
|
516
|
+
`${unread.length} page(s) could not be read for commercial claims: ${unread.map((page) => page.page_id ?? "(no id)").join(", ")}.`,
|
|
517
|
+
"Fix the page's fetch failure, its URL in the spec or its malformed markup, then re-run qa run. A page over the claim limit needs its prices checked by hand.",
|
|
518
|
+
unread.length,
|
|
519
|
+
));
|
|
520
|
+
}
|
|
521
|
+
if (causes.length) return causes;
|
|
522
|
+
|
|
523
|
+
const inexact = array(journey?.pages).filter((page) => page?.state !== PricingState.Exact);
|
|
524
|
+
if (inexact.length) {
|
|
525
|
+
const named = inexact.map((page) => `${page.page_id} (${page.reason || page.representative_total?.reason || page.state})`);
|
|
526
|
+
return [cause(
|
|
527
|
+
"commercial_page_not_exact",
|
|
528
|
+
`Calculate gave no exact total for ${inexact.length} page(s): ${named.join(", ")}.`,
|
|
529
|
+
"Correct the spec where the reason names it. A store coupon cannot be previewed, so check that page's prices by hand. Re-run qa run if the reason is Stale.",
|
|
530
|
+
inexact.length,
|
|
531
|
+
)];
|
|
532
|
+
}
|
|
533
|
+
if (present(commercial.unresolved_money_claims_reason)) {
|
|
534
|
+
const count = commercial.unresolved_price_claims + commercial.unresolved_recurrence_claims;
|
|
535
|
+
return [cause(
|
|
536
|
+
String(commercial.unresolved_money_claims_reason),
|
|
537
|
+
`${count} price claim(s) in the page source hold the Map currency's fallback text, so they were not compared with totals in the run currency.`,
|
|
538
|
+
"No page repair is owed. A qa run in the campaign's own currency compares them.",
|
|
539
|
+
count,
|
|
540
|
+
)];
|
|
541
|
+
}
|
|
542
|
+
const packagePrices = Object.entries(commercial.package_price_claims?.unresolved || {});
|
|
543
|
+
packagePrices.forEach(([code, count]) => {
|
|
544
|
+
causes.push(cause(code, PACKAGE_PRICE_CAUSES[code].summary(count), PACKAGE_PRICE_CAUSES[code].fix, count));
|
|
545
|
+
});
|
|
546
|
+
const unresolvedPrices = commercial.unresolved_price_claims - packagePrices.reduce((sum, [, count]) => sum + count, 0);
|
|
547
|
+
const unresolved = unresolvedPrices + commercial.unresolved_recurrence_claims + commercial.unresolved_voucher_claims;
|
|
548
|
+
if (unresolved > 0) {
|
|
549
|
+
causes.push(cause(
|
|
550
|
+
"commercial_claims_unresolved",
|
|
551
|
+
`${unresolved} claim(s) could not be tied to one calculated value (${unresolvedPrices} price, ${commercial.unresolved_recurrence_claims} recurring, ${commercial.unresolved_voucher_claims} voucher).`,
|
|
552
|
+
"A page with more than one bound bundle price, or a voucher the plan cannot price, stays uncompared: check these by hand, or declare the voucher in the spec.",
|
|
553
|
+
unresolved,
|
|
554
|
+
));
|
|
555
|
+
}
|
|
556
|
+
return causes;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** One warn row per named cause: what `exceptions[]` carries and the sidecar keeps. */
|
|
560
|
+
function commercialIncompleteAssertions(reasons) {
|
|
561
|
+
return array(reasons).map((entry) => ({
|
|
562
|
+
id: `commercial-parity:incomplete:${entry.code}`,
|
|
563
|
+
family: "pricing",
|
|
564
|
+
page: "campaign",
|
|
565
|
+
status: "warn",
|
|
566
|
+
severity: "warn",
|
|
567
|
+
expected: "the page's commercial claims compared with calculated totals",
|
|
568
|
+
actual: `${entry.summary} Fix: ${entry.fix}`,
|
|
569
|
+
evidence: {
|
|
570
|
+
code: entry.code,
|
|
571
|
+
count: entry.count,
|
|
572
|
+
},
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
|
|
469
576
|
function compactReport(report, journey, plan, executed, issues, observedClaims, claimLimit, plannedScenarioCount = plan.length) {
|
|
470
577
|
const exactPages = array(journey?.pages).filter((page) => page?.state === PricingState.Exact).length;
|
|
471
578
|
const stalePages = array(journey?.pages).filter((page) => page?.state === PricingState.Stale).length;
|
|
@@ -498,6 +605,7 @@ function compactReport(report, journey, plan, executed, issues, observedClaims,
|
|
|
498
605
|
extracted_price_claims: report.extracted_price_claims,
|
|
499
606
|
compared_price_claims: report.compared_price_claims,
|
|
500
607
|
unresolved_price_claims: report.unresolved_price_claims,
|
|
608
|
+
package_price_claims: report.package_price_claims,
|
|
501
609
|
extracted_recurrence_claims: report.extracted_recurrence_claims,
|
|
502
610
|
compared_recurrence_claims: report.compared_recurrence_claims,
|
|
503
611
|
unresolved_recurrence_claims: report.unresolved_recurrence_claims,
|
|
@@ -568,6 +676,7 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
|
|
|
568
676
|
extracted_price_claims: 0,
|
|
569
677
|
compared_price_claims: 0,
|
|
570
678
|
unresolved_price_claims: 0,
|
|
679
|
+
package_price_claims: { extracted: 0, compared: 0, unresolved: {} },
|
|
571
680
|
extracted_recurrence_claims: 0,
|
|
572
681
|
compared_recurrence_claims: 0,
|
|
573
682
|
unresolved_recurrence_claims: 0,
|
|
@@ -586,6 +695,19 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
|
|
|
586
695
|
};
|
|
587
696
|
}
|
|
588
697
|
|
|
698
|
+
/** What a crash in the check reports: the same warn row as any other stop. */
|
|
699
|
+
export function crashedCommercialResult() {
|
|
700
|
+
const reason = cause(
|
|
701
|
+
"commercial_runner_error",
|
|
702
|
+
"The commercial parity check crashed before it finished, so no price was compared.",
|
|
703
|
+
"Re-run qa run. If it crashes again, record it with findings add and tell the operator.",
|
|
704
|
+
);
|
|
705
|
+
return {
|
|
706
|
+
assertions: commercialIncompleteAssertions([reason]),
|
|
707
|
+
commercial: { ...unavailableCommercialReport(reason.code, { status: "incomplete" }), incomplete_reasons: [reason] },
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
589
711
|
// One blocker row per currency the calculate endpoint answered in other than
|
|
590
712
|
// the run's: expected totals were asked for in `currency` and came back priced
|
|
591
713
|
// in another one. Answered codes are compared and named upper-cased, so `usd`
|
|
@@ -641,13 +763,15 @@ export async function runCommercialParity({
|
|
|
641
763
|
const observedClaims = array(captures).reduce((sum, capture) => sum + captureClaimCount(capture), 0);
|
|
642
764
|
const aggregateClaimOverflow = observedClaims > limits.max_aggregate_claims;
|
|
643
765
|
const orderedPreflightIssues = [
|
|
644
|
-
{ exceeded: aggregateClaimOverflow, code: "commercial_aggregate_claim_limit" },
|
|
645
|
-
{ exceeded: scenarioOverflow, code: "commercial_scenario_limit" },
|
|
646
|
-
];
|
|
766
|
+
{ exceeded: aggregateClaimOverflow, code: "commercial_aggregate_claim_limit", summary: `The funnel carries ${observedClaims} commercial claims, over the limit of ${limits.max_aggregate_claims}, so none were compared.` },
|
|
767
|
+
{ exceeded: scenarioOverflow, code: "commercial_scenario_limit", summary: "The funnel plans more calculate scenarios than the limit, so none ran." },
|
|
768
|
+
].filter((issue) => issue.exceeded);
|
|
647
769
|
const issues = [
|
|
648
770
|
...captureIssues(captures),
|
|
649
|
-
...orderedPreflightIssues.
|
|
771
|
+
...orderedPreflightIssues.map(({ code }) => ({ code })),
|
|
650
772
|
];
|
|
773
|
+
// Why calculate did not give this run its answers, said where it is found.
|
|
774
|
+
const stopped = orderedPreflightIssues.map(({ code, summary }) => cause(code, summary, OVER_LIMITS_FIX));
|
|
651
775
|
const apiKeyResolution = resolveCommercialApiKey(resolved);
|
|
652
776
|
const apiKey = apiKeyResolution.value;
|
|
653
777
|
let executed = [];
|
|
@@ -657,8 +781,18 @@ export async function runCommercialParity({
|
|
|
657
781
|
// preflight issues above remain visible when multiple ceilings are hit.
|
|
658
782
|
} else if (apiKeyResolution.unsupported) {
|
|
659
783
|
issues.push({ code: "unsupported_campaigns_api_key_source" });
|
|
784
|
+
stopped.push(cause(
|
|
785
|
+
"unsupported_campaigns_api_key_source",
|
|
786
|
+
`The packet's campaign.api_key_source names an environment variable QA refuses to read, so 0 of ${plan.length} planned calculate scenarios ran.`,
|
|
787
|
+
"Use env:CAMPAIGNS_API_KEY, or set campaign.campaigns_api_key in the CampaignSpec, then re-run qa run.",
|
|
788
|
+
));
|
|
660
789
|
} else if (plan.some((descriptor) => !descriptor?.unresolved) && !present(apiKey)) {
|
|
661
790
|
issues.push({ code: "missing_campaigns_api_key" });
|
|
791
|
+
stopped.push(cause(
|
|
792
|
+
"missing_campaigns_api_key",
|
|
793
|
+
`No campaign key was resolved, so 0 of ${plan.length} planned calculate scenarios ran and no price was compared.`,
|
|
794
|
+
"Set campaign.campaigns_api_key in the CampaignSpec (or the Build Packet) to the campaign's API key, then re-run qa run.",
|
|
795
|
+
));
|
|
662
796
|
} else if (typeof fetchImpl !== "function") {
|
|
663
797
|
issues.push({ code: "price_preview_fetch_unavailable" });
|
|
664
798
|
} else {
|
|
@@ -669,9 +803,17 @@ export async function runCommercialParity({
|
|
|
669
803
|
fetchImpl,
|
|
670
804
|
limits,
|
|
671
805
|
}));
|
|
672
|
-
executed.filter((entry) => entry?.ok === false)
|
|
673
|
-
|
|
674
|
-
});
|
|
806
|
+
const failures = executed.filter((entry) => entry?.ok === false)
|
|
807
|
+
.map((entry) => entry.error_code || "price_preview_failed");
|
|
808
|
+
failures.forEach((code) => issues.push({ code }));
|
|
809
|
+
if (failures.length) {
|
|
810
|
+
stopped.push(cause(
|
|
811
|
+
"price_preview_failed",
|
|
812
|
+
`The calculate endpoint failed for ${failures.length} of ${plan.length} planned scenarios (${[...new Set(failures)].join(", ")}).`,
|
|
813
|
+
"Check that the key belongs to this campaign and that the proxy is reachable, then re-run qa run.",
|
|
814
|
+
failures.length,
|
|
815
|
+
));
|
|
816
|
+
}
|
|
675
817
|
}
|
|
676
818
|
|
|
677
819
|
const journey = normalizeJourney(
|
|
@@ -715,17 +857,30 @@ export async function runCommercialParity({
|
|
|
715
857
|
maxAssertions: Math.max(0, maxAssertions - parity.assertions.length),
|
|
716
858
|
});
|
|
717
859
|
parity.recurring_claim_absences = absences;
|
|
860
|
+
const commercial = compactReport(
|
|
861
|
+
parity,
|
|
862
|
+
parityJourney,
|
|
863
|
+
plan,
|
|
864
|
+
executed,
|
|
865
|
+
issues,
|
|
866
|
+
observedClaims,
|
|
867
|
+
limits.max_aggregate_claims,
|
|
868
|
+
commercialPlanning.observed_scenarios ?? plan.length,
|
|
869
|
+
);
|
|
870
|
+
const assertions = [...currencyAssertions, ...parity.assertions, ...absentAssertions];
|
|
871
|
+
if (commercial.status !== "incomplete") return { assertions, commercial };
|
|
872
|
+
// An incomplete run always names a cause in the exceptions list. A currency
|
|
873
|
+
// blocker or an absent rebill claim already wrote its own row.
|
|
874
|
+
const reasons = incompleteCauses(stopped, commercial, parityJourney);
|
|
875
|
+
if (!reasons.length && !currencyAssertions.length && !absentAssertions.length) {
|
|
876
|
+
reasons.push(cause(
|
|
877
|
+
"commercial_coverage_incomplete",
|
|
878
|
+
`Commercial parity stopped short: issues ${array(commercial.issues).map((issue) => issue.code).join(", ") || "none"}.`,
|
|
879
|
+
"Read verdict.commercial for what was and was not compared, and tell the operator.",
|
|
880
|
+
));
|
|
881
|
+
}
|
|
718
882
|
return {
|
|
719
|
-
assertions: [...
|
|
720
|
-
commercial:
|
|
721
|
-
parity,
|
|
722
|
-
parityJourney,
|
|
723
|
-
plan,
|
|
724
|
-
executed,
|
|
725
|
-
issues,
|
|
726
|
-
observedClaims,
|
|
727
|
-
limits.max_aggregate_claims,
|
|
728
|
-
commercialPlanning.observed_scenarios ?? plan.length,
|
|
729
|
-
),
|
|
883
|
+
assertions: [...assertions, ...commercialIncompleteAssertions(reasons)],
|
|
884
|
+
commercial: { ...commercial, incomplete_reasons: reasons },
|
|
730
885
|
};
|
|
731
886
|
}
|
package/src/qa-node.mjs
CHANGED
|
@@ -103,6 +103,7 @@ import {
|
|
|
103
103
|
import {
|
|
104
104
|
captureCommercialClaims,
|
|
105
105
|
COMMERCIAL_QA_LIMITS,
|
|
106
|
+
crashedCommercialResult,
|
|
106
107
|
mapConcurrent,
|
|
107
108
|
createPageSourceLoader,
|
|
108
109
|
planCommercialParity,
|
|
@@ -2609,8 +2610,10 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
|
|
|
2609
2610
|
const vendorBeacons = vendorBeaconRun ? createVendorBeaconLedger({ mode: vendorBeaconRun, analytics: resolved.spec?.analytics }) : null;
|
|
2610
2611
|
if (vendorBeacons) args = { ...args, [QA_VENDOR_BEACONS]: vendorBeacons };
|
|
2611
2612
|
const qcResults = [];
|
|
2613
|
+
const renderedPackagePrices = new Map();
|
|
2612
2614
|
if (args.browser === true) {
|
|
2613
2615
|
const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
|
|
2616
|
+
renderedPackagePrices,
|
|
2614
2617
|
brandContract: resolved.brandContract,
|
|
2615
2618
|
// With no generatable brand theme on the local preview, the starter
|
|
2616
2619
|
// template is the design: its residue is a warning (local-preview-policy.mjs).
|
|
@@ -2649,16 +2652,15 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
|
|
|
2649
2652
|
try {
|
|
2650
2653
|
commercialResult = await runCommercialParity({
|
|
2651
2654
|
resolved,
|
|
2652
|
-
captures: [...capturesByPageId.
|
|
2655
|
+
captures: [...capturesByPageId.entries()].map(([pageId, capture]) => (renderedPackagePrices.has(pageId)
|
|
2656
|
+
? { ...capture, rendered_package_prices: renderedPackagePrices.get(pageId) }
|
|
2657
|
+
: capture)),
|
|
2653
2658
|
planning: commercialPlanning,
|
|
2654
2659
|
maxAssertions: remainingAssertionBudget,
|
|
2655
2660
|
currency,
|
|
2656
2661
|
});
|
|
2657
2662
|
} catch (error) {
|
|
2658
|
-
commercialResult =
|
|
2659
|
-
assertions: [],
|
|
2660
|
-
commercial: unavailableCommercialReport("commercial_runner_error"),
|
|
2661
|
-
};
|
|
2663
|
+
commercialResult = crashedCommercialResult();
|
|
2662
2664
|
reportCommercialRunnerError(args, error);
|
|
2663
2665
|
}
|
|
2664
2666
|
assertions.push(...commercialResult.assertions);
|
|
@@ -3795,6 +3797,9 @@ function output(value, args) {
|
|
|
3795
3797
|
printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
|
|
3796
3798
|
if (value.commercial) {
|
|
3797
3799
|
console.log(`Commercial parity: ${value.commercial.status} (${value.commercial.finding_count || 0} findings, ${value.commercial.checked_pages || 0} pages checked)`);
|
|
3800
|
+
for (const entry of value.commercial.incomplete_reasons || []) {
|
|
3801
|
+
console.log(` ${entry.code}: ${entry.summary} Fix: ${entry.fix}`);
|
|
3802
|
+
}
|
|
3798
3803
|
}
|
|
3799
3804
|
console.log(`Local copy: ${value.local_path}`);
|
|
3800
3805
|
if (value.posted?.ok && value.dashboard_url) {
|
package/src/qa-verdict.mjs
CHANGED
|
@@ -88,7 +88,9 @@ export function computeDisposition(assertions) {
|
|
|
88
88
|
function dispositionWithCommercial(assertions, commercial) {
|
|
89
89
|
const disposition = computeDisposition(assertions);
|
|
90
90
|
if (disposition === "blocked") return disposition;
|
|
91
|
-
|
|
91
|
+
// Warn-only either way: a proven mismatch and a proof that stopped short are
|
|
92
|
+
// both things the operator has to hear about, and neither ever blocks.
|
|
93
|
+
return commercial?.status === "mismatch" || commercial?.status === "incomplete"
|
|
92
94
|
? "ready_with_exceptions"
|
|
93
95
|
: disposition;
|
|
94
96
|
}
|
package/src/readback.mjs
CHANGED
|
@@ -1310,6 +1310,7 @@ function renderEvidenceDebt(debt, lines) {
|
|
|
1310
1310
|
: `evidence currency is unknown (${stage.reason}); absence of proof is not no debt`;
|
|
1311
1311
|
lines.push(` ${name}: ${stage.status} — ${explanation}`);
|
|
1312
1312
|
lines.push(` recorded: ${stage.recorded_build_fingerprint ?? "unknown"}; current: ${stage.current_build_fingerprint ?? "unknown"}`);
|
|
1313
|
+
if (stage.baseline) lines.push(` baseline: accepted by ${oneLine(stage.baseline.accepted_by)} at ${oneLine(stage.baseline.accepted_at)}, not tested; no purchase tested since`);
|
|
1313
1314
|
if (stage.change) {
|
|
1314
1315
|
const owes = stage.advisory ? "advisory: reported, not required"
|
|
1315
1316
|
: stage.scope === "pages" ? `owed on ${stage.owed_pages.map((page) => oneLine(page.page_id)).join(", ")} (qa run --page)` : "the full stage is owed";
|