orphaned-refs-sweep.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. /**
  2. * Orphaned unresolved-refs sweep (#1187)
  3. *
  4. * A resolution pass that dies mid-run (watchdog SIGKILL, Ctrl-C, crash)
  5. * leaves the refs it never reached in unresolved_refs. The git-scoped sync
  6. * fast path only ever reads the changed files' rows, so those orphans — and
  7. * the call edges they represent — used to be missing permanently until a
  8. * full re-index. Field report: a Spring monorepo where blast radius showed
  9. * 3 of 10 caller files for a method behind @Resource field injection.
  10. *
  11. * These tests pin the healing behavior: a completed pass consumes every row
  12. * it processes (resolved or not), and sync sweeps any leftovers even when
  13. * no files changed.
  14. */
  15. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  16. import * as fs from 'fs';
  17. import * as path from 'path';
  18. import * as os from 'os';
  19. import CodeGraph from '../src/index';
  20. describe('Orphaned refs sweep (#1187)', () => {
  21. let testDir: string;
  22. let cg: CodeGraph;
  23. beforeEach(() => {
  24. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-orphan-sweep-'));
  25. });
  26. afterEach(() => {
  27. if (cg) {
  28. cg.destroy();
  29. }
  30. if (fs.existsSync(testDir)) {
  31. fs.rmSync(testDir, { recursive: true, force: true });
  32. }
  33. });
  34. /** Distinct files with a `calls` edge into the node. */
  35. function callerFiles(target: { id: string }): string[] {
  36. return [...new Set(cg.getCallers(target.id).map((c) => c.node.filePath))].sort();
  37. }
  38. /**
  39. * Leave `relPath` in the exact on-disk state a resolution pass killed
  40. * mid-run leaves behind: content re-extracted (nodes + refs re-inserted,
  41. * old edges cascade-deleted, content hash stamped current) but resolution
  42. * never run. The content tweak is needed because re-extraction of
  43. * byte-identical content is a no-op; the hash stamp means a later sync
  44. * sees NO changed files.
  45. */
  46. async function interruptAfterExtraction(relPath: string): Promise<void> {
  47. fs.appendFileSync(path.join(testDir, relPath), '\n// interrupted-run edit\n');
  48. await cg.indexFiles([relPath]);
  49. }
  50. function findMethod(name: string) {
  51. const hit = cg
  52. .searchNodes(name)
  53. .find((r) => (r.node.kind === 'method' || r.node.kind === 'function') && r.node.name === name);
  54. expect(hit, `expected an indexed definition of ${name}`).toBeDefined();
  55. return hit!.node;
  56. }
  57. describe('sync() heals an interrupted resolution run', () => {
  58. beforeEach(async () => {
  59. // The #1187 shape: a concrete @Component class called through Spring
  60. // @Resource field injection from another package.
  61. const supportDir = path.join(testDir, 'src', 'support');
  62. const notifyDir = path.join(testDir, 'src', 'notify');
  63. fs.mkdirSync(supportDir, { recursive: true });
  64. fs.mkdirSync(notifyDir, { recursive: true });
  65. fs.writeFileSync(
  66. path.join(supportDir, 'MemberDescriptionSupport.java'),
  67. [
  68. 'package com.demo.support;',
  69. '',
  70. 'public class MemberDescriptionSupport {',
  71. ' public String getSuperVipName() {',
  72. ' return "SVIP";',
  73. ' }',
  74. '}',
  75. '',
  76. ].join('\n')
  77. );
  78. fs.writeFileSync(
  79. path.join(notifyDir, 'NotifyBuilder.java'),
  80. [
  81. 'package com.demo.notify;',
  82. '',
  83. 'import com.demo.support.MemberDescriptionSupport;',
  84. '',
  85. 'public class NotifyBuilder {',
  86. ' private MemberDescriptionSupport memberDescriptionSupport;',
  87. '',
  88. ' public String buildParams() {',
  89. ' return memberDescriptionSupport.getSuperVipName();',
  90. ' }',
  91. '}',
  92. '',
  93. ].join('\n')
  94. );
  95. cg = CodeGraph.initSync(testDir);
  96. await cg.indexAll();
  97. });
  98. it('resolves leftover refs on a sync with NO file changes', async () => {
  99. const target = findMethod('getSuperVipName');
  100. // Healthy baseline: the caller edge exists, no refs pending.
  101. expect(callerFiles(target)).toContain('src/notify/NotifyBuilder.java');
  102. expect(cg.getPendingReferenceCount()).toBe(0);
  103. // Simulate the interrupted run: re-extract the caller (cascade-deleting
  104. // its old nodes and edges, re-inserting its refs) and stop before
  105. // resolution — exactly the state a killed "Resolving refs" phase
  106. // leaves behind.
  107. await interruptAfterExtraction('src/notify/NotifyBuilder.java');
  108. expect(cg.getPendingReferenceCount()).toBeGreaterThan(0);
  109. expect(callerFiles(target)).not.toContain('src/notify/NotifyBuilder.java');
  110. // The file on disk is unchanged, so this sync re-extracts nothing —
  111. // pre-fix it returned without touching resolution and the edge stayed
  112. // missing forever.
  113. const result = await cg.sync();
  114. expect(result.filesAdded).toBe(0);
  115. expect(result.filesModified).toBe(0);
  116. expect(cg.getPendingReferenceCount()).toBe(0);
  117. expect(callerFiles(target)).toContain('src/notify/NotifyBuilder.java');
  118. });
  119. it('is idempotent: a second no-change sync stays clean', async () => {
  120. await interruptAfterExtraction('src/notify/NotifyBuilder.java');
  121. await cg.sync();
  122. const target = findMethod('getSuperVipName');
  123. const healed = callerFiles(target);
  124. const again = await cg.sync();
  125. expect(again.filesAdded + again.filesModified + again.filesRemoved).toBe(0);
  126. expect(cg.getPendingReferenceCount()).toBe(0);
  127. expect(callerFiles(target)).toEqual(healed);
  128. });
  129. });
  130. describe('completed passes consume every processed row', () => {
  131. it('resolveReferences() deletes unresolvable rows (parity with the batched path)', async () => {
  132. const srcDir = path.join(testDir, 'src');
  133. fs.mkdirSync(srcDir, { recursive: true });
  134. fs.writeFileSync(
  135. path.join(srcDir, 'app.ts'),
  136. [
  137. 'export function helper() { return 1; }',
  138. 'export function main() {',
  139. ' helper();',
  140. ' totallyUndefinedCall();', // resolves to nothing anywhere
  141. '}',
  142. '',
  143. ].join('\n')
  144. );
  145. cg = CodeGraph.initSync(testDir);
  146. await cg.indexAll();
  147. expect(cg.getPendingReferenceCount()).toBe(0);
  148. // Re-extract without resolving: both the resolvable helper() ref and
  149. // the unresolvable one are back in the table.
  150. await interruptAfterExtraction('src/app.ts');
  151. expect(cg.getPendingReferenceCount()).toBeGreaterThan(0);
  152. // The non-batched full pass (which also backs the git-scoped sync
  153. // path) must consume BOTH: pre-fix it deleted only resolved rows, so
  154. // unresolvable ones parked forever and defeated the orphan sweep's
  155. // "non-empty table means interrupted run" invariant.
  156. cg.resolveReferences();
  157. expect(cg.getPendingReferenceCount()).toBe(0);
  158. });
  159. it('batched resolution does not stop at an all-unresolvable batch', async () => {
  160. const srcDir = path.join(testDir, 'src');
  161. fs.mkdirSync(srcDir, { recursive: true });
  162. // File A: only unresolvable refs. Extracted first, so its rows sort
  163. // first and fill the whole first batch.
  164. fs.writeFileSync(
  165. path.join(srcDir, 'a.ts'),
  166. [
  167. 'export function a() {',
  168. ' ghostOne();',
  169. ' ghostTwo();',
  170. ' ghostThree();',
  171. '}',
  172. '',
  173. ].join('\n')
  174. );
  175. // File B: a resolvable ref whose rows sort after A's.
  176. fs.writeFileSync(
  177. path.join(srcDir, 'b.ts'),
  178. [
  179. "import { target } from './c';",
  180. 'export function b() { target(); }',
  181. '',
  182. ].join('\n')
  183. );
  184. fs.writeFileSync(
  185. path.join(srcDir, 'c.ts'),
  186. 'export function target() { return 2; }\n'
  187. );
  188. cg = CodeGraph.initSync(testDir);
  189. await cg.indexAll();
  190. // Re-queue A's refs then B's, in that order.
  191. await interruptAfterExtraction('src/a.ts');
  192. await interruptAfterExtraction('src/b.ts');
  193. expect(cg.getPendingReferenceCount()).toBeGreaterThan(0);
  194. // Batch size 2 puts only A's unresolvable refs in the first batch.
  195. // The old early break ended the whole run there, leaving B's ref an
  196. // orphan even though the batch's rows WERE consumed (progress).
  197. const resolver = (cg as unknown as { resolver: { resolveAndPersistBatched(p?: unknown, b?: number): Promise<unknown> } }).resolver;
  198. await resolver.resolveAndPersistBatched(undefined, 2);
  199. expect(cg.getPendingReferenceCount()).toBe(0);
  200. const target = findMethod('target');
  201. expect(callerFiles(target)).toContain('src/b.ts');
  202. });
  203. });
  204. });