|
|
@@ -1681,9 +1681,20 @@ function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[]
|
|
|
while ((m = THUNK_DISPATCH_RE.exec(safe)) && added < THUNK_FANOUT_CAP) {
|
|
|
const name = m[1]!;
|
|
|
if (name === node.name) continue; // self-dispatch (recursive thunk) — skip
|
|
|
- const target = ctx
|
|
|
+ // Resolve the dispatched name, PREFERRING the thunk/action-creator over a same-named
|
|
|
+ // service function. `dispatch(X(...))` dispatches a thunk or an action-creator (both
|
|
|
+ // `constant`s) — never an unrelated helper that merely shares the name. On octo-call,
|
|
|
+ // `leaveCall` is BOTH a `createAsyncThunk` const AND a service function, and the bare
|
|
|
+ // `.find()` picked the function (wrong). Order: thunk const > other const > same-file
|
|
|
+ // callable > first match. A single candidate (no collision) is unaffected.
|
|
|
+ const cands = ctx
|
|
|
.getNodesByName(name)
|
|
|
- .find((n) => n.kind === 'constant' || n.kind === 'function' || n.kind === 'method');
|
|
|
+ .filter((n) => n.kind === 'constant' || n.kind === 'function' || n.kind === 'method');
|
|
|
+ const target =
|
|
|
+ cands.find((n) => !!n.signature && THUNK_DECL_RE.test(n.signature)) ??
|
|
|
+ cands.find((n) => n.kind === 'constant') ??
|
|
|
+ cands.find((n) => n.filePath === node.filePath) ??
|
|
|
+ cands[0];
|
|
|
if (!target || target.id === node.id) continue;
|
|
|
const key = `${node.id}>${target.id}`;
|
|
|
if (seen.has(key)) continue;
|
|
|
@@ -1703,11 +1714,485 @@ function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[]
|
|
|
return edges;
|
|
|
}
|
|
|
|
|
|
+// ── Object-literal registry dispatch ─────────────────────────────────────────
|
|
|
+// A command/handler registry maps string keys → handler class/function symbols in an
|
|
|
+// object literal, then dispatches by a RUNTIME key static parsing can't follow:
|
|
|
+// this.commands = { [Cmd.ADD]: AddObjectCommand, ... } // registration
|
|
|
+// new this.commands[command](args).execute() // dynamic dispatch
|
|
|
+// Bridge it like gin-middleware-chain: link each dispatching function → each registered
|
|
|
+// handler's callable entry (a class's execute/run/handle/… method — preferring the method
|
|
|
+// chained at the dispatch site — or the function value). Scoped to a registry + dispatch in
|
|
|
+// the SAME file (the cross-file barrel-namespace variant, e.g. trezor's getMethod, is
|
|
|
+// deferred). Gated on a real object literal with ≥2 entries that RESOLVE to callables (a
|
|
|
+// `{ width: 5 }` literal resolves to nothing → no edges); fan-out capped.
|
|
|
+const REGISTRY_ASSIGN_RE = /(?:(?:const|let|var)\s+([A-Za-z_$][\w$]*)|((?:this\.)?[A-Za-z_$][\w$]*))\s*=\s*\{/g;
|
|
|
+const REGISTRY_DISPATCH_RE = /(?:\bnew\s+)?((?:this\.)?[A-Za-z_$][\w$]*)\s*\[\s*([A-Za-z_$][\w$.]*)\s*\]\s*(?:\(|\.[A-Za-z_$])/g;
|
|
|
+const REGISTRY_MIN_ENTRIES = 2;
|
|
|
+const REGISTRY_FANOUT_CAP = 40;
|
|
|
+const REGISTRY_CLASS_ENTRY = new Set(['execute', 'run', 'handle', 'perform', 'process', 'call', 'apply', 'dispatch']);
|
|
|
+const REGISTRY_JS_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/;
|
|
|
+
|
|
|
+/** From the index of an opening `{`, return the brace-balanced body up to its matching `}`. */
|
|
|
+function braceBody(src: string, openIdx: number): string | null {
|
|
|
+ let depth = 0;
|
|
|
+ for (let i = openIdx; i < src.length; i++) {
|
|
|
+ if (src[i] === '{') depth++;
|
|
|
+ else if (src[i] === '}' && --depth === 0) return src.slice(openIdx + 1, i);
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+/** Top-level `key: Identifier` entries of an object-literal body. DEPTH-AWARE: only depth-0
|
|
|
+ * segments are considered, so method-shorthand bodies (`number(a,b){…}`), arrow values
|
|
|
+ * (`x: () => …`), and nested objects (`x: { … }`) don't leak their inner `k: v` pairs as
|
|
|
+ * bogus handlers. The per-segment anchor (`^… key: Ident …$`) keeps only pure identifier
|
|
|
+ * values — a data value (`x: 5`), call, or arrow fails to match. */
|
|
|
+function registryEntryNames(body: string): string[] {
|
|
|
+ const segs: string[] = [];
|
|
|
+ let depth = 0;
|
|
|
+ let start = 0;
|
|
|
+ for (let i = 0; i < body.length; i++) {
|
|
|
+ const c = body[i];
|
|
|
+ if (c === '{' || c === '(' || c === '[') depth++;
|
|
|
+ else if (c === '}' || c === ')' || c === ']') depth--;
|
|
|
+ else if (c === ',' && depth === 0) { segs.push(body.slice(start, i)); start = i + 1; }
|
|
|
+ }
|
|
|
+ segs.push(body.slice(start));
|
|
|
+ const names: string[] = [];
|
|
|
+ for (const seg of segs) {
|
|
|
+ const m = /^\s*(?:\[[^\]]+\]|['"]?[\w$]+['"]?)\s*:\s*([A-Za-z_$][\w$]*)\s*$/.exec(seg);
|
|
|
+ if (m && m[1]!.length >= 3 && !names.includes(m[1]!)) names.push(m[1]!);
|
|
|
+ }
|
|
|
+ return names;
|
|
|
+}
|
|
|
+
|
|
|
+/** Resolve a registered handler name to its callable entry: a function value, or a class's
|
|
|
+ * `execute`-like method (preferring the method chained at the dispatch site), else the class. */
|
|
|
+function resolveRegistryHandler(ctx: ResolutionContext, name: string, chained: string | null): Node | null {
|
|
|
+ const cands = ctx.getNodesByName(name);
|
|
|
+ const fn = cands.find((n) => n.kind === 'function');
|
|
|
+ if (fn) return fn;
|
|
|
+ const cls = cands.find((n) => n.kind === 'class' || n.kind === 'struct');
|
|
|
+ if (cls) {
|
|
|
+ const methods = ctx
|
|
|
+ .getNodesInFile(cls.filePath)
|
|
|
+ .filter((n) => n.kind === 'method' && n.startLine >= cls.startLine && n.startLine <= (cls.endLine ?? cls.startLine));
|
|
|
+ const want = chained && REGISTRY_CLASS_ENTRY.has(chained) ? chained : null;
|
|
|
+ const entry =
|
|
|
+ (want && methods.find((m) => m.name === want)) ||
|
|
|
+ methods.find((m) => REGISTRY_CLASS_ENTRY.has(m.name)) ||
|
|
|
+ methods.find((m) => m.name === 'constructor');
|
|
|
+ return entry ?? cls;
|
|
|
+ }
|
|
|
+ // Require a CALLABLE target — a registry dispatched as `reg[k](…)` invokes a function/
|
|
|
+ // method, never a data `constant` (dropping it removes false positives like a `{ x: URL }`
|
|
|
+ // entry resolving to the global URL constant).
|
|
|
+ return cands.find((n) => n.kind === 'method') ?? null;
|
|
|
+}
|
|
|
+
|
|
|
+function objectRegistryEdges(ctx: ResolutionContext): Edge[] {
|
|
|
+ const edges: Edge[] = [];
|
|
|
+ const seen = new Set<string>();
|
|
|
+ for (const file of ctx.getAllFiles()) {
|
|
|
+ if (!REGISTRY_JS_EXT.test(file)) continue;
|
|
|
+ const content = ctx.readFile(file);
|
|
|
+ // Cheap pre-filter: a computed member access BY NAME (`ident[ident`) — the dispatch shape.
|
|
|
+ if (!content || !/[\w$]\s*\[\s*[A-Za-z_$]/.test(content)) continue;
|
|
|
+ // Skip minified/generated bundles (draco, three.min, base64…): their pervasive `h[x](...)`
|
|
|
+ // calls + single-letter `{a:b}` literals are a false-positive minefield. Average line
|
|
|
+ // length is the reliable tell — real source ~30–80, minified in the hundreds/thousands.
|
|
|
+ const newlines = (content.match(/\n/g)?.length ?? 0) + 1;
|
|
|
+ if (content.length / newlines > 200) continue;
|
|
|
+ const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
|
|
|
+
|
|
|
+ // 1. Dispatch sites: `(new )?<ref>[<ident-key>]` followed by a call or a chained method.
|
|
|
+ // A quoted-string key (`['save']`) does NOT match — that's a static access, not dispatch.
|
|
|
+ REGISTRY_DISPATCH_RE.lastIndex = 0;
|
|
|
+ const dispatches: Array<{ ref: string; line: number; chained: string | null }> = [];
|
|
|
+ let dm: RegExpExecArray | null;
|
|
|
+ while ((dm = REGISTRY_DISPATCH_RE.exec(safe))) {
|
|
|
+ const win = safe.slice(dm.index, dm.index + 160);
|
|
|
+ const cm = /\]\s*\([^)]*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win) || /\]\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win);
|
|
|
+ dispatches.push({ ref: dm[1]!, line: safe.slice(0, dm.index).split('\n').length, chained: cm ? cm[1]! : null });
|
|
|
+ }
|
|
|
+ if (!dispatches.length) continue;
|
|
|
+ // Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`)
|
|
|
+ // matches a `this.commands[k]` dispatch, not just the constructor form `this.commands = {…}`.
|
|
|
+ const norm = (r: string) => r.replace(/^this\./, '');
|
|
|
+ const refs = new Set(dispatches.map((d) => norm(d.ref)));
|
|
|
+
|
|
|
+ // 2. Registries: an object literal assigned to a dispatched ref, ≥2 entries resolving to callables.
|
|
|
+ REGISTRY_ASSIGN_RE.lastIndex = 0;
|
|
|
+ const registries = new Map<string, { names: string[]; line: number }>();
|
|
|
+ let am: RegExpExecArray | null;
|
|
|
+ while ((am = REGISTRY_ASSIGN_RE.exec(safe))) {
|
|
|
+ const lhs = norm(am[1] ?? am[2]!);
|
|
|
+ if (!refs.has(lhs) || registries.has(lhs)) continue;
|
|
|
+ const body = braceBody(safe, am.index + am[0].length - 1);
|
|
|
+ if (!body) continue;
|
|
|
+ const names = registryEntryNames(body); // depth-0 `key: Identifier` entries only
|
|
|
+ if (names.length >= REGISTRY_MIN_ENTRIES) {
|
|
|
+ registries.set(lhs, { names, line: safe.slice(0, am.index).split('\n').length });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!registries.size) continue;
|
|
|
+
|
|
|
+ // 3. Link each dispatcher → each registered handler's callable entry.
|
|
|
+ const nodesInFile = ctx.getNodesInFile(file);
|
|
|
+ for (const d of dispatches) {
|
|
|
+ const reg = registries.get(norm(d.ref));
|
|
|
+ if (!reg) continue;
|
|
|
+ const disp = enclosingFn(nodesInFile, d.line);
|
|
|
+ if (!disp) continue;
|
|
|
+ let added = 0;
|
|
|
+ for (const name of reg.names) {
|
|
|
+ if (added >= REGISTRY_FANOUT_CAP) break;
|
|
|
+ const target = resolveRegistryHandler(ctx, name, d.chained);
|
|
|
+ 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: d.line,
|
|
|
+ provenance: 'heuristic',
|
|
|
+ metadata: { synthesizedBy: 'object-registry', via: name, registeredAt: `${file}:${reg.line}` },
|
|
|
+ });
|
|
|
+ added++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return edges;
|
|
|
+}
|
|
|
+
|
|
|
+// ── RTK Query generated-hook → endpoint ──────────────────────────────────────
|
|
|
+// RTK Query generates one `useGetXQuery`/`useUpdateYMutation` hook per endpoint
|
|
|
+// (`createApi({ endpoints: b => ({ getX: b.query(...) }) })`). Components call the
|
|
|
+// hook; the fetch logic lives in the endpoint's queryFn. The hook↔endpoint link is
|
|
|
+// pure NAMING CONVENTION (no static edge): strip `use` + the optional `Lazy`
|
|
|
+// variant + the `Query|Mutation` suffix, lowercase the head → the endpoint key.
|
|
|
+// Both are extracted as function nodes (the hook from its `export const {…}=api`
|
|
|
+// binding, carrying a sentinel signature; the endpoint from the createApi object),
|
|
|
+// so bridging hook→endpoint connects `component → useGetXQuery → getX → queryFn`.
|
|
|
+// Gated on the extraction sentinel so it only ever fires on genuinely-generated
|
|
|
+// hooks (never a hand-written `useFooQuery`), and on a SAME-FILE endpoint (RTK
|
|
|
+// colocates the hooks and their api in one module) — 0 on any non-RTK repo.
|
|
|
+const RTK_HOOK_DERIVE_RE = /^use([A-Z][A-Za-z0-9]*?)(?:Query|Mutation)$/;
|
|
|
+// MUST match the signature set in tree-sitter.ts `extractRtkHookBindings`.
|
|
|
+const RTK_GENERATED_HOOK_SIGNATURE = '= RTK Query generated hook';
|
|
|
+
|
|
|
+/** Derive the endpoint key from a generated-hook name (`useLazyGetRecordsQuery`
|
|
|
+ * → `getRecords`), or null if it doesn't fit the convention. */
|
|
|
+function rtkEndpointNameFromHook(hook: string): string | null {
|
|
|
+ const m = RTK_HOOK_DERIVE_RE.exec(hook);
|
|
|
+ if (!m) return null;
|
|
|
+ let mid = m[1]!;
|
|
|
+ if (mid.startsWith('Lazy')) mid = mid.slice(4); // useLazyGetX → getX (same endpoint)
|
|
|
+ if (!mid) return null;
|
|
|
+ return mid.charAt(0).toLowerCase() + mid.slice(1);
|
|
|
+}
|
|
|
+
|
|
|
+function rtkQueryEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
|
|
|
+ const edges: Edge[] = [];
|
|
|
+ const seen = new Set<string>();
|
|
|
+ for (const hook of queries.iterateNodesByKind('function')) {
|
|
|
+ // Only our extracted generated-hook bindings (sentinel) — not a real hook fn.
|
|
|
+ if (hook.signature !== RTK_GENERATED_HOOK_SIGNATURE) continue;
|
|
|
+ const endpointName = rtkEndpointNameFromHook(hook.name);
|
|
|
+ if (!endpointName) continue;
|
|
|
+ // The endpoint is a same-file function by the derived name (RTK colocates the
|
|
|
+ // api definition and its generated-hook exports in one module).
|
|
|
+ const target = ctx
|
|
|
+ .getNodesByName(endpointName)
|
|
|
+ .find((n) => n.kind === 'function' && n.filePath === hook.filePath);
|
|
|
+ if (!target || target.id === hook.id) continue;
|
|
|
+ const key = `${hook.id}>${target.id}`;
|
|
|
+ if (seen.has(key)) continue;
|
|
|
+ seen.add(key);
|
|
|
+ edges.push({
|
|
|
+ source: hook.id,
|
|
|
+ target: target.id,
|
|
|
+ kind: 'calls',
|
|
|
+ line: hook.startLine,
|
|
|
+ provenance: 'heuristic',
|
|
|
+ metadata: { synthesizedBy: 'rtk-query', via: endpointName, registeredAt: `${hook.filePath}:${hook.startLine}` },
|
|
|
+ });
|
|
|
+ }
|
|
|
+ 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;
|
|
|
+}
|
|
|
+
|
|
|
+// ── 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;
|
|
|
+}
|
|
|
+
|
|
|
+// ── Celery task dispatch (Python) ─────────────────────────────────────────────
|
|
|
+// Celery decouples a task's call site from its body through async dispatch:
|
|
|
+// # tasks.py
|
|
|
+// @shared_task # also @app.task / @celery_app.task / @<app>.task / @task
|
|
|
+// def process(account_ids): ...
|
|
|
+// # views.py — a DIFFERENT module
|
|
|
+// process.apply_async(kwargs={...}) # or process.delay(...) — dynamic, no static edge
|
|
|
+// Bridge it: link the enclosing function/method at each `.delay(`/`.apply_async(` site → the
|
|
|
+// task function body. Precision rests on the DECORATOR gate — the dispatched name must resolve
|
|
|
+// to a Python function carrying a celery task decorator (read from the source lines above its
|
|
|
+// `def`, since the def's own startLine excludes the decorator). A `.delay()` on a non-task
|
|
|
+// object resolves to no task node → no edge, so a Celery-free repo yields 0. Same-file /
|
|
|
+// unique-candidate disambiguation like vuex. (Canvas forms — `group(t).delay()`, `t.s()`/`.si()`
|
|
|
+// — have no single identifier before `.delay`/`.apply_async`, so they're skipped, not mis-bridged.)
|
|
|
+const CELERY_DISPATCH_RE = /\b([A-Za-z_]\w*)\s*\.\s*(?:delay|apply_async)\s*\(/g;
|
|
|
+// A task decorator: bare `@shared_task`/`@task` or attribute `@app.task`/`@celery_app.task`,
|
|
|
+// each optionally called with args. `\b`-bounded and `@`-anchored so `@mytask`, or a symbol
|
|
|
+// merely named `task`, can't match. No `/g`, so `.test()` is stateless across reuse.
|
|
|
+const CELERY_TASK_DECORATOR_RE = /@\s*(?:[A-Za-z_][\w.]*\.)?(?:shared_task|task)\b/;
|
|
|
+const CELERY_PY_EXT = /\.py$/;
|
|
|
+const CELERY_FANOUT_CAP = 80;
|
|
|
+const CELERY_DECORATOR_LOOKBACK = 12; // max lines above a `def` to scan for its decorators
|
|
|
+
|
|
|
+function celeryDispatchEdges(ctx: ResolutionContext): Edge[] {
|
|
|
+ // Memoize the decorator check per task-candidate node: it reads the file and scans a few
|
|
|
+ // lines above the def. Only called on names that are actually `.delay`/`.apply_async`
|
|
|
+ // receivers, so the candidate set stays small.
|
|
|
+ const taskCache = new Map<string, boolean>();
|
|
|
+ const isCeleryTask = (node: Node): boolean => {
|
|
|
+ let v = taskCache.get(node.id);
|
|
|
+ if (v !== undefined) return v;
|
|
|
+ v = false;
|
|
|
+ if (node.kind === 'function' && CELERY_PY_EXT.test(node.filePath)) {
|
|
|
+ const content = ctx.readFile(node.filePath);
|
|
|
+ if (content) {
|
|
|
+ const lines = content.split('\n');
|
|
|
+ // startLine is the `def` line (decorators sit ABOVE it). Walk upward, stopping at the
|
|
|
+ // previous declaration so a non-task def can never inherit the prior def's decorator.
|
|
|
+ const stop = Math.max(0, node.startLine - 1 - CELERY_DECORATOR_LOOKBACK);
|
|
|
+ for (let i = node.startLine - 2; i >= stop; i--) {
|
|
|
+ const t = (lines[i] ?? '').trim();
|
|
|
+ if (/^(?:async\s+def|def|class)\b/.test(t)) break; // previous decl → stop
|
|
|
+ if (CELERY_TASK_DECORATOR_RE.test(t)) { v = true; break; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ taskCache.set(node.id, v);
|
|
|
+ return v;
|
|
|
+ };
|
|
|
+
|
|
|
+ const resolve = (name: string, dispatchFile: string): Node | null => {
|
|
|
+ const cands = ctx.getNodesByName(name).filter((n) => n.kind === 'function' && isCeleryTask(n));
|
|
|
+ if (!cands.length) return null;
|
|
|
+ if (cands.length === 1) return cands[0]!;
|
|
|
+ // Cross-module name collision: prefer a task defined in the dispatching file, else bail
|
|
|
+ // (ambiguous — precision over recall, like vuex's root-key resolution).
|
|
|
+ return cands.find((c) => c.filePath === dispatchFile) ?? null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const edges: Edge[] = [];
|
|
|
+ const seen = new Set<string>();
|
|
|
+ for (const file of ctx.getAllFiles()) {
|
|
|
+ if (!CELERY_PY_EXT.test(file)) continue;
|
|
|
+ const content = ctx.readFile(file);
|
|
|
+ if (!content || (!content.includes('.delay(') && !content.includes('.apply_async('))) continue;
|
|
|
+ const safe = stripCommentsForRegex(content, 'python');
|
|
|
+ const nodesInFile = ctx.getNodesInFile(file);
|
|
|
+ CELERY_DISPATCH_RE.lastIndex = 0;
|
|
|
+ let m: RegExpExecArray | null;
|
|
|
+ let added = 0;
|
|
|
+ while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) {
|
|
|
+ const name = m[1]!;
|
|
|
+ const line = safe.slice(0, m.index).split('\n').length;
|
|
|
+ const disp = enclosingFn(nodesInFile, line);
|
|
|
+ if (!disp) continue; // module-level dispatch — no source symbol to attribute
|
|
|
+ const target = resolve(name, file);
|
|
|
+ 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: 'celery-dispatch', via: name, 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).
|
|
|
+ * Redux-thunk dispatch chain + object-literal registry dispatch + RTK Query
|
|
|
+ * generated-hook → endpoint + Pinia useStore().action() + Vuex string dispatch +
|
|
|
+ * Celery task .delay()/.apply_async() → task body).
|
|
|
* Returns the count added. Never throws into indexing — callers wrap in try/catch.
|
|
|
*/
|
|
|
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
|
|
|
@@ -1746,6 +2231,11 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
|
|
const mybatisEdges = mybatisJavaXmlEdges(queries);
|
|
|
const ginEdges = ginMiddlewareChainEdges(queries, ctx);
|
|
|
const thunkEdges = reduxThunkEdges(queries, ctx);
|
|
|
+ const registryEdges = objectRegistryEdges(ctx);
|
|
|
+ const rtkEdges = rtkQueryEdges(queries, ctx);
|
|
|
+ const piniaEdges = piniaStoreEdges(ctx);
|
|
|
+ const vuexEdges = vuexDispatchEdges(ctx);
|
|
|
+ const celeryEdges = celeryDispatchEdges(ctx);
|
|
|
|
|
|
const merged: Edge[] = [];
|
|
|
const seen = new Set<string>();
|
|
|
@@ -1770,6 +2260,11 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
|
|
...mybatisEdges,
|
|
|
...ginEdges,
|
|
|
...thunkEdges,
|
|
|
+ ...registryEdges,
|
|
|
+ ...rtkEdges,
|
|
|
+ ...piniaEdges,
|
|
|
+ ...vuexEdges,
|
|
|
+ ...celeryEdges,
|
|
|
]) {
|
|
|
const key = `${e.source}>${e.target}`;
|
|
|
if (seen.has(key)) continue;
|