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>
This commit is contained in:
Colby McHenry
2026-06-20 20:49:06 -05:00
co-authored by Claude Opus 4.8
parent 8ea32059b6
commit 80a1044d3d
5 changed files with 229 additions and 3 deletions
+91 -1
View File
@@ -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;