@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,192 @@
|
|
|
1
|
+
// QA verdict discovery: the one walk over the local verdicts a campaign has
|
|
2
|
+
// written, with the projections each reader needs.
|
|
3
|
+
//
|
|
4
|
+
// `qa run` writes full verdicts to `<target repo>/qa-output/<campaign_slug>/
|
|
5
|
+
// <run_id>.json`, and the Assembly Report's qa stage records the paths it
|
|
6
|
+
// produced. Three readers walked that on their own — the ledger-divergence
|
|
7
|
+
// check (repo-relative list), run-record inference (the best candidate by
|
|
8
|
+
// identity score and time) and closeout (digests of the recorded paths) —
|
|
9
|
+
// each with its own copy of the identity rule and the directory spelling.
|
|
10
|
+
//
|
|
11
|
+
// A leaf: node built-ins, the workspace constant and the string leaves only.
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
+
import { QA_OUTPUT_REL_PATH } from "./campaign-workspace.mjs";
|
|
17
|
+
import { absentOrMalformed } from "./fs-identity.mjs";
|
|
18
|
+
import { isPlainObject as isObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
19
|
+
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
20
|
+
|
|
21
|
+
// The slug as identity, null when absent: the one spelling the identifiers
|
|
22
|
+
// and the candidate score both read.
|
|
23
|
+
function qaVerdictSlug(packet) {
|
|
24
|
+
return normalizePublicRouteSlug(packet?.campaign?.public_route_slug) || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The names a campaign's verdicts are filed and stamped under: the map id and
|
|
28
|
+
// the slug as identity. A verdict is this campaign's when its campaign_slug
|
|
29
|
+
// is one of them.
|
|
30
|
+
export function qaVerdictIdentifiers(packet) {
|
|
31
|
+
return [...new Set([optionalString(packet?.spec?.map_id), qaVerdictSlug(packet)].filter(Boolean))];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function qaVerdictIdentityMatch(verdict, packet) {
|
|
35
|
+
const slug = optionalString(verdict?.campaign_slug);
|
|
36
|
+
return Boolean(slug) && qaVerdictIdentifiers(packet).includes(slug);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `<root>/qa-output/<identifier>` — the directory `qa run` writes under.
|
|
40
|
+
export function qaVerdictDir(root, identifier) {
|
|
41
|
+
return join(root, QA_OUTPUT_REL_PATH, identifier);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The verdict paths the Assembly Report's qa stage records (any `*.json`
|
|
45
|
+
// string under a path/file/verdict/output-named key, or inside outputs[] /
|
|
46
|
+
// artifacts[] / qa), relative to the report unless absolute.
|
|
47
|
+
export function qaVerdictPathHints(report) {
|
|
48
|
+
const paths = [];
|
|
49
|
+
const visit = (value) => {
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
for (const item of value) visit(item);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (isObject(value)) {
|
|
55
|
+
for (const [key, item] of Object.entries(value)) {
|
|
56
|
+
if (typeof item === "string" && /(?:path|file|verdict|output)$/i.test(key)) visit(item);
|
|
57
|
+
else if (key === "outputs" || key === "artifacts" || key === "qa") visit(item);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === "string" && /\.json(?:[?#].*)?$/i.test(value.trim())) paths.push(value.trim());
|
|
62
|
+
};
|
|
63
|
+
visit(report?.qa || null);
|
|
64
|
+
visit(report?.stages?.qa || null);
|
|
65
|
+
return [...new Set(paths)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readVerdictFile(path) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
71
|
+
return isObject(parsed) ? parsed : null;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (absentOrMalformed(error)) return null;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sha256File(path) {
|
|
79
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Every verdict candidate for a campaign: the files the report records
|
|
83
|
+
// (source "assembly_report", resolved against the report) and every `*.json`
|
|
84
|
+
// under `qa-output/<identifier>/` beneath each root (source "qa_output").
|
|
85
|
+
// { path, repoRelPath, source, verdict, sha256, mtimeMs, identityMatch, trusted }
|
|
86
|
+
// `verdict` is null for a recorded path that exists but is not a JSON object
|
|
87
|
+
// (it still has a digest: closeout compares bytes, not shape). `trusted` is
|
|
88
|
+
// false only for a verdict the receiver stamped `trusted: false` (an
|
|
89
|
+
// anonymous submission); locally written verdicts never carry the field.
|
|
90
|
+
// An absent directory or file, or a file that is not JSON, is no candidate;
|
|
91
|
+
// any other read failure (a permission error, an I/O error) is the caller's.
|
|
92
|
+
//
|
|
93
|
+
// Lazy per candidate: a directory's names are listed when the walk reaches
|
|
94
|
+
// it, but each file is read (and hashed, when asked) only as the consumer
|
|
95
|
+
// pulls it, so a reader that stops early — closeout after a few digests —
|
|
96
|
+
// pays for what it took, not for every recorded path or every listed file.
|
|
97
|
+
export function* iterateQaVerdicts({ packet = null, report = null, reportPath = null, roots = [], withDigest = false } = {}) {
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const candidate = (path, source, repoRelPath) => {
|
|
100
|
+
const absolute = resolve(path);
|
|
101
|
+
if (seen.has(absolute)) return null;
|
|
102
|
+
let stats;
|
|
103
|
+
try {
|
|
104
|
+
stats = statSync(absolute);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (absentOrMalformed(error)) return null;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
if (!stats.isFile()) return null;
|
|
110
|
+
seen.add(absolute);
|
|
111
|
+
const verdict = readVerdictFile(absolute);
|
|
112
|
+
let sha256 = null;
|
|
113
|
+
if (withDigest) {
|
|
114
|
+
try {
|
|
115
|
+
sha256 = sha256File(absolute);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// The file can vanish between the stat and the read; nothing else may.
|
|
118
|
+
if (!absentOrMalformed(error)) throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
path: absolute,
|
|
123
|
+
repoRelPath,
|
|
124
|
+
source,
|
|
125
|
+
verdict,
|
|
126
|
+
sha256,
|
|
127
|
+
mtimeMs: stats.mtimeMs,
|
|
128
|
+
identityMatch: verdict ? qaVerdictIdentityMatch(verdict, packet) : false,
|
|
129
|
+
trusted: verdict?.trusted !== false,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const reportBase = reportPath ? dirname(resolve(reportPath)) : null;
|
|
134
|
+
for (const hint of qaVerdictPathHints(report)) {
|
|
135
|
+
const found = candidate(reportBase ? resolve(reportBase, hint) : hint, "assembly_report", null);
|
|
136
|
+
if (found) yield found;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const uniqueRoots = [...new Set(roots.filter((root) => typeof root === "string" && root).map((root) => resolve(root)))];
|
|
140
|
+
for (const root of uniqueRoots) {
|
|
141
|
+
for (const identifier of qaVerdictIdentifiers(packet)) {
|
|
142
|
+
const dir = qaVerdictDir(root, identifier);
|
|
143
|
+
let names = [];
|
|
144
|
+
try {
|
|
145
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
146
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
147
|
+
.map((entry) => entry.name)
|
|
148
|
+
.sort();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (absentOrMalformed(error)) continue;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const name of names) {
|
|
155
|
+
const path = join(dir, name);
|
|
156
|
+
// Repo-relative on purpose: divergence evidence must read the same
|
|
157
|
+
// wherever the repo sits on disk.
|
|
158
|
+
const found = candidate(path, "qa_output", relative(root, path).split("\\").join("/"));
|
|
159
|
+
if (found) yield found;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function discoverQaVerdicts(options = {}) {
|
|
166
|
+
return [...iterateQaVerdicts(options)];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Run-record inference: how well a candidate answers "this campaign, this
|
|
170
|
+
// deployment". Identity first, then the verdict schema, then a deploy origin
|
|
171
|
+
// the assertions actually visited.
|
|
172
|
+
export function qaVerdictCandidateScore(candidate, packet) {
|
|
173
|
+
const verdict = candidate?.verdict || {};
|
|
174
|
+
const mapId = optionalString(packet?.spec?.map_id);
|
|
175
|
+
const slug = qaVerdictSlug(packet);
|
|
176
|
+
let score = 0;
|
|
177
|
+
if (mapId && verdict.campaign_slug === mapId) score += 100;
|
|
178
|
+
if (slug && verdict.campaign_slug === slug) score += 80;
|
|
179
|
+
if (verdict.schema_version === "1.0" || verdict.schema_version === "campaigns-os-qa-verdict/v0") score += 10;
|
|
180
|
+
const deployOrigins = [optionalString(packet?.deploy?.preview_url), optionalString(packet?.deploy?.production_url)].filter(Boolean);
|
|
181
|
+
const assertionUrls = Array.isArray(verdict.assertions)
|
|
182
|
+
? verdict.assertions.map((assertion) => optionalString(assertion?.url)).filter(Boolean)
|
|
183
|
+
: [];
|
|
184
|
+
if (deployOrigins.some((origin) => assertionUrls.some((url) => url.startsWith(origin)))) score += 25;
|
|
185
|
+
return score;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function qaVerdictCandidateTime(candidate) {
|
|
189
|
+
const completedAt = Date.parse(candidate?.verdict?.completed_at || "");
|
|
190
|
+
if (Number.isFinite(completedAt)) return completedAt;
|
|
191
|
+
return Number(candidate?.mtimeMs || 0);
|
|
192
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The one QA-verdict publish rail. `qa run` and `qa publish` both post a
|
|
2
|
+
// verdict to the QA portal through `publishQaVerdict`, and both stamp what
|
|
3
|
+
// came back onto the Run Record through `qaVerdictPublishBlock`, so a verdict
|
|
4
|
+
// published at the end of a run and one published later from disk are
|
|
5
|
+
// classified by the same rule and recorded in the same shape.
|
|
6
|
+
//
|
|
7
|
+
// The outcome is classified the way the Run Record remit is (#397): by what
|
|
8
|
+
// the receiver answered, not by whether the transport threw. A 409 is
|
|
9
|
+
// `already_stored` — the portal already holds this run_id, which is the
|
|
10
|
+
// outcome the send was for — and a 2xx with a non-JSON body is an
|
|
11
|
+
// acknowledgement whose payload could not be read, not a refusal. Only
|
|
12
|
+
// another non-2xx, or a transport failure, is a failed publish.
|
|
13
|
+
|
|
14
|
+
import { classifyRemitOutcome, describeRemitBaseKind, remit } from "./remit.mjs";
|
|
15
|
+
|
|
16
|
+
export const QA_VERDICT_PUBLISH_ENDPOINT = "/api/qa/verdicts";
|
|
17
|
+
|
|
18
|
+
// Who published: the run that produced the verdict, or a later `qa publish`
|
|
19
|
+
// of the stored copy. Carried on the Run Record block so a reader can tell a
|
|
20
|
+
// verdict that went out with its run from one published from disk.
|
|
21
|
+
export const QA_VERDICT_PUBLISHERS = Object.freeze({
|
|
22
|
+
run: "qa run",
|
|
23
|
+
publish: "qa publish",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// The durable states the Run Record block carries. `skipped` is a run whose
|
|
27
|
+
// publish was off (`--no-post-verdict`, consent off); `ok` and `failed` are a
|
|
28
|
+
// send the receiver answered or did not, classified in `result`.
|
|
29
|
+
export const QA_VERDICT_PUBLISH_STATES = Object.freeze(["skipped", "ok", "failed"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* POST `verdict` to `<proxyBase>/api/qa/verdicts` and classify the answer.
|
|
33
|
+
* Never throws for a receiver or transport failure — the caller decides what
|
|
34
|
+
* a failed publish means (a `qa run` keeps its local verdict and reports; a
|
|
35
|
+
* `qa publish` exits non-zero). Returns:
|
|
36
|
+
*
|
|
37
|
+
* { attempted: true, ok, error, endpoint, result, http_status, base_kind, response }
|
|
38
|
+
*
|
|
39
|
+
* `response` is the parsed receiver body on a parsed 2xx, else null. The
|
|
40
|
+
* publish attaches no credential, and says so to the transport gate: plain
|
|
41
|
+
* http is still refused for anything but a loopback receiver, but the warning
|
|
42
|
+
* must not claim a credential is travelling in clear.
|
|
43
|
+
*/
|
|
44
|
+
export async function publishQaVerdict(verdict, proxyBase, { fetchImpl = globalThis.fetch, remitImpl = remit } = {}) {
|
|
45
|
+
let httpStatus = null;
|
|
46
|
+
let response = null;
|
|
47
|
+
let failure = null;
|
|
48
|
+
try {
|
|
49
|
+
response = await remitImpl(QA_VERDICT_PUBLISH_ENDPOINT, verdict, proxyBase, {
|
|
50
|
+
fetchImpl,
|
|
51
|
+
label: "QA verdict publish",
|
|
52
|
+
credential: null,
|
|
53
|
+
onResponse: (answer) => {
|
|
54
|
+
httpStatus = Number(answer?.status) || null;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
failure = error;
|
|
59
|
+
}
|
|
60
|
+
const outcome = classifyRemitOutcome(failure);
|
|
61
|
+
return {
|
|
62
|
+
attempted: true,
|
|
63
|
+
ok: outcome.ok,
|
|
64
|
+
error: outcome.error,
|
|
65
|
+
endpoint: QA_VERDICT_PUBLISH_ENDPOINT,
|
|
66
|
+
result: outcome.result,
|
|
67
|
+
http_status: outcome.http_status ?? httpStatus,
|
|
68
|
+
base_kind: describeRemitBaseKind(proxyBase),
|
|
69
|
+
response: outcome.ok && !failure ? response : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The outcome block for a run whose publish was turned off: nothing sent, nothing known. */
|
|
74
|
+
export function skippedQaVerdictPublish() {
|
|
75
|
+
return { attempted: false, ok: null, error: null, endpoint: null, result: null, http_status: null, base_kind: null, response: null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The Run Record's `qa_verdict_publish` block for one publish outcome. The
|
|
80
|
+
* verdict's run_id is the idempotency key a later `qa publish` reads; the
|
|
81
|
+
* state is derived from the outcome the same way `remit_state` is from a
|
|
82
|
+
* remit (`ok` when the receiver's answer counts as stored, `failed` when it
|
|
83
|
+
* refused or never answered, `skipped` when nothing was sent).
|
|
84
|
+
*/
|
|
85
|
+
export function qaVerdictPublishBlock(outcome, { verdictRunId, publisher, publishedAt = null }) {
|
|
86
|
+
const attempted = Boolean(outcome?.attempted);
|
|
87
|
+
const ok = attempted ? outcome.ok === true : null;
|
|
88
|
+
return {
|
|
89
|
+
verdict_run_id: String(verdictRunId),
|
|
90
|
+
publisher,
|
|
91
|
+
attempted,
|
|
92
|
+
ok,
|
|
93
|
+
error: attempted && typeof outcome.error === "string" ? outcome.error : null,
|
|
94
|
+
endpoint: attempted ? outcome.endpoint ?? QA_VERDICT_PUBLISH_ENDPOINT : null,
|
|
95
|
+
state: attempted ? (ok ? "ok" : "failed") : "skipped",
|
|
96
|
+
result: attempted ? outcome.result ?? null : null,
|
|
97
|
+
base_kind: attempted ? outcome.base_kind ?? null : null,
|
|
98
|
+
published_at: attempted && ok ? publishedAt : null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The QA portal link for a published verdict. */
|
|
103
|
+
export function qaPortalUrl(proxyBase, mapId, runId) {
|
|
104
|
+
return `${String(proxyBase).replace(/\/+$/, "")}/qa?slug=${encodeURIComponent(mapId)}&run=${encodeURIComponent(runId)}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
export const QA_SCHEMA_VERSION = "1.0";
|
|
2
|
+
|
|
3
|
+
export const STATUS = Object.freeze({
|
|
4
|
+
PASS: "pass",
|
|
5
|
+
FAIL: "fail",
|
|
6
|
+
WARN: "warn",
|
|
7
|
+
SKIPPED: "skipped",
|
|
8
|
+
MANUAL_REVIEW: "manual_review",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// The dispositions that END a run session, and the single definition of that
|
|
12
|
+
// set. A blocked attempt keeps the session open for repair; these close it.
|
|
13
|
+
//
|
|
14
|
+
// Both sides of the decision read this: autoEndRunSessionAfterTerminalQa, which
|
|
15
|
+
// closes the session and remits under its run_id, and buildQaCloseoutActions,
|
|
16
|
+
// which prints the run-record command an operator runs next. Membership is
|
|
17
|
+
// enumerated rather than excluded so an unrecognised disposition — a newer
|
|
18
|
+
// toolkit's verdict, a foreign one — falls on the session-keeping side for
|
|
19
|
+
// BOTH. Getting that backwards on one side only is the whole bug class: the
|
|
20
|
+
// printed command would spend a run_id the session still holds, and the
|
|
21
|
+
// session's own close would then be refused 409 by the receiver, which stores
|
|
22
|
+
// one record per run_id and has no replace verb.
|
|
23
|
+
export const SESSION_ENDING_DISPOSITIONS = Object.freeze(new Set(["ready", "ready_with_exceptions"]));
|
|
24
|
+
|
|
25
|
+
export const SEVERITY = Object.freeze({
|
|
26
|
+
INFO: "info",
|
|
27
|
+
WARN: "warn",
|
|
28
|
+
BLOCKER: "blocker",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Canonical vocabulary of every assertion family the QA runner emits. External
|
|
32
|
+
// consumers (e.g. the portal proxy's verdict allowlist) import this instead of
|
|
33
|
+
// scanning emitter source for `family:` literals. Order is load-bearing:
|
|
34
|
+
// theme_gate first, then the gate-suppressed families in the order a
|
|
35
|
+
// gate-blocked verdict emits its skipped audit assertions —
|
|
36
|
+
// GATE_SUPPRESSED_FAMILIES in qa-node.mjs derives from this list. A drift test
|
|
37
|
+
// in qa-verdict.test.mjs asserts set equality against the literals the
|
|
38
|
+
// emitters actually use.
|
|
39
|
+
export const QA_ASSERTION_FAMILY_VOCABULARY = Object.freeze([
|
|
40
|
+
"theme_gate",
|
|
41
|
+
"polish_gate",
|
|
42
|
+
"funnel-flow",
|
|
43
|
+
"meta-tags",
|
|
44
|
+
"api-metadata",
|
|
45
|
+
"browser-runtime",
|
|
46
|
+
"template_residue",
|
|
47
|
+
"pricing",
|
|
48
|
+
"browser-test-order",
|
|
49
|
+
"browser-receipt-rendering",
|
|
50
|
+
"analytics-correctness",
|
|
51
|
+
"analytics-parity",
|
|
52
|
+
"parity-capture",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// A waiver record riding an assertion (packet 01: the `qa waive` lane for
|
|
56
|
+
// analytics-correctness:purchase-fires). Shape mirrors report.theme.waiver:
|
|
57
|
+
// { reason, waived_by, waived_at }.
|
|
58
|
+
function assertionCarriesWaiver(assertion) {
|
|
59
|
+
const waiver = assertion?.waiver;
|
|
60
|
+
return !!(waiver && typeof waiver === "object" && !Array.isArray(waiver));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function computeDisposition(assertions) {
|
|
64
|
+
let hasBlocker = false;
|
|
65
|
+
let hasSoftIssue = false;
|
|
66
|
+
for (const assertion of assertions) {
|
|
67
|
+
if (assertion.status === STATUS.FAIL && assertion.severity === SEVERITY.BLOCKER) {
|
|
68
|
+
hasBlocker = true;
|
|
69
|
+
} else if (
|
|
70
|
+
assertion.status === STATUS.WARN ||
|
|
71
|
+
assertion.status === STATUS.MANUAL_REVIEW ||
|
|
72
|
+
(assertion.status === STATUS.FAIL && assertion.severity === SEVERITY.WARN) ||
|
|
73
|
+
// Packet 01 / ratified I-9: a waived blocker is an accepted exception,
|
|
74
|
+
// never a clean pass — any assertion carrying a waiver record keeps the
|
|
75
|
+
// disposition at ready_with_exceptions, never plain ready. (An unwaived
|
|
76
|
+
// failing blocker still lands in the branch above and blocks.)
|
|
77
|
+
assertionCarriesWaiver(assertion)
|
|
78
|
+
) {
|
|
79
|
+
hasSoftIssue = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (hasBlocker) return "blocked";
|
|
83
|
+
if (hasSoftIssue) return "ready_with_exceptions";
|
|
84
|
+
return "ready";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function dispositionWithCommercial(assertions, commercial) {
|
|
88
|
+
const disposition = computeDisposition(assertions);
|
|
89
|
+
if (disposition === "blocked") return disposition;
|
|
90
|
+
return commercial?.status === "mismatch"
|
|
91
|
+
? "ready_with_exceptions"
|
|
92
|
+
: disposition;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createVerdict({
|
|
96
|
+
runId,
|
|
97
|
+
mapId,
|
|
98
|
+
publicRouteSlug = null,
|
|
99
|
+
campaignRefId = null,
|
|
100
|
+
specVersion,
|
|
101
|
+
specHash,
|
|
102
|
+
startedAt,
|
|
103
|
+
completedAt,
|
|
104
|
+
runtime,
|
|
105
|
+
operator = "",
|
|
106
|
+
baseUrl = null,
|
|
107
|
+
entryUrls = [],
|
|
108
|
+
pageUrls = [],
|
|
109
|
+
testedUrls = [],
|
|
110
|
+
assertions,
|
|
111
|
+
testOrders = [],
|
|
112
|
+
exceptions = null,
|
|
113
|
+
commercial = null,
|
|
114
|
+
causeSummary = null,
|
|
115
|
+
browser = null,
|
|
116
|
+
}) {
|
|
117
|
+
const normalizedExceptions = Array.isArray(exceptions)
|
|
118
|
+
? exceptions
|
|
119
|
+
: deriveExceptions(assertions);
|
|
120
|
+
const normalizedBaseUrl = optionalString(baseUrl);
|
|
121
|
+
const normalizedEntryUrls = Array.isArray(entryUrls) ? entryUrls : [];
|
|
122
|
+
const normalizedPageUrls = Array.isArray(pageUrls) ? pageUrls : [];
|
|
123
|
+
const normalizedTestedUrls = Array.isArray(testedUrls) ? testedUrls : [];
|
|
124
|
+
return {
|
|
125
|
+
schema_version: QA_SCHEMA_VERSION,
|
|
126
|
+
run_id: runId,
|
|
127
|
+
// campaign_slug carries the Map ID for schema back-compat; the true public
|
|
128
|
+
// route slug rides alongside so consumers stop conflating the two.
|
|
129
|
+
campaign_slug: mapId,
|
|
130
|
+
public_route_slug: optionalString(publicRouteSlug),
|
|
131
|
+
campaign_ref_id: campaignRefId,
|
|
132
|
+
spec_version: specVersion,
|
|
133
|
+
spec_hash: specHash,
|
|
134
|
+
started_at: startedAt,
|
|
135
|
+
completed_at: completedAt,
|
|
136
|
+
runtime,
|
|
137
|
+
operator,
|
|
138
|
+
...(normalizedBaseUrl ? { base_url: normalizedBaseUrl } : {}),
|
|
139
|
+
entry_urls: normalizedEntryUrls,
|
|
140
|
+
page_urls: normalizedPageUrls,
|
|
141
|
+
tested_urls: normalizedTestedUrls,
|
|
142
|
+
disposition: dispositionWithCommercial(assertions, commercial),
|
|
143
|
+
assertions,
|
|
144
|
+
test_orders: testOrders,
|
|
145
|
+
exceptions: normalizedExceptions,
|
|
146
|
+
...(commercial && typeof commercial === "object" && !Array.isArray(commercial)
|
|
147
|
+
? { commercial }
|
|
148
|
+
: {}),
|
|
149
|
+
// Additive, and present ONLY when the run has something to say about a
|
|
150
|
+
// requested browser pass it did not perform — today, a --browser run a
|
|
151
|
+
// blocked gate finalized before any page was rendered. Absence therefore
|
|
152
|
+
// means "this verdict makes no claim about the browser pass", not "the
|
|
153
|
+
// browser pass ran": consumers read the browser-runtime assertions and
|
|
154
|
+
// tested_urls for that, exactly as they did before the field existed.
|
|
155
|
+
...(browser && typeof browser === "object" && !Array.isArray(browser)
|
|
156
|
+
? { browser }
|
|
157
|
+
: {}),
|
|
158
|
+
// Additive, and absent on verdicts emitted before per-finding cause
|
|
159
|
+
// classification existed. Consumers must tolerate its absence rather than
|
|
160
|
+
// read a missing summary as "nothing was caused by the change".
|
|
161
|
+
...(causeSummary && typeof causeSummary === "object" && !Array.isArray(causeSummary)
|
|
162
|
+
? { cause_summary: causeSummary }
|
|
163
|
+
: {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function optionalString(value) {
|
|
168
|
+
if (value == null) return null;
|
|
169
|
+
const text = String(value).trim();
|
|
170
|
+
return text || null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Is this assertion a FINDING — something the operator has to look at — rather
|
|
175
|
+
* than a clean pass? One definition, because the exception projection and the
|
|
176
|
+
* per-finding cause classification must count the same set: a summary that
|
|
177
|
+
* says "11 findings, 9 pre-existing" is unreadable if "finding" means one
|
|
178
|
+
* thing in the count and another in the list.
|
|
179
|
+
*/
|
|
180
|
+
export function isFindingAssertion(assertion) {
|
|
181
|
+
return assertion?.status === STATUS.FAIL
|
|
182
|
+
|| assertion?.status === STATUS.WARN
|
|
183
|
+
|| assertion?.status === STATUS.MANUAL_REVIEW
|
|
184
|
+
|| assertion?.severity === SEVERITY.WARN
|
|
185
|
+
|| assertion?.severity === SEVERITY.BLOCKER;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function deriveExceptions(assertions = []) {
|
|
189
|
+
if (!Array.isArray(assertions)) return [];
|
|
190
|
+
return assertions
|
|
191
|
+
.filter((assertion) => isFindingAssertion(assertion))
|
|
192
|
+
.map((assertion) => ({
|
|
193
|
+
id: assertion.id || null,
|
|
194
|
+
family: assertion.family || null,
|
|
195
|
+
page: assertion.page || null,
|
|
196
|
+
url: assertion.url || null,
|
|
197
|
+
status: assertion.status || null,
|
|
198
|
+
severity: assertion.severity || null,
|
|
199
|
+
expected: assertion.expected,
|
|
200
|
+
actual: assertion.actual,
|
|
201
|
+
// Cause rides the projection so the exceptions list — the quick read an
|
|
202
|
+
// operator or portal actually looks at — answers "was this us?" without
|
|
203
|
+
// re-joining back to the assertions array.
|
|
204
|
+
...(assertion.cause !== undefined ? { cause: assertion.cause } : {}),
|
|
205
|
+
...(assertion.cause_reason !== undefined ? { cause_reason: assertion.cause_reason } : {}),
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function validateVerdict(verdict) {
|
|
210
|
+
const errors = [];
|
|
211
|
+
if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
|
|
212
|
+
return ["verdict: must be an object"];
|
|
213
|
+
}
|
|
214
|
+
for (const field of ["run_id", "campaign_slug", "spec_version", "spec_hash", "started_at", "completed_at", "runtime", "disposition"]) {
|
|
215
|
+
if (typeof verdict[field] !== "string" || verdict[field].length === 0) errors.push(`${field}: required non-empty string`);
|
|
216
|
+
}
|
|
217
|
+
if (verdict.schema_version !== QA_SCHEMA_VERSION) errors.push(`schema_version: expected ${QA_SCHEMA_VERSION}`);
|
|
218
|
+
if (!Array.isArray(verdict.assertions)) errors.push("assertions: must be an array");
|
|
219
|
+
if (!Array.isArray(verdict.test_orders)) errors.push("test_orders: must be an array");
|
|
220
|
+
if (!Array.isArray(verdict.exceptions)) errors.push("exceptions: must be an array");
|
|
221
|
+
if (verdict.commercial !== undefined
|
|
222
|
+
&& (!verdict.commercial || typeof verdict.commercial !== "object" || Array.isArray(verdict.commercial))) {
|
|
223
|
+
errors.push("commercial: must be an object when present");
|
|
224
|
+
}
|
|
225
|
+
return errors;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Purchase-proof coverage summary.
|
|
229
|
+
//
|
|
230
|
+
// COUNTS ONLY, deliberately. This summary is written onto the Assembly Report's
|
|
231
|
+
// qa stage, which is committed and rides into the readback bundle. The sidecar
|
|
232
|
+
// projection strips order ids, refs, emails and checkout URLs out of the verdict
|
|
233
|
+
// before it can be published, and sidecar-bundle conformance FAILS a bundle
|
|
234
|
+
// whose projected order arrays are non-empty. So the signal that a purchase
|
|
235
|
+
// actually happened has to be numbers, never the orders themselves.
|
|
236
|
+
export function summarizePurchaseProof({ verdict = null, proofPolicy = null } = {}) {
|
|
237
|
+
const orders = Array.isArray(verdict?.test_orders) ? verdict.test_orders.filter((order) => order && typeof order === "object") : [];
|
|
238
|
+
// A real id is a non-empty trimmed string or a POSITIVE number. Numeric `0`
|
|
239
|
+
// is the conventional placeholder the runner emits when an id was never
|
|
240
|
+
// received, and `Number.isFinite(0)` is true — counting it would report an
|
|
241
|
+
// order as created on a run that created nothing. Note the `??` chain stops
|
|
242
|
+
// at a literal `0` (it is not nullish), so a zero here is never rescued by
|
|
243
|
+
// the next field either; both facts push the same way, toward not counting.
|
|
244
|
+
const created = orders.filter((order) => {
|
|
245
|
+
const id = order.next_order_id ?? order.order_id ?? order.ref_id;
|
|
246
|
+
if (typeof id === "string") return id.trim().length > 0;
|
|
247
|
+
return Number.isFinite(id) && id > 0;
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
declared_order_path_depth: optionalString(proofPolicy?.order_path_depth),
|
|
251
|
+
declared_typed_card_depth: optionalString(proofPolicy?.typed_card_depth),
|
|
252
|
+
order_paths_executed: orders.length,
|
|
253
|
+
orders_created: created.length,
|
|
254
|
+
orders_verified: orders.filter((order) => order.verification?.verified === true).length,
|
|
255
|
+
// null, not false, when nothing ran: "no order was out of test mode" and
|
|
256
|
+
// "no order ran at all" are different facts.
|
|
257
|
+
all_orders_test_mode: orders.length ? orders.every((order) => order.is_test === true) : null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The browser placeholder-text residue gate (H3.1) emits one assertion per
|
|
262
|
+
// page as `template-residue:<page_id>:placeholder-text`. Both the emitter
|
|
263
|
+
// (qa-browser.mjs) and the stage-ledger summary below build the id from this
|
|
264
|
+
// suffix so the two cannot drift.
|
|
265
|
+
export const PLACEHOLDER_TEXT_ASSERTION_SUFFIX = ":placeholder-text";
|
|
266
|
+
|
|
267
|
+
export function isPlaceholderTextAssertion(assertion) {
|
|
268
|
+
const id = optionalString(assertion?.id);
|
|
269
|
+
return Boolean(id && id.startsWith("template-residue:") && id.endsWith(PLACEHOLDER_TEXT_ASSERTION_SUFFIX));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// What the verdict says about the placeholder-text gate, in a shape the
|
|
273
|
+
// Assembly Report's qa stage can carry: `pass` only when the gate ran on at
|
|
274
|
+
// least one page and no page failed; `fail` when any page failed; null when
|
|
275
|
+
// the gate never ran (gate-skipped verdict, a non-browser run, an older
|
|
276
|
+
// toolkit), so a doctor reading the report cannot mistake silence for a pass.
|
|
277
|
+
export function summarizePlaceholderTextGate(verdict) {
|
|
278
|
+
const assertions = (Array.isArray(verdict?.assertions) ? verdict.assertions : []).filter(isPlaceholderTextAssertion);
|
|
279
|
+
const evaluated = assertions.filter((assertion) => assertion.status === STATUS.PASS || assertion.status === STATUS.FAIL);
|
|
280
|
+
if (!evaluated.length) return null;
|
|
281
|
+
const failed = evaluated.filter((assertion) => assertion.status === STATUS.FAIL);
|
|
282
|
+
return {
|
|
283
|
+
status: failed.length ? STATUS.FAIL : STATUS.PASS,
|
|
284
|
+
pages_checked: evaluated.length,
|
|
285
|
+
pages_failed: failed.length,
|
|
286
|
+
};
|
|
287
|
+
}
|