From 09227725a0d33ec4dfcd9d77588b54bbdce1b15f Mon Sep 17 00:00:00 2001 From: Divyansh Date: Fri, 31 Jul 2026 15:05:05 +0530 Subject: [PATCH] feat: demote drifted anchor claims in hook worker --- libs/hooks/anchor-drift.ts | 170 +++++++++++++++ libs/hooks/worker.ts | 48 +++- package.json | 2 +- scripts/check-anchor-drift-worker.js | 315 +++++++++++++++++++++++++++ 4 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 libs/hooks/anchor-drift.ts create mode 100644 scripts/check-anchor-drift-worker.js diff --git a/libs/hooks/anchor-drift.ts b/libs/hooks/anchor-drift.ts new file mode 100644 index 0000000..ad93c7f --- /dev/null +++ b/libs/hooks/anchor-drift.ts @@ -0,0 +1,170 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import type { ClaimCodeAnchor } from "../knowledge-graph/claim.js"; +import type { + ClaimAnchorAuditIssue, + ClaimAnchorAuditResult, +} from "../knowledge-graph/code-anchors/types.js"; +import type { CompactClaim, CompactEdge, CompactMemoryProposal } from "../knowledge-graph/proposal.js"; +import type { GraphMemoryProvider } from "../knowledge-graph/provider.js"; +import type { ClaimId } from "../knowledge-graph/schema.js"; +import type { GraphReadResult } from "../knowledge-graph/service.js"; + +export type ActionableAnchorStatus = Extract< + ClaimAnchorAuditIssue["status"], + "drifted" | "missing_file" | "missing_symbol" +>; + +export type ActionableAnchorIssue = Omit & { + status: ActionableAnchorStatus; + anchor: ClaimCodeAnchor; +}; + +export type CheckoutSkipReason = + | "missing_cwd" + | "git_unavailable" + | "detached_head" + | "not_default_branch" + | "dirty_tracked_files"; + +export type CheckoutInspection = + | { eligible: true; repoRoot: string; gitHead: string } + | { eligible: false; reason: CheckoutSkipReason }; + +export type AnchorDriftPassResult = + | { status: "clean" } + | { status: "applied"; claimIds: ClaimId[]; memoryCommitId: string }; + +export function inspectAnchorDriftCheckout(cwd: string | null, defaultBranch: string): CheckoutInspection { + if (cwd === null || cwd.trim().length === 0) return { eligible: false, reason: "missing_cwd" }; + + const repoRoot = gitText(cwd, ["rev-parse", "--show-toplevel"]); + if (repoRoot === undefined) return { eligible: false, reason: "git_unavailable" }; + + const branch = gitText(repoRoot, ["branch", "--show-current"]); + if (branch === undefined) return { eligible: false, reason: "git_unavailable" }; + if (branch.length === 0) return { eligible: false, reason: "detached_head" }; + if (branch !== defaultBranch) return { eligible: false, reason: "not_default_branch" }; + + const unstaged = gitDiffStatus(repoRoot, ["diff", "--quiet", "--ignore-submodules=all", "--"]); + const staged = gitDiffStatus(repoRoot, ["diff", "--cached", "--quiet", "--ignore-submodules=all", "--"]); + if (unstaged === "error" || staged === "error") return { eligible: false, reason: "git_unavailable" }; + if (unstaged === "dirty" || staged === "dirty") return { eligible: false, reason: "dirty_tracked_files" }; + + const gitHead = gitText(repoRoot, ["rev-parse", "HEAD"]); + return gitHead === undefined || gitHead.length === 0 + ? { eligible: false, reason: "git_unavailable" } + : { eligible: true, repoRoot, gitHead }; +} + +export function buildAnchorDriftProposal( + graph: GraphReadResult, + audit: ClaimAnchorAuditResult, + gitHead: string, +): CompactMemoryProposal | undefined { + const issuesByClaim = groupActionableIssues(audit); + const claimsById = new Map(graph.claims.map((claim) => [claim.id, claim])); + const claims: CompactClaim[] = []; + const edges: CompactEdge[] = []; + + for (const claimId of [...issuesByClaim.keys()].sort()) { + const claim = claimsById.get(claimId); + if (claim === undefined || claim.truth !== "code_verified") continue; + + const replacementId = anchorDriftClaimId(claim.id); + const about = graph.edges + .filter((edge) => edge.kind === "about" && edge.from_type === "claim" && edge.from_id === claim.id) + .map((edge) => edge.to_id) + .sort(); + + claims.push({ + id: replacementId, + kind: claim.kind, + text: claim.text, + truth: "unknown", + intent: claim.intent, + ...(about.length === 0 ? {} : { about }), + }); + edges.push({ + kind: "supersedes", + from: replacementId, + to: claim.id, + metadata: { + reason: "anchor_drift", + git_commit_sha: gitHead, + issues: issuesByClaim.get(claimId)?.map(({ status, anchor }) => ({ status, anchor })) ?? [], + }, + }); + } + + if (claims.length === 0) return undefined; + return { + title: `Demote ${claims.length} drifted code claim${claims.length === 1 ? "" : "s"}`, + summary: "Mark claims with changed or missing code anchors as unknown while preserving their history.", + creates: { claims, edges }, + }; +} + +export async function runAnchorDriftPass( + provider: GraphMemoryProvider, + gitHead: string, +): Promise { + const audit = await provider.auditCodeAnchors(); + if (!hasActionableIssues(audit)) return { status: "clean" }; + + const proposal = buildAnchorDriftProposal(await provider.readGraph(), audit, gitHead); + if (proposal === undefined) return { status: "clean" }; + + const result = await provider.applyProposal(proposal); + return { + status: "applied", + claimIds: (proposal.creates.edges ?? []).map((edge) => edge.to).sort(), + memoryCommitId: result.memory_commit_id, + }; +} + +function groupActionableIssues(audit: ClaimAnchorAuditResult): Map { + const grouped = new Map>(); + for (const issue of [...audit.drifted, ...audit.missing_files, ...audit.missing_symbols]) { + if (!isActionableAnchorIssue(issue)) continue; + const issues = grouped.get(issue.claim_id) ?? new Map(); + issues.set(issueKey(issue), issue); + grouped.set(issue.claim_id, issues); + } + return new Map( + [...grouped.entries()].map(([claimId, issues]) => [claimId, [...issues.values()].sort(compareIssues)]), + ); +} + +function hasActionableIssues(audit: ClaimAnchorAuditResult): boolean { + return [...audit.drifted, ...audit.missing_files, ...audit.missing_symbols].some(isActionableAnchorIssue); +} + +function isActionableAnchorIssue(issue: ClaimAnchorAuditIssue): issue is ActionableAnchorIssue { + return issue.anchor !== undefined && + (issue.status === "drifted" || issue.status === "missing_file" || issue.status === "missing_symbol"); +} + +function issueKey(issue: ActionableAnchorIssue): string { + return `${issue.status}\0${issue.anchor.file}\0${issue.anchor.symbol ?? ""}`; +} + +function compareIssues(left: ActionableAnchorIssue, right: ActionableAnchorIssue): number { + return issueKey(left).localeCompare(issueKey(right)); +} + +function anchorDriftClaimId(claimId: ClaimId): ClaimId { + const digest = createHash("sha256").update(`anchor-drift:${claimId}`).digest("hex").slice(0, 16); + return `claim.anchor_drift.${digest}`; +} + +function gitText(cwd: string, args: string[]): string | undefined { + const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +function gitDiffStatus(cwd: string, args: string[]): "clean" | "dirty" | "error" { + const result = spawnSync("git", ["-C", cwd, ...args], { stdio: "ignore" }); + if (result.status === 0) return "clean"; + return result.status === 1 ? "dirty" : "error"; +} diff --git a/libs/hooks/worker.ts b/libs/hooks/worker.ts index 6b9d245..9177464 100644 --- a/libs/hooks/worker.ts +++ b/libs/hooks/worker.ts @@ -3,11 +3,18 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ClaimedMemoryUpdateAttempt } from "./types.js"; +import { inspectAnchorDriftCheckout, runAnchorDriftPass } from "./anchor-drift.js"; import { LocalAgentRuntimeStore } from "./runtime-store.js"; import { WorkerLease } from "../utils/worker-lease.js"; -import { ensureGreplicaConfig } from "../config/greplica-config.js"; -import { canScheduleMemoryUpdates, type RepoInstallation } from "../install/repo-installation-store.js"; +import { ensureGreplicaConfig, type GreplicaConfig } from "../config/greplica-config.js"; +import { + canScheduleMemoryUpdates, + RepoInstallationStore, + type RepoInstallation, +} from "../install/repo-installation-store.js"; import { platformInstaller } from "../install/platforms/index.js"; +import { createGraphMemoryProvider } from "../knowledge-graph/provider-factory.js"; +import type { RepoRef } from "../knowledge-graph/service.js"; import { openDatabase } from "../storage/sqlite/db.js"; const hookWorkerLockName = "hook-memory-update-worker"; @@ -51,6 +58,16 @@ export async function runHookWorker(): Promise { const runtimeStore = new LocalAgentRuntimeStore(db, config.session); if (!lease.renew()) return; const attempts = runtimeStore.claimDueMemoryUpdateAttempts(); + const installations = new Map(new RepoInstallationStore(db).list().map((installation) => [installation.id, installation])); + const latestAttemptByRepo = new Map(); + for (const attempt of attempts) latestAttemptByRepo.set(attempt.session.repo_id, attempt); + + for (const attempt of latestAttemptByRepo.values()) { + if (!leaseValid || !lease.renew()) return; + const installation = installations.get(attempt.session.repo_id); + if (installation !== undefined) await maybeDemoteAnchorDrift(attempt, installation, config); + } + for (const attempt of attempts) { if (!leaseValid || !lease.renew()) return; await maybeUpdateWorkingMemory(attempt); @@ -62,6 +79,33 @@ export async function runHookWorker(): Promise { } } +async function maybeDemoteAnchorDrift( + attempt: ClaimedMemoryUpdateAttempt, + installation: RepoInstallation, + config: GreplicaConfig, +): Promise { + const checkout = inspectAnchorDriftCheckout(attempt.session.cwd, installation.defaultBranch); + if (!checkout.eligible) return; + + const repo = { + repo_root: checkout.repoRoot, + remote_url: installation.remoteUrl, + repo_name: installation.repoName, + default_branch: installation.defaultBranch, + } satisfies RepoRef; + + try { + const provider = createGraphMemoryProvider(repo, config); + try { + await runAnchorDriftPass(provider, checkout.gitHead); + } finally { + provider.close(); + } + } catch { + // Background drift checks must not block transcript memory updates. A later due run retries. + } +} + async function maybeUpdateWorkingMemory(attempt: ClaimedMemoryUpdateAttempt): Promise { const cwd = attempt.session.cwd; const transcriptPath = attempt.session.transcript_path; diff --git a/package.json b/package.json index d6dd889..05ccc91 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-anchor-drift-worker.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js && node scripts/check-agent-runner-spawn-error.js", "test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js", "test:reconciliation-code-evidence": "npm run build && node scripts/check-reconciliation-code-evidence.js", "test:repo-installations": "npm run build && node scripts/check-repo-installations.js", diff --git a/scripts/check-anchor-drift-worker.js b/scripts/check-anchor-drift-worker.js new file mode 100644 index 0000000..99eccce --- /dev/null +++ b/scripts/check-anchor-drift-worker.js @@ -0,0 +1,315 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); +const { buildAnchorDriftProposal, inspectAnchorDriftCheckout, runAnchorDriftPass } = await import( + new URL("dist/libs/hooks/anchor-drift.js", root) +); +const { LocalGraphMemoryProvider } = await import(new URL("dist/libs/knowledge-graph/local-provider.js", root)); +const { KnowledgeGraphService } = await import(new URL("dist/libs/knowledge-graph/service.js", root)); +const { RepoInstallationStore } = await import(new URL("dist/libs/install/repo-installation-store.js", root)); +const { openDatabase } = await import(new URL("dist/libs/storage/sqlite/db.js", root)); +const { SqliteRepository } = await import(new URL("dist/libs/storage/sqlite/repository.js", root)); + +checkProposalBuilder(); +checkCheckoutEligibility(); +await checkLocalReconciliation(); + +console.log("check-anchor-drift-worker: ok"); + +function checkProposalBuilder() { + const graph = { + components: [{ id: "component.checkout", name: "Checkout" }], + flows: [], + sources: [{ id: "source.session", kind: "session", ref: "codex:test" }], + claims: [ + codeClaim("claim.content", "Checkout applies a 5% discount", [ + { file: "checkout.ts", symbol: "discount" }, + { file: "checkout-config.ts" }, + ]), + codeClaim("claim.missing", "Legacy checkout helper exists", [{ file: "legacy.ts", symbol: "legacy" }]), + { id: "claim.source", kind: "fact", text: "Source fact", truth: "source_verified", intent: "intended" }, + { id: "claim.unknown", kind: "fact", text: "Unknown fact", truth: "unknown", intent: "unknown" }, + codeClaim("claim.fresh", "Fresh fact", [{ file: "fresh.ts", symbol: "fresh" }]), + ], + edges: [ + canonicalEdge("about", "claim", "claim.content", "component", "component.checkout"), + canonicalEdge("evidenced_by", "claim", "claim.content", "source", "source.session"), + canonicalEdge("about", "claim", "claim.missing", "component", "component.checkout"), + ], + }; + const audit = emptyAudit(); + audit.drifted.push( + { claim_id: "claim.content", anchor: { file: "checkout.ts", symbol: "discount" }, status: "drifted" }, + { claim_id: "claim.content", anchor: { file: "checkout.ts", symbol: "discount" }, status: "drifted" }, + { claim_id: "claim.source", anchor: { file: "source.ts", symbol: "source" }, status: "drifted" }, + { claim_id: "claim.unknown", anchor: { file: "unknown.ts", symbol: "unknown" }, status: "drifted" }, + { claim_id: "claim.content", status: "drifted" }, + ); + audit.missing_files.push({ + claim_id: "claim.content", + anchor: { file: "checkout-config.ts" }, + status: "missing_file", + }); + audit.missing_symbols.push({ + claim_id: "claim.missing", + anchor: { file: "legacy.ts", symbol: "legacy" }, + status: "missing_symbol", + }); + audit.missing_anchors.push({ claim_id: "claim.fresh", status: "missing_anchors" }); + audit.ambiguous_symbols.push({ + claim_id: "claim.fresh", + anchor: { file: "fresh.ts", symbol: "fresh" }, + status: "ambiguous_symbol", + }); + audit.unsupported_languages.push({ + claim_id: "claim.fresh", + anchor: { file: "fresh.xyz", symbol: "fresh" }, + status: "unsupported_language", + }); + + const proposal = buildAnchorDriftProposal(graph, audit, "abc123"); + assert.ok(proposal); + assert.deepEqual(proposal, buildAnchorDriftProposal(graph, audit, "abc123"), "proposal output must be deterministic"); + assert.equal(proposal.creates.claims.length, 2, "only active code-verified claims should be demoted"); + + const contentReplacement = proposal.creates.claims.find((claim) => claim.text.includes("5%")); + assert.ok(contentReplacement); + assert.equal(contentReplacement.truth, "unknown"); + assert.equal(contentReplacement.kind, "fact"); + assert.equal(contentReplacement.intent, "intended"); + assert.deepEqual(contentReplacement.about, ["component.checkout"]); + assert.equal(contentReplacement.code_anchors, undefined); + assert.equal(contentReplacement.evidenced_by, undefined); + + const contentEdge = proposal.creates.edges.find((edge) => edge.to === "claim.content"); + assert.ok(contentEdge); + assert.equal(contentEdge.metadata.reason, "anchor_drift"); + assert.equal(contentEdge.metadata.git_commit_sha, "abc123"); + assert.deepEqual( + contentEdge.metadata.issues.map((issue) => issue.status), + ["drifted", "missing_file"], + "duplicate issues should collapse and sort deterministically", + ); + assert.equal(proposal.creates.claims.some((claim) => claim.text === "Source fact"), false); + assert.equal(proposal.creates.claims.some((claim) => claim.text === "Unknown fact"), false); + assert.equal(proposal.creates.claims.some((claim) => claim.text === "Fresh fact"), false); +} + +function checkCheckoutEligibility() { + const repo = createGitRepo("greplica-anchor-worker-checkout-"); + try { + const tracked = join(repo, "tracked.txt"); + writeFileSync(tracked, "baseline\n"); + git(repo, "add", "tracked.txt"); + git(repo, "commit", "-m", "baseline"); + + assert.equal(inspectAnchorDriftCheckout(repo, "main").eligible, true); + writeFileSync(join(repo, "untracked.txt"), "local only\n"); + assert.equal(inspectAnchorDriftCheckout(repo, "main").eligible, true, "untracked files should not block drift checks"); + + writeFileSync(tracked, "unstaged\n"); + assert.deepEqual(inspectAnchorDriftCheckout(repo, "main"), { eligible: false, reason: "dirty_tracked_files" }); + git(repo, "restore", "tracked.txt"); + + writeFileSync(tracked, "staged\n"); + git(repo, "add", "tracked.txt"); + assert.deepEqual(inspectAnchorDriftCheckout(repo, "main"), { eligible: false, reason: "dirty_tracked_files" }); + git(repo, "restore", "--staged", "tracked.txt"); + git(repo, "restore", "tracked.txt"); + + git(repo, "switch", "-c", "feature"); + assert.deepEqual(inspectAnchorDriftCheckout(repo, "main"), { eligible: false, reason: "not_default_branch" }); + git(repo, "switch", "main"); + git(repo, "checkout", "--detach", "HEAD"); + assert.deepEqual(inspectAnchorDriftCheckout(repo, "main"), { eligible: false, reason: "detached_head" }); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +} + +async function checkLocalReconciliation() { + const repo = createGitRepo("greplica-anchor-worker-integration-"); + const greplicaHome = mkdtempSync(join(tmpdir(), "greplica-anchor-worker-home-")); + const dbPath = join(greplicaHome, "graph.db"); + const previousGreplicaHome = process.env.GREPLICA_HOME; + process.env.GREPLICA_HOME = greplicaHome; + const db = openDatabase(dbPath); + try { + const files = { + content: join(repo, "content.py"), + missingFile: join(repo, "removed.py"), + missingSymbol: join(repo, "symbol.py"), + fresh: join(repo, "fresh.py"), + }; + writeFileSync(files.content, "def content_value():\n return 3\n\ndef content_helper():\n return 30\n"); + writeFileSync(files.missingFile, "def removed_value():\n return 4\n"); + writeFileSync(files.missingSymbol, "def old_symbol():\n return 5\n"); + writeFileSync(files.fresh, "def fresh_value():\n return 6\n"); + git(repo, "add", "."); + git(repo, "commit", "-m", "seed code"); + + const repoRef = { repo_root: repo, repo_name: "anchor-worker-fixture", default_branch: "main" }; + const repository = new SqliteRepository(db); + const service = createTestService(repository); + const initialized = service.initRepo(repoRef); + const installation = new RepoInstallationStore(db).activateLocal(repoRef, { + hooksEnabled: true, + autoMemoryUpdates: true, + }); + await service.applyProposal(repoRef, { + title: "Seed verified anchor claims", + creates: { + components: [{ id: "component.fixture", name: "Fixture" }], + claims: [ + { + ...codeClaim("claim.content", "content_value returns 3", [ + { file: "content.py", symbol: "content_value" }, + { file: "content.py", symbol: "content_helper" }, + ]), + about: "component.fixture", + }, + { ...codeClaim("claim.missing_file", "removed_value returns 4", [{ file: "removed.py", symbol: "removed_value" }]), about: "component.fixture" }, + { ...codeClaim("claim.missing_symbol", "old_symbol returns 5", [{ file: "symbol.py", symbol: "old_symbol" }]), about: "component.fixture" }, + { ...codeClaim("claim.fresh", "fresh_value returns 6", [{ file: "fresh.py", symbol: "fresh_value" }]), about: "component.fixture" }, + ], + }, + }); + + writeFileSync(files.content, "def content_value():\n return 8\n\ndef content_helper():\n return 30\n"); + unlinkSync(files.missingFile); + writeFileSync(files.missingSymbol, "def new_symbol():\n return 5\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-m", "drift anchored code"); + const gitHead = git(repo, "rev-parse", "HEAD"); + + const passDb = openDatabase(dbPath); + const provider = new LocalGraphMemoryProvider( + installation, + repoRef, + createTestService(new SqliteRepository(passDb)), + passDb, + ); + let firstPass; + try { + const audit = await provider.auditCodeAnchors(); + assert.deepEqual( + audit.drifted.map((issue) => `${issue.claim_id}:${issue.anchor?.symbol ?? ""}`), + ["claim.content:content_value"], + "one changed anchor should make its multi-anchor claim actionable", + ); + assert.deepEqual(audit.missing_files.map((issue) => issue.claim_id), ["claim.missing_file"]); + assert.deepEqual(audit.missing_symbols.map((issue) => issue.claim_id), ["claim.missing_symbol"]); + firstPass = await runAnchorDriftPass(provider, gitHead); + } finally { + provider.close(); + } + + assert.equal(firstPass.status, "applied"); + assert.deepEqual(firstPass.claimIds, ["claim.content", "claim.missing_file", "claim.missing_symbol"]); + const active = service.readGraph(repoRef); + assert.deepEqual( + active.claims.filter((claim) => claim.truth === "code_verified").map((claim) => claim.id), + ["claim.fresh"], + "only the unchanged claim should remain code-verified", + ); + const replacements = active.claims.filter((claim) => claim.truth === "unknown"); + assert.equal(replacements.length, 3); + assert.equal(replacements.every((claim) => claim.code_anchors === undefined), true); + assert.equal( + active.edges.filter((edge) => edge.kind === "about" && replacements.some((claim) => claim.id === edge.from_id)).length, + 3, + "replacement claims should remain connected to their component", + ); + const supersedes = db + .prepare("SELECT metadata FROM edges WHERE repo_id = ? AND kind = 'supersedes' ORDER BY id") + .all(initialized.repo_id) + .map((row) => ({ metadata: JSON.parse(row.metadata) })); + assert.equal(supersedes.length, 3); + assert.equal(supersedes.every((edge) => edge.metadata?.git_commit_sha === gitHead), true); + assert.deepEqual( + supersedes.flatMap((edge) => edge.metadata?.issues?.map((issue) => issue.status) ?? []).sort(), + ["drifted", "missing_file", "missing_symbol"], + "supersession metadata should preserve the deterministic audit evidence", + ); + assert.deepEqual( + repository.readSupersededClaims(initialized.repo_id).map((claim) => claim.id).sort(), + ["claim.content", "claim.missing_file", "claim.missing_symbol"], + "original claims should remain in superseded history", + ); + + const retryDb = openDatabase(dbPath); + const retryProvider = new LocalGraphMemoryProvider( + installation, + repoRef, + createTestService(new SqliteRepository(retryDb)), + retryDb, + ); + try { + assert.deepEqual(await runAnchorDriftPass(retryProvider, gitHead), { status: "clean" }); + } finally { + retryProvider.close(); + } + assert.equal(service.readGraph(repoRef).claims.length, 4, "a retry should not create duplicate replacements"); + } finally { + db.close(); + if (previousGreplicaHome === undefined) delete process.env.GREPLICA_HOME; + else process.env.GREPLICA_HOME = previousGreplicaHome; + rmSync(repo, { recursive: true, force: true }); + rmSync(greplicaHome, { recursive: true, force: true }); + } +} + +function codeClaim(id, text, codeAnchors) { + return { id, kind: "fact", text, truth: "code_verified", intent: "intended", code_anchors: codeAnchors }; +} + +function canonicalEdge(kind, fromType, fromId, toType, toId) { + return { + id: `edge_${kind}_${fromId}_${toId}`, + kind, + from_type: fromType, + from_id: fromId, + to_type: toType, + to_id: toId, + }; +} + +function emptyAudit() { + return { + missing_anchors: [], + missing_files: [], + missing_symbols: [], + ambiguous_symbols: [], + unsupported_languages: [], + drifted: [], + }; +} + +function createTestService(repository) { + return new KnowledgeGraphService(repository, undefined, { + ensureForGraph: async () => ({ + created: 0, + reused: 0, + provider: "local", + model: "test", + dimensions: 0, + }), + }); +} + +function createGitRepo(prefix) { + const repo = mkdtempSync(join(tmpdir(), prefix)); + mkdirSync(repo, { recursive: true }); + git(repo, "init", "-b", "main"); + git(repo, "config", "user.email", "greplica-test@example.com"); + git(repo, "config", "user.name", "Greplica Test"); + return repo; +} + +function git(cwd, ...args) { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +}