feat(resolution): bridge Pinia useStore().action() calls to the action

The dispatch bridge for Pinia, on top of the store-action extraction foundation
(cc9c2f7). A consumer does `const store = useXStore()` then `store.action()` —
a method-on-instance call with no static edge to the action, which lives in the
store module. So tracing "what does this view do when it loads" stopped at the
`store.fetchUser()` line.

piniaStoreEdges (callback-synthesizer.ts): map each `const useXStore =
defineStore(...)` factory → its file; per consumer file, bind `const s =
useXStore()` vars; link the enclosing function (or the .vue component, via a
fallback) → the `s.method()` action node IN THE STORE'S FILE. The same-store-file
gate is the precision lever — a Pinia built-in (`$patch`) or an unrelated
same-named method resolves to nothing. Covers the options and setup store forms
uniformly (the action is a function node in the store file either way) and
surfaces in explore as `dynamic: pinia store`.

Validated 100% precision (Geeker 41 edges, MallChat 64; 0 targets outside a
store file), 0 on the Vuex-only element-admin control (no defineStore), n=2 in
hand. Suite green (1612); new __tests__/pinia-store-synthesizer.test.ts. The
Vuex string-key dispatch bridge (`dispatch('ns/action')`) remains a follow-up
(n=1 in hand — needs a 2nd string-literal Vuex repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-06-20 20:30:13 -05:00
parent cc9c2f7420
commit 8ea32059b6
4 changed files with 195 additions and 3 deletions
+2 -1
View File
@@ -11,7 +11,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. (Connecting a component's `dispatch('user/login')` / `store.action()` call through to the action is a separate follow-up.)
- 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 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.
+108
View File
@@ -0,0 +1,108 @@
/**
* Pinia `useStore().action()` dispatch bridge.
*
* A Pinia store factory `export const useXStore = defineStore(...)` exposes its
* actions as methods on the store instance; a consumer does `const s = useXStore()`
* then `s.action()`. That method-on-instance call has no static edge to the action
* (which lives in the store module). This bridges consumer → action by binding the
* store var to its factory's file and resolving `s.method()` to a function node IN
* THAT FILE — so it covers both the options and setup store forms, stays precise
* (a Pinia built-in like `$patch`, or an unrelated same-named method, resolves to
* nothing), and fires only when a `defineStore` factory actually exists.
*/
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('pinia-store synthesizer', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pinia-store-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
it('bridges `const s = useXStore(); s.action()` to the action, across options + setup forms', async () => {
// Options-form store.
fs.writeFileSync(
path.join(dir, 'authStore.ts'),
`import { defineStore } from 'pinia';
export const useAuthStore = defineStore({
id: 'auth',
state: () => ({ token: '' }),
actions: {
async getMenu() { return loadMenu(); },
setToken(t: string) { this.token = t; },
},
});
`
);
// Setup-form store.
fs.writeFileSync(
path.join(dir, 'chatStore.ts'),
`import { defineStore } from 'pinia';
export const useChatStore = defineStore('chat', () => {
const getList = async () => { return fetchList(); };
return { getList };
});
`
);
// Consumer binds both stores and calls their actions (plus a Pinia built-in).
fs.writeFileSync(
path.join(dir, 'init.ts'),
`import { useAuthStore } from './authStore';
import { useChatStore } from './chatStore';
export function init() {
const authStore = useAuthStore();
const chatStore = useChatStore();
authStore.getMenu();
authStore.setToken('x');
authStore.$patch({}); // Pinia built-in — must not bridge
chatStore.getList();
}
`
);
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
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') = 'pinia-store'`
)
.all();
const pairs = edges.map((r: any) => `${r.source}->${r.target}`).sort();
// Exactly the three real actions, all from `init`.
expect(pairs).toEqual(['init->getList', 'init->getMenu', 'init->setToken']);
// Each target is the action in its own store file (cross-file, store-scoped).
expect(edges.every((r: any) => /Store\.ts$/.test(r.tf))).toBe(true);
// The Pinia built-in `$patch` produced no edge.
expect(pairs.some((p: string) => p.includes('patch'))).toBe(false);
cg.close?.();
});
it('produces nothing when there is no defineStore factory (not a Pinia store)', async () => {
fs.writeFileSync(
path.join(dir, 'thing.ts'),
`function useThing() { return { run() { return 1; } }; }
export function go() {
const thing = useThing();
thing.run();
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const c = db
.prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'pinia-store'`)
.get().c;
expect(c).toBe(0);
cg.close?.();
});
});
+1 -1
View File
@@ -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). **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}`. |
| **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}`. |
| **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)
+84 -1
View File
@@ -1923,12 +1923,93 @@ function rtkQueryEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
return edges;
}
// ── Pinia useStore().action() dispatch bridge ────────────────────────────────
// A Pinia store factory `export const useXStore = defineStore(...)` exposes its
// actions as methods on the store instance; a consumer does `const s = useXStore()`
// then `s.action()`. The call is a method-on-instance with no static edge to the
// action (which lives in the store's module). Bridge it: map each factory → its
// file, bind `const <var> = useXStore()` per consumer file, and link the enclosing
// function → the `<var>.method()` action node IN THE STORE'S FILE. The same-store-
// file gate keeps it precise (a Pinia built-in like `$patch` or an unrelated
// same-named method resolves to nothing). Covers both the options and setup store
// forms uniformly (the action is a function node in the store file either way).
const PINIA_CONSUMER_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs|vue)$/;
const PINIA_FACTORY_RE = /\b(?:export\s+)?const\s+(\w+)\s*=\s*defineStore\s*\(/g;
const PINIA_BIND_RE = /\bconst\s+(\w+)\s*=\s*(?:await\s+)?(\w+)\s*\(/g;
const PINIA_CALL_RE = /(\w+)\s*\.\s*(\w+)\s*\(/g;
const PINIA_FANOUT_CAP = 80;
function piniaStoreEdges(ctx: ResolutionContext): Edge[] {
// 1. Map each `const useXStore = defineStore(...)` factory → its store file.
const factoryFile = new Map<string, string>();
for (const file of ctx.getAllFiles()) {
if (!PINIA_CONSUMER_EXT.test(file)) continue;
const content = ctx.readFile(file);
if (!content || !content.includes('defineStore')) continue;
PINIA_FACTORY_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PINIA_FACTORY_RE.exec(content))) factoryFile.set(m[1]!, file);
}
if (!factoryFile.size) return [];
const edges: Edge[] = [];
const seen = new Set<string>();
for (const file of ctx.getAllFiles()) {
if (!PINIA_CONSUMER_EXT.test(file)) continue;
const content = ctx.readFile(file);
if (!content || !content.includes('Store')) continue;
const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
// 2. Bind store vars in this file: `const <var> = <known-factory>(...)`.
const varStore = new Map<string, string>();
PINIA_BIND_RE.lastIndex = 0;
let bm: RegExpExecArray | null;
while ((bm = PINIA_BIND_RE.exec(safe))) {
const sf = factoryFile.get(bm[2]!);
if (sf) varStore.set(bm[1]!, sf);
}
if (!varStore.size) continue;
// 3. Link `<var>.<method>(` → the action function node in the store's file.
const nodesInFile = ctx.getNodesInFile(file);
const fallbackDispatcher = nodesInFile.find((n) => n.kind === 'component'); // .vue top-level setup
PINIA_CALL_RE.lastIndex = 0;
let cm: RegExpExecArray | null;
let added = 0;
while ((cm = PINIA_CALL_RE.exec(safe)) && added < PINIA_FANOUT_CAP) {
const storeFile = varStore.get(cm[1]!);
if (!storeFile) continue;
const method = cm[2]!;
const line = safe.slice(0, cm.index).split('\n').length;
const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher;
if (!disp) continue;
const target = ctx
.getNodesByName(method)
.find((n) => n.kind === 'function' && n.filePath === storeFile);
if (!target || target.id === disp.id) continue;
const key = `${disp.id}>${target.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: disp.id,
target: target.id,
kind: 'calls',
line,
provenance: 'heuristic',
metadata: { synthesizedBy: 'pinia-store', via: method, 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).
* generated-hook → endpoint + Pinia useStore().action() dispatch).
* Returns the count added. Never throws into indexing — callers wrap in try/catch.
*/
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
@@ -1969,6 +2050,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
const thunkEdges = reduxThunkEdges(queries, ctx);
const registryEdges = objectRegistryEdges(ctx);
const rtkEdges = rtkQueryEdges(queries, ctx);
const piniaEdges = piniaStoreEdges(ctx);
const merged: Edge[] = [];
const seen = new Set<string>();
@@ -1995,6 +2077,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
...thunkEdges,
...registryEdges,
...rtkEdges,
...piniaEdges,
]) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;