sync.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759
  1. /**
  2. * Sync Module Tests
  3. *
  4. * Tests for sync functionality (incremental updates).
  5. * Note: Git hooks functionality has been removed in favor of codegraph's
  6. * Claude Code hooks integration.
  7. */
  8. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  9. import * as fs from 'fs';
  10. import * as path from 'path';
  11. import * as os from 'os';
  12. import { execFileSync } from 'child_process';
  13. import CodeGraph from '../src/index';
  14. describe('Sync Module', () => {
  15. describe('Sync Functionality', () => {
  16. let testDir: string;
  17. let cg: CodeGraph;
  18. beforeEach(async () => {
  19. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sync-func-'));
  20. // Create initial source files
  21. const srcDir = path.join(testDir, 'src');
  22. fs.mkdirSync(srcDir);
  23. fs.writeFileSync(
  24. path.join(srcDir, 'index.ts'),
  25. `export function hello() { return 'world'; }`
  26. );
  27. // Initialize and index
  28. cg = CodeGraph.initSync(testDir, {
  29. config: {
  30. include: ['**/*.ts'],
  31. exclude: [],
  32. },
  33. });
  34. await cg.indexAll();
  35. });
  36. afterEach(() => {
  37. if (cg) {
  38. cg.destroy();
  39. }
  40. if (fs.existsSync(testDir)) {
  41. fs.rmSync(testDir, { recursive: true, force: true });
  42. }
  43. });
  44. describe('getChangedFiles()', () => {
  45. it('should detect added files', () => {
  46. // Add a new file
  47. fs.writeFileSync(
  48. path.join(testDir, 'src', 'new.ts'),
  49. `export function newFunc() { return 42; }`
  50. );
  51. const changes = cg.getChangedFiles();
  52. expect(changes.added).toContain('src/new.ts');
  53. expect(changes.modified).toHaveLength(0);
  54. expect(changes.removed).toHaveLength(0);
  55. });
  56. it('should detect modified files', () => {
  57. // Modify existing file
  58. fs.writeFileSync(
  59. path.join(testDir, 'src', 'index.ts'),
  60. `export function hello() { return 'modified'; }`
  61. );
  62. const changes = cg.getChangedFiles();
  63. expect(changes.added).toHaveLength(0);
  64. expect(changes.modified).toContain('src/index.ts');
  65. expect(changes.removed).toHaveLength(0);
  66. });
  67. it('should detect removed files', () => {
  68. // Remove file
  69. fs.unlinkSync(path.join(testDir, 'src', 'index.ts'));
  70. const changes = cg.getChangedFiles();
  71. expect(changes.added).toHaveLength(0);
  72. expect(changes.modified).toHaveLength(0);
  73. expect(changes.removed).toContain('src/index.ts');
  74. });
  75. });
  76. describe('sync()', () => {
  77. it('should reindex added files', async () => {
  78. // Add a new file
  79. fs.writeFileSync(
  80. path.join(testDir, 'src', 'new.ts'),
  81. `export function newFunc() { return 42; }`
  82. );
  83. const result = await cg.sync();
  84. expect(result.filesAdded).toBe(1);
  85. expect(result.filesModified).toBe(0);
  86. expect(result.filesRemoved).toBe(0);
  87. // Verify new function is in the graph
  88. const nodes = cg.searchNodes('newFunc');
  89. expect(nodes.length).toBeGreaterThan(0);
  90. });
  91. it('should reindex modified files', async () => {
  92. // Modify existing file
  93. fs.writeFileSync(
  94. path.join(testDir, 'src', 'index.ts'),
  95. `export function goodbye() { return 'farewell'; }`
  96. );
  97. const result = await cg.sync();
  98. expect(result.filesModified).toBe(1);
  99. // Verify new function is in the graph
  100. const nodes = cg.searchNodes('goodbye');
  101. expect(nodes.length).toBeGreaterThan(0);
  102. // Verify old function is gone
  103. const oldNodes = cg.searchNodes('hello');
  104. expect(oldNodes.length).toBe(0);
  105. });
  106. it('should remove nodes from deleted files', async () => {
  107. // Remove file
  108. fs.unlinkSync(path.join(testDir, 'src', 'index.ts'));
  109. const result = await cg.sync();
  110. expect(result.filesRemoved).toBe(1);
  111. // Verify function is gone
  112. const nodes = cg.searchNodes('hello');
  113. expect(nodes.length).toBe(0);
  114. });
  115. it('should report no changes when nothing changed', async () => {
  116. const result = await cg.sync();
  117. expect(result.filesAdded).toBe(0);
  118. expect(result.filesModified).toBe(0);
  119. expect(result.filesRemoved).toBe(0);
  120. expect(result.filesChecked).toBeGreaterThan(0);
  121. });
  122. });
  123. });
  124. describe('Git-based sync', () => {
  125. let testDir: string;
  126. let cg: CodeGraph;
  127. function git(...args: string[]) {
  128. execFileSync('git', args, { cwd: testDir, stdio: 'pipe' });
  129. }
  130. beforeEach(async () => {
  131. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-git-sync-'));
  132. // Initialize a git repo with an initial commit
  133. git('init');
  134. git('config', 'user.email', 'test@test.com');
  135. git('config', 'user.name', 'Test');
  136. const srcDir = path.join(testDir, 'src');
  137. fs.mkdirSync(srcDir);
  138. fs.writeFileSync(
  139. path.join(srcDir, 'index.ts'),
  140. `export function hello() { return 'world'; }`
  141. );
  142. git('add', '-A');
  143. git('commit', '-m', 'initial');
  144. // Initialize CodeGraph and index
  145. cg = CodeGraph.initSync(testDir, {
  146. config: {
  147. include: ['**/*.ts'],
  148. exclude: [],
  149. },
  150. });
  151. await cg.indexAll();
  152. });
  153. afterEach(() => {
  154. if (cg) {
  155. cg.destroy();
  156. }
  157. if (fs.existsSync(testDir)) {
  158. fs.rmSync(testDir, { recursive: true, force: true });
  159. }
  160. });
  161. it('should detect modified files via git', async () => {
  162. fs.writeFileSync(
  163. path.join(testDir, 'src', 'index.ts'),
  164. `export function hello() { return 'modified'; }`
  165. );
  166. const result = await cg.sync();
  167. expect(result.filesModified).toBe(1);
  168. expect(result.changedFilePaths).toContain('src/index.ts');
  169. });
  170. it('should detect new untracked files via git', async () => {
  171. fs.writeFileSync(
  172. path.join(testDir, 'src', 'new.ts'),
  173. `export function newFunc() { return 42; }`
  174. );
  175. const result = await cg.sync();
  176. expect(result.filesAdded).toBe(1);
  177. expect(result.changedFilePaths).toContain('src/new.ts');
  178. // Verify the function was indexed
  179. const nodes = cg.searchNodes('newFunc');
  180. expect(nodes.length).toBeGreaterThan(0);
  181. });
  182. it('should stop reporting untracked files once they are indexed (issue #206)', async () => {
  183. // Untracked files stay `??` in git status even after codegraph indexes
  184. // them. Change detection must compare them against the DB by hash, not
  185. // report every untracked file as "added" on every sync/status.
  186. fs.writeFileSync(
  187. path.join(testDir, 'src', 'new.ts'),
  188. `export function newFunc() { return 42; }`
  189. );
  190. // First sync indexes the untracked file.
  191. const first = await cg.sync();
  192. expect(first.filesAdded).toBe(1);
  193. // The file is still untracked in git, but now lives in the DB.
  194. expect(cg.searchNodes('newFunc').length).toBeGreaterThan(0);
  195. // status must not keep flagging it as a pending addition...
  196. const changes = cg.getChangedFiles();
  197. expect(changes.added).not.toContain('src/new.ts');
  198. expect(changes.modified).not.toContain('src/new.ts');
  199. // ...and a second sync must be a no-op for it.
  200. const second = await cg.sync();
  201. expect(second.filesAdded).toBe(0);
  202. expect(second.filesModified).toBe(0);
  203. });
  204. it('should re-index an untracked file when its contents change', async () => {
  205. const filePath = path.join(testDir, 'src', 'new.ts');
  206. fs.writeFileSync(filePath, `export function newFunc() { return 42; }`);
  207. await cg.sync();
  208. // Modify the still-untracked file.
  209. fs.writeFileSync(filePath, `export function renamedFunc() { return 7; }`);
  210. const changes = cg.getChangedFiles();
  211. expect(changes.modified).toContain('src/new.ts');
  212. const result = await cg.sync();
  213. expect(result.filesModified).toBe(1);
  214. expect(cg.searchNodes('renamedFunc').length).toBeGreaterThan(0);
  215. expect(cg.searchNodes('newFunc').length).toBe(0);
  216. });
  217. it('should detect deleted files via git', async () => {
  218. fs.unlinkSync(path.join(testDir, 'src', 'index.ts'));
  219. const result = await cg.sync();
  220. expect(result.filesRemoved).toBe(1);
  221. // Verify function is gone
  222. const nodes = cg.searchNodes('hello');
  223. expect(nodes.length).toBe(0);
  224. });
  225. it('should skip files with unsupported extensions', async () => {
  226. // A .txt file has no supported grammar, so sync must not index it.
  227. fs.writeFileSync(
  228. path.join(testDir, 'src', 'notes.txt'),
  229. `just some notes`
  230. );
  231. const result = await cg.sync();
  232. expect(result.filesAdded).toBe(0);
  233. expect(result.filesModified).toBe(0);
  234. });
  235. it('should report no changes on clean working tree', async () => {
  236. const result = await cg.sync();
  237. expect(result.filesAdded).toBe(0);
  238. expect(result.filesModified).toBe(0);
  239. expect(result.filesRemoved).toBe(0);
  240. expect(result.changedFilePaths).toBeUndefined();
  241. });
  242. });
  243. // Incremental sync's git fast path used to consume `git status` output without
  244. // the ignore matcher the full index applies — so a committed dependency dir
  245. // (built-in default exclude) or a tracked file under a .gitignored dir would
  246. // leak into the index via `sync`, then vanish on the next `index --force`. The
  247. // git fast path must exclude exactly what the full scan does. (#766)
  248. describe('Incremental sync honors the ignore matcher (#766)', () => {
  249. let testDir: string;
  250. let cg: CodeGraph;
  251. function git(...args: string[]) {
  252. execFileSync('git', args, { cwd: testDir, stdio: 'pipe' });
  253. }
  254. beforeEach(async () => {
  255. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-766-'));
  256. git('init');
  257. git('config', 'user.email', 'test@test.com');
  258. git('config', 'user.name', 'Test');
  259. // Real project source — must keep flowing through sync untouched.
  260. fs.mkdirSync(path.join(testDir, 'src'));
  261. fs.writeFileSync(
  262. path.join(testDir, 'src', 'index.ts'),
  263. `export function hello() { return 'world'; }`
  264. );
  265. // A COMMITTED vendor/ dir: tracked in git, but a built-in default exclude
  266. // git knows nothing about. git status happily reports edits to it.
  267. fs.mkdirSync(path.join(testDir, 'vendor'));
  268. fs.writeFileSync(
  269. path.join(testDir, 'vendor', 'lib.ts'),
  270. `export function vendoredHelper() { return 1; }`
  271. );
  272. // A tracked file inside a .gitignored dir: gitignore is a no-op for files
  273. // already committed, so git status still reports modifications to it.
  274. fs.writeFileSync(path.join(testDir, '.gitignore'), 'generated/\n');
  275. fs.mkdirSync(path.join(testDir, 'generated'));
  276. fs.writeFileSync(
  277. path.join(testDir, 'generated', 'out.ts'),
  278. `export function generatedThing() { return 2; }`
  279. );
  280. git('add', '-A'); // .gitignore + src/ + vendor/ (generated/ is now ignored)
  281. git('add', '-f', 'generated/out.ts'); // force the ignored-but-tracked file in
  282. git('commit', '-m', 'initial');
  283. cg = CodeGraph.initSync(testDir, {
  284. config: { include: ['**/*.ts'], exclude: [] },
  285. });
  286. await cg.indexAll();
  287. });
  288. afterEach(() => {
  289. if (cg) cg.destroy();
  290. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  291. });
  292. it('the full index excludes both (baseline the sync path must match)', () => {
  293. expect(cg.searchNodes('hello').length).toBeGreaterThan(0);
  294. expect(cg.searchNodes('vendoredHelper')).toHaveLength(0);
  295. expect(cg.searchNodes('generatedThing')).toHaveLength(0);
  296. });
  297. it('does not re-index a modified tracked file in a built-in excluded dir (vendor/)', () => {
  298. fs.writeFileSync(
  299. path.join(testDir, 'vendor', 'lib.ts'),
  300. `export function vendoredHelper() { return 999; }`
  301. );
  302. const changes = cg.getChangedFiles();
  303. expect(changes.modified).not.toContain('vendor/lib.ts');
  304. expect(changes.added).not.toContain('vendor/lib.ts');
  305. });
  306. it('does not re-index a modified tracked file in a .gitignored dir', () => {
  307. fs.writeFileSync(
  308. path.join(testDir, 'generated', 'out.ts'),
  309. `export function generatedThing() { return 999; }`
  310. );
  311. const changes = cg.getChangedFiles();
  312. expect(changes.modified).not.toContain('generated/out.ts');
  313. expect(changes.added).not.toContain('generated/out.ts');
  314. });
  315. it('does not index a new untracked file in an excluded dir', () => {
  316. // vendor/ isn't in .gitignore, so an untracked file there surfaces as `??`
  317. // in git status — it must still be filtered to match the full index.
  318. fs.writeFileSync(
  319. path.join(testDir, 'vendor', 'extra.ts'),
  320. `export function vendoredExtra() { return 3; }`
  321. );
  322. const changes = cg.getChangedFiles();
  323. expect(changes.added).not.toContain('vendor/extra.ts');
  324. });
  325. it('status (getChangedFiles) agrees with sync — no phantom pending changes', async () => {
  326. // The user-visible symptom today: `codegraph status` reads getChangedFiles
  327. // and reports a vendor edit as a pending change that `sync` (a filesystem
  328. // reconcile) then never indexes — so the count never clears. Both must now
  329. // agree that nothing happened.
  330. fs.writeFileSync(
  331. path.join(testDir, 'vendor', 'lib.ts'),
  332. `export function vendoredHelper() { return 999; }`
  333. );
  334. const changes = cg.getChangedFiles();
  335. expect(changes.added).toHaveLength(0);
  336. expect(changes.modified).toHaveLength(0);
  337. const result = await cg.sync();
  338. expect(result.filesModified).toBe(0);
  339. expect(result.changedFilePaths ?? []).not.toContain('vendor/lib.ts');
  340. expect(cg.searchNodes('vendoredHelper')).toHaveLength(0);
  341. });
  342. it('still syncs a normal modified source file (no over-filtering)', () => {
  343. fs.writeFileSync(
  344. path.join(testDir, 'src', 'index.ts'),
  345. `export function hello() { return 'changed'; }`
  346. );
  347. const changes = cg.getChangedFiles();
  348. expect(changes.modified).toContain('src/index.ts');
  349. });
  350. });
  351. // Incremental sync used to scope resolution to the CHANGED files' refs, and
  352. // a completed pass deleted every ref it failed to resolve — so when a changed
  353. // file introduced an export/symbol that would satisfy a previously-failed ref
  354. // in an UNCHANGED file, nothing ever revisited it: the cross-file edge stayed
  355. // missing (with status reporting a clean index) until a full re-index. Failed
  356. // refs are now parked as status='failed' and retried when a sync lands files
  357. // carrying a matching symbol name. (#1240)
  358. describe('Sync resolves refs satisfied by a new export in another file (#1240)', () => {
  359. let testDir: string;
  360. let cg: CodeGraph;
  361. function write(rel: string, content: string) {
  362. fs.writeFileSync(path.join(testDir, rel), content);
  363. }
  364. function callersOf(fnName: string, kind: string = 'function'): string[] {
  365. const results = cg.searchNodes(fnName);
  366. const def = results.map((r) => r.node).find((n) => n.kind === kind && n.name === fnName);
  367. if (!def) return [];
  368. return cg.getCallers(def.id).map((c) => c.node.name);
  369. }
  370. beforeEach(async () => {
  371. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1240-'));
  372. // a.ts references `greet`, which does not exist anywhere yet — the ref
  373. // fails resolution during the initial index.
  374. write('a.ts', `import { greet } from './b';\n\nexport function run(): number {\n return greet();\n}\n`);
  375. write('b.ts', `export function other(): number {\n return 1;\n}\n`);
  376. cg = CodeGraph.initSync(testDir, {
  377. config: { include: ['**/*.ts'], exclude: [] },
  378. });
  379. await cg.indexAll();
  380. });
  381. afterEach(() => {
  382. if (cg) cg.destroy();
  383. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  384. });
  385. it('baseline: the unresolvable ref creates no edge and does not report as pending', () => {
  386. expect(callersOf('greet')).toHaveLength(0);
  387. // Failed refs are parked, not pending — status must keep reporting a
  388. // healthy index, or every repo with external-library imports would
  389. // permanently warn about an "interrupted run".
  390. expect(cg.getPendingReferenceCount()).toBe(0);
  391. });
  392. it('creates the cross-file calls edge from the UNCHANGED file after sync', async () => {
  393. write('b.ts', `export function greet(): number {\n return 42;\n}\n`);
  394. const result = await cg.sync();
  395. expect(result.filesModified).toBe(1);
  396. // The ref lives in a.ts, which did NOT change — only the retry of the
  397. // parked failed ref can create this edge.
  398. expect(callersOf('greet')).toContain('run');
  399. expect(cg.getPendingReferenceCount()).toBe(0);
  400. });
  401. it('the synced graph matches a full re-index (the issue\'s exact complaint)', async () => {
  402. write('b.ts', `export function greet(): number {\n return 42;\n}\n`);
  403. await cg.sync();
  404. const synced = cg.getStats();
  405. await cg.indexAll();
  406. const reindexed = cg.getStats();
  407. expect(synced.edgeCount).toBe(reindexed.edgeCount);
  408. expect(synced.nodeCount).toBe(reindexed.nodeCount);
  409. });
  410. it('a second sync is a no-op and does not duplicate edges', async () => {
  411. write('b.ts', `export function greet(): number {\n return 42;\n}\n`);
  412. await cg.sync();
  413. const afterFirst = cg.getStats();
  414. const second = await cg.sync();
  415. expect(second.filesModified).toBe(0);
  416. expect(cg.getStats().edgeCount).toBe(afterFirst.edgeCount);
  417. expect(callersOf('greet')).toContain('run');
  418. });
  419. it('retries dotted method refs via the name tail when a class gains the method', async () => {
  420. // `h.greet()` is stored as reference_name 'h.greet'; the retry lookup
  421. // must match it through name_tail ('greet') when Helper gains greet.
  422. write('use.ts', `import { Helper } from './helper';\n\nexport function useHelper(): number {\n const h = new Helper();\n return h.greet();\n}\n`);
  423. write('helper.ts', `export class Helper {\n other(): number {\n return 1;\n }\n}\n`);
  424. await cg.sync();
  425. expect(callersOf('greet', 'method')).toHaveLength(0);
  426. write('helper.ts', `export class Helper {\n other(): number {\n return 1;\n }\n greet(): number {\n return 42;\n }\n}\n`);
  427. const result = await cg.sync();
  428. expect(result.filesModified).toBe(1);
  429. expect(callersOf('greet', 'method')).toContain('useHelper');
  430. });
  431. });
  432. // The removal-side counterpart of #1240: when a re-index (or file deletion)
  433. // drops a symbol other files had resolved edges to, those edges cascade away
  434. // and the referencing files — which did not change — were never given a
  435. // chance to re-resolve, so they could not rebind to an alternative
  436. // definition the way a full re-index would. Resolution edges now carry their
  437. // originating reference (metadata.refName), and a dropped edge is
  438. // resurrected as that exact ref: re-resolved in the same sync, or parked as
  439. // failed until the symbol reappears.
  440. describe('Sync rebinds or parks refs when a resolved symbol is removed (#1240 removal case)', () => {
  441. let testDir: string;
  442. let cg: CodeGraph;
  443. function write(rel: string, content: string) {
  444. fs.writeFileSync(path.join(testDir, rel), content);
  445. }
  446. function greetDef(): { id: string; filePath: string } | undefined {
  447. const results = cg.searchNodes('greet');
  448. const def = results.map((r) => r.node).find((n) => n.kind === 'function' && n.name === 'greet');
  449. return def ? { id: def.id, filePath: def.filePath } : undefined;
  450. }
  451. function greetCallers(): string[] {
  452. const def = greetDef();
  453. return def ? cg.getCallers(def.id).map((c) => c.node.name) : [];
  454. }
  455. beforeEach(async () => {
  456. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1240-removal-'));
  457. // No import — cross-file name matching, so the caller can legitimately
  458. // rebind to a definition in ANY file, which is what a full re-index does.
  459. write('a.ts', `export function run(): number {\n return greet();\n}\n`);
  460. write('b.ts', `export function greet(): number {\n return 42;\n}\n`);
  461. cg = CodeGraph.initSync(testDir, {
  462. config: { include: ['**/*.ts'], exclude: [] },
  463. });
  464. await cg.indexAll();
  465. // Baseline: the call resolved into b.ts.
  466. expect(greetCallers()).toContain('run');
  467. });
  468. afterEach(() => {
  469. if (cg) cg.destroy();
  470. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  471. });
  472. it('rebinds the unchanged caller when the symbol moves to another file', async () => {
  473. write('b.ts', `export function other(): number {\n return 1;\n}\n`);
  474. write('d.ts', `export function greet(): number {\n return 42;\n}\n`);
  475. await cg.sync();
  476. const def = greetDef();
  477. expect(def?.filePath).toBe('d.ts');
  478. expect(greetCallers()).toContain('run');
  479. // Parity with a full re-index — the issue's contract.
  480. const synced = cg.getStats();
  481. await cg.indexAll();
  482. expect(cg.getStats().edgeCount).toBe(synced.edgeCount);
  483. });
  484. it('drops the edge on removal and restores it when the symbol returns', async () => {
  485. write('b.ts', `export function other(): number {\n return 1;\n}\n`);
  486. await cg.sync();
  487. // Removed with no alternative: the edge must be gone (not preserved
  488. // against a nonexistent symbol) and status must stay clean while the
  489. // ref waits parked.
  490. expect(greetDef()).toBeUndefined();
  491. expect(cg.getPendingReferenceCount()).toBe(0);
  492. write('b.ts', `export function other(): number {\n return 1;\n}\nexport function greet(): number {\n return 42;\n}\n`);
  493. await cg.sync();
  494. expect(greetCallers()).toContain('run');
  495. });
  496. it('handles whole-file deletion: parks the ref, then rebinds when the symbol reappears elsewhere', async () => {
  497. fs.unlinkSync(path.join(testDir, 'b.ts'));
  498. const removal = await cg.sync();
  499. expect(removal.filesRemoved).toBe(1);
  500. expect(greetDef()).toBeUndefined();
  501. expect(cg.getPendingReferenceCount()).toBe(0);
  502. write('d.ts', `export function greet(): number {\n return 99;\n}\n`);
  503. await cg.sync();
  504. expect(greetDef()?.filePath).toBe('d.ts');
  505. expect(greetCallers()).toContain('run');
  506. });
  507. });
  508. describe('Cross-file module-attribute caller edges survive callee re-index (#899)', () => {
  509. let testDir: string;
  510. let cg: CodeGraph;
  511. beforeEach(async () => {
  512. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-899-'));
  513. // pkg/mod.py — a module with two functions, both called from a separate
  514. // test file via `mod.<fn>(...)` (module-attribute access). This is the
  515. // exact shape from the RAGFlow production case in issue #899.
  516. fs.mkdirSync(path.join(testDir, 'pkg'), { recursive: true });
  517. fs.mkdirSync(path.join(testDir, 'test'), { recursive: true });
  518. fs.writeFileSync(
  519. path.join(testDir, 'pkg', '__init__.py'),
  520. ``
  521. );
  522. fs.writeFileSync(
  523. path.join(testDir, 'pkg', 'mod.py'),
  524. [
  525. `def callee_one(value):`,
  526. ` """First callee — docstring above the second callee so edits here shift its line."""`,
  527. ` return value + 1`,
  528. ``,
  529. ``,
  530. `def callee_two(value):`,
  531. ` """Second callee, called from the test file via mod.callee_two(...)."""`,
  532. ` return value + 2`,
  533. ``,
  534. ].join('\n')
  535. );
  536. fs.writeFileSync(
  537. path.join(testDir, 'test', 'test_callers.py'),
  538. [
  539. `from pkg import mod`,
  540. ``,
  541. ``,
  542. `def test_calls_callee_one():`,
  543. ` assert mod.callee_one(1) == 2`,
  544. ``,
  545. ``,
  546. `def test_calls_callee_two():`,
  547. ` assert mod.callee_two(1) == 3`,
  548. ``,
  549. ].join('\n')
  550. );
  551. cg = CodeGraph.initSync(testDir, {
  552. config: { include: ['**/*.py'], exclude: [] },
  553. });
  554. await cg.indexAll();
  555. });
  556. afterEach(() => {
  557. if (cg) cg.destroy();
  558. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  559. });
  560. function callerCount(fnName: string): number {
  561. const results = cg.searchNodes(fnName);
  562. const def = results.map(r => r.node).find(n => n.kind === 'function' && n.name === fnName);
  563. if (!def) return -1;
  564. return cg.getCallers(def.id).length;
  565. }
  566. it('preserves incoming cross-file calls edges when the callee file is re-indexed', async () => {
  567. // Baseline: both callees have one cross-file caller each.
  568. expect(callerCount('callee_one')).toBe(1);
  569. expect(callerCount('callee_two')).toBe(1);
  570. // Docstring-only edit to callee_one — adds 1 line, shifting callee_two's
  571. // line number. A naive ID-based edge restore would drop callee_two's
  572. // incoming edge (its node id changed); the (kind, name) re-resolve
  573. // preserves it. A docstring-only edit also confirms zero-AST-change
  574. // re-indexes don't sever edges.
  575. fs.writeFileSync(
  576. path.join(testDir, 'pkg', 'mod.py'),
  577. [
  578. `def callee_one(value):`,
  579. ` """First callee — docstring above the second callee so edits here shift its line."""`,
  580. ` """Probe: extra docstring line to shift callee_two's start line by 1."""`,
  581. ` return value + 1`,
  582. ``,
  583. ``,
  584. `def callee_two(value):`,
  585. ` """Second callee, called from the test file via mod.callee_two(...)."""`,
  586. ` return value + 2`,
  587. ``,
  588. ].join('\n')
  589. );
  590. const result = await cg.sync();
  591. expect(result.filesModified).toBe(1);
  592. // Both incoming cross-file calls edges must survive the callee re-index.
  593. expect(callerCount('callee_one')).toBe(1);
  594. expect(callerCount('callee_two')).toBe(1);
  595. });
  596. it('drops incoming edges for a callee that was renamed during re-index', async () => {
  597. // Baseline.
  598. expect(callerCount('callee_one')).toBe(1);
  599. // Rename callee_one -> callee_one_renamed. The old edge's target
  600. // (kind=function, name=callee_one) no longer matches any re-indexed
  601. // node, so the edge is correctly dropped (not preserved against a
  602. // non-existent symbol).
  603. fs.writeFileSync(
  604. path.join(testDir, 'pkg', 'mod.py'),
  605. [
  606. `def callee_one_renamed(value):`,
  607. ` """Renamed callee — the old edge targeting callee_one must not be restored."""`,
  608. ` return value + 1`,
  609. ``,
  610. ``,
  611. `def callee_two(value):`,
  612. ` """Second callee, called from the test file via mod.callee_two(...)."""`,
  613. ` return value + 2`,
  614. ``,
  615. ].join('\n')
  616. );
  617. await cg.sync();
  618. // The renamed callee has no callers (the test still calls mod.callee_one,
  619. // which no longer exists). The old callee_one node is gone, so its
  620. // callerCount is -1 (definition not found); callee_one_renamed exists
  621. // but has no incoming edges (the test calls the old name).
  622. expect(callerCount('callee_one')).toBe(-1);
  623. expect(callerCount('callee_one_renamed')).toBe(0);
  624. // callee_two is untouched by the rename and its edge survives.
  625. expect(callerCount('callee_two')).toBe(1);
  626. });
  627. });
  628. });