sync-rebuild-convergence.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. /**
  2. * Incremental sync must converge to a full rebuild (CG-33).
  3. *
  4. * A long-lived, auto-synced index silently diverged from a clean rebuild of the
  5. * identical tree: 4.3% of distinct edges wrong, in BOTH directions, on
  6. * codegraph's own repo. Two mechanisms, both exercised here:
  7. *
  8. * 1. Resolution binds a reference to one of the same-named definitions
  9. * PROJECT-WIDE, so adding or removing a definition changes the answer for
  10. * references in files the sync never touches. Those references resolved once
  11. * and their rows were deleted, so nothing revisited them — the index kept an
  12. * answer that was only correct against an older graph.
  13. * 2. When nothing disambiguated the candidates, the winner was whichever row
  14. * the index scan reached first — i.e. the order files were WRITTEN. A full
  15. * index writes in scan order; a sync appends each file as it changes, so the
  16. * same tree resolved differently depending on how the index was built.
  17. *
  18. * The assertions here compare the whole edge SET, never counts: the divergence
  19. * is bidirectional and nets out of a total (raw rows differed by 0.7% while
  20. * 4.3% of edges were wrong), so a count check passes on a broken index.
  21. */
  22. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  23. import * as fs from 'fs';
  24. import * as path from 'path';
  25. import * as os from 'os';
  26. import CodeGraph from '../src/index';
  27. import { createDatabase } from '../src/db/sqlite-adapter';
  28. describe('Incremental sync converges to a full rebuild (CG-33)', () => {
  29. let testDir: string;
  30. let cg: CodeGraph;
  31. const write = (rel: string, content: string) => {
  32. const full = path.join(testDir, rel);
  33. fs.mkdirSync(path.dirname(full), { recursive: true });
  34. fs.writeFileSync(full, content);
  35. };
  36. /**
  37. * Every edge as a `source|target|kind` triple, read from the database with a
  38. * second read-only connection. Node ids are `sha256(filePath:kind:name:line)`,
  39. * so for an identical tree they are identical across a sync and a rebuild —
  40. * which is what makes the two sets directly comparable.
  41. */
  42. const edgeSet = (): Set<string> => {
  43. const { db } = createDatabase(path.join(testDir, '.codegraph', 'codegraph.db'), { readOnly: true });
  44. try {
  45. const rows = db.prepare('SELECT source, target, kind FROM edges').all() as Array<{
  46. source: string;
  47. target: string;
  48. kind: string;
  49. }>;
  50. return new Set(rows.map((r) => `${r.source}|${r.target}|${r.kind}`));
  51. } finally {
  52. db.close();
  53. }
  54. };
  55. /** Human-readable diff, so a failure names the edges instead of just a count. */
  56. const describeDiff = (synced: Set<string>, rebuilt: Set<string>): string => {
  57. const missing = [...rebuilt].filter((e) => !synced.has(e));
  58. const stale = [...synced].filter((e) => !rebuilt.has(e));
  59. return `missing from synced: ${missing.length}, stale in synced: ${stale.length}`;
  60. };
  61. /**
  62. * Rebuild the index from scratch over the CURRENT tree and return its edge
  63. * set. `indexAll` recreates the database file, so this is the same ground
  64. * truth a user gets from `codegraph index`.
  65. */
  66. const rebuildEdgeSet = async (): Promise<Set<string>> => {
  67. await cg.indexAll();
  68. return edgeSet();
  69. };
  70. beforeEach(() => {
  71. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg33-'));
  72. });
  73. afterEach(() => {
  74. cg?.destroy();
  75. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  76. });
  77. /**
  78. * The originating shape. `caller.ts` calls `pct` with no import, so it binds
  79. * by name; at index time `zeta.ts` is the only definition. A later sync adds
  80. * `alpha.ts`, which sorts FIRST and is therefore the rebuild's answer — but
  81. * `caller.ts` never changes, so nothing re-resolves it.
  82. */
  83. it('rebinds references in UNCHANGED files when a sync adds a competing definition', async () => {
  84. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  85. write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  86. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  87. await cg.indexAll();
  88. write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  89. const result = await cg.sync();
  90. expect(result.filesAdded).toBe(1);
  91. expect(result.definitionDelta).toContain('pct');
  92. const synced = edgeSet();
  93. const rebuilt = await rebuildEdgeSet();
  94. expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
  95. });
  96. /**
  97. * The mirror direction: removing a definition narrows the candidate set too,
  98. * so the delta must include names the sync DROPPED, not just names it added.
  99. *
  100. * This one already converged before the fix — a removal cascades the edge
  101. * away and the #1240 removal path resurrects it, so the reference gets
  102. * re-resolved for free. It is here as a standing guard on the invariant, and
  103. * because the removal half of the delta has no other coverage: an
  104. * implementation that only sampled post-sync names would still pass every
  105. * other test in this file.
  106. */
  107. it('rebinds references in UNCHANGED files when a sync removes a competing definition', async () => {
  108. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  109. write('src/alpha.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  110. write('src/zeta.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  111. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  112. await cg.indexAll();
  113. fs.rmSync(path.join(testDir, 'src', 'alpha.ts'));
  114. const result = await cg.sync();
  115. expect(result.filesRemoved).toBe(1);
  116. const synced = edgeSet();
  117. const rebuilt = await rebuildEdgeSet();
  118. expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
  119. });
  120. /**
  121. * The delta must be computed per FILE. Comparing one name set across the whole
  122. * changed batch cancels a name that is added in one changed file while another
  123. * changed file already defined it — which is precisely the shape a commit that
  124. * splits a module out has, and it was the largest residual class in the first
  125. * measurement of this fix.
  126. */
  127. it('flags a name added in one changed file even when another changed file already defines it', async () => {
  128. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  129. write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\nexport function keep(): number {\n return 0;\n}\n`);
  130. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  131. await cg.indexAll();
  132. // One commit: a NEW file gains `pct`, and the file that already had `pct`
  133. // is edited too (so a batch-wide name set would see `pct` on both sides).
  134. write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  135. write('src/zeta.ts', `export function pct(n: number): number {\n return n + 1;\n}\nexport function keep(): number {\n return 0;\n}\n`);
  136. const result = await cg.sync();
  137. expect(result.definitionDelta).toContain('pct');
  138. const synced = edgeSet();
  139. const rebuilt = await rebuildEdgeSet();
  140. expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
  141. });
  142. /**
  143. * The realistic case the issue was filed from: many edits driven through sync
  144. * one after another, the way a watcher or a `git pull` applies them. Drift
  145. * accumulated across syncs, so a single-edit test would not have caught it.
  146. */
  147. it('stays converged across a sequence of adds, edits, renames and deletes', async () => {
  148. write('src/caller.ts', `export function run(): number {\n return pct(1) + fmt(2) + collect(3);\n}\n`);
  149. write('src/util/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  150. write('src/util/omega.ts', `export function fmt(n: number): number {\n return n;\n}\n`);
  151. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  152. await cg.indexAll();
  153. // 1. add a competing `pct` that sorts before the existing one
  154. write('src/util/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  155. await cg.sync();
  156. // 2. body-only edit — must produce NO definition delta, so the common sync
  157. // pays nothing for this machinery
  158. write('src/util/alpha.ts', `export function pct(n: number): number {\n return n * 3;\n}\n`);
  159. const bodyOnly = await cg.sync();
  160. expect(bodyOnly.filesModified).toBe(1);
  161. expect(bodyOnly.definitionDelta).toBeUndefined();
  162. // 3. a rename: `fmt` moves out of omega.ts into a file that sorts first
  163. write('src/util/omega.ts', `export function other(n: number): number {\n return n;\n}\n`);
  164. write('src/util/beta.ts', `export function fmt(n: number): number {\n return n;\n}\n`);
  165. await cg.sync();
  166. // 4. a symbol appears for a reference that never resolved at all
  167. write('src/util/gamma.ts', `export function collect(n: number): number {\n return n;\n}\n`);
  168. await cg.sync();
  169. // 5. delete the current `pct` winner, so the reference must fall back...
  170. fs.rmSync(path.join(testDir, 'src', 'util', 'alpha.ts'));
  171. await cg.sync();
  172. // 6. ...and then a later sync introduces a new winner ahead of it again.
  173. // Ending here rather than on the delete matters: after the delete the
  174. // binding happens to land back where it started, which a broken index
  175. // also reaches. The final state must be one only re-resolution reaches.
  176. write('src/util/aaa.ts', `export function pct(n: number): number {\n return n * 5;\n}\n`);
  177. await cg.sync();
  178. const synced = edgeSet();
  179. expect(synced.size).toBeGreaterThan(0);
  180. const rebuilt = await rebuildEdgeSet();
  181. expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
  182. });
  183. /**
  184. * Guards the escape hatch itself: with the rebind pass off, the same sequence
  185. * must still produce a structurally sound index (no lost or orphaned edges) —
  186. * just a drifted one. If this ever fails, the pass is doing something the
  187. * kill switch cannot undo.
  188. */
  189. it('CODEGRAPH_NO_REBIND=1 disables the pass without corrupting the index', async () => {
  190. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  191. write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  192. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  193. await cg.indexAll();
  194. const before = edgeSet();
  195. process.env.CODEGRAPH_NO_REBIND = '1';
  196. try {
  197. write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  198. await cg.sync();
  199. } finally {
  200. delete process.env.CODEGRAPH_NO_REBIND;
  201. }
  202. const after = edgeSet();
  203. // Every edge that existed before is still there — the pass is the only
  204. // thing that would have re-opened them, and it did not run.
  205. for (const edge of before) expect(after.has(edge)).toBe(true);
  206. });
  207. });
  208. /**
  209. * Resolution's candidate order must be a property of the CODE, not of the order
  210. * rows were written. This is the half of CG-33 that a re-resolution pass alone
  211. * cannot fix: without it, re-resolving a reference against the very same graph
  212. * can still pick a different winner than a rebuild does.
  213. */
  214. describe('Same-name candidate order is content-derived, not insertion-derived (CG-33)', () => {
  215. let testDir: string;
  216. let cg: CodeGraph;
  217. afterEach(() => {
  218. cg?.destroy();
  219. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  220. });
  221. it('getNodesByName orders by (file_path, start_line) even when rows were written in another order', async () => {
  222. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg33-order-'));
  223. fs.mkdirSync(path.join(testDir, 'src'), { recursive: true });
  224. fs.writeFileSync(path.join(testDir, 'src', 'mid.ts'), `export function pad(): void {}\nexport function dup(): number {\n return 2;\n}\n`);
  225. fs.writeFileSync(path.join(testDir, 'src', 'zeta.ts'), `export function dup(): number {\n return 1;\n}\n`);
  226. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  227. await cg.indexAll();
  228. // A sync APPENDS this file's nodes, so `alpha.ts` gets the highest rowids
  229. // despite sorting first — exactly the divergence a full index never has,
  230. // and the reason candidate order cannot come from the physical row order.
  231. fs.writeFileSync(path.join(testDir, 'src', 'alpha.ts'), `export function dup(): number {\n return 3;\n}\n`);
  232. await cg.sync();
  233. const keys = cg.getNodesByName('dup').map((n) => `${n.filePath}:${String(n.startLine).padStart(6, '0')}`);
  234. expect(keys.length).toBeGreaterThanOrEqual(3);
  235. expect(keys).toEqual([...keys].sort());
  236. expect(keys[0]).toContain('src/alpha.ts');
  237. });
  238. });