devlensio 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/graph/edges/callEdges.js +97 -5
  2. package/dist/graph/edges/callEdges.test.js +154 -0
  3. package/dist/parser/extractors/classes.js +23 -4
  4. package/dist/parser/extractors/functions.d.ts +10 -3
  5. package/dist/parser/extractors/functions.js +135 -65
  6. package/dist/parser/extractors/hooks.js +5 -29
  7. package/dist/parser/extractors/objectMethods.js +3 -4
  8. package/dist/parser/index.js +11 -6
  9. package/dist/parser/overloads.d.ts +2 -0
  10. package/dist/parser/overloads.js +96 -0
  11. package/dist/parser/overloads.test.d.ts +1 -0
  12. package/dist/parser/overloads.test.js +175 -0
  13. package/dist/parser/typeUtils.d.ts +2 -0
  14. package/dist/parser/typeUtils.js +2 -0
  15. package/dist/types.d.ts +6 -0
  16. package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
  17. package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
  18. package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
  19. package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
  20. package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
  21. package/extractors/go/calls.go +134 -2
  22. package/extractors/go/contract.go +1 -0
  23. package/extractors/go/exports.go +107 -0
  24. package/extractors/go/exports_test.go +64 -0
  25. package/extractors/go/extractor.go +1 -0
  26. package/extractors/go/lookup.go +63 -7
  27. package/extractors/go/nodes.go +28 -0
  28. package/extractors/go/parser.go +51 -8
  29. package/extractors/java/devlens_java_extractor.jar +0 -0
  30. package/extractors/java/src/devlens/extractor/ExportsMapBuilder.java +148 -0
  31. package/extractors/java/src/devlens/extractor/Extractor.java +34 -0
  32. package/extractors/java/src/devlens/extractor/LookupMaps.java +12 -0
  33. package/extractors/java/src/devlens/extractor/Overloads.java +290 -0
  34. package/extractors/java/src/devlens/extractor/Parser.java +39 -0
  35. package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +9 -0
  36. package/extractors/java/src/devlens/extractor/edges/Calls.java +42 -36
  37. package/extractors/java/src/devlens/extractor/edges/Routes.java +10 -3
  38. package/extractors/python/devlens_extractors_python/edges/calls.py +127 -26
  39. package/extractors/python/devlens_extractors_python/exports_map.py +263 -0
  40. package/extractors/python/devlens_extractors_python/extractor.py +7 -1
  41. package/extractors/python/devlens_extractors_python/lookup.py +6 -0
  42. package/extractors/python/devlens_extractors_python/parser/__init__.py +6 -0
  43. package/extractors/python/devlens_extractors_python/parser/functions.py +103 -6
  44. package/extractors/python/devlens_extractors_python/parser/overloads.py +105 -0
  45. package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
  46. package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
  47. package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
  48. package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
  49. package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
  50. package/extractors/rust/src/calls.rs +27 -16
  51. package/extractors/rust/src/contract.rs +2 -0
  52. package/extractors/rust/src/exports.rs +387 -0
  53. package/extractors/rust/src/exports_test.rs +105 -0
  54. package/extractors/rust/src/extractor.rs +2 -0
  55. package/extractors/rust/src/lookup.rs +60 -0
  56. package/extractors/rust/src/main.rs +4 -0
  57. package/extractors/rust/src/nodes.rs +19 -0
  58. package/extractors/rust/src/parser.rs +66 -1
  59. package/extractors/rust/src/routes.rs +2 -1
  60. package/package.json +1 -1
@@ -61,16 +61,110 @@ def call_name(func: ast.AST) -> str | None:
61
61
 
62
62
  def extract_calls(body: list[ast.stmt]) -> list[str]:
63
63
  """Collect every called name in a function body, deduped."""
