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/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/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 02e4fd7fb..f9aa74d1e 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -919,7 +919,17 @@ 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 && + !symbols.objectRestParamBindings?.length && + !symbols.objectPropBindings?.length + ) + return null; const defNames = new Set( symbols.definitions .filter((d) => d.kind === 'function' || d.kind === 'method') @@ -932,6 +942,12 @@ function buildPointsToMapForFile( importedNames, symbols.paramBindings, definitionParams, + symbols.arrayElemBindings, + symbols.spreadArgBindings, + symbols.forOfBindings, + symbols.arrayCallbackBindings, + symbols.objectRestParamBindings, + symbols.objectPropBindings, ); } @@ -1063,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 @@ -1103,6 +1131,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/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index b76c215a3..1fde3b3e2 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -43,6 +43,7 @@ import { } from '../../../parser.js'; import { computeConfidence } from '../../resolve.js'; import type { CallNodeLookup } from '../call-resolver.js'; +import { findCaller, resolveByMethodOrGlobal } from '../call-resolver.js'; import type { ChaContext } from '../cha.js'; import { resolveThisDispatch } from '../cha.js'; import type { PipelineContext } from '../context.js'; @@ -560,6 +561,206 @@ function runPostNativeCha(db: BetterSqlite3Database): Set { return newTargetIds; } +/** + * Post-pass: backfill function-as-object-property method definitions and their call edges. + * + * The Rust engine does not recognise `fn.method = function(){}` patterns as + * method definitions, 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 extractFuncPropMethodsWalk). + * 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. + * + * Note: `Foo.prototype.bar = function(){}` patterns are handled natively by + * the Rust extractor and do not need a WASM re-parse here. + */ +async function runPostNativePrototypeMethods( + db: BetterSqlite3Database, + rootDir: string, +): Promise { + // 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 + .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; + + // Pre-filter: only re-parse files that contain the function-as-object-property + // 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)); + // 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; + } + }); + + if (protoFiles.length === 0) return; + + // WASM-parse only the files that have func-prop patterns to get full + // ExtractorOutput including method definitions and typeMap entries. + const absPaths = protoFiles.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 + // func-prop 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) : []; + }), + ); + + // 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 func-prop + // methods. A caller in app.js calling a 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 fileNodeStmt = db.prepare(`SELECT id FROM nodes WHERE kind = 'file' AND file = ?`); + + for (const [relPath, symbols] of wasmResults) { + const fileNodeRow = fileNodeStmt.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; // receiver calls only + + 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 func-prop 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))(); + } +} + // Extensions where `this`/`super` dispatch can occur (JS/TS family) const THIS_DISPATCH_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.mts', '.cts']); @@ -1391,6 +1592,16 @@ export async function tryNativeOrchestrator( } } + // 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(`Function-prop methods post-pass failed: ${toErrorMessage(err)}`); + } + // Phase 8.5: this/super dispatch — hybrid WASM re-parse to resolve call sites // whose raw receiver info the Rust pipeline does not persist to DB. const { elapsedMs: thisDispatchMs, targetIds: thisDispatchTargetIds } = 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/graph/resolver/points-to.ts b/src/domain/graph/resolver/points-to.ts index 5b259b7b2..a46329aae 100644 --- a/src/domain/graph/resolver/points-to.ts +++ b/src/domain/graph/resolver/points-to.ts @@ -19,7 +19,16 @@ * 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, + ObjectPropBinding, + ObjectRestParamBinding, + ParamBinding, + SpreadArgBinding, +} from '../../../types.js'; export type PointsToMap = Map>; @@ -41,11 +50,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 +66,12 @@ export function buildPointsToMap( importedNames: ReadonlyMap, paramBindings?: readonly ParamBinding[], definitionParams?: ReadonlyMap, + arrayElemBindings?: readonly ArrayElemBinding[], + spreadArgBindings?: readonly SpreadArgBinding[], + forOfBindings?: readonly ForOfBinding[], + arrayCallbackBindings?: readonly ArrayCallbackBinding[], + objectRestParamBindings?: readonly ObjectRestParamBinding[], + objectPropBindings?: readonly ObjectPropBinding[], ): PointsToMap { const pts: PointsToMap = new Map(); @@ -100,6 +119,86 @@ 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}[*]` }); + } + } + + // 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 8ca487861..8d84978a1 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -805,12 +805,24 @@ 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.objectRestParamBindings?.length + ? { objectRestParamBindings: symbols.objectRestParamBindings } + : {}), + ...(symbols.objectPropBindings?.length + ? { objectPropBindings: symbols.objectPropBindings } + : {}), ...(symbols.newExpressions?.length ? { newExpressions: symbols.newExpressions } : {}), ...(symbols.returnTypeMap?.size ? { returnTypeMap: Array.from(symbols.returnTypeMap.entries()) } : {}), ...(symbols.callAssignments?.length ? { callAssignments: symbols.callAssignments } : {}), - ...(symbols.paramBindings?.length ? { paramBindings: symbols.paramBindings } : {}), }; } diff --git a/src/domain/wasm-worker-pool.ts b/src/domain/wasm-worker-pool.ts index b15e8c476..ac668a323 100644 --- a/src/domain/wasm-worker-pool.ts +++ b/src/domain/wasm-worker-pool.ts @@ -107,6 +107,14 @@ 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.objectRestParamBindings?.length) + out.objectRestParamBindings = ser.objectRestParamBindings; + if (ser.objectPropBindings?.length) out.objectPropBindings = ser.objectPropBindings; if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions; if (ser.returnTypeMap?.length) { const returnTypeMap = new Map(); @@ -114,7 +122,6 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp out.returnTypeMap = returnTypeMap; } if (ser.callAssignments?.length) out.callAssignments = ser.callAssignments; - if (ser.paramBindings?.length) out.paramBindings = ser.paramBindings; return out; } diff --git a/src/domain/wasm-worker-protocol.ts b/src/domain/wasm-worker-protocol.ts index 5ff236e62..a4460b799 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'; @@ -65,10 +64,16 @@ export interface SerializedExtractorOutput { receiver?: string; }>; fnRefBindings?: import('../types.js').FnRefBinding[]; + arrayElemBindings?: import('../types.js').ArrayElemBinding[]; + 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[]; + paramBindings?: import('../types.js').ParamBinding[]; newExpressions?: readonly string[]; returnTypeMap?: Array<[string, TypeMapEntry]>; callAssignments?: CallAssignment[]; - paramBindings?: ParamBinding[]; } export interface WorkerParseResponseOk { 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/javascript.ts b/src/extractors/javascript.ts index db9b91521..a3a056026 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,12 @@ import type { Export, ExtractorOutput, FnRefBinding, + ForOfBinding, Import, + ObjectPropBinding, + ObjectRestParamBinding, ParamBinding, + SpreadArgBinding, SubDeclaration, TreeSitterNode, TreeSitterQuery, @@ -310,6 +316,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); } } @@ -324,6 +331,12 @@ 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 objectRestParamBindings: ObjectRestParamBinding[] = []; + const objectPropBindings: ObjectPropBinding[] = []; const matches = query.matches(tree.rootNode); @@ -352,9 +365,23 @@ 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); + // 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); @@ -370,6 +397,12 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr callAssignments, fnRefBindings, paramBindings, + arrayElemBindings, + spreadArgBindings, + forOfBindings, + arrayCallbackBindings, + objectRestParamBindings, + objectPropBindings, newExpressions, }; } @@ -608,6 +641,12 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput { callAssignments: [], fnRefBindings: [], paramBindings: [], + arrayElemBindings: [], + spreadArgBindings: [], + forOfBindings: [], + arrayCallbackBindings: [], + objectRestParamBindings: [], + objectPropBindings: [], }; walkJavaScriptNode(tree.rootNode, ctx); @@ -623,8 +662,22 @@ 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 + extractArrayElemBindingsWalk(tree.rootNode, ctx.arrayElemBindings!); + extractSpreadForOfWalk( + tree.rootNode, + ctx.spreadArgBindings!, + ctx.forOfBindings!, + 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); @@ -1765,6 +1818,345 @@ 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[] = []; + // 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; + 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); + pushedFunc = true; + } + } else if (node.type === 'method_definition') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + // 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') { + // `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') { + 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; + } + } + } + // 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 }); + } + } + } + } + + for (let i = 0; i < node.childCount; i++) { + walk(node.child(i)!, depth + 1); + } + + if (pushedFunc) funcStack.pop(); + if (pushedClass) classStack.pop(); + } + + 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') { + // `function_declaration` has a `name` field; `arrow_function` and + // `function_expression` do not — get the name from the enclosing + // `variable_declarator` instead (e.g. `const f3 = ({ ...rest }) => {}`). + const nameNode = + node.childForFieldName('name') ?? + (node.parent?.type === 'variable_declarator' + ? node.parent.childForFieldName('name') + : null); + 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; @@ -2341,6 +2733,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, @@ -2366,6 +2812,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/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/src/types.ts b/src/types.ts index 4b51ba63e..3101dd704 100644 --- a/src/types.ts +++ b/src/types.ts @@ -564,6 +564,72 @@ 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; +} + +/** + * 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[]; @@ -595,6 +661,18 @@ 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.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/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/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 92d167e44..7ec19aa8f 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 super.count() * 2; // static super.count() → Counter.count via CHA parents map } } 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..fa6628a6b --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/expected-edges.json @@ -0,0 +1,67 @@ +{ + "$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..a722ae38b --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/more1/more1.js @@ -0,0 +1,54 @@ +// 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/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..2559c72e8 --- /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); 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..94ed8fb43 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/expected-edges.json @@ -0,0 +1,31 @@ +{ + "$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..99ce4f34d --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/spread/spread.js @@ -0,0 +1,21 @@ +// 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/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..0ab7c579f --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/jelly-micro/this/this.js @@ -0,0 +1,10 @@ +// Jelly micro-test: this — function-as-object property methods, this-dispatch + +function f() {} +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/fixtures/typescript/expected-edges.json b/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json index 312b6c6dc..69366d35f 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); } 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/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/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/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 diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index 1d3409e3d..1411f42dd 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -779,4 +779,158 @@ 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); + }); + + 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 }); + }); + }); + + 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' }), + ); + }); + }); + + 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', + }), + ); + }); + }); }); diff --git a/tests/unit/points-to.test.ts b/tests/unit/points-to.test.ts index 6e8998e1f..8f5b22920 100644 --- a/tests/unit/points-to.test.ts +++ b/tests/unit/points-to.test.ts @@ -139,3 +139,109 @@ 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'); + }); +});