store-binding-cache.test.ts 3.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. import { afterEach, describe, expect, it } from 'vitest';
  2. import * as fs from 'node:fs';
  3. import * as os from 'node:os';
  4. import * as path from 'node:path';
  5. import { CodeGraph } from '../src';
  6. const projects: { dir: string; cg: CodeGraph }[] = [];
  7. afterEach(() => {
  8. for (const { dir, cg } of projects.splice(0)) {
  9. cg.close();
  10. fs.rmSync(dir, { recursive: true, force: true });
  11. }
  12. });
  13. function consumer(active: boolean): string {
  14. return `import { useStore as current } from './store';
  15. export function run() {
  16. const { reset } = ${active ? 'current.getState()' : 'external()'};
  17. reset();
  18. reset();
  19. }
  20. export function effects(client: any) {
  21. client.user.create();
  22. client?.user?.create();
  23. }
  24. `;
  25. }
  26. async function project(active: boolean) {
  27. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-store-cache-'));
  28. fs.writeFileSync(path.join(dir, 'store.ts'), `import { create } from 'zustand';
  29. export const useStore = create((set) => ({ reset: () => set({}) }));
  30. `);
  31. fs.writeFileSync(path.join(dir, 'decoy.ts'), 'export function reset() { return 99; }');
  32. fs.writeFileSync(path.join(dir, 'consumer.ts'), consumer(active));
  33. const cg = CodeGraph.initSync(dir);
  34. projects.push({ dir, cg });
  35. const result = await cg.indexAll();
  36. expect(result.success).toBe(true);
  37. expect(result.filesErrored).toBe(0);
  38. return { dir, cg };
  39. }
  40. function assertBindings(cg: CodeGraph, active: boolean) {
  41. const functions = cg.getNodesByKind('function');
  42. const run = functions.find(n => n.name === 'run' && n.filePath === 'consumer.ts')!;
  43. const action = functions.find(n => n.name === 'reset' && n.filePath === 'store.ts')!;
  44. const decoy = functions.find(n => n.name === 'reset' && n.filePath === 'decoy.ts')!;
  45. const targets = cg.getOutgoingEdges(run.id).filter(e => e.kind === 'calls').map(e => e.target);
  46. expect(targets.includes(action.id)).toBe(active);
  47. expect(targets).not.toContain(decoy.id);
  48. const pendingActions = cg.getUnresolvedReferencesFrom(run.id).filter(r => r.referenceName === 'reset');
  49. expect(pendingActions).toHaveLength(active ? 0 : 2);
  50. // Eligibility must not remove untyped qualified call-site evidence, including
  51. // repeated/optional chains, or turn it into a guessed edge.
  52. const effects = functions.find(n => n.name === 'effects' && n.filePath === 'consumer.ts')!;
  53. expect(cg.getOutgoingEdges(effects.id).filter(e => e.kind === 'calls')).toEqual([]);
  54. expect(cg.getUnresolvedReferencesFrom(effects.id).filter(r => r.referenceKind === 'calls')
  55. .map(r => [r.referenceName, r.line, r.column])).toEqual([
  56. ['client.user.create', 8, 2], ['client.user.create', 9, 2],
  57. ]);
  58. }
  59. describe('store eligibility cache across edits and resolver contexts', () => {
  60. it.each([false, true])('sync refreshes eligibility starting with getState=%s', async (initial) => {
  61. const { dir, cg } = await project(initial);
  62. assertBindings(cg, initial);
  63. // Reuse the same CodeGraph/resolver and path in both directions. A cached
  64. // negative must not mask a new store binding, and removing it must remove
  65. // both action edges while preserving unresolved call-site evidence.
  66. for (const active of [!initial, initial]) {
  67. fs.writeFileSync(path.join(dir, 'consumer.ts'), consumer(active));
  68. const result = await cg.sync();
  69. expect(result.filesModified).toBe(1);
  70. assertBindings(cg, active);
  71. }
  72. }, 60000);
  73. it('does not share eligibility between projects with the same relative file path', async () => {
  74. const absent = await project(false);
  75. const present = await project(true);
  76. assertBindings(absent.cg, false);
  77. assertBindings(present.cg, true);
  78. }, 60000);
  79. });