1
0

object-literal-methods.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  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. const fetchUser = result.nodes.find((n) => n.name === 'fetchUser')!;
  54. const fetchUserRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fetchUser.id);
  55. // `get().reset()` keeps its call receiver (#1683): the ref is the chain
  56. // `get().reset`, which the resolver binds to the store's own `reset`.
  57. expect(fetchUserRefs.map((r) => r.referenceName)).toContain('get().reset');
  58. expect(fetchUserRefs.map((r) => r.referenceName)).not.toContain('reset');
  59. // The action's body wasn't mis-attributed to the file scope (the reason we
  60. // skip the generic body-visit for the store-factory call).
  61. const fileNode = result.nodes.find((n) => n.kind === 'file')!;
  62. const fileRefs = result.unresolvedReferences.filter((r) => r.fromNodeId === fileNode.id);
  63. expect(fileRefs.map((r) => r.referenceName)).not.toContain('reset');
  64. });
  65. it('extracts actions through a middleware wrapper (create(persist(...)))', () => {
  66. const code = `
  67. import { create } from 'zustand'
  68. import { persist } from 'zustand/middleware'
  69. export const useCounter = create(
  70. persist(
  71. (set, get) => ({
  72. value: 0,
  73. increment: () => set({ value: get().value + 1 }),
  74. }),
  75. { name: 'counter' }
  76. )
  77. )
  78. `;
  79. const result = extractFromSource('counter.ts', code);
  80. const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  81. expect(fnNames).toContain('increment');
  82. });
  83. it('extracts actions when the initializer returns via a block (=> { return {...} })', () => {
  84. const code = `
  85. import { create } from 'zustand'
  86. export const useThing = create((set) => {
  87. const initial = 0
  88. return {
  89. value: initial,
  90. bump: () => set({ value: 1 }),
  91. }
  92. })
  93. `;
  94. const result = extractFromSource('thing.ts', code);
  95. const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  96. expect(fnNames).toContain('bump');
  97. });
  98. it('does NOT extract methods from a non-exported call-wrapped object (noise gate)', () => {
  99. const code = `
  100. function wrap(f: any) { return f }
  101. const local = wrap(() => ({ shouldNotExtract: () => {} }))
  102. `;
  103. const result = extractFromSource('inline.ts', code);
  104. const names = result.nodes.map((n) => n.name);
  105. expect(names).not.toContain('shouldNotExtract');
  106. });
  107. it('still extracts the existing direct-object shape (export const actions = {...})', () => {
  108. const code = `
  109. export const actions = {
  110. load: async () => { helper() },
  111. }
  112. function helper() {}
  113. `;
  114. const result = extractFromSource('actions.ts', code);
  115. const fnNames = result.nodes.filter((n) => n.kind === 'function').map((n) => n.name);
  116. expect(fnNames).toContain('load');
  117. });
  118. });
  119. describe('object-literal method resolution (end-to-end)', () => {
  120. let tmpDir: string | undefined;
  121. afterEach(() => {
  122. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  123. tmpDir = undefined;
  124. });
  125. it('resolves callers of store actions across files (destructured + chained getState())', async () => {
  126. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-store-'));
  127. fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"name":"t","dependencies":{"zustand":"^4"}}\n');
  128. fs.writeFileSync(
  129. path.join(tmpDir, 'store.ts'),
  130. `import { create } from 'zustand'\n` +
  131. `interface S { fetchUser(): Promise<void>; reset(): void }\n` +
  132. `export const useStore = create<S>((set, get) => ({\n` +
  133. ` fetchUser: async () => { get().reset() },\n` +
  134. ` reset: () => set({}),\n` +
  135. `}))\n`
  136. );
  137. fs.writeFileSync(
  138. path.join(tmpDir, 'caller.ts'),
  139. `import { useStore } from './store'\n` +
  140. `export async function loginFlow() {\n` +
  141. ` const { fetchUser } = useStore.getState()\n` +
  142. ` await fetchUser()\n` +
  143. `}\n` +
  144. `export function hardReset() {\n` +
  145. ` useStore.getState().reset()\n` +
  146. `}\n`
  147. );
  148. const cg = CodeGraph.initSync(tmpDir);
  149. await cg.indexAll();
  150. const fns = cg.getNodesByKind('function');
  151. const fetchUser = fns.find((n) => n.name === 'fetchUser' && n.filePath.endsWith('store.ts'));
  152. const reset = fns.find((n) => n.name === 'reset' && n.filePath.endsWith('store.ts'));
  153. expect(fetchUser).toBeDefined();
  154. expect(reset).toBeDefined();
  155. // Destructured-then-bare call: loginFlow -> fetchUser
  156. const fetchUserCallers = cg.getCallers(fetchUser!.id).map((c) => c.node.name);
  157. expect(fetchUserCallers).toContain('loginFlow');
  158. // Chained getState() call: hardReset -> reset, AND in-store sibling: fetchUser -> reset
  159. const resetCallers = cg.getCallers(reset!.id).map((c) => c.node.name);
  160. expect(resetCallers).toContain('hardReset');
  161. expect(resetCallers).toContain('fetchUser');
  162. cg.close();
  163. });
  164. });