Skip to content

Commit 45630ad

Browse files
committed
fix(extractor): strip brackets from computed string-key method names
Computed class/object-literal methods like `['myMethod']()` were stored with the full bracket-quoted text as their name (`['myMethod']`), while call sites using `obj.myMethod()` look up the plain name `myMethod`. This caused silent resolution failures — no call edge was created even though the method was correctly defined. Fix: the four WASM extraction paths that produce method names now check whether the name node is `computed_property_name` and, if so, extract the inner string literal value (stripping `['` and `']`). Non-string computed keys like `[Symbol.iterator]` are skipped — they cannot be resolved at dot-notation call sites and are not useful in the graph. The same fix is applied to the native Rust `handle_method_def`. Affected paths (WASM): - handleMethodDef (walk path — primary extraction) - handleMethodCapture (query path — used by wasm-worker) - extractObjectLiteralFunctions (var-bound object literals) - runContextCollectorWalk funcStack push (PTS/spread-for-of tracking) Closes #1517
1 parent bd12eb0 commit 45630ad

4 files changed

Lines changed: 286 additions & 18 deletions

File tree

crates/codegraph-core/src/extractors/javascript.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -954,7 +954,29 @@ fn handle_class_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
954954

955955
fn handle_method_def(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
956956
if let Some(name_node) = node.child_by_field_name("name") {
957-
let method_name = node_text(&name_node, source);
957+
// For computed property names (`['methodName']`), extract the inner string
958+
// literal so the stored name matches the plain identifier used at call sites
959+
// (e.g. `obj.methodName()`). Non-string computed keys like `[Symbol.iterator]`
960+
// cannot be resolved at dot-notation call sites, so we skip them.
961+
let method_name_owned: String;
962+
let method_name: &str = if name_node.kind() == "computed_property_name" {
963+
// child(0)='[', child(1)=string literal, child(2)=']'
964+
let inner = name_node.child(1);
965+
match inner {
966+
Some(inner) if inner.kind() == "string" => {
967+
let s = node_text(&inner, source);
968+
let stripped = s.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
969+
.or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
970+
.unwrap_or(s);
971+
if stripped.is_empty() { return; }
972+
method_name_owned = stripped.to_string();
973+
&method_name_owned
974+
}
975+
_ => return, // non-string computed key — skip
976+
}
977+
} else {
978+
node_text(&name_node, source)
979+
};
958980
let parent_class = find_parent_class(node, source);
959981
let full_name = match parent_class {
960982
Some(cls) => format!("{}.{}", cls, method_name),

src/extractors/javascript.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,19 @@ function handleClassCapture(
169169

170170
/** Handle method_definition capture. */
171171
function handleMethodCapture(c: Record<string, TreeSitterNode>, definitions: Definition[]): void {
172-
const methName = c.meth_name!.text;
172+
const methNameNode = c.meth_name!;
173+
let methName: string;
174+
if (methNameNode.type === 'computed_property_name') {
175+
// Extract the inner string literal from `['methodName']` or `["methodName"]`.
176+
// Non-string computed keys (e.g. `[Symbol.iterator]`) cannot be resolved at
177+
// dot-notation call sites, so skip them entirely.
178+
const inner = methNameNode.child(1); // child(0)='[', child(1)=string, child(2)=']'
179+
if (!inner || (inner.type !== 'string' && inner.type !== 'string_fragment')) return;
180+
methName = inner.text.replace(/^['"]|['"]$/g, '');
181+
if (!methName) return;
182+
} else {
183+
methName = methNameNode.text;
184+
}
173185
const parentClass = findParentClass(c.meth_node!);
174186
const fullName = parentClass ? `${parentClass}.${methName}` : methName;
175187
const methChildren = extractParameters(c.meth_node!);
@@ -825,8 +837,20 @@ function handleClassDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
825837
function handleMethodDef(node: TreeSitterNode, ctx: ExtractorOutput): void {
826838
const nameNode = node.childForFieldName('name');
827839
if (nameNode) {
840+
let methName: string;
841+
if (nameNode.type === 'computed_property_name') {
842+
// Extract the inner string literal from `['methodName']` or `["methodName"]`.
843+
// Non-string computed keys (e.g. `[Symbol.iterator]`) cannot be resolved at
844+
// dot-notation call sites, so skip them entirely.
845+
const inner = nameNode.child(1); // child(0)='[', child(1)=string, child(2)=']'
846+
if (!inner || (inner.type !== 'string' && inner.type !== 'string_fragment')) return;
847+
methName = inner.text.replace(/^['"]|['"]$/g, '');
848+
if (!methName) return;
849+
} else {
850+
methName = nameNode.text;
851+
}
828852
const parentClass = findParentClass(node);
829-
const fullName = parentClass ? `${parentClass}.${nameNode.text}` : nameNode.text;
853+
const fullName = parentClass ? `${parentClass}.${methName}` : methName;
830854
const methChildren = extractParameters(node);
831855
const methVis = extractVisibility(node);
832856
ctx.definitions.push({
@@ -1084,8 +1108,19 @@ function extractObjectLiteralFunctions(
10841108
} else if (child.type === 'method_definition') {
10851109
const nameNode = child.childForFieldName('name');
10861110
if (nameNode) {
1111+
let methodName: string;
1112+
if (nameNode.type === 'computed_property_name') {
1113+
// Strip brackets+quotes from `['methodName']` to get a resolvable name.
1114+
// Skip non-string computed keys (e.g. [Symbol.iterator]).
1115+
const inner = nameNode.child(1);
1116+
if (!inner || (inner.type !== 'string' && inner.type !== 'string_fragment')) continue;
1117+
methodName = inner.text.replace(/^['"]|['"]$/g, '');
1118+
if (!methodName) continue;
1119+
} else {
1120+
methodName = nameNode.text;
1121+
}
10871122
definitions.push({
1088-
name: `${varName}.${nameNode.text}`,
1123+
name: `${varName}.${methodName}`,
10891124
kind: 'function',
10901125
line: nodeStartLine(child),
10911126
endLine: nodeEndLine(child),
@@ -1832,9 +1867,23 @@ function runContextCollectorWalk(rootNode: TreeSitterNode, out: ContextCollector
18321867
// Qualify with the enclosing class name so the PTS key matches
18331868
// callerName from findCaller (which uses def.name = 'ClassName.method').
18341869
const enclosingClass = classStack.length > 0 ? classStack[classStack.length - 1] : null;
1835-
const qualifiedName = enclosingClass ? `${enclosingClass}.${nameNode.text}` : nameNode.text;
1836-
funcStack.push(qualifiedName);
1837-
pushedFunc = true;
1870+
let rawName: string;
1871+
if (nameNode.type === 'computed_property_name') {
1872+
const inner = nameNode.child(1);
1873+
if (!inner || (inner.type !== 'string' && inner.type !== 'string_fragment')) {
1874+
// Non-string computed key — skip adding to funcStack (no resolvable name).
1875+
rawName = '';
1876+
} else {
1877+
rawName = inner.text.replace(/^['"]|['"]$/g, '');
1878+
}
1879+
} else {
1880+
rawName = nameNode.text;
1881+
}
1882+
if (rawName) {
1883+
const qualifiedName = enclosingClass ? `${enclosingClass}.${rawName}` : rawName;
1884+
funcStack.push(qualifiedName);
1885+
pushedFunc = true;
1886+
}
18381887
}
18391888
} else if (t === 'variable_declarator') {
18401889
// `const process = (arr) => { ... }` — arrow/expression functions assigned
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* Integration test for #1517: computed property name method resolution at call sites.
3+
*
4+
* PR #1509 added extraction of computed class/object-literal method names like
5+
* `['myMethod']()`. The name was stored with brackets (`['myMethod']`), but call
6+
* sites use the plain property name (`obj.myMethod()`), causing silent resolution
7+
* failures — no call edge was created even though the method existed.
8+
*
9+
* Fix: handleMethodCapture (WASM) and handle_method_def (native) now strip the
10+
* brackets and quotes from string-literal computed keys, storing the method as
11+
* `myMethod` (or `ClassName.myMethod` for class methods). Non-string computed
12+
* keys like `[Symbol.iterator]` are skipped — they cannot be resolved at
13+
* dot-notation call sites.
14+
*/
15+
16+
import fs from 'node:fs';
17+
import os from 'node:os';
18+
import path from 'node:path';
19+
import Database from 'better-sqlite3';
20+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
21+
import { buildGraph } from '../../src/domain/graph/builder.js';
22+
23+
const FIXTURE = {
24+
'service.js': `
25+
class ApiClient {
26+
['fetchData'](url) {
27+
return url;
28+
}
29+
['postData'](url, body) {
30+
return body;
31+
}
32+
regularMethod() {
33+
return 42;
34+
}
35+
}
36+
37+
const client = new ApiClient();
38+
client.fetchData('https://example.com');
39+
client.postData('https://example.com', {});
40+
client.regularMethod();
41+
`,
42+
43+
'utils.js': `
44+
const helpers = {
45+
['formatDate'](date) {
46+
return date.toString();
47+
},
48+
['parseQuery'](str) {
49+
return str;
50+
},
51+
regularHelper() {
52+
return true;
53+
},
54+
};
55+
56+
helpers.formatDate(new Date());
57+
helpers.parseQuery('foo=bar');
58+
helpers.regularHelper();
59+
`,
60+
};
61+
62+
let tmpDir: string;
63+
64+
beforeAll(async () => {
65+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1517-'));
66+
for (const [rel, content] of Object.entries(FIXTURE)) {
67+
fs.writeFileSync(path.join(tmpDir, rel), content);
68+
}
69+
await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true });
70+
});
71+
72+
afterAll(() => {
73+
fs.rmSync(tmpDir, { recursive: true, force: true });
74+
});
75+
76+
function readCallEdges(dbPath: string) {
77+
const db = new Database(dbPath, { readonly: true });
78+
try {
79+
return db
80+
.prepare(
81+
`SELECT n1.name AS src, n2.name AS tgt
82+
FROM edges e
83+
JOIN nodes n1 ON e.source_id = n1.id
84+
JOIN nodes n2 ON e.target_id = n2.id
85+
WHERE e.kind = 'calls'
86+
ORDER BY n1.name, n2.name`,
87+
)
88+
.all() as Array<{ src: string; tgt: string }>;
89+
} finally {
90+
db.close();
91+
}
92+
}
93+
94+
function readNodes(dbPath: string) {
95+
const db = new Database(dbPath, { readonly: true });
96+
try {
97+
return db.prepare('SELECT name, kind FROM nodes ORDER BY name').all() as Array<{
98+
name: string;
99+
kind: string;
100+
}>;
101+
} finally {
102+
db.close();
103+
}
104+
}
105+
106+
describe('computed property name method resolution (#1517)', () => {
107+
it('stores computed class method under plain name (no brackets)', () => {
108+
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
109+
const nodes = readNodes(dbPath);
110+
const fetchDataNode = nodes.find((n) => n.name === 'ApiClient.fetchData');
111+
expect(
112+
fetchDataNode,
113+
'ApiClient.fetchData node missing — computed method name stored with brackets instead of plain name',
114+
).toBeDefined();
115+
expect(fetchDataNode!.kind).toBe('method');
116+
});
117+
118+
it('stores computed object-literal method under plain name (no brackets)', () => {
119+
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
120+
const nodes = readNodes(dbPath);
121+
const formatDateNode = nodes.find((n) => n.name === 'formatDate');
122+
expect(
123+
formatDateNode,
124+
'formatDate node missing — computed method name stored with brackets instead of plain name',
125+
).toBeDefined();
126+
expect(formatDateNode!.kind).toBe('method');
127+
});
128+
129+
it('does not store any node with brackets in its name from computed keys', () => {
130+
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
131+
const nodes = readNodes(dbPath);
132+
const bracketedNodes = nodes.filter((n) => n.name.includes('['));
133+
expect(
134+
bracketedNodes,
135+
`Found nodes with brackets in name (bracket representation leaked): ${bracketedNodes.map((n) => n.name).join(', ')}`,
136+
).toHaveLength(0);
137+
});
138+
139+
it('resolves call to computed class method at dot-notation call site', () => {
140+
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
141+
const edges = readCallEdges(dbPath);
142+
const edge = edges.find((e) => e.tgt === 'ApiClient.fetchData');
143+
expect(
144+
edge,
145+
'No call edge to ApiClient.fetchData — computed class method not resolvable at call site',
146+
).toBeDefined();
147+
});
148+
149+
it('resolves call to second computed class method at dot-notation call site', () => {
150+
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
151+
const edges = readCallEdges(dbPath);
152+
const edge = edges.find((e) => e.tgt === 'ApiClient.postData');
153+
expect(
154+
edge,
155+
'No call edge to ApiClient.postData — computed class method not resolvable at call site',
156+
).toBeDefined();
157+
});
158+
159+
it('resolves call to regular (non-computed) class method at dot-notation call site', () => {
160+
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
161+
const edges = readCallEdges(dbPath);
162+
const edge = edges.find((e) => e.tgt === 'ApiClient.regularMethod');
163+
expect(
164+
edge,
165+
'No call edge to ApiClient.regularMethod — regular method resolution broken by fix',
166+
).toBeDefined();
167+
});
168+
169+
it('resolves call to computed object-literal method at dot-notation call site', () => {
170+
const dbPath = path.join(tmpDir, '.codegraph', 'graph.db');
171+
const edges = readCallEdges(dbPath);
172+
const edge = edges.find((e) => e.tgt === 'formatDate');
173+
expect(
174+
edge,
175+
'No call edge to formatDate — computed object-literal method not resolvable at call site',
176+
).toBeDefined();
177+
});
178+
});

tests/parsers/javascript.test.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1266,46 +1266,65 @@ describe('JavaScript parser', () => {
12661266
});
12671267
});
12681268

1269-
describe('computed method name extraction (#1471)', () => {
1270-
it('extracts computed getter method from object literal', () => {
1269+
describe('computed method name extraction (#1471, #1517)', () => {
1270+
it('extracts computed getter with plain name (strips brackets+quotes)', () => {
12711271
const symbols = parseJS(`const obj = { get ['property7']() {} };`);
12721272
expect(symbols.definitions).toContainEqual(
1273-
expect.objectContaining({ name: "['property7']", kind: 'method' }),
1273+
expect.objectContaining({ name: 'property7', kind: 'method' }),
12741274
);
12751275
});
12761276

1277-
it('extracts computed setter method with parameter from object literal', () => {
1277+
it('extracts computed setter with plain name and preserves parameter', () => {
12781278
const symbols = parseJS(`const obj = { set ['property8'](value) {} };`);
1279-
const def = symbols.definitions.find((d) => d.name === "['property8']");
1279+
const def = symbols.definitions.find((d) => d.name === 'property8');
12801280
expect(def).toBeDefined();
12811281
expect(def).toMatchObject({ kind: 'method' });
12821282
expect(def!.children).toContainEqual(
12831283
expect.objectContaining({ name: 'value', kind: 'parameter' }),
12841284
);
12851285
});
12861286

1287-
it('extracts computed regular method with parameter from object literal', () => {
1287+
it('extracts computed regular method with plain name and preserves parameter', () => {
12881288
const symbols = parseJS(`const obj = { ['property9'](parameters) {} };`);
1289-
const def = symbols.definitions.find((d) => d.name === "['property9']");
1289+
const def = symbols.definitions.find((d) => d.name === 'property9');
12901290
expect(def).toBeDefined();
12911291
expect(def!.children).toContainEqual(
12921292
expect.objectContaining({ name: 'parameters', kind: 'parameter' }),
12931293
);
12941294
});
12951295

1296-
it('extracts computed generator method from object literal', () => {
1296+
it('extracts computed generator method with plain name', () => {
12971297
const symbols = parseJS(`const obj = { *['generator10'](parameters) {} };`);
12981298
expect(symbols.definitions).toContainEqual(
1299-
expect.objectContaining({ name: "['generator10']", kind: 'method' }),
1299+
expect.objectContaining({ name: 'generator10', kind: 'method' }),
13001300
);
13011301
});
13021302

1303-
it('extracts computed async method from object literal', () => {
1303+
it('extracts computed async method with plain name', () => {
13041304
const symbols = parseJS(`const obj = { async ['property11'](parameters) {} };`);
13051305
expect(symbols.definitions).toContainEqual(
1306-
expect.objectContaining({ name: "['property11']", kind: 'method' }),
1306+
expect.objectContaining({ name: 'property11', kind: 'method' }),
13071307
);
13081308
});
1309+
1310+
it('extracts computed class method with plain name', () => {
1311+
const symbols = parseJS(`class MyClass { ['myMethod']() { return 1; } }`);
1312+
expect(symbols.definitions).toContainEqual(
1313+
expect.objectContaining({ name: 'MyClass.myMethod', kind: 'method' }),
1314+
);
1315+
});
1316+
1317+
it('does not extract non-string computed key (Symbol.iterator)', () => {
1318+
const symbols = parseJS(`class MyClass { [Symbol.iterator]() {} }`);
1319+
const def = symbols.definitions.find((d) => d.name.includes('iterator'));
1320+
expect(def).toBeUndefined();
1321+
});
1322+
1323+
it('does not use the bracketed form in the stored name', () => {
1324+
const symbols = parseJS(`const obj = { ['property7']() {} };`);
1325+
const def = symbols.definitions.find((d) => d.name.includes('['));
1326+
expect(def).toBeUndefined();
1327+
});
13091328
});
13101329

13111330
describe('class expression inside function extraction (#1471)', () => {

0 commit comments

Comments
 (0)