Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2aacecc
feat(native): implement return-type + call-assignment extraction in R…
carlos-alm Jun 2, 2026
2f11a0f
fix: add missing return_type_map and call_assignments in test FileSym…
carlos-alm Jun 2, 2026
1a263f8
fix(native): align return-type extraction with WASM reference
carlos-alm Jun 2, 2026
e85489b
Merge branch 'main' into fix/native-return-type-parity
carlos-alm Jun 2, 2026
21879b7
fix: resolve merge conflicts with main
carlos-alm Jun 2, 2026
be22ab7
fix: include ts-resolver files missed in merge commit
carlos-alm Jun 2, 2026
0ea6d21
Merge remote-tracking branch 'origin/fix/native-return-type-parity' i…
carlos-alm Jun 2, 2026
11a1126
feat(resolver): field-based points-to analysis for higher-order calls…
carlos-alm Jun 2, 2026
a37c55a
fix(resolver): address review feedback on Phase 8.3 points-to analysi…
carlos-alm Jun 2, 2026
7071a95
fix(resolver): remove unknown 'dynamic' field from resolveCallTargets…
carlos-alm Jun 2, 2026
1c5df80
Merge branch 'main' into feat/phase-8.3-points-to-analysis
carlos-alm Jun 3, 2026
d794d47
fix(resolver): prevent pts-resolved edge from preempting higher-confi…
carlos-alm Jun 3, 2026
8019fe4
fix(resolver): fix TS2532 and upgrade is_dynamic flag when promoting …
carlos-alm Jun 3, 2026
40541c1
feat(resolver): extend pts analysis to native Rust path (#1290)
carlos-alm Jun 3, 2026
cf3bd2c
fix(rust): add missing fn_ref_bindings field to test struct initializ…
carlos-alm Jun 3, 2026
65ed277
fix(rust): add BUILTIN_GLOBALS guard and named MAX_SOLVER_ITERATIONS …
carlos-alm Jun 3, 2026
d5ee803
fix(test): add always-on WASM pts test and clarify reserved config fi…
carlos-alm Jun 3, 2026
d9695b6
chore: merge main into feat/phase-8.3-points-to-analysis
carlos-alm Jun 3, 2026
437abff
Merge remote-tracking branch 'origin/main' into feat/phase-8.3-points…
carlos-alm Jun 3, 2026
65e916d
fix(rust): add pts_edge_map confidence-upgrade path to match JS ptsEd…
carlos-alm Jun 3, 2026
d0f719b
Merge branch 'main' into feat/phase-8.3-points-to-analysis
carlos-alm Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/codegraph-core/src/build_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,11 @@ fn build_and_insert_call_edges(
})
.collect(),
type_map,
fn_ref_bindings: if symbols.fn_ref_bindings.is_empty() {
None
} else {
Some(symbols.fn_ref_bindings.clone())
},
});
}

Expand Down
166 changes: 159 additions & 7 deletions crates/codegraph-core/src/edge_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use napi_derive::napi;

use crate::barrel_resolution::{self, BarrelContext, ReexportRef};
use crate::import_resolution;
use crate::types::FnRefBinding;

/// Kind sets for hierarchy edge resolution -- mirrors the JS constants in
/// `build-edges.js` (`HIERARCHY_SOURCE_KINDS`, `EXTENDS_TARGET_KINDS`,
Expand All @@ -13,6 +14,10 @@ const HIERARCHY_SOURCE_KINDS: &[&str] = &["class", "struct", "record", "enum"];
const EXTENDS_TARGET_KINDS: &[&str] = &["class", "struct", "trait", "record"];
const IMPLEMENTS_TARGET_KINDS: &[&str] = &["interface", "trait", "class"];

/// Confidence penalty per alias hop — mirrors `PROPAGATION_HOP_PENALTY` in
/// `src/extractors/javascript.ts`.
const PROPAGATION_HOP_PENALTY: f64 = 0.1;

