diff --git a/CHANGELOG.md b/CHANGELOG.md index 5773803..66f2f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- Vue store actions, mutations, and getters are now indexed as symbols you can find and read. Whether your store is **Vuex** (`mutations` / `actions` objects in a module) or **Pinia** — both the options form (`defineStore({ actions: { … } })`) and the setup form (`defineStore('id', () => { … })`, where actions are local functions) — each action, mutation, and getter is now a real node. So `codegraph search` finds `login` or `getSessionList`, and `codegraph_explore` / `codegraph_node` show its body and what it calls, instead of "not found" because the function only existed as an object-literal property. (Connecting a component's `dispatch('user/login')` / `store.action()` call through to the action is a separate follow-up.) - `codegraph_explore` now connects React data-fetching flows built on **RTK Query** (Redux Toolkit's `createApi`). An endpoint defined inside `createApi({ endpoints })` and the `useGetXQuery` / `useUpdateYMutation` hook it generates were both invisible to analysis — so "what does this component fetch?" or "where does `useGetThingQuery` get its data?" dead-ended, because the hook, the endpoint, and the component had nothing linking them. CodeGraph now indexes each endpoint and each generated hook as real symbols and wires the path `component → useGetXQuery → getX → queryFn`, so the flow resolves in one explore call instead of reading the API slice by hand. Both the arrow (`endpoints: build => ({ … })`) and method (`endpoints(builder) { return { … } }`) styles are recognized, along with the `useLazyGetXQuery` variant; hand-written hooks of a similar name are left untouched. - `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking. diff --git a/__tests__/vue-store-extraction.test.ts b/__tests__/vue-store-extraction.test.ts new file mode 100644 index 0000000..9fc2f9e --- /dev/null +++ b/__tests__/vue-store-extraction.test.ts @@ -0,0 +1,138 @@ +/** + * Vue store action/mutation/getter extraction (the foundation for finding and + * reading store logic — `codegraph_node login` / `getSessionList`). + * + * Vuex/Pinia define a store's callable surface as object-literal members nested + * under `actions`/`mutations`/`getters`, or as body-local consts in a Pinia setup + * store — none of which were extracted, so the symbols an agent looks for didn't + * exist as nodes. This covers the three dominant forms: + * - Vuex module: non-exported `const actions = {…}` / `const mutations = {…}`. + * - Pinia options: `defineStore({ actions: {…}, getters: {…} })`. + * - Pinia setup: `defineStore('id', () => { const foo = …; return { foo } })`. + * And the precision gate: a non-exported `const actions = {…}` in a file that + * isn't a Vue store contributes nothing. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; + +describe('vue store extraction', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vue-store-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + it('extracts Vuex module + Pinia options + Pinia setup store members as function nodes', async () => { + // Vuex MODULE form: non-exported `const mutations`/`const actions` collections, + // wired via a default export (element-admin style). Method shorthand + arrow pairs. + fs.writeFileSync( + path.join(dir, 'userModule.js'), + `import { persistToken } from './auth-utils'; +const state = { token: '' }; +const mutations = { + SET_TOKEN: (state, token) => { state.token = token; }, +}; +const actions = { + login({ commit }, info) { + persistToken(info.token); + }, + async logout({ commit }) { + commit('SET_TOKEN', ''); + }, +}; +export default { namespaced: true, state, mutations, actions }; +` + ); + fs.writeFileSync( + path.join(dir, 'auth-utils.js'), + `export function persistToken(token) { return token; } +` + ); + // Pinia OPTIONS form: actions + getters as object properties of a defineStore config. + fs.writeFileSync( + path.join(dir, 'authStore.ts'), + `import { defineStore } from 'pinia'; +export const useAuthStore = defineStore({ + id: 'auth', + state: () => ({ name: '' }), + getters: { + upperName: state => state.name.toUpperCase(), + }, + actions: { + async fetchMenu() { return loadMenu(); }, + setName(n: string) { this.name = n; }, + }, +}); +` + ); + // Pinia SETUP form: actions are body-local consts exposed via the return block. + fs.writeFileSync( + path.join(dir, 'chatStore.ts'), + `import { defineStore } from 'pinia'; +export const useChatStore = defineStore('chat', () => { + const list = reactive([]); + const getList = async () => { return fetchList(); }; + function pushItem(x) { list.push(x); } + return { list, getList, pushItem }; +}); +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const fn = (name: string) => + db.prepare(`SELECT count(*) c FROM nodes WHERE name = ? AND kind = 'function'`).get(name).c; + + // Vuex module: actions + mutations extracted. + expect(fn('login')).toBeGreaterThan(0); + expect(fn('logout')).toBeGreaterThan(0); + expect(fn('SET_TOKEN')).toBeGreaterThan(0); + // Pinia options: actions + getter extracted. + expect(fn('fetchMenu')).toBeGreaterThan(0); + expect(fn('setName')).toBeGreaterThan(0); + expect(fn('upperName')).toBeGreaterThan(0); + // Pinia setup: body-local actions extracted (and reachable via their bodies). + expect(fn('getList')).toBeGreaterThan(0); + expect(fn('pushItem')).toBeGreaterThan(0); + + // The extracted action spans its real body — `login`'s `persistToken(...)` + // call attributes to it (extraction, not the deferred dispatch synthesis). + const loginCalls = db + .prepare( + `SELECT t.name FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE s.name = 'login' AND e.kind = 'calls'` + ) + .all() + .map((r: any) => r.name); + expect(loginCalls).toContain('persistToken'); + + cg.close?.(); + }); + + it('does not extract a non-exported `const actions = {…}` outside a Vue store file', async () => { + // A plain module that happens to hold a non-exported `const actions` object of + // functions, but lacks any second Vue-store signal — the gate must not fire. + fs.writeFileSync( + path.join(dir, 'commands.js'), + `const actions = { + doThing() { return 1; }, + doOther() { return 2; }, +}; +export function run(key) { return actions[key](); } +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doThing'`).get().c).toBe(0); + expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doOther'`).get().c).toBe(0); + // The real exported function is still extracted normally. + expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'run' AND kind='function'`).get().c).toBeGreaterThan(0); + + cg.close?.(); + }); +}); diff --git a/docs/design/dispatch-synthesizer-backlog.md b/docs/design/dispatch-synthesizer-backlog.md index 85f5898..7480d95 100644 --- a/docs/design/dispatch-synthesizer-backlog.md +++ b/docs/design/dispatch-synthesizer-backlog.md @@ -56,7 +56,7 @@ Status legend (matches the playbook): ✅ done+validated · 🟡 shipped but und |---|---|---|---|---| | **Name→class registry / command bus** | any (TS/JS first) | object-literal registry `{key: Handler}` + computed-key dispatch `(new) reg[var](…)` | S (fan-out, `object-registry`) | ✅ **SHIPPED v1 (2026-06-20)** — `objectRegistryEdges`. Links each dispatcher fn → each registered handler's callable entry (a class's `execute`/run/handle method — preferring the method chained at the dispatch — or the function value). Precise on **xrengine** (CommandManager, 64 edges, class registry → `.execute`), **Prebid.js** (7: builder/consent/message dispatch, fn registry), **warp-drive** (1). **0 false positives** after: minified-file skip (avg line >200), **depth-aware** entry parse (top-level `key: Ident` only — method-shorthand/nested-object bodies don't leak), callable-only targets (no data `constant`), dynamic-dispatch gate. Handles constructor + field-initializer (`this.` normalized) forms. **Deferred (recall, documented):** assign-then-call (`const h=reg[k]; h()` — warp-drive's main `COMMANDS`), augmentation (`reg[k]=H` — Prebid single-entry), method-shorthand entry recall, and the **cross-file barrel-namespace** variant (trezor `getMethod`: `import * as M; M[method]→new` + computed dynamic import + camel↔Pascal — the hard tier, still 🔬). | | **RTK Query** | TS / Redux Toolkit | `createApi({ endpoints: b => ({ getX: b.query(...) }) })` → generated `useGetXQuery` hook → component; endpoint name ↔ hook name (`getX`↔`useGetXQuery`) is convention | X (extract endpoints) + S (endpoint→hook) | ✅ **SHIPPED (2026-06-20)** — `synthesizedBy:'rtk-query'`. **X:** extraction mints a function node per endpoint (named by its key, spanning the `queryFn`/`query` handler so its calls attribute; both `endpoints: b => ({…})` arrow and `endpoints(b){ return {…} }` method forms; a factory-handler endpoint `queryFn: makeFn(url)` falls back to a bare node spanning the builder call) **and** per generated-hook binding from `export const {…} = api` (carrying the sentinel signature `= RTK Query generated hook`). **S:** `rtkQueryEdges` bridges hook→same-file endpoint by the naming convention (strip `use` + optional `Lazy` + `Query`/`Mutation`, lc head). Component→hook is normal import/call resolution; hook→endpoint surfaces in explore as `dynamic: rtk query`. Validated **100% precision** (hooks == synth edges, **0 cross-file**) on **basetool** (small, 54 edges, both forms + factory fallback), **minusx-metabase** (small, 11), **shapeshift** (large, 13); **0** on the uwave-web control (no `createApi` → a complete no-op, 0 nodes/edges added). Sentinel gate correctly ignores hand-written look-alikes (shapeshift's `useFoxyQuery` is a real custom hook, never bridged). **Deferred:** cross-module `injectEndpoints` where the hook destructuring's RHS isn't the same bare api const (synth requires same-file endpoint). | -| **Vuex / Pinia** | Vue | `store.dispatch('ns/action')` / `commit('mutation')` → action/mutation by string key (namespaced) | S (string-keyed, like `event-emitter`) | ⬜ | +| **Vuex / Pinia** | Vue | `store.dispatch('ns/action')` / `commit('mutation')` → action/mutation by string key (namespaced); Pinia `useStore().action()` instance call | **X (extract collections) ✅ + S (dispatch bridge) ⬜** | 🟡 **EXTRACTION FOUNDATION SHIPPED (2026-06-20)** — store actions/mutations/getters are now nodes (`codegraph_node login`/`getSessionList` works). Corpus probe found this is **NOT one clean string-keyed shape** — it's ~5: **(1)** Vuex MODULE non-exported `const actions/mutations = {…}` (element-admin), **(2)** Vuex split-file `export default {…}` + computed-key `commit(CONST)` + `mapActions` (vue2-elm), **(3)** Pinia OPTIONS `defineStore({actions:{…}})` (Geeker), **(4)** Pinia SETUP `defineStore('id',()=>{const f=…;return{f}})` body-locals (MallChat), **(5)** Pinia `useStore().action()` instance dispatch. Extraction covers **1, 3, 4** (`extractObjectLiteralFunctions` on `actions`/`mutations`/`getters` collections + a `findPiniaSetupFn`/`extractPiniaSetupBody` for setup locals; `looksLikeVueStoreFile` ≥2-signal gate + the shape gate make it a **0-node no-op on a Redux control** despite the word "actions"). Validated findable on element-admin (50 fns), Geeker (21), MallChat (68); vue2-elm form-2 + computed-key **deferred** (n=1, needs export-default dispatch + const-string resolution). **Next — the dispatch BRIDGE synth (⬜), 2 separate members:** **(a)** Vuex string-key `dispatch('ns/action')`/`commit('M')` → action/mutation node (an `event-emitter`-style string-key clone, + namespace `module/`) — **n=1 in hand (element-admin)**, needs a 2nd string-literal Vuex repo; **(b)** Pinia `useStore().action()` → action (per-file `const s=useXStore()` var-binding → store-file method) — **n=2 in hand (Geeker+MallChat)**. Corpus: `/tmp/cg-vuex-eval/{vue-element-admin,vue2-elm,Geeker-Admin,MallChatWeb}`. | | **NgRx effects** | Angular | `createEffect(() => actions.pipe(ofType(LoginAction), …))` → effect handler; `Store.dispatch(new LoginAction())` → effect by action type/class | S (type/class-keyed) | ⬜ | ### Tier B — backend command/event/message buses (each needs its own canonical flow + ≥2 repos) diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 6b309ce..8548a3d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -44,6 +44,16 @@ export { generateNodeId } from './tree-sitter-helpers'; */ const RTK_HOOK_NAME_RE = /^use[A-Z][A-Za-z0-9]*(?:Query|Mutation)$/; +/** Vue store collections whose object-literal members are the symbols an agent + * looks for. Extracted as function nodes so `actions`/`mutations`/`getters` are + * findable + readable (the foundation under any later dispatch-bridge synth). */ +const VUE_STORE_COLLECTION_NAMES = new Set(['actions', 'mutations', 'getters']); +/** Store-definition callees whose config object carries those collections. */ +const VUE_STORE_FACTORY_CALLEES = new Set(['defineStore', 'createStore']); +/** Distinct signals that a file is a Vuex/Pinia store (≥2 ⇒ treat a bare + * `const actions = {…}` as a store collection — see looksLikeVueStoreFile). */ +const VUE_STORE_FILE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b/g; + /** * Extract the name from a node based on language */ @@ -325,6 +335,8 @@ export class TreeSitterExtractor { // (see flushFnRefCandidates). private fnRefSpec: FnRefSpec | undefined; private fnRefCandidates: Array = []; + // Memoized "is this a Vue store file" verdict (per-extractor = per-file). + private vueStoreFile: boolean | null = null; constructor(filePath: string, source: string, language?: Language) { this.filePath = filePath; @@ -2103,6 +2115,118 @@ export class TreeSitterExtractor { } } + /** Cheap per-file heuristic: the file carries ≥2 distinct Vue-store signals + * (defineStore/createStore/Vuex, or the actions/mutations/getters/namespaced + * vocabulary). Gates the non-exported `const actions = {…}` Vuex-module form so + * a stray `const actions` in unrelated code is never mistaken for a store. */ + private looksLikeVueStoreFile(): boolean { + if (this.vueStoreFile !== null) return this.vueStoreFile; + const seen = new Set(); + VUE_STORE_FILE_SIGNAL.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = VUE_STORE_FILE_SIGNAL.exec(this.source))) { + seen.add(m[0]); + if (seen.size >= 2) break; + } + this.vueStoreFile = seen.size >= 2; + return this.vueStoreFile; + } + + /** True if an object literal has ≥1 inline function member (`key: () => …` / + * `method(){}`) — distinguishes an inline action map (zustand/SvelteKit form + * actions) from a Pinia SETUP store's all-shorthand `return { foo, bar }` + * (whose functions are body-local consts, walked normally instead). */ + private objectHasInlineFunctions(obj: SyntaxNode): boolean { + for (let i = 0; i < obj.namedChildCount; i++) { + const member = obj.namedChild(i); + if (member?.type === 'method_definition') return true; + if (member?.type === 'pair') { + const v = getChildByField(member, 'value'); + if (v?.type === 'arrow_function' || v?.type === 'function_expression') return true; + } + } + return false; + } + + /** Vue store action/mutation/getter collections defined INLINE in a store call: + * `defineStore({ actions: {…}, getters: {…} })` (Pinia options form), + * `defineStore('id', { actions: {…} })`, `createStore({ mutations: {…} })`, + * `new Vuex.Store({ actions: {…} })`. Returns the object literals under those + * keys so their methods become nodes. Gated on the store-factory callee. */ + private findVueStoreCollectionObjects(callNode: SyntaxNode): SyntaxNode[] { + const callee = getChildByField(callNode, 'function') ?? getChildByField(callNode, 'constructor'); + if (!callee) return []; + const calleeName = + callee.type === 'identifier' + ? getNodeText(callee, this.source) + : callee.type === 'member_expression' + ? getNodeText(getChildByField(callee, 'property') ?? callee, this.source) + : ''; + if (!VUE_STORE_FACTORY_CALLEES.has(calleeName) && calleeName !== 'Store') return []; + const args = getChildByField(callNode, 'arguments'); + if (!args) return []; + const objects: SyntaxNode[] = []; + for (let i = 0; i < args.namedChildCount; i++) { + const arg = args.namedChild(i); + if (arg?.type !== 'object' && arg?.type !== 'object_expression') continue; + for (let j = 0; j < arg.namedChildCount; j++) { + const member = arg.namedChild(j); + if (member?.type !== 'pair') continue; + const key = getChildByField(member, 'key'); + if (!key || !VUE_STORE_COLLECTION_NAMES.has(getNodeText(key, this.source))) continue; + const value = getChildByField(member, 'value'); + if (value && (value.type === 'object' || value.type === 'object_expression')) { + objects.push(value); + } + } + } + return objects; + } + + /** The SETUP function of a Pinia setup store (`defineStore('id', () => {…})`) + * — an arrow/function arg with a block body. Returns null for the options form + * (`defineStore({…})`) and for any non-defineStore call. The setup body's local + * function consts are the store's actions; the generic body walk doesn't reach + * them (nested functions are separate scopes), so they're extracted explicitly. */ + private findPiniaSetupFn(callNode: SyntaxNode): SyntaxNode | null { + const callee = getChildByField(callNode, 'function'); + if (!callee || callee.type !== 'identifier' || getNodeText(callee, this.source) !== 'defineStore') return null; + const args = getChildByField(callNode, 'arguments'); + if (!args) return null; + for (let i = 0; i < args.namedChildCount; i++) { + const arg = args.namedChild(i); + if (arg?.type !== 'arrow_function' && arg?.type !== 'function_expression') continue; + const body = getChildByField(arg, 'body'); + if (body?.type === 'statement_block') return arg; // block body ⇒ setup form + } + return null; + } + + /** Extract a Pinia setup store's actions: the body-local `const foo = () => …` + * / `function foo(){}` declarations, named by the binding. (State refs and other + * consts are left to the normal value-extraction; only the functions matter as + * the store's callable surface.) */ + private extractPiniaSetupBody(setupFn: SyntaxNode): void { + const body = getChildByField(setupFn, 'body'); + if (!body || body.type !== 'statement_block') return; + for (let i = 0; i < body.namedChildCount; i++) { + const stmt = body.namedChild(i); + if (!stmt) continue; + if (stmt.type === 'function_declaration') { + this.extractFunction(stmt); + } else if (this.extractor!.variableTypes.includes(stmt.type)) { + for (let j = 0; j < stmt.namedChildCount; j++) { + const decl = stmt.namedChild(j); + if (decl?.type !== 'variable_declarator') continue; + const v = getChildByField(decl, 'value'); + if (v?.type === 'arrow_function' || v?.type === 'function_expression') { + this.extractFunction(v); // name resolved from the parent declarator + } + } + } + } + } + /** * Extract a variable declaration (const, let, var, etc.) * @@ -2190,7 +2314,13 @@ export class TreeSitterExtractor { : valueNode?.type === 'call_expression' ? this.findInitializerReturnedObject(valueNode) : null; - const extractObjectMethods = isExported && !!objectOfFns; + // Only treat as an inline object-of-functions when the object actually + // HAS inline functions. A Pinia SETUP store `defineStore('id', () => { + // const foo = …; return { foo } })` returns an ALL-SHORTHAND object + // whose functions are body-local consts — it must fall through to a + // normal body walk (extracting those consts), not be skipped here. + const hasInlineFns = !!objectOfFns && this.objectHasInlineFunctions(objectOfFns); + const extractObjectMethods = isExported && !!objectOfFns && hasInlineFns; // RTK Query: `createApi`/`injectEndpoints` define endpoints as // object-literal properties whose values are `build.query/mutation(...)` @@ -2202,16 +2332,39 @@ export class TreeSitterExtractor { const rtkEndpoints = valueNode?.type === 'call_expression' ? this.findRtkEndpointsObject(valueNode) : null; + // Pinia SETUP store: `defineStore('id', () => { const foo = …; return {…} })`. + // Its actions are body-local consts the generic walk can't reach. + const piniaSetup = + valueNode?.type === 'call_expression' ? this.findPiniaSetupFn(valueNode) : null; + + // Vue store collections — make `actions`/`mutations`/`getters` findable + // function nodes (the foundation under any later dispatch-bridge synth). + // Two positions: INLINE in a store call (`defineStore({ actions: {…} })` + // / `createStore` / `new Vuex.Store`), and the non-exported Vuex-MODULE + // form (`const actions = {…}` at a store file's top level, wired via a + // `export default { actions }`). The Pinia SETUP form is handled by the + // body walk above (its actions are local consts). + const storeCollections: SyntaxNode[] = []; + if (valueNode?.type === 'call_expression' || valueNode?.type === 'new_expression') { + storeCollections.push(...this.findVueStoreCollectionObjects(valueNode)); + } + if (objectOfFns && !extractObjectMethods && + VUE_STORE_COLLECTION_NAMES.has(name) && this.looksLikeVueStoreFile()) { + storeCollections.push(objectOfFns); + } + // Visit the initializer body for calls — EXCEPT object literals (their // function-valued properties are extracted below) and the store-factory - // / createApi call whose returned object we extract method-by-method - // below (walking the whole call would re-visit those method arrows and - // mis-attribute their inner calls to the file/module scope). + // / createApi / store-collection call whose nested objects we extract + // method-by-method below (walking the whole call would re-visit those + // method arrows and mis-attribute their inner calls to the file scope). if (valueNode && valueNode.type !== 'object' && valueNode.type !== 'object_expression' && !(extractObjectMethods && valueNode.type === 'call_expression') && - !rtkEndpoints) { + !rtkEndpoints && + !piniaSetup && + storeCollections.length === 0) { this.visitFunctionBody(valueNode, ''); } @@ -2221,6 +2374,12 @@ export class TreeSitterExtractor { if (rtkEndpoints) { this.extractRtkEndpoints(rtkEndpoints); } + if (piniaSetup) { + this.extractPiniaSetupBody(piniaSetup); + } + for (const coll of storeCollections) { + this.extractObjectLiteralFunctions(coll); + } } } }