@nextcommerce/campaigns-os 1.33.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/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// Per-finding cause class — "did the change under test cause this?"
|
|
2
|
+
//
|
|
3
|
+
// A run that surfaces eleven findings, none of them caused by the change being
|
|
4
|
+
// tested, reads exactly like a run that broke eleven things. The operator has
|
|
5
|
+
// no mechanical way to tell the two apart, so every bump run ends in a manual
|
|
6
|
+
// read of every finding. This module attaches one cause class to each finding
|
|
7
|
+
// so the answer is on the report.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately mechanical. Every class here is derived from something the
|
|
10
|
+
// toolkit ALREADY records: the assertion identity the verdict already uses to
|
|
11
|
+
// derive exceptions, the doctor issue codes the Run Record already snapshots,
|
|
12
|
+
// the runner's own environment markers, and the SDK-pin checkpoint the doctor
|
|
13
|
+
// already evaluates. Nothing is inferred from message text, severity, or
|
|
14
|
+
// plausibility. When no class can be assigned from recorded data the answer is
|
|
15
|
+
// `unknown` with a reason, never a guess.
|
|
16
|
+
|
|
17
|
+
export const CAUSE_CLASSES = Object.freeze({
|
|
18
|
+
CAUSED_BY_CHANGE: "caused_by_change",
|
|
19
|
+
PRE_EXISTING: "pre_existing",
|
|
20
|
+
TEST_ENVIRONMENT: "test_environment",
|
|
21
|
+
UPSTREAM_DRIFT: "upstream_drift",
|
|
22
|
+
UNKNOWN: "unknown",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Report order: the class an operator is looking for first comes first.
|
|
26
|
+
const CAUSE_CLASS_VOCABULARY = Object.freeze([
|
|
27
|
+
CAUSE_CLASSES.CAUSED_BY_CHANGE,
|
|
28
|
+
CAUSE_CLASSES.PRE_EXISTING,
|
|
29
|
+
CAUSE_CLASSES.TEST_ENVIRONMENT,
|
|
30
|
+
CAUSE_CLASSES.UPSTREAM_DRIFT,
|
|
31
|
+
CAUSE_CLASSES.UNKNOWN,
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const CAUSE_CLASS_LABELS = Object.freeze({
|
|
35
|
+
[CAUSE_CLASSES.CAUSED_BY_CHANGE]: "caused by this change",
|
|
36
|
+
[CAUSE_CLASSES.PRE_EXISTING]: "pre-existing",
|
|
37
|
+
[CAUSE_CLASSES.TEST_ENVIRONMENT]: "test environment",
|
|
38
|
+
[CAUSE_CLASSES.UPSTREAM_DRIFT]: "upstream drift",
|
|
39
|
+
[CAUSE_CLASSES.UNKNOWN]: "unknown",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Doctor issue codes that ARE an already-detected version disagreement between
|
|
43
|
+
// what the CampaignSpec pins and what the target carries. Enumerated, not
|
|
44
|
+
// prefix-matched: the sibling `page_kit.sdk_version.spec_missing` /
|
|
45
|
+
// `.target_missing` / `.waiver_inert` codes are configuration gaps and waiver
|
|
46
|
+
// hygiene, not drift, and calling them drift would tell an operator the
|
|
47
|
+
// upstream moved when it did not.
|
|
48
|
+
const UPSTREAM_DRIFT_DOCTOR_CODES = Object.freeze([
|
|
49
|
+
// observed target SDK version != the CampaignSpec pin (the blocking form)
|
|
50
|
+
"page_kit.sdk_version",
|
|
51
|
+
// the repo pin moved ahead of the spec's build hint (the advisory form, #413)
|
|
52
|
+
"page_kit.sdk_version.repo_newer",
|
|
53
|
+
// the same disagreement, accepted under a named-human waiver
|
|
54
|
+
"page_kit.sdk_version.waived",
|
|
55
|
+
// two spec-side declarations of the pin disagree with each other
|
|
56
|
+
"page_kit.sdk_version.spec_conflict",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const UPSTREAM_DRIFT_DOCTOR_CODE_SET = new Set(UPSTREAM_DRIFT_DOCTOR_CODES);
|
|
60
|
+
|
|
61
|
+
function text(value) {
|
|
62
|
+
return typeof value === "string" && value.trim() ? value.trim() : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stable identity for one QA assertion.
|
|
67
|
+
*
|
|
68
|
+
* Reuses the identity the verdict already projects in deriveExceptions —
|
|
69
|
+
* family, id, page — and nothing else. `url` is deliberately excluded: the
|
|
70
|
+
* same campaign QA'd locally and then against its published deploy produces
|
|
71
|
+
* different URLs for the identical assertion, and including it would report
|
|
72
|
+
* every finding of the published run as new.
|
|
73
|
+
*/
|
|
74
|
+
export function qaAssertionFingerprint(assertion) {
|
|
75
|
+
return `qa:${text(assertion?.family)}|${text(assertion?.id)}|${text(assertion?.page)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Stable identity for one doctor issue.
|
|
80
|
+
*
|
|
81
|
+
* Code granularity, because the code is what the Run Record already snapshots
|
|
82
|
+
* (observations.doctor.error_codes / warning_codes) and therefore the only
|
|
83
|
+
* doctor identity a previous run can be compared against. Two distinct
|
|
84
|
+
* violations sharing a code are one finding to this comparison.
|
|
85
|
+
*/
|
|
86
|
+
export function doctorIssueFingerprint(issue) {
|
|
87
|
+
return `doctor:${text(issue?.code)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Environment classification for a QA assertion, using the runner's own
|
|
92
|
+
* markers only:
|
|
93
|
+
* - the order-creation budget safety stop, which the runner records as
|
|
94
|
+
* `evidence.order_creation_budget` and which is explicitly not a broken
|
|
95
|
+
* checkout;
|
|
96
|
+
* - a `<leg>:runner` assertion, the id the analytics and test-order legs
|
|
97
|
+
* emit when the capture itself failed to complete rather than when the
|
|
98
|
+
* page was wrong.
|
|
99
|
+
* Returns a reason string, or null when the assertion is not environmental.
|
|
100
|
+
*/
|
|
101
|
+
export function qaEnvironmentReason(assertion) {
|
|
102
|
+
const evidence = assertion?.evidence;
|
|
103
|
+
if (evidence && typeof evidence === "object" && !Array.isArray(evidence) && evidence.order_creation_budget) {
|
|
104
|
+
return "order_creation_budget_stop";
|
|
105
|
+
}
|
|
106
|
+
if (text(assertion?.id).endsWith(":runner")) return "runner_capture_failure";
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Upstream-drift classification for a doctor issue: true only for the codes
|
|
112
|
+
* the SDK-pin checkpoint already emits for an observed-vs-declared version
|
|
113
|
+
* disagreement.
|
|
114
|
+
*/
|
|
115
|
+
export function doctorUpstreamDriftReason(issue) {
|
|
116
|
+
return UPSTREAM_DRIFT_DOCTOR_CODE_SET.has(text(issue?.code)) ? "sdk_pin_disagreement" : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The prior-run comparison set: a Map of fingerprint -> status. `status` is
|
|
121
|
+
* whatever the surface calls a status ("fail"/"warn"/... for QA,
|
|
122
|
+
* "error"/"warning" for doctor). Same fingerprint AND same status is
|
|
123
|
+
* pre-existing; a fingerprint whose status moved is not.
|
|
124
|
+
*/
|
|
125
|
+
export function priorSetFromEntries(entries = []) {
|
|
126
|
+
const map = new Map();
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const fingerprint = text(entry?.fingerprint);
|
|
129
|
+
if (!fingerprint) continue;
|
|
130
|
+
if (!map.has(fingerprint)) map.set(fingerprint, text(entry?.status));
|
|
131
|
+
}
|
|
132
|
+
return map;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Prior comparison set built from a previous run's QA verdict object. */
|
|
136
|
+
function priorSetFromVerdict(verdict, { isFinding }) {
|
|
137
|
+
const assertions = Array.isArray(verdict?.assertions) ? verdict.assertions : [];
|
|
138
|
+
return priorSetFromEntries(
|
|
139
|
+
assertions
|
|
140
|
+
.filter((assertion) => assertion && typeof assertion === "object" && isFinding(assertion))
|
|
141
|
+
.map((assertion) => ({ fingerprint: qaAssertionFingerprint(assertion), status: assertion.status })),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Prior comparison set built from a previous Run Record's doctor observations.
|
|
147
|
+
* These are code lists the record already carries, so this works against every
|
|
148
|
+
* Run Record ever written — no new field, no upgrade window.
|
|
149
|
+
*/
|
|
150
|
+
function priorSetFromRunRecordDoctor(record) {
|
|
151
|
+
const doctor = record?.observations?.doctor;
|
|
152
|
+
if (!doctor || typeof doctor !== "object") return null;
|
|
153
|
+
const entries = [];
|
|
154
|
+
for (const code of Array.isArray(doctor.error_codes) ? doctor.error_codes : []) {
|
|
155
|
+
entries.push({ fingerprint: doctorIssueFingerprint({ code }), status: "error" });
|
|
156
|
+
}
|
|
157
|
+
for (const code of Array.isArray(doctor.warning_codes) ? doctor.warning_codes : []) {
|
|
158
|
+
entries.push({ fingerprint: doctorIssueFingerprint({ code }), status: "warning" });
|
|
159
|
+
}
|
|
160
|
+
return priorSetFromEntries(entries);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The classification itself, for one finding. `prior` is a Map from
|
|
165
|
+
* priorSetFromEntries, or null when no previous run was found.
|
|
166
|
+
*
|
|
167
|
+
* Order is load-bearing. Environment and upstream drift are decided first,
|
|
168
|
+
* because a finding the runner already knows is environmental stays
|
|
169
|
+
* environmental whether or not it also happened last time — labelling a
|
|
170
|
+
* Chromium failure "pre-existing" would tell the operator the campaign is at
|
|
171
|
+
* fault. Only then does the previous-run comparison run.
|
|
172
|
+
*/
|
|
173
|
+
function classifyFinding({ fingerprint, status, environmentReason = null, upstreamDriftReason = null, prior = null, noPriorReason = "no_prior_run" }) {
|
|
174
|
+
if (environmentReason) {
|
|
175
|
+
return { cause: CAUSE_CLASSES.TEST_ENVIRONMENT, cause_reason: environmentReason };
|
|
176
|
+
}
|
|
177
|
+
if (upstreamDriftReason) {
|
|
178
|
+
return { cause: CAUSE_CLASSES.UPSTREAM_DRIFT, cause_reason: upstreamDriftReason };
|
|
179
|
+
}
|
|
180
|
+
if (!prior) {
|
|
181
|
+
return { cause: CAUSE_CLASSES.UNKNOWN, cause_reason: noPriorReason };
|
|
182
|
+
}
|
|
183
|
+
if (!prior.has(fingerprint)) {
|
|
184
|
+
return { cause: CAUSE_CLASSES.CAUSED_BY_CHANGE, cause_reason: "new_since_prior_run" };
|
|
185
|
+
}
|
|
186
|
+
const priorStatus = prior.get(fingerprint);
|
|
187
|
+
const currentStatus = text(status);
|
|
188
|
+
if (priorStatus === currentStatus) {
|
|
189
|
+
return { cause: CAUSE_CLASSES.PRE_EXISTING, cause_reason: "same_status_in_prior_run" };
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
cause: CAUSE_CLASSES.CAUSED_BY_CHANGE,
|
|
193
|
+
cause_reason: `status_changed_since_prior_run:${priorStatus || "unknown"}->${currentStatus || "unknown"}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Classify one QA assertion. Mutates nothing; returns the cause fields. */
|
|
198
|
+
export function classifyQaAssertion(assertion, { prior = null, noPriorReason = "no_prior_run" } = {}) {
|
|
199
|
+
return classifyFinding({
|
|
200
|
+
fingerprint: qaAssertionFingerprint(assertion),
|
|
201
|
+
status: assertion?.status,
|
|
202
|
+
environmentReason: qaEnvironmentReason(assertion),
|
|
203
|
+
prior,
|
|
204
|
+
noPriorReason,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Classify one doctor issue. `status` is "error" or "warning". */
|
|
209
|
+
export function classifyDoctorIssue(issue, status, { prior = null, noPriorReason = "no_prior_run" } = {}) {
|
|
210
|
+
return classifyFinding({
|
|
211
|
+
fingerprint: doctorIssueFingerprint(issue),
|
|
212
|
+
status,
|
|
213
|
+
upstreamDriftReason: doctorUpstreamDriftReason(issue),
|
|
214
|
+
prior,
|
|
215
|
+
noPriorReason,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Count findings by cause class. `total` is the number of findings counted,
|
|
221
|
+
* which is the number the summary line leads with — an operator comparing
|
|
222
|
+
* "11 findings" against the per-class counts must be able to add them up.
|
|
223
|
+
*/
|
|
224
|
+
export function summarizeCauses(findings = []) {
|
|
225
|
+
const counts = Object.fromEntries(CAUSE_CLASS_VOCABULARY.map((cause) => [cause, 0]));
|
|
226
|
+
let total = 0;
|
|
227
|
+
for (const finding of findings) {
|
|
228
|
+
const cause = text(finding?.cause);
|
|
229
|
+
if (!cause) continue;
|
|
230
|
+
total += 1;
|
|
231
|
+
if (cause in counts) counts[cause] += 1;
|
|
232
|
+
}
|
|
233
|
+
return { total, counts };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The one-line report header. Reads as prose, not as a JSON dump, and names
|
|
238
|
+
* the previous Run Record when a comparison actually happened, so the answer
|
|
239
|
+
* is checkable.
|
|
240
|
+
*
|
|
241
|
+
* "Compared against" is claimed only when `comparison` says a comparison
|
|
242
|
+
* happened. A prior record that exists but carries no usable evidence has a
|
|
243
|
+
* run id, and naming it here would read as though it had been compared —
|
|
244
|
+
* formatCauseBasisLine is where that case gets explained.
|
|
245
|
+
*/
|
|
246
|
+
export function formatCauseSummaryLine(summary) {
|
|
247
|
+
if (!summary || !summary.total) return "Causes: no findings.";
|
|
248
|
+
const parts = CAUSE_CLASS_VOCABULARY
|
|
249
|
+
.filter((cause) => summary.counts[cause] > 0)
|
|
250
|
+
.map((cause) => `${summary.counts[cause]} ${CAUSE_CLASS_LABELS[cause]}`);
|
|
251
|
+
const id = text(summary.prior_run_id);
|
|
252
|
+
let compared = " (no previous run to compare against)";
|
|
253
|
+
if (summary.comparison === "prior_run" && id) {
|
|
254
|
+
// The QA comparison reads that record's FINAL QA attempt, which is not the
|
|
255
|
+
// same artifact as the record itself. Say so, or a reader checking the
|
|
256
|
+
// record by hand will look at the wrong attempt.
|
|
257
|
+
compared = summary.surface === "qa"
|
|
258
|
+
? ` (compared against the final QA attempt of run ${id})`
|
|
259
|
+
: ` (compared against run ${id})`;
|
|
260
|
+
} else if (summary.comparison && summary.comparison !== "prior_run") {
|
|
261
|
+
compared = " (no comparison was possible)";
|
|
262
|
+
}
|
|
263
|
+
return `Causes: ${summary.total} finding${summary.total === 1 ? "" : "s"} — ${parts.join(", ")}${compared}.`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Why no comparison happened, one sentence per reason. "No previous run" and
|
|
267
|
+
// "a previous run whose evidence is missing" are different facts and need
|
|
268
|
+
// different sentences: telling an operator who already has a prior record that
|
|
269
|
+
// the labels will improve once a second run exists is simply untrue, and sends
|
|
270
|
+
// them to re-run something that will fail the same way.
|
|
271
|
+
const CAUSE_BASIS_SENTENCES = Object.freeze({
|
|
272
|
+
no_prior_run: () => "There is no previous run for this campaign to compare against, so every finding is labelled unknown. The comparison starts working once a Run Record exists.",
|
|
273
|
+
prior_run_without_qa_verdict: (id) => `Previous run ${id} exists but references no QA verdict, so there was nothing to compare against and every finding is labelled unknown.`,
|
|
274
|
+
prior_run_verdict_unreadable: (id) => `Previous run ${id} exists but its QA verdict is missing or unreadable, so there was nothing to compare against and every finding is labelled unknown.`,
|
|
275
|
+
prior_run_verdict_unlocated: (id) => `Previous run ${id} exists and references a QA verdict written outside the packet directory, but no verdict matching that reference could be located under the target repo's qa-output/, so there was nothing to compare against and every finding is labelled unknown.`,
|
|
276
|
+
prior_run_without_doctor_observations: (id) => `Previous run ${id} exists but carries no doctor observations, so there was nothing to compare against and every finding is labelled unknown.`,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The follow-up line explaining a missing comparison, or null when one
|
|
281
|
+
* happened. One formatter, used by both the QA and the doctor report, so the
|
|
282
|
+
* two commands can never explain the same state differently.
|
|
283
|
+
*/
|
|
284
|
+
export function formatCauseBasisLine(summary) {
|
|
285
|
+
if (!summary || !summary.comparison || summary.comparison === "prior_run") return null;
|
|
286
|
+
const reason = text(summary.comparison);
|
|
287
|
+
const id = text(summary.prior_run_id) || "(unidentified)";
|
|
288
|
+
const sentence = CAUSE_BASIS_SENTENCES[reason];
|
|
289
|
+
const explanation = sentence
|
|
290
|
+
? sentence(id)
|
|
291
|
+
: "No previous-run comparison was possible, so every finding is labelled unknown.";
|
|
292
|
+
return ` Comparison basis: ${reason}. ${explanation}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The cause block a human report prints: the summary line, then the basis
|
|
297
|
+
* line when no comparison happened. One function for the QA and the doctor
|
|
298
|
+
* report, so the two cannot print the same summary differently.
|
|
299
|
+
*/
|
|
300
|
+
export function formatCauseReportLines(summary) {
|
|
301
|
+
if (!summary) return [];
|
|
302
|
+
const lines = [formatCauseSummaryLine(summary)];
|
|
303
|
+
const basis = formatCauseBasisLine(summary);
|
|
304
|
+
if (basis) lines.push(basis);
|
|
305
|
+
return lines;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The short per-finding tag the report prints beside each finding. */
|
|
309
|
+
export function formatCauseTag(finding) {
|
|
310
|
+
const cause = text(finding?.cause);
|
|
311
|
+
if (!cause) return "";
|
|
312
|
+
const label = CAUSE_CLASS_LABELS[cause] || cause;
|
|
313
|
+
const reason = text(finding?.cause_reason);
|
|
314
|
+
return reason ? `[${label}: ${reason}]` : `[${label}]`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Previous-run lookup.
|
|
319
|
+
//
|
|
320
|
+
// Reuses the existing Run Record discovery (readRunRecordsForTarget, which
|
|
321
|
+
// orders `.campaign-runtime/run-records/` newest-first by minted run id and
|
|
322
|
+
// swallows unreadable files). No second scanner, no second ordering rule.
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
326
|
+
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
327
|
+
import { iterateQaVerdicts } from "./qa-verdict-discovery.mjs";
|
|
328
|
+
|
|
329
|
+
import { readRunRecordsForTarget } from "./run-record.mjs";
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The most recent Run Record for the same campaign identity under `baseDir`,
|
|
333
|
+
* or null. Records are discovered newest-first, and the current run's record
|
|
334
|
+
* does not exist yet at classification time (both `qa run` and `doctor`
|
|
335
|
+
* complete before `run-record` assembles anything), so the first identity
|
|
336
|
+
* match is the previous run. `currentRunId` is skipped anyway, defensively.
|
|
337
|
+
*
|
|
338
|
+
* Only the FIRST identity match is considered. Walking further back to find a
|
|
339
|
+
* record that happens to carry usable evidence would silently compare this run
|
|
340
|
+
* against a non-adjacent one and report findings introduced in between as
|
|
341
|
+
* pre-existing.
|
|
342
|
+
*/
|
|
343
|
+
export function findPriorRunRecord({ baseDir, mapId = null, currentRunId = null } = {}) {
|
|
344
|
+
if (!text(baseDir)) return null;
|
|
345
|
+
for (const entry of readRunRecordsForTarget(baseDir)) {
|
|
346
|
+
const record = entry?.record;
|
|
347
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) continue;
|
|
348
|
+
if (currentRunId && record.run_id === currentRunId) continue;
|
|
349
|
+
if (text(mapId) && text(record.identity?.map_id) !== text(mapId)) continue;
|
|
350
|
+
return record;
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const EXTERNAL_REF_PREFIX = "external:";
|
|
356
|
+
|
|
357
|
+
function resolveArtifactPath(baseDir, artifactPath) {
|
|
358
|
+
return isAbsolute(artifactPath) ? artifactPath : resolvePath(baseDir, artifactPath);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// One read of a verdict file; null when the comparison cannot use it (missing,
|
|
362
|
+
// not a file, not JSON, or carrying no assertions array).
|
|
363
|
+
function readPriorVerdictFile(path) {
|
|
364
|
+
try {
|
|
365
|
+
if (!existsSync(path) || !statSync(path).isFile()) return null;
|
|
366
|
+
const verdict = JSON.parse(readFileSync(path, "utf8"));
|
|
367
|
+
if (!verdict || typeof verdict !== "object" || Array.isArray(verdict) || !Array.isArray(verdict.assertions)) return null;
|
|
368
|
+
return verdict;
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// The identity a Run Record carries, in the shape the verdict discovery leaf
|
|
375
|
+
// reads (`spec.map_id` / `campaign.public_route_slug`), so the previous run's
|
|
376
|
+
// verdicts are filed and matched under the names the record itself stores.
|
|
377
|
+
function recordIdentityForDiscovery(record) {
|
|
378
|
+
return {
|
|
379
|
+
spec: { map_id: text(record?.identity?.map_id) || null },
|
|
380
|
+
campaign: { public_route_slug: text(record?.identity?.campaign_slug) || null },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Locate a previous run's QA verdict that its Run Record references as
|
|
386
|
+
* `external:qa_verdict` — the reference the record writes when the verdict
|
|
387
|
+
* lived outside the packet directory. That is the ordinary layout whenever
|
|
388
|
+
* `assembly.target_repo` is not the packet's own directory: `qa run` writes
|
|
389
|
+
* the full verdict under `<target repo>/qa-output/<identifier>/` and the
|
|
390
|
+
* record, relativizing against the packet directory, keeps only the kind and
|
|
391
|
+
* the file's digest. The digest is the one thing that ties a file on disk to
|
|
392
|
+
* THAT reference, so the target repo's `qa-output/` directories are searched
|
|
393
|
+
* for the file whose bytes hash to it: the referenced verdict itself, never a
|
|
394
|
+
* neighbouring attempt.
|
|
395
|
+
*
|
|
396
|
+
* The committed sidecar (`.campaign-runtime/qa-verdict.json`) is deliberately
|
|
397
|
+
* not a fallback. It is a projection, so its digest cannot match, and the
|
|
398
|
+
* record stores no verdict run id to match it by; any other rule (same
|
|
399
|
+
* campaign, older than the record, same disposition) admits a projection of a
|
|
400
|
+
* different attempt — one restored by `qa promote`, say — and would report a
|
|
401
|
+
* finding that attempt carried and the recorded final attempt had fixed as
|
|
402
|
+
* pre-existing. Until the record can name the attempt, an unlocated reference
|
|
403
|
+
* stays `prior_run_verdict_unlocated`.
|
|
404
|
+
*
|
|
405
|
+
* Null is returned without a search when no target repo is known or the
|
|
406
|
+
* reference carries no digest, as well as when the search finds no match; the
|
|
407
|
+
* basis sentence for `prior_run_verdict_unlocated` is worded to be true in all
|
|
408
|
+
* three cases (nothing matching the reference could be located).
|
|
409
|
+
*
|
|
410
|
+
* Returns `{ verdict, path }` or null. The single-record boundary holds: this
|
|
411
|
+
* reads the verdict the ONE previous record references, not the newest file
|
|
412
|
+
* lying around.
|
|
413
|
+
*/
|
|
414
|
+
function locateExternalPriorVerdict({ targetRepo, record, ref }) {
|
|
415
|
+
const digest = text(ref?.sha256);
|
|
416
|
+
if (!digest || !text(targetRepo)) return null;
|
|
417
|
+
const identity = recordIdentityForDiscovery(record);
|
|
418
|
+
for (const candidate of iterateQaVerdicts({ packet: identity, roots: [targetRepo], withDigest: true })) {
|
|
419
|
+
if (candidate.sha256 !== digest) continue;
|
|
420
|
+
const verdict = candidate.verdict && Array.isArray(candidate.verdict.assertions) ? candidate.verdict : null;
|
|
421
|
+
return verdict ? { verdict, path: candidate.path } : null;
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The previous run's FINAL QA verdict, reached through that run's own Run
|
|
428
|
+
* Record artifact reference. Returns `{ verdict, record, path, reason }`;
|
|
429
|
+
* `verdict` is null whenever `reason` is set.
|
|
430
|
+
*
|
|
431
|
+
* The LAST qa_verdict reference, not the first. A run session that needed
|
|
432
|
+
* repair and re-test carries one qa_verdict artifact per attempt, appended in
|
|
433
|
+
* session order with the run's canonical verdict last — so the first reference
|
|
434
|
+
* is typically the blocked attempt that triggered the repair. Comparing
|
|
435
|
+
* against it would report a defect that was fixed before that run closed, and
|
|
436
|
+
* reintroduced by the change under test, as pre-existing: precisely the
|
|
437
|
+
* false-clean answer this label exists to prevent.
|
|
438
|
+
*
|
|
439
|
+
* A reference of `external:qa_verdict` is a reference, not an absence: it is
|
|
440
|
+
* resolved by digest through `targetRepo` (the packet's `assembly.target_repo`)
|
|
441
|
+
* — see locateExternalPriorVerdict. Only a record with no qa_verdict artifact
|
|
442
|
+
* at all reports `prior_run_without_qa_verdict`.
|
|
443
|
+
*
|
|
444
|
+
* The single-record boundary is unchanged: this still reads the final attempt
|
|
445
|
+
* of exactly one earlier run, never a merged view across runs.
|
|
446
|
+
*/
|
|
447
|
+
export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, currentRunId = null } = {}) {
|
|
448
|
+
const record = findPriorRunRecord({ baseDir, mapId, currentRunId });
|
|
449
|
+
if (!record) return { verdict: null, record: null, path: null, reason: "no_prior_run" };
|
|
450
|
+
const ref = (Array.isArray(record.artifacts) ? record.artifacts : []).findLast((artifact) => artifact?.kind === "qa_verdict");
|
|
451
|
+
const refPath = text(ref?.path);
|
|
452
|
+
if (!refPath) return { verdict: null, record, path: null, reason: "prior_run_without_qa_verdict" };
|
|
453
|
+
if (refPath.startsWith(EXTERNAL_REF_PREFIX)) {
|
|
454
|
+
const located = locateExternalPriorVerdict({ targetRepo, record, ref });
|
|
455
|
+
return located
|
|
456
|
+
? { verdict: located.verdict, record, path: located.path, reason: null }
|
|
457
|
+
: { verdict: null, record, path: null, reason: "prior_run_verdict_unlocated" };
|
|
458
|
+
}
|
|
459
|
+
const path = resolveArtifactPath(baseDir, refPath);
|
|
460
|
+
const verdict = readPriorVerdictFile(path);
|
|
461
|
+
if (!verdict) return { verdict: null, record, path, reason: "prior_run_verdict_unreadable" };
|
|
462
|
+
return { verdict, record, path, reason: null };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The previous run's doctor findings, read from the Run Record's own doctor
|
|
467
|
+
* observations (error_codes / warning_codes). Every Run Record ever written
|
|
468
|
+
* carries these, so doctor cause labels work against existing history with no
|
|
469
|
+
* upgrade window. Returns `{ prior, record, reason }`.
|
|
470
|
+
*/
|
|
471
|
+
function loadPriorDoctorFindings({ baseDir, mapId = null, currentRunId = null } = {}) {
|
|
472
|
+
const record = findPriorRunRecord({ baseDir, mapId, currentRunId });
|
|
473
|
+
if (!record) return { prior: null, record: null, reason: "no_prior_run" };
|
|
474
|
+
const prior = priorSetFromRunRecordDoctor(record);
|
|
475
|
+
if (!prior) return { prior: null, record, reason: "prior_run_without_doctor_observations" };
|
|
476
|
+
return { prior, record, reason: null };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// The QA wiring: classify one run's findings in place and return the summary
|
|
481
|
+
// the report leads with.
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Stamp `cause` and `cause_reason` onto every FINDING assertion in `assertions`
|
|
486
|
+
* (passes are left alone — a passing assertion has no cause to explain), and
|
|
487
|
+
* return the summary block for the verdict.
|
|
488
|
+
*
|
|
489
|
+
* `baseDir` is the Build Packet directory, the same root the Run Record uses;
|
|
490
|
+
* `targetRepo` is where `qa run` writes full verdicts, needed when it is not
|
|
491
|
+
* the packet directory (see loadPriorQaVerdict). Packet-less runs (`--site`,
|
|
492
|
+
* raw map id) have no Run Record home, so they get `unknown` / `no_prior_run`
|
|
493
|
+
* throughout — which is the truth, not a silence.
|
|
494
|
+
*/
|
|
495
|
+
export function annotateQaAssertionCauses(assertions, { baseDir = null, targetRepo = null, mapId = null, currentRunId = null, isFinding } = {}) {
|
|
496
|
+
const lookup = baseDir
|
|
497
|
+
? loadPriorQaVerdict({ baseDir, targetRepo, mapId, currentRunId })
|
|
498
|
+
: { verdict: null, record: null, path: null, reason: "no_prior_run" };
|
|
499
|
+
const prior = lookup.verdict ? priorSetFromVerdict(lookup.verdict, { isFinding }) : null;
|
|
500
|
+
const noPriorReason = lookup.reason || "no_prior_run";
|
|
501
|
+
const findings = [];
|
|
502
|
+
for (const assertion of Array.isArray(assertions) ? assertions : []) {
|
|
503
|
+
if (!assertion || typeof assertion !== "object" || !isFinding(assertion)) continue;
|
|
504
|
+
const { cause, cause_reason } = classifyQaAssertion(assertion, { prior, noPriorReason });
|
|
505
|
+
assertion.cause = cause;
|
|
506
|
+
assertion.cause_reason = cause_reason;
|
|
507
|
+
findings.push(assertion);
|
|
508
|
+
}
|
|
509
|
+
const { total, counts } = summarizeCauses(findings);
|
|
510
|
+
return {
|
|
511
|
+
schema_version: CAUSE_SUMMARY_SCHEMA,
|
|
512
|
+
surface: "qa",
|
|
513
|
+
total,
|
|
514
|
+
counts,
|
|
515
|
+
// The RUN RECORD's id, the same identity the doctor summary reports, so
|
|
516
|
+
// "which previous run was this compared against" has one answer across
|
|
517
|
+
// both surfaces. The attempt actually read rides alongside under its own
|
|
518
|
+
// name rather than being conflated with it.
|
|
519
|
+
prior_run_id: text(lookup.record?.run_id) || null,
|
|
520
|
+
prior_qa_attempt_run_id: text(lookup.verdict?.run_id) || null,
|
|
521
|
+
comparison: lookup.verdict ? "prior_run" : noPriorReason,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const CAUSE_SUMMARY_SCHEMA = "campaigns-os-finding-cause/v0";
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The doctor twin. `errors` and `warnings` are the doctor output's own arrays;
|
|
529
|
+
* both are stamped in place. Returns the same summary shape.
|
|
530
|
+
*/
|
|
531
|
+
export function annotateDoctorIssueCauses({ errors = [], warnings = [], baseDir = null, mapId = null, currentRunId = null } = {}) {
|
|
532
|
+
const lookup = baseDir
|
|
533
|
+
? loadPriorDoctorFindings({ baseDir, mapId, currentRunId })
|
|
534
|
+
: { prior: null, record: null, reason: "no_prior_run" };
|
|
535
|
+
const noPriorReason = lookup.reason || "no_prior_run";
|
|
536
|
+
const findings = [];
|
|
537
|
+
const stamp = (issues, status) => {
|
|
538
|
+
for (const issue of Array.isArray(issues) ? issues : []) {
|
|
539
|
+
if (!issue || typeof issue !== "object" || Array.isArray(issue)) continue;
|
|
540
|
+
const { cause, cause_reason } = classifyDoctorIssue(issue, status, { prior: lookup.prior, noPriorReason });
|
|
541
|
+
issue.cause = cause;
|
|
542
|
+
issue.cause_reason = cause_reason;
|
|
543
|
+
findings.push(issue);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
stamp(errors, "error");
|
|
547
|
+
stamp(warnings, "warning");
|
|
548
|
+
const { total, counts } = summarizeCauses(findings);
|
|
549
|
+
return {
|
|
550
|
+
schema_version: CAUSE_SUMMARY_SCHEMA,
|
|
551
|
+
surface: "doctor",
|
|
552
|
+
total,
|
|
553
|
+
counts,
|
|
554
|
+
prior_run_id: text(lookup.record?.run_id) || null,
|
|
555
|
+
comparison: lookup.prior ? "prior_run" : noPriorReason,
|
|
556
|
+
};
|
|
557
|
+
}
|