sync-rebuild-convergence.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  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. * ---
  23. *
  24. * THIS SUITE MUST FAIL WITH `CODEGRAPH_NO_REBIND=1` (CG-35).
  25. *
  26. * That environment variable is the kill switch on the rebind half of the fix
  27. * (`src/index.ts`, guarding `resurrectStaleResolutionEdges`). The convergence
  28. * cases below are the only coverage that half has, so the check is the suite's
  29. * own regression test:
  30. *
  31. * CODEGRAPH_NO_REBIND=1 npx vitest run __tests__/sync-rebuild-convergence.test.ts
  32. *
  33. * must report failures, and an unset run must be green. If you change a case
  34. * here, re-run both. A version of this suite passed under the kill switch
  35. * because `rebuildEdgeSet` was not rebuilding anything — see the note there.
  36. */
  37. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  38. import * as fs from 'fs';
  39. import * as path from 'path';
  40. import * as os from 'os';
  41. import CodeGraph from '../src/index';
  42. import { createDatabase } from '../src/db/sqlite-adapter';
  43. describe('Incremental sync converges to a full rebuild (CG-33)', () => {
  44. let testDir: string;
  45. let cg: CodeGraph;
  46. const write = (rel: string, content: string) => {
  47. const full = path.join(testDir, rel);
  48. fs.mkdirSync(path.dirname(full), { recursive: true });
  49. fs.writeFileSync(full, content);
  50. };
  51. /**
  52. * Every edge as a `source|target|kind` triple, read from the database with a
  53. * second read-only connection. Node ids are `sha256(filePath:kind:name:line)`,
  54. * so for an identical tree they are identical across a sync and a rebuild —
  55. * which is what makes the two sets directly comparable.
  56. */
  57. const edgeSet = (): Set<string> => {
  58. const { db } = createDatabase(path.join(testDir, '.codegraph', 'codegraph.db'), { readOnly: true });
  59. try {
  60. const rows = db.prepare('SELECT source, target, kind FROM edges').all() as Array<{
  61. source: string;
  62. target: string;
  63. kind: string;
  64. }>;
  65. return new Set(rows.map((r) => `${r.source}|${r.target}|${r.kind}`));
  66. } finally {
  67. db.close();
  68. }
  69. };
  70. /**
  71. * Run `fn` against a second, WRITABLE connection to the same database. Used
  72. * by the two rule tests below to plant edge shapes the extractor cannot
  73. * produce on demand — an edge from an engine older than the refName stamp,
  74. * and a synthesized dispatch edge.
  75. */
  76. const withDb = <T>(fn: (db: ReturnType<typeof createDatabase>['db']) => T): T => {
  77. const { db } = createDatabase(path.join(testDir, '.codegraph', 'codegraph.db'));
  78. try {
  79. return fn(db);
  80. } finally {
  81. db.close();
  82. }
  83. };
  84. /** Human-readable diff, so a failure names the edges instead of just a count. */
  85. const describeDiff = (synced: Set<string>, rebuilt: Set<string>): string => {
  86. const missing = [...rebuilt].filter((e) => !synced.has(e));
  87. const stale = [...synced].filter((e) => !rebuilt.has(e));
  88. return `missing from synced: ${missing.length}, stale in synced: ${stale.length}`;
  89. };
  90. /**
  91. * Rebuild the index from scratch over the CURRENT tree and return its edge
  92. * set — the ground truth a user gets from `codegraph index`.
  93. *
  94. * It must go through `CodeGraph.recreate`, which is what the CLI's `index`
  95. * command does: it DELETES the database file and builds an empty one. Calling
  96. * `indexAll` on the live handle instead is not a rebuild at all — every file
  97. * hashes identical, so the store writes nothing (`nodesCreated: 0`), no
  98. * reference is re-created, and every existing edge survives untouched. The
  99. * comparison then reads the synced index against ITSELF and can never fail,
  100. * which is exactly how this suite passed with `CODEGRAPH_NO_REBIND=1` (CG-35).
  101. */
  102. const rebuildEdgeSet = async (): Promise<Set<string>> => {
  103. // Close the live handle first: `recreate` unlinks the database file, and a
  104. // held handle makes that EBUSY on Windows.
  105. cg.destroy();
  106. cg = await CodeGraph.recreate(testDir);
  107. await cg.indexAll();
  108. return edgeSet();
  109. };
  110. beforeEach(() => {
  111. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg33-'));
  112. });
  113. afterEach(() => {
  114. cg?.destroy();
  115. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  116. });
  117. /**
  118. * The originating shape. `caller.ts` calls `pct` with no import, so it binds
  119. * by name; at index time `zeta.ts` is the only definition. A later sync adds
  120. * `alpha.ts`, which sorts FIRST and is therefore the rebuild's answer — but
  121. * `caller.ts` never changes, so nothing re-resolves it.
  122. */
  123. it('rebinds references in UNCHANGED files when a sync adds a competing definition', async () => {
  124. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  125. write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  126. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  127. await cg.indexAll();
  128. write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  129. const result = await cg.sync();
  130. expect(result.filesAdded).toBe(1);
  131. expect(result.definitionDelta).toContain('pct');
  132. const synced = edgeSet();
  133. const rebuilt = await rebuildEdgeSet();
  134. expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
  135. });
  136. it('keeps one edge when re-resolution selects the same target', async () => {
  137. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  138. write('src/alpha.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  139. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  140. await cg.indexAll();
  141. // zeta.ts introduces a competing definition, so the existing edge is
  142. // reopened, but alpha.ts remains the deterministic first candidate.
  143. write('src/zeta.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  144. const result = await cg.sync();
  145. expect(result.definitionDelta).toContain('pct');
  146. const targets = withDb((db) =>
  147. (
  148. db
  149. .prepare(
  150. `SELECT target.file_path AS file
  151. FROM edges edge
  152. JOIN nodes source ON source.id = edge.source
  153. JOIN nodes target ON target.id = edge.target
  154. WHERE source.name = 'run'
  155. AND target.name = 'pct'
  156. AND edge.kind = 'calls'`
  157. )
  158. .all() as Array<{ file: string }>
  159. ).map((row) => row.file)
  160. );
  161. expect(targets).toEqual(['src/alpha.ts']);
  162. });
  163. it('rolls back edge deletion when requeueing its reference is interrupted', async () => {
  164. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  165. write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  166. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  167. await cg.indexAll();
  168. const originalEdge = withDb((db) => {
  169. const row = db
  170. .prepare(
  171. `SELECT edge.source, edge.target, edge.kind
  172. FROM edges edge
  173. JOIN nodes source ON source.id = edge.source
  174. JOIN nodes target ON target.id = edge.target
  175. WHERE source.name = 'run'
  176. AND target.name = 'pct'
  177. AND edge.kind = 'calls'`
  178. )
  179. .get() as { source: string; target: string; kind: string };
  180. db.exec(
  181. `CREATE TRIGGER interrupt_pct_requeue
  182. BEFORE INSERT ON unresolved_refs
  183. WHEN NEW.reference_name = 'pct'
  184. BEGIN
  185. SELECT RAISE(ABORT, 'forced rebind interruption');
  186. END;`
  187. );
  188. return `${row.source}|${row.target}|${row.kind}`;
  189. });
  190. write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  191. await expect(cg.sync()).rejects.toThrow(/forced rebind interruption/);
  192. // A failed requeue leaves the last committed graph answer untouched.
  193. expect(edgeSet().has(originalEdge)).toBe(true);
  194. const queued = withDb(
  195. (db) =>
  196. (
  197. db
  198. .prepare(
  199. `SELECT COUNT(*) AS count
  200. FROM unresolved_refs ref
  201. JOIN nodes source ON source.id = ref.from_node_id
  202. WHERE source.name = 'run' AND ref.reference_name = 'pct'`
  203. )
  204. .get() as { count: number }
  205. ).count
  206. );
  207. expect(queued).toBe(0);
  208. });
  209. /**
  210. * The mirror direction: removing a definition narrows the candidate set too,
  211. * so the delta must include names the sync DROPPED, not just names it added.
  212. *
  213. * This one already converged before the fix — a removal cascades the edge
  214. * away and the #1240 removal path resurrects it, so the reference gets
  215. * re-resolved for free. It is here as a standing guard on the invariant, and
  216. * because the removal half of the delta has no other coverage: an
  217. * implementation that only sampled post-sync names would still pass every
  218. * other test in this file.
  219. */
  220. it('rebinds references in UNCHANGED files when a sync removes a competing definition', async () => {
  221. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  222. write('src/alpha.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  223. write('src/zeta.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  224. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  225. await cg.indexAll();
  226. fs.rmSync(path.join(testDir, 'src', 'alpha.ts'));
  227. const result = await cg.sync();
  228. expect(result.filesRemoved).toBe(1);
  229. const synced = edgeSet();
  230. const rebuilt = await rebuildEdgeSet();
  231. expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
  232. });
  233. /**
  234. * The delta must be computed per FILE. Comparing one name set across the whole
  235. * changed batch cancels a name that is added in one changed file while another
  236. * changed file already defined it — which is precisely the shape a commit that
  237. * splits a module out has, and it was the largest residual class in the first
  238. * measurement of this fix.
  239. */
  240. it('flags a name added in one changed file even when another changed file already defines it', async () => {
  241. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  242. write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\nexport function keep(): number {\n return 0;\n}\n`);
  243. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  244. await cg.indexAll();
  245. // One commit: a NEW file gains `pct`, and the file that already had `pct`
  246. // is edited too (so a batch-wide name set would see `pct` on both sides).
  247. write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  248. write('src/zeta.ts', `export function pct(n: number): number {\n return n + 1;\n}\nexport function keep(): number {\n return 0;\n}\n`);
  249. const result = await cg.sync();
  250. expect(result.definitionDelta).toContain('pct');
  251. const synced = edgeSet();
  252. const rebuilt = await rebuildEdgeSet();
  253. expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
  254. });
  255. /**
  256. * The realistic case the issue was filed from: many edits driven through sync
  257. * one after another, the way a watcher or a `git pull` applies them. Drift
  258. * accumulated across syncs, so a single-edit test would not have caught it.
  259. */
  260. it('stays converged across a sequence of adds, edits, renames and deletes', async () => {
  261. write('src/caller.ts', `export function run(): number {\n return pct(1) + fmt(2) + collect(3);\n}\n`);
  262. write('src/util/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  263. write('src/util/omega.ts', `export function fmt(n: number): number {\n return n;\n}\n`);
  264. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  265. await cg.indexAll();
  266. // 1. add a competing `pct` that sorts before the existing one
  267. write('src/util/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  268. await cg.sync();
  269. // 2. body-only edit — must produce NO definition delta, so the common sync
  270. // pays nothing for this machinery
  271. write('src/util/alpha.ts', `export function pct(n: number): number {\n return n * 3;\n}\n`);
  272. const bodyOnly = await cg.sync();
  273. expect(bodyOnly.filesModified).toBe(1);
  274. expect(bodyOnly.definitionDelta).toBeUndefined();
  275. // 3. a rename: `fmt` moves out of omega.ts into a file that sorts first
  276. write('src/util/omega.ts', `export function other(n: number): number {\n return n;\n}\n`);
  277. write('src/util/beta.ts', `export function fmt(n: number): number {\n return n;\n}\n`);
  278. await cg.sync();
  279. // 4. a symbol appears for a reference that never resolved at all
  280. write('src/util/gamma.ts', `export function collect(n: number): number {\n return n;\n}\n`);
  281. await cg.sync();
  282. // 5. delete the current `pct` winner, so the reference must fall back...
  283. fs.rmSync(path.join(testDir, 'src', 'util', 'alpha.ts'));
  284. await cg.sync();
  285. // 6. ...and then a later sync introduces a new winner ahead of it again.
  286. // Ending here rather than on the delete matters: after the delete the
  287. // binding happens to land back where it started, which a broken index
  288. // also reaches. The final state must be one only re-resolution reaches.
  289. write('src/util/aaa.ts', `export function pct(n: number): number {\n return n * 5;\n}\n`);
  290. await cg.sync();
  291. const synced = edgeSet();
  292. expect(synced.size).toBeGreaterThan(0);
  293. const rebuilt = await rebuildEdgeSet();
  294. expect(describeDiff(synced, rebuilt)).toBe('missing from synced: 0, stale in synced: 0');
  295. });
  296. /**
  297. * The rebind pass DELETES an edge and re-inserts the reference behind it, so
  298. * it may only touch edges it can reconstruct. Two shapes it must leave alone,
  299. * both of which it would otherwise destroy permanently:
  300. *
  301. * - an edge with no `metadata.refName` — written by an engine older than the
  302. * stamp. Rebuilding a reference from the target's plain name would strip the
  303. * receiver context the original text carried (`h.greet` → `greet`);
  304. * - a synthesized dispatch edge (`provenance='heuristic'`), which is not
  305. * resolution output at all: nothing would re-create it, and the synthesizer
  306. * that wired it does not run again on this sync.
  307. *
  308. * Both are planted directly, since extraction cannot be asked to emit them.
  309. * The sync then changes the answer for `pct`, which is exactly the condition
  310. * that makes the pass want to re-open every edge targeting `pct`.
  311. */
  312. it('never deletes an edge it cannot reconstruct — no refName stamp, or synthesized', async () => {
  313. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  314. write('src/other.ts', `export function other(): number {\n return 0;\n}\n`);
  315. write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  316. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  317. await cg.indexAll();
  318. const planted = withDb((db) => {
  319. const pct = db.prepare("SELECT id FROM nodes WHERE name = 'pct'").get() as { id: string };
  320. const other = db.prepare("SELECT id FROM nodes WHERE name = 'other'").get() as { id: string };
  321. // 1. Strip the stamp off the real edge, leaving the rest of its metadata
  322. // intact — the shape an index built before the stamp existed has.
  323. db.prepare(
  324. `UPDATE edges SET metadata = json_remove(metadata, '$.refName')
  325. WHERE target = ? AND kind = 'calls'`
  326. ).run(pct.id);
  327. // 2. A synthesized edge that DOES carry a stamp, so only the provenance
  328. // rule can save it.
  329. db.prepare(
  330. `INSERT INTO edges (source, target, kind, metadata, line, col, provenance)
  331. VALUES (?, ?, 'calls', ?, 1, 0, 'heuristic')`
  332. ).run(other.id, pct.id, JSON.stringify({ refName: 'pct', synthesizedBy: 'cg35-test' }));
  333. return {
  334. unstamped: `${(db.prepare("SELECT source FROM edges WHERE target = ? AND provenance IS NULL AND kind = 'calls'").get(pct.id) as { source: string }).source}|${pct.id}|calls`,
  335. synthesized: `${other.id}|${pct.id}|calls`,
  336. };
  337. });
  338. const before = edgeSet();
  339. expect(before.has(planted.unstamped)).toBe(true);
  340. expect(before.has(planted.synthesized)).toBe(true);
  341. write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  342. const result = await cg.sync();
  343. expect(result.definitionDelta).toContain('pct');
  344. // Both survive: the pass considered them (their target is `pct`) and
  345. // declined. Drift is the acceptable outcome here; an edge that no pass can
  346. // ever restore is not.
  347. const after = edgeSet();
  348. expect(after.has(planted.unstamped)).toBe(true);
  349. expect(after.has(planted.synthesized)).toBe(true);
  350. });
  351. /**
  352. * The per-name ceiling in `getResolutionEdgesByTargetName` (500 by default).
  353. * Above it a name is generic — `push`, `get`, `join` — one new definition
  354. * won't flip most of its references, and rebinding an arbitrary subset would
  355. * manufacture wrong edges while costing the most work. It must DECLINE the
  356. * name outright, and declining must be lossless.
  357. *
  358. * The rare name in the same sync is the control: it proves the pass ran and
  359. * that the ceiling is what spared the generic one, not a dead rebind pass.
  360. */
  361. it('declines a name over the per-name ceiling instead of rebinding an arbitrary subset', async () => {
  362. // Must exceed the 500 default in getResolutionEdgesByTargetName.
  363. const OVER_CEILING = 501;
  364. const callers = Array.from(
  365. { length: OVER_CEILING },
  366. (_, i) => `export function hot${i}(): number {\n return push(${i});\n}\n`
  367. ).join('');
  368. write('src/hot.ts', callers);
  369. write('src/rare.ts', `export function rare(): number {\n return tug(1);\n}\n`);
  370. write(
  371. 'src/zzz_defs.ts',
  372. `export function push(n: number): number {\n return n;\n}\nexport function tug(n: number): number {\n return n;\n}\n`
  373. );
  374. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  375. await cg.indexAll();
  376. const targetsOf = (name: string): string[] =>
  377. withDb((db) =>
  378. (
  379. db
  380. .prepare(
  381. `SELECT t.file_path AS file FROM edges e
  382. JOIN nodes t ON t.id = e.target
  383. JOIN nodes s ON s.id = e.source
  384. WHERE t.name = ? AND e.kind = 'calls'`
  385. )
  386. .all(name) as Array<{ file: string }>
  387. ).map((r) => r.file)
  388. );
  389. expect(targetsOf('push')).toHaveLength(OVER_CEILING);
  390. expect(new Set(targetsOf('push'))).toEqual(new Set(['src/zzz_defs.ts']));
  391. expect(targetsOf('tug')).toEqual(['src/zzz_defs.ts']);
  392. // One sync adds a competing definition of BOTH names, in a file that sorts
  393. // first and is therefore the rebuild's answer for each.
  394. write(
  395. 'src/aaa.ts',
  396. `export function push(n: number): number {\n return n * 2;\n}\nexport function tug(n: number): number {\n return n * 2;\n}\n`
  397. );
  398. const result = await cg.sync();
  399. expect(result.definitionDelta).toContain('push');
  400. expect(result.definitionDelta).toContain('tug');
  401. // `push` is untouched — every edge still there, still on the old target.
  402. // This is knowingly divergent from a rebuild; see "Don't chase the
  403. // residual" in docs/benchmarks/index-drift-cg33.md.
  404. const pushTargets = targetsOf('push');
  405. expect(pushTargets).toHaveLength(OVER_CEILING);
  406. expect(new Set(pushTargets)).toEqual(new Set(['src/zzz_defs.ts']));
  407. // `tug` — the control — rebound.
  408. expect(targetsOf('tug')).toEqual(['src/aaa.ts']);
  409. });
  410. /**
  411. * Guards the escape hatch itself: with the rebind pass off, the same sequence
  412. * must still produce a structurally sound index (no lost or orphaned edges) —
  413. * just a drifted one. If this ever fails, the pass is doing something the
  414. * kill switch cannot undo.
  415. */
  416. it('CODEGRAPH_NO_REBIND=1 disables the pass without corrupting the index', async () => {
  417. write('src/caller.ts', `export function run(): number {\n return pct(1);\n}\n`);
  418. write('src/zeta.ts', `export function pct(n: number): number {\n return n;\n}\n`);
  419. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  420. await cg.indexAll();
  421. const before = edgeSet();
  422. process.env.CODEGRAPH_NO_REBIND = '1';
  423. try {
  424. write('src/alpha.ts', `export function pct(n: number): number {\n return n * 2;\n}\n`);
  425. await cg.sync();
  426. } finally {
  427. delete process.env.CODEGRAPH_NO_REBIND;
  428. }
  429. const after = edgeSet();
  430. // Every edge that existed before is still there — the pass is the only
  431. // thing that would have re-opened them, and it did not run.
  432. for (const edge of before) expect(after.has(edge)).toBe(true);
  433. });
  434. });
  435. /**
  436. * Resolution's candidate order must be a property of the CODE, not of the order
  437. * rows were written. This is the half of CG-33 that a re-resolution pass alone
  438. * cannot fix: without it, re-resolving a reference against the very same graph
  439. * can still pick a different winner than a rebuild does.
  440. */
  441. describe('Same-name candidate order is content-derived, not insertion-derived (CG-33)', () => {
  442. let testDir: string;
  443. let cg: CodeGraph;
  444. afterEach(() => {
  445. cg?.destroy();
  446. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  447. });
  448. it('getNodesByName orders by (file_path, start_line) even when rows were written in another order', async () => {
  449. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg33-order-'));
  450. fs.mkdirSync(path.join(testDir, 'src'), { recursive: true });
  451. fs.writeFileSync(path.join(testDir, 'src', 'mid.ts'), `export function pad(): void {}\nexport function dup(): number {\n return 2;\n}\n`);
  452. fs.writeFileSync(path.join(testDir, 'src', 'zeta.ts'), `export function dup(): number {\n return 1;\n}\n`);
  453. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  454. await cg.indexAll();
  455. // A sync APPENDS this file's nodes, so `alpha.ts` gets the highest rowids
  456. // despite sorting first — exactly the divergence a full index never has,
  457. // and the reason candidate order cannot come from the physical row order.
  458. fs.writeFileSync(path.join(testDir, 'src', 'alpha.ts'), `export function dup(): number {\n return 3;\n}\n`);
  459. await cg.sync();
  460. const keys = cg.getNodesByName('dup').map((n) => `${n.filePath}:${String(n.startLine).padStart(6, '0')}`);
  461. expect(keys.length).toBeGreaterThanOrEqual(3);
  462. expect(keys).toEqual([...keys].sort());
  463. expect(keys[0]).toContain('src/alpha.ts');
  464. });
  465. });