From 80a1044d3d6bed31f99e073c15f89ffefa05eec9 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 20 Jun 2026 20:49:06 -0500 Subject: [PATCH] feat(resolution): bridge Vuex string dispatch/commit to actions and mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Vue store dispatch family (the Pinia bridge was 8ea3205). Vuex dispatches by a runtime STRING key — `dispatch('user/login')` / `commit('SET_TOKEN')` / `this.$store.dispatch('app/toggleDevice')` — with no static edge to the handler. vuexDispatchEdges (callback-synthesizer.ts): the last `/` segment of the key is the action/mutation name, the preceding segment is the namespace (≈ the module file). Resolve the name to a function node IN A STORE FILE — the ≥2-signal store-file gate excludes a same-named `api/` helper (`getInfo`/`login` collide in practice) — disambiguated by the immediate namespace segment appearing in the path (handles deep nesting like `d2admin/user/set`), or the same file for a root local `commit('M')` inside an action. The .vue component is a dispatcher fallback for top-level setup calls. Surfaces in explore as `dynamic: vuex dispatch`. Also extracts the canonical Vuex MODULE shape `export default { namespaced, actions: {…}, mutations: {…} }` (tree-sitter.ts: extractStoreCollectionMethods off the export_statement, store-file gated) — its object-literal methods were otherwise never nodes, so d2-admin's actions couldn't be bridged. Validated 100% precision on three repos — vue-element-admin (55 edges), vue-admin-template (12), d2-admin (63): 0 non-store targets, 0 namespace mismatches (54/54 namespaced edges route to the correct module despite 6 colliding `load` actions in d2-admin), 0 on Redux controls (basetool/uwave — non-string `dispatch()` correctly ignored). Suite green (1613); new __tests__/vuex-dispatch-synthesizer.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +- __tests__/vuex-dispatch-synthesizer.test.ts | 100 ++++++++++++++++++++ docs/design/dispatch-synthesizer-backlog.md | 2 +- src/extraction/tree-sitter.ts | 35 +++++++ src/resolution/callback-synthesizer.ts | 92 +++++++++++++++++- 5 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 __tests__/vuex-dispatch-synthesizer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c55e06e..1e27563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ 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. -- `codegraph_explore` now connects a Vue component to the **Pinia** store action it calls. When code does `const store = useUserStore()` and then `store.fetchUser()`, that call now links through to the `fetchUser` action in the store module — so "what happens when this view loads its data?" traces from the component into the action's body instead of stopping at the `store.fetchUser()` line. Works for both Pinia store styles (options and setup), and stays precise (a built-in like `store.$patch()` or an unrelated same-named method isn't mislinked). (Vuex string-dispatch — `dispatch('user/login')` — remains a separate follow-up.) +- `codegraph_explore` now connects a Vue component to the **Pinia** store action it calls. When code does `const store = useUserStore()` and then `store.fetchUser()`, that call now links through to the `fetchUser` action in the store module — so "what happens when this view loads its data?" traces from the component into the action's body instead of stopping at the `store.fetchUser()` line. Works for both Pinia store styles (options and setup), and stays precise (a built-in like `store.$patch()` or an unrelated same-named method isn't mislinked). +- `codegraph_explore` now follows **Vuex** string dispatch. A `dispatch('user/login')` or `commit('SET_TOKEN')` call — namespaced `'module/action'` keys included — now links to the action or mutation it names, resolved to the correct store module even when several modules share an action name (and without being fooled by a same-named `api/` helper). So "what runs when this dispatches?" traces from the call into the store handler and on to the mutations it commits. Vuex's canonical `export default { namespaced, actions, mutations }` module shape is now indexed too, so those handlers are findable symbols. - `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__/vuex-dispatch-synthesizer.test.ts b/__tests__/vuex-dispatch-synthesizer.test.ts new file mode 100644 index 0000000..b2f9a71 --- /dev/null +++ b/__tests__/vuex-dispatch-synthesizer.test.ts @@ -0,0 +1,100 @@ +/** + * Vuex string-keyed dispatch/commit bridge. + * + * Vuex dispatches actions/mutations by a runtime STRING key — `dispatch('user/login')`, + * `commit('SET_TOKEN')` — with no static edge to the handler (an object-literal + * method in a store module). This bridges the key to its function node: the last + * `/` segment is the action/mutation name, the preceding segment is the namespace + * (≈ the module file). It resolves to a node IN A STORE FILE (excluding a same-named + * `api/` helper — a real collision), disambiguated by the namespace appearing in the + * path, or the same file for a root `commit('M')` inside an action. Redux-style + * `dispatch(actionCreator())` (no string key) produces nothing. + * + * Also exercises the canonical Vuex MODULE shape `export default { namespaced, + * actions: {…}, mutations: {…} }` — whose methods only become nodes via the + * store-collection extraction this bridge depends on. + */ +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('vuex-dispatch synthesizer', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vuex-dispatch-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + it('bridges namespaced dispatch + local commit to the right store handler, excluding an api collision', async () => { + fs.mkdirSync(path.join(dir, 'store', 'modules'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'api'), { recursive: true }); + // Canonical Vuex module: `export default { namespaced, actions, mutations }`. + fs.writeFileSync( + path.join(dir, 'store', 'modules', 'user.js'), + `import { login as apiLogin } from '../../api/user'; +export default { + namespaced: true, + state: { token: '' }, + mutations: { + SET_TOKEN(state, t) { state.token = t; }, + }, + actions: { + login({ commit }, info) { + apiLogin(info); + commit('SET_TOKEN', info.token); // root/local key → SET_TOKEN in THIS module + }, + }, +}; +` + ); + // Collision: an api helper ALSO named `login` — must never be the dispatch target. + fs.writeFileSync( + path.join(dir, 'api', 'user.js'), + `export function login(info) { return info; } +` + ); + // Consumer dispatches by namespaced string key. + fs.writeFileSync( + path.join(dir, 'app.js'), + `import store from './store'; +export function bootstrap() { + store.dispatch('user/login', { token: 'x' }); +} +` + ); + // Redux-style control: a non-string dispatch must produce no vuex edge. + fs.writeFileSync( + path.join(dir, 'reduxy.js'), + `export function reduxy(dispatch) { + dispatch(someAction()); +} +` + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + const edges = db + .prepare( + `SELECT s.name source, t.name target, t.file_path tf, json_extract(e.metadata,'$.via') via + FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE json_extract(e.metadata,'$.synthesizedBy') = 'vuex-dispatch'` + ) + .all(); + + // bootstrap → login, resolving to the STORE module (not api/user.js). + const loginEdge = edges.find((r: any) => r.source === 'bootstrap' && r.target === 'login'); + expect(loginEdge).toBeTruthy(); + expect(loginEdge.tf).toMatch(/store[\\/]modules[\\/]user\.js$/); + expect(loginEdge.via).toBe('user/login'); + // The api helper of the same name was never targeted. + expect(edges.some((r: any) => /api[\\/]user\.js$/.test(r.tf))).toBe(false); + // Local commit('SET_TOKEN') inside the action → the same module's mutation. + expect(edges.some((r: any) => r.source === 'login' && r.target === 'SET_TOKEN')).toBe(true); + // Redux-style non-string dispatch contributed nothing. + expect(edges.some((r: any) => r.source === 'reduxy')).toBe(false); + + cg.close?.(); + }); +}); diff --git a/docs/design/dispatch-synthesizer-backlog.md b/docs/design/dispatch-synthesizer-backlog.md index cfaa99a..691804c 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); 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). **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 — ✅ **SHIPPED (2026-06-20)** `synthesizedBy:'pinia-store'` (`piniaStoreEdges`): maps each `const useXStore=defineStore(…)` factory → its file, binds `const s=useXStore()` per consumer file, links the enclosing fn (or the `.vue` component, via fallback) → the `s.method()` action node IN THE STORE'S FILE (same-store-file gate ⇒ `$patch`/built-ins/unrelated same-named methods resolve to nothing). Covers options + setup forms uniformly. **100% precision** (Geeker 41 edges, MallChat 64; 0 targets outside a store file), 0 on the Vuex-only element-admin control; surfaces as `dynamic: pinia store`; suite 1612 + `pinia-store-synthesizer.test.ts`. Corpus: `/tmp/cg-vuex-eval/{vue-element-admin,vue2-elm,Geeker-Admin,MallChatWeb}`. | +| **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). **The dispatch BRIDGE synth, 2 members — BOTH ✅ SHIPPED (2026-06-20):** **(a)** Vuex string-key `dispatch('ns/action')`/`commit('M')` → action/mutation node — `synthesizedBy:'vuex-dispatch'` (`vuexDispatchEdges`): last `/` segment = action name, preceding = namespace; resolve to a function node IN A STORE FILE (the ≥2-signal `isStoreFile` gate excludes a same-named `api/` helper — `getInfo`/`login` collide), disambiguated by the immediate namespace segment in the path (handles DEEP nesting `d2admin/user/set`) or same-file for a root local `commit('M')`. Also added `export default { namespaced, actions:{…}, mutations:{…} }` extraction (the canonical Vuex module form — `extractStoreCollectionMethods` off the export_statement, store-file gated) since d2-admin needs it. **100% precision: element-admin 55 edges, vue-admin-template 12, d2-admin 63; 0 non-store targets, 0 namespace mismatches (54/54 namespaced edges route to the correct module); 0 on Redux controls (basetool/uwave — non-string `dispatch()` ignored).** `+ vuex-dispatch-synthesizer.test.ts`. **(b)** Pinia `useStore().action()` → action — ✅ **SHIPPED (2026-06-20)** `synthesizedBy:'pinia-store'` (`piniaStoreEdges`): maps each `const useXStore=defineStore(…)` factory → its file, binds `const s=useXStore()` per consumer file, links the enclosing fn (or the `.vue` component, via fallback) → the `s.method()` action node IN THE STORE'S FILE (same-store-file gate ⇒ `$patch`/built-ins/unrelated same-named methods resolve to nothing). Covers options + setup forms uniformly. **100% precision** (Geeker 41 edges, MallChat 64; 0 targets outside a store file), 0 on the Vuex-only element-admin control; surfaces as `dynamic: pinia store`; suite 1612 + `pinia-store-synthesizer.test.ts`. 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 8548a3d..13cda08 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1066,6 +1066,24 @@ export class TreeSitterExtractor { const parentId = this.nodeStack[this.nodeStack.length - 1]; if (parentId) this.emitReExportRefs(node, parentId); } + // Vuex MODULE default export — `export default { namespaced, actions: {…}, + // mutations: {…} }` (the canonical Vuex module shape). Object-literal methods + // aren't otherwise extracted, so scan the config's actions/mutations/getters + // collections and extract their methods as nodes. Store-file gated (the + // ≥2-signal heuristic) so a plain default-exported object is untouched; skip + // the subtree afterward (the collection methods are now handled). + else if ( + nodeType === 'export_statement' && + (this.language === 'typescript' || this.language === 'tsx' || + this.language === 'javascript' || this.language === 'jsx') && + this.looksLikeVueStoreFile() + ) { + const exported = getChildByField(node, 'value'); + if (exported && (exported.type === 'object' || exported.type === 'object_expression')) { + this.extractStoreCollectionMethods(exported); + skipChildren = true; + } + } // Check for function calls else if (this.extractor.callTypes.includes(nodeType)) { this.extractCall(node); @@ -2183,6 +2201,23 @@ export class TreeSitterExtractor { return objects; } + /** Extract the methods of a store-config object's `actions`/`mutations`/`getters` + * properties. Used for the canonical Vuex MODULE shape `export default { + * namespaced, actions: {…}, mutations: {…} }` — object-literal methods aren't + * otherwise extracted, so the actions/mutations would never be nodes. */ + private extractStoreCollectionMethods(configObj: SyntaxNode): void { + for (let j = 0; j < configObj.namedChildCount; j++) { + const member = configObj.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')) { + this.extractObjectLiteralFunctions(value); + } + } + } + /** 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 diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index e7d568b..9500f71 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -2004,12 +2004,100 @@ function piniaStoreEdges(ctx: ResolutionContext): Edge[] { return edges; } +// ── Vuex string-keyed dispatch / commit bridge ─────────────────────────────── +// Vuex dispatches actions/mutations by a runtime STRING key: `dispatch('user/login')` +// / `commit('SET_TOKEN')` / `this.$store.dispatch('app/toggleDevice')`. The action +// & mutation definitions are object-literal methods in store module files (now +// extracted as function nodes). Bridge the string key to its node: the LAST `/` +// segment is the action/mutation name; the preceding segment is the namespace +// (≈ the store module's file). Resolve the name to a function node IN A STORE FILE +// (the store-file gate excludes a same-named `api/` helper — `getInfo`/`login` +// commonly collide), disambiguated by the namespace appearing in the path (or, for +// a root key, the same file — Vuex's local-module `commit('M')` inside an action). +const VUEX_DISPATCH_RE = /\b(?:dispatch|commit)\s*\(\s*['"]([A-Za-z][\w/]*)['"]/g; +const VUEX_STORE_SIGNAL = /\bdefineStore\b|\bcreateStore\b|\bVuex\b|\bmutations\b|\bactions\b|\bgetters\b|\bnamespaced\b/g; +const VUEX_FANOUT_CAP = 120; + +/** A path segment (dir or filename stem) equals `seg` — `…/modules/user.js` has + * the segment `user` for namespace `user`. */ +function pathHasSegment(filePath: string, seg: string): boolean { + return new RegExp('[\\\\/]' + seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\\\/.]').test(filePath); +} + +function vuexDispatchEdges(ctx: ResolutionContext): Edge[] { + const storeFileCache = new Map(); + const isStoreFile = (file: string): boolean => { + let v = storeFileCache.get(file); + if (v === undefined) { + const c = ctx.readFile(file); + const seen = new Set(); + if (c) { + VUEX_STORE_SIGNAL.lastIndex = 0; + let sm: RegExpExecArray | null; + while ((sm = VUEX_STORE_SIGNAL.exec(c))) { seen.add(sm[0]); if (seen.size >= 2) break; } + } + v = seen.size >= 2; + storeFileCache.set(file, v); + } + return v; + }; + + const resolve = (key: string, dispatchFile: string): Node | null => { + const segs = key.split('/'); + const action = segs[segs.length - 1]!; + const cands = ctx.getNodesByName(action).filter((n) => n.kind === 'function' && isStoreFile(n.filePath)); + if (!cands.length) return null; + if (segs.length > 1) { + const mod = segs[segs.length - 2]!; // immediate namespace ≈ the module file + return cands.find((c) => pathHasSegment(c.filePath, mod)) ?? (cands.length === 1 ? cands[0]! : null); + } + // Root key: a local `commit('M')` inside an action targets the same module file; + // otherwise accept only an unambiguous single store-wide match. + return cands.find((c) => c.filePath === dispatchFile) ?? (cands.length === 1 ? cands[0]! : null); + }; + + const edges: Edge[] = []; + const seen = new Set(); + for (const file of ctx.getAllFiles()) { + if (!PINIA_CONSUMER_EXT.test(file)) continue; + const content = ctx.readFile(file); + if (!content || (!content.includes('dispatch(') && !content.includes('commit('))) continue; + const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript'); + const nodesInFile = ctx.getNodesInFile(file); + const fallback = nodesInFile.find((n) => n.kind === 'component'); // .vue top-level + VUEX_DISPATCH_RE.lastIndex = 0; + let m: RegExpExecArray | null; + let added = 0; + while ((m = VUEX_DISPATCH_RE.exec(safe)) && added < VUEX_FANOUT_CAP) { + const key = m[1]!; + const line = safe.slice(0, m.index).split('\n').length; + const disp = enclosingFn(nodesInFile, line) ?? fallback; + if (!disp) continue; + const target = resolve(key, file); + if (!target || target.id === disp.id) continue; + const edgeKey = `${disp.id}>${target.id}`; + if (seen.has(edgeKey)) continue; + seen.add(edgeKey); + edges.push({ + source: disp.id, + target: target.id, + kind: 'calls', + line, + provenance: 'heuristic', + metadata: { synthesizedBy: 'vuex-dispatch', via: key, registeredAt: `${file}:${line}` }, + }); + added++; + } + } + return edges; +} + /** * Synthesize dispatcher→callback edges (field observers + EventEmitters + * React re-render + JSX children + Vue templates + SvelteKit load + RN event * channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain + * Redux-thunk dispatch chain + object-literal registry dispatch + RTK Query - * generated-hook → endpoint + Pinia useStore().action() dispatch). + * generated-hook → endpoint + Pinia useStore().action() + Vuex string dispatch). * Returns the count added. Never throws into indexing — callers wrap in try/catch. */ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number { @@ -2051,6 +2139,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo const registryEdges = objectRegistryEdges(ctx); const rtkEdges = rtkQueryEdges(queries, ctx); const piniaEdges = piniaStoreEdges(ctx); + const vuexEdges = vuexDispatchEdges(ctx); const merged: Edge[] = []; const seen = new Set(); @@ -2078,6 +2167,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo ...registryEdges, ...rtkEdges, ...piniaEdges, + ...vuexEdges, ]) { const key = `${e.source}>${e.target}`; if (seen.has(key)) continue;