From 93f58c34b9acf60ca91f226a930b313f45c56ea3 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 01:36:15 -0600 Subject: [PATCH 01/11] fix(native): always run JS role re-classification on full builds to fix hasActiveFileSiblings parity The Rust orchestrator classifies roles before JS post-passes and does not implement the hasActiveFileSiblings heuristic. This causes functions with fan_in=0 and fan_out>0 (e.g. main, square in sample-project) to be classified as dead-unresolved by Rust but leaf by the JS classifier. Previously, JS classifyNodeRoles only ran when CHA or this-dispatch post-passes added new edges. For projects with no inheritance (like sample-project), those passes add zero edges, so Rust's stale roles were never overridden. Fix: always run a full JS classifyNodeRoles(db, null) after native full builds so the JS classifier's hasActiveFileSiblings heuristic takes effect. Incremental builds keep the existing scoped re-classification for post-pass edges only, since the heuristic gap was corrected on the preceding full build. Closes #1659 --- .../builder/stages/native-orchestrator.ts | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index aeb0da38..20f155b4 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -1988,23 +1988,45 @@ async function runPostNativePasses( ); const chaMs = performance.now() - chaStart; - // Role re-classification after JS edge-writing post-passes. - // The Rust orchestrator classifies roles before these post-passes (CHA, - // this-dispatch) add edges, so roles for the edge endpoints are stale. - // Scoped to the files containing those endpoints: a new edge only changes - // fan-in/out for its own source and target nodes, so re-classifying their - // files restores correctness without re-running the classifier over the - // whole graph (which cost ~130ms per build on codegraph itself and was a - // major part of the v3.12.0 native full-build benchmark regression). + // Role re-classification after the Rust orchestrator build. + // + // Two reasons to re-classify: + // + // 1. Post-pass edges (CHA, this-dispatch): the Rust orchestrator classifies + // roles before these passes add edges, so fan-in/out for their endpoints + // is stale. On incremental builds, scope to the affected files for speed. + // + // 2. hasActiveFileSiblings parity: the Rust classifier does not implement the + // JS hasActiveFileSiblings heuristic. That heuristic promotes functions with + // fan_in=0 but fan_out>0 to 'leaf' when their file has other connected + // callables — preventing false dead-unresolved classifications for functions + // like `main` or `square` that call others but are never called themselves. + // On full builds, always run a full JS re-classification so the Rust roles + // are replaced by the canonical JS classifier output (#1659). + // + // Strategy: + // - Full build: always run full JS classifyNodeRoles(db, null). + // - Incremental build with post-pass edges: run scoped re-classification + // for the affected files (same as before). The full-build pass already + // produced correct JS roles for all unchanged files on the previous build. + // - Incremental build with no post-pass edges: skip re-classification + // (Rust roles on unchanged files are not stale, and the heuristic gap + // was corrected on the last full build). let reclassifyMs = 0; - if (chaEdgeCount > 0 || thisDispatchTargetIds.size > 0) { - const affectedFiles = [...new Set([...chaAffectedFiles, ...thisDispatchAffectedFiles])]; - // When edges were inserted but all their endpoint nodes have null `file` - // columns (rare but possible), affectedFiles stays empty even though - // fan-in/out changed. Fall back to full-graph re-classification in that - // case — scoped classification with an empty set would be a no-op, leaving - // roles stale for those nodes. - const scopedFiles = affectedFiles.length > 0 ? affectedFiles : null; + const needsFullReclassify = !!result.isFullBuild; + const needsScopedReclassify = + !needsFullReclassify && (chaEdgeCount > 0 || thisDispatchTargetIds.size > 0); + if (needsFullReclassify || needsScopedReclassify) { + let scopedFiles: string[] | null = null; + if (needsScopedReclassify) { + const affectedFiles = [...new Set([...chaAffectedFiles, ...thisDispatchAffectedFiles])]; + // When edges were inserted but all their endpoint nodes have null `file` + // columns (rare but possible), affectedFiles stays empty even though + // fan-in/out changed. Fall back to full-graph re-classification in that + // case — scoped classification with an empty set would be a no-op, leaving + // roles stale for those nodes. + scopedFiles = affectedFiles.length > 0 ? affectedFiles : null; + } const reclassifyStart = performance.now(); try { const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as { @@ -2017,7 +2039,7 @@ async function runPostNativePasses( debug( scopedFiles ? `Post-pass role re-classification complete (${scopedFiles.length} file(s))` - : 'Post-pass role re-classification complete (full graph — null-file endpoints)', + : 'Post-pass role re-classification complete (full graph)', ); } catch (err) { debug(`Post-pass role re-classification failed: ${toErrorMessage(err)}`); From 066d7d0028b56b8b9705a30a176483afaa34adee Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 02:11:21 -0600 Subject: [PATCH 02/11] fix(wasm): emit receiver edge for CJS require-destructured class types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit const { Calculator } = require('./utils') creates a kind='function' shadow node for Calculator in the importing file. The JS resolveReceiverEdge logic uses importedNames.has(effectiveReceiver) to detect import artifacts — but CJS require bindings were not in importedNames (only ES module imports were), so the shadow node appeared to be a locally-defined symbol and blocked the global class lookup. Result: no receiver edge from main to Calculator even though calc.compute() is a typed receiver call. Fix: - Add cjsRequireBindings field to ExtractorOutput + SerializedExtractorOutput to carry const { X } = require('…') name→source mappings from the extractor without adding them to symbols.imports (which would create spurious DB edges) - extractDestructuredBindingsWalk and handleVariableDecl now populate cjsRequireBindings when require() is the RHS of a destructured const - buildImportArtifactNames merges importedNames with cjsRequireBindings into a combined map passed exclusively to resolveReceiverEdge - buildFileCallEdges receives importArtifactNames as a separate param so CJS names affect only receiver-edge resolution, not call-target resolution or roles Closes #1661 --- .../graph/builder/stages/build-edges.ts | 46 +++++++++- src/domain/wasm-worker-entry.ts | 1 + src/domain/wasm-worker-pool.ts | 1 + src/domain/wasm-worker-protocol.ts | 2 + src/extractors/javascript.ts | 90 ++++++++++++++++++- src/types.ts | 8 ++ 6 files changed, 143 insertions(+), 5 deletions(-) diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 0c5f8fb5..f9fa88e9 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -844,6 +844,16 @@ function buildCallEdgesJS( const seenCallEdges = new Set(); const ptsMap = buildPointsToMapForFile(symbols, importedNames); + // Build the import-artifact name set: importedNames plus CJS require bindings. + // Used only by resolveReceiverEdge to distinguish local definitions from CJS + // import shadows — does NOT affect call-target resolution or DB edges (#1661). + const importArtifactNames = buildImportArtifactNames( + importedNames, + symbols, + ctx, + relPath, + rootDir, + ); buildFileCallEdges( relPath, @@ -856,6 +866,7 @@ function buildCallEdgesJS( typeMap, ptsMap, chaCtx, + importArtifactNames, ); buildClassHierarchyEdges(ctx, relPath, symbols, allEdgeRows); } @@ -900,6 +911,38 @@ function buildImportedNamesMap( return importedNames; } +/** + * Build a map of all names that are import artifacts in this file — includes + * both ES module imports (already in importedNames) and CJS require destructuring + * bindings (`const { X } = require('./path')`). Used exclusively by resolveReceiverEdge + * to classify same-file function-kind nodes as import artifacts vs. local definitions. + * Does NOT affect call resolution or DB edge creation (#1661). + */ +function buildImportArtifactNames( + importedNames: Map, + symbols: ExtractorOutput, + ctx: PipelineContext, + relPath: string, + rootDir: string, +): ReadonlyMap { + if (!symbols.cjsRequireBindings?.length) return importedNames; + const combined = new Map(importedNames); + const traceBarrel = (resolvedPath: string, cleanName: string): string => { + if (!isBarrelFile(ctx, resolvedPath)) return resolvedPath; + const actual = resolveBarrelExportCached(ctx, resolvedPath, cleanName); + return actual ?? resolvedPath; + }; + for (const binding of symbols.cjsRequireBindings) { + const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), binding.source); + for (const name of binding.names) { + if (!combined.has(name)) { + combined.set(name, traceBarrel(resolvedPath, name)); + } + } + } + return combined; +} + function makeContextLookup(ctx: PipelineContext, getNodeIdStmt: NodeIdStmt): CallNodeLookup { return { byNameAndFile: (name, file) => ctx.nodesByNameAndFile.get(`${name}|${file}`) ?? [], @@ -1483,6 +1526,7 @@ function buildFileCallEdges( typeMap: Map, ptsMap?: PointsToMap | null, chaCtx?: ChaContext, + importArtifactNames?: ReadonlyMap, ): void { // Tracks edges that were inserted by the pts fallback (edgeKey → allEdgeRows index). // Kept separate from seenCallEdges so that a subsequent direct-call edge for the same @@ -1584,7 +1628,7 @@ function buildFileCallEdges( relPath, typeMap as Map, seenCallEdges, - importedNames, + importArtifactNames ?? importedNames, ); if (recv) { allEdgeRows.push([ diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index 4541b073..a4a27dbd 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -834,6 +834,7 @@ function serializeExtractorOutput( ? { returnTypeMap: Array.from(symbols.returnTypeMap.entries()) } : {}), ...(symbols.callAssignments?.length ? { callAssignments: symbols.callAssignments } : {}), + ...(symbols.cjsRequireBindings?.length ? { cjsRequireBindings: symbols.cjsRequireBindings } : {}), }; } diff --git a/src/domain/wasm-worker-pool.ts b/src/domain/wasm-worker-pool.ts index 75641b61..524e752f 100644 --- a/src/domain/wasm-worker-pool.ts +++ b/src/domain/wasm-worker-pool.ts @@ -126,6 +126,7 @@ function deserializeBindingFields(ser: SerializedExtractorOutput, out: Extractor if (ser.thisCallBindings?.length) out.thisCallBindings = ser.thisCallBindings; if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions; if (ser.callAssignments?.length) out.callAssignments = ser.callAssignments; + if (ser.cjsRequireBindings?.length) out.cjsRequireBindings = ser.cjsRequireBindings; } /** Deserialize the Map-typed fields that require entry-by-entry reconstruction. */ diff --git a/src/domain/wasm-worker-protocol.ts b/src/domain/wasm-worker-protocol.ts index 9db8c3f6..40d21215 100644 --- a/src/domain/wasm-worker-protocol.ts +++ b/src/domain/wasm-worker-protocol.ts @@ -79,6 +79,8 @@ export interface SerializedExtractorOutput { callAssignments?: CallAssignment[]; /** Variable-level dataflow vertices extracted during parsing (P1+). */ dataflowVertices?: import('../types.js').DataflowVertex[]; + /** CJS require bindings — see ExtractorOutput.cjsRequireBindings (#1661). */ + cjsRequireBindings?: Array<{ names: string[]; source: string }>; } export interface WorkerParseResponseOk { diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 5fe89749..16cb5019 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -399,8 +399,11 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr arrayCallbackBindings, }); - // Extract definitions from destructured bindings (query patterns don't match object_pattern) - extractDestructuredBindingsWalk(tree.rootNode, definitions); + // Extract definitions from destructured bindings (query patterns don't match object_pattern). + // Also collects CJS require bindings (const { X } = require('…')) into a separate list so + // importedNames can classify them as import artifacts without creating DB edges (#1661). + const cjsRequireBindings: Array<{ names: string[]; source: string }> = []; + extractDestructuredBindingsWalk(tree.rootNode, definitions, cjsRequireBindings); // Everything without bespoke traversal semantics is collected in ONE pass: // dynamic import() calls, prototype-method definitions, param bindings, @@ -443,6 +446,7 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr thisCallBindings, newExpressions, ...(definePropertyReceivers.size > 0 ? { definePropertyReceivers } : {}), + ...(cjsRequireBindings.length > 0 ? { cjsRequireBindings } : {}), }; } @@ -508,8 +512,15 @@ function extractConstantsWalk(node: TreeSitterNode, definitions: Definition[]): /** * Walk the AST to find destructured const bindings (query patterns don't match object_pattern). * e.g. `const { handleToken, checkPermissions } = initAuth(config)` + * + * When `cjsRequireBindings` is provided, also records `const { X } = require('./path')` patterns + * so the edge builder can classify X as an import artifact rather than a local definition (#1661). */ -function extractDestructuredBindingsWalk(node: TreeSitterNode, definitions: Definition[]): void { +function extractDestructuredBindingsWalk( + node: TreeSitterNode, + definitions: Definition[], + cjsRequireBindings?: Array<{ names: string[]; source: string }>, +): void { for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (!child) continue; @@ -537,6 +548,43 @@ function extractDestructuredBindingsWalk(node: TreeSitterNode, definitions: Defi nodeEndLine(declNode), definitions, ); + // Record CJS require bindings so importedNames can classify these names + // as import artifacts, preventing false local-definition blocking (#1661). + if (cjsRequireBindings) { + const valueN = declarator.childForFieldName('value'); + if (valueN?.type === 'call_expression') { + const fn = valueN.childForFieldName('function'); + if (fn?.text === 'require') { + const args = valueN.childForFieldName('arguments'); + const strArg = args && findChild(args, 'string'); + if (strArg) { + const modPath = strArg.text.replace(/['"]/g, ''); + const names: string[] = []; + for (let k = 0; k < nameN.childCount; k++) { + const prop = nameN.child(k); + if (!prop) continue; + if ( + prop.type === 'shorthand_property_identifier_pattern' || + prop.type === 'shorthand_property_identifier' + ) { + names.push(prop.text); + } else if (prop.type === 'pair_pattern' || prop.type === 'pair') { + const val = prop.childForFieldName('value'); + if ( + val?.type === 'identifier' || + val?.type === 'shorthand_property_identifier_pattern' + ) { + names.push(val.text); + } + } + } + if (names.length > 0) { + cjsRequireBindings.push({ names, source: modPath }); + } + } + } + } + } } else if (nameN && nameN.type === 'array_pattern') { // `const [x, y] = ...` — emit a single constant node whose name is the // full array pattern text (e.g. `[x, y]`), matching native engine behaviour. @@ -551,7 +599,7 @@ function extractDestructuredBindingsWalk(node: TreeSitterNode, definitions: Defi } if (child.type !== 'export_statement') { - extractDestructuredBindingsWalk(child, definitions); + extractDestructuredBindingsWalk(child, definitions, cjsRequireBindings); } } } @@ -1111,6 +1159,40 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { nodeEndLine(node), ctx.definitions, ); + // Record CJS require bindings for import-artifact classification (#1661). + if (valueN?.type === 'call_expression') { + const fn = valueN.childForFieldName('function'); + if (fn?.text === 'require') { + const args = valueN.childForFieldName('arguments'); + const strArg = args && findChild(args, 'string'); + if (strArg) { + const modPath = strArg.text.replace(/['"]/g, ''); + const names: string[] = []; + for (let k = 0; k < nameN.childCount; k++) { + const prop = nameN.child(k); + if (!prop) continue; + if ( + prop.type === 'shorthand_property_identifier_pattern' || + prop.type === 'shorthand_property_identifier' + ) { + names.push(prop.text); + } else if (prop.type === 'pair_pattern' || prop.type === 'pair') { + const val = prop.childForFieldName('value'); + if ( + val?.type === 'identifier' || + val?.type === 'shorthand_property_identifier_pattern' + ) { + names.push(val.text); + } + } + } + if (names.length > 0) { + if (!ctx.cjsRequireBindings) ctx.cjsRequireBindings = []; + ctx.cjsRequireBindings.push({ names, source: modPath }); + } + } + } + } } else if (isConst && nameN.type === 'array_pattern' && !hasFunctionScopeAncestor(node)) { // Array destructuring: `const [x, y] = ...` — emit a single constant node // whose name is the full array pattern text (e.g. `[x, y]`), matching diff --git a/src/types.ts b/src/types.ts index 7e1528ad..c85f36f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -729,6 +729,14 @@ export interface ExtractorOutput { * `definePropertyReceivers.set("getter", "obj")`. */ definePropertyReceivers?: Map; + /** + * CJS require bindings from `const { X, Y } = require('./path')` patterns. + * Used by buildImportedNamesMap to classify X and Y as import artifacts so + * receiver-edge resolution falls back to the global class lookup rather than + * treating the destructured-binding function node as a local definition (#1661). + * Does NOT cause DB import edges — use `imports` for that. + */ + cjsRequireBindings?: Array<{ names: string[]; source: string }>; /** WASM tree retained for downstream analysis (complexity, CFG, dataflow). */ _tree?: TreeSitterTree; /** Language identifier. */ From 7aadef25d5b164e23ab9dccebd9193b1da1889cb Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 02:24:18 -0600 Subject: [PATCH 03/11] feat(dynamic-calls): add ObjC performSelector / Dart Function.apply detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjC (objc.ts + objc.rs): - [obj performSelector:@selector(greet)] → unresolved-dynamic sink edge - [obj performSelector:@selector(greet) withObject:arg] → unresolved-dynamic - objc_msgSend(obj, sel, ...) → unresolved-dynamic sink edge Dart (dart.ts + dart.rs): - Function.apply(fn, positional, named) → unresolved-dynamic sink edge Handles layout B (method selector and argument_part in adjacent selector nodes) by scanning sibling selectors for the method name. Tests added to both Rust (#[test]) and TS (vitest) for each new pattern. Closes #1664 --- crates/codegraph-core/src/extractors/dart.rs | 39 ++++++++++++ crates/codegraph-core/src/extractors/objc.rs | 64 ++++++++++++++++++++ src/extractors/dart.ts | 54 ++++++++++++++++- src/extractors/objc.ts | 26 +++++++- tests/parsers/dart.test.ts | 9 +++ tests/parsers/objc.test.ts | 27 +++++++++ 6 files changed, 215 insertions(+), 4 deletions(-) diff --git a/crates/codegraph-core/src/extractors/dart.rs b/crates/codegraph-core/src/extractors/dart.rs index 45964fb0..29d36e04 100644 --- a/crates/codegraph-core/src/extractors/dart.rs +++ b/crates/codegraph-core/src/extractors/dart.rs @@ -33,6 +33,7 @@ fn match_dart_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth "library_import" => handle_dart_import(node, source, symbols), "constructor_invocation" | "new_expression" => handle_dart_constructor_call(node, source, symbols), "type_alias" => handle_dart_type_alias(node, source, symbols), + "selector" => handle_dart_selector(node, source, symbols), _ => {} } } @@ -308,6 +309,44 @@ fn handle_dart_type_alias(node: &Node, source: &[u8], symbols: &mut FileSymbols) } } +fn handle_dart_selector(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + // selector with argument_part represents a function call; mirrors handleDartSelector in dart.ts + if find_child(node, "argument_part").is_none() { + return; + } + let unconditional = match find_child(node, "unconditional_assignable_selector") { + Some(n) => n, + None => return, + }; + let id = match find_child(&unconditional, "identifier") { + Some(n) => n, + None => return, + }; + let method_name = node_text(&id, source); + + // Function.apply(fn, positionalArgs, namedArgs) — dynamic higher-order dispatch + if method_name == "apply" { + if let Some(parent) = node.parent() { + for i in 0..parent.child_count() { + if let Some(sibling) = parent.child(i) { + if sibling.id() != node.id() && node_text(&sibling, source) == "Function" { + symbols.calls.push(Call { + name: "".to_string(), + line: start_line(node), + dynamic: Some(true), + dynamic_kind: Some("unresolved-dynamic".to_string()), + ..Default::default() + }); + return; + } + } + } + } + } + + push_simple_call(symbols, node, method_name); +} + fn is_inside_class(node: &Node) -> bool { let mut current = node.parent(); while let Some(parent) = current { diff --git a/crates/codegraph-core/src/extractors/objc.rs b/crates/codegraph-core/src/extractors/objc.rs index e8538d6c..0bf3daa2 100644 --- a/crates/codegraph-core/src/extractors/objc.rs +++ b/crates/codegraph-core/src/extractors/objc.rs @@ -325,6 +325,19 @@ fn handle_c_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { (node_text(&fn_node, source).to_string(), None) }; + // objc_msgSend(obj, sel, ...) — raw ObjC runtime call; selector is a SEL value + if name == "objc_msgSend" { + symbols.calls.push(Call { + name: "".to_string(), + line: start_line(node), + dynamic: Some(true), + dynamic_kind: Some("unresolved-dynamic".to_string()), + receiver, + ..Default::default() + }); + return; + } + push_call(symbols, node, name, receiver, None); } @@ -336,6 +349,20 @@ fn handle_message_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { .map(|n| node_text(&n, source).to_string()); let selector = build_message_selector(node, source); + + // performSelector: / performSelector:withObject: — SEL dispatch; not statically resolvable + if selector == "performSelector:" || selector.starts_with("performSelector:withObject") { + symbols.calls.push(Call { + name: "".to_string(), + line: start_line(node), + dynamic: Some(true), + dynamic_kind: Some("unresolved-dynamic".to_string()), + receiver, + ..Default::default() + }); + return; + } + push_call(symbols, node, selector, receiver, None); } @@ -763,4 +790,41 @@ mod tests { assert_eq!(params[0].name, "callback"); assert_eq!(params[0].kind, "parameter"); } + + #[test] + fn flags_perform_selector_as_unresolved_dynamic() { + let code = "\ +@implementation Foo +- (void)go { + [self performSelector:@selector(greet)]; +} +@end"; + let s = parse_objc(code); + let dyn_call = s.calls.iter().find(|c| c.name == "").unwrap(); + assert_eq!(dyn_call.dynamic, Some(true)); + assert_eq!(dyn_call.dynamic_kind.as_deref(), Some("unresolved-dynamic")); + } + + #[test] + fn flags_perform_selector_with_object_as_unresolved_dynamic() { + let code = "\ +@implementation Foo +- (void)go { + [self performSelector:@selector(greet) withObject:arg]; +} +@end"; + let s = parse_objc(code); + let dyn_call = s.calls.iter().find(|c| c.name == "").unwrap(); + assert_eq!(dyn_call.dynamic, Some(true)); + assert_eq!(dyn_call.dynamic_kind.as_deref(), Some("unresolved-dynamic")); + } + + #[test] + fn flags_objc_msg_send_as_unresolved_dynamic() { + let code = "void run(id obj, SEL sel) { objc_msgSend(obj, sel); }"; + let s = parse_objc(code); + let dyn_call = s.calls.iter().find(|c| c.name == "").unwrap(); + assert_eq!(dyn_call.dynamic, Some(true)); + assert_eq!(dyn_call.dynamic_kind.as_deref(), Some("unresolved-dynamic")); + } } diff --git a/src/extractors/dart.ts b/src/extractors/dart.ts index 3816c39b..a7a6e06f 100644 --- a/src/extractors/dart.ts +++ b/src/extractors/dart.ts @@ -255,14 +255,62 @@ function handleDartSelector(node: TreeSitterNode, ctx: ExtractorOutput): void { const argPart = findChild(node, 'argument_part'); if (!argPart) return; - // Look for the identifier this selector belongs to + const line = node.startPosition.row + 1; + + // Look for the identifier this selector belongs to. + // Two layouts are possible depending on grammar version: + // A) selector has both unconditional_assignable_selector + argument_part (same node) + // B) one selector node holds unconditional_assignable_selector (.method), + // the next holds argument_part (the call args) — method name is in the previous sibling const unconditional = findChild(node, 'unconditional_assignable_selector'); + let methodName: string | null = null; + let receiverText: string | null = null; + if (unconditional) { const id = findChild(unconditional, 'identifier'); - if (id) { - ctx.calls.push({ name: id.text, line: node.startPosition.row + 1 }); + if (id) methodName = id.text; + } else { + // Layout B: look at the previous sibling selector for the method name + const parent = node.parent; + if (parent) { + for (let i = 0; i < parent.childCount; i++) { + const sibling = parent.child(i); + if (sibling === node) break; + if (sibling?.type === 'selector') { + const unc2 = findChild(sibling, 'unconditional_assignable_selector'); + if (unc2) { + const id2 = findChild(unc2, 'identifier'); + if (id2) methodName = id2.text; + } + } else { + receiverText = sibling?.text ?? null; + } + } } } + + if (!methodName) return; + + // Function.apply(fn, positionalArgs, namedArgs) — dynamic higher-order dispatch + if (methodName === 'apply') { + const parent = node.parent; + if (parent) { + for (let i = 0; i < parent.childCount; i++) { + const sibling = parent.child(i); + if (sibling && sibling !== node && sibling.text === 'Function') { + ctx.calls.push({ + name: '', + line, + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }); + return; + } + } + } + } + + ctx.calls.push({ name: methodName, line }); } function handleDartTypeAlias(node: TreeSitterNode, ctx: ExtractorOutput): void { diff --git a/src/extractors/objc.ts b/src/extractors/objc.ts index 7e89a37f..dffa01a3 100644 --- a/src/extractors/objc.ts +++ b/src/extractors/objc.ts @@ -336,7 +336,19 @@ function handleCCallExpr(node: TreeSitterNode, ctx: ExtractorOutput): void { } else { call.name = funcNode.text; } - if (call.name) ctx.calls.push(call); + if (!call.name) return; + // objc_msgSend(obj, sel, ...) — raw ObjC runtime call; selector is a SEL value + if (call.name === 'objc_msgSend') { + ctx.calls.push({ + name: '', + line: call.line, + dynamic: true, + dynamicKind: 'unresolved-dynamic', + receiver: call.receiver, + }); + return; + } + ctx.calls.push(call); } function handleMessageExpr(node: TreeSitterNode, ctx: ExtractorOutput): void { @@ -368,6 +380,18 @@ function handleMessageExpr(node: TreeSitterNode, ctx: ExtractorOutput): void { name = selector.text; } + // performSelector: / performSelector:withObject: — SEL dispatch; not statically resolvable + if (name === 'performSelector:' || name.startsWith('performSelector:withObject')) { + ctx.calls.push({ + name: '', + line: node.startPosition.row + 1, + dynamic: true, + dynamicKind: 'unresolved-dynamic', + receiver: receiver?.text, + }); + return; + } + const call: Call = { name, line: node.startPosition.row + 1 }; if (receiver) call.receiver = receiver.text; ctx.calls.push(call); diff --git a/tests/parsers/dart.test.ts b/tests/parsers/dart.test.ts index 26fc8556..2d7557d0 100644 --- a/tests/parsers/dart.test.ts +++ b/tests/parsers/dart.test.ts @@ -50,4 +50,13 @@ import 'package:flutter/material.dart';`); // This test verifies the parser doesn't crash on constructor syntax expect(symbols).toBeDefined(); }); + + it('flags Function.apply as unresolved-dynamic', () => { + const symbols = parseDart(`void g() { + var r = Function.apply(callback, []); +}`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'unresolved-dynamic' }), + ); + }); }); diff --git a/tests/parsers/objc.test.ts b/tests/parsers/objc.test.ts index 142daf5f..dcd2b3cb 100644 --- a/tests/parsers/objc.test.ts +++ b/tests/parsers/objc.test.ts @@ -91,6 +91,33 @@ describe('Objective-C parser', () => { ); }); + it('flags performSelector: as unresolved-dynamic', () => { + const symbols = parseObjC(`void main() { + [obj performSelector:@selector(greet)]; +}`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'unresolved-dynamic' }), + ); + }); + + it('flags performSelector:withObject: as unresolved-dynamic', () => { + const symbols = parseObjC(`void main() { + [obj performSelector:@selector(greet) withObject:arg]; +}`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'unresolved-dynamic' }), + ); + }); + + it('flags objc_msgSend as unresolved-dynamic', () => { + const symbols = parseObjC(`void main() { + objc_msgSend(obj, sel, arg); +}`); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'unresolved-dynamic' }), + ); + }); + it('extracts keyword-selector method definitions with parameter names', () => { // The v3 grammar emits flat `identifier`+`method_parameter` children under // `method_definition` rather than wrapping them in `keyword_selector`. The From 9a6b882f4743a79947d5538dae7661a97799a95c Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 02:55:07 -0600 Subject: [PATCH 04/11] =?UTF-8?q?feat(dynamic-calls):=20res-2=20=E2=80=94?= =?UTF-8?q?=20closed=20dispatch=20table=20resolution=20({a:fnA,b:fnB}[k]()?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a subscript call's object is an inline object literal (or parenthesized object literal), extract all identifier values as arrayElemBindings under a synthetic table name , and emit a call with name='[*]' and dynamicKind='dispatch-table'. The existing pts wildcard solver then resolves that call to each value at confidence=baseline-0.1 (PROPAGATION_HOP_PENALTY). 'dispatch-table' is intentionally excluded from FLAG_ONLY_KINDS so no sink edge is emitted when pts resolves the targets. Changes: - types.ts: add 'dispatch-table' to DynamicKind union - javascript.ts: extractSubscriptCallInfo accepts optional arrayElemBindings; detects inline object (and parenthesized object) as dispatch table, seeds bindings, returns wildcard call; extractCallInfoWithBindings helper threads bindings through; dispatchQueryMatch and handleCallExpr both use it - dispatch-table.js fixture + expected-edges.json entries for pts-javascript - points-to.test.ts: 3 new unit tests for dispatch-table pts constraints Closes #1665 --- src/extractors/javascript.ts | 69 +++++++++++++++++-- src/types.ts | 1 + .../fixtures/pts-javascript/dispatch-table.js | 7 ++ .../pts-javascript/expected-edges.json | 14 ++++ tests/unit/points-to.test.ts | 37 ++++++++++ 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 tests/benchmarks/resolution/fixtures/pts-javascript/dispatch-table.js diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 16cb5019..99d9a7a4 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -294,6 +294,7 @@ function dispatchQueryMatch( imports: Import[], classes: ClassRelation[], exps: Export[], + arrayElemBindings?: ArrayElemBinding[], ): void { if (c.fn_node) { handleFnCapture(c, definitions); @@ -323,7 +324,9 @@ function dispatchQueryMatch( if (cbDef) definitions.push(cbDef); calls.push(...extractCallbackReferenceCalls(c.callmem_node)); } else if (c.callsub_node) { - const callInfo = extractCallInfo(c.callsub_fn!, c.callsub_node); + const callInfo = arrayElemBindings + ? extractCallInfoWithBindings(c.callsub_fn!, c.callsub_node, arrayElemBindings) + : extractCallInfo(c.callsub_fn!, c.callsub_node); if (callInfo) calls.push(callInfo); calls.push(...extractCallbackReferenceCalls(c.callsub_node)); } else if (c.newfn_node) { @@ -375,7 +378,7 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr // Build capture lookup for this match (1-3 captures each, very fast) const c: Record = Object.create(null); for (const cap of match.captures) c[cap.name] = cap.node; - dispatchQueryMatch(c, definitions, calls, imports, classes, exps); + dispatchQueryMatch(c, definitions, calls, imports, classes, exps, arrayElemBindings); } // Extract top-level constants via targeted walk (query patterns don't cover these) @@ -1314,7 +1317,9 @@ function handleCallExpr(node: TreeSitterNode, ctx: ExtractorOutput): void { ctx.calls.push({ name: 'this', line: nodeStartLine(node) }); return; // no further processing needed for this()-style calls } - const callInfo = extractCallInfo(fn, node); + const callInfo = ctx.arrayElemBindings + ? extractCallInfoWithBindings(fn, node, ctx.arrayElemBindings) + : extractCallInfo(fn, node); if (callInfo) ctx.calls.push(callInfo); if (fn.type === 'member_expression') { const cbDef = extractCallbackDefinition(node, fn); @@ -3008,6 +3013,18 @@ function extractCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): Call | n return null; } +/** Like extractCallInfo but passes arrayElemBindings for dispatch-table detection. */ +function extractCallInfoWithBindings( + fn: TreeSitterNode, + callNode: TreeSitterNode, + arrayElemBindings: ArrayElemBinding[], +): Call | null { + if (fn.type === 'subscript_expression') { + return extractSubscriptCallInfo(fn, callNode, arrayElemBindings); + } + return extractCallInfo(fn, callNode); +} + /** Return the first non-punctuation argument node from a call_expression. */ function getFirstCallArg(callNode: TreeSitterNode): TreeSitterNode | null { const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments'); @@ -3152,7 +3169,11 @@ function extractMemberExprCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode) } /** Extract call info from a subscript_expression function node (obj[key]()). */ -function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): Call | null { +function extractSubscriptCallInfo( + fn: TreeSitterNode, + callNode: TreeSitterNode, + arrayElemBindings?: ArrayElemBinding[], +): Call | null { const obj = fn.childForFieldName('object'); const index = fn.childForFieldName('index'); if (!index) return null; @@ -3172,6 +3193,46 @@ function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode): } } + // RES-2: {a:fnA,b:fnB}[k]() — inline object literal dispatch table. + // Collect all identifier values as array elements under a synthetic name + // so the pts solver can resolve the wildcard call to each target. + // Also handles ({a:fn})[k]() where the object is wrapped in parentheses. + const objNode = + obj?.type === 'parenthesized_expression' + ? (obj.childForFieldName('expression') ?? obj.child(1) ?? obj) + : obj; + if (indexType === 'identifier' && objNode?.type === 'object' && arrayElemBindings) { + const line = nodeStartLine(callNode); + const col = callNode.startPosition.column; + const tableName = ``; + let idx = 0; + for (let i = 0; i < objNode.childCount; i++) { + const child = objNode.child(i); + if (!child) continue; + if (child.type === 'shorthand_property_identifier') { + if (!BUILTIN_GLOBALS.has(child.text)) { + arrayElemBindings.push({ arrayName: tableName, index: idx, elemName: child.text }); + idx++; + } + } else if (child.type === 'pair') { + const valN = child.childForFieldName('value'); + if (valN?.type === 'identifier' && !BUILTIN_GLOBALS.has(valN.text)) { + arrayElemBindings.push({ arrayName: tableName, index: idx, elemName: valN.text }); + idx++; + } + } + } + if (idx > 0) { + return { + name: `${tableName}[*]`, + line, + dynamic: true, + dynamicKind: 'dispatch-table', + keyExpr: index.text, + }; + } + } + // obj[variable]() — key is a variable; may be resolvable via pts (RES-1), else flagged if (indexType === 'identifier') { const receiver = extractReceiverName(obj); diff --git a/src/types.ts b/src/types.ts index c85f36f2..ad878ff0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -484,6 +484,7 @@ export interface LOCMetrics { export type DynamicKind = | 'computed-literal' // obj["foo"]() — resolvable; already emitted as normal edge | 'computed-key' // obj[k]() — potentially resolvable via pts; else flagged + | 'dispatch-table' // {a:fnA,b:fnB}[k]() — inline object literal subscript; resolved via pts wildcard | 'reflection' // .call/.apply/.bind / Reflect.* / callable-ref — resolved when target is in codebase; sink edge emitted if unresolved | 'eval' // eval() / new Function() — undecidable; always flagged | 'unresolved-dynamic'; // any other detected dynamic pattern; flagged diff --git a/tests/benchmarks/resolution/fixtures/pts-javascript/dispatch-table.js b/tests/benchmarks/resolution/fixtures/pts-javascript/dispatch-table.js new file mode 100644 index 00000000..a4f4208b --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/pts-javascript/dispatch-table.js @@ -0,0 +1,7 @@ +// pts-dispatch-table: closed object literal subscript dispatch {a:fnA,b:fnB}[k]() +function dtFn1() {} +function dtFn2() {} + +function runDispatch(key) { + ({ a: dtFn1, b: dtFn2 })[key](); +} diff --git a/tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json index a198fe0f..36fcc304 100644 --- a/tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/pts-javascript/expected-edges.json @@ -93,6 +93,20 @@ "kind": "calls", "mode": "pts-spread", "notes": "y() inside consumer2 — spread consumer2(...[sprFn3, sprFn4]) binds sprFn4 to y" + }, + { + "source": { "name": "runDispatch", "file": "dispatch-table.js" }, + "target": { "name": "dtFn1", "file": "dispatch-table.js" }, + "kind": "calls", + "mode": "pts-dispatch-table", + "notes": "({a:dtFn1,b:dtFn2})[key]() — inline dispatch table; pts resolves wildcard to each value" + }, + { + "source": { "name": "runDispatch", "file": "dispatch-table.js" }, + "target": { "name": "dtFn2", "file": "dispatch-table.js" }, + "kind": "calls", + "mode": "pts-dispatch-table", + "notes": "({a:dtFn1,b:dtFn2})[key]() — inline dispatch table; pts resolves wildcard to each value" } ] } diff --git a/tests/unit/points-to.test.ts b/tests/unit/points-to.test.ts index b9dac077..3c435d61 100644 --- a/tests/unit/points-to.test.ts +++ b/tests/unit/points-to.test.ts @@ -417,3 +417,40 @@ describe('buildPointsToMap — object-rest parameter dispatch (Phase 8.3f)', () } }); }); + +describe('buildPointsToMap — dispatch-table pts constraints (RES-2)', () => { + it('seeds wildcard for synthetic dispatch-table array elem bindings', () => { + const defNames = new Set(['dtFn1', 'dtFn2']); + const arrayElemBindings = [ + { arrayName: '', index: 0, elemName: 'dtFn1' }, + { arrayName: '', index: 1, elemName: 'dtFn2' }, + ]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings); + expect(resolveViaPointsTo('[*]', pts)).toContain('dtFn1'); + expect(resolveViaPointsTo('[*]', pts)).toContain('dtFn2'); + }); + + it('resolves only identifier values, not string keys', () => { + const defNames = new Set(['fn1']); + const arrayElemBindings = [{ arrayName: '', index: 0, elemName: 'fn1' }]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings); + expect(resolveViaPointsTo('[*]', pts)).toEqual(['fn1']); + }); + + it('two dispatch tables in the same file use distinct synthetic names', () => { + const defNames = new Set(['fnA', 'fnB', 'fnC', 'fnD']); + const arrayElemBindings = [ + { arrayName: '', index: 0, elemName: 'fnA' }, + { arrayName: '', index: 1, elemName: 'fnB' }, + { arrayName: '', index: 0, elemName: 'fnC' }, + { arrayName: '', index: 1, elemName: 'fnD' }, + ]; + const pts = buildPointsToMap([], defNames, NO_IMPORTS, undefined, undefined, arrayElemBindings); + expect(resolveViaPointsTo('[*]', pts)).toContain('fnA'); + expect(resolveViaPointsTo('[*]', pts)).toContain('fnB'); + expect(resolveViaPointsTo('[*]', pts)).not.toContain('fnC'); + expect(resolveViaPointsTo('[*]', pts)).toContain('fnC'); + expect(resolveViaPointsTo('[*]', pts)).toContain('fnD'); + expect(resolveViaPointsTo('[*]', pts)).not.toContain('fnA'); + }); +}); From b385e3b7a27cff1bb926fbed4110f97a8ff1ac36 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 03:32:41 -0600 Subject: [PATCH 05/11] fix: remove unused receiverText variable and fix lint formatting errors (#1677) docs check acknowledged --- src/domain/wasm-worker-entry.ts | 4 +++- src/extractors/dart.ts | 3 --- tests/parsers/dart.test.ts | 6 +++++- tests/parsers/objc.test.ts | 18 +++++++++++++++--- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index a4a27dbd..d55f5f7e 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -834,7 +834,9 @@ function serializeExtractorOutput( ? { returnTypeMap: Array.from(symbols.returnTypeMap.entries()) } : {}), ...(symbols.callAssignments?.length ? { callAssignments: symbols.callAssignments } : {}), - ...(symbols.cjsRequireBindings?.length ? { cjsRequireBindings: symbols.cjsRequireBindings } : {}), + ...(symbols.cjsRequireBindings?.length + ? { cjsRequireBindings: symbols.cjsRequireBindings } + : {}), }; } diff --git a/src/extractors/dart.ts b/src/extractors/dart.ts index a7a6e06f..cd0642f4 100644 --- a/src/extractors/dart.ts +++ b/src/extractors/dart.ts @@ -264,7 +264,6 @@ function handleDartSelector(node: TreeSitterNode, ctx: ExtractorOutput): void { // the next holds argument_part (the call args) — method name is in the previous sibling const unconditional = findChild(node, 'unconditional_assignable_selector'); let methodName: string | null = null; - let receiverText: string | null = null; if (unconditional) { const id = findChild(unconditional, 'identifier'); @@ -282,8 +281,6 @@ function handleDartSelector(node: TreeSitterNode, ctx: ExtractorOutput): void { const id2 = findChild(unc2, 'identifier'); if (id2) methodName = id2.text; } - } else { - receiverText = sibling?.text ?? null; } } } diff --git a/tests/parsers/dart.test.ts b/tests/parsers/dart.test.ts index 2d7557d0..f0106a24 100644 --- a/tests/parsers/dart.test.ts +++ b/tests/parsers/dart.test.ts @@ -56,7 +56,11 @@ import 'package:flutter/material.dart';`); var r = Function.apply(callback, []); }`); expect(symbols.calls).toContainEqual( - expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'unresolved-dynamic' }), + expect.objectContaining({ + name: '', + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }), ); }); }); diff --git a/tests/parsers/objc.test.ts b/tests/parsers/objc.test.ts index dcd2b3cb..22141326 100644 --- a/tests/parsers/objc.test.ts +++ b/tests/parsers/objc.test.ts @@ -96,7 +96,11 @@ describe('Objective-C parser', () => { [obj performSelector:@selector(greet)]; }`); expect(symbols.calls).toContainEqual( - expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'unresolved-dynamic' }), + expect.objectContaining({ + name: '', + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }), ); }); @@ -105,7 +109,11 @@ describe('Objective-C parser', () => { [obj performSelector:@selector(greet) withObject:arg]; }`); expect(symbols.calls).toContainEqual( - expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'unresolved-dynamic' }), + expect.objectContaining({ + name: '', + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }), ); }); @@ -114,7 +122,11 @@ describe('Objective-C parser', () => { objc_msgSend(obj, sel, arg); }`); expect(symbols.calls).toContainEqual( - expect.objectContaining({ name: '', dynamic: true, dynamicKind: 'unresolved-dynamic' }), + expect.objectContaining({ + name: '', + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }), ); }); From 5b0cb774dd5b6cb959a024cbf28a41f4536f8802 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 03:36:20 -0600 Subject: [PATCH 06/11] fix: distinguish full-build vs null-file-endpoints in reclassify log (#1677) docs check acknowledged --- src/domain/graph/builder/stages/native-orchestrator.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 20f155b4..d10d3095 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -2039,7 +2039,9 @@ async function runPostNativePasses( debug( scopedFiles ? `Post-pass role re-classification complete (${scopedFiles.length} file(s))` - : 'Post-pass role re-classification complete (full graph)', + : needsFullReclassify + ? 'Post-pass role re-classification complete (full graph — full build)' + : 'Post-pass role re-classification complete (full graph — null-file endpoints)', ); } catch (err) { debug(`Post-pass role re-classification failed: ${toErrorMessage(err)}`); From 1d4e2d803d75004b2f400bc9789a69ef67c78def Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 04:54:33 -0600 Subject: [PATCH 07/11] fix(native): add CJS require bindings to imports for receiver-edge resolution (#1678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit const { X } = require('…') creates a kind='function' shadow node in the importing file. The Rust emit_receiver_edge checks is_local_definition via imported_names.contains_key(name) — but CJS require bindings were not added to symbols.imports, so the name appeared as a local definition and blocked the cross-file class fallback. Fix: when the RHS of a destructured const is a require() call, also push the names into symbols.imports so collect_imported_names_for_file includes them in the imported_names map used by the receiver-edge resolver. Mirrors the WASM cjsRequireBindings fix (commit 066d7d00, issue #1661). Closes #1678 --- .../src/extractors/javascript.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index aefcfbf5..bef0318e 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1341,6 +1341,29 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { // skip destructured const bindings inside function scopes so the // Rust walk path matches FUNCTION_SCOPE_TYPES behaviour. extract_destructured_bindings(&name_n, source, start_line(node), end_line(node), &mut symbols.definitions); + // If the RHS is a CJS require() call, also add to imports so the + // receiver-edge resolver treats the names as import artifacts, not + // local definitions — mirroring the WASM cjsRequireBindings fix (#1678). + if value_n.kind() == "call_expression" { + if let Some(fn_node) = value_n.child_by_field_name("function") { + if node_text(&fn_node, source) == "require" { + let args = value_n.child_by_field_name("arguments") + .or_else(|| find_child(&value_n, "arguments")); + if let Some(args) = args { + if let Some(str_arg) = find_child(&args, "string") { + let mod_path = node_text(&str_arg, source) + .replace(&['\'', '"'][..], ""); + let names = collect_object_pattern_names(&name_n, source); + if !names.is_empty() { + symbols.imports.push(Import::new( + mod_path, names, start_line(node), + )); + } + } + } + } + } + } } else if is_const && is_js_literal(&value_n) && find_parent_of_types(node, &[ "function_declaration", "arrow_function", From 45779fc3c393532e8a34404651607bbef6b5185b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 05:37:47 -0600 Subject: [PATCH 08/11] fix(native): mark CJS require bindings to fix receiver-edge parity without spurious import edges The previous fix (1d4e2d80) added CJS require names to symbols.imports which caused the pipeline to create 3 extra DB import edges (for each destructured name), making native produce 42 edges vs WASM's 39. Proper fix: mark CJS require imports with cjs_require=Some(true) and skip them in the import-resolution batch that feeds DB import edge creation. collect_imported_names_for_file still processes them, so imported_names.contains_key('Calculator') returns true and the is_local_definition check in emit_receiver_edge correctly falls back to the cross-file class. Changes: - types.rs: add cjs_require: Option to Import struct - javascript.rs: set cjs_require=Some(true) on CJS require imports - pipeline.rs: skip cjs_require imports in import-resolution batch Closes #1678 --- crates/codegraph-core/src/domain/graph/builder/pipeline.rs | 3 +++ crates/codegraph-core/src/extractors/javascript.rs | 6 ++++-- crates/codegraph-core/src/types.rs | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 196588d8..769945a8 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -242,6 +242,9 @@ fn resolve_pipeline_imports( let abs_file = Path::new(root_dir).join(rel_path); let abs_str = abs_file.to_str().unwrap_or("").replace('\\', "/"); for imp in &symbols.imports { + // Skip CJS require bindings — they feed imported_names for receiver-edge + // resolution but must not produce DB import edges (#1678). + if imp.cjs_require.unwrap_or(false) { continue; } batch_inputs.push(ImportResolutionInput { from_file: abs_str.clone(), import_source: imp.source.clone(), diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index bef0318e..a3f7e87f 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1355,9 +1355,11 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { .replace(&['\'', '"'][..], ""); let names = collect_object_pattern_names(&name_n, source); if !names.is_empty() { - symbols.imports.push(Import::new( + let mut imp = Import::new( mod_path, names, start_line(node), - )); + ); + imp.cjs_require = Some(true); + symbols.imports.push(imp); } } } diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index 008bdc87..a1c3ccdc 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -152,6 +152,11 @@ pub struct Import { pub scala_import: Option, #[napi(js_name = "bashSource")] pub bash_source: Option, + /// Marks a CJS destructured require binding (`const { X } = require('./m')`). + /// When true, this entry feeds imported_names for receiver-edge resolution + /// but must NOT produce a DB import edge (mirrors WASM cjsRequireBindings, #1678). + #[napi(js_name = "cjsRequire")] + pub cjs_require: Option, } impl Import { @@ -176,6 +181,7 @@ impl Import { swift_import: None, scala_import: None, bash_source: None, + cjs_require: None, } } } From 16ad3d70f826c77effbe25cd954e9532227bcda9 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 06:09:42 -0600 Subject: [PATCH 09/11] fix(native): skip cjs_require imports in build_import_edges to prevent spurious edges The previous fix filtered batch_inputs in pipeline.rs but build_import_edges iterates ctx.file_symbols[file].imports directly, bypassing that filter. Add the skip at the emission site in import_edges.rs so CJS require bindings never produce DB import edges. --- .../src/domain/graph/builder/stages/import_edges.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs index 7471e8a1..fd09d4a2 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/import_edges.rs @@ -432,6 +432,9 @@ pub fn build_import_edges(conn: &Connection, ctx: &ImportEdgeContext) -> Vec Date: Sun, 21 Jun 2026 06:33:59 -0600 Subject: [PATCH 10/11] fix(native): allow CJS require imports through path resolution for correct call targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously CJS require entries were filtered from batch_inputs, so imported_names had empty file paths. This caused resolve_call_targets to find shadow function nodes (in index.js) instead of the real definitions (in utils.js), resulting in wrong call edges and wrong roles (sumOfSquares: dead-unresolved instead of core). Allow CJS entries through path resolution to get correct target files. DB import edge creation is still blocked via the cjs_require flag in build_import_edges — correct separation of concerns. --- crates/codegraph-core/src/domain/graph/builder/pipeline.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 769945a8..196588d8 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -242,9 +242,6 @@ fn resolve_pipeline_imports( let abs_file = Path::new(root_dir).join(rel_path); let abs_str = abs_file.to_str().unwrap_or("").replace('\\', "/"); for imp in &symbols.imports { - // Skip CJS require bindings — they feed imported_names for receiver-edge - // resolution but must not produce DB import edges (#1678). - if imp.cjs_require.unwrap_or(false) { continue; } batch_inputs.push(ImportResolutionInput { from_file: abs_str.clone(), import_source: imp.source.clone(), From c7dbeef3bd83cb6282f3a9f852b5537fde8a9c01 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 21 Jun 2026 06:55:40 -0600 Subject: [PATCH 11/11] fix(native): use empty file for CJS imported_names to match WASM call-resolution behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CJS require bindings in imported_names now use an empty target_file. This makes resolve_call_targets' import-aware lookup fail for CJS names (file=""), falling through to same-file shadow nodes — exactly matching WASM's behavior where CJS names are absent from importedNamesMap. Without this, step 3's real-file paths routed calls to real nodes (math.js) instead of shadows (index.js), causing shadow add/sumOfSquares/square to have fan_in=0 → dead-unresolved instead of core. The CJS entries still flow through batch_inputs so propagate_return_types and other pipeline functions work correctly — which is what fixed main/square/sumOfSquares roles in step 3. --- .../src/domain/graph/builder/pipeline.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs index 196588d8..f90c25b6 100644 --- a/crates/codegraph-core/src/domain/graph/builder/pipeline.rs +++ b/crates/codegraph-core/src/domain/graph/builder/pipeline.rs @@ -1285,6 +1285,16 @@ fn collect_imported_names_for_file( let resolved_path = import_ctx.get_resolved(abs_str, &imp.source); for name in &imp.names { let clean_name = name.strip_prefix("* as ").unwrap_or(name).to_string(); + // CJS require bindings are included in imported_names so the receiver-edge + // resolver treats them as import artifacts (not locally-defined symbols). + // We use an empty target_file so the import-aware call-target lookup + // (`nodes_by_name_and_file.get(&(name, ""))`) always misses and falls + // through to the same-file shadow node — matching WASM call-resolution + // behaviour where CJS bindings are not in importedNamesMap (#1678). + if imp.cjs_require.unwrap_or(false) { + imported_names.push(ImportedName { name: clean_name, file: String::new() }); + continue; + } let mut target_file = resolved_path.clone(); if import_ctx.is_barrel_file(&resolved_path) { let mut visited = HashSet::new();