From 61846105c4187ef0623922cf41649219787af3c5 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sun, 27 Sep 2026 13:24:46 +0000 Subject: [PATCH] fix(sync): report pending reference recovery (#1360) (#2024) The orphan sweep discarded resolver statistics, so unchanged files caused the CLI to report a no-op after recovery. Carry processed, resolved, and unresolved sweep counts into SyncResult and report recovery without changing quiet mode. Cover successful recovery, unresolved attempts, and genuine no-ops with real SQLite regression tests. Co-authored-by: Claude Opus 5.5 --- __tests__/cli-sync.test.ts | 36 +++++++++++++++++++++++++++++-- __tests__/sync.test.ts | 44 ++++++++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 10 +++++++-- src/extraction/index.ts | 6 ++++++ src/index.ts | 8 ++++++- 5 files changed, 99 insertions(+), 5 deletions(-) diff --git a/__tests__/cli-sync.test.ts b/__tests__/cli-sync.test.ts index ed998a339..8ab3c9bbf 100644 --- a/__tests__/cli-sync.test.ts +++ b/__tests__/cli-sync.test.ts @@ -7,12 +7,12 @@ import CodeGraph from '../src/index'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); -describe('codegraph sync lock contention (#1361)', () => { +describe('codegraph sync reporting', () => { let testDir: string; beforeEach(async () => { testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cli-sync-')); - fs.writeFileSync(path.join(testDir, 'index.ts'), 'export function original() { return 1; }'); + fs.writeFileSync(path.join(testDir, 'index.ts'), 'export function original() { return target(); }\nexport function target() { return 1; }'); const cg = CodeGraph.initSync(testDir); try { await cg.indexAll(); @@ -38,6 +38,38 @@ describe('codegraph sync lock contention (#1361)', () => { }); } + it.each([false, true])('reports pending-reference recovery (quiet=%s)', (quiet) => { + for (const resolvable of [true, false]) { + const cg = CodeGraph.openSync(testDir); + try { + const queries = (cg as unknown as { queries: import('../src/db/queries').QueryBuilder }).queries; + const caller = cg.searchNodes('original').find(r => r.node.name === 'original')!.node; + const target = cg.searchNodes('target').find(r => r.node.name === 'target')!.node; + queries.db.prepare("DELETE FROM edges WHERE source = ? AND target = ? AND kind = 'calls'") + .run(caller.id, target.id); + queries.insertUnresolvedRef({ + fromNodeId: caller.id, referenceName: resolvable ? 'target' : 'missingTarget', + referenceKind: 'calls', line: 1, column: 37, filePath: 'index.ts', language: 'typescript', + }); + } finally { + cg.destroy(); + } + const recovered = sync(quiet); + expect(recovered.error).toBeUndefined(); + expect(recovered.status).toBe(0); + if (quiet) expect(recovered.stdout + recovered.stderr).toBe(''); + else { + expect(recovered.stdout).not.toContain('Already up to date'); + expect(recovered.stdout).toContain(`Resolved ${resolvable ? 1 : 0} pending references`); + if (!resolvable) expect(recovered.stdout).toContain('1 unresolved'); + } + const unchanged = sync(quiet); + expect(unchanged.status).toBe(0); + if (quiet) expect(unchanged.stdout + unchanged.stderr).toBe(''); + else expect(unchanged.stdout).toContain('Already up to date'); + } + }); + it.each([false, true])('reports contention and recovers after release (quiet=%s)', (quiet) => { fs.writeFileSync(path.join(testDir, 'index.ts'), 'export function changedUnderLock() { return 2; }'); const lockPath = path.join(testDir, '.codegraph', 'codegraph.lock'); diff --git a/__tests__/sync.test.ts b/__tests__/sync.test.ts index c3aa7936a..556f93a9a 100644 --- a/__tests__/sync.test.ts +++ b/__tests__/sync.test.ts @@ -1096,3 +1096,47 @@ describe('committed-but-unindexed changes (#1829)', () => { expect(cg.getChangedFiles().added).toHaveLength(0); }); }); + + +describe('sync pending-reference recovery reporting (#1360)', () => { + let testDir: string; + let cg: CodeGraph; + + beforeEach(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sync-recovery-')); + fs.writeFileSync(path.join(testDir, 'index.ts'), + 'export function caller() { return target(); }\nexport function target() { return 1; }\n'); + cg = CodeGraph.initSync(testDir); + await cg.indexAll(); + }); + + afterEach(() => { + cg?.destroy(); + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it.each([true, false])('reports sweep outcomes without file changes (resolvable=%s)', async (resolvable) => { + const queries = (cg as unknown as { queries: import('../src/db/queries').QueryBuilder }).queries; + const caller = cg.searchNodes('caller').find(r => r.node.name === 'caller')!.node; + const target = cg.searchNodes('target').find(r => r.node.name === 'target')!.node; + queries.db.prepare("DELETE FROM edges WHERE source = ? AND target = ? AND kind = 'calls'") + .run(caller.id, target.id); + queries.insertUnresolvedRef({ + fromNodeId: caller.id, referenceName: resolvable ? 'target' : 'missingTarget', + referenceKind: 'calls', line: 1, column: 35, filePath: 'index.ts', language: 'typescript', + }); + expect(cg.getPendingReferenceCount()).toBe(1); + + const result = await cg.sync(); + expect(result).toMatchObject({ + filesAdded: 0, filesModified: 0, filesRemoved: 0, + pendingRefsProcessed: 1, pendingRefsResolved: resolvable ? 1 : 0, + pendingRefsUnresolved: resolvable ? 0 : 1, + }); + expect(cg.getPendingReferenceCount()).toBe(0); + expect(cg.getCallees(caller.id).some(r => r.node.id === target.id)).toBe(resolvable); + expect(await cg.sync()).toMatchObject({ + pendingRefsProcessed: 0, pendingRefsResolved: 0, pendingRefsUnresolved: 0, + }); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 29869e008..8aaca0e2c 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -973,9 +973,9 @@ program const totalChanges = result.filesAdded + result.filesModified + result.filesRemoved; - if (totalChanges === 0) { + if (totalChanges === 0 && !result.pendingRefsProcessed) { clack.log.info('Already up to date'); - } else { + } else if (totalChanges > 0) { clack.log.success(`Synced ${formatNumber(totalChanges)} changed files`); const details: string[] = []; if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`); @@ -984,6 +984,12 @@ program clack.log.info(`${details.join(', ')} ${getGlyphs().dash} ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`); } + if (result.pendingRefsProcessed) { + const unresolved = result.pendingRefsUnresolved + ? ` (${formatNumber(result.pendingRefsUnresolved)} unresolved)` : ''; + clack.log.info(`Resolved ${formatNumber(result.pendingRefsResolved ?? 0)} pending references${unresolved}`); + } + clack.outro('Done'); } finally { cg.destroy(); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index b11d49b44..612eab58c 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -120,6 +120,12 @@ export interface IndexResult { * Result of a sync operation */ export interface SyncResult { + /** References attempted by the pending-reference recovery sweep, if run. */ + pendingRefsProcessed?: number; + /** Pending references successfully resolved by the recovery sweep. */ + pendingRefsResolved?: number; + /** Pending references the recovery sweep could not resolve. */ + pendingRefsUnresolved?: number; filesChecked: number; filesAdded: number; filesModified: number; diff --git a/src/index.ts b/src/index.ts index ee5d88322..ba0d86c16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -982,6 +982,9 @@ export class CodeGraph { // Grind them down with the batched resolver; this also makes a bare // `codegraph sync` the recovery command for a wedged index. On a // healthy index this is one COUNT query. + result.pendingRefsProcessed = 0; + result.pendingRefsResolved = 0; + result.pendingRefsUnresolved = 0; const orphanCount = this.queries.getUnresolvedReferencesCount(); if (orphanCount > 0) { options.onProgress?.({ @@ -990,7 +993,7 @@ export class CodeGraph { total: orphanCount, }); - await this.resolveReferencesBatched( + const recovery = await this.resolveReferencesBatched( (current, total) => { options.onProgress?.({ phase: 'resolving', @@ -1007,6 +1010,9 @@ export class CodeGraph { }, backpressure ); + result.pendingRefsProcessed = recovery.stats.total; + result.pendingRefsResolved = recovery.stats.resolved; + result.pendingRefsUnresolved = recovery.stats.unresolved; } if (filesChanged || orphanCount > 0) {