#[napi(object)]
pub struct NodeInfo {
pub id: u32,
Expand Down Expand Up @@ -73,6 +78,9 @@ pub struct FileEdgeInput {
pub classes: Vec<ClassInfo>,
#[napi(js_name = "typeMap")]
pub type_map: Vec<TypeMapInput>,
/// Function-reference bindings for Phase 8.3 pts analysis (optional).
#[napi(js_name = "fnRefBindings")]
pub fn_ref_bindings: Option<Vec<FnRefBinding>>,
}

#[napi(object)]
Expand Down Expand Up @@ -120,6 +128,74 @@ impl<'a> EdgeContext<'a> {
}
}

// ── Phase 8.3: points-to analysis ─────────────────────────────────────────

/// Maximum fixed-point iterations for the pts solver.
/// Mirrors `MAX_SOLVER_ITERATIONS` in `src/domain/graph/resolver/points-to.ts`.
/// TODO: wire through `CodegraphConfig.analysis.pointsToMaxIterations` once
/// config plumbing is in place (same pattern as `typePropagationDepth`).
const MAX_SOLVER_ITERATIONS: usize = 50;

/// Build a per-file points-to map. Mirrors `buildPointsToMap` in
/// `src/domain/graph/resolver/points-to.ts`.
///
/// Seeds every locally-defined callable and every imported name as
/// pointing to itself, then propagates assignments (`pts(lhs) ⊇ pts(rhs)`)
/// via fixed-point iteration.
fn build_points_to_map(
fn_ref_bindings: &[FnRefBinding],
def_names: &HashSet<&str>,
imported_names: &HashMap<&str, &str>,
) -> HashMap<String, HashSet<String>> {
let mut pts: HashMap<String, HashSet<String>> = HashMap::new();
for name in def_names {
pts.entry(name.to_string()).or_default().insert(name.to_string());
}
for name in imported_names.keys() {
pts.entry(name.to_string()).or_default().insert(name.to_string());
}
if fn_ref_bindings.is_empty() {
return pts;
}
let constraints: Vec<(String, String)> = fn_ref_bindings.iter().map(|b| {
let rhs_key = match &b.rhs_receiver {
Some(recv) => format!("{}.{}", recv, b.rhs),
None => b.rhs.clone(),
};
(b.lhs.clone(), rhs_key)
}).collect();
for _ in 0..MAX_SOLVER_ITERATIONS {
let mut changed = false;
for (lhs, rhs_key) in &constraints {
let rhs_pts: Option<Vec<String>> = pts.get(rhs_key.as_str())
.map(|s| s.iter().cloned().collect());
if let Some(targets) = rhs_pts {
let entry = pts.entry(lhs.clone()).or_default();
for t in targets {
if entry.insert(t) { changed = true; }
}
}
}
if !changed { break; }
}
pts
}

/// Return the concrete targets `call_name` flows to, excluding self-references.
/// Mirrors `resolveViaPointsTo` in `src/domain/graph/resolver/points-to.ts`.
fn resolve_via_points_to<'a>(
call_name: &str,
pts: &'a HashMap<String, HashSet<String>>,
) -> Vec<&'a str> {
match pts.get(call_name) {
None => vec![],
Some(targets) => targets.iter()
.filter(|t| t.as_str() != call_name)
.map(|t| t.as_str())
.collect(),
}
}

/// Build call, receiver, extends, and implements edges in Rust.
///
/// Mirrors the algorithm in builder.js `buildEdges` transaction (call edges
Expand Down Expand Up @@ -180,7 +256,24 @@ fn process_file<'a>(
DefWithId { _name: &d.name, line: d.line, end_line: d.end_line.unwrap_or(u32::MAX), node_id }
}).collect();

