diff --git a/packages/loopover-miner/lib/discover-cli.ts b/packages/loopover-miner/lib/discover-cli.ts index 4b3925b744..d6b6262f30 100644 --- a/packages/loopover-miner/lib/discover-cli.ts +++ b/packages/loopover-miner/lib/discover-cli.ts @@ -1,5 +1,6 @@ /** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner * can actually run it. Every piece already exists and is independently tested; this module only composes them. */ +import { existsSync } from "node:fs"; import { resolveForgeConfig } from "./forge-config.js"; import type { ForgeConfig } from "./forge-config.js"; import { @@ -516,8 +517,17 @@ export async function runDiscover(args: string[], options: RunDiscoverOptions = let overrideLedger = null; try { const ledgerEnv = options.env ?? process.env; - overrideLedger = initEventLedger(resolveEventLedgerDbPath(ledgerEnv)); - minRankScore = readMinRankOverride(overrideLedger, { enabled: readMinRankAutotuneEnabled(ledgerEnv) }) ?? AMS_MIN_RANK_SHIPPED; + const ledgerDbPath = resolveEventLedgerDbPath(ledgerEnv); + // #9679: --dry-run must make ZERO filesystem writes, but initEventLedger creates + migrates + prunes the + // ledger file. On the dry-run path only read the override when the ledger file ALREADY exists (opening a + // not-yet-existing SQLite file is itself a write, and retention pruning can delete rows) -- a missing file + // falls back to the shipped default, exactly the value an empty/new ledger would yield, so the preview is + // unchanged when it exists. Same "skip a file that doesn't exist yet" discipline as migrate-cli.ts / + // store-maintenance.ts. The real (non-dry-run) run is unchanged: it opens unconditionally. + if (!parsed.dryRun || existsSync(ledgerDbPath)) { + overrideLedger = initEventLedger(ledgerDbPath); + minRankScore = readMinRankOverride(overrideLedger, { enabled: readMinRankAutotuneEnabled(ledgerEnv) }) ?? AMS_MIN_RANK_SHIPPED; + } } catch { minRankScore = AMS_MIN_RANK_SHIPPED; } finally { diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 28c1dd2f79..0abc63348c 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -2175,3 +2175,104 @@ describe("min-rank override consumption at discover's enqueue (#8187)", () => { } }); }); + +describe("#9679: --dry-run makes zero event-ledger writes", () => { + const fanOut = vi.fn(async () => ({ + issues: [fanOutIssue({ issueNumber: 1, title: "candidate" })], + warnings: [], + rateLimitRemaining: 5000, + rateLimitResetAt: "2026-07-09T13:00:00.000Z", + })); + const enqueueOnce = () => vi.fn(() => ({ enqueued: 1, skippedBelowMinRank: 0, skippedInvalid: 0, eventsAppended: 0 })); + + it("REGRESSION: --dry-run does NOT create the event-ledger file when it is absent", async () => { + const { mkdtempSync, rmSync, existsSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { resolveEventLedgerDbPath } = await import("../../packages/loopover-miner/lib/event-ledger"); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + const dir = mkdtempSync(join(tmpdir(), "miner-discover-dryrun-noledger-")); + try { + const env = { LOOPOVER_MINER_CONFIG_DIR: dir }; + const ledgerPath = resolveEventLedgerDbPath(env); + expect(existsSync(ledgerPath)).toBe(false); + + const exitCode = await runDiscover(["acme/widgets", "--dry-run", "--json"], { + nowMs: NOW, + env, + fetchCandidateIssuesWithSummary: fanOut, + enqueueRankedDiscovery: enqueueOnce() as never, + }); + expect(exitCode).toBe(0); + // Before the fix the unconditional initEventLedger created (and migrated/pruned) this file on a dry run. + expect(existsSync(ledgerPath)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("REGRESSION: --dry-run STILL applies the earned min-rank override when the event ledger already exists", async () => { + const { mkdtempSync, rmSync, writeFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { initEventLedger, resolveEventLedgerDbPath } = await import("../../packages/loopover-miner/lib/event-ledger"); + const { resolveAmsPolicyConfigPath } = await import("../../packages/loopover-miner/lib/ams-policy"); + const { MINER_AMS_MIN_RANK_APPLIED_EVENT } = await import("../../packages/loopover-miner/lib/ams-calibration"); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + const dir = mkdtempSync(join(tmpdir(), "miner-discover-dryrun-override-")); + try { + const env = { LOOPOVER_MINER_CONFIG_DIR: dir }; + const ledger = initEventLedger(resolveEventLedgerDbPath(env)); + ledger.appendEvent({ type: MINER_AMS_MIN_RANK_APPLIED_EVENT, payload: { value: 0.2 } }); + ledger.close(); + writeFileSync(resolveAmsPolicyConfigPath(env), "minRankAutotuneEnabled: true\n"); + + // The ledger file now exists, so the guarded dry-run read still consumes the earned override (0.2), so the + // preview reports the exact below-min-rank skip set a real run would. + const enqueueSpy = enqueueOnce(); + const exitCode = await runDiscover(["acme/widgets", "--dry-run", "--json"], { + nowMs: NOW, + env, + fetchCandidateIssuesWithSummary: fanOut, + enqueueRankedDiscovery: enqueueSpy as never, + }); + expect(exitCode).toBe(0); + const minRankSeen = ((enqueueSpy.mock.calls[0] as unknown[] | undefined)?.[1] as { minRankScore?: number } | undefined)?.minRankScore; + expect(minRankSeen).toBe(0.2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("the non-dry-run path is unchanged: it still opens (and creates) the event ledger", async () => { + const { mkdtempSync, rmSync, existsSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { resolveEventLedgerDbPath } = await import("../../packages/loopover-miner/lib/event-ledger"); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + const dir = mkdtempSync(join(tmpdir(), "miner-discover-realrun-ledger-")); + try { + const env = { LOOPOVER_MINER_CONFIG_DIR: dir }; + const ledgerPath = resolveEventLedgerDbPath(env); + expect(existsSync(ledgerPath)).toBe(false); + + const exitCode = await runDiscover(["acme/widgets", "--json"], { + nowMs: NOW, + env, + fetchCandidateIssuesWithSummary: fanOut, + initPortfolioQueue: () => tempQueueStore(), + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + enqueueRankedDiscovery: enqueueOnce() as never, + }); + expect(exitCode).toBe(0); + expect(existsSync(ledgerPath)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});