Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

#### MCP / indexing

- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.

- The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454)

- Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532)
Expand Down
80 changes: 80 additions & 0 deletions __tests__/sync-rebuild-convergence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,86 @@ describe('Incremental sync converges to a full rebuild (CG-33)', () => {
expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
});

it('keeps one edge when re-resolution selects the same target', async () => {
write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
write('src/alpha.ts', `export function pct(n: number): number {\n return n;\n}\n`);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();

// zeta.ts introduces a competing definition, so the existing edge is
// reopened, but alpha.ts remains the deterministic first candidate.
write('src/zeta.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
const result = await cg.sync();
expect(result.definitionDelta).toContain('pct');

const targets = withDb((db) =>
(
db
.prepare(
`SELECT target.file_path AS file
FROM edges edge
JOIN nodes source ON source.id = edge.source
JOIN nodes target ON target.id = edge.target
WHERE source.name = 'run'
AND target.name = 'pct'
AND edge.kind = 'calls'`
)
.all() as Array<{ file: string }>
).map((row) => row.file)
);
expect(targets).toEqual(['src/alpha.ts']);
});

it('rolls back edge deletion when requeueing its reference is interrupted', async () => {
write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();

const originalEdge = withDb((db) => {
const row = db
.prepare(
`SELECT edge.source, edge.target, edge.kind
FROM edges edge
JOIN nodes source ON source.id = edge.source
JOIN nodes target ON target.id = edge.target
WHERE source.name = 'run'
AND target.name = 'pct'
AND edge.kind = 'calls'`
)
.get() as { source: string; target: string; kind: string };
db.exec(
`CREATE TRIGGER interrupt_pct_requeue
BEFORE INSERT ON unresolved_refs
WHEN NEW.reference_name = 'pct'
BEGIN
SELECT RAISE(ABORT, 'forced rebind interruption');
END;`
);
return `${row.source}|${row.target}|${row.kind}`;
});

write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
await expect(cg.sync()).rejects.toThrow(/forced rebind interruption/);

// A failed requeue leaves the last committed graph answer untouched.
expect(edgeSet().has(originalEdge)).toBe(true);
const queued = withDb(
(db) =>
(
db
.prepare(
`SELECT COUNT(*) AS count
FROM unresolved_refs ref
JOIN nodes source ON source.id = ref.from_node_id
WHERE source.name = 'run' AND ref.reference_name = 'pct'`
)
.get() as { count: number }
).count
);
expect(queued).toBe(0);
});

/**
* The mirror direction: removing a definition narrows the candidate set too,
* so the delta must include names the sync DROPPED, not just names it added.
Expand Down
15 changes: 15 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3526,6 +3526,21 @@ export class QueryBuilder {
return changed;
}

/**
* Replace resolution edges with their original unresolved references as one
* transaction. If ref insertion fails, the edge deletion is rolled back.
*/
replaceResolutionEdgesWithUnresolvedRefs(
edgeIds: number[],
refs: UnresolvedReference[]
): number {
return this.db.transaction(() => {
const changed = this.deleteEdgesByIds(edgeIds);
this.insertUnresolvedRefsBatch(refs);
return changed;
})();
}

/**
* Distinct node names present in the given files — the symbol names a sync
* pass uses to look up retryable failed refs after those files changed.
Expand Down
3 changes: 1 addition & 2 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2906,8 +2906,7 @@ export class ExtractionOrchestrator {
// rebind to the same target is a clean no-op, but leaving the old row in
// place for a rebind ELSEWHERE would keep both, turning drift into
// duplication.
this.queries.deleteEdgesByIds(edgeIds);
this.queries.insertUnresolvedRefsBatch(refs);
this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs);
return refs.length;
}

Expand Down