@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/campaign-spec/dist/types.d.ts +1 -1
  3. package/compatibility.json +1 -1
  4. package/contracts/effects.v1.json +129 -1
  5. package/contracts/release-ledger.json +549 -0
  6. package/contracts/supported-surface.json +3 -3
  7. package/docs/build-packet.md +31 -5
  8. package/docs/campaigns-os-build-flow.md +38 -0
  9. package/docs/campaignspec-authoring-examples.md +3 -2
  10. package/docs/effects.md +8 -0
  11. package/docs/local-setup.md +1 -1
  12. package/docs/orientation-contract-reference.md +1 -1
  13. package/docs/qa-and-test-orders.md +44 -10
  14. package/docs/readback.md +8 -1
  15. package/docs/runtime-readiness.md +1 -1
  16. package/docs/skills-revision.md +10 -10
  17. package/docs/supported-surface.md +1 -1
  18. package/package.json +1 -1
  19. package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +3 -3
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +4 -4
  29. package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
  30. package/skills.json +11 -11
  31. package/src/change-scope.mjs +25 -6
  32. package/src/cli.mjs +120 -7
  33. package/src/commercial-journey.mjs +3 -0
  34. package/src/commercial-parity.mjs +157 -7
  35. package/src/doctor/checks.mjs +28 -3
  36. package/src/doctor/ci.mjs +36 -1
  37. package/src/evidence-debt.mjs +9 -1
  38. package/src/input-currency.mjs +4 -2
  39. package/src/input-refresh.mjs +1 -1
  40. package/src/invocation.mjs +3 -2
  41. package/src/orchestration-stage-contract.mjs +3 -1
  42. package/src/polish-gate.mjs +10 -0
  43. package/src/qa-browser.mjs +46 -0
  44. package/src/qa-commercial-parity.mjs +173 -18
  45. package/src/qa-node.mjs +10 -5
  46. package/src/qa-verdict.mjs +3 -1
  47. package/src/readback.mjs +1 -0
  48. package/src/runtime-state-ignore.mjs +44 -19
  49. package/src/stage-ledger.mjs +35 -9
  50. package/src/stage-record.mjs +93 -14
@@ -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
- addIssue(errors, "spec.local_path", `CampaignSpec local_path does not exist: ${localSpecPath}`);
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()) return;
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 has no commerce signature to compare against"],
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(", ")}.`);
@@ -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
  }
@@ -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
- const COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial"]);
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
 
@@ -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
 
@@ -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 || "");
@@ -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);
@@ -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.filter((issue) => issue.exceeded).map(({ code }) => ({ code })),
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).forEach((entry) => {
673
- issues.push({ code: entry.error_code || "price_preview_failed" });
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: [...currencyAssertions, ...parity.assertions, ...absentAssertions],
720
- commercial: compactReport(
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.values()],
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) {
@@ -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
- return commercial?.status === "mismatch"
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";