Files
codegraph/__tests__/vue-store-extraction.test.ts
Colby Mchenry f40db4b9cc fix(js,ts): index the functions in every named object literal, not only exported ones (#2300) (#2363)
Issue #2300: a function written inside an object literal became a symbol
only when the object was an `export const`. A plain `const api = {...}`,
an object declared inside an IIFE or a function, and a namespace hung on
the page (`window.WS = {...}`, `ns.mod = {...}`) produced no member nodes,
and no node for `WS` either. Calls made inside those members were credited
to the enclosing constant or lost, and calls into them resolved to
nothing. Script-tag JavaScript, written almost entirely this way, was
mostly missing from callers and impact.

Cause: the TS/JS extractor, and the kernel's tsjs mirror of it, minted
members only for exported object-of-functions; any other literal was
walked as one opaque initializer, and an assignment to a member path was
never a declaration. Resolution had no way to reach a member through
`App.init()`, `window.App.init()` or `App.utils.pad()`.

Fix (extraction, TS and kernel byte-identical): a named object literal
owns its function members (method shorthand, `key: function`, `key: () =>`,
generators; static keys only), whether it is declared at module scope,
in a function body or IIFE, assigned to a path (`window.App = {...}`,
`App.utils = {...}`) or assigned to a plain name at module level
(`dw_page = {...}`). Members are `function` nodes qualified under the owner
(`api::load`, `window.App::init`, `App.utils::pad`), the exported case
included; a path owner is named by its last link and qualified by the
path. Calls in a member are the member's; other values are walked where
they were before. CommonJS exports, prototypes, `this.x = {...}`,
call-argument literals, a name reassigned inside a function, and
generated or minified files keep the old shape. Minified files are now
also recognised by content, in both extractors, and the kernel's
`.min.js` pattern matches the TS one.

Fix (resolution): a member is reached through its object only:
`App.init()`, `window.App.init()`, `App.utils.pad()`, a sibling's
`this.render()`, a `const { init } = App` binding; never a bare `init()`.
A same-file holder is chosen by lexical block (an IIFE's own `App` first);
otherwise a global one (`window.App = {...}`, or a classic script's
top-level `App`) when the caller neither imports nor binds the name.
Arrow members keep the `this` of the method around the literal, in the
`this.x` resolvers too. A dotted-path holder is never reached by its last
name alone, and Svelte's `$store` rule now applies only in `.svelte`
components, so the new local holders are not taken for `$n` in plain
scripts. The lookups read each file once (destructuring patterns only
when the raw text has a `} =`) and keep only the last 32 files' scans.

Contributor PR #2310 (@danusha2345): adopted its model (owner-qualified
members exported or not, path owners, IIFE/local owners, static keys,
no bare-name reach, `<script setup>` contains edges, SFC languages, test
scenarios), re-implemented on current main. Not taken: the
EXTRACTION_VERSION bump (already 28), the extraction-time binding oracle
with its ref/edge metadata and kernel ref patching (replaced by
resolution-time lookups), naming the owner `window.WS`, rewriting
`window.X.m()` ref names, flipping exported members' isExported, and
unrelated bare-call changes.

Verification: new js-object-literal-members suite (native + wasm, 18
tests); the issue's shapes fail on main and the guard test fails without
the guards. kernel-tsjs-parity passes with CODEGRAPH_KERNEL_EXPECT=1
(object-literal owners LF/CRLF, minified bundles); kernel parity sweeps
show 0 diffs on DokuWiki, vue-realworld, TodoMVC, excalidraw and this repo.

Validation (kernel loaded, before = origin/main 023fc311):
- DokuWiki: nodes +85, calls +105; 17 removed edges = 13 moved to the
  member, 4 re-resolved from a wrong LinkWizard::init to the right init.
- TodoMVC: nodes +427, calls +148; 306 removed = 226 moved, 33
  re-resolved (14 fixed `this.render()`/`this.save()`/`this.track()`, 19
  wrong-to-wrong fallback guesses), 47 dropped wrong edges (43 to a
  helper local to jQuery Mobile's scrollstart setup).
- excalidraw: nodes +86, calls +75; 34 removed = 33 moved, 1 fuzzy 0.3
  guess dropped.
- vue-realworld: nodes +10, calls +11; 7 calls re-resolved from the
  ApiService constant to its members.
New self-loops are real recursion, bar one lazily redefined method that
calls itself as written. 81 sampled added edges, 80 correct
(the other is main's own window.open guess, re-attributed). Whole-run
index time, median of 3 interleaved: vue-realworld 1.2s -> 1.2s,
DokuWiki 8.6s -> 9.1s, excalidraw 10.8s -> 10.7s, TodoMVC 16.9s -> 16.9s
(within run-to-run noise).

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:45:43 +00:00

147 lines
5.6 KiB
TypeScript

/**
* 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 is not treated as one. (Its members are nodes anyway, as
* every named object literal's are since #2300 — `actions::doThing`.)
*/
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 treat a non-exported `const actions = {…}` outside a Vue store file as a store', 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.
// The literal's members are its own nodes (#2300), and nothing reads the
// dynamic `actions[key]()` as a call to one of them.
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 kind, qualified_name FROM nodes WHERE name IN ('doThing', 'doOther') ORDER BY qualified_name`).all())
.toEqual([
{ kind: 'function', qualified_name: 'actions::doOther' },
{ kind: 'function', qualified_name: 'actions::doThing' },
]);
// The real exported function is still extracted normally, and calls nothing.
const run = cg.getNodesByQualifiedName('run');
expect(run.map((n) => n.kind)).toEqual(['function']);
expect(cg.getOutgoingEdges(run[0]!.id).filter((e) => e.kind === 'calls')).toEqual([]);
cg.close?.();
});
});