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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-27 13:24:46 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent afbaf6f61d
commit 61846105c4
5 changed files with 99 additions and 5 deletions
+34 -2
View File
@@ -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');
+44
View File
@@ -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,
});
});
});
+8 -2
View File
@@ -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();
+6
View File
@@ -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;
+7 -1
View File
@@ -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) {