diff --git a/packages/loopover-miner/lib/deny-hook-synthesis.ts b/packages/loopover-miner/lib/deny-hook-synthesis.ts index 00df30b6b2..4033a18b2c 100644 --- a/packages/loopover-miner/lib/deny-hook-synthesis.ts +++ b/packages/loopover-miner/lib/deny-hook-synthesis.ts @@ -24,6 +24,7 @@ import { import type { DenyRuleProposal, SynthesisConfig } from "@loopover/engine"; import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import type { DenyRule } from "./deny-hooks.js"; +import { DENY_HOOK_SYNTHESIS_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; // Re-export the pure synthesis helpers from the engine so this module's public API is unchanged after #5667 // moved derivation/audit into @loopover/engine. Only the SQLite store below (and its forge/db-path helpers) is @@ -59,6 +60,8 @@ export type DenyHookSynthesisStore = { repoFullName: string, options?: { includeDefaults?: boolean; apiBaseUrl?: string }, ): DenyRule[]; + /** Delete every proposal row for one repo across ALL forge hosts (#8009); returns the number of rows removed. */ + purgeByRepo(repoFullName: string): number; close(): void; }; @@ -248,6 +251,13 @@ export function initDenyHookSynthesisStore(dbPath: string = resolveDenyHookSynth approvedProposals: proposals, } as Parameters[0]); }, + /** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what + * `loopover-miner purge` invokes. Filters on `repo_full_name` alone (the spec's own doc covers why), so — + * unlike every other method here, which scopes to one forge — the sweep clears the repo's proposals under + * every `api_base_url` they were recorded against, mirroring governor-state's purgeByRepo. */ + purgeByRepo(repoFullName) { + return purgeStoreByRepo(db, DENY_HOOK_SYNTHESIS_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/purge-cli.ts b/packages/loopover-miner/lib/purge-cli.ts index 5fa0bd1631..d61b8ee805 100644 --- a/packages/loopover-miner/lib/purge-cli.ts +++ b/packages/loopover-miner/lib/purge-cli.ts @@ -1,7 +1,8 @@ // `loopover-miner purge` (#5564, #6599): an explicit, operator-invoked right-to-be-forgotten path across the local // ledgers. Deletes every row for one repo from the stores that have a real `repoColumn` (claim-ledger, -// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state, contribution-profile-cache, and -// governor-state's two repo-scoped tables — #7091), via each store's own `purgeByRepo` method (which reuses +// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state, contribution-profile-cache, +// governor-state's two repo-scoped tables — #7091 — plus policy-verdict-cache — #6987 — and ranked-candidates, +// replay-snapshot, and deny-hook-synthesis — #8009), via each store's own `purgeByRepo` method (which reuses // `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`). // `attempt-log.js` is deliberately reported as not-purgeable rather than silently skipped or approximated: its // payload is a free-form `Record` with no dedicated repo column, so a precise per-repo match @@ -30,6 +31,12 @@ import { openGovernorState, resolveGovernorStateDbPath } from "./governor-state. import type { GovernorState } from "./governor-state.js"; import { initPolicyVerdictCacheStore, resolvePolicyVerdictCacheDbPath } from "./policy-verdict-cache.js"; import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js"; +import { initRankedCandidatesStore, resolveRankedCandidatesDbPath } from "./ranked-candidates.js"; +import type { RankedCandidatesStore } from "./ranked-candidates.js"; +import { openReplaySnapshotStore, resolveReplaySnapshotDbPath } from "./replay-snapshot.js"; +import type { ReplaySnapshotStore } from "./replay-snapshot.js"; +import { initDenyHookSynthesisStore, resolveDenyHookSynthesisDbPath } from "./deny-hook-synthesis.js"; +import type { DenyHookSynthesisStore } from "./deny-hook-synthesis.js"; import { resolveAttemptLogDbPath } from "./attempt-log.js"; import { CLAIM_LEDGER_PURGE_SPEC, @@ -42,6 +49,9 @@ import { GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC, GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC, POLICY_VERDICT_CACHE_PURGE_SPEC, + RANKED_CANDIDATES_PURGE_SPEC, + REPLAY_SNAPSHOT_PURGE_SPEC, + DENY_HOOK_SYNTHESIS_PURGE_SPEC, countStoreByRepo, describeError, } from "./store-maintenance.js"; @@ -66,7 +76,10 @@ type PurgeOpenerKey = | "initRunStateStore" | "initContributionProfileCache" | "openGovernorState" - | "initPolicyVerdictCacheStore"; + | "initPolicyVerdictCacheStore" + | "initRankedCandidatesStore" + | "openReplaySnapshotStore" + | "initDenyHookSynthesisStore"; export type PurgeCliOptions = { openClaimLedger?: () => ClaimLedger; @@ -78,6 +91,9 @@ export type PurgeCliOptions = { initContributionProfileCache?: () => ContributionProfileCache; openGovernorState?: () => GovernorState; initPolicyVerdictCacheStore?: () => PolicyVerdictCacheStore; + initRankedCandidatesStore?: () => RankedCandidatesStore; + openReplaySnapshotStore?: () => ReplaySnapshotStore; + initDenyHookSynthesisStore?: () => DenyHookSynthesisStore; resolveDbPaths?: Record string>; }; @@ -102,6 +118,12 @@ const REAL_PURGE_TARGETS: PurgeTarget[] = [ // single handle (never reopening the file), and its dry-run count sums both via `specs` (#7091). { name: "governor-state", optionKey: "openGovernorState", opener: openGovernorState, resolveDbPath: resolveGovernorStateDbPath, specs: [GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC, GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC] }, { name: "policy-verdict-cache", optionKey: "initPolicyVerdictCacheStore", opener: initPolicyVerdictCacheStore, resolveDbPath: resolvePolicyVerdictCacheDbPath, spec: POLICY_VERDICT_CACHE_PURGE_SPEC }, + // Three more repo-scoped stores the earlier sweeps missed (#8009). deny-hook-synthesis's dry-run count works + // on both pre- and post-forge-scope files: its live table is `deny_rule_proposals` either way, and the purge + // filters on `repo_full_name` alone (all forge hosts), per its spec's own doc in store-maintenance.js. + { name: "ranked-candidates", optionKey: "initRankedCandidatesStore", opener: initRankedCandidatesStore, resolveDbPath: resolveRankedCandidatesDbPath, spec: RANKED_CANDIDATES_PURGE_SPEC }, + { name: "replay-snapshot", optionKey: "openReplaySnapshotStore", opener: openReplaySnapshotStore, resolveDbPath: resolveReplaySnapshotDbPath, spec: REPLAY_SNAPSHOT_PURGE_SPEC }, + { name: "deny-hook-synthesis", optionKey: "initDenyHookSynthesisStore", opener: initDenyHookSynthesisStore, resolveDbPath: resolveDenyHookSynthesisDbPath, spec: DENY_HOOK_SYNTHESIS_PURGE_SPEC }, ]; export type ParsedPurgeArgs = { json: boolean; dryRun: boolean; repoFullName: string } | { error: string }; diff --git a/packages/loopover-miner/lib/ranked-candidates.ts b/packages/loopover-miner/lib/ranked-candidates.ts index 8c45636648..76cee0e1eb 100644 --- a/packages/loopover-miner/lib/ranked-candidates.ts +++ b/packages/loopover-miner/lib/ranked-candidates.ts @@ -1,6 +1,7 @@ import type { SQLOutputValue } from "node:sqlite"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { RANKED_CANDIDATES_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; // Last-discover-run ranked-candidates snapshot (#4859 prerequisite): `discover-cli.js`'s runDiscover already // computes the FULL per-issue ranking breakdown (rankScore/laneFit/freshness/potential/feasibility/dupRisk, via @@ -54,6 +55,8 @@ export type RankedCandidatesStore = { dbPath: string; saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult; listRankedCandidates(): RankedCandidateRow[]; + /** Delete every snapshot row for one repo (#8009); returns the number of rows removed. */ + purgeByRepo(repoFullName: string): number; close(): void; }; @@ -101,17 +104,25 @@ function normalizeFiniteRankDimension(value: unknown, fallback: number): number return Number.isFinite(value) ? (value as number) : fallback; } +/** Guard an owner/repo value to the canonical `owner/repo` shape. Shared by the candidate write path and + * purgeByRepo (#8009), each throwing its own error name — a rejected candidate and a rejected purge target are + * different operator mistakes. */ +function normalizeRepoFullName(value: unknown, error: string): string { + const repoFullName = typeof value === "string" ? value.trim() : ""; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) throw new Error(error); + return `${owner}/${repo}`; +} + function normalizeCandidate(candidate: RankedCandidateInput): NormalizedRankedCandidate { if (!candidate || typeof candidate !== "object") throw new Error("invalid_ranked_candidate"); - const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner || !repo || extra !== undefined) throw new Error("invalid_ranked_candidate"); + const repoFullName = normalizeRepoFullName(candidate.repoFullName, "invalid_ranked_candidate"); const issueNumber = candidate.issueNumber; if (!Number.isInteger(issueNumber) || issueNumber <= 0) throw new Error("invalid_ranked_candidate"); const rankScore = Number(candidate.rankScore); if (!Number.isFinite(rankScore)) throw new Error("invalid_ranked_candidate"); return { - repoFullName: `${owner}/${repo}`, + repoFullName, issueNumber, title: typeof candidate.title === "string" ? candidate.title : "", htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null, @@ -225,6 +236,12 @@ export function initRankedCandidatesStore(dbPath: string = resolveRankedCandidat listRankedCandidates() { return listStatement.all().map((row) => rowToCandidate(asRankedCandidateDbRow(row))); }, + /** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what + * `loopover-miner purge` invokes. Reuses store-maintenance.js's identifier-guarded purgeStoreByRepo, + * exactly like the other repo-scoped stores. */ + purgeByRepo(repoFullName) { + return purgeStoreByRepo(db, RANKED_CANDIDATES_PURGE_SPEC, normalizeRepoFullName(repoFullName, "invalid_repo_full_name")); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/replay-snapshot.ts b/packages/loopover-miner/lib/replay-snapshot.ts index 363e9798f4..03b80b77ad 100644 --- a/packages/loopover-miner/lib/replay-snapshot.ts +++ b/packages/loopover-miner/lib/replay-snapshot.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { removeWorktree } from "@loopover/engine"; import type { WorktreeExecFn, WorktreeRemoveResult } from "@loopover/engine"; import { openLocalStoreAdapter, resolveLocalStoreDbPath, normalizeLocalStoreDbPath } from "./local-store.js"; +import { REPLAY_SNAPSHOT_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; // Freeze/snapshot mechanism for historical replay targets (#3010). Given a repo and a commit SHA T, exports: // (a) the full working tree checked out AT T via a DETACHED git worktree -- the same isolation primitive @@ -51,6 +52,8 @@ export type ReplaySnapshotStore = { dbPath: string; getSnapshot(repoFullName: string, commitSha: string): ReplaySnapshot | null; saveSnapshot(snapshot: Omit): ReplaySnapshot; + /** Delete every cached snapshot row for one repo (#8009); returns the number of rows removed. */ + purgeByRepo(repoFullName: string): number; close(): void; }; @@ -272,6 +275,14 @@ export function openReplaySnapshotStore(dbPath: string = resolveReplaySnapshotDb dbPath: resolvedPath, getSnapshot, saveSnapshot, + /** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what + * `loopover-miner purge` invokes. Reuses store-maintenance.js's identifier-guarded purgeStoreByRepo against + * the raw handle (the #7175 driver seam covers this store's own CRUD, not the shared maintenance helpers), + * exactly like the other repo-scoped stores. Removes only DB rows — exported worktrees are transient files + * the snapshot merely references, cleaned up by removeReplaySnapshotWorktree in their own lifecycle. */ + purgeByRepo(repoFullName: string): number { + return purgeStoreByRepo(db, REPLAY_SNAPSHOT_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/store-maintenance.ts b/packages/loopover-miner/lib/store-maintenance.ts index 72fe9fe89a..e2f180310b 100644 --- a/packages/loopover-miner/lib/store-maintenance.ts +++ b/packages/loopover-miner/lib/store-maintenance.ts @@ -58,6 +58,18 @@ export const GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC: LedgerPurgeSpec = { table: "go * column, exactly like `attempt-log.js`). */ export const POLICY_VERDICT_CACHE_PURGE_SPEC: LedgerPurgeSpec = { table: "policy_verdict_cache", repoColumn: "repo_scope" }; +/** Three more repo-scoped stores the #5564/#7091/#6987 sweeps missed (#8009), same `repoColumn` shape and same + * internal-constant-only discipline. ranked-candidates is a wholesale-replaced snapshot, but its rows persist + * between discover runs; replay_snapshots embeds commit SHAs and README content. deny-hook-synthesis's live + * table is always `deny_rule_proposals` (`deny_rule_proposals_v2` exists only transiently mid-rebuild inside + * its forge-scope migration, never at rest, so one spec covers both pre- and post-migration files), and — like + * `governor_reputation_history` above — it is purged on `repo_full_name` alone (its key is composite with + * `api_base_url`), so a right-to-be-forgotten sweep clears the repo across every forge host it was recorded + * against, not just the default one. */ +export const RANKED_CANDIDATES_PURGE_SPEC: LedgerPurgeSpec = { table: "miner_ranked_candidates", repoColumn: "repo_full_name" }; +export const REPLAY_SNAPSHOT_PURGE_SPEC: LedgerPurgeSpec = { table: "replay_snapshots", repoColumn: "repo_full_name" }; +export const DENY_HOOK_SYNTHESIS_PURGE_SPEC: LedgerPurgeSpec = { table: "deny_rule_proposals", repoColumn: "repo_full_name" }; + export type StoreIntegrityResult = { name: string; ok: boolean; detail: string }; export type LedgerRetentionPolicy = { maxAgeMs?: number; maxRows?: number }; diff --git a/test/unit/miner-deny-hook-synthesis.test.ts b/test/unit/miner-deny-hook-synthesis.test.ts index 993e649be9..8c47978af4 100644 --- a/test/unit/miner-deny-hook-synthesis.test.ts +++ b/test/unit/miner-deny-hook-synthesis.test.ts @@ -231,6 +231,27 @@ describe("initDenyHookSynthesisStore() (#4522)", () => { expect(() => store.setProposalStatus("acme/widgets", "path:abc", "bogus")).toThrow("invalid_proposal_status"); }); + it("purgeByRepo sweeps the repo's proposals under EVERY forge host and leaves other repos intact (#8009)", () => { + const store = tempStore(); + const history = [ + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + ]; + store.refreshProposals("acme/widgets", history, {}, "https://api.github.com"); + store.refreshProposals("acme/widgets", history, {}, "https://gitlab.example/api"); + store.refreshProposals("acme/other", history); + + // Filters on repo_full_name alone: one proposal per forge host = 2 rows removed. + expect(store.purgeByRepo("acme/widgets")).toBe(2); + expect(store.listProposals("acme/widgets", "https://api.github.com")).toEqual([]); + expect(store.listProposals("acme/widgets", "https://gitlab.example/api")).toEqual([]); + expect(store.listProposals("acme/other")).toHaveLength(1); + }); + + it("purgeByRepo returns 0 when the repo has no proposals (#8009)", () => { + expect(tempStore().purgeByRepo("acme/widgets")).toBe(0); + }); + it("migrates an existing pre-#5563 file, backfilling api_base_url and preserving every row", () => { const dir = mkdtempSync(join(tmpdir(), "miner-deny-hook-synthesis-legacy-")); tempDirs.push(dir); diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 929ae1ef2a..edcedf880d 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -1448,6 +1448,7 @@ describe("runDiscover (#4247)", () => { dbPath: ":memory:", saveRankedCandidates, listRankedCandidates: () => [], + purgeByRepo: () => 0, close: () => undefined, }), fetchCandidateIssuesWithSummary, diff --git a/test/unit/miner-purge-cli.test.ts b/test/unit/miner-purge-cli.test.ts index 98e69f2a35..1355f0b8a5 100644 --- a/test/unit/miner-purge-cli.test.ts +++ b/test/unit/miner-purge-cli.test.ts @@ -18,6 +18,9 @@ import { } from "../../packages/loopover-miner/lib/contribution-profile-cache.js"; import { initPolicyVerdictCacheStore } from "../../packages/loopover-miner/lib/policy-verdict-cache.js"; import { openGovernorState } from "../../packages/loopover-miner/lib/governor-state.js"; +import { initRankedCandidatesStore } from "../../packages/loopover-miner/lib/ranked-candidates.js"; +import { openReplaySnapshotStore } from "../../packages/loopover-miner/lib/replay-snapshot.js"; +import { initDenyHookSynthesisStore } from "../../packages/loopover-miner/lib/deny-hook-synthesis.js"; import { emptyContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile.js"; import { ATTEMPT_LOG_NOT_PURGEABLE_NOTE, @@ -85,7 +88,7 @@ describe("parsePurgeArgs (#5564)", () => { }); describe("runPurge --dry-run (#5564, #6599)", () => { - it("counts matching rows across the nine real stores without writing anything, and reports attempt-log as not-purgeable", async () => { + it("counts matching rows across the twelve real stores without writing anything, and reports attempt-log as not-purgeable", async () => { const root = tempDir(); const claimDbPath = join(root, "claim-ledger.sqlite3"); const eventDbPath = join(root, "event-ledger.sqlite3"); @@ -96,6 +99,9 @@ describe("runPurge --dry-run (#5564, #6599)", () => { const cacheDbPath = join(root, "contribution-profile-cache.sqlite3"); const policyVerdictCacheDbPath = join(root, "policy-verdict-cache.sqlite3"); const governorStateDbPath = join(root, "governor-state.sqlite3"); + const rankedCandidatesDbPath = join(root, "ranked-candidates.sqlite3"); + const replaySnapshotDbPath = join(root, "replay-snapshot.sqlite3"); + const denyHookSynthesisDbPath = join(root, "deny-hook-synthesis.sqlite3"); const attemptLogDbPath = join(root, "attempt-log.sqlite3"); // never created — dry run must not touch it const claimLedger = openClaimLedger(claimDbPath); @@ -161,6 +167,48 @@ describe("runPurge --dry-run (#5564, #6599)", () => { governorState.recordOwnSubmission({ repoFullName: "acme/widgets", fingerprint: "fp-2" }); governorState.close(); + // The three #8009 stores, one acme/widgets row + one acme/other row each (only widgets' must count). + const rankedCandidates = initRankedCandidatesStore(rankedCandidatesDbPath); + rankedCandidates.saveRankedCandidates([ + { repoFullName: "acme/widgets", issueNumber: 1, rankScore: 0.9 }, + { repoFullName: "acme/other", issueNumber: 2, rankScore: 0.5 }, + ]); + rankedCandidates.close(); + + const replaySnapshots = openReplaySnapshotStore(replaySnapshotDbPath); + replaySnapshots.saveSnapshot({ + repoFullName: "acme/widgets", + commitSha: "abc123", + worktreePath: "/repo/.loopover-replay-snapshots/abc123", + targetDate: "2026-01-05T00:00:00+00:00", + commits: [{ sha: "abc123", date: "2026-01-05T00:00:00+00:00", subject: "t" }], + tags: [], + readme: null, + }); + replaySnapshots.saveSnapshot({ + repoFullName: "acme/other", + commitSha: "def456", + worktreePath: "/repo/.loopover-replay-snapshots/def456", + targetDate: "2026-01-05T00:00:00+00:00", + commits: [{ sha: "def456", date: "2026-01-05T00:00:00+00:00", subject: "t" }], + tags: [], + readme: null, + }); + replaySnapshots.close(); + + // Two identical history records synthesize exactly one proposal per repo (the same seeding + // miner-deny-hook-synthesis.test.ts's own suite relies on). + const denyHookSynthesis = initDenyHookSynthesisStore(denyHookSynthesisDbPath); + denyHookSynthesis.refreshProposals("acme/widgets", [ + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + ]); + denyHookSynthesis.refreshProposals("acme/other", [ + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + ]); + denyHookSynthesis.close(); + const resolveDbPaths = { "claim-ledger": () => claimDbPath, "event-ledger": () => eventDbPath, @@ -171,6 +219,9 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "contribution-profile-cache": () => cacheDbPath, "policy-verdict-cache": () => policyVerdictCacheDbPath, "governor-state": () => governorStateDbPath, + "ranked-candidates": () => rankedCandidatesDbPath, + "replay-snapshot": () => replaySnapshotDbPath, + "deny-hook-synthesis": () => denyHookSynthesisDbPath, "attempt-log": () => attemptLogDbPath, }; @@ -191,6 +242,9 @@ describe("runPurge --dry-run (#5564, #6599)", () => { // governor-state sums BOTH tables: 2 reputation rows (two api_base_urls) + 2 own submissions = 4. { store: "governor-state", wouldPurge: 4 }, { store: "policy-verdict-cache", wouldPurge: 1 }, + { store: "ranked-candidates", wouldPurge: 1 }, + { store: "replay-snapshot", wouldPurge: 1 }, + { store: "deny-hook-synthesis", wouldPurge: 1 }, ], attemptLogNote: ATTEMPT_LOG_NOT_PURGEABLE_NOTE, attemptLogTotalRows: 0, @@ -223,12 +277,15 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), "policy-verdict-cache": () => join(root, "policy-verdict-cache.sqlite3"), "governor-state": () => join(root, "governor-state.sqlite3"), + "ranked-candidates": () => join(root, "ranked-candidates.sqlite3"), + "replay-snapshot": () => join(root, "replay-snapshot.sqlite3"), + "deny-hook-synthesis": () => join(root, "deny-hook-synthesis.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"], { resolveDbPaths })).toBe(0); const result = JSON.parse(String(log.mock.calls[0]?.[0])); - expect(result.stores).toHaveLength(9); + expect(result.stores).toHaveLength(12); expect(result.stores.every((entry: { wouldPurge: number }) => entry.wouldPurge === 0)).toBe(true); expect(result.attemptLogTotalRows).toBe(0); for (const resolve of Object.values(resolveDbPaths)) { @@ -266,6 +323,9 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), "policy-verdict-cache": () => join(root, "policy-verdict-cache.sqlite3"), "governor-state": () => join(root, "governor-state.sqlite3"), + "ranked-candidates": () => join(root, "ranked-candidates.sqlite3"), + "replay-snapshot": () => join(root, "replay-snapshot.sqlite3"), + "deny-hook-synthesis": () => join(root, "deny-hook-synthesis.sqlite3"), "attempt-log": () => attemptLogDbPath, }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -294,6 +354,9 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), "policy-verdict-cache": () => join(root, "policy-verdict-cache.sqlite3"), "governor-state": () => join(root, "governor-state.sqlite3"), + "ranked-candidates": () => join(root, "ranked-candidates.sqlite3"), + "replay-snapshot": () => join(root, "replay-snapshot.sqlite3"), + "deny-hook-synthesis": () => join(root, "deny-hook-synthesis.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -335,6 +398,9 @@ describe("runPurge --dry-run (#5564, #6599)", () => { LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB: process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB, LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB: process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB, LOOPOVER_MINER_GOVERNOR_STATE_DB: process.env.LOOPOVER_MINER_GOVERNOR_STATE_DB, + LOOPOVER_MINER_RANKED_CANDIDATES_DB: process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB, + LOOPOVER_MINER_REPLAY_SNAPSHOT_DB: process.env.LOOPOVER_MINER_REPLAY_SNAPSHOT_DB, + LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB: process.env.LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB, LOOPOVER_MINER_ATTEMPT_LOG_DB: process.env.LOOPOVER_MINER_ATTEMPT_LOG_DB, }; process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB = join(root, "claim-ledger.sqlite3"); @@ -346,12 +412,15 @@ describe("runPurge --dry-run (#5564, #6599)", () => { process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB = join(root, "contribution-profile-cache.sqlite3"); process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB = join(root, "policy-verdict-cache.sqlite3"); process.env.LOOPOVER_MINER_GOVERNOR_STATE_DB = join(root, "governor-state.sqlite3"); + process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB = join(root, "ranked-candidates.sqlite3"); + process.env.LOOPOVER_MINER_REPLAY_SNAPSHOT_DB = join(root, "replay-snapshot.sqlite3"); + process.env.LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB = join(root, "deny-hook-synthesis.sqlite3"); process.env.LOOPOVER_MINER_ATTEMPT_LOG_DB = join(root, "attempt-log.sqlite3"); try { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"])).toBe(0); const result = JSON.parse(String(log.mock.calls[0]?.[0])); - expect(result.stores).toHaveLength(9); + expect(result.stores).toHaveLength(12); expect(result.stores.every((entry: { wouldPurge: number }) => entry.wouldPurge === 0)).toBe(true); // Nothing was created — dry run against nonexistent default-path stores makes zero writes. expect(existsSync(process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB)).toBe(false); @@ -390,6 +459,9 @@ describe("runPurge (real, #5564, #6599)", () => { initContributionProfileCache: () => cache, openGovernorState: () => governorState, initPolicyVerdictCacheStore: () => fakeStore(0), + initRankedCandidatesStore: () => fakeStore(0), + openReplaySnapshotStore: () => fakeStore(0), + initDenyHookSynthesisStore: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -409,6 +481,9 @@ describe("runPurge (real, #5564, #6599)", () => { { store: "contribution-profile-cache", purged: 1 }, { store: "governor-state", purged: 4 }, { store: "policy-verdict-cache", purged: 0 }, + { store: "ranked-candidates", purged: 0 }, + { store: "replay-snapshot", purged: 0 }, + { store: "deny-hook-synthesis", purged: 0 }, { store: "attempt-log", purged: null, note: ATTEMPT_LOG_NOT_PURGEABLE_NOTE }, ], }); @@ -450,6 +525,9 @@ describe("runPurge (real, #5564, #6599)", () => { initContributionProfileCache: () => fakeStore(0), openGovernorState: () => fakeStore(0), initPolicyVerdictCacheStore: () => fakeStore(0), + initRankedCandidatesStore: () => fakeStore(0), + openReplaySnapshotStore: () => fakeStore(0), + initDenyHookSynthesisStore: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -486,6 +564,9 @@ describe("runPurge (real, #5564, #6599)", () => { initContributionProfileCache: () => fakeStore(0), openGovernorState: () => fakeStore(0), initPolicyVerdictCacheStore: () => fakeStore(0), + initRankedCandidatesStore: () => fakeStore(0), + openReplaySnapshotStore: () => fakeStore(0), + initDenyHookSynthesisStore: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"], options as never)).toBe(2); @@ -506,6 +587,9 @@ describe("runPurge (real, #5564, #6599)", () => { initContributionProfileCache: () => fakeStore(0), openGovernorState: () => fakeStore(0), initPolicyVerdictCacheStore: () => fakeStore(0), + initRankedCandidatesStore: () => fakeStore(0), + openReplaySnapshotStore: () => fakeStore(0), + initDenyHookSynthesisStore: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"], options as never)).toBe(2); @@ -528,6 +612,9 @@ describe("runPurge (real, #5564, #6599)", () => { LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB: process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB, LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB: process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB, LOOPOVER_MINER_GOVERNOR_STATE_DB: process.env.LOOPOVER_MINER_GOVERNOR_STATE_DB, + LOOPOVER_MINER_RANKED_CANDIDATES_DB: process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB, + LOOPOVER_MINER_REPLAY_SNAPSHOT_DB: process.env.LOOPOVER_MINER_REPLAY_SNAPSHOT_DB, + LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB: process.env.LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB, }; const claimDbPath = join(root, "claim-ledger.sqlite3"); const portfolioDbPath = join(root, "portfolio-queue.sqlite3"); @@ -541,6 +628,9 @@ describe("runPurge (real, #5564, #6599)", () => { process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB = join(root, "contribution-profile-cache.sqlite3"); process.env.LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB = join(root, "policy-verdict-cache.sqlite3"); process.env.LOOPOVER_MINER_GOVERNOR_STATE_DB = join(root, "governor-state.sqlite3"); + process.env.LOOPOVER_MINER_RANKED_CANDIDATES_DB = join(root, "ranked-candidates.sqlite3"); + process.env.LOOPOVER_MINER_REPLAY_SNAPSHOT_DB = join(root, "replay-snapshot.sqlite3"); + process.env.LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB = join(root, "deny-hook-synthesis.sqlite3"); try { // Seed real rows via the default store paths before purging through them. const seededClaim = openClaimLedger(claimDbPath); @@ -608,6 +698,9 @@ describe("runPurge (real, #5564, #6599)", () => { "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), "policy-verdict-cache": () => join(root, "policy-verdict-cache.sqlite3"), "governor-state": () => join(root, "governor-state.sqlite3"), + "ranked-candidates": () => join(root, "ranked-candidates.sqlite3"), + "replay-snapshot": () => join(root, "replay-snapshot.sqlite3"), + "deny-hook-synthesis": () => join(root, "deny-hook-synthesis.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; @@ -632,6 +725,9 @@ describe("runPurge (real, #5564, #6599)", () => { initContributionProfileCache: () => fakeStore(0), openGovernorState: () => fakeStore(0), initPolicyVerdictCacheStore: () => fakeStore(0), + initRankedCandidatesStore: () => fakeStore(0), + openReplaySnapshotStore: () => fakeStore(0), + initDenyHookSynthesisStore: () => fakeStore(0), } as never), ).toBe(0); const purged = JSON.parse(String(log.mock.calls[0]?.[0])); @@ -679,6 +775,9 @@ describe("runPurge (real, #5564, #6599)", () => { initContributionProfileCache: () => cacheStore, openGovernorState: () => governorStore, initPolicyVerdictCacheStore: () => fakeStore(0), + initRankedCandidatesStore: () => fakeStore(0), + openReplaySnapshotStore: () => fakeStore(0), + initDenyHookSynthesisStore: () => fakeStore(0), } as never), ).toBe(0); const summary = JSON.parse(String(log.mock.calls[0]?.[0])); @@ -722,6 +821,9 @@ describe("runPurge (real, #5564, #6599)", () => { initContributionProfileCache: () => fakeStore(0), openGovernorState: () => fakeStore(0), initPolicyVerdictCacheStore: () => policyStore, + initRankedCandidatesStore: () => fakeStore(0), + openReplaySnapshotStore: () => fakeStore(0), + initDenyHookSynthesisStore: () => fakeStore(0), } as never), ).toBe(0); const summary = JSON.parse(String(log.mock.calls[0]?.[0])); @@ -729,4 +831,82 @@ describe("runPurge (real, #5564, #6599)", () => { expect(policyStore.get("acme/widgets")).toBeNull(); expect(policyStore.get("acme/other")).not.toBeNull(); }); + + it("REGRESSION (#8009): really deletes ranked-candidates, replay-snapshot, and deny-hook-synthesis rows across api_base_urls, leaving other repos intact", () => { + const root = tempDir(); + const rankedDbPath = join(root, "ranked-candidates.sqlite3"); + const replayDbPath = join(root, "replay-snapshot.sqlite3"); + const denyDbPath = join(root, "deny-hook-synthesis.sqlite3"); + const history = [ + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + { blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] }, + ]; + + const seededRanked = initRankedCandidatesStore(rankedDbPath); + seededRanked.saveRankedCandidates([ + { repoFullName: "acme/widgets", issueNumber: 1, rankScore: 0.9 }, + { repoFullName: "acme/other", issueNumber: 2, rankScore: 0.5 }, + ]); + seededRanked.close(); + + const seededReplay = openReplaySnapshotStore(replayDbPath); + for (const [repoFullName, commitSha] of [["acme/widgets", "abc123"], ["acme/other", "def456"]] as const) { + seededReplay.saveSnapshot({ + repoFullName, + commitSha, + worktreePath: `/repo/.loopover-replay-snapshots/${commitSha}`, + targetDate: "2026-01-05T00:00:00+00:00", + commits: [{ sha: commitSha, date: "2026-01-05T00:00:00+00:00", subject: "t" }], + tags: [], + readme: null, + }); + } + seededReplay.close(); + + // acme/widgets proposals recorded under TWO forge hosts -- both must be swept, since the purge filters on + // repo_full_name alone (the api_base_url half of the composite key is ignored), like governor-state's. + const seededDeny = initDenyHookSynthesisStore(denyDbPath); + seededDeny.refreshProposals("acme/widgets", history, {}, "https://api.github.com"); + seededDeny.refreshProposals("acme/widgets", history, {}, "https://gitlab.example/api"); + seededDeny.refreshProposals("acme/other", history); + seededDeny.close(); + + // Inject the real openers against the seeded files (caller-owned, so we close them ourselves afterward). + const rankedStore = initRankedCandidatesStore(rankedDbPath); + const replayStore = openReplaySnapshotStore(replayDbPath); + const denyStore = initDenyHookSynthesisStore(denyDbPath); + closeables.push(rankedStore, replayStore, denyStore); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runPurge(["--repo", "acme/widgets", "--json"], { + openClaimLedger: () => fakeStore(0), + initEventLedger: () => fakeStore(0), + initGovernorLedger: () => fakeStore(0), + initPredictionLedger: () => fakeStore(0), + initPortfolioQueueStore: () => fakeStore(0), + initRunStateStore: () => fakeStore(0), + initContributionProfileCache: () => fakeStore(0), + openGovernorState: () => fakeStore(0), + initPolicyVerdictCacheStore: () => fakeStore(0), + initRankedCandidatesStore: () => rankedStore, + openReplaySnapshotStore: () => replayStore, + initDenyHookSynthesisStore: () => denyStore, + } as never), + ).toBe(0); + const summary = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(summary.stores).toContainEqual({ store: "ranked-candidates", purged: 1 }); + expect(summary.stores).toContainEqual({ store: "replay-snapshot", purged: 1 }); + // deny-hook-synthesis sweeps the repo's rows under BOTH forge hosts: one proposal each = 2. + expect(summary.stores).toContainEqual({ store: "deny-hook-synthesis", purged: 2 }); + + // acme/widgets is gone from every purged table across both forge hosts... + expect(rankedStore.listRankedCandidates().map((row) => row.repoFullName)).toEqual(["acme/other"]); + expect(replayStore.getSnapshot("acme/widgets", "abc123")).toBeNull(); + expect(denyStore.listProposals("acme/widgets", "https://api.github.com")).toEqual([]); + expect(denyStore.listProposals("acme/widgets", "https://gitlab.example/api")).toEqual([]); + // ...while another repo's rows are untouched. + expect(replayStore.getSnapshot("acme/other", "def456")).not.toBeNull(); + expect(denyStore.listProposals("acme/other")).toHaveLength(1); + }); }); diff --git a/test/unit/miner-ranked-candidates.test.ts b/test/unit/miner-ranked-candidates.test.ts index f66c91cbed..4e6e24d4c9 100644 --- a/test/unit/miner-ranked-candidates.test.ts +++ b/test/unit/miner-ranked-candidates.test.ts @@ -197,6 +197,33 @@ describe("loopover-miner ranked-candidates store (#4859 prerequisite)", () => { } }); + it("purgeByRepo deletes only the given repo's snapshot rows and returns the count (#8009)", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + store.saveRankedCandidates( + [fullCandidate, { ...fullCandidate, issueNumber: 43 }, { ...fullCandidate, repoFullName: "acme/other" }], + Date.parse("2026-07-13T12:00:00.000Z"), + ); + expect(store.purgeByRepo("acme/widgets")).toBe(2); + expect(store.listRankedCandidates().map((row) => row.repoFullName)).toEqual(["acme/other"]); + } finally { + store.close(); + } + }); + + it("purgeByRepo returns 0 for an unknown repo, and rejects a malformed one with its own error name (#8009)", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + expect(store.purgeByRepo("acme/widgets")).toBe(0); + // The shared owner/repo guard throws the purge path's OWN error name, not the candidate write path's. + expect(() => store.purgeByRepo("not-a-repo")).toThrow("invalid_repo_full_name"); + } finally { + store.close(); + } + }); + it("rolls back the whole transaction on a genuine SQL-level failure (a duplicate repo+issue within one save)", () => { // Both entries individually pass normalizeCandidate (nothing there checks for array-internal duplicates), so // this is the one realistic way to reach the PRIMARY KEY constraint -- and therefore replaceAll's own diff --git a/test/unit/miner-replay-snapshot.test.ts b/test/unit/miner-replay-snapshot.test.ts index 31a0c1cf11..ac9dd2899d 100644 --- a/test/unit/miner-replay-snapshot.test.ts +++ b/test/unit/miner-replay-snapshot.test.ts @@ -456,4 +456,27 @@ describe("openReplaySnapshotStore (#3010) — round-trip persistence", () => { const store = tempStore(); expect(store.getSnapshot("acme/widgets", "nope")).toBeNull(); }); + + it("purgeByRepo deletes only the given repo's snapshot rows and returns the count (#8009)", () => { + const store = tempStore(); + for (const [repoFullName, commitSha] of [["acme/widgets", "abc123"], ["acme/widgets", "abc456"], ["acme/other", "def789"]] as const) { + store.saveSnapshot({ + repoFullName, + commitSha, + worktreePath: `/repo/.loopover-replay-snapshots/${commitSha}`, + targetDate: "2026-01-05T00:00:00+00:00", + commits: [], + tags: [], + readme: null, + }); + } + expect(store.purgeByRepo("acme/widgets")).toBe(2); + expect(store.getSnapshot("acme/widgets", "abc123")).toBeNull(); + expect(store.getSnapshot("acme/widgets", "abc456")).toBeNull(); + expect(store.getSnapshot("acme/other", "def789")).not.toBeNull(); + }); + + it("purgeByRepo returns 0 when the repo has no snapshots (#8009)", () => { + expect(tempStore().purgeByRepo("acme/widgets")).toBe(0); + }); });