Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
11e70c8
feat(resolver): resolve prototype-based method calls (Foo.prototype.b…
carlos-alm Jun 5, 2026
a458aad
test(cha): add 3-level hierarchy fixture for transitive CHA closure (…
carlos-alm Jun 5, 2026
1f1b1c3
fix: resolve merge conflicts with main
carlos-alm Jun 5, 2026
e4fa7c2
fix: remove duplicate prototype extractor functions and fix format
carlos-alm Jun 5, 2026
832f7fc
fix: address review feedback — shorthand prototype props, inline-new …
carlos-alm Jun 5, 2026
253bd71
feat(resolver): track array spread and Array.from/concat/flat callbac…
carlos-alm Jun 6, 2026
4fe4f71
fix: add native orchestrator post-pass for prototype method resolution
carlos-alm Jun 6, 2026
0ffb24e
style: format test fixtures and pts test call sites
carlos-alm Jun 6, 2026
1b84435
fix: scan all files for prototype call edges, not just definition fil…
carlos-alm Jun 6, 2026
d926cf3
perf: pre-filter prototype files and remove dead seenByPair DB load (…
carlos-alm Jun 6, 2026
a6c5d2d
feat(resolver): resolve this-dispatch on function-as-object property …
carlos-alm Jun 6, 2026
c484fd1
feat(resolver): resolve property calls on object destructuring rest p…
carlos-alm Jun 6, 2026
cdc84cf
Merge remote-tracking branch 'origin/main' into feat/prototype-resolv…
carlos-alm Jun 6, 2026
4ed709e
fix(lint): apply biome auto-fixes across extractors and domain files
carlos-alm Jun 6, 2026
66b899a
fix(pts): resolve module-level for-of and class-method for-of PTS keys
carlos-alm Jun 6, 2026
667866e
fix(bench): sync JS fixture names and use super.count() in DoubleCoun…
carlos-alm Jun 6, 2026
058f0f4
Merge branch 'main' into feat/prototype-resolver-1317
carlos-alm Jun 6, 2026
194507c
fix(native): add prototype method extraction to Rust engine (#1327) (…
carlos-alm Jun 6, 2026
c036834
fix: resolve merge conflicts with main and fix duplicate paramBinding…
carlos-alm Jun 6, 2026
8407fcf
fix: extend native post-pass pre-filter to include arrow-function pro…
carlos-alm Jun 6, 2026
1f925bf
test(extractor): verify exported arrow function funcStack tracking in…
carlos-alm Jun 6, 2026
d68cf72
fix(wasm-worker): restore paramBindings in SerializedExtractorOutput,…
carlos-alm Jun 6, 2026
a20406b
fix(extractor): resolve arrow-function rest-param bindings via enclos…
carlos-alm Jun 6, 2026
0650ce9
fix(wasm-worker): remove duplicate paramBindings serialization in was…
carlos-alm Jun 6, 2026
07ce481
Merge branch 'main' into feat/prototype-resolver-1317
carlos-alm Jun 7, 2026
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
27 changes: 27 additions & 0 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,33 @@ export function resolveByMethodOrGlobal(
if (typeName) {
const typed = lookup.byName(`${typeName}.${call.name}`).filter((n) => n.kind === 'method');
if (typed.length > 0) return typed;
// Prototype alias: `Foo.prototype.bar = fn` seeds typeMap['Foo.bar'] = { type: 'fn' }
const protoAlias = (typeMap.get(`${typeName}.${call.name}`) as { type?: string } | undefined)
?.type;
if (protoAlias) {
const resolved = lookup
.byName(protoAlias)
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
if (resolved.length > 0) return resolved;
}
}
// Inline new-expression receiver: `(new Foo).bar()` — extract class name for type lookup
if (!typeName && call.receiver) {
const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inline-new regex only matches uppercase-initial constructors

The regex [A-Z_$][A-Za-z0-9_$]* restricts matching to PascalCase (or _/$-prefixed) names, so a receiver like (new animal()).move() would not be resolved. Pre-ES6 code often uses lowercase constructor functions (e.g. function animal() {}). If this constraint is intentional (to avoid false positives on non-constructor calls), it is worth documenting explicitly; otherwise changing the first character class to [A-Za-z_$] would broaden coverage.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged and documented. Added a comment to the regex explaining the uppercase-initial constraint is intentional — it acts as a heuristic to distinguish constructors (PascalCase) from regular functions, avoiding false positives on patterns like (new xmlParser()).parse(). Lowercase constructor functions are uncommon in practice and the trade-off favors precision over recall here.

if (m?.[1]) {
const inlineType = m[1];
const typed = lookup.byName(`${inlineType}.${call.name}`).filter((n) => n.kind === 'method');
if (typed.length > 0) return typed;
const protoAlias = (
typeMap.get(`${inlineType}.${call.name}`) as { type?: string } | undefined
)?.type;
if (protoAlias) {
const resolved = lookup
.byName(protoAlias)
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
if (resolved.length > 0) return resolved;
}
}
}
// Phase 8.3d: composite pts key — `obj.prop = fn` seeds typeMap['obj.prop'] = { type: 'fn' }.
// When a call site references `obj.prop` as a callback, resolve directly to the target fn.
Expand Down
123 changes: 123 additions & 0 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,9 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
// Extract definitions from destructured bindings (query patterns don't match object_pattern)
extractDestructuredBindingsWalk(tree.rootNode, definitions);

// Pre-ES6 prototype methods: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }`
extractPrototypeMethodsWalk(tree.rootNode, definitions, typeMap);

// Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking
const newExpressions: string[] = [];
extractNewExpressionsWalk(tree.rootNode, newExpressions);
Expand Down Expand Up @@ -614,6 +617,8 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput {
);
// Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis
extractParamBindingsWalk(tree.rootNode, ctx.paramBindings!);
// Pre-ES6 prototype methods: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }`
extractPrototypeMethodsWalk(tree.rootNode, ctx.definitions, ctx.typeMap!);
// Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking
const newExpressions: string[] = [];
extractNewExpressionsWalk(tree.rootNode, newExpressions);
Expand Down Expand Up @@ -1397,6 +1402,124 @@ function extractNewExpressionsWalk(rootNode: TreeSitterNode, newExpressions: str
walk(rootNode, 0);
}

/** AST node types that represent a function body (used by prototype extraction). */
const PROTO_FN_TYPES = new Set([
'function_expression',
'arrow_function',
'generator_function',
]);

/**
* Walk the AST collecting pre-ES6 prototype assignments:
* Foo.prototype.bar = function() {} → definition Foo.bar (kind: method)
* Foo.prototype.bar = f → typeMap['Foo.bar'] = { type: 'f', confidence: 0.9 }
* Foo.prototype = { bar: fn, baz: f } → same rules per property
*/
function extractPrototypeMethodsWalk(
rootNode: TreeSitterNode,
definitions: Definition[],
typeMap: Map<string, TypeMapEntry>,
): void {
function walk(node: TreeSitterNode, depth: number): void {
if (depth >= MAX_WALK_DEPTH) return;
if (node.type === 'expression_statement') {
const expr = node.child(0);
if (expr?.type === 'assignment_expression') handlePrototypeAssignment(expr, definitions, typeMap);
// Don't recurse inside expression_statement — no nested prototype assignments expected
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 IIFE wrapping silently skips nested prototype assignments

When an expression_statement's first child is not an assignment_expression (e.g. an IIFE call), the walk returns early without recursing into it. The very common pre-ES6 module pattern wraps all definitions in an IIFE — (function() { Foo.prototype.bar = function(){}; })(); — and every prototype assignment inside would be silently ignored. The comment "no nested prototype assignments expected" overstates the guarantee; removing the early return and simply calling handlePrototypeAssignment only when a child is an assignment_expression — then falling through to the normal child loop — would cover this case without any extra cost.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the IIFE concern was already addressed in the version merged from main. The extractPrototypeMethodsWalk walk in the current code does NOT have an early return after expression_statement; the for loop always runs, so IIFE children (including nested prototype assignments) are recursed into correctly. The old first-set duplicate code (which had the early return) was removed in the fix: resolve merge conflicts with main commit.

}
for (let i = 0; i < node.childCount; i++) {
walk(node.child(i)!, depth + 1);
}
}
walk(rootNode, 0);
}

function handlePrototypeAssignment(
assignNode: TreeSitterNode,
definitions: Definition[],
typeMap: Map<string, TypeMapEntry>,
): void {
const lhs = assignNode.childForFieldName('left');
const rhs = assignNode.childForFieldName('right');
if (!lhs || !rhs || lhs.type !== 'member_expression') return;

const lhsProp = lhs.childForFieldName('property');
const lhsObj = lhs.childForFieldName('object');
if (!lhsProp || !lhsObj) return;

// Pattern 1: Foo.prototype.bar = rhs
if (lhsObj.type === 'member_expression') {
const innerProp = lhsObj.childForFieldName('property');
const innerObj = lhsObj.childForFieldName('object');
if (
innerProp?.text === 'prototype' &&
innerObj?.type === 'identifier' &&
!BUILTIN_GLOBALS.has(innerObj.text)
) {
emitPrototypeMethod(innerObj.text, lhsProp.text, rhs, definitions, typeMap);
}
return;
}

// Pattern 2: Foo.prototype = { ... }
if (
lhsProp.text === 'prototype' &&
lhsObj.type === 'identifier' &&
!BUILTIN_GLOBALS.has(lhsObj.text) &&
(rhs.type === 'object' || rhs.type === 'object_expression')
) {
extractPrototypeObjectLiteral(lhsObj.text, rhs, definitions, typeMap);
}
}

function emitPrototypeMethod(
className: string,
methodName: string,
rhs: TreeSitterNode,
definitions: Definition[],
typeMap: Map<string, TypeMapEntry>,
): void {
const qualifiedName = `${className}.${methodName}`;
if (PROTO_FN_TYPES.has(rhs.type)) {
definitions.push({
name: qualifiedName,
kind: 'method',
line: nodeStartLine(rhs),
endLine: nodeEndLine(rhs),
});
} else if (rhs.type === 'identifier' && !BUILTIN_GLOBALS.has(rhs.text)) {
setTypeMapEntry(typeMap, qualifiedName, rhs.text, 0.9);
}
}

function extractPrototypeObjectLiteral(
className: string,
objNode: TreeSitterNode,
definitions: Definition[],
typeMap: Map<string, TypeMapEntry>,
): void {
for (let i = 0; i < objNode.childCount; i++) {
const child = objNode.child(i);
if (!child) continue;
if (child.type === 'pair') {
const key = child.childForFieldName('key');
const value = child.childForFieldName('value');
if (key && value) emitPrototypeMethod(className, key.text, value, definitions, typeMap);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 String-literal keys produce malformed definition names

For object-literal prototype assignments where a key is a quoted string — e.g. { 'bar': function(){} }key.text returns 'bar' (quotes included), so emitPrototypeMethod is called with methodName = "'bar'" and the resulting definition name becomes Foo.'bar'. This name will never match a call-site lookup. A guard like key.type === 'property_identifier' || key.type === 'identifier' would filter these out, or the text could be stripped of surrounding quotes when the node type is string.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the key.text.replace(/['"]/g, '') guard for string-literal keys was already present in the version merged from main. The extractPrototypeObjectLiteral function already handles this: const methodName = keyNode.type === 'string' ? keyNode.text.replace(/['"]/g, '') : keyNode.text;

} else if (child.type === 'method_definition') {
const nameNode = child.childForFieldName('name');
if (nameNode) {
definitions.push({
name: `${className}.${nameNode.text}`,
kind: 'method',
line: nodeStartLine(child),
endLine: nodeEndLine(child),
});
}
}
}
}

/**
* Extract variable-to-type assignments into a per-file type map.
*
Expand Down
68 changes: 68 additions & 0 deletions tests/parsers/javascript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,4 +734,72 @@ describe('JavaScript parser', () => {
expect(def.endLine).toBe(4);
});
});

describe('prototype method extraction', () => {
it('extracts Foo.prototype.bar = function() {} as a method definition', () => {
const symbols = parseJS(`
function C() {}
C.prototype.foo = function() {}
`);
expect(symbols.definitions).toContainEqual(
expect.objectContaining({ name: 'C.foo', kind: 'method' }),
);
});

it('extracts Foo.prototype.bar = arrow as a method definition', () => {
const symbols = parseJS(`
function C() {}
C.prototype.greet = () => 'hello';
`);
expect(symbols.definitions).toContainEqual(
expect.objectContaining({ name: 'C.greet', kind: 'method' }),
);
});

it('seeds typeMap for Foo.prototype.bar = identifier with confidence 0.9', () => {
const symbols = parseJS(`
const f = () => {};
class A {}
A.prototype.t = f;
`);
expect(symbols.typeMap.get('A.t')).toEqual({ type: 'f', confidence: 0.9 });
});

it('extracts methods from Foo.prototype = { bar: fn } object literal', () => {
const symbols = parseJS(`
function C() {}
C.prototype = {
foo: function() {},
baz: function() {},
};
`);
expect(symbols.definitions).toContainEqual(
expect.objectContaining({ name: 'C.foo', kind: 'method' }),
);
expect(symbols.definitions).toContainEqual(
expect.objectContaining({ name: 'C.baz', kind: 'method' }),
);
});

it('seeds typeMap for identifier values in object literal prototype assignment', () => {
const symbols = parseJS(`
function helper() {}
function C() {}
C.prototype = { run: helper };
`);
expect(symbols.typeMap.get('C.run')).toEqual({ type: 'helper', confidence: 0.9 });
});

it('does not extract prototype assignments on built-in globals', () => {
const symbols = parseJS(`Array.prototype.last = function() { return this[this.length - 1]; };`);
expect(symbols.definitions).not.toContainEqual(
expect.objectContaining({ name: 'Array.last' }),
);
});

it('does not seed typeMap for prototype identifier assignment from built-in globals', () => {
const symbols = parseJS(`Object.prototype.clone = myClone;`);
expect(symbols.typeMap.has('Object.clone')).toBe(false);
});
});
});
Loading