orphaned-refs-sweep.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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. import { createDatabase } from '../src/db/sqlite-adapter';
  21. import type { ReferenceResolver } from '../src/resolution';
  22. describe('Orphaned refs sweep (#1187)', () => {
  23. let testDir: string;
  24. let cg: CodeGraph;
  25. beforeEach(() => {
  26. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-orphan-sweep-'));
  27. });
  28. afterEach(() => {
  29. if (cg) {
  30. cg.destroy();
  31. }
  32. if (fs.existsSync(testDir)) {
  33. fs.rmSync(testDir, { recursive: true, force: true });
  34. }
  35. });
  36. /** Distinct files with a `calls` edge into the node. */
  37. function callerFiles(target: { id: string }): string[] {
  38. return [...new Set(cg.getCallers(target.id).map((c) => c.node.filePath))].sort();
  39. }
  40. /**
  41. * Leave `relPath` in the exact on-disk state a resolution pass killed
  42. * mid-run leaves behind: content re-extracted (nodes + refs re-inserted,
  43. * old edges cascade-deleted, content hash stamped current) but resolution
  44. * never run. The content tweak is needed because re-extraction of
  45. * byte-identical content is a no-op; the hash stamp means a later sync
  46. * sees NO changed files.
  47. */
  48. async function interruptAfterExtraction(relPath: string): Promise<void> {
  49. fs.appendFileSync(path.join(testDir, relPath), '\n// interrupted-run edit\n');
  50. await cg.indexFiles([relPath]);
  51. }
  52. function findMethod(name: string) {
  53. const hit = cg
  54. .searchNodes(name)
  55. .find((r) => (r.node.kind === 'method' || r.node.kind === 'function') && r.node.name === name);
  56. expect(hit, `expected an indexed definition of ${name}`).toBeDefined();
  57. return hit!.node;
  58. }
  59. // Compare call sites and resolution evidence, not just edge counts: a
  60. // recovery can also silently downgrade confidence without losing a row.
  61. function graphSnapshot() {
  62. const { db } = createDatabase(path.join(testDir, '.codegraph', 'codegraph.db'), { readOnly: true });
  63. try {
  64. const sorted = (sql: string) => db.prepare(sql).all().map((row) => JSON.stringify(row)).sort();
  65. return {
  66. nodes: sorted('SELECT id, kind, name, qualified_name, file_path FROM nodes'),
  67. edges: sorted('SELECT source, target, kind, line, col, metadata, provenance FROM edges'),
  68. refs: sorted('SELECT from_node_id, reference_name, reference_kind, line, col, file_path, language, status FROM unresolved_refs'),
  69. };
  70. } finally {
  71. db.close();
  72. }
  73. }
  74. describe('recovery has clean-index resolution parity (#1577)', () => {
  75. it('persists prerequisites before calls even when the orphan order is reversed', async () => {
  76. fs.writeFileSync(path.join(testDir, 'aTypes.java'), [
  77. 'class Base { void draw() {} }',
  78. 'class Child extends Base {}',
  79. 'class Decoy { void draw() {} }',
  80. ].join('\n'));
  81. // Put the caller beyond the first clean-index batch. Recovery below
  82. // queues that same caller FIRST and its inheritance prerequisite LAST.
  83. fs.writeFileSync(path.join(testDir, 'bPadding.java'),
  84. 'class Padding { void noop() {\n' + 'externalCall();\n'.repeat(5100) + '} }\n');
  85. fs.writeFileSync(path.join(testDir, 'zCaller.java'),
  86. 'class Caller { void run(Child child) { child.draw(); } }\n');
  87. cg = CodeGraph.initSync(testDir);
  88. await cg.indexAll();
  89. const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === 'Base::draw')!;
  90. expect(callerFiles(target)).toEqual(['zCaller.java']);
  91. const clean = graphSnapshot();
  92. for (const file of ['zCaller.java', 'bPadding.java', 'aTypes.java']) {
  93. await interruptAfterExtraction(file);
  94. }
  95. cg.destroy();
  96. cg = CodeGraph.openSync(testDir);
  97. expect(cg.getPendingReferenceCount()).toBeGreaterThan(5000);
  98. const recovered = await cg.sync();
  99. expect(recovered.filesAdded + recovered.filesModified + recovered.filesRemoved).toBe(0);
  100. expect(cg.getPendingReferenceCount()).toBe(0);
  101. expect(callerFiles(target)).toEqual(['zCaller.java']);
  102. expect(graphSnapshot()).toEqual(clean);
  103. await cg.sync();
  104. expect(graphSnapshot()).toEqual(clean);
  105. }, 15000);
  106. it('recovers inherited callbacks when the process restarts before the deferred pass', async () => {
  107. fs.writeFileSync(path.join(testDir, 'form.ts'), [
  108. 'class Base { handleSubmit() {} }',
  109. 'class Unrelated { missingHandler() {} }',
  110. 'class Form extends Base {',
  111. ' wire() { bus.on("submit", this.handleSubmit); }',
  112. ' save() { bus.on("save", this.handleSubmit); }',
  113. ' confirm() { bus.on("confirm", this.handleSubmit); }',
  114. ' missing() { bus.on("missing", this.missingHandler); }',
  115. '}',
  116. ].join('\n'));
  117. cg = CodeGraph.initSync(testDir);
  118. await cg.indexAll();
  119. const target = findMethod('handleSubmit');
  120. expect(cg.getIncomingEdges(target.id).filter((e) => e.kind === 'references')).toHaveLength(3);
  121. const clean = graphSnapshot();
  122. await interruptAfterExtraction('form.ts');
  123. // Stop after the final batch has persisted, before the deferred
  124. // inherited-member pass runs. There are no later batches to hide the
  125. // bug: failed rows plus a lost in-memory queue used to look healthy.
  126. // One ref per batch also exercises consecutive all-deferred batches:
  127. // their intentionally pending rows must not trip the non-progress guard.
  128. const resolver = (cg as unknown as { resolver: ReferenceResolver }).resolver;
  129. await expect(resolver.resolveAndPersistBatched((current, total) => {
  130. if (current === total) throw new Error('interrupted before deferred resolution');
  131. }, 1)).rejects.toThrow('interrupted before deferred resolution');
  132. cg.destroy();
  133. cg = CodeGraph.openSync(testDir);
  134. await cg.sync();
  135. expect(cg.getIncomingEdges(target.id).filter((e) => e.kind === 'references')).toHaveLength(3);
  136. expect(cg.getIncomingEdges(findMethod('missingHandler').id).filter((e) => e.kind === 'references')).toEqual([]);
  137. expect(cg.getPendingReferenceCount()).toBe(0);
  138. expect(graphSnapshot()).toEqual(clean);
  139. await cg.sync();
  140. expect(graphSnapshot()).toEqual(clean);
  141. });
  142. });
  143. describe('sync() heals an interrupted resolution run', () => {
  144. beforeEach(async () => {
  145. // The #1187 shape: a concrete @Component class called through Spring
  146. // @Resource field injection from another package.
  147. const supportDir = path.join(testDir, 'src', 'support');
  148. const notifyDir = path.join(testDir, 'src', 'notify');
  149. fs.mkdirSync(supportDir, { recursive: true });
  150. fs.mkdirSync(notifyDir, { recursive: true });
  151. fs.writeFileSync(
  152. path.join(supportDir, 'MemberDescriptionSupport.java'),
  153. [
  154. 'package com.demo.support;',
  155. '',
  156. 'public class MemberDescriptionSupport {',
  157. ' public String getSuperVipName() {',
  158. ' return "SVIP";',
  159. ' }',
  160. '}',
  161. '',
  162. ].join('\n')
  163. );
  164. fs.writeFileSync(
  165. path.join(notifyDir, 'NotifyBuilder.java'),
  166. [
  167. 'package com.demo.notify;',
  168. '',
  169. 'import com.demo.support.MemberDescriptionSupport;',
  170. '',
  171. 'public class NotifyBuilder {',
  172. ' private MemberDescriptionSupport memberDescriptionSupport;',
  173. '',
  174. ' public String buildParams() {',
  175. ' return memberDescriptionSupport.getSuperVipName();',
  176. ' }',
  177. '}',
  178. '',
  179. ].join('\n')
  180. );
  181. cg = CodeGraph.initSync(testDir);
  182. await cg.indexAll();
  183. });
  184. it('resolves leftover refs on a sync with NO file changes', async () => {
  185. const target = findMethod('getSuperVipName');
  186. // Healthy baseline: the caller edge exists, no refs pending.
  187. expect(callerFiles(target)).toContain('src/notify/NotifyBuilder.java');
  188. expect(cg.getPendingReferenceCount()).toBe(0);
  189. // Simulate the interrupted run: re-extract the caller (cascade-deleting
  190. // its old nodes and edges, re-inserting its refs) and stop before
  191. // resolution — exactly the state a killed "Resolving refs" phase
  192. // leaves behind.
  193. await interruptAfterExtraction('src/notify/NotifyBuilder.java');
  194. expect(cg.getPendingReferenceCount()).toBeGreaterThan(0);
  195. expect(callerFiles(target)).not.toContain('src/notify/NotifyBuilder.java');
  196. // The file on disk is unchanged, so this sync re-extracts nothing —
  197. // pre-fix it returned without touching resolution and the edge stayed
  198. // missing forever.
  199. const result = await cg.sync();
  200. expect(result.filesAdded).toBe(0);
  201. expect(result.filesModified).toBe(0);
  202. expect(cg.getPendingReferenceCount()).toBe(0);
  203. expect(callerFiles(target)).toContain('src/notify/NotifyBuilder.java');
  204. });
  205. it('is idempotent: a second no-change sync stays clean', async () => {
  206. await interruptAfterExtraction('src/notify/NotifyBuilder.java');
  207. await cg.sync();
  208. const target = findMethod('getSuperVipName');
  209. const healed = callerFiles(target);
  210. const again = await cg.sync();
  211. expect(again.filesAdded + again.filesModified + again.filesRemoved).toBe(0);
  212. expect(cg.getPendingReferenceCount()).toBe(0);
  213. expect(callerFiles(target)).toEqual(healed);
  214. });
  215. });
  216. describe('completed passes consume every processed row', () => {
  217. it('resolveReferences() deletes unresolvable rows (parity with the batched path)', async () => {
  218. const srcDir = path.join(testDir, 'src');
  219. fs.mkdirSync(srcDir, { recursive: true });
  220. fs.writeFileSync(
  221. path.join(srcDir, 'app.ts'),
  222. [
  223. 'export function helper() { return 1; }',
  224. 'export function main() {',
  225. ' helper();',
  226. ' totallyUndefinedCall();', // resolves to nothing anywhere
  227. '}',
  228. '',
  229. ].join('\n')
  230. );
  231. cg = CodeGraph.initSync(testDir);
  232. await cg.indexAll();
  233. expect(cg.getPendingReferenceCount()).toBe(0);
  234. // Re-extract without resolving: both the resolvable helper() ref and
  235. // the unresolvable one are back in the table.
  236. await interruptAfterExtraction('src/app.ts');
  237. expect(cg.getPendingReferenceCount()).toBeGreaterThan(0);
  238. // The non-batched full pass (which also backs the git-scoped sync
  239. // path) must consume BOTH: pre-fix it deleted only resolved rows, so
  240. // unresolvable ones parked forever and defeated the orphan sweep's
  241. // "non-empty table means interrupted run" invariant.
  242. cg.resolveReferences();
  243. expect(cg.getPendingReferenceCount()).toBe(0);
  244. });
  245. it('batched resolution does not stop at an all-unresolvable batch', async () => {
  246. const srcDir = path.join(testDir, 'src');
  247. fs.mkdirSync(srcDir, { recursive: true });
  248. // File A: only unresolvable refs. Extracted first, so its rows sort
  249. // first and fill the whole first batch.
  250. fs.writeFileSync(
  251. path.join(srcDir, 'a.ts'),
  252. [
  253. 'export function a() {',
  254. ' ghostOne();',
  255. ' ghostTwo();',
  256. ' ghostThree();',
  257. '}',
  258. '',
  259. ].join('\n')
  260. );
  261. // File B: a resolvable ref whose rows sort after A's.
  262. fs.writeFileSync(
  263. path.join(srcDir, 'b.ts'),
  264. [
  265. "import { target } from './c';",
  266. 'export function b() { target(); }',
  267. '',
  268. ].join('\n')
  269. );
  270. fs.writeFileSync(
  271. path.join(srcDir, 'c.ts'),
  272. 'export function target() { return 2; }\n'
  273. );
  274. cg = CodeGraph.initSync(testDir);
  275. await cg.indexAll();
  276. // Re-queue A's refs then B's, in that order.
  277. await interruptAfterExtraction('src/a.ts');
  278. await interruptAfterExtraction('src/b.ts');
  279. expect(cg.getPendingReferenceCount()).toBeGreaterThan(0);
  280. // Batch size 2 puts only A's unresolvable refs in the first batch.
  281. // The old early break ended the whole run there, leaving B's ref an
  282. // orphan even though the batch's rows WERE consumed (progress).
  283. const resolver = (cg as unknown as { resolver: { resolveAndPersistBatched(p?: unknown, b?: number): Promise<unknown> } }).resolver;
  284. await resolver.resolveAndPersistBatched(undefined, 2);
  285. expect(cg.getPendingReferenceCount()).toBe(0);
  286. const target = findMethod('target');
  287. expect(callerFiles(target)).toContain('src/b.ts');
  288. });
  289. });
  290. });