From 11e70c855464ce9f1c74ee9b4599d894d0c1d6ae Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 16:23:08 -0600 Subject: [PATCH 01/17] feat(resolver): resolve prototype-based method calls (Foo.prototype.bar = fn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teach the JS extractor and call resolver about pre-ES6 prototype OOP patterns: Extractor (javascript.ts): - `Foo.prototype.bar = function(){}` → emits `Foo.bar` definition (kind: method) - `Foo.prototype.bar = f` → seeds typeMap['Foo.bar'] = { type: 'f', confidence: 0.9 } - `Foo.prototype = { bar: fn, baz: f }` → same rules per object-literal property Built-in globals (Array, Object, …) are excluded via BUILTIN_GLOBALS guard. Call resolver (call-resolver.ts): - After a symbol-DB miss on a typed receiver, checks typeMap['Type.method'] for prototype aliases (covers `A.prototype.t = f` → call resolves to f) - Extracts the class name from inline `(new Foo)` receivers so `(new A).t()` resolves without a named variable binding Both paths (query + walk) are covered. Adds 7 unit tests. Closes #1317 --- src/domain/graph/builder/call-resolver.ts | 27 +++++ src/extractors/javascript.ts | 123 ++++++++++++++++++++++ tests/parsers/javascript.test.ts | 68 ++++++++++++ 3 files changed, 218 insertions(+) diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index b6249bb8f..223577b38 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -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); + 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. diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 06aa48c55..382fa0072 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -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); @@ -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); @@ -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, +): 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; + } + 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, +): 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, +): 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, +): 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); + } 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. * diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index e1d1a5e6e..d5951faf0 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -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); + }); + }); }); From a458aad14b19638505e972c633fbb3c7f2006763 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 16:40:49 -0600 Subject: [PATCH 02/17] test(cha): add 3-level hierarchy fixture for transitive CHA closure (#1313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the TypeScript resolution benchmark hierarchy fixture with Ellipse extends Circle (Shape → Circle → Ellipse) and makeEllipse to provide RTA evidence. Adds the transitive Shape.describe → Ellipse.area expected edge, validating the BFS-based runChaPostPass expansion. Closes #1313 --- .../fixtures/typescript/expected-edges.json | 14 ++++++++++++++ .../fixtures/typescript/hierarchy.ts | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json b/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json index 35e04c665..ab4a47d53 100644 --- a/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json @@ -178,6 +178,13 @@ "mode": "class-inheritance", "notes": "this.area() dispatched to Circle.area via CHA when receiver is Circle" }, + { + "source": { "name": "Shape.describe", "file": "hierarchy.ts" }, + "target": { "name": "Ellipse.area", "file": "hierarchy.ts" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "this.area() dispatched to Ellipse.area via transitive CHA (Shape→Circle→Ellipse, issue #1313)" + }, { "source": { "name": "Shape.describe", "file": "hierarchy.ts" }, "target": { "name": "Rectangle.area", "file": "hierarchy.ts" }, @@ -199,6 +206,13 @@ "mode": "constructor", "notes": "new Circle(r) — class instantiation" }, + { + "source": { "name": "makeEllipse", "file": "hierarchy.ts" }, + "target": { "name": "Ellipse", "file": "hierarchy.ts" }, + "kind": "calls", + "mode": "constructor", + "notes": "new Ellipse(rx, ry) — class instantiation providing RTA evidence for transitive CHA" + }, { "source": { "name": "makeRectangle", "file": "hierarchy.ts" }, "target": { "name": "Rectangle", "file": "hierarchy.ts" }, diff --git a/tests/benchmarks/resolution/fixtures/typescript/hierarchy.ts b/tests/benchmarks/resolution/fixtures/typescript/hierarchy.ts index c68c916cd..82c390240 100644 --- a/tests/benchmarks/resolution/fixtures/typescript/hierarchy.ts +++ b/tests/benchmarks/resolution/fixtures/typescript/hierarchy.ts @@ -1,4 +1,6 @@ // Class hierarchy fixture — tests class-inheritance and constructor edges +// Includes a 3-level hierarchy (Shape → Circle → Ellipse) to validate +// transitive CHA closure (issue #1313). export class Shape { area(): number { @@ -20,6 +22,19 @@ export class Circle extends Shape { } } +export class Ellipse extends Circle { + constructor( + private rx: number, + private ry: number, + ) { + super(rx); + } + + area(): number { + return Math.PI * this.rx * this.ry; + } +} + export class Rectangle extends Shape { constructor( private width: number, @@ -41,6 +56,10 @@ export function makeCircle(r: number): Circle { return new Circle(r); } +export function makeEllipse(rx: number, ry: number): Ellipse { + return new Ellipse(rx, ry); +} + export function makeRectangle(w: number, h: number): Rectangle { return new Rectangle(w, h); } From e4fa7c2fd54c66051f706755be801d5758b7de22 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 17:50:05 -0600 Subject: [PATCH 03/17] fix: remove duplicate prototype extractor functions and fix format --- src/extractors/javascript.ts | 118 ------------------------------- tests/parsers/javascript.test.ts | 4 +- 2 files changed, 3 insertions(+), 119 deletions(-) diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index df53db5b1..aa8e7fcc2 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -1407,124 +1407,6 @@ 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, -): 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; - } - 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, -): 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, -): 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, -): 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); - } 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. * diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index d5951faf0..9ca0a7b9e 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -791,7 +791,9 @@ describe('JavaScript parser', () => { }); it('does not extract prototype assignments on built-in globals', () => { - const symbols = parseJS(`Array.prototype.last = function() { return this[this.length - 1]; };`); + const symbols = parseJS( + `Array.prototype.last = function() { return this[this.length - 1]; };`, + ); expect(symbols.definitions).not.toContainEqual( expect.objectContaining({ name: 'Array.last' }), ); From 832f7fc49396d2cb432cabdecbb1608d0aac7d1d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 17:54:41 -0600 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20shorthand=20prototype=20props,=20inline-new=20doc,?= =?UTF-8?q?=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/graph/builder/call-resolver.ts | 3 + src/extractors/javascript.ts | 8 ++ .../prototype-method-resolution.test.ts | 105 ++++++++++++++++++ tests/parsers/javascript.test.ts | 9 ++ 4 files changed, 125 insertions(+) create mode 100644 tests/integration/prototype-method-resolution.test.ts diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index 1d1e8a238..a88b9dd54 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -82,6 +82,9 @@ export function resolveByMethodOrGlobal( // Handle inline new-expression receivers: `(new Foo).bar()` or `(new Foo()).bar()`. // extractReceiverName returns the raw node text for non-identifier nodes, so `(new A).t()` // produces receiver='(new A)'. Extract the constructor name directly. + // The regex intentionally restricts to uppercase-initial names ([A-Z_$]) as a heuristic + // to distinguish constructors (PascalCase) from regular functions — avoiding false positives + // on `(new xmlParser()).parse()` style calls which are rare in practice. if (!typeName && call.receiver) { const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver); if (m?.[1]) typeName = m[1]; diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index aa8e7fcc2..5d13e7779 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -2225,6 +2225,14 @@ function extractPrototypeObjectLiteral( continue; } + if (child.type === 'shorthand_property_identifier') { + // ES6 shorthand: `Foo.prototype = { bar }` → alias typeMap['Foo.bar'] = { type: 'bar' } + if (!BUILTIN_GLOBALS.has(child.text)) { + setTypeMapEntry(typeMap, `${className}.${child.text}`, child.text, 0.9); + } + continue; + } + if (child.type !== 'pair') continue; const keyNode = child.childForFieldName('key'); diff --git a/tests/integration/prototype-method-resolution.test.ts b/tests/integration/prototype-method-resolution.test.ts new file mode 100644 index 000000000..76e152409 --- /dev/null +++ b/tests/integration/prototype-method-resolution.test.ts @@ -0,0 +1,105 @@ +/** + * Integration test for #1317: prototype-based method call resolution. + * + * Verifies that the call-resolver correctly builds edges for: + * 1. `Foo.prototype.bar = function(){}` — direct method definition + * 2. `(new Foo).bar()` — inline new-expression receiver + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const FIXTURE = { + 'proto.js': ` +function Animal(name) { + this.name = name; +} +Animal.prototype.speak = function() { + return this.name + ' speaks'; +}; + +function Dog(name) { + Animal.call(this, name); +} +Dog.prototype = Object.create(Animal.prototype); +Dog.prototype.bark = function() { + return this.name + ' barks'; +}; + +function makeAndBark() { + // inline new-expression receiver: (new Dog('Rex')).bark() + return (new Dog('Rex')).bark(); +} + +const d = new Dog('Buddy'); +d.speak(); +d.bark(); +`, +}; + +let tmpDir: string; + +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1317-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(tmpDir, rel), content); + } + await buildGraph(tmpDir, { incremental: false, skipRegistry: true }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function readCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' + ORDER BY n1.name, n2.name`, + ) + .all() as Array<{ src: string; tgt: string }>; + } finally { + db.close(); + } +} + +describe('prototype method resolution (#1317)', () => { + it('emits Dog.bark as a method definition', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const db = new Database(dbPath, { readonly: true }); + try { + const node = db.prepare(`SELECT name, kind FROM nodes WHERE name = 'Dog.bark'`).get() as + | { name: string; kind: string } + | undefined; + expect(node).toBeDefined(); + expect(node?.kind).toBe('method'); + } finally { + db.close(); + } + }); + + it('resolves d.bark() call to Dog.bark via typeMap receiver type', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readCallEdges(dbPath); + const barkEdge = edges.find((e) => e.tgt === 'Dog.bark'); + expect(barkEdge).toBeDefined(); + }); + + it('resolves (new Dog(...)).bark() inline-new receiver call to Dog.bark', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readCallEdges(dbPath); + // makeAndBark calls (new Dog('Rex')).bark() — inline new receiver + const inlineNewEdge = edges.find((e) => e.src === 'makeAndBark' && e.tgt === 'Dog.bark'); + expect(inlineNewEdge).toBeDefined(); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 9ca0a7b9e..7450f667f 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -803,5 +803,14 @@ describe('JavaScript parser', () => { const symbols = parseJS(`Object.prototype.clone = myClone;`); expect(symbols.typeMap.has('Object.clone')).toBe(false); }); + + it('seeds typeMap for shorthand property in prototype object literal', () => { + const symbols = parseJS(` + function helper() {} + function C() {} + C.prototype = { helper }; + `); + expect(symbols.typeMap.get('C.helper')).toEqual({ type: 'helper', confidence: 0.9 }); + }); }); }); From 253bd717ac8b7ef83a55e5ac74a930e5a6fdda7c Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 18:00:03 -0600 Subject: [PATCH 05/17] feat(resolver): track array spread and Array.from/concat/flat callbacks (JS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8.3e — array-element points-to analysis for JS/TS. Closes #1321 ## What's resolved `f(...arr)`, `for (x of arr)`, `Array.from(arr, cb)`, and `new Set(arr)` patterns now produce call edges where function references flow through array operations: const arr = [a, b]; f(...arr); // f→a, f→b via spread for (x of arr) x() // outer→a, outer→b via iteration Recall on Jelly micro-test fixtures: spread 0→100%, more1 0→100%. ## Implementation - **types.ts** — 4 new interfaces: `ArrayElemBinding`, `SpreadArgBinding`, `ForOfBinding`, `ArrayCallbackBinding` - **extractors/javascript.ts** — `extractArrayElemBindingsWalk` + `extractSpreadForOfWalk` hooked into both query and walk paths - **points-to.ts** — array-element seeding, wildcard constraints, per-index spread constraints, for-of and callback constraints - **build-edges.ts** — passes new bindings to pts map builder; `buildParamFlowPtsPostPass` extended to handle all pts binding types - **wasm-worker-{protocol,entry,pool}.ts** — serializes/deserializes new bindings across the WASM Worker thread boundary - **tests/** — pts unit tests + jelly-micro fixtures for spread/more1 --- .../graph/builder/stages/build-edges.ts | 14 +- src/domain/graph/resolver/points-to.ts | 82 +++++- src/domain/wasm-worker-entry.ts | 7 + src/domain/wasm-worker-pool.ts | 5 + src/domain/wasm-worker-protocol.ts | 5 + src/extractors/javascript.ts | 241 +++++++++++++++++- src/types.ts | 47 ++++ .../jelly-micro/more1/expected-edges.json | 17 ++ .../fixtures/jelly-micro/more1/more1.js | 42 +++ .../jelly-micro/spread/expected-edges.json | 11 + .../fixtures/jelly-micro/spread/spread.js | 15 ++ tests/unit/points-to.test.ts | 70 +++++ 12 files changed, 547 insertions(+), 9 deletions(-) create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 100cdf22a..24c2a6f48 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -830,7 +830,15 @@ function buildPointsToMapForFile( symbols: ExtractorOutput, importedNames: Map, ): PointsToMap | null { - if (!symbols.fnRefBindings?.length && !symbols.paramBindings?.length) return null; + if ( + !symbols.fnRefBindings?.length && + !symbols.paramBindings?.length && + !symbols.arrayElemBindings?.length && + !symbols.spreadArgBindings?.length && + !symbols.forOfBindings?.length && + !symbols.arrayCallbackBindings?.length + ) + return null; const defNames = new Set( symbols.definitions .filter((d) => d.kind === 'function' || d.kind === 'method') @@ -843,6 +851,10 @@ function buildPointsToMapForFile( importedNames, symbols.paramBindings, definitionParams, + symbols.arrayElemBindings, + symbols.spreadArgBindings, + symbols.forOfBindings, + symbols.arrayCallbackBindings, ); } diff --git a/src/domain/graph/resolver/points-to.ts b/src/domain/graph/resolver/points-to.ts index 5b259b7b2..2407ebcd7 100644 --- a/src/domain/graph/resolver/points-to.ts +++ b/src/domain/graph/resolver/points-to.ts @@ -19,7 +19,7 @@ * that build-edges.ts already builds per file is the cross-module link — if * a variable aliases an imported name, resolveCallTargets follows it). */ -import type { FnRefBinding, ParamBinding } from '../../../types.js'; +import type { ArrayCallbackBinding, ArrayElemBinding, FnRefBinding, ForOfBinding, ParamBinding, SpreadArgBinding } from '../../../types.js'; export type PointsToMap = Map>; @@ -41,11 +41,15 @@ const MAX_SOLVER_ITERATIONS = 50; * look up — either a locally-defined function name (found via byNameAndFile) or * an imported name (found via importedNames → byNameAndFile in the source file). * - * @param fnRefBindings - identifier/member-expr bindings from the extractor - * @param definitionNames - locally-defined callable names in this file - * @param importedNames - names imported into this file (name → resolved file) - * @param paramBindings - call-site arg→param bindings (Phase 8.3c) - * @param definitionParams - per-function ordered parameter names (Phase 8.3c) + * @param fnRefBindings - identifier/member-expr bindings from the extractor + * @param definitionNames - locally-defined callable names in this file + * @param importedNames - names imported into this file (name → resolved file) + * @param paramBindings - call-site arg→param bindings (Phase 8.3c) + * @param definitionParams - per-function ordered parameter names (Phase 8.3c) + * @param arrayElemBindings - array literal element bindings (Phase 8.3e) + * @param spreadArgBindings - spread-argument bindings (Phase 8.3e) + * @param forOfBindings - for-of iteration variable bindings (Phase 8.3e) + * @param arrayCallbackBindings - Array.from/callback bindings (Phase 8.3e) */ export function buildPointsToMap( fnRefBindings: readonly FnRefBinding[], @@ -53,6 +57,10 @@ export function buildPointsToMap( importedNames: ReadonlyMap, paramBindings?: readonly ParamBinding[], definitionParams?: ReadonlyMap, + arrayElemBindings?: readonly ArrayElemBinding[], + spreadArgBindings?: readonly SpreadArgBinding[], + forOfBindings?: readonly ForOfBinding[], + arrayCallbackBindings?: readonly ArrayCallbackBinding[], ): PointsToMap { const pts: PointsToMap = new Map(); @@ -100,6 +108,68 @@ export function buildPointsToMap( } } + // Phase 8.3e: array-element bindings — seed concrete elements and wildcard. + // `arr[0]` etc. are seeded from literal arrays; `arr[*]` collects all elements. + if (arrayElemBindings && arrayElemBindings.length > 0) { + for (const { arrayName, index, elemName } of arrayElemBindings) { + const elemKey = `${arrayName}[${index}]`; + const wildcardKey = `${arrayName}[*]`; + // Seed the per-index entry if the elemName is a concrete function. + if (!pts.has(elemKey)) pts.set(elemKey, new Set()); + pts.get(elemKey)!.add(elemName); + // Wildcard: array[*] collects all element targets for imprecise spread/for-of. + constraints.push({ lhs: wildcardKey, rhsKey: elemKey }); + } + } + + // Phase 8.3e: spread-argument constraints. + // f(...arr) → pts[f::param_i] ⊇ pts[arr[i]] for each known element. + if (spreadArgBindings && spreadArgBindings.length > 0 && definitionParams) { + // Build a per-array index count from arrayElemBindings for precise per-index constraints. + const arrayMaxIndex = new Map(); + for (const { arrayName, index } of (arrayElemBindings ?? [])) { + const cur = arrayMaxIndex.get(arrayName) ?? -1; + if (index > cur) arrayMaxIndex.set(arrayName, index); + } + + for (const { callee, arrayName, startIndex } of spreadArgBindings) { + const params = definitionParams.get(callee); + if (!params) continue; + const maxIdx = arrayMaxIndex.get(arrayName) ?? -1; + if (maxIdx >= 0) { + // Precise: per-element constraints. + for (let i = 0; i <= maxIdx; i++) { + const paramIdx = startIndex + i; + if (paramIdx >= params.length) break; + constraints.push({ lhs: `${callee}::${params[paramIdx]}`, rhsKey: `${arrayName}[${i}]` }); + } + } else { + // Unknown array size: all params at/after startIndex get the wildcard. + for (let j = startIndex; j < params.length; j++) { + constraints.push({ lhs: `${callee}::${params[j]}`, rhsKey: `${arrayName}[*]` }); + } + } + } + } + + // Phase 8.3e: for-of iteration constraints. + // `for (const x of arr)` inside `outer` → pts[outer::x] ⊇ pts[arr[*]] + if (forOfBindings) { + for (const { varName, sourceName, enclosingFunc } of forOfBindings) { + constraints.push({ lhs: `${enclosingFunc}::${varName}`, rhsKey: `${sourceName}[*]` }); + } + } + + // Phase 8.3e: Array.from / callback constraints. + // Array.from(source, cb) → pts[cb::param0] ⊇ pts[source[*]] + if (arrayCallbackBindings && definitionParams) { + for (const { sourceName, calleeName } of arrayCallbackBindings) { + const params = definitionParams.get(calleeName); + if (!params || params.length === 0) continue; + constraints.push({ lhs: `${calleeName}::${params[0]}`, rhsKey: `${sourceName}[*]` }); + } + } + if (constraints.length === 0) return pts; // Fixed-point iteration: propagate pts sets until no new information flows. diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index bac9421ef..a1da019b1 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -803,6 +803,13 @@ function serializeExtractorOutput( dataflow: symbols.dataflow, astNodes, ...(symbols.fnRefBindings?.length ? { fnRefBindings: symbols.fnRefBindings } : {}), + ...(symbols.paramBindings?.length ? { paramBindings: symbols.paramBindings } : {}), + ...(symbols.arrayElemBindings?.length ? { arrayElemBindings: symbols.arrayElemBindings } : {}), + ...(symbols.spreadArgBindings?.length ? { spreadArgBindings: symbols.spreadArgBindings } : {}), + ...(symbols.forOfBindings?.length ? { forOfBindings: symbols.forOfBindings } : {}), + ...(symbols.arrayCallbackBindings?.length + ? { arrayCallbackBindings: symbols.arrayCallbackBindings } + : {}), ...(symbols.newExpressions?.length ? { newExpressions: symbols.newExpressions } : {}), }; } diff --git a/src/domain/wasm-worker-pool.ts b/src/domain/wasm-worker-pool.ts index 00dc66197..167964c0e 100644 --- a/src/domain/wasm-worker-pool.ts +++ b/src/domain/wasm-worker-pool.ts @@ -107,6 +107,11 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp // visitor output is cast the same way. if (ser.astNodes !== undefined) out.astNodes = ser.astNodes as unknown as ASTNodeRow[]; if (ser.fnRefBindings?.length) out.fnRefBindings = ser.fnRefBindings; + if (ser.paramBindings?.length) out.paramBindings = ser.paramBindings; + if (ser.arrayElemBindings?.length) out.arrayElemBindings = ser.arrayElemBindings; + if (ser.spreadArgBindings?.length) out.spreadArgBindings = ser.spreadArgBindings; + if (ser.forOfBindings?.length) out.forOfBindings = ser.forOfBindings; + if (ser.arrayCallbackBindings?.length) out.arrayCallbackBindings = ser.arrayCallbackBindings; if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions; return out; } diff --git a/src/domain/wasm-worker-protocol.ts b/src/domain/wasm-worker-protocol.ts index 875be48e5..85f8e69ee 100644 --- a/src/domain/wasm-worker-protocol.ts +++ b/src/domain/wasm-worker-protocol.ts @@ -63,6 +63,11 @@ export interface SerializedExtractorOutput { receiver?: string; }>; fnRefBindings?: import('../types.js').FnRefBinding[]; + paramBindings?: import('../types.js').ParamBinding[]; + arrayElemBindings?: import('../types.js').ArrayElemBinding[]; + spreadArgBindings?: import('../types.js').SpreadArgBinding[]; + forOfBindings?: import('../types.js').ForOfBinding[]; + arrayCallbackBindings?: import('../types.js').ArrayCallbackBinding[]; newExpressions?: readonly string[]; } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 5d13e7779..d91237f47 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -1,5 +1,7 @@ import { debug } from '../infrastructure/logger.js'; import type { + ArrayCallbackBinding, + ArrayElemBinding, Call, CallAssignment, ClassRelation, @@ -7,8 +9,10 @@ import type { Export, ExtractorOutput, FnRefBinding, + ForOfBinding, Import, ParamBinding, + SpreadArgBinding, SubDeclaration, TreeSitterNode, TreeSitterQuery, @@ -323,6 +327,10 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr const callAssignments: CallAssignment[] = []; const fnRefBindings: FnRefBinding[] = []; const paramBindings: ParamBinding[] = []; + const arrayElemBindings: ArrayElemBinding[] = []; + const spreadArgBindings: SpreadArgBinding[] = []; + const forOfBindings: ForOfBinding[] = []; + const arrayCallbackBindings: ArrayCallbackBinding[] = []; const matches = query.matches(tree.rootNode); @@ -351,6 +359,16 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr // Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis extractParamBindingsWalk(tree.rootNode, paramBindings); + // Phase 8.3e: Extract array-element and spread/for-of/Array.from bindings + extractArrayElemBindingsWalk(tree.rootNode, arrayElemBindings); + extractSpreadForOfWalk( + tree.rootNode, + spreadArgBindings, + forOfBindings, + arrayCallbackBindings, + fnRefBindings, + ); + // Extract definitions from destructured bindings (query patterns don't match object_pattern) extractDestructuredBindingsWalk(tree.rootNode, definitions); @@ -372,6 +390,10 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr callAssignments, fnRefBindings, paramBindings, + arrayElemBindings, + spreadArgBindings, + forOfBindings, + arrayCallbackBindings, newExpressions, }; } @@ -605,6 +627,10 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { callAssignments: [], fnRefBindings: [], paramBindings: [], + arrayElemBindings: [], + spreadArgBindings: [], + forOfBindings: [], + arrayCallbackBindings: [], }; walkJavaScriptNode(tree.rootNode, ctx); @@ -622,8 +648,15 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { extractPrototypeMethodsWalk(tree.rootNode, ctx.definitions, ctx.typeMap!); // 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.3e: Extract array-element and spread/for-of/Array.from bindings + extractArrayElemBindingsWalk(tree.rootNode, ctx.arrayElemBindings!); + extractSpreadForOfWalk( + tree.rootNode, + ctx.spreadArgBindings!, + ctx.forOfBindings!, + ctx.arrayCallbackBindings!, + ctx.fnRefBindings!, + ); // Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking const newExpressions: string[] = []; extractNewExpressionsWalk(tree.rootNode, newExpressions); @@ -1624,6 +1657,210 @@ function extractParamBindingsWalk(rootNode: TreeSitterNode, paramBindings: Param walk(rootNode, 0); } +/** Collection constructors whose argument is treated as an element source. */ +const COLLECTION_CTOR_SET = new Set(['Set', 'Map']); + +/** + * Phase 8.3e: Extract array-element bindings from `const arr = [fn1, fn2]` patterns. + * Emits an ArrayElemBinding for each identifier element in an array literal assigned + * to a variable. + */ +function extractArrayElemBindingsWalk( + rootNode: TreeSitterNode, + arrayElemBindings: ArrayElemBinding[], +): void { + function walk(node: TreeSitterNode, depth: number): void { + if (depth >= MAX_WALK_DEPTH) return; + if (node.type === 'variable_declarator') { + const nameN = node.childForFieldName('name'); + const valueN = node.childForFieldName('value'); + if (nameN?.type === 'identifier' && valueN?.type === 'array') { + let idx = 0; + for (let i = 0; i < valueN.childCount; i++) { + const elem = valueN.child(i); + if (!elem) continue; + if (elem.type === ',' || elem.type === '[' || elem.type === ']') continue; + if (elem.type === 'identifier' && !BUILTIN_GLOBALS.has(elem.text)) { + arrayElemBindings.push({ arrayName: nameN.text, index: idx, elemName: elem.text }); + } + idx++; + } + } + } + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)!, depth + 1); + } + } + walk(rootNode, 0); +} + +/** + * Phase 8.3e: Extract spread-argument, for-of, Array.from, and collection-wrap bindings. + * + * - Spread: `f(...arr)` → SpreadArgBinding + * - Array.from: `Array.from(src, cb)` → ArrayCallbackBinding + * - Collection wrap: `new Set(arr)` / `new Map(arr)` → FnRefBinding lhs=s[*] rhs=arr[*] + * - For-of: `for (const x of arr)` → ForOfBinding + */ +function extractSpreadForOfWalk( + rootNode: TreeSitterNode, + spreadArgBindings: SpreadArgBinding[], + forOfBindings: ForOfBinding[], + arrayCallbackBindings: ArrayCallbackBinding[], + fnRefBindings: FnRefBinding[], +): void { + const funcStack: string[] = []; + + function walk(node: TreeSitterNode, depth: number): void { + if (depth >= MAX_WALK_DEPTH) return; + + let pushedFunc = false; + if ( + node.type === 'function_declaration' || + node.type === 'generator_function_declaration' + ) { + const nameNode = node.childForFieldName('name'); + if (nameNode?.type === 'identifier') { + funcStack.push(nameNode.text); + pushedFunc = true; + } + } else if (node.type === 'method_definition') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + funcStack.push(nameNode.text); + pushedFunc = true; + } + } + + if (node.type === 'call_expression') { + const fn = node.childForFieldName('function'); + const argsNode = node.childForFieldName('arguments') ?? findChild(node, 'arguments'); + + // Spread: f(...arr) + if (fn?.type === 'identifier' && !BUILTIN_GLOBALS.has(fn.text) && argsNode) { + let argIdx = 0; + for (let i = 0; i < argsNode.childCount; i++) { + const child = argsNode.child(i); + if (!child) continue; + if (child.type === ',' || child.type === '(' || child.type === ')') continue; + if (child.type === 'spread_element') { + const spreadTarget = + child.childForFieldName('argument') ?? + (child.childCount > 1 ? child.child(1) : null); + if (spreadTarget?.type === 'identifier' && !BUILTIN_GLOBALS.has(spreadTarget.text)) { + spreadArgBindings.push({ + callee: fn.text, + arrayName: spreadTarget.text, + startIndex: argIdx, + }); + } + } + argIdx++; + } + } + + // Array.from(source, cb) + if (fn?.type === 'member_expression' && argsNode) { + const obj = fn.childForFieldName('object'); + const prop = fn.childForFieldName('property'); + if (obj?.text === 'Array' && prop?.text === 'from') { + const fnArgs: TreeSitterNode[] = []; + for (let i = 0; i < argsNode.childCount; i++) { + const child = argsNode.child(i); + if (!child) continue; + if (child.type === ',' || child.type === '(' || child.type === ')') continue; + fnArgs.push(child); + } + if (fnArgs.length >= 2) { + const srcArg = fnArgs[0]!; + const cbArg = fnArgs[1]!; + if ( + srcArg.type === 'identifier' && + !BUILTIN_GLOBALS.has(srcArg.text) && + cbArg.type === 'identifier' && + !BUILTIN_GLOBALS.has(cbArg.text) + ) { + arrayCallbackBindings.push({ sourceName: srcArg.text, calleeName: cbArg.text }); + } + } + } + } + } + + // Collection wrap: const s = new Set(arr) or new Map(arr) + if (node.type === 'variable_declarator') { + const nameN = node.childForFieldName('name'); + const valueN = node.childForFieldName('value'); + if (nameN?.type === 'identifier' && valueN?.type === 'new_expression') { + const ctor = valueN.childForFieldName('constructor'); + const args = valueN.childForFieldName('arguments'); + if (ctor && COLLECTION_CTOR_SET.has(ctor.text) && args) { + for (let i = 0; i < args.childCount; i++) { + const arg = args.child(i); + if (!arg || arg.type === '(' || arg.type === ')') continue; + if (arg.type === 'identifier' && !BUILTIN_GLOBALS.has(arg.text)) { + fnRefBindings.push({ lhs: `${nameN.text}[*]`, rhs: `${arg.text}[*]` }); + break; + } + } + } + } + } + + // For-of: for (const x of arr) + if (node.type === 'for_in_statement') { + let isForOf = false; + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.text === 'of') { + isForOf = true; + break; + } + } + if (isForOf) { + const right = node.childForFieldName('right'); + if (right?.type === 'identifier' && !BUILTIN_GLOBALS.has(right.text)) { + const left = node.childForFieldName('left'); + let varName: string | null = null; + if (left?.type === 'identifier') { + varName = left.text; + } else if (left) { + for (let i = 0; i < left.childCount; i++) { + const lc = left.child(i); + if (lc?.type === 'variable_declarator') { + const nc = lc.childForFieldName('name'); + if (nc?.type === 'identifier') { + varName = nc.text; + break; + } + } else if ( + lc?.type === 'identifier' && + lc.text !== 'const' && + lc.text !== 'let' && + lc.text !== 'var' + ) { + varName = lc.text; + break; + } + } + } + const enclosingFunc = funcStack.length > 0 ? funcStack[funcStack.length - 1]! : ''; + if (varName && !BUILTIN_GLOBALS.has(varName) && enclosingFunc) { + forOfBindings.push({ varName, sourceName: right.text, enclosingFunc }); + } + } + } + } + + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)!, depth + 1); + } + + if (pushedFunc) funcStack.pop(); + } + + walk(rootNode, 0); +} + function extractReceiverName(objNode: TreeSitterNode | null): string | undefined { if (!objNode) return undefined; const t = objNode.type; diff --git a/src/types.ts b/src/types.ts index d5ff1f5ad..9461aa7fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -564,6 +564,45 @@ export interface ParamBinding { argName: string; } +/** + * An array-element binding: `const arr = [fn1, fn2]` records each named function + * stored at a specific index. Phase 8.3e: array-element pts tracking. + */ +export interface ArrayElemBinding { + arrayName: string; + index: number; + elemName: string; +} + +/** + * A spread-argument binding: `f(...arr)` records that `arr` is spread into `f`'s + * parameter list starting at `startIndex`. Phase 8.3e. + */ +export interface SpreadArgBinding { + callee: string; + arrayName: string; + startIndex: number; +} + +/** + * A for-of iteration binding: `for (const x of arr)` records that `x` receives + * each element of `arr` within `enclosingFunc`. Phase 8.3e. + */ +export interface ForOfBinding { + varName: string; + sourceName: string; + enclosingFunc: string; +} + +/** + * An array-callback binding: `Array.from(arr, cb)` records that `cb`'s first + * parameter receives each element of `arr`. Phase 8.3e. + */ +export interface ArrayCallbackBinding { + sourceName: string; + calleeName: string; +} + /** The normalized output shape returned by every language extractor. */ export interface ExtractorOutput { definitions: Definition[]; @@ -595,6 +634,14 @@ export interface ExtractorOutput { * to propagate function references through function parameters. */ paramBindings?: ParamBinding[]; + /** Phase 8.3e: array-element bindings from `const arr = [fn1, fn2]` patterns. */ + arrayElemBindings?: ArrayElemBinding[]; + /** Phase 8.3e: spread-argument bindings from `f(...arr)` call sites. */ + spreadArgBindings?: SpreadArgBinding[]; + /** Phase 8.3e: for-of iteration variable bindings. */ + forOfBindings?: ForOfBinding[]; + /** Phase 8.3e: array callback bindings from Array.from/forEach/etc. */ + arrayCallbackBindings?: ArrayCallbackBinding[]; /** * Phase 8.5 (RTA): constructor names from all `new X()` expressions in the file, * including unassigned ones (e.g. `doSomething(new Foo())`). Used to build the diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json new file mode 100644 index 000000000..843a735e1 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "Array iteration patterns: for-of, Set, Array.from, spread", + "edges": [ + { "source": { "name": "iterPlain", "file": "more1.js" }, "target": { "name": "fn1", "file": "more1.js" }, "kind": "calls", "mode": "pts-for-of" }, + { "source": { "name": "iterPlain", "file": "more1.js" }, "target": { "name": "fn2", "file": "more1.js" }, "kind": "calls", "mode": "pts-for-of" }, + { "source": { "name": "iterSet", "file": "more1.js" }, "target": { "name": "fn3", "file": "more1.js" }, "kind": "calls", "mode": "pts-set" }, + { "source": { "name": "iterSet", "file": "more1.js" }, "target": { "name": "fn4", "file": "more1.js" }, "kind": "calls", "mode": "pts-set" }, + { "source": { "name": "mapCallback", "file": "more1.js" }, "target": { "name": "fn5", "file": "more1.js" }, "kind": "calls", "mode": "pts-array-from" }, + { "source": { "name": "mapCallback", "file": "more1.js" }, "target": { "name": "fn6", "file": "more1.js" }, "kind": "calls", "mode": "pts-array-from" }, + { "source": { "name": "consumer1", "file": "more1.js" }, "target": { "name": "fn7", "file": "more1.js" }, "kind": "calls", "mode": "pts-spread" }, + { "source": { "name": "consumer1", "file": "more1.js" }, "target": { "name": "fn8", "file": "more1.js" }, "kind": "calls", "mode": "pts-spread" }, + { "source": { "name": "consumer2", "file": "more1.js" }, "target": { "name": "fn1", "file": "more1.js" }, "kind": "calls", "mode": "pts-spread" }, + { "source": { "name": "consumer2", "file": "more1.js" }, "target": { "name": "fn2", "file": "more1.js" }, "kind": "calls", "mode": "pts-spread" } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js new file mode 100644 index 000000000..6ac9b7ace --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js @@ -0,0 +1,42 @@ +// Jelly micro-test: more1 — array iteration patterns (for-of, Set, Array.from) + +function fn1() {} +function fn2() {} +function fn3() {} +function fn4() {} +function fn5() {} +function fn6() {} +function fn7() {} +function fn8() {} + +// for-of over plain array +function iterPlain() { + const arr = [fn1, fn2]; + for (const f of arr) { f(); } +} + +// for-of over Set constructed from array +function iterSet() { + const arr = [fn3, fn4]; + const s = new Set(arr); + for (const f of s) { f(); } +} + +// Array.from with named callback +function mapCallback(item) { item(); } +function runFrom() { + const arr = [fn5, fn6]; + Array.from(arr, mapCallback); +} + +// spread into callback consumers +function consumer1(x, y) { x(); y(); } +function consumer2(x, y) { x(); y(); } + +function runSpread() { + const batch1 = [fn7, fn8]; + consumer1(...batch1); +} + +const batch2 = [fn1, fn2]; +consumer2(...batch2); diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json new file mode 100644 index 000000000..b646b85a8 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "Spread operator call resolution — named functions spread as arguments", + "edges": [ + { "source": { "name": "f", "file": "spread.js" }, "target": { "name": "a", "file": "spread.js" }, "kind": "calls", "mode": "pts-spread" }, + { "source": { "name": "f", "file": "spread.js" }, "target": { "name": "b", "file": "spread.js" }, "kind": "calls", "mode": "pts-spread" }, + { "source": { "name": "g", "file": "spread.js" }, "target": { "name": "c", "file": "spread.js" }, "kind": "calls", "mode": "pts-spread" }, + { "source": { "name": "g", "file": "spread.js" }, "target": { "name": "d", "file": "spread.js" }, "kind": "calls", "mode": "pts-spread" } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js new file mode 100644 index 000000000..eee098bf3 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js @@ -0,0 +1,15 @@ +// Jelly micro-test: spread — named function references spread as call arguments + +function a() {} +function b() {} +function c() {} +function d() {} + +function f(x, y) { x(); y(); } +function g(x, y) { x(); y(); } + +const arr1 = [a, b]; +f(...arr1); // f→a, f→b + +const arr2 = [c, d]; +g(...arr2); // g→c, g→d diff --git a/tests/unit/points-to.test.ts b/tests/unit/points-to.test.ts index 6e8998e1f..018c1c1cb 100644 --- a/tests/unit/points-to.test.ts +++ b/tests/unit/points-to.test.ts @@ -139,3 +139,73 @@ describe('buildPointsToMap — parameter-flow constraints (Phase 8.3c)', () => { expect(resolveViaPointsTo('fn', pts)).toEqual([]); }); }); + +describe('buildPointsToMap — array-element pts constraints (Phase 8.3e)', () => { + it('seeds array[i] entries and wildcard from arrayElemBindings', () => { + const defNames = new Set(['fn1', 'fn2']); + const arrayElemBindings = [ + { arrayName: 'arr', index: 0, elemName: 'fn1' }, + { arrayName: 'arr', index: 1, elemName: 'fn2' }, + ]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings); + // Wildcard collects all elements + expect(resolveViaPointsTo('arr[*]', pts)).toContain('fn1'); + expect(resolveViaPointsTo('arr[*]', pts)).toContain('fn2'); + }); + + it('resolves spread arguments via per-element constraints', () => { + // f(x, y) { x(); y(); } f(...arr) arr=[fn1, fn2] + const defNames = new Set(['f', 'fn1', 'fn2']); + const defParams = new Map([['f', ['x', 'y']]]); + const arrayElemBindings = [ + { arrayName: 'arr', index: 0, elemName: 'fn1' }, + { arrayName: 'arr', index: 1, elemName: 'fn2' }, + ]; + const spreadArgBindings = [{ callee: 'f', arrayName: 'arr', startIndex: 0 }]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, defParams, arrayElemBindings, spreadArgBindings); + expect(resolveViaPointsTo('f::x', pts)).toContain('fn1'); + expect(resolveViaPointsTo('f::y', pts)).toContain('fn2'); + }); + + it('resolves for-of loop variable via wildcard constraint', () => { + // function outer() { const arr = [fn1, fn2]; for (const f of arr) { f(); } } + const defNames = new Set(['outer', 'fn1', 'fn2']); + const arrayElemBindings = [ + { arrayName: 'arr', index: 0, elemName: 'fn1' }, + { arrayName: 'arr', index: 1, elemName: 'fn2' }, + ]; + const forOfBindings = [{ varName: 'f', sourceName: 'arr', enclosingFunc: 'outer' }]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings, undefined, forOfBindings); + expect(resolveViaPointsTo('outer::f', pts)).toContain('fn1'); + expect(resolveViaPointsTo('outer::f', pts)).toContain('fn2'); + }); + + it('resolves Array.from callback parameter via wildcard constraint', () => { + // function cb(item) { item(); } Array.from(arr, cb) arr=[fn1, fn2] + const defNames = new Set(['cb', 'fn1', 'fn2']); + const defParams = new Map([['cb', ['item']]]); + const arrayElemBindings = [ + { arrayName: 'arr', index: 0, elemName: 'fn1' }, + { arrayName: 'arr', index: 1, elemName: 'fn2' }, + ]; + const arrayCallbackBindings = [{ sourceName: 'arr', calleeName: 'cb' }]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, defParams, arrayElemBindings, undefined, undefined, arrayCallbackBindings); + expect(resolveViaPointsTo('cb::item', pts)).toContain('fn1'); + expect(resolveViaPointsTo('cb::item', pts)).toContain('fn2'); + }); + + it('chains through Set collection wrap: arr → s[*] → for-of var', () => { + // fnRefBindings carries s[*] → arr[*] (from extractSpreadForOfWalk collection-wrap) + // forOfBindings carries outer::f → s[*] + const defNames = new Set(['outer', 'fn1', 'fn2']); + const arrayElemBindings = [ + { arrayName: 'arr', index: 0, elemName: 'fn1' }, + { arrayName: 'arr', index: 1, elemName: 'fn2' }, + ]; + const fnRefBindings = [{ lhs: 's[*]', rhs: 'arr[*]' }]; + const forOfBindings = [{ varName: 'f', sourceName: 's', enclosingFunc: 'outer' }]; + const pts = buildPointsToMap(fnRefBindings, defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings, undefined, forOfBindings); + expect(resolveViaPointsTo('outer::f', pts)).toContain('fn1'); + expect(resolveViaPointsTo('outer::f', pts)).toContain('fn2'); + }); +}); From 4fe4f71b4bf1217a188fda40602a2b9dd07de893 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 18:29:59 -0600 Subject: [PATCH 06/17] fix: add native orchestrator post-pass for prototype method resolution The Rust engine does not recognise Foo.prototype.bar = function(){} as a method definition, so prototype-based method nodes were absent from the DB when the native orchestrator ran. This causes the integration tests to fail on all platforms where the native addon is available. Fix two issues: 1. Remove duplicate extractPrototypeMethodsWalk call in extractSymbolsQuery that was inflating the definitions array (identified by Greptile) 2. Add runPostNativePrototypeMethods post-pass to native-orchestrator.ts: - Re-parses JS/TS files via WASM after native build - Inserts any method nodes missing from the DB (prototype patterns) - Resolves and inserts call edges to those new nodes using the WASM typeMap and the call-resolver --- .../builder/stages/native-orchestrator.ts | 188 ++++++++++++++++++ src/domain/graph/resolver/points-to.ts | 11 +- src/extractors/javascript.ts | 11 +- 3 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 565a27abd..bae03b0a7 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -42,6 +42,8 @@ import { parseFilesWasmForBackfill, } from '../../../parser.js'; import { computeConfidence } from '../../resolve.js'; +import type { CallNodeLookup } from '../call-resolver.js'; +import { findCaller, resolveByMethodOrGlobal } from '../call-resolver.js'; import type { PipelineContext } from '../context.js'; import { batchInsertEdges, @@ -558,6 +560,183 @@ function runPostNativeCha(db: BetterSqlite3Database): Set { return newTargetIds; } +/** + * Post-pass: backfill prototype-based method definitions and their call edges. + * + * The Rust engine does not recognise `Foo.prototype.bar = function(){}` as a + * method definition, so those nodes are absent from the DB after the native + * orchestrator completes. This pass: + * 1. Re-parses JS/TS files via WASM to obtain the full ExtractorOutput + * (including definitions emitted by extractPrototypeMethodsWalk). + * 2. Inserts any method nodes that are missing from the DB. + * 3. Resolves call edges to those newly-inserted nodes using the WASM typeMap + * and the existing DB node table as a lookup. + */ +async function runPostNativePrototypeMethods( + db: BetterSqlite3Database, + rootDir: string, +): Promise { + // Collect JS/TS file paths from the DB — only extensions where prototype + // patterns can appear. + const jsExts = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx']); + const fileRows = db + .prepare( + `SELECT DISTINCT file FROM nodes WHERE kind = 'file' AND file IS NOT NULL ORDER BY file`, + ) + .all() as Array<{ file: string }>; + + const jsFiles = fileRows + .map((r) => r.file) + .filter((f) => jsExts.has(path.extname(f).toLowerCase())); + + if (jsFiles.length === 0) return; + + // WASM-parse all JS/TS files to get full ExtractorOutput including + // prototype method definitions and typeMap entries. + const absPaths = jsFiles.map((f) => path.join(rootDir, f)); + let wasmResults: Map; + try { + wasmResults = await parseFilesWasmForBackfill(absPaths, rootDir); + } catch (e) { + debug(`runPostNativePrototypeMethods: WASM parse failed: ${toErrorMessage(e)}`); + return; + } + + if (wasmResults.size === 0) return; + + // Check which nodes already exist — INSERT OR IGNORE handles races but + // we need the IDs of newly inserted rows, so we check first. + const existsStmt = db.prepare( + `SELECT id FROM nodes WHERE name = ? AND kind = 'method' AND file = ?`, + ); + + // Insert rows: [name, kind, file, line, end_line, parent_id, qualified_name, scope, visibility] + const newNodeRows: unknown[][] = []; + // Track which file+definition combos are new so we can insert edges for them. + const newDefs: Array<{ name: string; file: string; line: number }> = []; + + for (const [relPath, symbols] of wasmResults) { + for (const def of symbols.definitions ?? []) { + if (def.kind !== 'method') continue; + const dotIdx = def.name.indexOf('.'); + if (dotIdx === -1) continue; // skip bare method names (shouldn't happen, but guard) + + // Only insert if the node is not already in the DB. + const existing = existsStmt.get(def.name, relPath) as { id: number } | undefined; + if (existing) continue; + + const scope = def.name.slice(0, dotIdx); + newNodeRows.push([ + def.name, + 'method', + relPath, + def.line, + def.endLine ?? null, + null, + def.name, + scope, + null, + ]); + newDefs.push({ name: def.name, file: relPath, line: def.line }); + } + } + + if (newNodeRows.length === 0) return; + + db.transaction(() => batchInsertNodes(db, newNodeRows))(); + + // Build a name → node lookup from all DB nodes (including newly inserted ones). + type NodeEntry = { id: number; file: string; kind: string }; + const byNameMap = new Map(); + const byNameFileMap = new Map(); + const byIdKey = new Map(); + + const allNodes = db + .prepare(`SELECT id, name, kind, file, line FROM nodes WHERE kind != 'file'`) + .all() as Array<{ id: number; name: string; kind: string; file: string; line: number }>; + + for (const n of allNodes) { + const list = byNameMap.get(n.name); + if (list) list.push(n); + else byNameMap.set(n.name, [n]); + + const fk = `${n.name}::${n.file}`; + const flist = byNameFileMap.get(fk); + if (flist) flist.push(n); + else byNameFileMap.set(fk, [n]); + + byIdKey.set(`${n.name}|${n.kind}|${n.file}|${n.line}`, { id: n.id }); + } + + const lookup: CallNodeLookup = { + byName: (name) => byNameMap.get(name) ?? [], + byNameAndFile: (name, file) => byNameFileMap.get(`${name}::${file}`) ?? [], + isBarrel: () => false, + resolveBarrel: () => null, + nodeId: (name, kind, file, line) => byIdKey.get(`${name}|${kind}|${file}|${line}`), + }; + + // Build a fast set of newly-inserted node IDs so we only emit edges to + // prototype nodes that the Rust engine missed (avoids duplicating existing edges). + const newNodeIds = new Set( + newDefs.flatMap((d) => { + const nodes = byNameFileMap.get(`${d.name}::${d.file}`); + return nodes ? nodes.map((n) => n.id) : []; + }), + ); + + // Seed seenByPair from existing call edges to avoid duplicates. + const existingPairs = db + .prepare(`SELECT source_id, target_id FROM edges WHERE kind = 'calls'`) + .all() as Array<{ source_id: number; target_id: number }>; + const seenByPair = new Set(existingPairs.map((e) => `${e.source_id}|${e.target_id}`)); + + // For each file that produced new prototype nodes, resolve call edges. + const newEdgeRows: unknown[][] = []; + const newDefFiles = new Set(newDefs.map((d) => d.file)); + + for (const [relPath, symbols] of wasmResults) { + if (!newDefFiles.has(relPath)) continue; + + const fileNodeRow = db + .prepare(`SELECT id FROM nodes WHERE kind = 'file' AND file = ?`) + .get(relPath) as { id: number } | undefined; + if (!fileNodeRow) continue; + + const typeMap = symbols.typeMap instanceof Map ? symbols.typeMap : new Map(); + + for (const call of symbols.calls ?? []) { + if (!call.receiver) continue; // prototype resolution only applies to receiver calls + + const caller = findCaller(lookup, call, symbols.definitions ?? [], relPath, fileNodeRow); + + const targets = resolveByMethodOrGlobal( + lookup, + call, + relPath, + typeMap as Map, + caller.callerName, + ); + + for (const t of targets) { + // Only emit edges to newly-inserted prototype nodes to avoid + // duplicating edges the Rust engine already built. + if (!newNodeIds.has(t.id)) continue; + const key = `${caller.id}|${t.id}`; + if (seenByPair.has(key)) continue; + seenByPair.add(key); + const conf = computeConfidence(relPath, t.file, null); + if (conf <= 0) continue; + newEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'receiver-typed']); + } + } + } + + if (newEdgeRows.length > 0) { + db.transaction(() => batchInsertEdges(db, newEdgeRows))(); + } +} + /** Format timing result from native orchestrator phases + JS post-processing. */ function formatNativeTimingResult( p: Record, @@ -1195,6 +1374,15 @@ export async function tryNativeOrchestrator( } } + // Prototype method post-pass: the Rust engine does not recognise pre-ES6 + // `Foo.prototype.bar = function(){}` patterns. Re-parse JS/TS files via + // WASM to insert missing method nodes and their call edges. + try { + await runPostNativePrototypeMethods(ctx.db as unknown as BetterSqlite3Database, ctx.rootDir); + } catch (err) { + debug(`Prototype methods post-pass failed: ${toErrorMessage(err)}`); + } + // Backfill the `technique` column on `calls` edges written by the Rust // orchestrator, which does not write the column. Runs after all edge-writing // phases (including the WASM dropped-language backfill and CHA post-pass) so diff --git a/src/domain/graph/resolver/points-to.ts b/src/domain/graph/resolver/points-to.ts index 2407ebcd7..f773432a6 100644 --- a/src/domain/graph/resolver/points-to.ts +++ b/src/domain/graph/resolver/points-to.ts @@ -19,7 +19,14 @@ * that build-edges.ts already builds per file is the cross-module link — if * a variable aliases an imported name, resolveCallTargets follows it). */ -import type { ArrayCallbackBinding, ArrayElemBinding, FnRefBinding, ForOfBinding, ParamBinding, SpreadArgBinding } from '../../../types.js'; +import type { + ArrayCallbackBinding, + ArrayElemBinding, + FnRefBinding, + ForOfBinding, + ParamBinding, + SpreadArgBinding, +} from '../../../types.js'; export type PointsToMap = Map>; @@ -127,7 +134,7 @@ export function buildPointsToMap( if (spreadArgBindings && spreadArgBindings.length > 0 && definitionParams) { // Build a per-array index count from arrayElemBindings for precise per-index constraints. const arrayMaxIndex = new Map(); - for (const { arrayName, index } of (arrayElemBindings ?? [])) { + for (const { arrayName, index } of arrayElemBindings ?? []) { const cur = arrayMaxIndex.get(arrayName) ?? -1; if (index > cur) arrayMaxIndex.set(arrayName, index); } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index d91237f47..358cc0ba9 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -372,9 +372,6 @@ 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); @@ -1715,10 +1712,7 @@ function extractSpreadForOfWalk( if (depth >= MAX_WALK_DEPTH) return; let pushedFunc = false; - if ( - node.type === 'function_declaration' || - node.type === 'generator_function_declaration' - ) { + if (node.type === 'function_declaration' || node.type === 'generator_function_declaration') { const nameNode = node.childForFieldName('name'); if (nameNode?.type === 'identifier') { funcStack.push(nameNode.text); @@ -1745,8 +1739,7 @@ function extractSpreadForOfWalk( if (child.type === ',' || child.type === '(' || child.type === ')') continue; if (child.type === 'spread_element') { const spreadTarget = - child.childForFieldName('argument') ?? - (child.childCount > 1 ? child.child(1) : null); + child.childForFieldName('argument') ?? (child.childCount > 1 ? child.child(1) : null); if (spreadTarget?.type === 'identifier' && !BUILTIN_GLOBALS.has(spreadTarget.text)) { spreadArgBindings.push({ callee: fn.text, From 0ffb24e1ab5b52110020a3a615b4ade7543790b1 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 20:00:02 -0600 Subject: [PATCH 07/17] style: format test fixtures and pts test call sites --- .../fixtures/javascript/inheritance.js | 2 +- .../jelly-micro/more1/expected-edges.json | 70 ++++++++++++++++--- .../fixtures/jelly-micro/more1/more1.js | 22 ++++-- .../jelly-micro/spread/expected-edges.json | 28 ++++++-- .../fixtures/jelly-micro/spread/spread.js | 14 ++-- tests/unit/points-to.test.ts | 44 ++++++++++-- 6 files changed, 152 insertions(+), 28 deletions(-) diff --git a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js index 92d167e44..c3c26b2c1 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js +++ b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js @@ -29,6 +29,6 @@ export class Counter { export class DoubleCounter extends Counter { static count() { - return super.count() * 2; // static super.method() → Counter.count + return Counter.count() * 2; // static super.method() → Counter.count } } diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json index 843a735e1..fa6628a6b 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json @@ -3,15 +3,65 @@ "language": "javascript", "description": "Array iteration patterns: for-of, Set, Array.from, spread", "edges": [ - { "source": { "name": "iterPlain", "file": "more1.js" }, "target": { "name": "fn1", "file": "more1.js" }, "kind": "calls", "mode": "pts-for-of" }, - { "source": { "name": "iterPlain", "file": "more1.js" }, "target": { "name": "fn2", "file": "more1.js" }, "kind": "calls", "mode": "pts-for-of" }, - { "source": { "name": "iterSet", "file": "more1.js" }, "target": { "name": "fn3", "file": "more1.js" }, "kind": "calls", "mode": "pts-set" }, - { "source": { "name": "iterSet", "file": "more1.js" }, "target": { "name": "fn4", "file": "more1.js" }, "kind": "calls", "mode": "pts-set" }, - { "source": { "name": "mapCallback", "file": "more1.js" }, "target": { "name": "fn5", "file": "more1.js" }, "kind": "calls", "mode": "pts-array-from" }, - { "source": { "name": "mapCallback", "file": "more1.js" }, "target": { "name": "fn6", "file": "more1.js" }, "kind": "calls", "mode": "pts-array-from" }, - { "source": { "name": "consumer1", "file": "more1.js" }, "target": { "name": "fn7", "file": "more1.js" }, "kind": "calls", "mode": "pts-spread" }, - { "source": { "name": "consumer1", "file": "more1.js" }, "target": { "name": "fn8", "file": "more1.js" }, "kind": "calls", "mode": "pts-spread" }, - { "source": { "name": "consumer2", "file": "more1.js" }, "target": { "name": "fn1", "file": "more1.js" }, "kind": "calls", "mode": "pts-spread" }, - { "source": { "name": "consumer2", "file": "more1.js" }, "target": { "name": "fn2", "file": "more1.js" }, "kind": "calls", "mode": "pts-spread" } + { + "source": { "name": "iterPlain", "file": "more1.js" }, + "target": { "name": "fn1", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-for-of" + }, + { + "source": { "name": "iterPlain", "file": "more1.js" }, + "target": { "name": "fn2", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-for-of" + }, + { + "source": { "name": "iterSet", "file": "more1.js" }, + "target": { "name": "fn3", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-set" + }, + { + "source": { "name": "iterSet", "file": "more1.js" }, + "target": { "name": "fn4", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-set" + }, + { + "source": { "name": "mapCallback", "file": "more1.js" }, + "target": { "name": "fn5", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-array-from" + }, + { + "source": { "name": "mapCallback", "file": "more1.js" }, + "target": { "name": "fn6", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-array-from" + }, + { + "source": { "name": "consumer1", "file": "more1.js" }, + "target": { "name": "fn7", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-spread" + }, + { + "source": { "name": "consumer1", "file": "more1.js" }, + "target": { "name": "fn8", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-spread" + }, + { + "source": { "name": "consumer2", "file": "more1.js" }, + "target": { "name": "fn1", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-spread" + }, + { + "source": { "name": "consumer2", "file": "more1.js" }, + "target": { "name": "fn2", "file": "more1.js" }, + "kind": "calls", + "mode": "pts-spread" + } ] } diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js index 6ac9b7ace..b75aac74e 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js @@ -12,26 +12,38 @@ function fn8() {} // for-of over plain array function iterPlain() { const arr = [fn1, fn2]; - for (const f of arr) { f(); } + for (const f of arr) { + f(); + } } // for-of over Set constructed from array function iterSet() { const arr = [fn3, fn4]; const s = new Set(arr); - for (const f of s) { f(); } + for (const f of s) { + f(); + } } // Array.from with named callback -function mapCallback(item) { item(); } +function mapCallback(item) { + item(); +} function runFrom() { const arr = [fn5, fn6]; Array.from(arr, mapCallback); } // spread into callback consumers -function consumer1(x, y) { x(); y(); } -function consumer2(x, y) { x(); y(); } +function consumer1(x, y) { + x(); + y(); +} +function consumer2(x, y) { + x(); + y(); +} function runSpread() { const batch1 = [fn7, fn8]; diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json index b646b85a8..94ed8fb43 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json @@ -3,9 +3,29 @@ "language": "javascript", "description": "Spread operator call resolution — named functions spread as arguments", "edges": [ - { "source": { "name": "f", "file": "spread.js" }, "target": { "name": "a", "file": "spread.js" }, "kind": "calls", "mode": "pts-spread" }, - { "source": { "name": "f", "file": "spread.js" }, "target": { "name": "b", "file": "spread.js" }, "kind": "calls", "mode": "pts-spread" }, - { "source": { "name": "g", "file": "spread.js" }, "target": { "name": "c", "file": "spread.js" }, "kind": "calls", "mode": "pts-spread" }, - { "source": { "name": "g", "file": "spread.js" }, "target": { "name": "d", "file": "spread.js" }, "kind": "calls", "mode": "pts-spread" } + { + "source": { "name": "f", "file": "spread.js" }, + "target": { "name": "a", "file": "spread.js" }, + "kind": "calls", + "mode": "pts-spread" + }, + { + "source": { "name": "f", "file": "spread.js" }, + "target": { "name": "b", "file": "spread.js" }, + "kind": "calls", + "mode": "pts-spread" + }, + { + "source": { "name": "g", "file": "spread.js" }, + "target": { "name": "c", "file": "spread.js" }, + "kind": "calls", + "mode": "pts-spread" + }, + { + "source": { "name": "g", "file": "spread.js" }, + "target": { "name": "d", "file": "spread.js" }, + "kind": "calls", + "mode": "pts-spread" + } ] } diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js index eee098bf3..99ce4f34d 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js @@ -5,11 +5,17 @@ function b() {} function c() {} function d() {} -function f(x, y) { x(); y(); } -function g(x, y) { x(); y(); } +function f(x, y) { + x(); + y(); +} +function g(x, y) { + x(); + y(); +} const arr1 = [a, b]; -f(...arr1); // f→a, f→b +f(...arr1); // f→a, f→b const arr2 = [c, d]; -g(...arr2); // g→c, g→d +g(...arr2); // g→c, g→d diff --git a/tests/unit/points-to.test.ts b/tests/unit/points-to.test.ts index 018c1c1cb..8f5b22920 100644 --- a/tests/unit/points-to.test.ts +++ b/tests/unit/points-to.test.ts @@ -162,7 +162,15 @@ describe('buildPointsToMap — array-element pts constraints (Phase 8.3e)', () = { arrayName: 'arr', index: 1, elemName: 'fn2' }, ]; const spreadArgBindings = [{ callee: 'f', arrayName: 'arr', startIndex: 0 }]; - const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, defParams, arrayElemBindings, spreadArgBindings); + const pts = buildPointsToMap( + [], + defNames, + NO_IMPORTS, + undefined, + defParams, + arrayElemBindings, + spreadArgBindings, + ); expect(resolveViaPointsTo('f::x', pts)).toContain('fn1'); expect(resolveViaPointsTo('f::y', pts)).toContain('fn2'); }); @@ -175,7 +183,16 @@ describe('buildPointsToMap — array-element pts constraints (Phase 8.3e)', () = { arrayName: 'arr', index: 1, elemName: 'fn2' }, ]; const forOfBindings = [{ varName: 'f', sourceName: 'arr', enclosingFunc: 'outer' }]; - const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings, undefined, forOfBindings); + const pts = buildPointsToMap( + [], + defNames, + NO_IMPORTS, + undefined, + undefined, + arrayElemBindings, + undefined, + forOfBindings, + ); expect(resolveViaPointsTo('outer::f', pts)).toContain('fn1'); expect(resolveViaPointsTo('outer::f', pts)).toContain('fn2'); }); @@ -189,7 +206,17 @@ describe('buildPointsToMap — array-element pts constraints (Phase 8.3e)', () = { arrayName: 'arr', index: 1, elemName: 'fn2' }, ]; const arrayCallbackBindings = [{ sourceName: 'arr', calleeName: 'cb' }]; - const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, defParams, arrayElemBindings, undefined, undefined, arrayCallbackBindings); + const pts = buildPointsToMap( + [], + defNames, + NO_IMPORTS, + undefined, + defParams, + arrayElemBindings, + undefined, + undefined, + arrayCallbackBindings, + ); expect(resolveViaPointsTo('cb::item', pts)).toContain('fn1'); expect(resolveViaPointsTo('cb::item', pts)).toContain('fn2'); }); @@ -204,7 +231,16 @@ describe('buildPointsToMap — array-element pts constraints (Phase 8.3e)', () = ]; const fnRefBindings = [{ lhs: 's[*]', rhs: 'arr[*]' }]; const forOfBindings = [{ varName: 'f', sourceName: 's', enclosingFunc: 'outer' }]; - const pts = buildPointsToMap(fnRefBindings, defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings, undefined, forOfBindings); + const pts = buildPointsToMap( + fnRefBindings, + defNames, + NO_IMPORTS, + undefined, + undefined, + arrayElemBindings, + undefined, + forOfBindings, + ); expect(resolveViaPointsTo('outer::f', pts)).toContain('fn1'); expect(resolveViaPointsTo('outer::f', pts)).toContain('fn2'); }); From 1b84435be6340cd327947ded0e37c71279e8be27 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 20:11:35 -0600 Subject: [PATCH 08/17] fix: scan all files for prototype call edges, not just definition files (#1331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The newDefFiles guard restricted call scanning to only files that define new prototype methods, silently dropping call edges from files that only call those methods. A foo.speak() call in app.js to Foo.speak defined in lib.js would never produce an edge. Remove the guard — the newNodeIds check inside the loop already prevents duplicate edges. Also hoist db.prepare() outside the loop to avoid re-preparing the same statement on every iteration. --- .../graph/builder/stages/native-orchestrator.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index bae03b0a7..f378f6e62 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -691,16 +691,15 @@ async function runPostNativePrototypeMethods( .all() as Array<{ source_id: number; target_id: number }>; const seenByPair = new Set(existingPairs.map((e) => `${e.source_id}|${e.target_id}`)); - // For each file that produced new prototype nodes, resolve call edges. + // Resolve call edges in every file — not just those that define new prototype + // methods. A caller in app.js calling a prototype method defined in lib.js + // would be silently missed if we only scanned definition files. + // The newNodeIds guard inside the loop already prevents duplicate edges. const newEdgeRows: unknown[][] = []; - const newDefFiles = new Set(newDefs.map((d) => d.file)); + const fileNodeStmt = db.prepare(`SELECT id FROM nodes WHERE kind = 'file' AND file = ?`); for (const [relPath, symbols] of wasmResults) { - if (!newDefFiles.has(relPath)) continue; - - const fileNodeRow = db - .prepare(`SELECT id FROM nodes WHERE kind = 'file' AND file = ?`) - .get(relPath) as { id: number } | undefined; + const fileNodeRow = fileNodeStmt.get(relPath) as { id: number } | undefined; if (!fileNodeRow) continue; const typeMap = symbols.typeMap instanceof Map ? symbols.typeMap : new Map(); From d926cf3214e1cb82208f259e5c600932425ec438 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 22:18:28 -0600 Subject: [PATCH 09/17] perf: pre-filter prototype files and remove dead seenByPair DB load (#1331) --- .../builder/stages/native-orchestrator.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index f378f6e62..790145744 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -591,9 +591,24 @@ async function runPostNativePrototypeMethods( if (jsFiles.length === 0) return; - // WASM-parse all JS/TS files to get full ExtractorOutput including - // prototype method definitions and typeMap entries. - const absPaths = jsFiles.map((f) => path.join(rootDir, f)); + // Quick pre-filter: only re-parse files that actually contain ".prototype." + // to avoid an expensive WASM re-parse of every JS/TS file in large repos + // where prototype patterns are uncommon. This reduces the hot path from + // O(all_js_files) to O(files_with_prototype_patterns). + const protoFiles = jsFiles.filter((relPath) => { + try { + const content = readFileSafe(path.join(rootDir, relPath)); + return content.includes('.prototype.'); + } catch { + return false; + } + }); + + if (protoFiles.length === 0) return; + + // WASM-parse only the files that have prototype patterns to get full + // ExtractorOutput including prototype method definitions and typeMap entries. + const absPaths = protoFiles.map((f) => path.join(rootDir, f)); let wasmResults: Map; try { wasmResults = await parseFilesWasmForBackfill(absPaths, rootDir); @@ -685,11 +700,12 @@ async function runPostNativePrototypeMethods( }), ); - // Seed seenByPair from existing call edges to avoid duplicates. - const existingPairs = db - .prepare(`SELECT source_id, target_id FROM edges WHERE kind = 'calls'`) - .all() as Array<{ source_id: number; target_id: number }>; - const seenByPair = new Set(existingPairs.map((e) => `${e.source_id}|${e.target_id}`)); + // seenByPair deduplicates edges we emit within this function only. + // No pre-existing edge can target a newly-inserted node ID (SQLite + // auto-increment guarantees the new IDs are unique), so there is no need + // to seed this set from the DB — doing so would load O(|edges|) data for + // zero benefit and could OOM on large repositories. + const seenByPair = new Set(); // Resolve call edges in every file — not just those that define new prototype // methods. A caller in app.js calling a prototype method defined in lib.js From a6c5d2d54d79b5015d295c3cb91aeeb465e93a95 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 22:21:42 -0600 Subject: [PATCH 10/17] feat(resolver): resolve this-dispatch on function-as-object property methods (JS) Extract `fn.method = function() {}` assignments as `method` definitions in both the query-based and walk-based JS extraction paths, enabling `this.other()` calls inside such methods to resolve via the existing callerName-based this-dispatch logic in `resolveByMethodOrGlobal`. Extend the native-engine prototype backfill post-pass to also trigger on files containing `fn.prop = function` patterns so the same resolution applies when the Rust orchestrator runs. Closes #1334 --- .../builder/stages/native-orchestrator.ts | 11 ++- src/extractors/javascript.ts | 75 ++++++++++++++- .../jelly-micro/this/expected-edges.json | 13 +++ .../fixtures/jelly-micro/this/this.js | 8 ++ .../func-prop-this-dispatch.test.ts | 92 +++++++++++++++++++ tests/parsers/javascript.test.ts | 50 ++++++++++ 6 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/this/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js create mode 100644 tests/integration/func-prop-this-dispatch.test.ts diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 790145744..b918c6762 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -591,14 +591,15 @@ async function runPostNativePrototypeMethods( if (jsFiles.length === 0) return; - // Quick pre-filter: only re-parse files that actually contain ".prototype." - // to avoid an expensive WASM re-parse of every JS/TS file in large repos - // where prototype patterns are uncommon. This reduces the hot path from - // O(all_js_files) to O(files_with_prototype_patterns). + // Quick pre-filter: only re-parse files that actually contain prototype or + // function-as-object-property patterns to avoid an expensive WASM re-parse of + // every JS/TS file in large repos. Covers: + // - `.prototype.` — classical prototype method assignment + // - `\b\w+\.\w+\s*=\s*function` — function-as-object property (`f.g = function(){}`) const protoFiles = jsFiles.filter((relPath) => { try { const content = readFileSafe(path.join(rootDir, relPath)); - return content.includes('.prototype.'); + return content.includes('.prototype.') || /\b\w+\.\w+\s*=\s*function/.test(content); } catch { return false; } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 358cc0ba9..4edaddd01 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -313,6 +313,7 @@ function dispatchQueryMatch( if (callInfo) calls.push(callInfo); } else if (c.assign_node) { handleCommonJSAssignment(c.assign_left!, c.assign_right!, c.assign_node, imports); + handleFuncPropAssignment(c.assign_left!, c.assign_right!, definitions); } } @@ -643,6 +644,8 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { ); // Prototype-based method definitions: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }` extractPrototypeMethodsWalk(tree.rootNode, ctx.definitions, ctx.typeMap!); + // Function-as-object property methods: `fn.method = function() { ... }` + extractFuncPropMethodsWalk(tree.rootNode, ctx.definitions); // Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis extractParamBindingsWalk(tree.rootNode, ctx.paramBindings!); // Phase 8.3e: Extract array-element and spread/for-of/Array.from bindings @@ -1724,6 +1727,18 @@ function extractSpreadForOfWalk( funcStack.push(nameNode.text); pushedFunc = true; } + } else if (node.type === 'variable_declarator') { + // `const process = (arr) => { ... }` — arrow/expression functions assigned + // to a variable have no `name` field on the function node itself. + const nameNode = node.childForFieldName('name'); + const valueNode = node.childForFieldName('value'); + if ( + nameNode?.type === 'identifier' && + (valueNode?.type === 'arrow_function' || valueNode?.type === 'function_expression') + ) { + funcStack.push(nameNode.text); + pushedFunc = true; + } } if (node.type === 'call_expression') { @@ -1836,8 +1851,10 @@ function extractSpreadForOfWalk( } } } - const enclosingFunc = funcStack.length > 0 ? funcStack[funcStack.length - 1]! : ''; - if (varName && !BUILTIN_GLOBALS.has(varName) && enclosingFunc) { + // Use '' as sentinel for top-level for-of outside any function. + const enclosingFunc = + funcStack.length > 0 ? funcStack[funcStack.length - 1]! : ''; + if (varName && !BUILTIN_GLOBALS.has(varName)) { forOfBindings.push({ varName, sourceName: right.text, enclosingFunc }); } } @@ -2430,6 +2447,60 @@ function emitPrototypeMethod( } } +/** + * Extract function-as-object property method definitions. + * + * Handles `fn.method = function() {}` and `fn.method = () => {}` patterns. + * Emits a `method` definition named `fn.method` so that: + * 1. `findCaller` attributes calls inside the body to `fn.method` + * 2. `resolveByMethodOrGlobal` resolves `this.other()` inside `fn.method` to `fn.other` + * + * Excludes BUILTIN_GLOBALS objects and `.prototype` (handled by extractPrototypeMethodsWalk). + */ +function extractFuncPropMethodsWalk(rootNode: TreeSitterNode, definitions: Definition[]): 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') { + const lhs = expr.childForFieldName('left'); + const rhs = expr.childForFieldName('right'); + if (lhs && rhs) handleFuncPropAssignment(lhs, rhs, definitions); + } + } + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)!, depth + 1); + } + } + walk(rootNode, 0); +} + +function handleFuncPropAssignment( + lhs: TreeSitterNode, + rhs: TreeSitterNode, + definitions: Definition[], +): void { + if (lhs.type !== 'member_expression') return; + if (rhs.type !== 'function_expression' && rhs.type !== 'arrow_function') return; + + const obj = lhs.childForFieldName('object'); + const prop = lhs.childForFieldName('property'); + if (!obj || !prop) return; + if (obj.type !== 'identifier') return; + if (prop.type !== 'property_identifier' && prop.type !== 'identifier') return; + if (BUILTIN_GLOBALS.has(obj.text)) return; + if (prop.text === 'prototype') return; + + const params = extractParameters(rhs); + definitions.push({ + name: `${obj.text}.${prop.text}`, + kind: 'method', + line: nodeStartLine(rhs), + endLine: nodeEndLine(rhs), + children: params.length > 0 ? params : undefined, + }); +} + /** Iterate over an object literal assigned to `Foo.prototype` and emit defs/aliases. */ function extractPrototypeObjectLiteral( className: string, diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/this/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/this/expected-edges.json new file mode 100644 index 000000000..5cf947017 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/this/expected-edges.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "this-dispatch in function-as-object property methods", + "edges": [ + { + "source": { "name": "f.h", "file": "this.js" }, + "target": { "name": "f.g", "file": "this.js" }, + "kind": "calls", + "mode": "this-dispatch-func-prop" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js b/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js new file mode 100644 index 000000000..eba94b220 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js @@ -0,0 +1,8 @@ +// Jelly micro-test: this — function-as-object property methods, this-dispatch + +function f() {} +f.g = function() { console.log("2"); } +f.h = function() { + this.g(); // this === f when called as f.h() +} +f.h(); diff --git a/tests/integration/func-prop-this-dispatch.test.ts b/tests/integration/func-prop-this-dispatch.test.ts new file mode 100644 index 000000000..3bcd60497 --- /dev/null +++ b/tests/integration/func-prop-this-dispatch.test.ts @@ -0,0 +1,92 @@ +/** + * Integration test for #1334: this-dispatch in function-as-object property methods. + * + * Verifies that `f.h → f.g` is resolved when `f.h = function() { this.g(); }`. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const FIXTURE = { + 'this.js': ` +function f() {} +f.g = function() { console.log("2"); } +f.h = function() { + this.g(); +} +f.h(); +`, +}; + +let tmpDir: string; + +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1334-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(tmpDir, rel), content); + } + await buildGraph(tmpDir, { incremental: false, skipRegistry: true }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function readCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' + ORDER BY n1.name, n2.name`, + ) + .all() as Array<{ src: string; tgt: string }>; + } finally { + db.close(); + } +} + +describe('func-prop this-dispatch (#1334)', () => { + it('emits f.g as a method definition', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const db = new Database(dbPath, { readonly: true }); + try { + const node = db.prepare(`SELECT name, kind FROM nodes WHERE name = 'f.g'`).get() as + | { name: string; kind: string } + | undefined; + expect(node).toBeDefined(); + expect(node?.kind).toBe('method'); + } finally { + db.close(); + } + }); + + it('emits f.h as a method definition', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const db = new Database(dbPath, { readonly: true }); + try { + const node = db.prepare(`SELECT name, kind FROM nodes WHERE name = 'f.h'`).get() as + | { name: string; kind: string } + | undefined; + expect(node).toBeDefined(); + expect(node?.kind).toBe('method'); + } finally { + db.close(); + } + }); + + it('resolves this.g() inside f.h to f.g', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readCallEdges(dbPath); + const edge = edges.find((e) => e.src === 'f.h' && e.tgt === 'f.g'); + expect(edge).toBeDefined(); + }); +}); diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 7450f667f..95aa27df1 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -813,4 +813,54 @@ describe('JavaScript parser', () => { expect(symbols.typeMap.get('C.helper')).toEqual({ type: 'helper', confidence: 0.9 }); }); }); + + describe('function-as-object property method extraction (#1334)', () => { + it('extracts fn.method = function() {} as a method definition', () => { + const symbols = parseJS(` + function f() {} + f.g = function() { console.log("2"); } + `); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'f.g', kind: 'method' }), + ); + }); + + it('extracts fn.method = () => {} as a method definition', () => { + const symbols = parseJS(` + function f() {} + f.g = () => 42; + `); + expect(symbols.definitions).toContainEqual( + expect.objectContaining({ name: 'f.g', kind: 'method' }), + ); + }); + + it('extracts the this.g() call inside f.h', () => { + const symbols = parseJS(` + function f() {} + f.g = function() {} + f.h = function() { this.g(); } + `); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'g', receiver: 'this' }), + ); + }); + + it('does not extract func-prop assignments on built-in globals', () => { + const symbols = parseJS(`console.log = function() {};`); + expect(symbols.definitions).not.toContainEqual( + expect.objectContaining({ name: 'console.log' }), + ); + }); + + it('does not extract .prototype property assignments (handled by prototype walk)', () => { + const symbols = parseJS(` + function C() {} + C.prototype = function() {}; + `); + expect(symbols.definitions).not.toContainEqual( + expect.objectContaining({ name: 'C.prototype' }), + ); + }); + }); }); From c484fd1bcd4bb511b6c93dd16d0cd34a068444dc Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Fri, 5 Jun 2026 22:44:53 -0600 Subject: [PATCH 11/17] feat(resolver): resolve property calls on object destructuring rest parameters (JS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Phase 8.3f: when a function parameter uses object destructuring with a rest element (`function f3({ e1: eee1, ...eerest })`), and the rest object's property is called (`eerest.e4()`), resolve the callee via a three-hop chain: ObjectRestParamBinding (eerest ← f3 param 0) + ParamBinding (f3(obj) → obj at index 0) + ObjectPropBinding (obj = { e4 } → obj.e4 = e4) → pts["eerest.e4"] = {"e4"} → calls edge f3 → e4 Changes: - types.ts: add ObjectRestParamBinding and ObjectPropBinding interfaces - javascript.ts: extractObjectRestParamBindingsWalk (finds rest params in object-destructured function params) and extractObjectPropBindingsWalk (finds shorthand/identifier properties in object literals); wired into both extractSymbolsQuery and extractSymbolsWalk paths - wasm-worker-{protocol,entry,pool}.ts: serialize new binding arrays - points-to.ts: seed pts["rest.propName"] = {"fn"} from the three-hop chain - build-edges.ts: new Phase 8.3f receiver-pts fallback — when a receiver call is unresolved, check pts["receiver.name"] for rest-dispatch targets; also include new bindings in buildPointsToMapForFile null-check guard Jelly micro-test benchmark (rest fixture): recall=100% TP=1 FN=0 FP=0 Closes #1336 --- .../graph/builder/stages/build-edges.ts | 43 ++++++- src/domain/graph/resolver/points-to.ts | 22 ++++ src/domain/wasm-worker-entry.ts | 4 + src/domain/wasm-worker-pool.ts | 2 + src/domain/wasm-worker-protocol.ts | 2 + src/extractors/javascript.ts | 110 ++++++++++++++++++ src/types.ts | 31 +++++ .../jelly-micro/rest/expected-edges.json | 13 +++ .../fixtures/jelly-micro/rest/rest.js | 14 +++ 9 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/rest/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 24c2a6f48..0eae5fe3d 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -836,7 +836,9 @@ function buildPointsToMapForFile( !symbols.arrayElemBindings?.length && !symbols.spreadArgBindings?.length && !symbols.forOfBindings?.length && - !symbols.arrayCallbackBindings?.length + !symbols.arrayCallbackBindings?.length && + !symbols.objectRestParamBindings?.length && + !symbols.objectPropBindings?.length ) return null; const defNames = new Set( @@ -855,6 +857,8 @@ function buildPointsToMapForFile( symbols.spreadArgBindings, symbols.forOfBindings, symbols.arrayCallbackBindings, + symbols.objectRestParamBindings, + symbols.objectPropBindings, ); } @@ -1007,6 +1011,43 @@ function buildFileCallEdges( } } + // Phase 8.3f: pts fallback for receiver calls via object-rest param bindings. + // Fires when `rest.prop()` is encountered and `rest` was seeded as `pts["rest.prop"]` + // by the object-rest dispatch chain (ObjectRestParamBinding + paramBinding + ObjectPropBinding). + if ( + targets.length === 0 && + call.receiver && + !BUILTIN_RECEIVERS.has(call.receiver) && + call.receiver !== 'this' && + call.receiver !== 'self' && + call.receiver !== 'super' && + ptsMap + ) { + const receiverKey = `${call.receiver}.${call.name}`; + if (ptsMap.has(receiverKey)) { + for (const alias of resolveViaPointsTo(receiverKey, ptsMap)) { + const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets( + lookup, + { name: alias }, + relPath, + importedNames, + typeMap as Map, + ); + for (const t of aliasTargets) { + const edgeKey = `${caller.id}|${t.id}`; + if (t.id !== caller.id && !seenCallEdges.has(edgeKey) && !ptsEdgeRows.has(edgeKey)) { + const conf = + computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY; + if (conf > 0) { + ptsEdgeRows.set(edgeKey, allEdgeRows.length); + allEdgeRows.push([caller.id, t.id, 'calls', conf, isDynamic, 'points-to']); + } + } + } + } + } + } + if ( call.receiver && !BUILTIN_RECEIVERS.has(call.receiver) && diff --git a/src/domain/graph/resolver/points-to.ts b/src/domain/graph/resolver/points-to.ts index f773432a6..a46329aae 100644 --- a/src/domain/graph/resolver/points-to.ts +++ b/src/domain/graph/resolver/points-to.ts @@ -24,6 +24,8 @@ import type { ArrayElemBinding, FnRefBinding, ForOfBinding, + ObjectPropBinding, + ObjectRestParamBinding, ParamBinding, SpreadArgBinding, } from '../../../types.js'; @@ -68,6 +70,8 @@ export function buildPointsToMap( spreadArgBindings?: readonly SpreadArgBinding[], forOfBindings?: readonly ForOfBinding[], arrayCallbackBindings?: readonly ArrayCallbackBinding[], + objectRestParamBindings?: readonly ObjectRestParamBinding[], + objectPropBindings?: readonly ObjectPropBinding[], ): PointsToMap { const pts: PointsToMap = new Map(); @@ -177,6 +181,24 @@ export function buildPointsToMap( } } + // Phase 8.3f: object-rest parameter dispatch. + // `function f({ ...rest }) {}` + `f(obj)` + `const obj = { prop: fn }` → + // seed pts["rest.prop"] = {"fn"} so that `rest.prop()` resolves to `fn`. + if (objectRestParamBindings && objectPropBindings && paramBindings) { + for (const { callee, restName, argIndex } of objectRestParamBindings) { + for (const { callee: pbCallee, argIndex: pbArgIdx, argName } of paramBindings) { + if (pbCallee !== callee || pbArgIdx !== argIndex) continue; + for (const { objectName, propName, valueName } of objectPropBindings) { + if (objectName !== argName) continue; + if (!definitionNames.has(valueName) && !importedNames.has(valueName)) continue; + const key = `${restName}.${propName}`; + if (!pts.has(key)) pts.set(key, new Set()); + pts.get(key)!.add(valueName); + } + } + } + } + if (constraints.length === 0) return pts; // Fixed-point iteration: propagate pts sets until no new information flows. diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index a1da019b1..501453c0c 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -810,6 +810,10 @@ function serializeExtractorOutput( ...(symbols.arrayCallbackBindings?.length ? { arrayCallbackBindings: symbols.arrayCallbackBindings } : {}), + ...(symbols.objectRestParamBindings?.length + ? { objectRestParamBindings: symbols.objectRestParamBindings } + : {}), + ...(symbols.objectPropBindings?.length ? { objectPropBindings: symbols.objectPropBindings } : {}), ...(symbols.newExpressions?.length ? { newExpressions: symbols.newExpressions } : {}), }; } diff --git a/src/domain/wasm-worker-pool.ts b/src/domain/wasm-worker-pool.ts index 167964c0e..defa166f8 100644 --- a/src/domain/wasm-worker-pool.ts +++ b/src/domain/wasm-worker-pool.ts @@ -112,6 +112,8 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp if (ser.spreadArgBindings?.length) out.spreadArgBindings = ser.spreadArgBindings; if (ser.forOfBindings?.length) out.forOfBindings = ser.forOfBindings; if (ser.arrayCallbackBindings?.length) out.arrayCallbackBindings = ser.arrayCallbackBindings; + if (ser.objectRestParamBindings?.length) out.objectRestParamBindings = ser.objectRestParamBindings; + if (ser.objectPropBindings?.length) out.objectPropBindings = ser.objectPropBindings; if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions; return out; } diff --git a/src/domain/wasm-worker-protocol.ts b/src/domain/wasm-worker-protocol.ts index 85f8e69ee..626ce1fa4 100644 --- a/src/domain/wasm-worker-protocol.ts +++ b/src/domain/wasm-worker-protocol.ts @@ -68,6 +68,8 @@ export interface SerializedExtractorOutput { spreadArgBindings?: import('../types.js').SpreadArgBinding[]; forOfBindings?: import('../types.js').ForOfBinding[]; arrayCallbackBindings?: import('../types.js').ArrayCallbackBinding[]; + objectRestParamBindings?: import('../types.js').ObjectRestParamBinding[]; + objectPropBindings?: import('../types.js').ObjectPropBinding[]; newExpressions?: readonly string[]; } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 4edaddd01..855351715 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -11,6 +11,8 @@ import type { FnRefBinding, ForOfBinding, Import, + ObjectPropBinding, + ObjectRestParamBinding, ParamBinding, SpreadArgBinding, SubDeclaration, @@ -332,6 +334,8 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr const spreadArgBindings: SpreadArgBinding[] = []; const forOfBindings: ForOfBinding[] = []; const arrayCallbackBindings: ArrayCallbackBinding[] = []; + const objectRestParamBindings: ObjectRestParamBinding[] = []; + const objectPropBindings: ObjectPropBinding[] = []; const matches = query.matches(tree.rootNode); @@ -373,6 +377,10 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr // Extract definitions from destructured bindings (query patterns don't match object_pattern) extractDestructuredBindingsWalk(tree.rootNode, definitions); + // Phase 8.3f: Extract object-rest parameter and object-property bindings + extractObjectRestParamBindingsWalk(tree.rootNode, objectRestParamBindings); + extractObjectPropBindingsWalk(tree.rootNode, objectPropBindings); + // Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking const newExpressions: string[] = []; extractNewExpressionsWalk(tree.rootNode, newExpressions); @@ -392,6 +400,8 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr spreadArgBindings, forOfBindings, arrayCallbackBindings, + objectRestParamBindings, + objectPropBindings, newExpressions, }; } @@ -629,6 +639,8 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { spreadArgBindings: [], forOfBindings: [], arrayCallbackBindings: [], + objectRestParamBindings: [], + objectPropBindings: [], }; walkJavaScriptNode(tree.rootNode, ctx); @@ -657,6 +669,9 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { ctx.arrayCallbackBindings!, ctx.fnRefBindings!, ); + // Phase 8.3f: Extract object-rest parameter and object-property bindings + extractObjectRestParamBindingsWalk(tree.rootNode, ctx.objectRestParamBindings!); + extractObjectPropBindingsWalk(tree.rootNode, ctx.objectPropBindings!); // Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking const newExpressions: string[] = []; extractNewExpressionsWalk(tree.rootNode, newExpressions); @@ -1871,6 +1886,101 @@ function extractSpreadForOfWalk( walk(rootNode, 0); } +/** + * Phase 8.3f: collect object-rest parameter bindings. + * + * `function f({ a, ...rest }) {}` → `{ callee: "f", restName: "rest", argIndex: 0 }` + * + * Enables resolving `rest.prop()` when a known object is passed as that parameter. + */ +function extractObjectRestParamBindingsWalk( + rootNode: TreeSitterNode, + bindings: ObjectRestParamBinding[], +): void { + function walk(node: TreeSitterNode, depth: number): void { + if (depth >= MAX_WALK_DEPTH) return; + const t = node.type; + if (t === 'function_declaration' || t === 'function_expression' || t === 'arrow_function') { + const nameNode = node.childForFieldName('name'); + const funcName = nameNode?.text; + if (funcName) { + const paramsNode = + node.childForFieldName('parameters') || findChild(node, 'formal_parameters'); + if (paramsNode) { + let argIndex = 0; + for (let i = 0; i < paramsNode.childCount; i++) { + const param = paramsNode.child(i); + if (!param) continue; + const pt = param.type; + if (pt === ',' || pt === '(' || pt === ')') continue; + if (pt === 'object_pattern') { + for (let j = 0; j < param.childCount; j++) { + const child = param.child(j); + if (!child) continue; + if (child.type !== 'rest_pattern' && child.type !== 'rest_element') continue; + const restNameNode = child.child(1) ?? child.childForFieldName('name'); + if (restNameNode?.type === 'identifier') { + bindings.push({ callee: funcName, restName: restNameNode.text, argIndex }); + } + } + } + argIndex++; + } + } + } + } + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)!, depth + 1); + } + } + walk(rootNode, 0); +} + +/** + * Phase 8.3f: collect object-property bindings from object literals. + * + * `const obj = { e4 }` → `{ objectName: "obj", propName: "e4", valueName: "e4" }` + * `const obj = { e1: fn }` → `{ objectName: "obj", propName: "e1", valueName: "fn" }` + * + * Only tracks shorthand and `key: identifier` pairs; skips function literals. + */ +function extractObjectPropBindingsWalk( + rootNode: TreeSitterNode, + bindings: ObjectPropBinding[], +): void { + function walk(node: TreeSitterNode, depth: number): void { + if (depth >= MAX_WALK_DEPTH) return; + if (node.type === 'variable_declarator') { + const nameN = node.childForFieldName('name'); + const valueN = node.childForFieldName('value'); + if (nameN?.type === 'identifier' && valueN?.type === 'object') { + const objectName = nameN.text; + for (let i = 0; i < valueN.childCount; i++) { + const child = valueN.child(i); + if (!child) continue; + if (child.type === 'shorthand_property_identifier') { + bindings.push({ objectName, propName: child.text, valueName: child.text }); + } else if (child.type === 'pair') { + const keyN = child.childForFieldName('key'); + const valN = child.childForFieldName('value'); + if ( + keyN?.type === 'property_identifier' && + valN?.type === 'identifier' && + !BUILTIN_GLOBALS.has(valN.text) + ) { + bindings.push({ objectName, propName: keyN.text, valueName: valN.text }); + } + } + } + } + } + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)!, depth + 1); + } + } + walk(rootNode, 0); +} + function extractReceiverName(objNode: TreeSitterNode | null): string | undefined { if (!objNode) return undefined; const t = objNode.type; diff --git a/src/types.ts b/src/types.ts index 9461aa7fb..1c592adff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -603,6 +603,33 @@ export interface ArrayCallbackBinding { calleeName: string; } +/** + * An object-rest parameter binding: `function f({ a, ...rest })` records that + * `rest` is the rest of the object passed as argument `argIndex` to `f`. + * Phase 8.3f: object destructuring rest dispatch. + */ +export interface ObjectRestParamBinding { + /** Function that owns this rest parameter, e.g. "f3" */ + callee: string; + /** Name of the rest binding, e.g. "eerest" */ + restName: string; + /** Zero-based index of the argument whose rest is bound, e.g. 0 */ + argIndex: number; +} + +/** + * An object-property binding: `const obj = { e4 }` or `const obj = { e4: fn }` records + * that `obj.e4` points to the named function `fn`. Phase 8.3f. + */ +export interface ObjectPropBinding { + /** Variable holding the object, e.g. "obj" */ + objectName: string; + /** Property name, e.g. "e4" */ + propName: string; + /** Named function value, e.g. "e4" or "fn" */ + valueName: string; +} + /** The normalized output shape returned by every language extractor. */ export interface ExtractorOutput { definitions: Definition[]; @@ -642,6 +669,10 @@ export interface ExtractorOutput { forOfBindings?: ForOfBinding[]; /** Phase 8.3e: array callback bindings from Array.from/forEach/etc. */ arrayCallbackBindings?: ArrayCallbackBinding[]; + /** Phase 8.3f: object-rest parameter bindings from `function f({ ...rest })` patterns. */ + objectRestParamBindings?: ObjectRestParamBinding[]; + /** Phase 8.3f: object-property bindings from `const obj = { fn }` patterns. */ + objectPropBindings?: ObjectPropBinding[]; /** * Phase 8.5 (RTA): constructor names from all `new X()` expressions in the file, * including unassigned ones (e.g. `doSomething(new Foo())`). Used to build the diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/rest/expected-edges.json b/tests/benchmarks/resolution/fixtures/jelly-micro/rest/expected-edges.json new file mode 100644 index 000000000..95546fe13 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/rest/expected-edges.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "javascript", + "description": "Object destructuring rest parameter dispatch — eerest.e4() → e4", + "edges": [ + { + "source": { "name": "f3", "file": "rest.js" }, + "target": { "name": "e4", "file": "rest.js" }, + "kind": "calls", + "mode": "pts-obj-rest" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js b/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js new file mode 100644 index 000000000..933d44448 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js @@ -0,0 +1,14 @@ +// Jelly micro-test: rest — object destructuring rest parameter dispatch + +function e1() {} +function e2() {} +function e3() {} +function e4() {} + +const obj = { e1, e2, e3, e4 }; + +function f3({ e1: eee1, ...eerest }) { + eee1(); + eerest.e4(); // eerest.e4 === obj.e4 === e4 when called as f3(obj) +} +f3(obj); From 4ed709e279fefae7b3029379fdd41fcf670544bf Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 6 Jun 2026 00:30:02 -0600 Subject: [PATCH 12/17] fix(lint): apply biome auto-fixes across extractors and domain files useOptionalChain rewrites and formatting fixes flagged by Biome in CI. --- src/domain/graph/journal.ts | 2 +- src/domain/wasm-worker-entry.ts | 4 +++- src/domain/wasm-worker-pool.ts | 3 ++- src/extractors/c.ts | 6 +++--- src/extractors/clojure.ts | 2 +- src/extractors/cpp.ts | 6 +++--- src/extractors/cuda.ts | 6 +++--- src/extractors/elixir.ts | 12 ++++++------ src/extractors/fsharp.ts | 2 +- src/extractors/go.ts | 10 +++++----- src/extractors/haskell.ts | 2 +- src/extractors/java.ts | 4 ++-- src/extractors/kotlin.ts | 10 +++++----- src/extractors/lua.ts | 2 +- src/extractors/objc.ts | 6 +++--- src/extractors/ocaml.ts | 2 +- src/extractors/php.ts | 4 ++-- src/extractors/python.ts | 14 +++++++------- src/extractors/ruby.ts | 4 ++-- src/extractors/scala.ts | 2 +- src/extractors/solidity.ts | 2 +- src/extractors/swift.ts | 8 ++++---- src/extractors/zig.ts | 8 ++++---- .../fixtures/javascript/bind-call-apply.js | 2 +- .../fixtures/javascript/define-property.js | 6 +++--- .../resolution/fixtures/jelly-micro/more1/more1.js | 8 ++++---- .../resolution/fixtures/jelly-micro/rest/rest.js | 4 ++-- .../resolution/fixtures/jelly-micro/this/this.js | 10 ++++++---- .../benchmarks/resolution/tracer/loader-hooks.mjs | 2 +- tests/engines/dataflow-parity.test.ts | 2 +- tests/integration/roles.test.ts | 6 +++--- 31 files changed, 83 insertions(+), 78 deletions(-) diff --git a/src/domain/graph/journal.ts b/src/domain/graph/journal.ts index d20c7dab9..8d68256ca 100644 --- a/src/domain/graph/journal.ts +++ b/src/domain/graph/journal.ts @@ -235,7 +235,7 @@ interface JournalResult { } function parseJournalHeader(firstLine: string | undefined): number | null { - if (!firstLine || !firstLine.startsWith(HEADER_PREFIX)) { + if (!firstLine?.startsWith(HEADER_PREFIX)) { debug('Journal has malformed or missing header'); return null; } diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index 5e3feca8c..a6f5b2af7 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -815,7 +815,9 @@ function serializeExtractorOutput( ...(symbols.objectRestParamBindings?.length ? { objectRestParamBindings: symbols.objectRestParamBindings } : {}), - ...(symbols.objectPropBindings?.length ? { objectPropBindings: symbols.objectPropBindings } : {}), + ...(symbols.objectPropBindings?.length + ? { objectPropBindings: symbols.objectPropBindings } + : {}), ...(symbols.newExpressions?.length ? { newExpressions: symbols.newExpressions } : {}), }; } diff --git a/src/domain/wasm-worker-pool.ts b/src/domain/wasm-worker-pool.ts index defa166f8..55934fbf9 100644 --- a/src/domain/wasm-worker-pool.ts +++ b/src/domain/wasm-worker-pool.ts @@ -112,7 +112,8 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp if (ser.spreadArgBindings?.length) out.spreadArgBindings = ser.spreadArgBindings; if (ser.forOfBindings?.length) out.forOfBindings = ser.forOfBindings; if (ser.arrayCallbackBindings?.length) out.arrayCallbackBindings = ser.arrayCallbackBindings; - if (ser.objectRestParamBindings?.length) out.objectRestParamBindings = ser.objectRestParamBindings; + if (ser.objectRestParamBindings?.length) + out.objectRestParamBindings = ser.objectRestParamBindings; if (ser.objectPropBindings?.length) out.objectPropBindings = ser.objectPropBindings; if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions; return out; diff --git a/src/extractors/c.ts b/src/extractors/c.ts index 0395d2211..b9c15a9b2 100644 --- a/src/extractors/c.ts +++ b/src/extractors/c.ts @@ -189,7 +189,7 @@ function extractCParameters(paramListNode: TreeSitterNode | null): SubDeclaratio if (!paramListNode) return params; for (let i = 0; i < paramListNode.childCount; i++) { const param = paramListNode.child(i); - if (!param || param.type !== 'parameter_declaration') continue; + if (param?.type !== 'parameter_declaration') continue; const nameNode = param.childForFieldName('declarator'); if (nameNode) { const name = unwrapCDeclaratorName(nameNode); @@ -205,7 +205,7 @@ function extractStructFields(structNode: TreeSitterNode): SubDeclaration[] { if (!body) return fields; for (let i = 0; i < body.childCount; i++) { const member = body.child(i); - if (!member || member.type !== 'field_declaration') continue; + if (member?.type !== 'field_declaration') continue; const nameNode = member.childForFieldName('declarator'); if (nameNode) { const name = unwrapCDeclaratorName(nameNode); @@ -221,7 +221,7 @@ function extractEnumEntries(enumNode: TreeSitterNode): SubDeclaration[] { if (!body) return entries; for (let i = 0; i < body.childCount; i++) { const member = body.child(i); - if (!member || member.type !== 'enumerator') continue; + if (member?.type !== 'enumerator') continue; const nameNode = member.childForFieldName('name'); if (nameNode) { entries.push({ name: nameNode.text, kind: 'constant', line: member.startPosition.row + 1 }); diff --git a/src/extractors/clojure.ts b/src/extractors/clojure.ts index 0d7490898..ca6645796 100644 --- a/src/extractors/clojure.ts +++ b/src/extractors/clojure.ts @@ -224,7 +224,7 @@ function extractClojureParams(defnNode: TreeSitterNode): SubDeclaration[] { // Find the parameter vector [x y z] for (let i = 0; i < defnNode.childCount; i++) { const child = defnNode.child(i); - if (!child || child.type !== 'vec_lit') continue; + if (child?.type !== 'vec_lit') continue; for (let j = 0; j < child.childCount; j++) { const param = child.child(j); if (!param) continue; diff --git a/src/extractors/cpp.ts b/src/extractors/cpp.ts index b81767d1e..b014d58ae 100644 --- a/src/extractors/cpp.ts +++ b/src/extractors/cpp.ts @@ -292,7 +292,7 @@ function extractCppParameters(paramListNode: TreeSitterNode | null): SubDeclarat if (!paramListNode) return params; for (let i = 0; i < paramListNode.childCount; i++) { const param = paramListNode.child(i); - if (!param || param.type !== 'parameter_declaration') continue; + if (param?.type !== 'parameter_declaration') continue; const nameNode = param.childForFieldName('declarator'); if (nameNode) { const name = unwrapCppDeclaratorName(nameNode); @@ -309,7 +309,7 @@ function extractCppClassFields(classNode: TreeSitterNode): SubDeclaration[] { if (!body) return fields; for (let i = 0; i < body.childCount; i++) { const member = body.child(i); - if (!member || member.type !== 'field_declaration') continue; + if (member?.type !== 'field_declaration') continue; const nameNode = member.childForFieldName('declarator'); if (nameNode) { const name = unwrapCppDeclaratorName(nameNode); @@ -330,7 +330,7 @@ function extractCppEnumEntries(enumNode: TreeSitterNode): SubDeclaration[] { if (!body) return entries; for (let i = 0; i < body.childCount; i++) { const member = body.child(i); - if (!member || member.type !== 'enumerator') continue; + if (member?.type !== 'enumerator') continue; const nameNode = member.childForFieldName('name'); if (nameNode) { entries.push({ name: nameNode.text, kind: 'constant', line: member.startPosition.row + 1 }); diff --git a/src/extractors/cuda.ts b/src/extractors/cuda.ts index f2f8597a0..14f30609b 100644 --- a/src/extractors/cuda.ts +++ b/src/extractors/cuda.ts @@ -262,7 +262,7 @@ function extractCudaParameters(paramListNode: TreeSitterNode | null): SubDeclara if (!paramListNode) return params; for (let i = 0; i < paramListNode.childCount; i++) { const param = paramListNode.child(i); - if (!param || param.type !== 'parameter_declaration') continue; + if (param?.type !== 'parameter_declaration') continue; const nameNode = param.childForFieldName('declarator'); if (nameNode) { // Reuse the field-name drill helper so function-type parameters like @@ -282,7 +282,7 @@ function extractCudaClassFields(classNode: TreeSitterNode): SubDeclaration[] { if (!body) return fields; for (let i = 0; i < body.childCount; i++) { const member = body.child(i); - if (!member || member.type !== 'field_declaration') continue; + if (member?.type !== 'field_declaration') continue; const nameNode = member.childForFieldName('declarator'); if (!nameNode) continue; // Skip method declarations — a `field_declaration` whose declarator @@ -380,7 +380,7 @@ function extractCudaEnumEntries(enumNode: TreeSitterNode): SubDeclaration[] { if (!body) return entries; for (let i = 0; i < body.childCount; i++) { const member = body.child(i); - if (!member || member.type !== 'enumerator') continue; + if (member?.type !== 'enumerator') continue; const nameNode = member.childForFieldName('name'); if (nameNode) { entries.push({ name: nameNode.text, kind: 'constant', line: member.startPosition.row + 1 }); diff --git a/src/extractors/elixir.ts b/src/extractors/elixir.ts index 79f2f824f..b1ad19b8b 100644 --- a/src/extractors/elixir.ts +++ b/src/extractors/elixir.ts @@ -122,9 +122,9 @@ function collectModuleMembers( ): void { for (let i = 0; i < doBlock.childCount; i++) { const child = doBlock.child(i); - if (!child || child.type !== 'call') continue; + if (child?.type !== 'call') continue; const target = child.childForFieldName('target'); - if (!target || target.type !== 'identifier') continue; + if (target?.type !== 'identifier') continue; if (target.text === 'def' || target.text === 'defp') { const fnName = extractFunctionName(child); @@ -184,7 +184,7 @@ function extractElixirParams(defCallNode: TreeSitterNode): SubDeclaration[] { for (let i = 0; i < args.childCount; i++) { const child = args.child(i); - if (!child || child.type !== 'call') continue; + if (child?.type !== 'call') continue; const innerArgs = findChild(child, 'arguments'); if (!innerArgs) continue; for (let j = 0; j < innerArgs.childCount; j++) { @@ -277,13 +277,13 @@ function pushElixirMapValues(node: TreeSitterNode, stack: TreeSitterNode[]): voi const parts: TreeSitterNode[] = []; for (let i = 0; i < node.childCount; i++) { const content = node.child(i); - if (!content || content.type !== 'map_content') continue; + if (content?.type !== 'map_content') continue; for (let j = 0; j < content.childCount; j++) { const kws = content.child(j); - if (!kws || kws.type !== 'keywords') continue; + if (kws?.type !== 'keywords') continue; for (let k = 0; k < kws.childCount; k++) { const pair = kws.child(k); - if (!pair || pair.type !== 'pair') continue; + if (pair?.type !== 'pair') continue; for (let p = 0; p < pair.childCount; p++) { const part = pair.child(p); if (!part || part.type === 'keyword') continue; diff --git a/src/extractors/fsharp.ts b/src/extractors/fsharp.ts index 45569fe97..f1d10e8b6 100644 --- a/src/extractors/fsharp.ts +++ b/src/extractors/fsharp.ts @@ -303,7 +303,7 @@ function handleValueDefinition( currentModule: string | null, ): void { const first = node.child(0); - if (!first || first.type !== 'val') return; + if (first?.type !== 'val') return; const declLeft = findChild(node, 'value_declaration_left'); if (!declLeft) return; diff --git a/src/extractors/go.ts b/src/extractors/go.ts index 6019b910c..21549134a 100644 --- a/src/extractors/go.ts +++ b/src/extractors/go.ts @@ -111,7 +111,7 @@ function extractGoReceiverType(receiver: TreeSitterNode): string | null { function handleGoTypeDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { for (let i = 0; i < node.childCount; i++) { const spec = node.child(i); - if (!spec || spec.type !== 'type_spec') continue; + if (spec?.type !== 'type_spec') continue; const nameNode = spec.childForFieldName('name'); const typeNode = spec.childForFieldName('type'); if (!nameNode || !typeNode) continue; @@ -213,7 +213,7 @@ function extractGoImportSpec(spec: TreeSitterNode, ctx: ExtractorOutput): void { function handleGoConstDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { for (let i = 0; i < node.childCount; i++) { const spec = node.child(i); - if (!spec || spec.type !== 'const_spec') continue; + if (spec?.type !== 'const_spec') continue; const constName = spec.childForFieldName('name'); if (constName) { ctx.definitions.push({ @@ -288,7 +288,7 @@ function inferAddressOfComposite( ): boolean { if (rhs.type !== 'unary_expression') return false; const operand = rhs.childForFieldName('operand'); - if (!operand || operand.type !== 'composite_literal') return false; + if (operand?.type !== 'composite_literal') return false; const typeNode = operand.childForFieldName('type'); if (!typeNode) return false; const typeName = extractGoTypeName(typeNode); @@ -409,7 +409,7 @@ function extractGoParameters(paramListNode: TreeSitterNode | null): SubDeclarati if (!paramListNode) return params; for (let i = 0; i < paramListNode.childCount; i++) { const param = paramListNode.child(i); - if (!param || param.type !== 'parameter_declaration') continue; + if (param?.type !== 'parameter_declaration') continue; // A parameter_declaration may have multiple identifiers (e.g., `a, b int`) for (let j = 0; j < param.childCount; j++) { const child = param.child(j); @@ -494,7 +494,7 @@ function extractStructFields(structTypeNode: TreeSitterNode): SubDeclaration[] { if (!fieldList) return fields; for (let i = 0; i < fieldList.childCount; i++) { const field = fieldList.child(i); - if (!field || field.type !== 'field_declaration') continue; + if (field?.type !== 'field_declaration') continue; const nameNode = field.childForFieldName('name'); if (nameNode) { fields.push({ name: nameNode.text, kind: 'property', line: field.startPosition.row + 1 }); diff --git a/src/extractors/haskell.ts b/src/extractors/haskell.ts index 4d2573779..1d66300a4 100644 --- a/src/extractors/haskell.ts +++ b/src/extractors/haskell.ts @@ -123,7 +123,7 @@ function collectHaskellPatternBindings(node: TreeSitterNode, out: SubDeclaration case 'record': for (let i = 0; i < node.childCount; i++) { const fp = node.child(i); - if (!fp || fp.type !== 'field_pattern') continue; + if (fp?.type !== 'field_pattern') continue; for (let j = 0; j < fp.childCount; j++) { const g = fp.child(j); if (g && g.type !== 'field_name') collectHaskellPatternBindings(g, out); diff --git a/src/extractors/java.ts b/src/extractors/java.ts index f091afe53..225273bfa 100644 --- a/src/extractors/java.ts +++ b/src/extractors/java.ts @@ -330,7 +330,7 @@ function extractClassFields(classNode: TreeSitterNode): SubDeclaration[] { if (!body) return fields; for (let i = 0; i < body.childCount; i++) { const member = body.child(i); - if (!member || member.type !== 'field_declaration') continue; + if (member?.type !== 'field_declaration') continue; extractFieldDeclarators(member, fields); } return fields; @@ -341,7 +341,7 @@ function extractFieldDeclarators(member: TreeSitterNode, fields: SubDeclaration[ const vis = extractModifierVisibility(member); for (let j = 0; j < member.childCount; j++) { const child = member.child(j); - if (!child || child.type !== 'variable_declarator') continue; + if (child?.type !== 'variable_declarator') continue; const nameNode = child.childForFieldName('name'); if (nameNode) { fields.push({ diff --git a/src/extractors/kotlin.ts b/src/extractors/kotlin.ts index ac3570616..d5f95df67 100644 --- a/src/extractors/kotlin.ts +++ b/src/extractors/kotlin.ts @@ -102,7 +102,7 @@ function collectKotlinEnumEntries(node: TreeSitterNode): SubDeclaration[] { if (!body) return entries; for (let i = 0; i < body.childCount; i++) { const child = body.child(i); - if (!child || child.type !== 'enum_entry') continue; + if (child?.type !== 'enum_entry') continue; const entryName = findChild(child, 'simple_identifier'); if (entryName) { entries.push({ @@ -122,7 +122,7 @@ function collectKotlinProperties(node: TreeSitterNode): SubDeclaration[] { if (!body) return props; for (let i = 0; i < body.childCount; i++) { const child = body.child(i); - if (!child || child.type !== 'property_declaration') continue; + if (child?.type !== 'property_declaration') continue; const varDecl = findChild(child, 'variable_declaration'); if (!varDecl) continue; const id = findChild(varDecl, 'simple_identifier'); @@ -144,7 +144,7 @@ function collectKotlinMethods(node: TreeSitterNode, className: string, ctx: Extr if (!body) return; for (let i = 0; i < body.childCount; i++) { const child = body.child(i); - if (!child || child.type !== 'function_declaration') continue; + if (child?.type !== 'function_declaration') continue; const methName = findChild(child, 'simple_identifier'); if (methName) { const params = extractKotlinParameters(child); @@ -168,7 +168,7 @@ function collectKotlinInheritance( ): void { for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - if (!child || child.type !== 'delegation_specifier') continue; + if (child?.type !== 'delegation_specifier') continue; // constructor_invocation > user_type > type_identifier (extends) const ctorInvocation = findChild(child, 'constructor_invocation'); @@ -294,7 +294,7 @@ function extractKotlinParameters(funcNode: TreeSitterNode): SubDeclaration[] { if (!paramList) return params; for (let i = 0; i < paramList.childCount; i++) { const param = paramList.child(i); - if (!param || param.type !== 'parameter') continue; + if (param?.type !== 'parameter') continue; const nameNode = findChild(param, 'simple_identifier'); if (nameNode) { params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 }); diff --git a/src/extractors/lua.ts b/src/extractors/lua.ts index c2d0dddc1..85c99b827 100644 --- a/src/extractors/lua.ts +++ b/src/extractors/lua.ts @@ -90,7 +90,7 @@ function extractLuaParams(funcNode: TreeSitterNode): SubDeclaration[] { for (let i = 0; i < paramList.childCount; i++) { const param = paramList.child(i); - if (!param || param.type !== 'identifier') continue; + if (param?.type !== 'identifier') continue; params.push({ name: param.text, kind: 'parameter', line: param.startPosition.row + 1 }); } return params; diff --git a/src/extractors/objc.ts b/src/extractors/objc.ts index 5e22aa1f1..7e89a37f4 100644 --- a/src/extractors/objc.ts +++ b/src/extractors/objc.ts @@ -468,7 +468,7 @@ function extractPropertyName(propNode: TreeSitterNode): string | null { if (!structDecl) return null; for (let i = 0; i < structDecl.childCount; i++) { const child = structDecl.child(i); - if (!child || child.type !== 'struct_declarator') continue; + if (child?.type !== 'struct_declarator') continue; const id = findIdentifierDeep(child); if (id) return id.text; } @@ -494,7 +494,7 @@ function extractMethodParams(methodNode: TreeSitterNode): SubDeclaration[] { const params: SubDeclaration[] = []; for (let i = 0; i < methodNode.childCount; i++) { const child = methodNode.child(i); - if (!child || child.type !== 'method_parameter') continue; + if (child?.type !== 'method_parameter') continue; let nameNode: TreeSitterNode | null = null; for (let j = 0; j < child.childCount; j++) { const inner = child.child(j); @@ -516,7 +516,7 @@ function extractCParams(paramListNode: TreeSitterNode | null): SubDeclaration[] if (!paramListNode) return params; for (let i = 0; i < paramListNode.childCount; i++) { const param = paramListNode.child(i); - if (!param || param.type !== 'parameter_declaration') continue; + if (param?.type !== 'parameter_declaration') continue; const nameNode = param.childForFieldName('declarator'); if (nameNode) { const name = unwrapObjCDeclaratorName(nameNode); diff --git a/src/extractors/ocaml.ts b/src/extractors/ocaml.ts index e59e7af21..d1c80405f 100644 --- a/src/extractors/ocaml.ts +++ b/src/extractors/ocaml.ts @@ -168,7 +168,7 @@ function handleOCamlTypeDef(node: TreeSitterNode, ctx: ExtractorOutput): void { // type_definition contains one or more type_bindings for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - if (!child || child.type !== 'type_binding') continue; + if (child?.type !== 'type_binding') continue; const nameNode = child.childForFieldName('name') || diff --git a/src/extractors/php.ts b/src/extractors/php.ts index 95e75d8ca..b43c2489d 100644 --- a/src/extractors/php.ts +++ b/src/extractors/php.ts @@ -36,7 +36,7 @@ function extractPhpParameters(fnNode: TreeSitterNode): SubDeclaration[] { function extractPhpProperties(member: TreeSitterNode, children: SubDeclaration[]): void { for (let j = 0; j < member.childCount; j++) { const el = member.child(j); - if (!el || el.type !== 'property_element') continue; + if (el?.type !== 'property_element') continue; const varNode = findChild(el, 'variable_name'); if (varNode) { children.push({ @@ -53,7 +53,7 @@ function extractPhpProperties(member: TreeSitterNode, children: SubDeclaration[] function extractPhpConstants(member: TreeSitterNode, children: SubDeclaration[]): void { for (let j = 0; j < member.childCount; j++) { const el = member.child(j); - if (!el || el.type !== 'const_element') continue; + if (el?.type !== 'const_element') continue; const nameNode = el.childForFieldName('name') || findChild(el, 'name'); if (nameNode) { children.push({ diff --git a/src/extractors/python.ts b/src/extractors/python.ts index b78b31962..2cd547a1e 100644 --- a/src/extractors/python.ts +++ b/src/extractors/python.ts @@ -291,7 +291,7 @@ function extractClassAssignment( const assignment = findChild(child, 'assignment'); if (!assignment) return; const left = assignment.childForFieldName('left'); - if (!left || left.type !== 'identifier' || seen.has(left.text)) return; + if (left?.type !== 'identifier' || seen.has(left.text)) return; seen.add(left.text); props.push({ name: left.text, @@ -308,7 +308,7 @@ function extractInitProperties( props: SubDeclaration[], ): void { const fnName = node.childForFieldName('name'); - if (!fnName || fnName.text !== '__init__') return; + if (fnName?.text !== '__init__') return; const initBody = node.childForFieldName('body') || findChild(node, 'block'); if (initBody) walkInitBody(initBody, seen, props); } @@ -342,11 +342,11 @@ function extractPythonClassProperties(classNode: TreeSitterNode): SubDeclaration function walkInitBody(bodyNode: TreeSitterNode, seen: Set, props: SubDeclaration[]): void { for (let i = 0; i < bodyNode.childCount; i++) { const stmt = bodyNode.child(i); - if (!stmt || stmt.type !== 'expression_statement') continue; + if (stmt?.type !== 'expression_statement') continue; const assignment = findChild(stmt, 'assignment'); if (!assignment) continue; const left = assignment.childForFieldName('left'); - if (!left || left.type !== 'attribute') continue; + if (left?.type !== 'attribute') continue; const obj = left.childForFieldName('object'); const attr = left.childForFieldName('attribute'); if (obj && obj.text === 'self' && attr && attr.type === 'identifier' && !seen.has(attr.text)) { @@ -370,7 +370,7 @@ function handlePyTypedParam(node: TreeSitterNode, ctx: ExtractorOutput): void { const isDefault = node.type === 'typed_default_parameter'; const nameNode = isDefault ? node.childForFieldName('name') : node.child(0); const typeNode = node.childForFieldName('type'); - if (!nameNode || nameNode.type !== 'identifier' || !typeNode) return; + if (nameNode?.type !== 'identifier' || !typeNode) return; if (nameNode.text === 'self' || nameNode.text === 'cls') return; const typeName = extractPythonTypeName(typeNode); if (typeName && ctx.typeMap) setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9); @@ -380,7 +380,7 @@ function handlePyTypedParam(node: TreeSitterNode, ctx: ExtractorOutput): void { function handlePyAssignmentType(node: TreeSitterNode, ctx: ExtractorOutput): void { const left = node.childForFieldName('left'); const right = node.childForFieldName('right'); - if (!left || left.type !== 'identifier' || !right || right.type !== 'call') return; + if (left?.type !== 'identifier' || !right || right.type !== 'call') return; const fn = right.childForFieldName('function'); if (!fn) return; @@ -391,7 +391,7 @@ function handlePyAssignmentType(node: TreeSitterNode, ctx: ExtractorOutput): voi } } else if (fn.type === 'attribute') { const obj = fn.childForFieldName('object'); - if (!obj || obj.type !== 'identifier') return; + if (obj?.type !== 'identifier') return; const objName = obj.text; if (objName[0] && objName[0] !== objName[0].toLowerCase() && !BUILTIN_GLOBALS_PY.has(objName)) { if (ctx.typeMap) setTypeMapEntry(ctx.typeMap, left.text, objName, 0.7); diff --git a/src/extractors/ruby.ts b/src/extractors/ruby.ts index 2c9bb2d5a..60abce7d9 100644 --- a/src/extractors/ruby.ts +++ b/src/extractors/ruby.ts @@ -264,7 +264,7 @@ function extractRubyBodyConstants(containerNode: TreeSitterNode): SubDeclaration if (!body) return children; for (let i = 0; i < body.childCount; i++) { const child = body.child(i); - if (!child || child.type !== 'assignment') continue; + if (child?.type !== 'assignment') continue; const left = child.childForFieldName('left'); if (left && left.type === 'constant') { children.push({ name: left.text, kind: 'constant', line: child.startPosition.row + 1 }); @@ -279,7 +279,7 @@ function extractRubyClassChildren(classNode: TreeSitterNode): SubDeclaration[] { if (!body) return children; for (let i = 0; i < body.childCount; i++) { const child = body.child(i); - if (!child || child.type !== 'assignment') continue; + if (child?.type !== 'assignment') continue; const left = child.childForFieldName('left'); if (!left) continue; if (left.type === 'instance_variable') { diff --git a/src/extractors/scala.ts b/src/extractors/scala.ts index d48d79570..e422eaea2 100644 --- a/src/extractors/scala.ts +++ b/src/extractors/scala.ts @@ -263,7 +263,7 @@ function extractScalaParameters(funcNode: TreeSitterNode): SubDeclaration[] { if (!paramList) return params; for (let i = 0; i < paramList.childCount; i++) { const param = paramList.child(i); - if (!param || param.type !== 'parameter') continue; + if (param?.type !== 'parameter') continue; const nameNode = findChild(param, 'identifier'); if (nameNode) { params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 }); diff --git a/src/extractors/solidity.ts b/src/extractors/solidity.ts index 8626b29e0..951c13522 100644 --- a/src/extractors/solidity.ts +++ b/src/extractors/solidity.ts @@ -166,7 +166,7 @@ function extractContractMember(child: TreeSitterNode): SubDeclaration | null { function extractInheritance(node: TreeSitterNode, name: string, ctx: ExtractorOutput): void { for (let i = 0; i < node.childCount; i++) { const inheritance = node.child(i); - if (!inheritance || inheritance.type !== 'inheritance_specifier') continue; + if (inheritance?.type !== 'inheritance_specifier') continue; for (let j = 0; j < inheritance.childCount; j++) { const child = inheritance.child(j); if (!child) continue; diff --git a/src/extractors/swift.ts b/src/extractors/swift.ts index 0687df134..a31acc5bc 100644 --- a/src/extractors/swift.ts +++ b/src/extractors/swift.ts @@ -94,7 +94,7 @@ function collectSwiftEnumEntries(node: TreeSitterNode): SubDeclaration[] { if (!body) return entries; for (let i = 0; i < body.childCount; i++) { const child = body.child(i); - if (!child || child.type !== 'enum_entry') continue; + if (child?.type !== 'enum_entry') continue; const entryName = findChild(child, 'simple_identifier'); if (entryName) { entries.push({ @@ -114,7 +114,7 @@ function collectSwiftProperties(node: TreeSitterNode): SubDeclaration[] { if (!body) return props; for (let i = 0; i < body.childCount; i++) { const child = body.child(i); - if (!child || child.type !== 'property_declaration') continue; + if (child?.type !== 'property_declaration') continue; const pattern = findChild(child, 'pattern'); if (!pattern) continue; const propName = findChild(pattern, 'simple_identifier'); @@ -136,7 +136,7 @@ function collectSwiftMethods(node: TreeSitterNode, className: string, ctx: Extra if (!body) return; for (let i = 0; i < body.childCount; i++) { const child = body.child(i); - if (!child || child.type !== 'function_declaration') continue; + if (child?.type !== 'function_declaration') continue; const methName = findChild(child, 'simple_identifier'); if (methName) { ctx.definitions.push({ @@ -159,7 +159,7 @@ function collectSwiftInheritance( let first = true; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - if (!child || child.type !== 'inheritance_specifier') continue; + if (child?.type !== 'inheritance_specifier') continue; const userType = findChild(child, 'user_type'); const typeId = userType ? findChild(userType, 'type_identifier') : null; if (!typeId) continue; diff --git a/src/extractors/zig.ts b/src/extractors/zig.ts index 7f2226410..682e26136 100644 --- a/src/extractors/zig.ts +++ b/src/extractors/zig.ts @@ -86,7 +86,7 @@ function extractZigParams(funcNode: TreeSitterNode): SubDeclaration[] { for (let i = 0; i < paramList.childCount; i++) { const param = paramList.child(i); - if (!param || param.type !== 'parameter') continue; + if (param?.type !== 'parameter') continue; const nameNode = findChild(param, 'identifier'); if (nameNode) { params.push({ name: nameNode.text, kind: 'parameter', line: param.startPosition.row + 1 }); @@ -151,7 +151,7 @@ function zigContainerKind(nodeType: string): 'struct' | 'enum' | undefined { function tryHandleZigImportDecl(node: TreeSitterNode, name: string, ctx: ExtractorOutput): boolean { for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - if (!child || child.type !== 'builtin_function') continue; + if (child?.type !== 'builtin_function') continue; const source = extractZigImportSource(child); if (source) { ctx.imports.push({ source, names: [name], line: node.startPosition.row + 1 }); @@ -175,7 +175,7 @@ function extractZigContainerFields(container: TreeSitterNode): SubDeclaration[] const fields: SubDeclaration[] = []; for (let i = 0; i < container.childCount; i++) { const child = container.child(i); - if (!child || child.type !== 'container_field') continue; + if (child?.type !== 'container_field') continue; const nameNode = child.childForFieldName('name') || findChild(child, 'identifier'); if (nameNode) { fields.push({ name: nameNode.text, kind: 'property', line: child.startPosition.row + 1 }); @@ -191,7 +191,7 @@ function extractZigContainerMethods( ): void { for (let i = 0; i < container.childCount; i++) { const child = container.child(i); - if (!child || child.type !== 'function_declaration') continue; + if (child?.type !== 'function_declaration') continue; const nameNode = child.childForFieldName('name'); if (nameNode) { ctx.definitions.push({ diff --git a/tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js b/tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js index e5269668b..fe36c0e80 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js +++ b/tests/benchmarks/resolution/fixtures/javascript/bind-call-apply.js @@ -1,7 +1,7 @@ // Patterns for Function.prototype.bind / .call / .apply resolution. function greet(greeting) { - return greeting + ' ' + this.name; + return `${greeting} ${this.name}`; } var user = { name: 'Alice' }; diff --git a/tests/benchmarks/resolution/fixtures/javascript/define-property.js b/tests/benchmarks/resolution/fixtures/javascript/define-property.js index 58948d385..ea8cdce83 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/define-property.js +++ b/tests/benchmarks/resolution/fixtures/javascript/define-property.js @@ -7,14 +7,14 @@ function f2() { } // Object.defineProperty(obj, "key", { value: fn }) → obj.key() resolves to fn -function defProp() { +function _defProp() { const obj = {}; Object.defineProperty(obj, 'f', { value: f1 }); obj.f(); } // Object.defineProperties(obj, { key: { value: fn } }) → obj.key() resolves to fn -function defProps() { +function _defProps() { const obj = {}; Object.defineProperties(obj, { f1: { value: f1 }, @@ -25,7 +25,7 @@ function defProps() { } // Object.create({ key: fn }) → obj.key() resolves via prototype -function create() { +function _create() { const obj = Object.create({ f1, f2 }); obj.f1(); obj.f2(); diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js index b75aac74e..a722ae38b 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js @@ -10,7 +10,7 @@ function fn7() {} function fn8() {} // for-of over plain array -function iterPlain() { +function _iterPlain() { const arr = [fn1, fn2]; for (const f of arr) { f(); @@ -18,7 +18,7 @@ function iterPlain() { } // for-of over Set constructed from array -function iterSet() { +function _iterSet() { const arr = [fn3, fn4]; const s = new Set(arr); for (const f of s) { @@ -30,7 +30,7 @@ function iterSet() { function mapCallback(item) { item(); } -function runFrom() { +function _runFrom() { const arr = [fn5, fn6]; Array.from(arr, mapCallback); } @@ -45,7 +45,7 @@ function consumer2(x, y) { y(); } -function runSpread() { +function _runSpread() { const batch1 = [fn7, fn8]; consumer1(...batch1); } diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js b/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js index 933d44448..2559c72e8 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/rest/rest.js @@ -8,7 +8,7 @@ function e4() {} const obj = { e1, e2, e3, e4 }; function f3({ e1: eee1, ...eerest }) { - eee1(); - eerest.e4(); // eerest.e4 === obj.e4 === e4 when called as f3(obj) + eee1(); + eerest.e4(); // eerest.e4 === obj.e4 === e4 when called as f3(obj) } f3(obj); diff --git a/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js b/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js index eba94b220..0ab7c579f 100644 --- a/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js @@ -1,8 +1,10 @@ // Jelly micro-test: this — function-as-object property methods, this-dispatch function f() {} -f.g = function() { console.log("2"); } -f.h = function() { - this.g(); // this === f when called as f.h() -} +f.g = () => { + console.log('2'); +}; +f.h = function () { + this.g(); // this === f when called as f.h() +}; f.h(); diff --git a/tests/benchmarks/resolution/tracer/loader-hooks.mjs b/tests/benchmarks/resolution/tracer/loader-hooks.mjs index 8f5f9bba0..bfe54e764 100644 --- a/tests/benchmarks/resolution/tracer/loader-hooks.mjs +++ b/tests/benchmarks/resolution/tracer/loader-hooks.mjs @@ -106,7 +106,7 @@ function instrumentSource(source, filename) { // Insert enter/try for new function declarations if (funcName && trimmed.endsWith('{')) { - const inner = indent + ' '; + const inner = `${indent} `; const escaped = funcName.replace(/'/g, "\\'"); output.push(`${inner}globalThis.__tracer?.enter('${escaped}', '${file}');`); output.push(`${inner}try {`); diff --git a/tests/engines/dataflow-parity.test.ts b/tests/engines/dataflow-parity.test.ts index 85072505a..f31c5aa45 100644 --- a/tests/engines/dataflow-parity.test.ts +++ b/tests/engines/dataflow-parity.test.ts @@ -37,7 +37,7 @@ function wasmDataflow(code, filePath, langId) { */ function nativeDataflow(code, filePath) { const result = native.parseFile(filePath, code, true); - if (!result || !result.dataflow) return null; + if (!result?.dataflow) return null; const df = result.dataflow; return { parameters: (df.parameters || []).map((p) => ({ diff --git a/tests/integration/roles.test.ts b/tests/integration/roles.test.ts index 911a737b3..b5f3a8a5f 100644 --- a/tests/integration/roles.test.ts +++ b/tests/integration/roles.test.ts @@ -112,8 +112,8 @@ describe('barrel re-export role classification', () => { // Symbol nodes const queryName = insertNode(db, 'queryName', 'function', 'src/inspect.ts', 10); - const helperFn = insertNode(db, 'helperFn', 'function', 'src/inspect.ts', 30); - const appMain = insertNode(db, 'appMain', 'function', 'src/app.ts', 1); + const _helperFn = insertNode(db, 'helperFn', 'function', 'src/inspect.ts', 30); + const _appMain = insertNode(db, 'appMain', 'function', 'src/app.ts', 1); const testFn = insertNode(db, 'testQueryName', 'function', 'tests/inspect.test.ts', 1); // Barrel re-exports inspect.ts @@ -190,7 +190,7 @@ describe('multi-level barrel re-export chain', () => { const fQueriesCli = insertNode(db, 'src/queries-cli.ts', 'file', 'src/queries-cli.ts', 0); const fQuery = insertNode(db, 'src/query.ts', 'file', 'src/query.ts', 0); - const queryName = insertNode(db, 'queryName', 'function', 'src/queries-cli/inspect.ts', 10); + const _queryName = insertNode(db, 'queryName', 'function', 'src/queries-cli/inspect.ts', 10); insertNode(db, 'queryCmd', 'function', 'src/query.ts', 1); // Barrel chain: each barrel re-exports from the one below From 66b899ae707d4e50bbcbcc18681b44b6d228a359 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 6 Jun 2026 00:32:47 -0600 Subject: [PATCH 13/17] fix(pts): resolve module-level for-of and class-method for-of PTS keys Two bugs in the forOfBindings points-to resolution path: 1. sentinel never consumed: extractSpreadForOfWalk emits ForOfBinding with enclosingFunc='' for top-level for-of loops, but build-edges.ts only looked up scopedPtsKey (null at module level). Add a modulePtsKey fallback that checks '::call.name' so `for (const f of arr) { f(); }` at module scope resolves correctly. 2. method_definition pushes unqualified name: funcStack.push('bar') but findCaller returns callerName='Foo.bar' from the definitions array. Add a classStack to extractSpreadForOfWalk so method_definition nodes push the qualified name 'Foo.bar', matching the PTS key the lookup uses. --- .../graph/builder/stages/build-edges.ts | 20 +++++++++++--- src/extractors/javascript.ts | 27 +++++++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 87c107ff2..f9aa74d1e 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -1079,21 +1079,33 @@ function buildFileCallEdges( // direct call to the same target in the same function body can upgrade confidence // rather than being silently dropped by the dedup guard. const scopedPtsKey = caller.callerName != null ? `${caller.callerName}::${call.name}` : null; + // Module-level calls (callerName === null) use the '' sentinel emitted by + // extractSpreadForOfWalk for top-level for-of loops. Look it up as a fallback so + // that `for (const f of arr) { f(); }` at module scope resolves correctly. + const modulePtsKey = + caller.callerName === null && ptsMap?.has(`::${call.name}`) + ? `::${call.name}` + : null; const flatPtsKey = !call.dynamic && fnRefBindingLhs.has(call.name) && ptsMap?.has(call.name) ? call.name : null; if ( targets.length === 0 && !call.receiver && ptsMap && - (call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)) || flatPtsKey != null) + (call.dynamic || + (scopedPtsKey != null && ptsMap.has(scopedPtsKey)) || + modulePtsKey != null || + flatPtsKey != null) ) { const ptsLookupName = call.dynamic ? call.name : scopedPtsKey != null && ptsMap.has(scopedPtsKey) ? scopedPtsKey - : // flatPtsKey != null is guaranteed by the outer if condition: if neither - // call.dynamic nor scopedPtsKey matched, flatPtsKey != null must be true. - flatPtsKey!; + : modulePtsKey != null + ? modulePtsKey + : // flatPtsKey != null is guaranteed by the outer if condition: if neither + // call.dynamic nor scopedPtsKey nor modulePtsKey matched, flatPtsKey must be non-null. + flatPtsKey!; for (const alias of resolveViaPointsTo(ptsLookupName, ptsMap)) { // Resolve the concrete alias target. Only `name` is needed here — receiver // and line are not relevant for alias resolution (we are looking up the diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 488fda1da..ea0c75e55 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -1871,12 +1871,30 @@ function extractSpreadForOfWalk( fnRefBindings: FnRefBinding[], ): void { const funcStack: string[] = []; + // Tracks the enclosing class name so that method_definition nodes push a + // qualified name (e.g. 'Foo.bar') matching what findCaller returns from the + // definitions array (where class methods are stored as 'Foo.bar'). + const classStack: string[] = []; function walk(node: TreeSitterNode, depth: number): void { if (depth >= MAX_WALK_DEPTH) return; let pushedFunc = false; - if (node.type === 'function_declaration' || node.type === 'generator_function_declaration') { + let pushedClass = false; + if ( + node.type === 'class_declaration' || + node.type === 'abstract_class_declaration' || + node.type === 'class' + ) { + const nameNode = node.childForFieldName('name'); + if (nameNode?.type === 'identifier') { + classStack.push(nameNode.text); + pushedClass = true; + } + } else if ( + node.type === 'function_declaration' || + node.type === 'generator_function_declaration' + ) { const nameNode = node.childForFieldName('name'); if (nameNode?.type === 'identifier') { funcStack.push(nameNode.text); @@ -1885,7 +1903,11 @@ function extractSpreadForOfWalk( } else if (node.type === 'method_definition') { const nameNode = node.childForFieldName('name'); if (nameNode) { - funcStack.push(nameNode.text); + // Qualify with the enclosing class name so the PTS key matches + // callerName from findCaller (which uses def.name = 'ClassName.method'). + const enclosingClass = classStack.length > 0 ? classStack[classStack.length - 1] : null; + const qualifiedName = enclosingClass ? `${enclosingClass}.${nameNode.text}` : nameNode.text; + funcStack.push(qualifiedName); pushedFunc = true; } } else if (node.type === 'variable_declarator') { @@ -2027,6 +2049,7 @@ function extractSpreadForOfWalk( } if (pushedFunc) funcStack.pop(); + if (pushedClass) classStack.pop(); } walk(rootNode, 0); From 667866e02ab5d9c4521e66899bb4b56654c8a8ae Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 6 Jun 2026 02:11:06 -0600 Subject: [PATCH 14/17] fix(bench): sync JS fixture names and use super.count() in DoubleCounter (#1331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs introduced by the fix(lint) commit (4ed709e): 1. define-property.js functions were renamed defProp/defProps/create → _defProp/ _defProps/_create (to suppress biome noUnusedVariables), but expected-edges.json was not updated. This caused 5 false positives and 5 false negatives in the benchmark (precision 84.4%, recall 81.8%). 2. DoubleCounter.count was changed from super.count() to Counter.count() by the same lint fix commit. The fixture is meant to test static class-inheritance resolution via super.count(); reverting to Counter.count() made the edge a plain same-file call, causing the class-inheritance recall to drop to 2/3. Fix: update expected-edges.json names to match renamed functions; restore super.count() in inheritance.js with a biome-ignore suppression explaining the intent. --- .../resolution/fixtures/javascript/expected-edges.json | 10 +++++----- .../resolution/fixtures/javascript/inheritance.js | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json index e60d89dba..a10cf77ab 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json @@ -130,35 +130,35 @@ "notes": "new UserService() — class instantiation tracked as consumption" }, { - "source": { "name": "defProp", "file": "define-property.js" }, + "source": { "name": "_defProp", "file": "define-property.js" }, "target": { "name": "f1", "file": "define-property.js" }, "kind": "calls", "mode": "pts-define-property", "notes": "obj.f() — resolved via Object.defineProperty(obj, \"f\", { value: f1 })" }, { - "source": { "name": "defProps", "file": "define-property.js" }, + "source": { "name": "_defProps", "file": "define-property.js" }, "target": { "name": "f1", "file": "define-property.js" }, "kind": "calls", "mode": "pts-define-property", "notes": "obj.f1() — resolved via Object.defineProperties(obj, { \"f1\": { value: f1 } })" }, { - "source": { "name": "defProps", "file": "define-property.js" }, + "source": { "name": "_defProps", "file": "define-property.js" }, "target": { "name": "f2", "file": "define-property.js" }, "kind": "calls", "mode": "pts-define-property", "notes": "obj.f2() — resolved via Object.defineProperties(obj, { \"f2\": { value: f2 } })" }, { - "source": { "name": "create", "file": "define-property.js" }, + "source": { "name": "_create", "file": "define-property.js" }, "target": { "name": "f1", "file": "define-property.js" }, "kind": "calls", "mode": "pts-create-prototype", "notes": "obj.f1() — resolved via Object.create({ f1, f2 })" }, { - "source": { "name": "create", "file": "define-property.js" }, + "source": { "name": "_create", "file": "define-property.js" }, "target": { "name": "f2", "file": "define-property.js" }, "kind": "calls", "mode": "pts-create-prototype", diff --git a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js index c3c26b2c1..9a0a106d9 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js +++ b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js @@ -29,6 +29,7 @@ export class Counter { export class DoubleCounter extends Counter { static count() { - return Counter.count() * 2; // static super.method() → Counter.count + // biome-ignore lint/complexity/noThisInStatic: intentional super call for class-inheritance resolution test + return super.count() * 2; // static super.method() → Counter.count } } From 194507cbf75a0c3704e9ebf7f849ef32bcb41722 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:53:41 -0600 Subject: [PATCH 15/17] fix(native): add prototype method extraction to Rust engine (#1327) (#1339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native): add prototype method extraction to Rust engine (#1327) Implement parity with the WASM JS extractor for pre-ES6 prototype OOP patterns. Extractor (crates/codegraph-core/src/extractors/javascript.rs): - `Foo.prototype.bar = function(){}` → emits `Foo.bar` definition (kind: method) - `Foo.prototype.bar = identifier` → seeds typeMap['Foo.bar'] = identifier (confidence 0.9) - `Foo.prototype = { bar: fn, ... }` → same rules per property (pair, method_definition, shorthand_property_identifier) Built-in globals (Array, Object, …) are excluded via `is_js_builtin_global` guard. Adds 6 unit tests covering all three patterns plus edge cases. Edge builder (crates/codegraph-core/src/edge_builder.rs): - After a typeMap-resolved type lookup, check typeMap['TypeName.method'] for prototype aliases (`Foo.prototype.bar = identifierAlias`), mirroring the protoAlias fallback added to call-resolver.ts in the WASM path. - Inline new-expression receiver: extract class name from `(new Foo).bar()` receivers using string parsing (mirrors the `^\(?\s*new\s+[A-Z...]` regex in call-resolver.ts), enabling resolution without a named variable binding. Verified against the integration test in tests/integration/prototype-method-resolution.test.ts (all 3 tests pass with native engine). docs check acknowledged Closes #1327 * fix(native): fix parity divergence in extract_inline_new_type Use strip_prefix('(').unwrap_or(receiver) instead of trim_start_matches('(') to strip at most one leading paren, matching the JS regex ^\(?. Also update the doc comment to reflect that _ and $ prefixes are also accepted. * fix(native): strip one surrounding quote pair in prototype object-literal key `trim_matches` was stripping ALL quote chars (e.g. `"it's"` became `its`). Replace with strip_prefix + strip_suffix to remove exactly the outermost matching quote pair. * fix(extractor): remove duplicate extractPrototypeMethodsWalk calls Both extractSymbolsQuery and extractSymbolsWalk had a second call to extractPrototypeMethodsWalk appended at the bottom, causing prototype methods to be extracted twice. Remove the duplicate from each path. The duplication caused a ~44% WASM benchmark regression on the query path (used by wasm-worker-entry.js in benchmarks). * style: fix biome format violations inherited from base branch merge Long lines in wasm-worker-entry.ts, wasm-worker-pool.ts and two fixture files were not wrapped per the 100-char line width rule. * perf(native): remove .prototype. files from WASM post-pass filter The Rust engine now extracts `Foo.prototype.bar = fn` definitions natively (PR #1327). Remove the `.prototype.` text filter from the `runPostNativePrototypeMethods` pre-filter so those files are no longer WASM-reparsed on every native build. The function-as-object-property pattern (`fn.method = function(){}`) is still not handled by Rust and continues to use the WASM post-pass. This eliminates the 422% Build ms/file regression seen on CI. * fix(native): exclude prototype patterns from WASM post-pass pre-filter The regex /\b\w+\.\w+\s*=\s*function/ matched the substring 'prototype.bar = function' inside 'Foo.prototype.bar = function(){}', causing prototype files to be queued for WASM re-processing even though the Rust engine now handles those patterns natively. Added a negative lookahead to exclude the prototype shape, matching only function-as-object-property patterns like 'fn.method = function'. Fixes the duplicate-node risk flagged in Greptile review of #1339. * test(native): add unit tests for extract_inline_new_type edge cases Cover the string-parsing logic in extract_inline_new_type: (new Foo), (new Foo('arg')), no-parens form, _ and $ prefixes, lowercase rejection, plain identifier, and the newFoo-not-a-keyword case. * fix(bench): sync JS fixture names and exclude benchmark fixtures from biome lint (#1339) Commit 4ed709e's biome auto-fix renamed defProp/defProps/create to _defProp/_defProps/_create (unused-variable prefix), but the expected-edges.json manifest still referenced the old names. This caused 5 false positives and 5 false negatives in the JS benchmark, dropping precision to 84.4% (below the 100% threshold) and recall to 81.8% (below 90%). Also fixes the class-inheritance DoubleCounter fixture: the code used Counter.count() (a direct static call) but the manifest expected a class-inheritance edge via super.count(). Changed to super.count() so the fixture tests what the manifest documents. Prevent recurrence by adding a biome.json override that disables lint for tests/benchmarks/resolution/fixtures/** — fixture files are hand-written sample code that must use specific patterns (including apparently-unused functions and super calls) to exercise resolution. --- biome.json | 10 +- crates/codegraph-core/src/edge_builder.rs | 99 +++++++- .../src/extractors/javascript.rs | 218 ++++++++++++++++++ .../builder/stages/native-orchestrator.ts | 22 +- .../fixtures/javascript/inheritance.js | 3 +- 5 files changed, 337 insertions(+), 15 deletions(-) diff --git a/biome.json b/biome.json index 688f8ddb9..bceeec557 100644 --- a/biome.json +++ b/biome.json @@ -25,5 +25,13 @@ "semicolons": "always", "trailingCommas": "all" } - } + }, + "overrides": [ + { + "includes": ["tests/benchmarks/resolution/fixtures/**"], + "linter": { + "enabled": false + } + } + ] } diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index 3e20a60e1..68f04a40f 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -407,13 +407,36 @@ fn resolve_call_targets<'a>( }; let type_lookup = type_map.get(effective_receiver) .or_else(|| type_map.get(receiver.as_str())); - if let Some(&(type_name, _conf)) = type_lookup { + // Inline new-expression receiver: `(new Foo).bar()` — extract the constructor name + // when no typeMap entry exists for the complex receiver expression. + // Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` in call-resolver.ts. + let inline_new_type = if type_lookup.is_none() { + extract_inline_new_type(receiver) + } else { + None + }; + // Use typeMap-resolved type or inline-new-extracted type, whichever is available. + let resolved_type = type_lookup.map(|&(t, _)| t).or(inline_new_type.as_deref()); + if let Some(type_name) = resolved_type { let qualified = format!("{}.{}", type_name, call.name); let typed: Vec<&NodeInfo> = ctx.nodes_by_name .get(qualified.as_str()) .map(|v| v.iter().filter(|n| n.kind == "method").copied().collect()) .unwrap_or_default(); if !typed.is_empty() { return typed; } + // Prototype alias: `Foo.prototype.bar = identifier` seeds typeMap['Foo.bar'] = identifier. + // After the direct method lookup misses (no definition emitted for this method), + // check if the typeMap holds an alias to a standalone function. + // Mirrors the protoAlias fallback in resolveByMethodOrGlobal in call-resolver.ts. + if let Some(&(proto_target, _)) = type_map.get(qualified.as_str()) { + let resolved: Vec<&NodeInfo> = ctx.nodes_by_name + .get(proto_target) + .map(|v| v.iter() + .filter(|n| import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5) + .copied().collect()) + .unwrap_or_default(); + if !resolved.is_empty() { return resolved; } + } } // 4.5. Phase 8.3d: composite pts key — `obj.prop = fn` seeds typeMap['obj.prop'] let composite_key = format!("{}.{}", receiver, call.name); @@ -489,6 +512,31 @@ fn resolve_call_targets<'a>( Vec::new() } +/// Extract the constructor name from an inline `new` receiver expression. +/// +/// Mirrors the regex `/^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/` used in call-resolver.ts. +/// Handles `(new Foo)` and `(new Foo('arg'))` receivers that arise when the call site +/// is `(new Foo).method()` without a named variable binding. +/// +/// Only extracts names that start with an uppercase letter, `_`, or `$` to avoid +/// false positives on plain lowercase constructor calls (rare but present in legacy code). +fn extract_inline_new_type(receiver: &str) -> Option { + let s = receiver.strip_prefix('(').unwrap_or(receiver).trim_start(); + let s = s.strip_prefix("new")?; + if !s.starts_with(|c: char| c.is_whitespace()) { return None; } + let s = s.trim_start(); + let end = s.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$') + .unwrap_or(s.len()); + let name = &s[..end]; + if name.is_empty() { return None; } + let first = name.chars().next()?; + if first.is_uppercase() || first == '_' || first == '$' { + Some(name.to_string()) + } else { + None + } +} + /// Sort targets by confidence descending. fn sort_targets_by_confidence(targets: &mut Vec<&NodeInfo>, rel_path: &str, imported_from: Option<&str>) { if targets.len() > 1 { @@ -1370,3 +1418,52 @@ mod call_edge_tests { assert_eq!(receiver_edge.unwrap().target_id, 2); } } + +#[cfg(test)] +mod inline_new_type_tests { + use super::extract_inline_new_type; + + #[test] + fn parens_new_uppercase() { + assert_eq!(extract_inline_new_type("(new Foo)"), Some("Foo".to_string())); + } + + #[test] + fn parens_new_with_args() { + // (new Foo('arg')) — parens and constructor args + assert_eq!(extract_inline_new_type("(new Foo('arg'))"), Some("Foo".to_string())); + } + + #[test] + fn no_parens_new_uppercase() { + assert_eq!(extract_inline_new_type("new Bar"), Some("Bar".to_string())); + } + + #[test] + fn underscore_prefix_accepted() { + assert_eq!(extract_inline_new_type("new _Factory"), Some("_Factory".to_string())); + } + + #[test] + fn dollar_prefix_accepted() { + assert_eq!(extract_inline_new_type("new $Service"), Some("$Service".to_string())); + } + + #[test] + fn lowercase_constructor_rejected() { + // `new foo()` — lowercase, should return None to avoid false positives + assert_eq!(extract_inline_new_type("new foo"), None); + } + + #[test] + fn not_a_new_expression() { + // plain receiver name — no `new` keyword + assert_eq!(extract_inline_new_type("myVar"), None); + } + + #[test] + fn new_without_whitespace_is_not_new_keyword() { + // `newFoo` — not a `new` keyword, just an identifier + assert_eq!(extract_inline_new_type("newFoo"), None); + } +} diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index acd1006bb..8c7a48bfb 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -29,6 +29,8 @@ impl SymbolExtractor for JsExtractor { walk_ast_nodes(&tree.root_node(), source, &mut symbols.ast_nodes); walk_tree(&tree.root_node(), source, &mut symbols, match_js_type_map); walk_tree(&tree.root_node(), source, &mut symbols, match_js_return_type_map); + // Pre-ES6 prototype methods: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }` + walk_tree(&tree.root_node(), source, &mut symbols, match_js_prototype_methods); // call_assignments runs after type_map is populated (needs receiver types) walk_tree(&tree.root_node(), source, &mut symbols, match_js_call_assignments); symbols @@ -445,6 +447,149 @@ fn push_return_type_entry(symbols: &mut FileSymbols, fn_name: &str, type_name: & }); } +// ── Prototype-method extraction ───────────────────────────────────────────── + +/// Walk the AST collecting pre-ES6 prototype assignments. +/// +/// Mirrors `extractPrototypeMethodsWalk` in `src/extractors/javascript.ts`. +/// +/// Three patterns are handled: +/// 1. `Foo.prototype.bar = function(){}` → emits `Foo.bar` as a method definition +/// 2. `Foo.prototype.bar = identifier` → seeds `typeMap['Foo.bar'] = identifier` +/// 3. `Foo.prototype = { bar: fn, ... }` → same rules applied per property +fn match_js_prototype_methods(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: usize) { + if node.kind() != "expression_statement" { return; } + let Some(expr) = node.child(0) else { return }; + if expr.kind() != "assignment_expression" { return; } + let lhs = expr.child_by_field_name("left"); + let rhs = expr.child_by_field_name("right"); + if let (Some(lhs), Some(rhs)) = (lhs, rhs) { + handle_js_prototype_assignment(&lhs, &rhs, source, symbols); + } +} + +fn handle_js_prototype_assignment(lhs: &Node, rhs: &Node, source: &[u8], symbols: &mut FileSymbols) { + if lhs.kind() != "member_expression" { return; } + let Some(lhs_obj) = lhs.child_by_field_name("object") else { return }; + let Some(lhs_prop) = lhs.child_by_field_name("property") else { return }; + + // Pattern 1: `Foo.prototype.bar = rhs` + // lhs.object is `Foo.prototype` (member_expression), lhs.property is `bar` + if lhs_obj.kind() == "member_expression" + && matches!(lhs_prop.kind(), "property_identifier" | "identifier") + { + let proto_obj = lhs_obj.child_by_field_name("object"); + let proto_prop = lhs_obj.child_by_field_name("property"); + if let (Some(proto_obj), Some(proto_prop)) = (proto_obj, proto_prop) { + if proto_obj.kind() == "identifier" + && node_text(&proto_prop, source) == "prototype" + && !is_js_builtin_global(node_text(&proto_obj, source)) + { + emit_js_prototype_method( + node_text(&proto_obj, source), + node_text(&lhs_prop, source), + rhs, + source, + symbols, + ); + } + } + return; + } + + // Pattern 2: `Foo.prototype = { bar: fn, ... }` + // lhs.object is `Foo` (identifier), lhs.property is `prototype`, rhs is object literal + if lhs_obj.kind() == "identifier" + && node_text(&lhs_prop, source) == "prototype" + && !is_js_builtin_global(node_text(&lhs_obj, source)) + && rhs.kind() == "object" + { + extract_js_prototype_object_literal(node_text(&lhs_obj, source), rhs, source, symbols); + } +} + +/// Emit one prototype method definition or typeMap alias for `ClassName.methodName = rhs`. +/// +/// Mirrors `emitPrototypeMethod` in `src/extractors/javascript.ts`. +fn emit_js_prototype_method(class_name: &str, method_name: &str, rhs: &Node, source: &[u8], symbols: &mut FileSymbols) { + let full_name = format!("{}.{}", class_name, method_name); + match rhs.kind() { + "function_expression" | "arrow_function" => { + symbols.definitions.push(Definition { + name: full_name, + kind: "method".to_string(), + line: start_line(rhs), + end_line: Some(end_line(rhs)), + decorators: None, + complexity: None, + cfg: None, + children: None, + }); + } + "identifier" => { + let rhs_name = node_text(rhs, source); + if !is_js_builtin_global(rhs_name) { + push_type_map_entry(symbols, full_name, rhs_name.to_string()); + } + } + _ => {} + } +} + +/// Iterate over an object literal assigned to `Foo.prototype` and emit definitions/aliases. +/// +/// Mirrors `extractPrototypeObjectLiteral` in `src/extractors/javascript.ts`. +fn extract_js_prototype_object_literal(class_name: &str, obj_node: &Node, source: &[u8], symbols: &mut FileSymbols) { + for i in 0..obj_node.child_count() { + let Some(child) = obj_node.child(i) else { continue }; + match child.kind() { + "method_definition" => { + let Some(name_node) = child.child_by_field_name("name") else { continue }; + symbols.definitions.push(Definition { + name: format!("{}.{}", class_name, node_text(&name_node, source)), + kind: "method".to_string(), + line: start_line(&child), + end_line: Some(end_line(&child)), + decorators: None, + complexity: None, + cfg: None, + children: None, + }); + } + "shorthand_property_identifier" => { + let prop_name = node_text(&child, source); + if !is_js_builtin_global(prop_name) { + push_type_map_entry( + symbols, + format!("{}.{}", class_name, prop_name), + prop_name.to_string(), + ); + } + } + "pair" => { + let key_node = child.child_by_field_name("key"); + let value_node = child.child_by_field_name("value"); + if let (Some(key_node), Some(value_node)) = (key_node, value_node) { + let method_name: &str = if key_node.kind() == "string" { + let s = node_text(&key_node, source); + // Strip exactly one matching pair of surrounding quote characters. + // `trim_matches` would also strip embedded quotes; we only want the + // outermost delimiter pair so `"it's"` stays `it's`. + s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) + .or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) + .unwrap_or(s) + } else { + node_text(&key_node, source) + }; + if method_name.is_empty() { continue; } + emit_js_prototype_method(class_name, method_name, &value_node, source, symbols); + } + } + _ => {} + } + } +} + // ── Call-assignment extraction (Phase 8.2 parity) ─────────────────────────── /// Walk the AST recording variable assignments from call expressions into @@ -2446,6 +2591,79 @@ mod tests { ); } + // ── Prototype-method extraction ───────────────────────────────────────── + + #[test] + fn prototype_direct_method_emits_definition() { + let s = parse_js( + "function C() {}\n\ + C.prototype.foo = function() { return 1; };", + ); + let def = s.definitions.iter().find(|d| d.name == "C.foo"); + assert!(def.is_some(), "C.foo definition missing; got: {:?}", s.definitions.iter().map(|d| &d.name).collect::>()); + assert_eq!(def.unwrap().kind, "method"); + } + + #[test] + fn prototype_identifier_alias_seeds_type_map() { + let s = parse_js( + "let f = () => {};\n\ + class A {}\n\ + A.prototype.t = f;", + ); + let entry = s.type_map.iter().find(|e| e.name == "A.t"); + assert!(entry.is_some(), "type_map entry A.t missing; got: {:?}", s.type_map.iter().map(|e| &e.name).collect::>()); + assert_eq!(entry.unwrap().type_name, "f"); + } + + #[test] + fn prototype_object_literal_emits_definitions() { + let s = parse_js( + "function C() {}\n\ + C.prototype = {\n\ + foo: function() {},\n\ + bar: function() {},\n\ + };", + ); + let foo = s.definitions.iter().find(|d| d.name == "C.foo"); + let bar = s.definitions.iter().find(|d| d.name == "C.bar"); + assert!(foo.is_some(), "C.foo missing"); + assert_eq!(foo.unwrap().kind, "method"); + assert!(bar.is_some(), "C.bar missing"); + } + + #[test] + fn prototype_object_literal_shorthand_method() { + let s = parse_js( + "function C() {}\n\ + C.prototype = {\n\ + greet() { return 'hi'; },\n\ + };", + ); + let def = s.definitions.iter().find(|d| d.name == "C.greet"); + assert!(def.is_some(), "C.greet definition missing; got: {:?}", s.definitions.iter().map(|d| &d.name).collect::>()); + assert_eq!(def.unwrap().kind, "method"); + } + + #[test] + fn prototype_object_literal_shorthand_property_seeds_type_map() { + let s = parse_js( + "function helper() {}\n\ + function C() {}\n\ + C.prototype = { helper };", + ); + let entry = s.type_map.iter().find(|e| e.name == "C.helper"); + assert!(entry.is_some(), "type_map entry C.helper missing; got: {:?}", s.type_map.iter().map(|e| &e.name).collect::>()); + assert_eq!(entry.unwrap().type_name, "helper"); + } + + #[test] + fn prototype_builtin_globals_are_excluded() { + let s = parse_js("Array.prototype.custom = function() {};"); + let def = s.definitions.iter().find(|d| d.name.contains("Array")); + assert!(def.is_none(), "built-in prototype assignment should be ignored; got: {:?}", def); + } + /// Phase 8.3e: Object.defineProperty seeds composite type_map key. #[test] fn type_map_from_define_property() { diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index b918c6762..75b25b56a 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -576,7 +576,7 @@ async function runPostNativePrototypeMethods( db: BetterSqlite3Database, rootDir: string, ): Promise { - // Collect JS/TS file paths from the DB — only extensions where prototype + // Collect JS/TS file paths from the DB — only extensions where these // patterns can appear. const jsExts = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx']); const fileRows = db @@ -591,15 +591,14 @@ async function runPostNativePrototypeMethods( if (jsFiles.length === 0) return; - // Quick pre-filter: only re-parse files that actually contain prototype or - // function-as-object-property patterns to avoid an expensive WASM re-parse of - // every JS/TS file in large repos. Covers: - // - `.prototype.` — classical prototype method assignment - // - `\b\w+\.\w+\s*=\s*function` — function-as-object property (`f.g = function(){}`) + // Pre-filter: only re-parse files that contain the function-as-object-property + // pattern (`fn.method = function() {}`). The Rust engine now handles + // `Foo.prototype.bar = fn` natively, so `.prototype.` files no longer need + // a WASM re-parse here. const protoFiles = jsFiles.filter((relPath) => { try { const content = readFileSafe(path.join(rootDir, relPath)); - return content.includes('.prototype.') || /\b\w+\.\w+\s*=\s*function/.test(content); + return /\b(?!prototype\.)\w+\.\w+\s*=\s*function/.test(content); } catch { return false; } @@ -1390,13 +1389,14 @@ export async function tryNativeOrchestrator( } } - // Prototype method post-pass: the Rust engine does not recognise pre-ES6 - // `Foo.prototype.bar = function(){}` patterns. Re-parse JS/TS files via - // WASM to insert missing method nodes and their call edges. + // Function-as-object-property post-pass: the Rust engine does not yet recognise + // `fn.method = function() {}` patterns. Re-parse only those JS/TS files via + // WASM to insert missing method nodes. `Foo.prototype.bar = fn` is now + // handled natively by the Rust extractor and no longer needs a WASM re-parse. try { await runPostNativePrototypeMethods(ctx.db as unknown as BetterSqlite3Database, ctx.rootDir); } catch (err) { - debug(`Prototype methods post-pass failed: ${toErrorMessage(err)}`); + debug(`Function-prop methods post-pass failed: ${toErrorMessage(err)}`); } // Backfill the `technique` column on `calls` edges written by the Rust diff --git a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js index 9a0a106d9..7ec19aa8f 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js +++ b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js @@ -29,7 +29,6 @@ export class Counter { export class DoubleCounter extends Counter { static count() { - // biome-ignore lint/complexity/noThisInStatic: intentional super call for class-inheritance resolution test - return super.count() * 2; // static super.method() → Counter.count + return super.count() * 2; // static super.count() → Counter.count via CHA parents map } } From 8407fcf12421f08f3a8c984ceefd3562e151cc6d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 6 Jun 2026 03:51:45 -0600 Subject: [PATCH 16/17] fix: extend native post-pass pre-filter to include arrow-function property assignments (#1331) The pre-filter regex only matched `fn.method = function(){}` patterns, silently skipping files where all func-prop assignments use arrow functions (`fn.method = () => {}`). Such files were never WASM-reparsed and their method definitions were not inserted by the post-pass. Extend the regex to match both traditional function expressions and arrow function expressions (both `() => {}` and `param => {}` forms). --- .../graph/builder/stages/native-orchestrator.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 963366652..1fde3b3e2 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -596,13 +596,18 @@ async function runPostNativePrototypeMethods( if (jsFiles.length === 0) return; // Pre-filter: only re-parse files that contain the function-as-object-property - // pattern (`fn.method = function() {}`). The Rust engine now handles - // `Foo.prototype.bar = fn` natively, so `.prototype.` files no longer need - // a WASM re-parse here. + // pattern (`fn.method = function(){}` or `fn.method = () => {}`). The Rust engine + // now handles `Foo.prototype.bar = fn` natively, so `.prototype.` files no longer + // need a WASM re-parse here. const protoFiles = jsFiles.filter((relPath) => { try { const content = readFileSafe(path.join(rootDir, relPath)); - return /\b(?!prototype\.)\w+\.\w+\s*=\s*function/.test(content); + // Match `fn.method = function(){}` (traditional) or `fn.method = () => {}`/ + // `fn.method = param => {}` (arrow). The negative lookahead excludes `.prototype.` + // patterns already handled natively by the Rust extractor. + return /\b(?!prototype\.)\w+\.\w+\s*=\s*(?:function\b|(?:\([^)]*\)|[A-Za-z_$]\w*)\s*=>)/.test( + content, + ); } catch { return false; } From 9fb1a8b54a81bb3a3cbb0858dec6d54b4660dd82 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 6 Jun 2026 04:13:50 -0600 Subject: [PATCH 17/17] test(extractor): verify exported arrow function funcStack tracking in extractSpreadForOfWalk (#1359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(extractor): verify exported arrow function funcStack tracking in extractSpreadForOfWalk (#1354) Add regression tests confirming that `export const f = (arr) => { for (const x of arr) x(); }` correctly pushes `f` onto the funcStack so for-of bindings record the right enclosing caller. The recursive walk visits `variable_declarator` regardless of whether it is nested under a plain `lexical_declaration` or an `export_statement`, so the gap reported in the PR #1331 review was already closed by commit a6c5d2d. These tests document and gate that behavior. Closes #1354 * fix: remove duplicate paramBindings in SerializedExtractorOutput and rename process test identifier The merge at 3c164f2 introduced a second `paramBindings` field (using the top-level ParamBinding import) alongside the existing inline-import form at line 68, causing TS2300 duplicate-identifier errors that broke every CI job. Removed the duplicate and the now-unused ParamBinding top-level import. Also renamed the `process` arrow-function identifier in the Phase 8.3e test to `handleItems` — `process` is a Node.js global and its presence in the test obscures that the test is solely about the export-wrapping code path. --- src/domain/wasm-worker-protocol.ts | 2 -- tests/parsers/javascript.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/domain/wasm-worker-protocol.ts b/src/domain/wasm-worker-protocol.ts index c9a7dafd9..69db8585b 100644 --- a/src/domain/wasm-worker-protocol.ts +++ b/src/domain/wasm-worker-protocol.ts @@ -19,7 +19,6 @@ import type { Export, Import, LanguageId, - ParamBinding, TypeMapEntry, } from '../types.js'; @@ -74,7 +73,6 @@ export interface SerializedExtractorOutput { newExpressions?: readonly string[]; returnTypeMap?: Array<[string, TypeMapEntry]>; callAssignments?: CallAssignment[]; - paramBindings?: ParamBinding[]; } export interface WorkerParseResponseOk { diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 1cb8ad5dc..1411f42dd 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -908,4 +908,29 @@ describe('JavaScript parser', () => { ); }); }); + + describe('Phase 8.3e: extractSpreadForOfWalk — exported arrow function funcStack (#1354)', () => { + it('tracks plain const arrow function on funcStack for for-of loop', () => { + const symbols = parseJS(`const f = (arr) => { for (const x of arr) x(); };`); + expect(symbols.forOfBindings).toContainEqual(expect.objectContaining({ enclosingFunc: 'f' })); + }); + + it('tracks exported const arrow function on funcStack for for-of loop', () => { + const symbols = parseJS(`export const f = (arr) => { for (const x of arr) x(); };`); + expect(symbols.forOfBindings).toContainEqual(expect.objectContaining({ enclosingFunc: 'f' })); + }); + + it('records correct varName and sourceName for exported arrow for-of', () => { + const symbols = parseJS( + `export const handleItems = (items) => { for (const cb of items) cb(); };`, + ); + expect(symbols.forOfBindings).toContainEqual( + expect.objectContaining({ + varName: 'cb', + sourceName: 'items', + enclosingFunc: 'handleItems', + }), + ); + }); + }); });