object-literal-methods.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. /**
  2. * Object-literal method extraction (general AST rule).
  3. *
  4. * The extractor pulls function-valued properties out of an object literal that
  5. * is the value of an exported const — either DIRECTLY
  6. * (`export const actions = { foo: () => {} }`) or RETURNED by an initializer
  7. * call (`export const useStore = create((set, get) => ({ foo: () => {} }))`,
  8. * incl. middleware wrappers). This makes store actions (Zustand/Redux/Pinia/
  9. * MobX/handler maps) real nodes, so `codegraph_node`/`callers` on them resolve
  10. * instead of returning "not found" and forcing the agent to Read the store.
  11. *
  12. * Keyed purely on AST shape — no library names in the implementation — so any
  13. * same-shaped store is covered. Resolution then falls out of the existing
  14. * exact-name matcher: every call form (`const {foo}=useStore.getState(); foo()`,
  15. * `useStore.getState().foo()`, in-store `get().foo()`) reduces to a bare `foo`
  16. * call that resolves to the action node once it exists.
  17. */
  18. import { describe, it, expect, beforeAll, afterEach } from 'vitest';
  19. import * as fs from 'fs';
  20. import * as path from 'path';
  21. import * as os from 'os';
  22. import { CodeGraph } from '../src';
  23. import { extractFromSource } from '../src/extraction';
  24. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  25. beforeAll(async () => {
  26. await initGrammars();
  27. await loadAllGrammars();
  28. });
  29. describe('object-literal method extraction', () => {
  30. it('extracts Zustand store actions (object returned by create()) as function nodes', () => {
  31. const code = `
  32. import { create } from 'zustand'
  33. interface Store {
  34. count: number
  35. fetchUser(): Promise<void>
  36. switchOrganization(id: string): Promise<void>
  37. reset(): void
  38. }
  39. export const useStore = create<Store>((set, get) => ({
  40. count: 0,
  41. fetchUser: async () => { await get().reset() },
  42. switchOrganization: async (id: string) => { set({ count: 1 }) },
  43. reset: () => set({ count: 0 }),
  44. }))
  45. `;
  46. const result = extractFromSource('store.ts', code);
  47. const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  48. expect(fnNames).toContain('fetchUser');
  49. expect(fnNames).toContain('switchOrganization');
  50. expect(fnNames).toContain('reset');
  51. // Each action's body was walked: fetchUser references its sibling `reset`,
  52. // so an in-store calls edge will resolve once the pipeline runs.
  53. // By KIND as well as name: the fixture's `Store` interface declares a
  54. // `fetchUser` too, and since #1638 that signature is a node of its own —
  55. // one that appears FIRST in the file, so a name-only lookup finds the
  56. // declaration and reads its return type where the action's body was meant.
  57. const fetchUser = result.nodes.find((n) => n.kind === 'function' && n.name === 'fetchUser')!;
  58. const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id);
  59. // `get().reset()` keeps its call receiver (#1683): the ref is the chain
  60. // `get().reset`, which the resolver binds to the store's own `reset`.
  61. expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get().reset');
  62. expect(fetchUserRefs.map((r) => r.referenceName)).not.toContain('reset');
  63. // The action's body wasn't mis-attributed to the file scope (the reason we
  64. // skip the generic body-visit for the store-factory call).
  65. const fileNode = result.nodes.find((n) => n.kind === 'file')!;
  66. const fileRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fileNode.id);
  67. expect(fileRefs.map((r) => r.referenceName)).not.toContain('reset');
  68. });
  69. it('extracts actions through a middleware wrapper (create(persist(...)))', () => {
  70. const code = `
  71. import { create } from 'zustand'
  72. import { persist } from 'zustand/middleware'
  73. export const useCounter = create(
  74. persist(
  75. (set, get) => ({
  76. value: 0,
  77. increment: () => set({ value: get().value + 1 }),
  78. }),
  79. { name: 'counter' }
  80. )
  81. )
  82. `;
  83. const result = extractFromSource('counter.ts', code);
  84. const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  85. expect(fnNames).toContain('increment');
  86. });
  87. it('extracts actions when the initializer returns via a block (=> { return {...} })', () => {
  88. const code = `
  89. import { create } from 'zustand'
  90. export const useThing = create((set) => {
  91. const initial = 0
  92. return {
  93. value: initial,
  94. bump: () => set({ value: 1 }),
  95. }
  96. })
  97. `;
  98. const result = extractFromSource('thing.ts', code);
  99. const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  100. expect(fnNames).toContain('bump');
  101. });
  102. it('does NOT extract methods from a non-exported call-wrapped object (noise gate)', () => {
  103. const code = `
  104. function wrap(f: any) { return f }
  105. const local = wrap(() => ({ shouldNotExtract: () => {} }))
  106. `;
  107. const result = extractFromSource('inline.ts', code);
  108. const names = result.nodes.map((n) => n.name);
  109. expect(names).not.toContain('shouldNotExtract');
  110. });
  111. it('still extracts the existing direct-object shape (export const actions = {...})', () => {
  112. const code = `
  113. export const actions = {
  114. load: async () => { helper() },
  115. }
  116. function helper() {}
  117. `;
  118. const result = extractFromSource('actions.ts', code);
  119. const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  120. expect(fnNames).toContain('load');
  121. });
  122. });
  123. describe('object-literal method resolution (end-to-end)', () => {
  124. let tmpDir: string | undefined;
  125. afterEach(() => {
  126. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  127. tmpDir = undefined;
  128. });
  129. it('resolves callers of store actions across files (destructured + chained getState())', async () => {
  130. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-store-'));
  131. fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"t","dependencies":{"zustand":"^4"}}\n');
  132. fs.writeFileSync(
  133. path.join(tmpDir, 'store.ts'),
  134. `import { create } from 'zustand'\n` +
  135. `interface S { fetchUser(): Promise<void>; reset(): void }\n` +
  136. `export const useStore = create<S>((set, get) => ({\n` +
  137. ` fetchUser: async () => { get().reset() },\n` +
  138. ` reset: () => set({}),\n` +
  139. `}))\n`
  140. );
  141. fs.writeFileSync(
  142. path.join(tmpDir, 'caller.ts'),
  143. `import { useStore } from './store'\n` +
  144. `export async function loginFlow() {\n` +
  145. ` const { fetchUser } = useStore.getState()\n` +
  146. ` await fetchUser()\n` +
  147. `}\n` +
  148. `export function hardReset() {\n` +
  149. ` useStore.getState().reset()\n` +
  150. `}\n`
  151. );
  152. const cg = CodeGraph.initSync(tmpDir);
  153. await cg.indexAll();
  154. const fns = cg.getNodesByKind('function');
  155. const fetchUser = fns.find((n) => n.name === 'fetchUser' && n.filePath.endsWith('store.ts'));
  156. const reset = fns.find((n) => n.name === 'reset' && n.filePath.endsWith('store.ts'));
  157. expect(fetchUser).toBeDefined();
  158. expect(reset).toBeDefined();
  159. // Destructured-then-bare call: loginFlow -> fetchUser
  160. const fetchUserCallers = cg.getCallers(fetchUser!.id).map((c) => c.node.name);
  161. expect(fetchUserCallers).toContain('loginFlow');
  162. // Chained getState() call: hardReset -> reset, AND in-store sibling: fetchUser -> reset
  163. const resetCallers = cg.getCallers(reset!.id).map((c) => c.node.name);
  164. expect(resetCallers).toContain('hardReset');
  165. expect(resetCallers).toContain('fetchUser');
  166. cg.close();
  167. });
  168. });