Pārlūkot izejas kodu

feat(resolution): bridge Vuex string dispatch/commit to actions and mutations

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) <noreply@anthropic.com>
Colby McHenry 2 dienas atpakaļ
vecāks
revīzija
80a1044d3d

+ 2 - 1
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.

+ 100 - 0
__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?.();
+  });
+});

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
docs/design/dispatch-synthesizer-backlog.md


+ 35 - 0
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

+ 91 - 1
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<string, boolean>();
+  const isStoreFile = (file: string): boolean => {
+    let v = storeFileCache.get(file);
+    if (v === undefined) {
+      const c = ctx.readFile(file);
+      const seen = new Set<string>();
+      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<string>();
+  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<string>();
@@ -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;

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels