@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
|
@@ -9,8 +9,12 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
COMMERCIAL_PARITY_SCHEMA_VERSION,
|
|
11
11
|
CommercialParityLimitError,
|
|
12
|
+
PACKAGE_PRICE_NOT_FILLED,
|
|
13
|
+
PACKAGE_PRICE_NOT_RENDERED,
|
|
14
|
+
PACKAGE_PRICE_UNRESOLVED,
|
|
12
15
|
createCommercialParityReport,
|
|
13
16
|
extractCommercialClaims,
|
|
17
|
+
packagePriceClaimCount,
|
|
14
18
|
recurringClaimAbsences,
|
|
15
19
|
recurringClaimAbsentAssertions,
|
|
16
20
|
} from "./commercial-parity.mjs";
|
|
@@ -450,6 +454,7 @@ function catalogImportedAt(spec) {
|
|
|
450
454
|
|
|
451
455
|
function captureClaimCount(capture) {
|
|
452
456
|
return array(capture?.price_claims).length
|
|
457
|
+
+ packagePriceClaimCount(capture)
|
|
453
458
|
+ array(capture?.recurrence_claims).length
|
|
454
459
|
+ array(capture?.vouchers).length;
|
|
455
460
|
}
|
|
@@ -466,6 +471,108 @@ function captureIssues(captures) {
|
|
|
466
471
|
return issues;
|
|
467
472
|
}
|
|
468
473
|
|
|
474
|
+
// One reason a run's commercial proof stopped short: what was not compared and
|
|
475
|
+
// what to do about it, for an agent that reads only the exceptions list. Text
|
|
476
|
+
// only, never a key or a URL: it is persisted.
|
|
477
|
+
function cause(code, summary, fix, count = 1) {
|
|
478
|
+
return { code, count, summary, fix };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// What each reason a package.price went uncompared means for the operator.
|
|
482
|
+
const PACKAGE_PRICE_CAUSES = Object.freeze({
|
|
483
|
+
[PACKAGE_PRICE_NOT_RENDERED]: {
|
|
484
|
+
summary: (count) => `${count} package.price claim(s) in the page source were not read from a rendered page, so they were not compared.`,
|
|
485
|
+
fix: "Run qa run with --browser. If it did, check that the tested URL served this page; prices hidden on purpose need no repair.",
|
|
486
|
+
},
|
|
487
|
+
[PACKAGE_PRICE_NOT_FILLED]: {
|
|
488
|
+
summary: (count) => `The SDK never filled ${count} visible package.price node(s), so a shopper may be reading the template's placeholder price.`,
|
|
489
|
+
fix: "Check that the SDK loaded and that the package ref is on the campaign, then re-run qa run.",
|
|
490
|
+
},
|
|
491
|
+
[PACKAGE_PRICE_UNRESOLVED]: {
|
|
492
|
+
summary: (count) => `${count} package.price claim(s) could not be tied to one calculated price: no package id around the node, no single exact row for the package on the page, or text that is not a money amount.`,
|
|
493
|
+
fix: "Put data-next-package-id around the price, or check it by hand.",
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const OVER_LIMITS_FIX = "This funnel is over the check's limits, so no re-run will cover it: check these prices by hand and tell the operator.";
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The causes of an incomplete proof, the ones found while running first. Later
|
|
501
|
+
* causes stay quiet when an earlier one explains them, so a missing key does not
|
|
502
|
+
* also report every page as unpriced. Not every cause has a repair: a store
|
|
503
|
+
* coupon or a run in another currency only says what was not compared.
|
|
504
|
+
*/
|
|
505
|
+
function incompleteCauses(stopped, commercial, journey) {
|
|
506
|
+
const causes = [...stopped];
|
|
507
|
+
// A page whose capture was refused also counts as missing from the comparison.
|
|
508
|
+
const refused = new Set(array(commercial.invalid_captures).map((capture) => capture.page_id));
|
|
509
|
+
const unread = [
|
|
510
|
+
...array(commercial.invalid_captures),
|
|
511
|
+
...array(commercial.missing_pages).filter((page) => !refused.has(page.page_id)),
|
|
512
|
+
];
|
|
513
|
+
if (unread.length) {
|
|
514
|
+
causes.push(cause(
|
|
515
|
+
"commercial_pages_unread",
|
|
516
|
+
`${unread.length} page(s) could not be read for commercial claims: ${unread.map((page) => page.page_id ?? "(no id)").join(", ")}.`,
|
|
517
|
+
"Fix the page's fetch failure, its URL in the spec or its malformed markup, then re-run qa run. A page over the claim limit needs its prices checked by hand.",
|
|
518
|
+
unread.length,
|
|
519
|
+
));
|
|
520
|
+
}
|
|
521
|
+
if (causes.length) return causes;
|
|
522
|
+
|
|
523
|
+
const inexact = array(journey?.pages).filter((page) => page?.state !== PricingState.Exact);
|
|
524
|
+
if (inexact.length) {
|
|
525
|
+
const named = inexact.map((page) => `${page.page_id} (${page.reason || page.representative_total?.reason || page.state})`);
|
|
526
|
+
return [cause(
|
|
527
|
+
"commercial_page_not_exact",
|
|
528
|
+
`Calculate gave no exact total for ${inexact.length} page(s): ${named.join(", ")}.`,
|
|
529
|
+
"Correct the spec where the reason names it. A store coupon cannot be previewed, so check that page's prices by hand. Re-run qa run if the reason is Stale.",
|
|
530
|
+
inexact.length,
|
|
531
|
+
)];
|
|
532
|
+
}
|
|
533
|
+
if (present(commercial.unresolved_money_claims_reason)) {
|
|
534
|
+
const count = commercial.unresolved_price_claims + commercial.unresolved_recurrence_claims;
|
|
535
|
+
return [cause(
|
|
536
|
+
String(commercial.unresolved_money_claims_reason),
|
|
537
|
+
`${count} price claim(s) in the page source hold the Map currency's fallback text, so they were not compared with totals in the run currency.`,
|
|
538
|
+
"No page repair is owed. A qa run in the campaign's own currency compares them.",
|
|
539
|
+
count,
|
|
540
|
+
)];
|
|
541
|
+
}
|
|
542
|
+
const packagePrices = Object.entries(commercial.package_price_claims?.unresolved || {});
|
|
543
|
+
packagePrices.forEach(([code, count]) => {
|
|
544
|
+
causes.push(cause(code, PACKAGE_PRICE_CAUSES[code].summary(count), PACKAGE_PRICE_CAUSES[code].fix, count));
|
|
545
|
+
});
|
|
546
|
+
const unresolvedPrices = commercial.unresolved_price_claims - packagePrices.reduce((sum, [, count]) => sum + count, 0);
|
|
547
|
+
const unresolved = unresolvedPrices + commercial.unresolved_recurrence_claims + commercial.unresolved_voucher_claims;
|
|
548
|
+
if (unresolved > 0) {
|
|
549
|
+
causes.push(cause(
|
|
550
|
+
"commercial_claims_unresolved",
|
|
551
|
+
`${unresolved} claim(s) could not be tied to one calculated value (${unresolvedPrices} price, ${commercial.unresolved_recurrence_claims} recurring, ${commercial.unresolved_voucher_claims} voucher).`,
|
|
552
|
+
"A page with more than one bound bundle price, or a voucher the plan cannot price, stays uncompared: check these by hand, or declare the voucher in the spec.",
|
|
553
|
+
unresolved,
|
|
554
|
+
));
|
|
555
|
+
}
|
|
556
|
+
return causes;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** One warn row per named cause: what `exceptions[]` carries and the sidecar keeps. */
|
|
560
|
+
function commercialIncompleteAssertions(reasons) {
|
|
561
|
+
return array(reasons).map((entry) => ({
|
|
562
|
+
id: `commercial-parity:incomplete:${entry.code}`,
|
|
563
|
+
family: "pricing",
|
|
564
|
+
page: "campaign",
|
|
565
|
+
status: "warn",
|
|
566
|
+
severity: "warn",
|
|
567
|
+
expected: "the page's commercial claims compared with calculated totals",
|
|
568
|
+
actual: `${entry.summary} Fix: ${entry.fix}`,
|
|
569
|
+
evidence: {
|
|
570
|
+
code: entry.code,
|
|
571
|
+
count: entry.count,
|
|
572
|
+
},
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
|
|
469
576
|
function compactReport(report, journey, plan, executed, issues, observedClaims, claimLimit, plannedScenarioCount = plan.length) {
|
|
470
577
|
const exactPages = array(journey?.pages).filter((page) => page?.state === PricingState.Exact).length;
|
|
471
578
|
const stalePages = array(journey?.pages).filter((page) => page?.state === PricingState.Stale).length;
|
|
@@ -498,6 +605,7 @@ function compactReport(report, journey, plan, executed, issues, observedClaims,
|
|
|
498
605
|
extracted_price_claims: report.extracted_price_claims,
|
|
499
606
|
compared_price_claims: report.compared_price_claims,
|
|
500
607
|
unresolved_price_claims: report.unresolved_price_claims,
|
|
608
|
+
package_price_claims: report.package_price_claims,
|
|
501
609
|
extracted_recurrence_claims: report.extracted_recurrence_claims,
|
|
502
610
|
compared_recurrence_claims: report.compared_recurrence_claims,
|
|
503
611
|
unresolved_recurrence_claims: report.unresolved_recurrence_claims,
|
|
@@ -568,6 +676,7 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
|
|
|
568
676
|
extracted_price_claims: 0,
|
|
569
677
|
compared_price_claims: 0,
|
|
570
678
|
unresolved_price_claims: 0,
|
|
679
|
+
package_price_claims: { extracted: 0, compared: 0, unresolved: {} },
|
|
571
680
|
extracted_recurrence_claims: 0,
|
|
572
681
|
compared_recurrence_claims: 0,
|
|
573
682
|
unresolved_recurrence_claims: 0,
|
|
@@ -586,6 +695,19 @@ export function unavailableCommercialReport(code, { status = "not_run" } = {}) {
|
|
|
586
695
|
};
|
|
587
696
|
}
|
|
588
697
|
|
|
698
|
+
/** What a crash in the check reports: the same warn row as any other stop. */
|
|
699
|
+
export function crashedCommercialResult() {
|
|
700
|
+
const reason = cause(
|
|
701
|
+
"commercial_runner_error",
|
|
702
|
+
"The commercial parity check crashed before it finished, so no price was compared.",
|
|
703
|
+
"Re-run qa run. If it crashes again, record it with findings add and tell the operator.",
|
|
704
|
+
);
|
|
705
|
+
return {
|
|
706
|
+
assertions: commercialIncompleteAssertions([reason]),
|
|
707
|
+
commercial: { ...unavailableCommercialReport(reason.code, { status: "incomplete" }), incomplete_reasons: [reason] },
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
589
711
|
// One blocker row per currency the calculate endpoint answered in other than
|
|
590
712
|
// the run's: expected totals were asked for in `currency` and came back priced
|
|
591
713
|
// in another one. Answered codes are compared and named upper-cased, so `usd`
|
|
@@ -641,13 +763,15 @@ export async function runCommercialParity({
|
|
|
641
763
|
const observedClaims = array(captures).reduce((sum, capture) => sum + captureClaimCount(capture), 0);
|
|
642
764
|
const aggregateClaimOverflow = observedClaims > limits.max_aggregate_claims;
|
|
643
765
|
const orderedPreflightIssues = [
|
|
644
|
-
{ exceeded: aggregateClaimOverflow, code: "commercial_aggregate_claim_limit" },
|
|
645
|
-
{ exceeded: scenarioOverflow, code: "commercial_scenario_limit" },
|
|
646
|
-
];
|
|
766
|
+
{ exceeded: aggregateClaimOverflow, code: "commercial_aggregate_claim_limit", summary: `The funnel carries ${observedClaims} commercial claims, over the limit of ${limits.max_aggregate_claims}, so none were compared.` },
|
|
767
|
+
{ exceeded: scenarioOverflow, code: "commercial_scenario_limit", summary: "The funnel plans more calculate scenarios than the limit, so none ran." },
|
|
768
|
+
].filter((issue) => issue.exceeded);
|
|
647
769
|
const issues = [
|
|
648
770
|
...captureIssues(captures),
|
|
649
|
-
...orderedPreflightIssues.
|
|
771
|
+
...orderedPreflightIssues.map(({ code }) => ({ code })),
|
|
650
772
|
];
|
|
773
|
+
// Why calculate did not give this run its answers, said where it is found.
|
|
774
|
+
const stopped = orderedPreflightIssues.map(({ code, summary }) => cause(code, summary, OVER_LIMITS_FIX));
|
|
651
775
|
const apiKeyResolution = resolveCommercialApiKey(resolved);
|
|
652
776
|
const apiKey = apiKeyResolution.value;
|
|
653
777
|
let executed = [];
|
|
@@ -657,8 +781,18 @@ export async function runCommercialParity({
|
|
|
657
781
|
// preflight issues above remain visible when multiple ceilings are hit.
|
|
658
782
|
} else if (apiKeyResolution.unsupported) {
|
|
659
783
|
issues.push({ code: "unsupported_campaigns_api_key_source" });
|
|
784
|
+
stopped.push(cause(
|
|
785
|
+
"unsupported_campaigns_api_key_source",
|
|
786
|
+
`The packet's campaign.api_key_source names an environment variable QA refuses to read, so 0 of ${plan.length} planned calculate scenarios ran.`,
|
|
787
|
+
"Use env:CAMPAIGNS_API_KEY, or set campaign.campaigns_api_key in the CampaignSpec, then re-run qa run.",
|
|
788
|
+
));
|
|
660
789
|
} else if (plan.some((descriptor) => !descriptor?.unresolved) && !present(apiKey)) {
|
|
661
790
|
issues.push({ code: "missing_campaigns_api_key" });
|
|
791
|
+
stopped.push(cause(
|
|
792
|
+
"missing_campaigns_api_key",
|
|
793
|
+
`No campaign key was resolved, so 0 of ${plan.length} planned calculate scenarios ran and no price was compared.`,
|
|
794
|
+
"Set campaign.campaigns_api_key in the CampaignSpec (or the Build Packet) to the campaign's API key, then re-run qa run.",
|
|
795
|
+
));
|
|
662
796
|
} else if (typeof fetchImpl !== "function") {
|
|
663
797
|
issues.push({ code: "price_preview_fetch_unavailable" });
|
|
664
798
|
} else {
|
|
@@ -669,9 +803,17 @@ export async function runCommercialParity({
|
|
|
669
803
|
fetchImpl,
|
|
670
804
|
limits,
|
|
671
805
|
}));
|
|
672
|
-
executed.filter((entry) => entry?.ok === false)
|
|
673
|
-
|
|
674
|
-
});
|
|
806
|
+
const failures = executed.filter((entry) => entry?.ok === false)
|
|
807
|
+
.map((entry) => entry.error_code || "price_preview_failed");
|
|
808
|
+
failures.forEach((code) => issues.push({ code }));
|
|
809
|
+
if (failures.length) {
|
|
810
|
+
stopped.push(cause(
|
|
811
|
+
"price_preview_failed",
|
|
812
|
+
`The calculate endpoint failed for ${failures.length} of ${plan.length} planned scenarios (${[...new Set(failures)].join(", ")}).`,
|
|
813
|
+
"Check that the key belongs to this campaign and that the proxy is reachable, then re-run qa run.",
|
|
814
|
+
failures.length,
|
|
815
|
+
));
|
|
816
|
+
}
|
|
675
817
|
}
|
|
676
818
|
|
|
677
819
|
const journey = normalizeJourney(
|
|
@@ -715,17 +857,30 @@ export async function runCommercialParity({
|
|
|
715
857
|
maxAssertions: Math.max(0, maxAssertions - parity.assertions.length),
|
|
716
858
|
});
|
|
717
859
|
parity.recurring_claim_absences = absences;
|
|
860
|
+
const commercial = compactReport(
|
|
861
|
+
parity,
|
|
862
|
+
parityJourney,
|
|
863
|
+
plan,
|
|
864
|
+
executed,
|
|
865
|
+
issues,
|
|
866
|
+
observedClaims,
|
|
867
|
+
limits.max_aggregate_claims,
|
|
868
|
+
commercialPlanning.observed_scenarios ?? plan.length,
|
|
869
|
+
);
|
|
870
|
+
const assertions = [...currencyAssertions, ...parity.assertions, ...absentAssertions];
|
|
871
|
+
if (commercial.status !== "incomplete") return { assertions, commercial };
|
|
872
|
+
// An incomplete run always names a cause in the exceptions list. A currency
|
|
873
|
+
// blocker or an absent rebill claim already wrote its own row.
|
|
874
|
+
const reasons = incompleteCauses(stopped, commercial, parityJourney);
|
|
875
|
+
if (!reasons.length && !currencyAssertions.length && !absentAssertions.length) {
|
|
876
|
+
reasons.push(cause(
|
|
877
|
+
"commercial_coverage_incomplete",
|
|
878
|
+
`Commercial parity stopped short: issues ${array(commercial.issues).map((issue) => issue.code).join(", ") || "none"}.`,
|
|
879
|
+
"Read verdict.commercial for what was and was not compared, and tell the operator.",
|
|
880
|
+
));
|
|
881
|
+
}
|
|
718
882
|
return {
|
|
719
|
-
assertions: [...
|
|
720
|
-
commercial:
|
|
721
|
-
parity,
|
|
722
|
-
parityJourney,
|
|
723
|
-
plan,
|
|
724
|
-
executed,
|
|
725
|
-
issues,
|
|
726
|
-
observedClaims,
|
|
727
|
-
limits.max_aggregate_claims,
|
|
728
|
-
commercialPlanning.observed_scenarios ?? plan.length,
|
|
729
|
-
),
|
|
883
|
+
assertions: [...assertions, ...commercialIncompleteAssertions(reasons)],
|
|
884
|
+
commercial: { ...commercial, incomplete_reasons: reasons },
|
|
730
885
|
};
|
|
731
886
|
}
|
package/src/qa-node.mjs
CHANGED
|
@@ -103,6 +103,7 @@ import {
|
|
|
103
103
|
import {
|
|
104
104
|
captureCommercialClaims,
|
|
105
105
|
COMMERCIAL_QA_LIMITS,
|
|
106
|
+
crashedCommercialResult,
|
|
106
107
|
mapConcurrent,
|
|
107
108
|
createPageSourceLoader,
|
|
108
109
|
planCommercialParity,
|
|
@@ -2609,8 +2610,10 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
|
|
|
2609
2610
|
const vendorBeacons = vendorBeaconRun ? createVendorBeaconLedger({ mode: vendorBeaconRun, analytics: resolved.spec?.analytics }) : null;
|
|
2610
2611
|
if (vendorBeacons) args = { ...args, [QA_VENDOR_BEACONS]: vendorBeacons };
|
|
2611
2612
|
const qcResults = [];
|
|
2613
|
+
const renderedPackagePrices = new Map();
|
|
2612
2614
|
if (args.browser === true) {
|
|
2613
2615
|
const browserAssertions = await runBrowserChecks(resolved.topologies, args, {
|
|
2616
|
+
renderedPackagePrices,
|
|
2614
2617
|
brandContract: resolved.brandContract,
|
|
2615
2618
|
// With no generatable brand theme on the local preview, the starter
|
|
2616
2619
|
// template is the design: its residue is a warning (local-preview-policy.mjs).
|
|
@@ -2649,16 +2652,15 @@ async function runResolvedQa(args, resolvedInputs, { runSessionActive = false, l
|
|
|
2649
2652
|
try {
|
|
2650
2653
|
commercialResult = await runCommercialParity({
|
|
2651
2654
|
resolved,
|
|
2652
|
-
captures: [...capturesByPageId.
|
|
2655
|
+
captures: [...capturesByPageId.entries()].map(([pageId, capture]) => (renderedPackagePrices.has(pageId)
|
|
2656
|
+
? { ...capture, rendered_package_prices: renderedPackagePrices.get(pageId) }
|
|
2657
|
+
: capture)),
|
|
2653
2658
|
planning: commercialPlanning,
|
|
2654
2659
|
maxAssertions: remainingAssertionBudget,
|
|
2655
2660
|
currency,
|
|
2656
2661
|
});
|
|
2657
2662
|
} catch (error) {
|
|
2658
|
-
commercialResult =
|
|
2659
|
-
assertions: [],
|
|
2660
|
-
commercial: unavailableCommercialReport("commercial_runner_error"),
|
|
2661
|
-
};
|
|
2663
|
+
commercialResult = crashedCommercialResult();
|
|
2662
2664
|
reportCommercialRunnerError(args, error);
|
|
2663
2665
|
}
|
|
2664
2666
|
assertions.push(...commercialResult.assertions);
|
|
@@ -3795,6 +3797,9 @@ function output(value, args) {
|
|
|
3795
3797
|
printThemeGateLines(value.theme_gate, value.packet_path, value.report_path);
|
|
3796
3798
|
if (value.commercial) {
|
|
3797
3799
|
console.log(`Commercial parity: ${value.commercial.status} (${value.commercial.finding_count || 0} findings, ${value.commercial.checked_pages || 0} pages checked)`);
|
|
3800
|
+
for (const entry of value.commercial.incomplete_reasons || []) {
|
|
3801
|
+
console.log(` ${entry.code}: ${entry.summary} Fix: ${entry.fix}`);
|
|
3802
|
+
}
|
|
3798
3803
|
}
|
|
3799
3804
|
console.log(`Local copy: ${value.local_path}`);
|
|
3800
3805
|
if (value.posted?.ok && value.dashboard_url) {
|
package/src/qa-verdict.mjs
CHANGED
|
@@ -88,7 +88,9 @@ export function computeDisposition(assertions) {
|
|
|
88
88
|
function dispositionWithCommercial(assertions, commercial) {
|
|
89
89
|
const disposition = computeDisposition(assertions);
|
|
90
90
|
if (disposition === "blocked") return disposition;
|
|
91
|
-
|
|
91
|
+
// Warn-only either way: a proven mismatch and a proof that stopped short are
|
|
92
|
+
// both things the operator has to hear about, and neither ever blocks.
|
|
93
|
+
return commercial?.status === "mismatch" || commercial?.status === "incomplete"
|
|
92
94
|
? "ready_with_exceptions"
|
|
93
95
|
: disposition;
|
|
94
96
|
}
|
package/src/readback.mjs
CHANGED
|
@@ -1310,6 +1310,7 @@ function renderEvidenceDebt(debt, lines) {
|
|
|
1310
1310
|
: `evidence currency is unknown (${stage.reason}); absence of proof is not no debt`;
|
|
1311
1311
|
lines.push(` ${name}: ${stage.status} — ${explanation}`);
|
|
1312
1312
|
lines.push(` recorded: ${stage.recorded_build_fingerprint ?? "unknown"}; current: ${stage.current_build_fingerprint ?? "unknown"}`);
|
|
1313
|
+
if (stage.baseline) lines.push(` baseline: accepted by ${oneLine(stage.baseline.accepted_by)} at ${oneLine(stage.baseline.accepted_at)}, not tested; no purchase tested since`);
|
|
1313
1314
|
if (stage.change) {
|
|
1314
1315
|
const owes = stage.advisory ? "advisory: reported, not required"
|
|
1315
1316
|
: stage.scope === "pages" ? `owed on ${stage.owed_pages.map((page) => oneLine(page.page_id)).join(", ")} (qa run --page)` : "the full stage is owed";
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
// second and later funnels) — current readback truth; docs/migration-sidecar-bundle.md
|
|
7
7
|
// names it and `bundle check` validates it. Committed by design.
|
|
8
8
|
// 2. Handoff inputs and agent context (input/, theme/, agent-context/,
|
|
9
|
-
// setup-handoff.json)
|
|
9
|
+
// setup-handoff.json) and the CampaignSpec a Map intake fetched
|
|
10
|
+
// (fetched-specs/) — durable build handoff. The committed records are
|
|
11
|
+
// bound to that spec, and spec derive edits it, so another machine cannot
|
|
12
|
+
// get it back from the Map. Committed by design.
|
|
10
13
|
// 3. Telemetry, sessions, caches, and evidence — per-machine, append-only,
|
|
11
14
|
// or carrying live URLs and absolute paths. Never meant for the repo, and
|
|
12
15
|
// nothing wrote a rule for them: three campaign repositories were found
|
|
@@ -32,7 +35,6 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
32
35
|
// A project's second and later funnels keep their Run Records under their
|
|
33
36
|
// own runtime directory (src/campaign-workspace.mjs).
|
|
34
37
|
".campaign-runtime/funnels/*/run-records/",
|
|
35
|
-
".campaign-runtime/fetched-specs/",
|
|
36
38
|
".campaign-runtime/progress/",
|
|
37
39
|
".campaign-runtime/polish-evidence/",
|
|
38
40
|
".campaign-runtime/evidence/",
|
|
@@ -53,12 +55,17 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
53
55
|
// present.
|
|
54
56
|
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/", ".campaign-runtime/funnels/*/run-records/"];
|
|
55
57
|
|
|
58
|
+
// Entries an earlier toolkit wrote into the managed block that are now meant
|
|
59
|
+
// to be committed. They leave the managed block on the next setup command or
|
|
60
|
+
// `spec fetch`; the same line elsewhere in the file is the operator's own.
|
|
61
|
+
export const RUNTIME_STATE_RETIRED_IGNORED_PATHS = [".campaign-runtime/fetched-specs/"];
|
|
62
|
+
|
|
56
63
|
export const RUNTIME_STATE_IGNORE_BLOCK = [
|
|
57
64
|
RUNTIME_STATE_IGNORE_MARKER,
|
|
58
|
-
"# Sessions, journals, Run Records,
|
|
59
|
-
"#
|
|
60
|
-
"#
|
|
61
|
-
"#
|
|
65
|
+
"# Sessions, journals, Run Records, capture evidence, and full QA verdicts are",
|
|
66
|
+
"# per-machine. The readback bundle (build-context, assembly-report, doctor-output,",
|
|
67
|
+
"# qa-verdict), fetched-specs/, input/, theme/, and agent-context/ are meant to be",
|
|
68
|
+
"# committed and are deliberately not listed here.",
|
|
62
69
|
...RUNTIME_STATE_IGNORED_PATHS,
|
|
63
70
|
].join("\n");
|
|
64
71
|
|
|
@@ -66,14 +73,29 @@ function ignoreRuleLines(text) {
|
|
|
66
73
|
return new Set(text.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")));
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
71
|
-
function
|
|
72
|
-
const lines = existing.split("\n");
|
|
73
|
-
const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
|
|
76
|
+
// The managed block spans the marker up to the first blank line or the end of
|
|
77
|
+
// the file.
|
|
78
|
+
function managedBlockEnd(lines, markerIndex) {
|
|
74
79
|
let end = markerIndex + 1;
|
|
75
80
|
while (end < lines.length && lines[end].trim() !== "") end += 1;
|
|
76
|
-
|
|
81
|
+
return end;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function retiredEntriesInBlock(existing) {
|
|
85
|
+
const lines = existing.split("\n");
|
|
86
|
+
const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
|
|
87
|
+
return lines.slice(markerIndex + 1, managedBlockEnd(lines, markerIndex)).map((line) => line.trim())
|
|
88
|
+
.filter((line) => RUNTIME_STATE_RETIRED_IGNORED_PATHS.includes(line));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Append the missing required entries at the end of the managed block and drop
|
|
92
|
+
// the retired ones from it.
|
|
93
|
+
function withBlockEntries(existing, missing) {
|
|
94
|
+
const lines = existing.split("\n");
|
|
95
|
+
const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
|
|
96
|
+
const end = managedBlockEnd(lines, markerIndex);
|
|
97
|
+
const block = lines.slice(markerIndex + 1, end).filter((line) => !RUNTIME_STATE_RETIRED_IGNORED_PATHS.includes(line.trim()));
|
|
98
|
+
lines.splice(markerIndex + 1, end - markerIndex - 1, ...block, ...missing);
|
|
77
99
|
return lines.join("\n");
|
|
78
100
|
}
|
|
79
101
|
|
|
@@ -81,10 +103,11 @@ function withRequiredEntries(existing, missing) {
|
|
|
81
103
|
* Ensure the target's .gitignore carries the runtime-state block. Idempotent
|
|
82
104
|
* on the marker line: an existing block, however edited beneath it, is left
|
|
83
105
|
* alone — except for RUNTIME_STATE_REQUIRED_IGNORED_PATHS, which are appended
|
|
84
|
-
* to an existing block when the file carries them nowhere
|
|
85
|
-
*
|
|
106
|
+
* to an existing block when the file carries them nowhere, and
|
|
107
|
+
* RUNTIME_STATE_RETIRED_IGNORED_PATHS, which are removed from it. Never throws
|
|
108
|
+
* for an unwritable target — the build must not fail on housekeeping — but
|
|
86
109
|
* reports what happened so callers can surface it.
|
|
87
|
-
* Returns `{ path, action: "added" | "updated" | "present" | "skipped", reason?, added_entries? }`.
|
|
110
|
+
* Returns `{ path, action: "added" | "updated" | "present" | "skipped", reason?, added_entries?, removed_entries? }`.
|
|
88
111
|
*/
|
|
89
112
|
export function ensureRuntimeStateIgnored(targetRepo, { dryRun = false } = {}) {
|
|
90
113
|
const path = join(resolve(targetRepo), ".gitignore");
|
|
@@ -98,14 +121,16 @@ export function ensureRuntimeStateIgnored(targetRepo, { dryRun = false } = {}) {
|
|
|
98
121
|
if (existing.includes(RUNTIME_STATE_IGNORE_MARKER)) {
|
|
99
122
|
const present = ignoreRuleLines(existing);
|
|
100
123
|
const missing = RUNTIME_STATE_REQUIRED_IGNORED_PATHS.filter((entry) => !present.has(entry));
|
|
101
|
-
|
|
102
|
-
if (
|
|
124
|
+
const retired = retiredEntriesInBlock(existing);
|
|
125
|
+
if (missing.length === 0 && retired.length === 0) return { path, action: "present" };
|
|
126
|
+
const changes = { added_entries: missing, ...(retired.length ? { removed_entries: retired } : {}) };
|
|
127
|
+
if (dryRun) return { path, action: "updated", dry_run: true, ...changes };
|
|
103
128
|
try {
|
|
104
|
-
writeFileSync(path,
|
|
129
|
+
writeFileSync(path, withBlockEntries(existing, missing));
|
|
105
130
|
} catch (error) {
|
|
106
131
|
return { path, action: "skipped", reason: `unwritable: ${error.message}` };
|
|
107
132
|
}
|
|
108
|
-
return { path, action: "updated",
|
|
133
|
+
return { path, action: "updated", ...changes };
|
|
109
134
|
}
|
|
110
135
|
}
|
|
111
136
|
if (dryRun) return { path, action: "added", dry_run: true };
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -106,20 +106,23 @@ const HISTORY_ENTRY_FIELDS = Object.freeze([
|
|
|
106
106
|
"build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
|
|
107
107
|
"source_brief_material", "source_spec_material_hash",
|
|
108
108
|
"verdict_run_id", "checked_at", "purchase_proof",
|
|
109
|
-
"outputs", "evidence",
|
|
109
|
+
"outputs", "evidence", "baseline_accepted_at",
|
|
110
110
|
]);
|
|
111
111
|
// The entry fields that say what a record was, rather than when or by whom it
|
|
112
112
|
// was written: a replacement equal on all of them is a re-record, not a new
|
|
113
113
|
// chapter, and archives nothing.
|
|
114
114
|
const HISTORY_IDENTITY_FIELDS = Object.freeze([
|
|
115
115
|
"build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
|
|
116
|
-
"source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence",
|
|
116
|
+
"source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence", "baseline_accepted_at",
|
|
117
117
|
]);
|
|
118
|
-
export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "qa run", "prepare-build --force"]);
|
|
119
|
-
export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed"]);
|
|
118
|
+
export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "record baseline", "qa run", "prepare-build --force"]);
|
|
119
|
+
export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed", "baseline_accepted"]);
|
|
120
120
|
const ARCHIVED_STAGE_KEYS = Object.freeze(["assembly", "polish", "qa"]);
|
|
121
121
|
|
|
122
122
|
const isCompletedStatus = (status) => typeof status === "string" && status.startsWith("completed");
|
|
123
|
+
// A record a later one replaces into history. A baseline counts here, and not
|
|
124
|
+
// as completed: it never hands a campaign off or proves anything.
|
|
125
|
+
const isReplacedRecord = (status) => isCompletedStatus(status) || status === "baseline_accepted";
|
|
123
126
|
|
|
124
127
|
// What a record says about itself, compared for the dedup. Evidence reads
|
|
125
128
|
// through meaningfulEvidence (an empty block is absent) and a verdict id is
|
|
@@ -168,7 +171,7 @@ function historyEntry(previous, { by, reason, at }) {
|
|
|
168
171
|
// verdict identity or evidence, which the QA producer has always archived.
|
|
169
172
|
function archivable(stageKey, previous) {
|
|
170
173
|
if (!isPlainObject(previous)) return false;
|
|
171
|
-
if (
|
|
174
|
+
if (isReplacedRecord(previous.status)) return true;
|
|
172
175
|
return stageKey === "qa" && Boolean(recordIdentity(previous).verdict_run_id || meaningfulEvidence(previous.evidence));
|
|
173
176
|
}
|
|
174
177
|
|
|
@@ -200,6 +203,19 @@ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
|
|
|
200
203
|
return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
// The baseline QA rests on, or null. The marker on the record is the rule, not
|
|
207
|
+
// its status: a blocked page-scoped run or an input change leaves QA resting on
|
|
208
|
+
// the baseline, and only a completed full QA clears the marker.
|
|
209
|
+
export function qaRestsOnBaseline(report) {
|
|
210
|
+
return report?.stages?.qa?.baseline_accepted_at && isPlainObject(report?.commerce_baseline) ? report.commerce_baseline : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A stage's baseline record, with what the stage held moved to its history.
|
|
214
|
+
export function archiveForBaseline(record, previous, { at }) {
|
|
215
|
+
const carried = Array.isArray(previous?.history) ? { ...record, history: previous.history } : record;
|
|
216
|
+
return archiveSupersededRecord(carried, previous, { by: "record baseline", reason: "baseline_accepted", at });
|
|
217
|
+
}
|
|
218
|
+
|
|
203
219
|
/**
|
|
204
220
|
* #668: the Polish record a CampaignSpec change moved to `polish.history`,
|
|
205
221
|
* restored as the live record stamped with `stamps` (the current inputs), or
|
|
@@ -241,6 +257,15 @@ export function campaignHandedOff(report) {
|
|
|
241
257
|
return completedIn(report?.stages?.deploy) || completedIn(report?.stages?.qa);
|
|
242
258
|
}
|
|
243
259
|
|
|
260
|
+
// True when QA has ever completed, now or in the stage's history. Not
|
|
261
|
+
// campaignHandedOff: a baseline stands in for a QA result, and a completed
|
|
262
|
+
// deploy is not one.
|
|
263
|
+
export function qaEverCompleted(report) {
|
|
264
|
+
const qa = report?.stages?.qa;
|
|
265
|
+
return isPlainObject(qa) && (isCompletedStatus(qa.status)
|
|
266
|
+
|| (Array.isArray(qa.history) && qa.history.some((entry) => isPlainObject(entry) && isCompletedStatus(entry.status))));
|
|
267
|
+
}
|
|
268
|
+
|
|
244
269
|
/**
|
|
245
270
|
* True when any stage of `report` carries a non-empty history: recorded
|
|
246
271
|
* operator-visible state an intake must not silently drop.
|
|
@@ -301,7 +326,7 @@ export function archiveForForceReset(previousReport, nextReport, { now, detectCh
|
|
|
301
326
|
continue;
|
|
302
327
|
}
|
|
303
328
|
if (Object.hasOwn(previous, "input_change")) reseeded.input_change = JSON.parse(JSON.stringify(previous.input_change));
|
|
304
|
-
if (
|
|
329
|
+
if (isReplacedRecord(previous.status)) {
|
|
305
330
|
reseeded = archiveSupersededRecord(reseeded, previous, { by: "prepare-build --force", reason: "force_reset", at: now });
|
|
306
331
|
const reason = typeof detectChange === "function" ? detectChange(key, previous) : null;
|
|
307
332
|
if (reason) reseeded.input_change = inputChangeFor(key, previous, { at: now, reason });
|
|
@@ -405,7 +430,7 @@ export function recordProducerStageOutcome(report, {
|
|
|
405
430
|
// A replaced completed record whose stamps differ from the current inputs
|
|
406
431
|
// records the change; a verdict stamped with the current inputs then
|
|
407
432
|
// clears it, the one it just recorded included; any other write carries it.
|
|
408
|
-
const detected = inputs &&
|
|
433
|
+
const detected = inputs && isReplacedRecord(previous.status) ? inputs.detectChange(previous) : null;
|
|
409
434
|
if (detected) next.input_change = inputChangeFor("qa", previous, { at: timestamp, reason: detected });
|
|
410
435
|
if (inputs && inputs.stampsCurrent(next)) delete next.input_change;
|
|
411
436
|
const archived = archiveStageRecord(next, previous, next, { by: "qa run", reason: detected || "rerecorded", at: timestamp, stageKey: "qa" });
|
|
@@ -591,9 +616,10 @@ export function qaRecordedBuildFingerprint(report) {
|
|
|
591
616
|
// #720: the build whose completed QA a page-scoped run extends. A completed
|
|
592
617
|
// QA record extends its own build. A page-scoped run that did not complete
|
|
593
618
|
// extends the build of the full QA it was based on, which was completed when
|
|
594
|
-
// that run was accepted.
|
|
619
|
+
// that run was accepted. A baseline extends its own build too, and has no
|
|
620
|
+
// purchase proof to pass on. Any other record (blocked or pending full QA, a
|
|
595
621
|
// skip, a required stage) extends nothing, so it owes a full QA run.
|
|
596
|
-
const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings"]);
|
|
622
|
+
const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings", "baseline_accepted"]);
|
|
597
623
|
export function qaExtendedBuildFingerprint(report) {
|
|
598
624
|
const qa = report?.stages?.qa;
|
|
599
625
|
if (QA_EXTENDABLE_STATUSES.includes(qa?.status)) return qaRecordedBuildFingerprint(report);
|