@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,509 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
3
|
+
import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
|
|
4
|
+
import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
5
|
+
import {
|
|
6
|
+
ASSEMBLY_REPORT_STAGE_KEYS,
|
|
7
|
+
NEXT_STAGE_CONTRACTS,
|
|
8
|
+
NEXT_STAGE_OWNERS,
|
|
9
|
+
stageIsBlocked,
|
|
10
|
+
stageIsTerminal,
|
|
11
|
+
} from "./orchestration-stage-contract.mjs";
|
|
12
|
+
|
|
13
|
+
const PRODUCER_STAGES = new Set(["doctor", "qa"]);
|
|
14
|
+
|
|
15
|
+
function nonEmptyStrings(values) {
|
|
16
|
+
return Array.isArray(values) ? values.filter((value) => typeof value === "string" && value.trim()) : [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function terminalStatus(disposition) {
|
|
20
|
+
if (disposition === "blocked") return "blocked";
|
|
21
|
+
if (disposition === "ready_with_warnings" || disposition === "ready_with_exceptions") return "completed_with_warnings";
|
|
22
|
+
if (disposition === "ready") return "completed";
|
|
23
|
+
throw new Error(`Unsupported producer disposition "${disposition}".`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extension fields on a producer stage that the PRODUCER owns, not the
|
|
27
|
+
// operator. Everything else on the stage object is someone else's data and
|
|
28
|
+
// passes through a producer write untouched (`waivers` is read elsewhere in
|
|
29
|
+
// this repo, and out-of-repo consumers read this report too), so this list must
|
|
30
|
+
// stay exactly as long as the evidence a producer can actually restate.
|
|
31
|
+
//
|
|
32
|
+
// Compatibility decision, deliberate and narrow: before this, every hand-authored
|
|
33
|
+
// extension field survived the spread, which let a stage carry a refreshed
|
|
34
|
+
// status/outputs/timestamp beside a previous run's `verdict_run_id` and an
|
|
35
|
+
// `evidence` block describing an already-fixed bug. Latest identity and latest
|
|
36
|
+
// status can no longer disagree. The previous pair is not deleted — it is moved,
|
|
37
|
+
// with its ORIGINAL status and timestamp, into a bounded `history[]` on the same
|
|
38
|
+
// stage. A previous stage with no `checked_at` (the pre-#308 report shape)
|
|
39
|
+
// produces a history entry with no `checked_at`: an absent timestamp is
|
|
40
|
+
// preserved as absent rather than stamped with now, because manufactured
|
|
41
|
+
// provenance is worse than the stale field it replaces. Both schema-legal
|
|
42
|
+
// `evidence` shapes archive, object and array alike; an array of operator notes
|
|
43
|
+
// is exactly the evidence a producer has no standing to silently drop.
|
|
44
|
+
const QA_OWNED_FIELDS = Object.freeze(["verdict_run_id", "evidence", "purchase_proof"]);
|
|
45
|
+
|
|
46
|
+
// Bounded so a committed handoff artifact cannot grow without limit, and deep
|
|
47
|
+
// enough that a couple of repair attempts do not evict the state a reviewer
|
|
48
|
+
// came looking for.
|
|
49
|
+
const PRODUCER_STAGE_HISTORY_LIMIT = 5;
|
|
50
|
+
// The fields a producer restates on every run even when nothing else moved.
|
|
51
|
+
// A re-run that reaches the same outcome differs from the previous report in
|
|
52
|
+
// these alone, and rewriting the file for them makes every digest taken of
|
|
53
|
+
// the report (a Run Record's assembly_report sha256, for one) go stale for
|
|
54
|
+
// no information.
|
|
55
|
+
const PRODUCER_STAGE_TIMESTAMP_FIELDS = Object.freeze(["checked_at", "completed_at"]);
|
|
56
|
+
|
|
57
|
+
// `$defs.stage.evidence` is `oneOf: [array, object]`, so an operator or an
|
|
58
|
+
// out-of-repo producer may legally have written either shape. Recognize both,
|
|
59
|
+
// or the array branch is deleted below with no history entry and the notes it
|
|
60
|
+
// carried leave the report entirely.
|
|
61
|
+
function evidenceValue(value) {
|
|
62
|
+
if (isPlainObject(value) || Array.isArray(value)) return value;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// An empty object or array is schema-legal but says nothing. History exists to
|
|
67
|
+
// preserve prior identity a reviewer might come looking for; an empty evidence
|
|
68
|
+
// block is not that, and archiving one produces a no-op entry that evicts a
|
|
69
|
+
// real one from the bounded window. Empty therefore reads as absent.
|
|
70
|
+
function meaningfulEvidence(value) {
|
|
71
|
+
const evidence = evidenceValue(value);
|
|
72
|
+
if (!evidence) return null;
|
|
73
|
+
const empty = Array.isArray(evidence) ? evidence.length === 0 : Object.keys(evidence).length === 0;
|
|
74
|
+
return empty ? null : evidence;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Key order is not meaning. `previous` has been round-tripped through disk and
|
|
78
|
+
// may come back with its keys in any order, while `incoming` carries whatever
|
|
79
|
+
// order the producer happened to build it in; a plain JSON.stringify comparison
|
|
80
|
+
// would call those two unequal and archive a new history entry on every
|
|
81
|
+
// re-record of an unchanged verdict, which is precisely what the dedup below
|
|
82
|
+
// exists to prevent. Canonicalize object keys (arrays keep their order, which
|
|
83
|
+
// IS meaning) before comparing.
|
|
84
|
+
function canonicalize(value) {
|
|
85
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
86
|
+
if (isPlainObject(value)) {
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const key of Object.keys(value).sort()) out[key] = canonicalize(value[key]);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sameJson(a, b) {
|
|
95
|
+
return JSON.stringify(canonicalize(a ?? null)) === JSON.stringify(canonicalize(b ?? null));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Archive the previous producer-owned identity, if there was one, without
|
|
100
|
+
* inventing anything it did not carry.
|
|
101
|
+
*/
|
|
102
|
+
function archivePreviousIdentity(previous, incoming) {
|
|
103
|
+
const hadIdentity = typeof previous.verdict_run_id === "string" && previous.verdict_run_id.trim()
|
|
104
|
+
? previous.verdict_run_id.trim()
|
|
105
|
+
: null;
|
|
106
|
+
const hadEvidence = meaningfulEvidence(previous.evidence);
|
|
107
|
+
if (!hadIdentity && !hadEvidence) return null;
|
|
108
|
+
// An unchanged verdict is a re-record, not a new chapter: re-running the same
|
|
109
|
+
// producer against the same verdict must not grow history. Both sides are
|
|
110
|
+
// normalized the same way, so an empty incoming evidence block compares equal
|
|
111
|
+
// to an empty previous one instead of looking like a change.
|
|
112
|
+
if (sameJson(hadIdentity, incoming.verdict_run_id ?? null) && sameJson(hadEvidence, meaningfulEvidence(incoming.evidence))) return null;
|
|
113
|
+
const entry = {};
|
|
114
|
+
if (typeof previous.status === "string" && previous.status.trim()) entry.status = previous.status;
|
|
115
|
+
if (typeof previous.checked_at === "string" && previous.checked_at.trim()) entry.checked_at = previous.checked_at;
|
|
116
|
+
if (hadIdentity) entry.verdict_run_id = hadIdentity;
|
|
117
|
+
if (hadEvidence) entry.evidence = JSON.parse(JSON.stringify(hadEvidence));
|
|
118
|
+
return entry;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function withoutStageTimestamps(report, stage) {
|
|
122
|
+
const copy = JSON.parse(JSON.stringify(report ?? null));
|
|
123
|
+
const stageValue = copy?.stages?.[stage];
|
|
124
|
+
if (isPlainObject(stageValue)) {
|
|
125
|
+
for (const field of PRODUCER_STAGE_TIMESTAMP_FIELDS) delete stageValue[field];
|
|
126
|
+
}
|
|
127
|
+
return copy;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* True when `nextReport` restates exactly the outcome `previousReport` already
|
|
132
|
+
* carries for `stage`, differing only in that stage's timestamps. A producer
|
|
133
|
+
* that sees this has nothing to write: the report on disk already says what
|
|
134
|
+
* this run found, and leaving its bytes alone keeps every digest of it valid.
|
|
135
|
+
*/
|
|
136
|
+
export function producerStageOutcomeUnchanged(previousReport, nextReport, stage) {
|
|
137
|
+
if (!PRODUCER_STAGES.has(stage)) throw new Error("Producer stage must be doctor or qa.");
|
|
138
|
+
return sameJson(withoutStageTimestamps(previousReport, stage), withoutStageTimestamps(nextReport, stage));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Return an Assembly Report copy with the current doctor/QA producer outcome.
|
|
143
|
+
* The producer supplies its own timestamp and artifact paths; this helper never
|
|
144
|
+
* invents historical completion evidence.
|
|
145
|
+
*
|
|
146
|
+
* `identity` (currently `{ verdict_run_id }`) and `evidence` are the QA
|
|
147
|
+
* producer's own explanation of the run the canonical fields point at.
|
|
148
|
+
* `proof` is the counts-only purchase-proof summary — never order ids, refs,
|
|
149
|
+
* emails or URLs, because this report is committed and rides into the readback
|
|
150
|
+
* bundle.
|
|
151
|
+
*/
|
|
152
|
+
export function recordProducerStageOutcome(report, {
|
|
153
|
+
stage,
|
|
154
|
+
disposition,
|
|
155
|
+
timestamp,
|
|
156
|
+
command,
|
|
157
|
+
outputs = [],
|
|
158
|
+
blockers = [],
|
|
159
|
+
warnings = [],
|
|
160
|
+
identity = null,
|
|
161
|
+
evidence = null,
|
|
162
|
+
proof = null,
|
|
163
|
+
} = {}) {
|
|
164
|
+
if (!PRODUCER_STAGES.has(stage)) throw new Error("Producer stage must be doctor or qa.");
|
|
165
|
+
if (typeof timestamp !== "string" || !Number.isFinite(Date.parse(timestamp))) {
|
|
166
|
+
throw new Error("Producer stage timestamp must be a parseable ISO timestamp.");
|
|
167
|
+
}
|
|
168
|
+
if (!report || typeof report !== "object" || Array.isArray(report)) throw new Error("Assembly Report must be an object.");
|
|
169
|
+
|
|
170
|
+
const updated = JSON.parse(JSON.stringify(report));
|
|
171
|
+
const stages = updated.stages && typeof updated.stages === "object" && !Array.isArray(updated.stages)
|
|
172
|
+
? updated.stages
|
|
173
|
+
: {};
|
|
174
|
+
const previous = stages[stage] && typeof stages[stage] === "object" && !Array.isArray(stages[stage])
|
|
175
|
+
? stages[stage]
|
|
176
|
+
: {};
|
|
177
|
+
const status = terminalStatus(disposition);
|
|
178
|
+
const next = {
|
|
179
|
+
...previous,
|
|
180
|
+
stage,
|
|
181
|
+
status,
|
|
182
|
+
inputs: nonEmptyStrings(previous.inputs),
|
|
183
|
+
outputs: nonEmptyStrings(outputs),
|
|
184
|
+
commands: nonEmptyStrings(command ? [command] : []),
|
|
185
|
+
blockers: nonEmptyStrings(blockers),
|
|
186
|
+
warnings: nonEmptyStrings(warnings),
|
|
187
|
+
checked_at: timestamp,
|
|
188
|
+
};
|
|
189
|
+
if (status.startsWith("completed")) next.completed_at = timestamp;
|
|
190
|
+
else delete next.completed_at;
|
|
191
|
+
|
|
192
|
+
// Only QA restates a verdict. The doctor stage has no verdict identity and no
|
|
193
|
+
// purchase proof, so it never gains these fields even if a caller passes them.
|
|
194
|
+
if (stage === "qa") {
|
|
195
|
+
const incomingRunId = typeof identity?.verdict_run_id === "string" && identity.verdict_run_id.trim()
|
|
196
|
+
? identity.verdict_run_id.trim()
|
|
197
|
+
: null;
|
|
198
|
+
const incomingEvidenceSource = evidenceValue(evidence);
|
|
199
|
+
const incomingEvidence = incomingEvidenceSource ? JSON.parse(JSON.stringify(incomingEvidenceSource)) : null;
|
|
200
|
+
const archived = archivePreviousIdentity(previous, { verdict_run_id: incomingRunId, evidence: incomingEvidence });
|
|
201
|
+
for (const field of QA_OWNED_FIELDS) delete next[field];
|
|
202
|
+
if (incomingRunId) next.verdict_run_id = incomingRunId;
|
|
203
|
+
if (incomingEvidence) next.evidence = incomingEvidence;
|
|
204
|
+
if (isPlainObject(proof)) next.purchase_proof = JSON.parse(JSON.stringify(proof));
|
|
205
|
+
if (archived) {
|
|
206
|
+
const priorHistory = Array.isArray(previous.history) ? previous.history.filter(isPlainObject) : [];
|
|
207
|
+
next.history = [...priorHistory, archived].slice(-PRODUCER_STAGE_HISTORY_LIMIT);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
stages[stage] = next;
|
|
211
|
+
updated.stages = stages;
|
|
212
|
+
return updated;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The gates that sit before the ladder, in the order the `next` picker
|
|
216
|
+
// consults them: a blocked prepare-build or a blocked doctor holds every
|
|
217
|
+
// stage behind it (`blockedStage`). Neither is a ladder step — a pending
|
|
218
|
+
// doctor (prepare-build with --no-doctor, or a report written before doctor
|
|
219
|
+
// ran) does not hold the ladder, exactly as the picker does not walk it — but
|
|
220
|
+
// it does hold "done": the report only reads completed once every recorded
|
|
221
|
+
// stage is terminal, and a gate that never recorded an outcome is named
|
|
222
|
+
// (`pendingStage`) once the ladder has nothing left to run.
|
|
223
|
+
const PRE_LADDER_GATES = Object.freeze([
|
|
224
|
+
Object.freeze({ reportKey: "prepare_build", blockedStage: "prepare-build", pendingStage: "prepare-build" }),
|
|
225
|
+
Object.freeze({ reportKey: "doctor", blockedStage: "doctor-blocked", pendingStage: "doctor" }),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
function stageOf(report, key) {
|
|
229
|
+
const stage = report?.stages?.[key];
|
|
230
|
+
return isPlainObject(stage) ? stage : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stageBlockers(stage) {
|
|
234
|
+
return Array.isArray(stage?.blockers) ? stage.blockers : [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* True when any recorded stage on `report` has status "blocked".
|
|
239
|
+
*/
|
|
240
|
+
export function anyAssemblyReportStageBlocked(report) {
|
|
241
|
+
return ASSEMBLY_REPORT_STAGE_KEYS.some((key) => stageIsBlocked(stageOf(report, key)?.status));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// `ownerKey` is the NEXT_STAGE_OWNERS row to spell the owner from; it differs
|
|
245
|
+
// from `stage` only for a pending doctor, which the picker has no row for
|
|
246
|
+
// (it names doctor-blocked alone) and which the same operator skill owns.
|
|
247
|
+
function nextBlock(stage, action, { ownerKey = stage, ...extras } = {}) {
|
|
248
|
+
const owners = NEXT_STAGE_OWNERS[ownerKey];
|
|
249
|
+
return { stage, owner: owners.default_skill, action, ...extras };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The Assembly Report's top-level summary, computed from the stage ledger it
|
|
254
|
+
* carries and nothing else. Every write of the report restates it
|
|
255
|
+
* (commitAssemblyReport, and prepare-build's initial write), so the summary
|
|
256
|
+
* can never lag the stages: before this it was written once by prepare-build
|
|
257
|
+
* and a finished ladder still read `status: "prepared"`, `next.stage:
|
|
258
|
+
* "setup"`, `blockers: []` beside a blocked or completed QA stage.
|
|
259
|
+
*
|
|
260
|
+
* - `status`: "blocked" when any recorded stage is blocked; "completed" only
|
|
261
|
+
* when every recorded stage — the pre-ladder gates (prepare_build, doctor)
|
|
262
|
+
* included — is terminal; otherwise "prepared". A freshly prepared report
|
|
263
|
+
* whose doctor never ran therefore never reads completed, whatever the
|
|
264
|
+
* ladder says.
|
|
265
|
+
* - `next`: the first stage that is not terminal, in the order the `next`
|
|
266
|
+
* command walks — a blocked prepare-build ("prepare-build"), a blocked
|
|
267
|
+
* doctor ("doctor-blocked"), then the ladder in NEXT_STAGE_CONTRACTS order.
|
|
268
|
+
* A pending gate does not hold the ladder (the picker does not walk it) but
|
|
269
|
+
* is named once the ladder is exhausted ("doctor" for a doctor that never
|
|
270
|
+
* recorded an outcome), and only when every recorded stage is terminal does
|
|
271
|
+
* `next.stage` read "done". `next.stage` uses the picker's vocabulary (the
|
|
272
|
+
* `next <stage>` argument, so "build" not "assembly"), `next.owner` names
|
|
273
|
+
* the skill that owns the stage, and `next.blocked` is true when the named
|
|
274
|
+
* stage is the one holding the ladder. This is the ledger's own position
|
|
275
|
+
* only: the `next` command additionally folds in live gates (doctor
|
|
276
|
+
* findings, purchase-proof coverage, the polish gate) and stays the
|
|
277
|
+
* authority for what runs next.
|
|
278
|
+
* - `blockers`: the union of the `blockers[]` of every stage currently
|
|
279
|
+
* blocked, in stage order, exact duplicates collapsed. A stage that was
|
|
280
|
+
* blocked and later passed contributes nothing, so a blocker cleared by a
|
|
281
|
+
* re-run leaves the top level with the stage. The entries are the stage's
|
|
282
|
+
* own blocker values, not copies: the summary is computed for a write, and
|
|
283
|
+
* the report is serialized right after.
|
|
284
|
+
*
|
|
285
|
+
* Pure: reads `report`, returns a fresh summary, copies nothing else.
|
|
286
|
+
*/
|
|
287
|
+
export function deriveAssemblyReportSummary(report) {
|
|
288
|
+
if (!isPlainObject(report)) throw new TypeError("deriveAssemblyReportSummary requires an Assembly Report object.");
|
|
289
|
+
const blockers = [];
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
|
|
292
|
+
const stage = stageOf(report, key);
|
|
293
|
+
if (!stageIsBlocked(stage?.status)) continue;
|
|
294
|
+
for (const blocker of stageBlockers(stage)) {
|
|
295
|
+
const id = JSON.stringify(canonicalize(blocker));
|
|
296
|
+
if (seen.has(id)) continue;
|
|
297
|
+
seen.add(id);
|
|
298
|
+
blockers.push(blocker);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const anyBlocked = anyAssemblyReportStageBlocked(report);
|
|
302
|
+
|
|
303
|
+
let next = null;
|
|
304
|
+
for (const gate of PRE_LADDER_GATES) {
|
|
305
|
+
if (!stageIsBlocked(stageOf(report, gate.reportKey)?.status)) continue;
|
|
306
|
+
next = nextBlock(gate.blockedStage, `Stage "${gate.reportKey}" is blocked; resolve its blockers before any stage runs.`, { blocked: true });
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
if (!next) {
|
|
310
|
+
for (const { cliStage, reportKey } of NEXT_STAGE_CONTRACTS) {
|
|
311
|
+
const status = stageOf(report, reportKey)?.status;
|
|
312
|
+
if (stageIsBlocked(status)) {
|
|
313
|
+
next = nextBlock(cliStage, `Stage "${reportKey}" is blocked; unblock it, then run ${cliStage}.`, { blocked: true });
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
if (!stageIsTerminal(status)) {
|
|
317
|
+
next = nextBlock(cliStage, `Run ${cliStage} with this packet.`);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!next) {
|
|
323
|
+
for (const gate of PRE_LADDER_GATES) {
|
|
324
|
+
if (stageIsTerminal(stageOf(report, gate.reportKey)?.status)) continue;
|
|
325
|
+
next = nextBlock(gate.pendingStage, `Stage "${gate.reportKey}" has not recorded a terminal outcome; run it before treating the report as complete.`, { ownerKey: gate.blockedStage });
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!next) next = nextBlock("done", "Every stage is terminal; run next to confirm the closeout actions.");
|
|
330
|
+
|
|
331
|
+
const status = anyBlocked ? "blocked" : next.stage === "done" ? "completed" : "prepared";
|
|
332
|
+
return { status, next, blockers };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Restate `report`'s derived summary (`status`, `next`, `blockers`) from its
|
|
337
|
+
* stages, in place, and return it. The report is the caller's own object
|
|
338
|
+
* (the fresh one prepare-build built, or the copy a producer's
|
|
339
|
+
* recordProducerStageOutcome already made), so nothing is cloned here.
|
|
340
|
+
*/
|
|
341
|
+
export function applyDerivedAssemblyReportSummary(report) {
|
|
342
|
+
return Object.assign(report, deriveAssemblyReportSummary(report));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// QA-owned gate evidence on the qa stage. The QA producer records, beside
|
|
346
|
+
// its verdict identity, the build it ran against and the outcome of gates a
|
|
347
|
+
// static doctor scan can only approximate (`gates.placeholder_text_residue`,
|
|
348
|
+
// from summarizePlaceholderTextGate). Doctor reads it back through
|
|
349
|
+
// qaGatePassedForCurrentBuild: a pass counts only while
|
|
350
|
+
// stages.assembly.build_fingerprint still equals the fingerprint QA saw, so a
|
|
351
|
+
// rebuild silently revokes it. Evidence is a QA-owned field, so the next QA
|
|
352
|
+
// record replaces it wholesale — a stale pass cannot outlive the run that
|
|
353
|
+
// recorded it.
|
|
354
|
+
export const QA_GATE_PLACEHOLDER_TEXT_RESIDUE = "placeholder_text_residue";
|
|
355
|
+
|
|
356
|
+
export function qaGateEvidence(report, gate) {
|
|
357
|
+
const evidence = report?.stages?.qa?.evidence;
|
|
358
|
+
if (!isPlainObject(evidence) || !isPlainObject(evidence.gates)) return null;
|
|
359
|
+
const outcome = evidence.gates[gate];
|
|
360
|
+
if (!isPlainObject(outcome)) return null;
|
|
361
|
+
return {
|
|
362
|
+
status: optionalString(outcome.status),
|
|
363
|
+
source_build_fingerprint: optionalString(evidence.source_build_fingerprint),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint }) {
|
|
368
|
+
const outcome = qaGateEvidence(report, gate);
|
|
369
|
+
const current = optionalString(buildFingerprint);
|
|
370
|
+
return Boolean(outcome && outcome.status === QA_STATUS.PASS && current && outcome.source_build_fingerprint === current);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* True when `report` is this packet's Assembly Report: the identity block
|
|
375
|
+
* names the packet's map id and public route slug (both absent on both sides
|
|
376
|
+
* also matches — a report with no identity belongs to a packet with none).
|
|
377
|
+
*/
|
|
378
|
+
export function assemblyReportMatchesPacket(report, packet) {
|
|
379
|
+
return isPlainObject(report)
|
|
380
|
+
&& optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id)
|
|
381
|
+
&& optionalString(report?.identity?.public_route_slug) === optionalString(packet?.campaign?.public_route_slug);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* One commit of an edit to the Assembly Report a campaign workspace binds,
|
|
386
|
+
* with the retained doctor sidecar kept honest about it in the same step.
|
|
387
|
+
*
|
|
388
|
+
* `workspace` is `resolveCampaignWorkspace(...)`'s result (or any object with
|
|
389
|
+
* `packet`, `reportPath`, `doctorOutPath`, `targetRepo`). The report is read
|
|
390
|
+
* from `reportPath`, `mutate(report)` returns the report to write — or `null`
|
|
391
|
+
* to leave the file's bytes alone (a re-run that restates what is already on
|
|
392
|
+
* disk, so every digest taken of the file stays valid) — and the write is
|
|
393
|
+
* atomic (tmp + rename), so a concurrent reader never sees a torn report.
|
|
394
|
+
* `null` means the same for an operator edit: nothing to write, so nothing
|
|
395
|
+
* to stamp stale — an edit that finds its change already recorded is a
|
|
396
|
+
* no-op, not an error.
|
|
397
|
+
*
|
|
398
|
+
* Exactly one doctor-freshness strategy is named:
|
|
399
|
+
*
|
|
400
|
+
* - `refreshDoctor(outcome)`: the caller recomputes doctor state (or already
|
|
401
|
+
* has it) and the sidecar at `doctorOutPath` is rewritten wholesale from
|
|
402
|
+
* what it returns, stamped `generated_by: command` (#312), which also
|
|
403
|
+
* clears any stale stamp; return `null` to leave the sidecar as it is. It
|
|
404
|
+
* runs whether or not the report was written, because a producer that
|
|
405
|
+
* found nothing new to restate still holds current doctor state.
|
|
406
|
+
* - `staleReason`: the edit changes what doctor would conclude without
|
|
407
|
+
* recomputing it, so the retained sidecar under `targetRepo`, if any, is
|
|
408
|
+
* stamped stale (`stale_marked_by: command`) — only when the report was
|
|
409
|
+
* actually written.
|
|
410
|
+
*
|
|
411
|
+
* `command` is required either way: it is the producer's own name as the
|
|
412
|
+
* sidecar will carry it (`"doctor"`, `"qa run"`, `"theme generate"`), threaded
|
|
413
|
+
* from the caller rather than guessed from argv here.
|
|
414
|
+
*
|
|
415
|
+
* `stage`: a producer (`"doctor"` | `"qa"`) restating its outcome. The write
|
|
416
|
+
* is skipped when the mutated report differs from disk only in that stage's
|
|
417
|
+
* timestamps (`producerStageOutcomeUnchanged`), and the report must be this
|
|
418
|
+
* packet's (`assemblyReportMatchesPacket`) or the edit is skipped — a
|
|
419
|
+
* producer never restates its outcome into another campaign's report, and
|
|
420
|
+
* an absent report is likewise a skip rather than an error, since a producer
|
|
421
|
+
* without a ledger still has a sidecar to keep current. Operator edits
|
|
422
|
+
* (waivers, evidence merges) pass no `stage`: they require the report to
|
|
423
|
+
* exist and bind its identity themselves.
|
|
424
|
+
*
|
|
425
|
+
* Returns `{ written, skipped, report, reportPath, doctorOutPath }` where
|
|
426
|
+
* `skipped` is `null`, `"absent"`, `"identity"` or `"unchanged"` and `report`
|
|
427
|
+
* is what is now on disk (the mutated report when written, else the one read,
|
|
428
|
+
* else `null`).
|
|
429
|
+
*/
|
|
430
|
+
export function commitAssemblyReport(workspace, mutate, {
|
|
431
|
+
refreshDoctor = null,
|
|
432
|
+
staleReason = null,
|
|
433
|
+
command = null,
|
|
434
|
+
stage = null,
|
|
435
|
+
} = {}) {
|
|
436
|
+
const hasRefresh = typeof refreshDoctor === "function";
|
|
437
|
+
const hasStale = typeof staleReason === "string" && staleReason.trim();
|
|
438
|
+
if (hasRefresh === Boolean(hasStale)) {
|
|
439
|
+
throw new TypeError("commitAssemblyReport requires exactly one of refreshDoctor (a function) or staleReason (a string).");
|
|
440
|
+
}
|
|
441
|
+
if (!optionalString(command)) {
|
|
442
|
+
throw new TypeError("commitAssemblyReport requires command: the doctor sidecar names the command that produced it (generated_by) or stamped it stale (stale_marked_by).");
|
|
443
|
+
}
|
|
444
|
+
if (stage !== null && !PRODUCER_STAGES.has(stage)) throw new Error("Producer stage must be doctor or qa.");
|
|
445
|
+
if (typeof mutate !== "function") throw new TypeError("commitAssemblyReport requires a mutate(report) function.");
|
|
446
|
+
const reportPath = optionalString(workspace?.reportPath);
|
|
447
|
+
const doctorOutPath = optionalString(workspace?.doctorOutPath);
|
|
448
|
+
const targetRepo = optionalString(workspace?.targetRepo);
|
|
449
|
+
if (!reportPath) throw new TypeError("commitAssemblyReport requires a workspace with reportPath.");
|
|
450
|
+
if (hasRefresh && !doctorOutPath) throw new TypeError("commitAssemblyReport requires a workspace with doctorOutPath to refresh the doctor sidecar.");
|
|
451
|
+
if (hasStale && !targetRepo) throw new TypeError("commitAssemblyReport requires a workspace with targetRepo to stamp the doctor sidecar stale.");
|
|
452
|
+
|
|
453
|
+
const outcome = { written: false, skipped: null, report: null, reportPath, doctorOutPath };
|
|
454
|
+
const finish = () => {
|
|
455
|
+
if (hasRefresh) {
|
|
456
|
+
const doctor = refreshDoctor(outcome);
|
|
457
|
+
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim() });
|
|
458
|
+
} else if (outcome.written) {
|
|
459
|
+
markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
|
|
460
|
+
}
|
|
461
|
+
return outcome;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
if (!existsSync(reportPath)) {
|
|
465
|
+
if (stage) {
|
|
466
|
+
outcome.skipped = "absent";
|
|
467
|
+
return finish();
|
|
468
|
+
}
|
|
469
|
+
throw new Error(`Assembly Report not found at ${reportPath}; run prepare-build/start first.`);
|
|
470
|
+
}
|
|
471
|
+
// A torn or hand-edited report fails by name: the raw SyntaxError names
|
|
472
|
+
// neither the file nor the command, and every caller's read of the report
|
|
473
|
+
// (waivers, the polish merge, the producer stage records) goes through
|
|
474
|
+
// here. Only the parse is caught; a read failure (EACCES, EISDIR) is not
|
|
475
|
+
// a malformed report and propagates as itself.
|
|
476
|
+
const raw = readFileSync(reportPath, "utf8");
|
|
477
|
+
let report;
|
|
478
|
+
try {
|
|
479
|
+
report = JSON.parse(raw);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
throw new Error(`Assembly Report at ${reportPath} is not valid JSON: ${error.message}`);
|
|
482
|
+
}
|
|
483
|
+
outcome.report = report;
|
|
484
|
+
if (stage && !assemblyReportMatchesPacket(report, workspace?.packet)) {
|
|
485
|
+
outcome.skipped = "identity";
|
|
486
|
+
return finish();
|
|
487
|
+
}
|
|
488
|
+
const mutated = mutate(report);
|
|
489
|
+
if (mutated === null || mutated === undefined) {
|
|
490
|
+
outcome.skipped = "unchanged";
|
|
491
|
+
return finish();
|
|
492
|
+
}
|
|
493
|
+
if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object, null, or undefined.");
|
|
494
|
+
// The summary is restated on every write, so a report whose top level lags
|
|
495
|
+
// its stages (written before the summary was derived) heals on the next
|
|
496
|
+
// commit; after that the restatement is a no-op and the unchanged check
|
|
497
|
+
// below keeps the file's bytes alone. `mutated` is the mutator's own object
|
|
498
|
+
// (every mutator in this repo returns a copy), so the restatement is in
|
|
499
|
+
// place rather than a second deep clone.
|
|
500
|
+
const next = applyDerivedAssemblyReportSummary(mutated);
|
|
501
|
+
if (stage && producerStageOutcomeUnchanged(report, next, stage)) {
|
|
502
|
+
outcome.skipped = "unchanged";
|
|
503
|
+
return finish();
|
|
504
|
+
}
|
|
505
|
+
writeJsonAtomic(reportPath, next);
|
|
506
|
+
outcome.written = true;
|
|
507
|
+
outcome.report = next;
|
|
508
|
+
return finish();
|
|
509
|
+
}
|