@nextcommerce/campaigns-os 1.62.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 (74) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/campaign-spec/dist/rules/campaign-currencies.d.ts +1 -0
  3. package/campaign-spec/dist/rules/campaign-currencies.js +1 -1
  4. package/campaign-spec/dist/rules/index.d.ts +1 -0
  5. package/campaign-spec/dist/rules/index.js +3 -0
  6. package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
  7. package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
  8. package/campaign-spec/dist/types.d.ts +16 -0
  9. package/compatibility.json +1 -1
  10. package/contracts/effects.v1.json +129 -1
  11. package/contracts/fixtures/runtime-recipe/accept/current.json +1 -0
  12. package/contracts/fixtures/runtime-recipe/accept/minimal.json +1 -0
  13. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  14. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +1 -0
  15. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +1 -0
  16. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +1 -0
  17. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +1 -0
  18. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +1 -0
  19. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +1 -0
  20. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +1 -0
  21. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +1 -0
  22. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +1 -0
  23. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +1 -0
  24. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +1 -0
  25. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +1 -0
  26. package/contracts/release-ledger.json +810 -0
  27. package/contracts/runtime-recipe.campaigns-os-node-v1.json +1 -0
  28. package/contracts/supported-surface.json +6 -6
  29. package/docs/build-packet.md +31 -5
  30. package/docs/campaigns-os-build-flow.md +38 -0
  31. package/docs/campaignspec-authoring-examples.md +31 -0
  32. package/docs/effects.md +8 -0
  33. package/docs/local-setup.md +1 -1
  34. package/docs/orientation-contract-reference.md +1 -1
  35. package/docs/qa-and-test-orders.md +44 -10
  36. package/docs/readback.md +8 -1
  37. package/docs/runtime-readiness.md +4 -3
  38. package/docs/skills-revision.md +10 -10
  39. package/docs/supported-surface.md +1 -1
  40. package/package.json +1 -1
  41. package/schemas/campaign-spec.v4.schema.json +15 -0
  42. package/schemas/campaign-spec.v5.schema.json +15 -0
  43. package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
  44. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  45. package/skills/campaign-readback-classification/SKILL.md +3 -3
  46. package/skills/campaign-run-evidence/SKILL.md +3 -3
  47. package/skills/contribution-intake/SKILL.md +3 -3
  48. package/skills/next-campaigns-build/SKILL.md +3 -3
  49. package/skills/next-campaigns-os/SKILL.md +3 -3
  50. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  51. package/skills/next-campaigns-polish/SKILL.md +3 -3
  52. package/skills/next-campaigns-qa/SKILL.md +4 -4
  53. package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
  54. package/skills.json +11 -11
  55. package/src/change-scope.mjs +25 -6
  56. package/src/cli.mjs +120 -7
  57. package/src/commercial-journey.mjs +3 -0
  58. package/src/commercial-parity.mjs +157 -7
  59. package/src/doctor/checks.mjs +28 -3
  60. package/src/doctor/ci.mjs +36 -1
  61. package/src/evidence-debt.mjs +9 -1
  62. package/src/input-currency.mjs +4 -2
  63. package/src/input-refresh.mjs +1 -1
  64. package/src/invocation.mjs +3 -2
  65. package/src/orchestration-stage-contract.mjs +3 -1
  66. package/src/polish-gate.mjs +10 -0
  67. package/src/qa-browser.mjs +46 -0
  68. package/src/qa-commercial-parity.mjs +173 -18
  69. package/src/qa-node.mjs +10 -5
  70. package/src/qa-verdict.mjs +3 -1
  71. package/src/readback.mjs +1 -0
  72. package/src/runtime-state-ignore.mjs +44 -19
  73. package/src/stage-ledger.mjs +35 -9
  74. package/src/stage-record.mjs +93 -14
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Dependency-free extraction and Exact-only commercial parity comparison.
3
3
  *
4
- * Source HTML is intentional: Campaign Cart replaces authored placeholders
5
- * after readiness, which can hide an authored mismatch in the rendered DOM.
4
+ * Source HTML is intentional for bundle prices: Campaign Cart replaces authored
5
+ * placeholders after readiness, which can hide an authored mismatch in the
6
+ * rendered DOM. package.price is the other way round: its authored text is a
7
+ * template placeholder no shopper is meant to read, so only its rendered value
8
+ * is compared and the source merely says the claim exists.
6
9
  */
