From e94f6c98998bdb850de2a15840124b1444e7d8ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 10:48:10 +0000 Subject: [PATCH] Add codemod-campaign loop (cross-PR migration campaign) The flagship complex loop (rank #1 from research deep-dive #3): a staged, throttled, ledger-tracked codemod campaign on the long-horizon primitives. - loops/codemod-campaign/: advanceCampaign() drives a deterministic codemod across the codebase in batches of PRs. The campaign ledger lives in the durable StateStore; the pilot batch is human-gated; open PRs are throttled by maxOpenPrs; each run reconciles the ledger against real PR state (merged -> migrated, closed -> returned to pool) and advances at most one batch. Idempotent and resumable. - Injected boundaries: codemod, targets, test runner, and a CampaignPrs (open + state) client, so it's fully unit-tested with fakes + a memory store. Shipped via the OpenSpec cycle (openspec/specs/codemod-campaign.md). Validated: typecheck, lint, 166 tests (+5), clean build. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012CrMioorha3QtPUgWbtDbR --- README.md | 8 +- loops/codemod-campaign/README.md | 42 +++++ loops/codemod-campaign/hooks/campaign.ts | 126 ++++++++++++++ loops/codemod-campaign/index.ts | 160 ++++++++++++++++++ loops/codemod-campaign/playbook.md | 31 ++++ .../archive/add-codemod-campaign/proposal.md | 62 +++++++ .../specs/codemod-campaign_delta.md | 39 +++++ .../archive/add-codemod-campaign/tasks.md | 23 +++ openspec/roadmap.md | 7 +- openspec/specs/codemod-campaign.md | 37 ++++ package.json | 4 + test/loops/codemod-campaign.test.ts | 130 ++++++++++++++ 12 files changed, 663 insertions(+), 6 deletions(-) create mode 100644 loops/codemod-campaign/README.md create mode 100644 loops/codemod-campaign/hooks/campaign.ts create mode 100644 loops/codemod-campaign/index.ts create mode 100644 loops/codemod-campaign/playbook.md create mode 100644 openspec/archive/add-codemod-campaign/proposal.md create mode 100644 openspec/archive/add-codemod-campaign/specs/codemod-campaign_delta.md create mode 100644 openspec/archive/add-codemod-campaign/tasks.md create mode 100644 openspec/specs/codemod-campaign.md create mode 100644 test/loops/codemod-campaign.test.ts diff --git a/README.md b/README.md index da9de03..9ce9dc7 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,11 @@ guardrail violation) it produces no output and never partially applies changes. | [`release-train`](loops/release-train/README.md) | Rolling Release PR (version + changelog) from conventional commits | deterministic | PR | | [`license-sbom-drift`](loops/license-sbom-drift/README.md) | Flag dependency licenses outside the allowlist | deterministic | PR | -Plus a **long-horizon** loop โ€” the -[Experiment Lifecycle Orchestrator](loops/experiment/README.md) โ€” built on the -`longrun` primitives (durable state, approval gates, resumable plans). +Plus two **long-horizon** loops on the `longrun` primitives (durable state, +approval gates, resumable advancement): the +[Experiment Lifecycle Orchestrator](loops/experiment/README.md) and the +[Codemod Campaign](loops/codemod-campaign/README.md) (throttled cross-PR +migration with a ledger + pilot gate). ๐ŸŒ **[Project site](https://dcca.github.io/loopy/)** ยท See the [roadmap](openspec/roadmap.md) for the backlog and the diff --git a/loops/codemod-campaign/README.md b/loops/codemod-campaign/README.md new file mode 100644 index 0000000..e787195 --- /dev/null +++ b/loops/codemod-campaign/README.md @@ -0,0 +1,42 @@ +# codemod-campaign loop + +Drives a deterministic **codemod across a codebase in throttled batches of PRs**, +tracked to completion against a durable ledger. The flagship complex loop โ€” it +uses all of loopy's long-horizon primitives: **`StateStore`** (the campaign +ledger), a human **`Gate`** (pilot-batch approval), and resumable advancement. + +It is **not** a single-shot `Loop`; it advances a campaign via +`advanceCampaign(...)`, called repeatedly (e.g. on a schedule). Each call does at +most one batch, reconciles against real PR state first, and is idempotent. + +## How it advances + +| On each `advanceCampaign` | What happens | +|---------------------------|--------------| +| **reconcile** | Read each open batch PR's state; merged โ†’ files become `migrated`, closed โ†’ files return to the pool. External PR state is the source of truth. | +| **complete?** | No remaining targets and no open batches โ†’ `completed`. | +| **throttle?** | Open PRs โ‰ฅ `maxOpenPrs` โ†’ `waiting` (don't pile on). | +| **select** | Take the next `batchSize` remaining files. | +| **apply + test** | Run the codemod on the batch; run the test/build boundary. Red โ†’ `failed`, no PR. | +| **pilot gate** | The first batch is **blocked** on human approval (review the codemod's shape once). | +| **open** | Open a batch PR; record it in the ledger with its files. | + +## Boundaries (injected, testable) + +- `services.codemod(files)` โ€” the pure transform โ†’ `FileChange[]` +- `services.targets.targets()` โ€” files still needing the transform +- `services.runner()` โ€” run tests/build after a batch +- `services.prs` โ€” `open(...)` a batch PR and read `state(prNumber)` +- a `StateStore` carries the ledger across runs + +## Guardrails / anti-patterns + +- **Campaign runaway** โ†’ `maxOpenPrs` cap + one batch per run. +- **Stale state** โ†’ reconcile against real PR state every run; closed PRs return files to the pool. +- **Partial-rollout corruption** โ†’ only `migrated` on confirmed merge; failed batches open no PR. +- **Gate bypass** โ†’ no PRs open until the pilot batch is approved. + +## Status + +Programmatic for now (the `CampaignPrs` boundary is injected). A GitHub-backed +`CampaignPrs` adapter and a `loopy campaign` CLI verb are planned follow-ups. diff --git a/loops/codemod-campaign/hooks/campaign.ts b/loops/codemod-campaign/hooks/campaign.ts new file mode 100644 index 0000000..56925ac --- /dev/null +++ b/loops/codemod-campaign/hooks/campaign.ts @@ -0,0 +1,126 @@ +import type { FileChange } from "../../../src/core/index.js"; + +/** Pure transform over the given files; returns the file changes to apply. */ +export type Codemod = (files: string[]) => Promise; + +/** Supplies the files that still match the campaign's "needs transform" predicate. */ +export interface TargetSource { + targets(): Promise; +} + +/** Runs the project's tests/build after a batch is applied. */ +export type TestRunner = () => Promise<{ ok: boolean; diagnostics?: string }>; + +export type PrState = "open" | "merged" | "closed"; + +/** The PR operations a campaign needs (create batch PRs; read their state). */ +export interface CampaignPrs { + state(prNumber: number): Promise; + open(input: { + branch: string; + title: string; + body: string; + changes: FileChange[]; + }): Promise<{ number: number }>; +} + +export interface OpenBatch { + prNumber: number; + branch: string; + files: string[]; +} + +/** Durable campaign ledger, persisted in the StateStore across runs. */ +export interface CampaignLedger { + migrated: string[]; + open: OpenBatch[]; + mergedBatches: number; + failedBatches: number; + batchesOpened: number; + pilotApproved: boolean; +} + +export interface CampaignSpec { + /** files per batch PR */ + batchSize: number; + /** maximum concurrent open batch PRs (throttle) */ + maxOpenPrs: number; + /** PR title prefix */ + title: string; +} + +export function emptyLedger(): CampaignLedger { + return { + migrated: [], + open: [], + mergedBatches: 0, + failedBatches: 0, + batchesOpened: 0, + pilotApproved: false, + }; +} + +/** Files still needing work: not migrated and not already in an open batch. */ +export function remainingTargets(all: string[], ledger: CampaignLedger): string[] { + const done = new Set(ledger.migrated); + const inFlight = new Set(ledger.open.flatMap((b) => b.files)); + return all.filter((f) => !done.has(f) && !inFlight.has(f)).sort(); +} + +export function selectBatch(remaining: string[], batchSize: number): string[] { + return remaining.slice(0, Math.max(1, batchSize)); +} + +/** + * Reconcile the ledger against real PR state: merged batches' files become + * migrated; closed batches are dropped (their files return to the pool); open + * batches stay. External PR state is the source of truth. + */ +export async function reconcile( + ledger: CampaignLedger, + prs: CampaignPrs, +): Promise { + const stillOpen: OpenBatch[] = []; + const migrated = new Set(ledger.migrated); + let mergedBatches = ledger.mergedBatches; + + for (const batch of ledger.open) { + const state = await prs.state(batch.prNumber); + if (state === "merged") { + for (const f of batch.files) migrated.add(f); + mergedBatches++; + } else if (state === "open") { + stillOpen.push(batch); + } + // "closed" โ†’ drop; files return to the pool to be retried later. + } + + return { ...ledger, migrated: [...migrated].sort(), open: stillOpen, mergedBatches }; +} + +/** Total targets across the campaign's lifetime (migrated + in-flight + remaining). */ +export function campaignTotal(all: string[], ledger: CampaignLedger): number { + const inFlight = ledger.open.reduce((n, b) => n + b.files.length, 0); + return ledger.migrated.length + inFlight + remainingTargets(all, ledger).length; +} + +export function renderBurndown( + all: string[], + ledger: CampaignLedger, + dateIso: string, +): string { + const total = campaignTotal(all, ledger); + const remaining = remainingTargets(all, ledger).length; + const inFlight = ledger.open.reduce((n, b) => n + b.files.length, 0); + const pct = total > 0 ? Math.round((ledger.migrated.length / total) * 100) : 100; + return [ + `## Codemod campaign burndown โ€” ${dateIso}`, + "", + `- Progress: **${ledger.migrated.length}/${total}** files migrated (${pct}%)`, + `- In flight: ${inFlight} file(s) across ${ledger.open.length} open PR(s)`, + `- Remaining: ${remaining} file(s)`, + `- Batches: ${ledger.batchesOpened} opened ยท ${ledger.mergedBatches} merged ยท ${ledger.failedBatches} failed`, + "", + "_Generated by loopy codemod-campaign. Throttled + ledger-tracked; pilot batch is human-gated._", + ].join("\n"); +} diff --git a/loops/codemod-campaign/index.ts b/loops/codemod-campaign/index.ts new file mode 100644 index 0000000..97a0392 --- /dev/null +++ b/loops/codemod-campaign/index.ts @@ -0,0 +1,160 @@ +import { createGate, type Gate, type StateStore } from "../../src/core/index.js"; +import { + campaignTotal, + emptyLedger, + reconcile, + remainingTargets, + renderBurndown, + selectBatch, + type CampaignLedger, + type CampaignPrs, + type CampaignSpec, + type Codemod, + type TargetSource, + type TestRunner, +} from "./hooks/campaign.js"; + +export type { + Codemod, + TargetSource, + TestRunner, + CampaignPrs, + CampaignLedger, + CampaignSpec, + OpenBatch, + PrState, +} from "./hooks/campaign.js"; +export { + emptyLedger, + remainingTargets, + selectBatch, + reconcile, + renderBurndown, + campaignTotal, +} from "./hooks/campaign.js"; + +export interface CampaignServices { + codemod: Codemod; + targets: TargetSource; + runner: TestRunner; + prs: CampaignPrs; +} + +export type CampaignStatus = + | "completed" + | "waiting" + | "blocked" + | "failed" + | "batch-opened"; + +export interface CampaignResult { + campaignId: string; + status: CampaignStatus; + reason?: string; + gateId?: string; + pr?: { number: number }; + ledger: CampaignLedger; + burndown: string; +} + +/** + * Advance a codemod campaign by at most one batch. Resumable and idempotent: + * the ledger (in the StateStore) is reconciled against real PR state every run, + * batches are throttled by `maxOpenPrs`, and the pilot batch is human-gated. + * + * Call repeatedly (e.g. on a schedule). Returns: + * - `completed` โ€” nothing left and no open batches + * - `waiting` โ€” throttled (open-PR cap) or batches in flight, nothing to do now + * - `blocked` โ€” awaiting the pilot-batch approval gate + * - `failed` โ€” the batch failed tests/build (no PR opened) + * - `batch-opened` โ€” a new batch PR was opened + */ +export async function advanceCampaign( + campaignId: string, + spec: CampaignSpec, + services: CampaignServices, + store: StateStore, + options?: { now?: () => Date; gate?: Gate }, +): Promise { + const now = options?.now ?? (() => new Date()); + const gate = options?.gate ?? createGate(store, options); + const key = `campaign:${campaignId}`; + + let ledger = (await store.load(key)) ?? emptyLedger(); + ledger = await reconcile(ledger, services.prs); + + const all = await services.targets.targets(); + const dateIso = now().toISOString().slice(0, 10); + const done = (status: CampaignStatus, extra: Partial = {}) => ({ + campaignId, + status, + ledger, + burndown: renderBurndown(all, ledger, dateIso), + ...extra, + }); + + const remaining = remainingTargets(all, ledger); + + // Completion: nothing left and nothing in flight. + if (remaining.length === 0 && ledger.open.length === 0) { + await store.save(key, ledger); + return done("completed"); + } + // Throttle: at the open-PR cap. + if (ledger.open.length >= spec.maxOpenPrs) { + await store.save(key, ledger); + return done("waiting", { reason: "open-PR cap reached" }); + } + // Nothing selectable now, but batches are still in flight. + if (remaining.length === 0) { + await store.save(key, ledger); + return done("waiting", { reason: "batches in flight" }); + } + + const batch = selectBatch(remaining, spec.batchSize); + const changes = await services.codemod(batch); + + const test = await services.runner(); + if (!test.ok) { + ledger = { ...ledger, failedBatches: ledger.failedBatches + 1 }; + await store.save(key, ledger); + return done("failed", { reason: test.diagnostics ?? "batch failed tests/build" }); + } + + // Pilot gate: review the codemod's shape once, before opening any PRs. + if (!ledger.pilotApproved) { + const gateId = `${campaignId}:pilot`; + const req = await gate.require( + gateId, + `Approve the pilot batch of campaign "${campaignId}" (${batch.length} file(s); ` + + `${campaignTotal(all, ledger)} total).`, + ); + if (req.status === "pending") { + await store.save(key, ledger); + return done("blocked", { gateId }); + } + if (req.status === "rejected") { + await store.save(key, ledger); + return done("blocked", { gateId, reason: "pilot batch rejected" }); + } + ledger = { ...ledger, pilotApproved: true }; + } + + const n = ledger.batchesOpened + 1; + const branch = `loopy/campaign-${campaignId}-${n}`; + const burndown = renderBurndown(all, ledger, dateIso); + const pr = await services.prs.open({ + branch, + title: `${spec.title} (batch ${n})`, + body: burndown, + changes, + }); + + ledger = { + ...ledger, + batchesOpened: n, + open: [...ledger.open, { prNumber: pr.number, branch, files: batch }], + }; + await store.save(key, ledger); + return done("batch-opened", { pr }); +} diff --git a/loops/codemod-campaign/playbook.md b/loops/codemod-campaign/playbook.md new file mode 100644 index 0000000..d658d4c --- /dev/null +++ b/loops/codemod-campaign/playbook.md @@ -0,0 +1,31 @@ +# Codemod Campaign Playbook + +This loop drives a **deterministic** codemod across a codebase as a staged, +throttled, ledger-tracked campaign of PRs. The transform itself is the injected +`codemod` boundary (jscodeshift/ts-morph/OpenRewrite-style); the loop handles +batching, throttling, reconciliation, the pilot gate, and burndown. + +## The campaign contract + +1. **Reconcile** the ledger against real PR state (merged/closed/open) โ€” external + state is the source of truth. +2. **Throttle**: never exceed `maxOpenPrs` concurrent batch PRs. +3. **Batch**: take the next `batchSize` files that still match the predicate. +4. **Apply + verify**: run the codemod, then the test/build boundary; a red batch + opens no PR. +5. **Pilot gate**: a human approves the first batch (the codemod's shape) before + any PRs are opened. +6. **Open** a batch PR and record it in the ledger; repeat on the next run. + +## Designing the codemod (the injected boundary) + +- Make it **idempotent** โ€” re-running on the same file must be a no-op. +- Keep batches **small and reviewable**; one concern per campaign. +- The transform must leave the tree **green** (the runner gates this). + +## Guardrails (enforced, not optional) + +- Open-PR cap + one batch per run (no runaway). +- Reconcile every run; closed PRs return their files to the pool. +- Files become "migrated" only when their batch PR is **merged**. +- No PRs until the pilot batch is approved at the Gate. diff --git a/openspec/archive/add-codemod-campaign/proposal.md b/openspec/archive/add-codemod-campaign/proposal.md new file mode 100644 index 0000000..06bde5c --- /dev/null +++ b/openspec/archive/add-codemod-campaign/proposal.md @@ -0,0 +1,62 @@ +# Proposal: Codemod-Campaign Loop + +**Change ID:** `add-codemod-campaign` +**Created:** 2026-06-23 +**Status:** Implementation Complete +**Completed:** 2026-06-23 + +--- + +## Problem Statement + +Large codebase migrations (codemods) can't land in one PR โ€” they must be fanned +across many small, reviewable PRs, throttled, tracked, and resumed over days. +This is the #1-ranked complex loop from the deep dive and the best showcase of +loopy's long-horizon primitives together (StateStore + Gate + resumable advance). + +## Proposed Solution + +Add `loops/codemod-campaign/`: `advanceCampaign(...)` drives a deterministic +codemod across the codebase in throttled batches of PRs, tracked in a durable +ledger (StateStore), reconciled against real PR state each run, with the pilot +batch human-gated. Each call advances at most one batch; idempotent and +resumable. + +## Scope + +### In Scope +- `loops/codemod-campaign/` (hooks/campaign.ts, index.ts, README, playbook) +- Ledger in StateStore; pilot Gate; throttle by `maxOpenPrs`; reconcile by PR state +- Injected boundaries: `codemod`, `targets`, `runner` (tests), `prs` (open + state) +- Unit tests (pilot gate โ†’ batches โ†’ throttle โ†’ reconcile/merge โ†’ completion โ†’ failure) +- Package export + +### Out of Scope +- A GitHub-backed `CampaignPrs` adapter (injected boundary for now; follow-up) +- A `loopy campaign ` CLI verb (follow-up) +- AI assistance for residual hard files (codemod is deterministic here) + +## Success Criteria + +- [x] No PRs open until the pilot batch is approved (Gate). +- [x] Throttles at `maxOpenPrs`; one batch per advance. +- [x] Reconciles against real PR state: merged โ†’ migrated, closed โ†’ returned to pool. +- [x] Completes when nothing remains and no batches are open. +- [x] Failed batch (red tests) opens no PR and is recorded. + +## Risks & Mitigations + +| Risk | Prob | Impact | Mitigation | +|------|------|--------|------------| +| Campaign runaway | Med | High | open-PR cap + one batch/run | +| Stale ledger | Med | Med | reconcile vs real PR state every run | +| Partial-rollout corruption | Low | Med | migrate only on merge; red batch โ†’ no PR | +| Gate bypass | Low | High | no PRs until pilot approved | + +--- + +## Archive Information + +**Archived:** 2026-06-23 +**Outcome:** Successfully implemented +**Verification:** typecheck + lint + 166 tests + build all passing diff --git a/openspec/archive/add-codemod-campaign/specs/codemod-campaign_delta.md b/openspec/archive/add-codemod-campaign/specs/codemod-campaign_delta.md new file mode 100644 index 0000000..48b0c9b --- /dev/null +++ b/openspec/archive/add-codemod-campaign/specs/codemod-campaign_delta.md @@ -0,0 +1,39 @@ +# Delta: Codemod-Campaign Loop + +**Change ID:** `add-codemod-campaign` +**Affects:** `loops/codemod-campaign/` + +## ADDED + +## Requirements + +### Requirement: Throttled, Resumable Campaign + +`advanceCampaign` drives a codemod across the codebase in batches of PRs, +tracked in a durable ledger and reconciled against real PR state each run, +advancing at most one batch per call. + +#### Scenario: Pilot gate before any PRs +- GIVEN a campaign with no approved pilot batch +- WHEN it is advanced +- THEN it blocks on the pilot approval gate and opens no PR until approved + +#### Scenario: Throttle at the open-PR cap +- GIVEN open batch PRs at `maxOpenPrs` +- WHEN it is advanced +- THEN it waits without opening another batch + +#### Scenario: Reconcile against PR state +- GIVEN an open batch PR that has since merged +- WHEN it is advanced +- THEN that batch's files become migrated (a closed PR returns its files to the pool) + +#### Scenario: Completion +- GIVEN no remaining targets and no open batches +- WHEN it is advanced +- THEN it reports completed + +#### Scenario: Failed batch opens no PR +- GIVEN the codemod batch fails the test/build runner +- WHEN it is advanced +- THEN no PR is opened and the failure is recorded diff --git a/openspec/archive/add-codemod-campaign/tasks.md b/openspec/archive/add-codemod-campaign/tasks.md new file mode 100644 index 0000000..e42d95a --- /dev/null +++ b/openspec/archive/add-codemod-campaign/tasks.md @@ -0,0 +1,23 @@ +# Implementation Tasks: Codemod-Campaign Loop + +**Change ID:** `add-codemod-campaign` +**Status:** Implementation Complete + +--- + +## Phase 1: Campaign engine +- [x] 1.1 `hooks/campaign.ts` โ€” ledger types, remainingTargets/selectBatch/reconcile/renderBurndown +- [x] 1.2 `index.ts` โ€” `advanceCampaign` (StateStore ledger + pilot Gate + throttle) +- [x] 1.3 README + playbook +- [x] 1.4 Unit tests (full lifecycle + throttle + failure) + +## Phase 2: Packaging +- [x] 2.1 Package export + +**Quality Gate:** typecheck + lint + 166 tests + build โ€” PASSED + +--- + +## Completion Checklist +- [x] All phases complete and validated +- [x] Ready for `/openspec-archive` diff --git a/openspec/roadmap.md b/openspec/roadmap.md index 682d9bf..18c2c56 100644 --- a/openspec/roadmap.md +++ b/openspec/roadmap.md @@ -42,9 +42,10 @@ API, npm registry, coverage tool, SCA/SAST scanner) is consumer configuration. [`research/complex-loops.md`](research/complex-loops.md) ranked new complex loops. Shipped from the top of the ranking: **flake-quarantine** (#1 โ€” first loop to use the durable `StateStore`), **release-train** (#3 โ€” dogfoods loopy's own releases; -see `.github/workflows/dogfood.yml`), and **license-sbom-drift** (#5). Next from -the ranking: **codemod-campaign** (cross-PR campaign on `runPlan`+ledger), -**prompt-eval-gate**, **model-upgrade-migration**. +see `.github/workflows/dogfood.yml`), **license-sbom-drift** (#5), and +**codemod-campaign** (#2 โ€” cross-PR campaign: ledger in `StateStore` + pilot +`Gate` + throttle + reconcile; see [`specs/codemod-campaign.md`](specs/codemod-campaign.md)). +Next from the ranking: **prompt-eval-gate**, **model-upgrade-migration**. ## Next horizon: product-level loops diff --git a/openspec/specs/codemod-campaign.md b/openspec/specs/codemod-campaign.md new file mode 100644 index 0000000..0ce575a --- /dev/null +++ b/openspec/specs/codemod-campaign.md @@ -0,0 +1,37 @@ +# Codemod-Campaign Loop Specification + +> Source of truth. Established by change `add-codemod-campaign` (2026-06-23). +> A cross-PR campaign on the long-horizon primitives (StateStore ledger + Gate). + +## Requirements + +### Requirement: Throttled, Resumable Campaign + +`advanceCampaign` drives a codemod across the codebase in batches of PRs, +tracked in a durable ledger and reconciled against real PR state each run, +advancing at most one batch per call. + +#### Scenario: Pilot gate before any PRs +- GIVEN a campaign with no approved pilot batch +- WHEN it is advanced +- THEN it blocks on the pilot approval gate and opens no PR until approved + +#### Scenario: Throttle at the open-PR cap +- GIVEN open batch PRs at `maxOpenPrs` +- WHEN it is advanced +- THEN it waits without opening another batch + +#### Scenario: Reconcile against PR state +- GIVEN an open batch PR that has since merged +- WHEN it is advanced +- THEN that batch's files become migrated (a closed PR returns its files to the pool) + +#### Scenario: Completion +- GIVEN no remaining targets and no open batches +- WHEN it is advanced +- THEN it reports completed + +#### Scenario: Failed batch opens no PR +- GIVEN the codemod batch fails the test/build runner +- WHEN it is advanced +- THEN no PR is opened and the failure is recorded diff --git a/package.json b/package.json index 9a0ac92..4d2fe03 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,10 @@ "./loops/license-sbom-drift": { "types": "./dist/loops/license-sbom-drift/index.d.ts", "default": "./dist/loops/license-sbom-drift/index.js" + }, + "./loops/codemod-campaign": { + "types": "./dist/loops/codemod-campaign/index.d.ts", + "default": "./dist/loops/codemod-campaign/index.js" } }, "files": [ diff --git a/test/loops/codemod-campaign.test.ts b/test/loops/codemod-campaign.test.ts new file mode 100644 index 0000000..d31e7a8 --- /dev/null +++ b/test/loops/codemod-campaign.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { createGate, createMemoryStateStore, type FileChange, type StateStore } from "../../src/core/index.js"; +import { + advanceCampaign, + remainingTargets, + selectBatch, + emptyLedger, + type CampaignPrs, + type CampaignServices, + type CampaignSpec, + type PrState, +} from "../../loops/codemod-campaign/index.js"; + +describe("campaign pure helpers", () => { + it("remainingTargets excludes migrated and in-flight files", () => { + const ledger = { ...emptyLedger(), migrated: ["a"], open: [{ prNumber: 1, branch: "b", files: ["b"] }] }; + expect(remainingTargets(["a", "b", "c", "d"], ledger)).toEqual(["c", "d"]); + }); + it("selectBatch takes up to batchSize", () => { + expect(selectBatch(["a", "b", "c"], 2)).toEqual(["a", "b"]); + }); +}); + +const spec = (over: Partial = {}): CampaignSpec => ({ + batchSize: 2, + maxOpenPrs: 5, + title: "Migrate to X", + ...over, +}); + +const changes: FileChange[] = [{ path: "src/a.ts", op: "write", contents: "x" }]; + +/** A fake PR backend whose per-PR state the test controls. */ +function fakePrs(state: Map): CampaignPrs & { opened: number[] } { + let next = 0; + const opened: number[] = []; + return { + opened, + state: async (n) => state.get(n) ?? "open", + open: async () => { + next += 1; + opened.push(next); + state.set(next, "open"); + return { number: next }; + }, + }; +} + +function services(targets: string[], prs: CampaignPrs, ok = true): CampaignServices { + return { + codemod: async () => changes, + targets: { targets: async () => targets }, + runner: async () => ({ ok, diagnostics: ok ? undefined : "tests red" }), + prs, + }; +} + +const id = "to-x"; + +describe("advanceCampaign lifecycle", () => { + it("blocks on the pilot gate, then opens batches, throttles, reconciles, and completes", async () => { + const store: StateStore = createMemoryStateStore(); + const prState = new Map(); + const prs = fakePrs(prState); + const targets = ["a", "b", "c", "d"]; + const gate = createGate(store); + + // 1. First advance โ†’ blocked on the pilot gate; no PR opened. + let r = await advanceCampaign(id, spec(), services(targets, prs), store); + expect(r.status).toBe("blocked"); + expect(r.gateId).toBe("to-x:pilot"); + expect(prs.opened).toHaveLength(0); + + // 2. Approve pilot โ†’ opens batch 1 [a,b]. + await gate.decide("to-x:pilot", "approved"); + r = await advanceCampaign(id, spec(), services(targets, prs), store); + expect(r.status).toBe("batch-opened"); + expect(r.pr?.number).toBe(1); + expect(r.ledger.open[0]?.files).toEqual(["a", "b"]); + + // 3. Next advance โ†’ opens batch 2 [c,d]. + r = await advanceCampaign(id, spec(), services(targets, prs), store); + expect(r.status).toBe("batch-opened"); + expect(r.pr?.number).toBe(2); + + // 4. Both batches in flight, none remaining โ†’ waiting. + r = await advanceCampaign(id, spec(), services(targets, prs), store); + expect(r.status).toBe("waiting"); + + // 5. Merge batch 1 โ†’ its files migrate; still waiting (batch 2 in flight). + prState.set(1, "merged"); + r = await advanceCampaign(id, spec(), services(targets, prs), store); + expect(r.ledger.migrated).toEqual(["a", "b"]); + expect(r.status).toBe("waiting"); + + // 6. Merge batch 2 โ†’ all migrated, nothing open โ†’ completed. + prState.set(2, "merged"); + r = await advanceCampaign(id, spec(), services(targets, prs), store); + expect(r.status).toBe("completed"); + expect(r.ledger.migrated).toEqual(["a", "b", "c", "d"]); + }); + + it("throttles at the open-PR cap", async () => { + const store = createMemoryStateStore(); + const prs = fakePrs(new Map()); + const gate = createGate(store); + const s = spec({ maxOpenPrs: 1 }); + + await advanceCampaign(id, s, services(["a", "b", "c", "d"], prs), store); // blocked pilot + await gate.decide("to-x:pilot", "approved"); + const opened = await advanceCampaign(id, s, services(["a", "b", "c", "d"], prs), store); + expect(opened.status).toBe("batch-opened"); + const throttled = await advanceCampaign(id, s, services(["a", "b", "c", "d"], prs), store); + expect(throttled.status).toBe("waiting"); + expect(throttled.reason).toMatch(/cap/); + }); + + it("fails the batch (no PR) when tests are red", async () => { + const store = createMemoryStateStore(); + const prs = fakePrs(new Map()); + const gate = createGate(store); + // approve pilot up-front so we reach the test gate + await gate.require("to-x:pilot", "x"); + await gate.decide("to-x:pilot", "approved"); + const r = await advanceCampaign(id, spec(), services(["a", "b"], prs, false), store); + expect(r.status).toBe("failed"); + expect(prs.opened).toHaveLength(0); + expect(r.ledger.failedBatches).toBe(1); + }); +});