Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Dynamic-dispatch analysis no longer repeatedly copies every source prefix while scanning match-dense files, avoiding quadratic work and excessive peak memory during the final resolution pass.
- Naming a file by its path in a `codegraph_explore` query now works reliably: the path is resolved against the index and that file is guaranteed a place at the top of the answer. Previously the path was broken into fragments — bracketed route segments like SvelteKit's `[id]` made this worst — and pieces like `page` or `runs` matched every sibling file, so the file you actually named could be crowded out of the answer entirely. A path that doesn't match any indexed file is now called out instead of silently ignored.
- Plainly-worded `codegraph_explore` questions now find camelCase code: a query like "auto-scroll to bottom" can reach a function named `scrollFeedToBottom`, because query words are matched against the words inside identifiers, not just whole names.
- Variables and constants now count when `codegraph_explore` picks its starting symbols, so state held in plain variables — `$state`-style variables in Svelte, for example — no longer gets overlooked.
Expand Down
5 changes: 4 additions & 1 deletion __tests__/object-registry-synthesizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export function direct() { return new table.add().execute(); }
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file
`SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file,
e.line edge_line, json_extract(e.metadata,'$.registeredAt') registered_at
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
Expand All @@ -77,6 +78,8 @@ export function direct() { return new table.add().execute(); }
expect(rows.every((r: any) => r.source_name === 'executeCommand')).toBe(true);
expect(rows.every((r: any) => r.target_kind === 'method' && r.target_name === 'execute')).toBe(true);
expect(rows.every((r: any) => /commands\.ts$/.test(r.target_file))).toBe(true);
expect(rows.every((r: any) => r.edge_line === 13)).toBe(true);
expect(rows.every((r: any) => /manager\.ts:6$/.test(r.registered_at))).toBe(true);
// The statically-accessed look-alike registry contributed nothing.
expect(rows.some((r: any) => /static\.ts$/.test(r.target_file))).toBe(false);
});
Expand Down
17 changes: 16 additions & 1 deletion __tests__/synthesis-tail-scaling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* SQL-side, and language-gates passes off the files table.
*
* These tests pin the query-level building blocks and the end-to-end kotlin
* bridge so the memory fix can't silently change what gets synthesized.
* bridge so the memory fixes can't silently change what gets synthesized.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
Expand Down Expand Up @@ -100,4 +100,19 @@ class C {
expect(langs.has('kotlin')).toBe(false);
cg.close();
});

it('does not rescan source prefixes to locate every synthesized edge', () => {
const source = fs.readFileSync(
path.resolve('src/resolution/callback-synthesizer.ts'),
'utf8'
);
const prefixRescans = source
.split('\n')
.filter((line) => !/^(?:\/\/|\*)/.test(line.trimStart()))
.filter((line) => line.includes('.slice(0,') && line.includes(".split('\\n').length"));

// Repeating this expression for every regex match makes a match-dense file O(n²).
// Wall-clock thresholds are too noisy for CI, so pin the allocation pattern directly.
expect(prefixRescans).toEqual([]);
});
});
41 changes: 27 additions & 14 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P
const content = ctx.readFile(file);
if (!content || !content.includes('emitter.')) continue;
const safe = stripCommentsForRegex(content, 'typescript');
const lineAt = makeLineAt(safe, 1);
const nodes = ctx.getNodesInFile(file)
.filter((n) => n.kind === 'method' || n.kind === 'function');

Expand All @@ -617,7 +618,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P
while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) {
const verb = m[1]!;
const arg = m[2]!.trim();
const line = safe.slice(0, m.index).split('\n').length;
const line = lineAt(m.index);
const encl = nodes
.filter((n) => n.startLine <= line && n.endLine >= line)
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
Expand Down Expand Up @@ -714,14 +715,15 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
const content = ctx.readFile(file);
if (!content || !content.includes('router.')) continue;
const safe = stripCommentsForRegex(content, 'typescript');
const lineAt = makeLineAt(safe, 1);
const nodes = ctx.getNodesInFile(file)
.filter((n) => n.kind === 'method' || n.kind === 'function');

ARKUI_ROUTER_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ARKUI_ROUTER_RE.exec(safe))) {
const url = m[1]!;
const line = safe.slice(0, m.index).split('\n').length;
const line = lineAt(m.index);
const encl = nodes
.filter((n) => n.startLine <= line && n.endLine >= line)
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
Expand Down Expand Up @@ -1961,13 +1963,14 @@ async function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionCon
const content = ctx.readFile(file);
if (!content || (!content.includes('.Use(') && !/\.(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\(/.test(content))) continue;
const safe = stripCommentsForRegex(content, 'go');
const lineAt = makeLineAt(safe, 1);
GIN_REG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = GIN_REG_RE.exec(safe))) {
const parenIdx = m.index + m[0].length - 1;
const argStr = goBalancedArgs(safe, parenIdx);
if (!argStr) continue;
const line = safe.slice(0, m.index).split('\n').length;
const line = lineAt(m.index);
for (const arg of goSplitArgs(argStr)) {
const name = goHandlerIdent(arg);
if (name && !registered.has(name)) registered.set(name, `${file}:${line}`);
Expand Down Expand Up @@ -2115,6 +2118,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on
if (!src) continue;
// Thunks are TS/JS-family (same // and /* */ comment syntax); map to a CommentLang.
const safe = stripCommentsForRegex(src, node.language === 'javascript' || node.language === 'jsx' ? 'javascript' : 'typescript');
const lineAt = makeLineAt(safe, node.startLine);
THUNK_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let added = 0;
Expand All @@ -2139,7 +2143,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on
const key = `${node.id}>${target.id}`;
if (seen.has(key)) continue;
seen.add(key);
const line = node.startLine + safe.slice(0, m.index).split('\n').length - 1;
const line = lineAt(m.index);
edges.push({
source: node.id,
target: target.id,
Expand Down Expand Up @@ -2248,6 +2252,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield):
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');
const lineAt = makeLineAt(safe, 1);

// 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.
Expand All @@ -2257,7 +2262,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield):
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 });
dispatches.push({ ref: dm[1]!, line: lineAt(dm.index), chained: cm ? cm[1]! : null });
}
if (!dispatches.length) continue;
// Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`)
Expand All @@ -2276,7 +2281,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield):
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 });
registries.set(lhs, { names, line: lineAt(am.index) });
}
}
if (!registries.size) continue;
Expand Down Expand Up @@ -2408,6 +2413,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro
const content = ctx.readFile(file);
if (!content || !content.includes('Store')) continue;
const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript');
const lineAt = makeLineAt(safe, 1);