64
+ return extract_calls_with_sites(body)[0]
65
+
66
+
67
+ def _infer_arg_type(arg: ast.AST) -> str:
68
+ """Cheap literal arg-type tag in the same vocabulary as parameter
69
+ annotations (str/int/bool/list/dict); anything else stays unknown."""
70
+ if isinstance(arg, ast.Constant):
71
+ if isinstance(arg.value, bool):
72
+ return "bool"
73
+ if isinstance(arg.value, str):
74
+ return "str"
75
+ if isinstance(arg.value, (int, float)):
76
+ return "int" if isinstance(arg.value, int) else "float"
77
+ return "unknown"
78
+ if isinstance(arg, ast.JoinedStr):
79
+ return "str"
80
+ if isinstance(arg, ast.List):
81
+ return "list"
82
+ if isinstance(arg, ast.Dict):
83
+ return "dict"
84
+ if isinstance(arg, ast.Tuple):
85
+ return "tuple"
86
+ return "unknown"
87
+
88
+
89
+ def extract_calls_with_sites(body: list[ast.stmt]) -> tuple[list[str], list[dict]]:
90
+ """ONE scope-guarded walk producing both outputs: the legacy deduped call
91
+ name list and structured call sites {name, argCount, argTypes, hasSpread}.
92
+ argCount counts positional args + keyword args; *args/**kwargs set
93
+ hasSpread (runtime arity unknowable). One record per
94
+ (name, argCount, argTypes) triple so same-arity different-type call sites
95
+ both survive (each can hit a different overload sibling)."""
64
96
  calls: list[str] = []
97
+ sites: list[dict] = []
98
+ seen_sites: set[str] = set()
65
99
 
66
100
  def visit(node: ast.AST) -> None:
67
- if isinstance(node, ast.Call):
68
- name = call_name(node.func)
69
- if name:
70
- calls.append(name)
101
+ if not isinstance(node, ast.Call):
102
+ return
103
+ name = call_name(node.func)
104
+ if not name:
105
+ return
106
+ if name not in calls:
107
+ calls.append(name)
108
+
109
+ arg_types: list[str] = []
110
+ has_spread = False
111
+ for a in node.args:
112
+ if isinstance(a, ast.Starred):
113
+ has_spread = True
114
+ arg_types.append("unknown")
115
+ else:
116
+ arg_types.append(_infer_arg_type(a))
117
+ for kw in node.keywords:
118
+ if kw.arg is None: # **kwargs
119
+ has_spread = True
120
+ arg_types.append("unknown")
121
+ else:
122
+ arg_types.append(_infer_arg_type(kw.value))
123
+
124
+ arg_count = len(node.args) + len(node.keywords)
125
+ key = f"{name}/{arg_count}/{','.join(arg_types)}"
126
+ if key in seen_sites:
127
+ return
128
+ seen_sites.add(key)
129
+ site = {"name": name, "argCount": arg_count, "argTypes": arg_types}
130
+ if has_spread:
131
+ site["hasSpread"] = True
132
+ sites.append(site)
71
133
 
72
134
  _walk_scope(body, visit)
73
- return list(dict.fromkeys(calls)) # dedupe, keep first-seen order
135
+ return calls, sites
136
+
137
+
138
+ def _param_records(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict]:
139
+ """Typed parameter records {name, type, isOptional?, isRest?} for arity and
140
+ type-shape matching. vararg/kwarg ride as isRest entries; defaults mark
141
+ isOptional. Annotation text is the type vocabulary used by callSites."""
142
+ records: list[dict] = []
143
+ a = node.args
144
+ positional = a.posonlyargs + a.args
145
+ defaults = a.defaults # aligns with positional[-len(defaults):]
146
+ kw_defaults = a.kw_defaults # aligns with kwonlyargs (None = required)
147
+
148
+ def record(arg: ast.arg, optional: bool, rest: bool) -> None:
149
+ rec: dict = {"name": arg.arg,
150
+ "type": ast.unparse(arg.annotation) if arg.annotation else "unknown"}
151
+ if rest:
152
+ rec["isRest"] = True
153
+ if optional:
154
+ rec["isOptional"] = True
155
+ records.append(rec)
156
+
157
+ offset = len(positional) - len(defaults)
158
+ for i, arg in enumerate(positional):
159
+ record(arg, i >= offset, False)
160
+ # kwonly: kw_defaults aligns 1:1 with kwonlyargs (None = required)
161
+ for i, arg in enumerate(a.kwonlyargs):
162
+ record(arg, kw_defaults[i] is not None, False)
163
+ if a.vararg:
164
+ record(a.vararg, False, True)
165
+ if a.kwarg:
166
+ record(a.kwarg, False, True)
167
+ return records
74
168
 
75
169
 
