fix(sync): preserve cross-file caller edges across callee re-index (#899) (#927)

`storeExtractionResult` deletes a re-indexed file's nodes via `deleteFile`,
which cascades through `edges.FK ... ON DELETE CASCADE` to delete every edge
whose source OR target is one of those nodes. Edges whose source is in the
re-indexed file are re-emitted by the extractor, but edges whose source is in
a *different* (unchanged) file are not — they are silently dropped. This is
issue #899: re-indexing a callee file severs `calls`/`references` edges from
callers that import it via module-attribute access (`pkg.mod.fn(...)`), so
`codegraph callers fn` reports 0 callers for functions that have real call
sites. A docstring-only edit on the callee is sufficient to trigger it.

The bug affects every incremental path that routes through `sync()` /
`indexFile()`: `codegraph sync`, the file-watcher auto-sync (which calls
`sync()`), and the git sync hooks. `codegraph index` was already fixed by
#894 (it now clears-then-rebuilds, so it's a full re-extraction, not
incremental). `sync` remains the fast incremental path and still has the bug.

Fix: before the delete, snapshot incoming cross-file edges paired with the
target node's (name, kind). After re-inserting the file's nodes + same-file
edges, re-insert the snapshot — re-resolving each edge's target to the
re-indexed node's NEW id by (filePath, kind, name). Node ids are
`sha256(filePath:kind:name:line)`, so any line shift in the callee file (e.g.
a docstring-only edit above the symbol) changes every target id and a naive
re-insert by old id would drop them all. Matching by (kind, name) is stable
across line shifts; if the symbol was renamed/removed, no match is found and
the edge stays dropped (correct). `insertEdges` still filters to endpoints
that exist, so edges whose caller (source) was deleted are also dropped.

Regression tests in `__tests__/sync.test.ts` model the RAGFlow production
case: a `pkg/mod.py` with two callees, both called from `test/test_callers.py`
via `mod.<fn>(...)`. The first test confirms a docstring-only edit that shifts
the second callee's line preserves both incoming edges. The second test
confirms renaming a callee correctly drops its old incoming edge (no phantom
preservation against a non-existent symbol).
This commit is contained in:
Josh
2026-06-21 11:47:01 -05:00
committed by GitHub
parent 4aa2752ef1
commit 6110df8b76
3 changed files with 207 additions and 0 deletions
+26
View File
@@ -1419,6 +1419,32 @@ export class QueryBuilder {
return rows.map((r) => r.fp);
}
/**
* Cross-file edges whose TARGET is a node in `filePath` and whose SOURCE is a
* node in a *different* file, paired with the target node's (name, kind) so a
* caller can re-resolve the edge to the re-indexed target's new ID (node IDs
* are `sha256(filePath:kind:name:line)`, so any line shift in the callee file
* changes target IDs and a naive re-insert by old ID silently drops them).
* Used by `storeExtractionResult` to preserve incoming edges across a file
* re-index (issue #899). Same edge-kind rules as
* {@link getDependentFilePaths}: all kinds except `contains`.
*/
getCrossFileIncomingEdgesWithTarget(filePath: string): Array<Edge & { targetName: string; targetKind: NodeKind }> {
const sql = `SELECT e.*, tgt.name AS target_name, tgt.kind AS target_kind
FROM edges e
JOIN nodes tgt ON tgt.id = e.target
JOIN nodes src ON src.id = e.source
WHERE tgt.file_path = ?
AND e.kind != 'contains'
AND src.file_path != ?`;
const rows = this.db.prepare(sql).all(filePath, filePath) as Array<EdgeRow & { target_name: string; target_kind: NodeKind }>;
return rows.map(row => ({
...rowToEdge(row),
targetName: row.target_name,
targetKind: row.target_kind,
}));
}
// ===========================================================================
// File Operations
// ===========================================================================