// 2. Bind store vars in this file: `const <var> = <known-factory>(...)`.
const varStore = new Map<string, string>();
Expand All @@ -2429,7 +2435,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro
const storeFile = varStore.get(cm[1]!);
if (!storeFile) continue;
const method = cm[2]!;
const line = safe.slice(0, cm.index).split('\n').length;
const line = lineAt(cm.index);
const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher;
if (!disp) continue;
const target = ctx
Expand Down Expand Up @@ -2514,14 +2520,15 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P
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 lineAt = makeLineAt(safe, 1);
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 line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line) ?? fallback;
if (!disp) continue;
const target = resolve(key, file);
Expand Down Expand Up @@ -2611,13 +2618,14 @@ async function celeryDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield):
const content = ctx.readFile(file);
if (!content || (!content.includes('.delay(') && !content.includes('.apply_async('))) continue;
const safe = stripCommentsForRegex(content, 'python');
const lineAt = makeLineAt(safe, 1);
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 line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue; // module-level dispatch — no source symbol to attribute
const target = resolve(name, file);
Expand Down Expand Up @@ -2732,14 +2740,15 @@ async function springEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
const content = ctx.readFile(file);
if (!content || !content.includes('.publishEvent(')) continue;
const safe = stripCommentsForRegex(content, 'java');
const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
SPRING_PUBLISH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let added = 0;
while ((m = SPRING_PUBLISH_RE.exec(safe)) && added < SPRING_FANOUT_CAP) {
const targets = listeners.get(m[1]!);
if (!targets || !targets.length) continue;
const line = safe.slice(0, m.index).split('\n').length;
const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets) {
Expand Down Expand Up @@ -2845,14 +2854,15 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
const content = ctx.readFile(file);
if (!content || (!content.includes('.Send(') && !content.includes('.Publish('))) continue;
const safe = stripCommentsForRegex(content, 'csharp');
const lineAt = makeLineAt(safe, 1);
const safeLines = safe.split('\n');
const nodesInFile = ctx.getNodesInFile(file);
MEDIATR_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let added = 0;
while ((m = MEDIATR_DISPATCH_RE.exec(safe)) && added < MEDIATR_FANOUT_CAP) {
if (!MEDIATR_RECEIVER_RE.test(m[1]!)) continue; // not a mediator (MessagingCenter, HttpClient, …)
const line = safe.slice(0, m.index).split('\n').length;
const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
const type = resolveMediatrArgType(m[2]!, safeLines, disp.startLine, line);
Expand Down Expand Up @@ -2943,12 +2953,13 @@ async function sidekiqDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield)
const content = ctx.readFile(file);
if (!content || !/\.perform_(?:async|in|at)\b/.test(content)) continue;
const safe = stripCommentsForRegex(content, 'ruby');
const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
SIDEKIQ_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let added = 0;
while ((m = SIDEKIQ_DISPATCH_RE.exec(safe)) && added < SIDEKIQ_FANOUT_CAP) {
const line = safe.slice(0, m.index).split('\n').length;
const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
const target = resolve(m[1]!);
Expand Down Expand Up @@ -3296,6 +3307,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
const content = ctx.readFile(file);
if (!content || !/[A-Z][A-Za-z0-9_@]*:[a-z]/.test(content)) continue;
const safe = stripCommentsForRegex(content, 'erlang');
const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
ERLANG_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
Expand All @@ -3310,7 +3322,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti
const behaviour = behaviours[0]!;
const targets = targetsOf(behaviour, fn);
if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue;
const line = safe.slice(0, m.index).split('\n').length;
const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets) {
Expand Down Expand Up @@ -3450,14 +3462,15 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P
const content = ctx.readFile(file);
if (!content || !content.includes('event(')) continue;
const safe = stripCommentsForRegex(content, 'php');
const lineAt = makeLineAt(safe, 1);
const nodesInFile = ctx.getNodesInFile(file);
LARAVEL_DISPATCH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
let added = 0;
while ((m = LARAVEL_DISPATCH_RE.exec(safe)) && added < LARAVEL_FANOUT_CAP) {
const targets = listeners.get(phpSimpleName(m[1]!));
if (!targets) continue;
const line = safe.slice(0, m.index).split('\n').length;
const line = lineAt(m.index);
const disp = enclosingFn(nodesInFile, line);
if (!disp) continue;
for (const target of targets.values()) {
Expand Down