76
170
  def _match_orm_call(call: ast.Call) -> dict | None:
@@ -140,12 +234,15 @@ def extract_function(node: ast.FunctionDef | ast.AsyncFunctionDef, rel_path: str
140
234
  raw = ast.get_source_segment(source, node) or ""
141
235
  params = [a.arg for a in node.args.posonlyargs + node.args.args + node.args.kwonlyargs]
142
236
  decorators = [ast.unparse(d) for d in node.decorator_list]
237
+ calls, call_sites = extract_calls_with_sites(node.body)
143
238
 
144
239
  # hasErrorHandling/throws use ast.walk deliberately — mirrors the JS
145
240
  # extractor, which scans the whole function subtree for try/raise.
146
241
  metadata = {
147
242
  "params": params,
148
- "calls": extract_calls(node.body),
243
+ "parameters": _param_records(node),
244
+ "calls": calls,
245
+ "callSites": call_sites,
149
246
  "ormOps": extract_orm_ops(node.body),
150
247
  "isAsync": is_async,
151
248
  "hasErrorHandling": any(isinstance(n, (ast.Try, ast.TryStar)) for n in ast.walk(node)),
@@ -0,0 +1,105 @@
1
+ """Overload disambiguation — same-name function-likes in the same file.
2
+
3
+ Node ids are ``rel/path.py::name``, so two same-name defs in one file (Python
4
+ shadowing, dynamic redefinition) produced two nodes with ONE id; downstream
5
+ dedupe silently dropped all but the first and every CALLS edge picked one
6
+ arbitrarily.
7
+
8
+ Convention (mirrors the TypeScript reference, src/parser/overloads.ts — keep
9
+ in sync, see MULTI_LANGUAGE_EXPANSION.md):
10
+ - unique name in file → id unchanged
11
+ - distinct arities in group → ``#arity`` suffix (the def's OWN param count,
12
+ never ordinals)
13
+ - same arity, different types → ``#arity#<sig8>`` where sig8 = first 8 hex
14
+ chars of sha-256 over the signature string
15
+ "(T1,T2)" (rest params prefixed "...",
16
+ optional suffixed "?", untyped "unknown")
17
+ - signature-identical defs collapse to one node (prefer the one with a
18
+ body; Python defs always have bodies so the first by line order wins)
19
+ Groups are per file; the same name in different files or on different classes
20
+ (dotted names) never collides.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+
26
+ _OVERLOADABLE_TYPES = {"FUNCTION", "METHOD"}
27
+
28
+
29
+ def _param_types(node: dict) -> list[str]:
30
+ parameters = node.get("metadata", {}).get("parameters")
31
+ if not isinstance(parameters, list):
32
+ return []
33
+ types: list[str] = []
34
+ for p in parameters:
35
+ t = p.get("type") if isinstance(p, dict) else None
36
+ t = t if isinstance(t, str) and t else "unknown"
37
+ if p.get("isRest"):
38
+ types.append(f"...{t}")
39
+ elif p.get("isOptional"):
40
+ types.append(f"{t}?")
41
+ else:
42
+ types.append(t)
43
+ return types
44
+
45
+
46
+ def _signature_key(node: dict) -> str:
47
+ return f"({','.join(_param_types(node))})"
48
+
49
+
50
+ def _arity(node: dict) -> int:
51
+ params = node.get("metadata", {}).get("params")
52
+ return len(params) if isinstance(params, list) else 0
53
+
54
+
55
+ def _has_body(node: dict) -> bool:
56
+ return node.get("metadata", {}).get("isOverloadSignature") is not True
57
+
58
+
59
+ def apply_overload_disambiguation(nodes: list[dict], rel_path: str) -> None:
60
+ """Suffix colliding ids in place; mark signature-identical duplicates with
61
+ metadata.isDuplicateOverload for the caller to drop."""
62
+ groups: dict[str, list[dict]] = {}
63
+ for node in nodes:
64
+ if node.get("type") in _OVERLOADABLE_TYPES:
65
+ groups.setdefault(node["name"], []).append(node)
66
+
67
+ for name, group in groups.items():
68
+ if len(group) < 2:
69
+ continue
70
+ group.sort(key=lambda n: n["startLine"])
71
+
72
+ # Collapse signature-identical defs, preferring the one with a body.
73
+ by_signature: dict[str, dict] = {}
74
+ keys: dict[int, str] = {}
75
+ for node in group:
76
+ key = _signature_key(node)
77
+ keys[id(node)] = key
78
+ existing = by_signature.get(key)
79
+ if existing is None:
80
+ by_signature[key] = node
81
+ elif not _has_body(existing) and _has_body(node):
82
+ by_signature[key] = node
83
+ survivors = sorted(by_signature.values(), key=lambda n: n["startLine"])
84
+ if len(survivors) < len(group):
85
+ kept = {id(n) for n in survivors}
86
+ for node in group:
87
+ if id(node) not in kept:
88
+ node["metadata"]["isDuplicateOverload"] = True
89
+
90
+ # Single survivor after collapse → the name is unique again, no suffix.
91
+ if len(survivors) < 2:
92
+ continue
93
+
94
+ # Distinct arities within the group → readable #arity suffix.
95
+ arities = {_arity(n) for n in survivors}
96
+ use_arity_only = len(arities) == len(survivors)
97
+
98
+ for node in survivors:
99
+ arity = _arity(node)
100
+ if use_arity_only:
101
+ suffix = f"#{arity}"
102
+ else:
103
+ sig8 = hashlib.sha256(keys[id(node)].encode("utf-8")).hexdigest()[:8]
104
+ suffix = f"#{arity}#{sig8}"
105
+ node["id"] = f"{rel_path}::{name}{suffix}"
@@ -18,7 +18,7 @@
18
18
  // metadata.calls already carries the raw strings (parser); resolved targets
19
19
  // are written back as metadata.resolvedCalls.
20
20
 
21
- use crate::contract::{edge, CodeEdge};
21
+ use crate::contract::{edge_with_meta, CodeEdge};
22
22
  use crate::extractor::{Options, ParsedRepo};
23
23
  use crate::fingerprint::ParsedManifest;
24
24
  use crate::lookup::LookupMaps;
@@ -54,9 +54,10 @@ pub fn detect_calls(
54
54
  }
55
55
  let base_module = crate::walker::module_path_for_file(&pf.rel_path);
56
56
  let mut resolved: Vec<String> = vec![];
57
- for call in &item.calls {
58
- if let Some(target) = resolve_call(
59
- call,
57
+ for site in &item.call_sites {
58
+ if let Some((target, matched_by)) = resolve_call(
59
+ &site.name,
60
+ site.arg_count,
60
61
  item,
61
62
  &pf.rel_path,
62
63
  &base_module,
@@ -65,7 +66,13 @@ pub fn detect_calls(
65
66
  mf,
66
67
  tp,
67
68
  ) {
68
- edges.push(edge(&from, &target, "CALLS"));
69
+ let mut meta = serde_json::Map::new();
70
+ meta.insert(
71
+ "calledName".to_string(),
72
+ serde_json::Value::from(site.name.clone()),
73
+ );
74
+ meta.insert("matchedBy".to_string(), serde_json::Value::from(matched_by));
75
+ edges.push(edge_with_meta(&from, &target, "CALLS", meta));
69
76
  resolved.push(target);
70
77
  }
71
78
  }
@@ -91,8 +98,10 @@ pub fn detect_calls(
91
98
 
92
99
  /// Resolve one call string → target node id (or None).
93
100
  #[allow(clippy::too_many_arguments)]
101
+ #[allow(clippy::too_many_arguments)]
94
102
  fn resolve_call(
95
103
  call: &str,
104
+ arg_count: usize,
96
105
  item: &ParsedItem,
97
106
  rel_path: &str,
98
107
  base_module: &str,
@@ -100,7 +109,7 @@ fn resolve_call(
100
109
  lookup: &LookupMaps,
101
110
  mf: &ParsedManifest,
102
111
  tp: &mut ThirdPartyRegistry,
103
- ) -> Option<String> {
112
+ ) -> Option<(String, &'static str)> {
104
113
  // `x.method()` — receiver form
105
114
  if let Some((recv, method)) = call.split_once('.') {
106
115
  if recv == "self" {
@@ -109,12 +118,12 @@ fn resolve_call(
109
118
  if owner.is_empty() {
110
119
  return None;
111
120
  }
112
- return method_of_owner(owner, method, rel_path, lookup);
121
+ return method_of_owner(owner, method, rel_path, lookup).map(|t| (t, "name"));
113
122
  }
114
123
  // other receivers: no type resolution in V1 — metadata only
115
124
  return None;
116
125
  }
117
- resolve_path_call(call, rel_path, base_module, module_map, lookup, mf, tp)
126
+ resolve_path_call(call, arg_count, rel_path, base_module, module_map, lookup, mf, tp)
118
127
  }
119
128
 
120
129
  /// Resolve a path-form call (`a::b::c`, `Type::m`, plain `bar`) to a node id.
@@ -126,21 +135,23 @@ fn resolve_call(
126
135
  /// crate → lazy [crate]/name::member (gated).
127
136
  pub fn resolve_path_call(
128
137
  call: &str,
138
+ arg_count: usize,
129
139
  rel_path: &str,
130
140
  base_module: &str,
131
141
  module_map: &ModuleMap,
132
142
  lookup: &LookupMaps,
133
143
  mf: &ParsedManifest,
134
144
  tp: &mut ThirdPartyRegistry,
135
- ) -> Option<String> {
145
+ ) -> Option<(String, &'static str)> {
136
146
  let segs: Vec<&str> = call.split("::").collect();
137
147
  if segs.is_empty() {
138
148
  return None;
139
149
  }
140
150
  if segs.len() == 1 {
141
- // plain name → same-file FUNCTION, then crate-wide
142
- if let Some(id) = lookup.closest_by_path(segs[0], rel_path) {
143
- return Some(id);
151
+ // plain name → same-file FUNCTION, then crate-wide; arity filters
152
+ // cross-file same-name candidates before path proximity decides
153
+ if let Some(res) = lookup.closest_by_path_arity(segs[0], rel_path, arg_count) {
154
+ return Some(res);
144
155
  }
145
156
  return None;
146
157
  }
@@ -167,7 +178,7 @@ pub fn resolve_path_call(
167
178
  // handlers actually live (brought into scope via `use crate::controllers::*;`).
168
179
  if let Some((file, rest)) = module_map.resolve(&full_path, base_module) {
169
180
  if let Some(id) = resolve_in_file(&file, &rest, rel_path, lookup) {
170
- return Some(id);
181
+ return Some((id, "name"));
171
182
  }
172
183
  // don't return None — fall through to glob unroll + crate-root
173
184
  }
@@ -181,7 +192,7 @@ pub fn resolve_path_call(
181
192
  let cand = format!("{}::{}", g, full_path);
182
193
  if let Some((file, rest)) = module_map.resolve(&cand, base_module) {
183
194
  if let Some(id) = resolve_in_file(&file, &rest, rel_path, lookup) {
184
- return Some(id);
195
+ return Some((id, "name"));
185
196
  }
186
197
  }
187
198
  }
@@ -196,7 +207,7 @@ pub fn resolve_path_call(
196
207
  // misresolve against the caller's own module.
197
208
  if let Some((file, rest)) = module_map.resolve_use(&full_path, base_module) {
198
209
  if let Some(id) = resolve_in_file(&file, &rest, rel_path, lookup) {
199
- return Some(id);
210
+ return Some((id, "name"));
200
211
  }
201
212
  }
202
213
 
@@ -207,7 +218,7 @@ pub fn resolve_path_call(
207
218
  }
208
219
  if crate::thirdparty::is_external_crate(first, mf) {
209
220
  let member = full_path.split("::").skip(1).collect::<Vec<_>>().join("::");
210
- return tp.member_node(first, &member).map(|n| n.id.clone());
221
+ return tp.member_node(first, &member).map(|n| (n.id.clone(), "name"));
211
222
  }
212
223
  None
213
224
  }
@@ -95,6 +95,8 @@ pub struct ExtractorResult {
95
95
  pub nodes: Vec<CodeNode>,
96
96
  pub edges: Vec<CodeEdge>,
97
97
  pub routes: Vec<RouteNode>,
98
+ #[serde(skip_serializing_if = "Option::is_none")]
99
+ pub exports: Option<crate::exports::ExportsMap>,
98
100
  pub stats: Stats,
99
101
  pub errors: Vec<ExtractorError>,
100
102
  }