7
10
 
8
11
  import {
@@ -416,6 +419,48 @@ function governedPriceBinding(value) {
416
419
  return typeof value === "string" && /^bundle\.[^.]+\.price$/.test(value.trim());
417
420
  }
418
421
 
422
+ // The SDK's own package.price paths: an id in the path, or none and the nearest
423
+ // package id around the node. No other package.* field is a price claim here.
424
+ const PACKAGE_PRICE_BINDING = /^package\.(?:([1-9]\d*)\.)?price$/;
425
+ const PACKAGE_CONTEXT_ATTRIBUTES = Object.freeze(["data-next-package-id", "data-next-package", "data-package-id"]);
426
+
427
+ export const PACKAGE_PRICE_NOT_RENDERED = "package_price_not_rendered";
428
+ export const PACKAGE_PRICE_NOT_FILLED = "package_price_not_filled";
429
+ export const PACKAGE_PRICE_UNRESOLVED = "package_price_unresolved";
430
+
431
+ function packageContextId(node) {
432
+ for (let current = node; current; current = current.parent) {
433
+ for (const attribute of PACKAGE_CONTEXT_ATTRIBUTES) {
434
+ const id = positiveInteger(current.attrs?.[attribute]);
435
+ if (id !== null) return id;
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ // Lists the claims, deliberately without their text: what the source holds is
442
+ // the template's placeholder, and comparing it would report every starter page.
443
+ function extractPackagePrices(nodeIndex, consumeClaim) {
444
+ const seen = new Set();
445
+ const claims = [];
446
+ nodeIndex.nodes.forEach((node) => {
447
+ const binding = String(node.attrs?.["data-next-display"] || "").trim();
448
+ const match = PACKAGE_PRICE_BINDING.exec(binding);
449
+ const format = String(node.attrs?.["data-next-format"] || "currency").trim().toLowerCase();
450
+ if (!match
451
+ || format !== "currency"
452
+ || nodeIndex.hidden.get(node) === true
453
+ || nodeIndex.comparison.get(node) === true) return;
454
+ const packageId = positiveInteger(match[1]) ?? packageContextId(node);
455
+ const key = `${binding}\u0000${packageId}`;
456
+ if (seen.has(key)) return;
457
+ seen.add(key);
458
+ consumeClaim();
459
+ claims.push({ binding, package_id: packageId });
460
+ });
461
+ return claims;
462
+ }
463
+
419
464
  function extractPrices(nodeIndex, consumeClaim) {
420
465
  const seen = new Set();
421
466
  const prices = [];
@@ -529,6 +574,7 @@ export function extractCommercialClaims(html, options = {}) {
529
574
  }
530
575
  };
531
576
  const priceClaims = extractPrices(nodeIndex, consumeClaim);
577
+ const packagePriceClaims = extractPackagePrices(nodeIndex, consumeClaim);
532
578
  const recurrenceClaims = extractRecurrences(nodeIndex, extractionErrors, consumeClaim);
533
579
  const vouchers = extractVouchers(nodeIndex, extractionErrors, consumeClaim);
534
580
  return {
@@ -537,6 +583,7 @@ export function extractCommercialClaims(html, options = {}) {
537
583
  price_claims: priceClaims,
538
584
  recurrence_claims: recurrenceClaims,
539
585
  vouchers,
586
+ ...(packagePriceClaims.length ? { package_price_claims: packagePriceClaims } : {}),
540
587
  ...(extractionErrors.length ? { extraction_errors: extractionErrors } : {}),
541
588
  };
542
589
  }
@@ -563,8 +610,23 @@ function validVoucherClaim(claim) {
563
610
  return record(claim) && typeof claim.code === "string" && present(claim.code);
564
611
  }
565
612
 
613
+ function validPackagePriceClaim(claim) {
614
+ return record(claim)
615
+ && typeof claim.binding === "string"
616
+ && PACKAGE_PRICE_BINDING.test(claim.binding)
617
+ && (claim.package_id === null || positiveInteger(claim.package_id) !== null);
618
+ }
619
+
620
+ function validRenderedPackagePrice(claim) {
621
+ return validPackagePriceClaim(claim)
622
+ && typeof claim.filled === "boolean"
623
+ && (claim.text === null || typeof claim.text === "string");
624
+ }
625
+
566
626
  function validCaptureEnvelope(capture) {
567
627
  if (!record(capture)) return false;
628
+ if (capture.package_price_claims !== undefined && !Array.isArray(capture.package_price_claims)) return false;
629
+ if (capture.rendered_package_prices !== undefined && !Array.isArray(capture.rendered_package_prices)) return false;
568
630
  if (capture.page_id !== undefined
569
631
  && capture.page_id !== null
570
632
  && (typeof capture.page_id !== "string" || !present(capture.page_id))) return false;
@@ -580,7 +642,9 @@ function validCapture(capture) {
580
642
  return validCaptureEnvelope(capture)
581
643
  && capture.price_claims.every(validPriceClaim)
582
644
  && capture.recurrence_claims.every(validRecurrenceClaim)
583
- && capture.vouchers.every(validVoucherClaim);
645
+ && capture.vouchers.every(validVoucherClaim)
646
+ && array(capture.package_price_claims).every(validPackagePriceClaim)
647
+ && array(capture.rendered_package_prices).every(validRenderedPackagePrice);
584
648
  }
585
649
 
586
650
  function captureCandidates(capturesValue) {
@@ -626,6 +690,64 @@ function priceComparisonClaims(capture, page) {
626
690
  return { compared, unresolved: claims.length - compared.length };
627
691
  }
628
692
 
693
+ // Printed money as the SDK formats it: a symbol or code around one amount. A
694
+ // decimal comma or anything else stays unread, so it is unresolved, not wrong.
695
+ function renderedMoney(text) {
696
+ const match = decodeEntities(String(text ?? "")).replace(/[\u00a0\s]+/g, " ").trim()
697
+ .match(/^[^\d-]*(\d[\d,]*(?:\.\d{1,2})?)\s*[A-Za-z]*$/);
698
+ return match ? normalizeMoney(match[1]) : null;
699
+ }
700
+
701
+ // One distinct Exact per-unit price for the package on this page, or nothing:
702
+ // two rows of one package at different unit prices (quantity tiers, say) do not
703
+ // say which one a generic package.price means.
704
+ function exactPackageUnitPrice(page, packageId) {
705
+ const prices = new Set(array(page?.rows)
706
+ .filter((row) => String(row?.package_id) === String(packageId))
707
+ .map((row) => (row?.state === PricingState.Exact ? exactMoneyFact(row?.unit_price)?.value : null)));
708
+ return prices.size === 1 ? [...prices][0] ?? null : null;
709
+ }
710
+
711
+ // The rendered read is the authority once it found any. When it found none, the
712
+ // source still says the claims exist, so they are not allowed to disappear.
713
+ export function packagePriceClaimCount(capture) {
714
+ const rendered = array(capture?.rendered_package_prices);
715
+ return rendered.length || array(capture?.package_price_claims).length;
716
+ }
717
+
718
+ /**
719
+ * Sort a page's package.price claims into compared and unresolved. Only a
720
+ * rendered value the SDK filled is ever compared. Each unresolved claim names
721
+ * its reason, so the run can say what to do about it. Distinct claims only: one
722
+ * price repeated around an upsell page is one claim.
723
+ */
724
+ function packagePriceComparisonClaims(capture, page) {
725
+ const rendered = array(capture?.rendered_package_prices);
726
+ if (!rendered.length) {
727
+ return {
728
+ compared: [],
729
+ unresolved: array(capture?.package_price_claims).map(() => PACKAGE_PRICE_NOT_RENDERED),
730
+ };
731
+ }
732
+ const compared = [];
733
+ const unresolved = [];
734
+ const voucherUnpriced = voucherComparisonClaims(capture, page).unresolved > 0;
735
+ rendered.forEach((claim) => {
736
+ if (!claim?.filled) {
737
+ unresolved.push(PACKAGE_PRICE_NOT_FILLED);
738
+ return;
739
+ }
740
+ const value = renderedMoney(claim.text);
741
+ const truth = claim.package_id === null || voucherUnpriced ? null : exactPackageUnitPrice(page, claim.package_id);
742
+ if (!value || !truth) {
743
+ unresolved.push(PACKAGE_PRICE_UNRESOLVED);
744
+ return;
745
+ }
746
+ compared.push({ package_id: claim.package_id, value, truth });
747
+ });
748
+ return { compared, unresolved };
749
+ }
750
+
629
751
  function pageForCapture(capture, journey) {
630
752
  const pages = array(journey?.pages);
631
753
  if (present(capture?.page_id)) {
@@ -745,6 +867,19 @@ export function diffCommercialParity(capturesValue, journey, options = {}) {
745
867
  });
746
868
  }
747
869
 
870
+ (compareMoney ? packagePriceComparisonClaims(capture, page).compared : []).forEach(({ package_id: packageId, value, truth }) => {
871
+ if (moneyToCents(value) === moneyToCents(truth)) return;
872
+ findings.push({
873
+ type: "price-claim-mismatch",
874
+ page_id: pageId,
875
+ ...source,
876
+ binding: `package.${packageId}.price`,
877
+ package_id: packageId,
878
+ claimed: value,
879
+ calculated: truth,
880
+ });
881
+ });
882
+
748
883
  (compareMoney ? recurrenceComparisonClaims(capture, page).compared : []).forEach(({ claim, truth }) => {
749
884
  const packageId = positiveInteger(claim?.package_id);
750
885
  const claimed = {
@@ -817,7 +952,11 @@ function assertionForFinding(finding, id) {
817
952
  ...common,
818
953
  expected: finding.calculated,
819
954
  actual: finding.claimed,
820
- evidence: { type: finding.type, binding: finding.binding },
955
+ evidence: {
956
+ type: finding.type,
957
+ binding: finding.binding,
958
+ ...(finding.package_id !== undefined ? { package_id: finding.package_id } : {}),
959
+ },
821
960
  };
822
961
  }
823
962
  if (finding.type === "cadence-disclosure-mismatch") {
@@ -952,7 +1091,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
952
1091
  .filter((page) => !pageKeys.has(String(page?.page_id ?? "page")))
953
1092
  .map((page) => ({ page_id: present(page?.page_id) ? String(page.page_id) : null }));
954
1093
  const extractedPriceClaims = matched.reduce(
955
- (sum, { capture }) => sum + capture.price_claims.length,
1094
+ (sum, { capture }) => sum + capture.price_claims.length + packagePriceClaimCount(capture),
956
1095
  0,
957
1096
  );
958
1097
  const extractedRecurrenceClaims = matched.reduce(
@@ -963,12 +1102,22 @@ export function createCommercialParityReport(capturesValue, journey, options = {
963
1102
  (sum, { capture }) => sum + capture.vouchers.length,
964
1103
  0,
965
1104
  );
1105
+ const packagePriceCounts = {
1106
+ extracted: matched.reduce((sum, { capture }) => sum + packagePriceClaimCount(capture), 0),
1107
+ compared: 0,
1108
+ };
1109
+ const packagePriceUnresolved = {};
966
1110
  const priceComparisonCounts = countsOnly || moneyReason
967
1111
  ? { compared: 0, unresolved: extractedPriceClaims }
968
1112
  : matched.reduce((counts, { capture, page }) => {
969
1113
  const comparison = priceComparisonClaims(capture, page);
970
- counts.compared += comparison.compared.length;
971
- counts.unresolved += comparison.unresolved;
1114
+ const packagePrices = packagePriceComparisonClaims(capture, page);
1115
+ packagePriceCounts.compared += packagePrices.compared.length;
1116
+ packagePrices.unresolved.forEach((code) => {
1117
+ packagePriceUnresolved[code] = (packagePriceUnresolved[code] || 0) + 1;
1118
+ });
1119
+ counts.compared += comparison.compared.length + packagePrices.compared.length;
1120
+ counts.unresolved += comparison.unresolved + packagePrices.unresolved.length;
972
1121
  return counts;
973
1122
  }, { compared: 0, unresolved: 0 });
974
1123
  const recurrenceComparisonCounts = countsOnly || moneyReason
@@ -1007,6 +1156,7 @@ export function createCommercialParityReport(capturesValue, journey, options = {
1007
1156
  extracted_price_claims: extractedPriceClaims,
1008
1157
  compared_price_claims: priceComparisonCounts.compared,
1009
1158
  unresolved_price_claims: priceComparisonCounts.unresolved,
1159
+ package_price_claims: { ...packagePriceCounts, unresolved: packagePriceUnresolved },
1010
1160
  extracted_recurrence_claims: extractedRecurrenceClaims,
1011
1161
  compared_recurrence_claims: recurrenceComparisonCounts.compared,
1012
1162
  unresolved_recurrence_claims: recurrenceComparisonCounts.unresolved,
@@ -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,