// Phase 8.3: build pts map for alias resolution.
// Only callable (function/method) defs are seeded — mirrors JS buildPointsToMapForFile.
let pts_map: Option<HashMap<String, HashSet<String>>> =
file_input.fn_ref_bindings.as_deref().filter(|b| !b.is_empty()).map(|bindings| {
let def_names: HashSet<&str> = file_input.definitions.iter()
.filter(|d| d.kind == "function" || d.kind == "method")
.map(|d| d.name.as_str())
.collect();
build_points_to_map(bindings, &def_names, &imported_names)
});

let mut seen_edges: HashSet<u64> = HashSet::new();
// Phase 8.3: tracks pts-resolved edges separately from seen_edges so that a
// subsequent direct call to the same caller→target pair can upgrade confidence
// in-place rather than being silently dropped by the dedup guard.
// Mirrors `ptsEdgeRows` in `src/domain/graph/builder/stages/build-edges.ts`.
// Key: edge_key (same as seen_edges). Value: index into `edges` vec.
let mut pts_edge_map: HashMap<u64, usize> = HashMap::new();

for call in &file_input.calls {
if let Some(ref receiver) = call.receiver {
Expand All @@ -193,7 +286,52 @@ fn process_file<'a>(

let mut targets = resolve_call_targets(ctx, call, rel_path, imported_from, &type_map);
sort_targets_by_confidence(&mut targets, rel_path, imported_from);
emit_call_edges(&targets, caller_id, is_dynamic, rel_path, imported_from, &mut seen_edges, edges);
emit_call_edges(&targets, caller_id, is_dynamic, rel_path, imported_from, &mut seen_edges, &mut pts_edge_map, edges);

// Phase 8.3: pts fallback for unresolved dynamic identifier calls.
// When primary resolution finds nothing and the call is dynamic with no receiver,
// look up the call name in the pts map and retry resolution for each alias target.
// Confidence is penalised by one hop to reflect the extra indirection.
//
// Pts edges go into pts_edge_map (not seen_edges) so a later direct call to the
// same target in the same function body can upgrade confidence in-place — mirroring
// the ptsEdgeRows mechanism on the JS/WASM path.
if targets.is_empty() && call.dynamic.unwrap_or(false) && call.receiver.is_none() {
if let Some(ref pts) = pts_map {
for alias in resolve_via_points_to(call.name.as_str(), pts) {
let alias_imported_from = imported_names.get(alias).copied();
let alias_call = CallInfo {
name: alias.to_string(),
line: call.line,
dynamic: Some(true),
receiver: None,
};
let mut alias_targets = resolve_call_targets(
ctx, &alias_call, rel_path, alias_imported_from, &type_map,
);
sort_targets_by_confidence(&mut alias_targets, rel_path, alias_imported_from);
for t in &alias_targets {
let edge_key = ((caller_id as u64) << 32) | (t.id as u64);
if t.id != caller_id && !seen_edges.contains(&edge_key) && !pts_edge_map.contains_key(&edge_key) {
let conf = import_resolution::compute_confidence(
rel_path, &t.file, alias_imported_from,
) - PROPAGATION_HOP_PENALTY;
if conf > 0.0 {
pts_edge_map.insert(edge_key, edges.len());
edges.push(ComputedEdge {
source_id: caller_id,
target_id: t.id,
kind: "calls".to_string(),
confidence: conf,
dynamic: is_dynamic,
});
}
}
}
}
}
}

emit_receiver_edge(ctx, call, caller_id, rel_path, &type_map, &mut seen_edges, edges);
}

Expand Down Expand Up @@ -303,17 +441,30 @@ fn sort_targets_by_confidence(targets: &mut Vec<&NodeInfo>, rel_path: &str, impo
fn emit_call_edges(
targets: &[&NodeInfo], caller_id: u32, is_dynamic: u32,
rel_path: &str, imported_from: Option<&str>,
seen_edges: &mut HashSet<u64>, edges: &mut Vec<ComputedEdge>,
seen_edges: &mut HashSet<u64>, pts_edge_map: &mut HashMap<u64, usize>, edges: &mut Vec<ComputedEdge>,
) {
for t in targets {
let edge_key = ((caller_id as u64) << 32) | (t.id as u64);
if t.id != caller_id && !seen_edges.contains(&edge_key) {
seen_edges.insert(edge_key);
let confidence = import_resolution::compute_confidence(rel_path, &t.file, imported_from);
edges.push(ComputedEdge {
source_id: caller_id, target_id: t.id,
kind: "calls".to_string(), confidence, dynamic: is_dynamic,
});
if let Some(&pts_idx) = pts_edge_map.get(&edge_key) {
// A pts-resolved edge already exists for this caller→target pair with a
// penalised confidence. Upgrade it to the direct-call confidence in-place,
// then promote to seen_edges so no further processing is needed.
// Mirrors the ptsEdgeRows upgrade path in build-edges.ts.
if let Some(pts_row) = edges.get_mut(pts_idx) {
pts_row.confidence = confidence;
pts_row.dynamic = is_dynamic; // direct call overrides alias dynamic flag
}
pts_edge_map.remove(&edge_key);
seen_edges.insert(edge_key);
} else {
seen_edges.insert(edge_key);
edges.push(ComputedEdge {
source_id: caller_id, target_id: t.id,
kind: "calls".to_string(), confidence, dynamic: is_dynamic,
});
}
}
}
}
Expand Down Expand Up @@ -1059,6 +1210,7 @@ mod call_edge_tests {
imported_names: vec![],
classes,
type_map,
fn_ref_bindings: None,
}
}

Expand Down
44 changes: 44 additions & 0 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ use crate::complexity::compute_all_metrics;
use crate::types::*;
use tree_sitter::{Node, Tree};

/// Well-known JS globals that must not be recorded as pts targets.
/// Mirrors the `BUILTIN_GLOBALS` set in `src/extractors/javascript.ts`.
const JS_BUILTIN_GLOBALS: &[&str] = &[
"Math", "JSON", "Promise", "Array", "Object", "Date", "Error",
"Symbol", "Map", "Set", "RegExp", "Number", "String", "Boolean",
"WeakMap", "WeakSet", "WeakRef", "Proxy", "Reflect", "Intl",
"ArrayBuffer", "SharedArrayBuffer", "DataView", "Atomics", "BigInt",
"Float32Array", "Float64Array", "Int8Array", "Int16Array", "Int32Array",
"Uint8Array", "Uint16Array", "Uint32Array", "Uint8ClampedArray",
"URL", "URLSearchParams", "TextEncoder", "TextDecoder",
"AbortController", "AbortSignal", "Headers", "Request", "Response",
"FormData", "Blob", "File", "ReadableStream", "WritableStream",
"TransformStream", "console", "Buffer", "EventEmitter", "Stream",
];

pub struct JsExtractor;

impl SymbolExtractor for JsExtractor {
Expand Down Expand Up @@ -497,6 +512,35 @@ fn handle_var_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
cfg: None,
children: None,
});
} else if name_n.kind() == "identifier" && value_n.kind() == "identifier" {
// Phase 8.3: `const alias = handler` — record for pts analysis.
// Mirror the JS BUILTIN_GLOBALS guard: skip well-known JS globals so
// they are never seeded as pts targets (e.g. `const a = Array`).
let rhs_text = node_text(&value_n, source);
if !JS_BUILTIN_GLOBALS.contains(&rhs_text) {
symbols.fn_ref_bindings.push(FnRefBinding {
lhs: node_text(&name_n, source).to_string(),
rhs: rhs_text.to_string(),
rhs_receiver: None,
});
}
} else if name_n.kind() == "identifier" && value_n.kind() == "member_expression" {
// Phase 8.3: `const alias = obj.method` — record for pts analysis.
// Mirror the JS BUILTIN_GLOBALS guard: skip bindings where the
// receiver object is a well-known JS global (e.g. `const fn = Math.random`).
if let (Some(obj), Some(prop)) = (
value_n.child_by_field_name("object"),
value_n.child_by_field_name("property"),
) {
let obj_text = node_text(&obj, source);
if !JS_BUILTIN_GLOBALS.contains(&obj_text) {
symbols.fn_ref_bindings.push(FnRefBinding {
lhs: node_text(&name_n, source).to_string(),
rhs: node_text(&prop, source).to_string(),
rhs_receiver: Some(obj_text.to_string()),
});
}
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/codegraph-core/src/import_edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ mod tests {
ast_nodes: vec![],
dataflow: None,
line_count: None,
fn_ref_bindings: vec![],
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/codegraph-core/src/structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,7 @@ mod tests {
ast_nodes: vec![],
dataflow: None,
line_count: Some(42),
fn_ref_bindings: vec![],
};
file_symbols.insert("src/a.ts".to_string(), sym.clone());

Expand Down
16 changes: 16 additions & 0 deletions crates/codegraph-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,18 @@ pub struct NativeCallAssignment {
pub receiver_type_name: Option<String>,
}

/// Function-reference binding for Phase 8.3 points-to analysis.
/// Records `const alias = fn` and `const alias = obj.method` patterns.
/// Mirrors the `FnRefBinding` interface in `src/types.ts`.
#[napi(object)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FnRefBinding {
pub lhs: String,
pub rhs: String,
#[napi(js_name = "rhsReceiver")]
pub rhs_receiver: Option<String>,
}

#[napi(object)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSymbols {
Expand All @@ -326,6 +338,9 @@ pub struct FileSymbols {
pub return_type_map: Vec<TypeMapEntry>,
#[napi(js_name = "callAssignments")]
pub call_assignments: Vec<NativeCallAssignment>,
/// Phase 8.3: function-reference bindings for points-to analysis.
#[napi(js_name = "fnRefBindings")]
pub fn_ref_bindings: Vec<FnRefBinding>,
}

impl FileSymbols {
Expand All @@ -343,6 +358,7 @@ impl FileSymbols {
type_map: Vec::new(),
return_type_map: Vec::new(),
call_assignments: Vec::new(),
fn_ref_bindings: Vec::new(),
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ interface NativeFileEntry {
importedNames: Array<{ name: string; file: string }>;
classes: ClassRelation[];
typeMap: Array<{ name: string; typeName: string; confidence: number }>;
/** Phase 8.3: function-reference bindings for pts analysis. */
fnRefBindings?: Array<{ lhs: string; rhs: string; rhsReceiver?: string }>;
}

/** Shape returned by native buildCallEdges. */
Expand Down Expand Up @@ -505,6 +507,7 @@ function buildCallEdgesNative(
importedNames,
classes: symbols.classes,
typeMap,
fnRefBindings: symbols.fnRefBindings?.length ? symbols.fnRefBindings : undefined,
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/domain/graph/resolver/ts-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ function resolveTypeName(
// Skip generic type-parameter symbols (T, E, K, etc.) — they do not
// correspond to any real class and would overwrite useful lower-confidence
// heuristic entries, causing call edges to be silently dropped.
!!(symbol.flags & (ts.SymbolFlags.TypeParameter | ts.SymbolFlags.TypeAlias))
symbol.flags & (ts.SymbolFlags.TypeParameter | ts.SymbolFlags.TypeAlias)
)
return null;
// getFullyQualifiedName returns e.g. `"./path/to/module".ClassName` for
Expand Down
1 change: 1 addition & 0 deletions src/domain/wasm-worker-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ function serializeExtractorOutput(
_lineCount: code.split('\n').length,
dataflow: symbols.dataflow,
astNodes,
...(symbols.fnRefBindings?.length ? { fnRefBindings: symbols.fnRefBindings } : {}),
};
}

Expand Down
1 change: 1 addition & 0 deletions src/domain/wasm-worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp
// {line, kind, name, text?, receiver?} shape — see engine.ts:822 where the
// 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;
return out;
}

Expand Down
Loading
Loading