@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.
- package/CHANGELOG.md +71 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +1 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +1 -1
- package/campaign-spec/dist/rules/index.d.ts +1 -0
- package/campaign-spec/dist/rules/index.js +3 -0
- package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
- package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
- package/campaign-spec/dist/types.d.ts +16 -0
- package/compatibility.json +1 -1
- package/contracts/effects.v1.json +129 -1
- package/contracts/fixtures/runtime-recipe/accept/current.json +1 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +1 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +1 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +1 -0
- package/contracts/release-ledger.json +810 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +1 -0
- package/contracts/supported-surface.json +6 -6
- package/docs/build-packet.md +31 -5
- package/docs/campaigns-os-build-flow.md +38 -0
- package/docs/campaignspec-authoring-examples.md +31 -0
- 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 +4 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/package.json +1 -1
- package/schemas/campaign-spec.v4.schema.json +15 -0
- package/schemas/campaign-spec.v5.schema.json +15 -0
- 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
|
@@ -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
|
|
5
|
-
* after readiness, which can hide an authored mismatch in the
|
|
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: {
|
|
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
|
-
|
|
971
|
-
|
|
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,
|
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,
|