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
@@ -0,0 +1,387 @@
1
+ // exports.rs — the flattened public surface of a Rust crate, shipped in the
2
+ // ExtractorResult for cross-repo linking (docs/EXPORTS_MAP.md).
3
+ //
4
+ // Rust-specific concerns:
5
+ // - The "door" is the crate root (src/lib.rs or src/main.rs). The map key
6
+ // is the path an importer writes after the crate name:
7
+ // pub fn run() → "run"
8
+ // pub mod foo + pub items → "foo" (file node) and "foo::Item"
9
+ // pub use a::B as C; → "C" (binding-resolved, name-swap safe)
10
+ // pub use foo::*; → foo's pub items at the same level
11
+ // pub methods of pub types → "Type.method"
12
+ // - `pub use` chains are flattened recursively through mod.rs files with a
13
+ // visited guard — consumers of the map never walk re-export chains.
14
+ // - Private mods (`mod foo;`) contribute nothing; only `pub mod` opens a
15
+ // subpath. Inline `pub mod foo { ... }` bodies are out of scope for v1
16
+ // (their items are parsed inside the same file's mod tree, not top-level).
17
+ // - Values are ALWAYS arrays of proper nodeIds matching nodes.rs exactly
18
+ // (func/struct/enum/trait: rel::name; inherent methods: rel::Type.method;
19
+ // trait-impl methods are NOT surface — the trait defines them).
20
+ // - Rust rejects duplicate re-exports at compile time, so ambiguousNames is
21
+ // a safety net that should stay empty.
22
+ // - Pure-test files and #[test] items are excluded.
23
+ // - Workspace members other than the root crate are out of scope for v1.
24
+
25
+ use crate::module_map::ModuleMap;
26
+ use crate::parser::{ParsedFile, ParsedItem, KIND_ENUM, KIND_FUNCTION, KIND_METHOD, KIND_STRUCT, KIND_TRAIT};
27
+ use serde::Serialize;
28
+ use std::collections::{BTreeMap, BTreeSet, HashMap};
29
+
30
+ #[derive(Serialize, Clone)]
31
+ #[serde(rename_all = "camelCase")]
32
+ pub struct ExportsMap {
33
+ pub exports: BTreeMap<String, BTreeMap<String, Vec<String>>>,
34
+ pub ambiguous_names: BTreeMap<String, Vec<String>>,
35
+ }
36
+
37
+ pub fn build_exports_map(files: &[ParsedFile], module_map: &ModuleMap) -> Option<ExportsMap> {
38
+ let root = files
39
+ .iter()
40
+ .find(|f| f.rel_path == "src/lib.rs")
41
+ .or_else(|| files.iter().find(|f| f.rel_path == "src/main.rs"))?;
42
+
43
+ let index = ItemIndex::build(files);
44
+ let mut ctx = Ctx {
45
+ files,
46
+ module_map,
47
+ index,
48
+ by_rel: files.iter().map(|f| (f.rel_path.clone(), f)).collect(),
49
+ };
50
+
51
+ let mut exports: BTreeMap<String, Vec<String>> = BTreeMap::new();
52
+ let mut ambiguous: Vec<String> = Vec::new();
53
+ let mut visited = BTreeSet::new();
54
+ collect_surface(&ctx, root, "", &mut exports, &mut ambiguous, &mut visited);
55
+
56
+ if exports.is_empty() {
57
+ return None;
58
+ }
59
+ Some(ExportsMap {
60
+ exports: BTreeMap::from([(".".to_string(), exports)]),
61
+ ambiguous_names: if ambiguous.is_empty() {
62
+ BTreeMap::new()
63
+ } else {
64
+ BTreeMap::from([(".".to_string(), ambiguous)])
65
+ },
66
+ })
67
+ }
68
+
69
+ struct Ctx<'a> {
70
+ files: &'a [ParsedFile],
71
+ module_map: &'a ModuleMap,
72
+ index: ItemIndex,
73
+ by_rel: HashMap<String, &'a ParsedFile>,
74
+ }
75
+
76
+ /// (rel_path, item_name) → nodeIds, reconstructed with the SAME id helpers
77
+ /// nodes.rs uses, so map values are guaranteed joinable.
78
+ struct ItemIndex {
79
+ by_file: HashMap<String, HashMap<String, Vec<String>>>,
80
+ pub_types: BTreeSet<String>,
81
+ }
82
+
83
+ impl ItemIndex {
84
+ fn build(files: &[ParsedFile]) -> Self {
85
+ let mut by_file: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
86
+ let mut pub_types = BTreeSet::new();
87
+ for pf in files {
88
+ if pf.is_pure_test_file {
89
+ continue;
90
+ }
91
+ let mut names: HashMap<String, Vec<String>> = HashMap::new();
92
+ for item in &pf.items {
93
+ if item.is_test {
94
+ continue;
95
+ }
96
+ if let Some(id) = surface_node_id(pf, item) {
97
+ match item.kind {
98
+ KIND_STRUCT | KIND_ENUM | KIND_TRAIT => {
99
+ if item.is_pub {
100
+ pub_types.insert(item.name.clone());
101
+ }
102
+ }
103
+ _ => {}
104
+ }
105
+ names.entry(surface_key(item)).or_default().push(id);
106
+ }
107
+ }
108
+ by_file.insert(pf.rel_path.clone(), names);
109
+ }
110
+ ItemIndex { by_file, pub_types }
111
+ }
112
+
113
+ fn get(&self, rel: &str, key: &str) -> Option<Vec<String>> {
114
+ self.by_file.get(rel)?.get(key).cloned()
115
+ }
116
+ }
117
+
118
+ fn surface_key(item: &ParsedItem) -> String {
119
+ if item.kind == KIND_METHOD && !item.impl_target.is_empty() {
120
+ format!("{}.{}", item.impl_target, item.name)
121
+ } else {
122
+ item.name.clone()
123
+ }
124
+ }
125
+
126
+ /// nodeIds only for kinds that are import surface (impl blocks etc. excluded).
127
+ fn surface_node_id(pf: &ParsedFile, item: &ParsedItem) -> Option<String> {
128
+ match item.kind {
129
+ KIND_FUNCTION => Some(crate::lookup::func_node_id(&pf.rel_path, &item.name)),
130
+ KIND_METHOD if item.impl_trait_path.is_none() && !item.impl_target.is_empty() => {
131
+ Some(crate::lookup::method_node_id(
132
+ &pf.rel_path,
133
+ &item.impl_target,
134
+ &item.name,
135
+ None,
136
+ ))
137
+ }
138
+ KIND_STRUCT => Some(crate::lookup::struct_node_id(&pf.rel_path, &item.name)),
139
+ KIND_ENUM => Some(crate::lookup::enum_node_id(&pf.rel_path, &item.name)),
140
+ KIND_TRAIT => Some(crate::lookup::trait_node_id(&pf.rel_path, &item.name)),
141
+ _ => None,
142
+ }
143
+ }
144
+
145
+ fn collect_surface(
146
+ ctx: &Ctx,
147
+ file: &ParsedFile,
148
+ prefix: &str,
149
+ exports: &mut BTreeMap<String, Vec<String>>,
150
+ ambiguous: &mut Vec<String>,
151
+ visited: &mut BTreeSet<String>,
152
+ ) {
153
+ let visit_key = format!("{}@{}", file.rel_path, prefix);
154
+ if !visited.insert(visit_key) {
155
+ return;
156
+ }
157
+
158
+ let base_module = crate::walker::module_path_for_file(&file.rel_path);
159
+ let top_level = top_level_item_keys(file);
160
+
161
+ for item in &file.items {
162
+ if !item.is_pub || item.is_test {
163
+ continue;
164
+ }
165
+ match item.kind {
166
+ KIND_FUNCTION | KIND_STRUCT | KIND_ENUM | KIND_TRAIT => {
167
+ // parser flattens inline-mod contents into file.items; only
168
+ // DIRECT children of the file are crate surface (a `pub`
169
+ // struct inside a PRIVATE `mod` is not importable).
170
+ if !top_level.contains(&(item.kind.to_string(), item.name.clone())) {
171
+ continue;
172
+ }
173
+ if let Some(ids) = ctx.index.get(&file.rel_path, &surface_key(item)) {
174
+ record(exports, ambiguous, &format!("{}{}", prefix, item.name), ids);
175
+ }
176
+ }
177
+ KIND_METHOD => {
178
+ if item.impl_trait_path.is_none()
179
+ && !item.impl_target.is_empty()
180
+ && ctx.index.pub_types.contains(&item.impl_target)
181
+ {
182
+ if let Some(ids) = ctx.index.get(&file.rel_path, &surface_key(item)) {
183
+ record(
184
+ exports,
185
+ ambiguous,
186
+ &format!("{}{}", prefix, surface_key(item)),
187
+ ids,
188
+ );
189
+ }
190
+ }
191
+ }
192
+ _ => {}
193
+ }
194
+ }
195
+
196
+ for u in &file.uses {
197
+ if u.glob {
198
+ if let Some((target_file, _rest)) = ctx.module_map.resolve_use(&u.target, &base_module) {
199
+ if let Some(pf) = ctx.by_rel.get(&target_file) {
200
+ if pf.rel_path != file.rel_path {
201
+ collect_surface(ctx, pf, prefix, exports, ambiguous, visited);
202
+ }
203
+ }
204
+ }
205
+ continue;
206
+ }
207
+ if let Some(ids) = resolve_use_target(ctx, file, &base_module, &u.target, &mut BTreeSet::new(), visited) {
208
+ record(exports, ambiguous, &format!("{}{}", prefix, u.alias), ids);
209
+ }
210
+ }
211
+
212
+ for m in pub_mods(file) {
213
+ let module = if base_module.is_empty() {
214
+ m.clone()
215
+ } else {
216
+ format!("{}::{}", base_module, m)
217
+ };
218
+ if let Some(pf) = ctx
219
+ .module_map
220
+ .resolve_use(&module, "")
221
+ .and_then(|(f, _)| ctx.by_rel.get(&f).copied())
222
+ {
223
+ record(exports, ambiguous, &format!("{}{}", prefix, m), vec![format!("file::{}", pf.rel_path)]);
224
+ collect_surface(ctx, pf, &format!("{}{}::", prefix, m), exports, ambiguous, visited);
225
+ }
226
+ }
227
+ }
228
+
229
+ /// Resolve one `use` target path to definition nodeIds, following bindings
230
+ /// through mod.rs files. Binding-based — never string-matched against names
231
+ /// the target file happens to reuse.
232
+ fn resolve_use_target(
233
+ ctx: &Ctx,
234
+ from_file: &ParsedFile,
235
+ base_module: &str,
236
+ target: &str,
237
+ seen: &mut BTreeSet<(String, String)>,
238
+ visited: &mut BTreeSet<String>,
239
+ ) -> Option<Vec<String>> {
240
+ let key = (from_file.rel_path.clone(), target.to_string());
241
+ if !seen.insert(key) {
242
+ return None;
243
+ }
244
+
245
+ let (file, rest) = ctx.module_map.resolve_use(target, base_module)?;
246
+ let pf = ctx.by_rel.get(&file)?;
247
+
248
+ if rest.is_empty() {
249
+ let mut ids = vec![format!("file::{}", pf.rel_path)];
250
+ if pf.rel_path != from_file.rel_path {
251
+ collect_surface_into(ctx, pf, visited, &mut ids);
252
+ }
253
+ return Some(ids);
254
+ }
255
+
256
+ let segs: Vec<&str> = rest.split("::").collect();
257
+ let name = segs[segs.len() - 1];
258
+
259
+ if let Some(ids) = ctx.index.get(&pf.rel_path, name) {
260
+ return Some(ids);
261
+ }
262
+
263
+ let module_of_file = crate::walker::module_path_for_file(&pf.rel_path);
264
+
265
+ // re-export chains: the target file may bind the name via its own `use`
266
+ // (incl. `pub use` re-exports) — follow the binding, never string-match.
267
+ if let Some(first) = segs.first() {
268
+ let rest_after: String = segs[1..].join("::");
269
+ for u in &pf.uses {
270
+ if u.glob || u.alias != *first {
271
+ continue;
272
+ }
273
+ if rest_after.is_empty() {
274
+ return resolve_use_target(ctx, pf, &module_of_file, &u.target, seen, visited);
275
+ }
276
+ if let Some((bf, brest)) = ctx.module_map.resolve_use(&u.target, &module_of_file) {
277
+ let bpf = ctx.by_rel.get(&bf)?;
278
+ let combined = if brest.is_empty() {
279
+ rest_after.clone()
280
+ } else {
281
+ format!("{}::{}", brest, rest_after)
282
+ };
283
+ return resolve_use_target(
284
+ ctx,
285
+ bpf,
286
+ &crate::walker::module_path_for_file(&bf),
287
+ &combined,
288
+ seen,
289
+ visited,
290
+ );
291
+ }
292
+ }
293
+ }
294
+ let mut module = module_of_file.clone();
295
+ for seg in &segs {
296
+ module = if module.is_empty() {
297
+ seg.to_string()
298
+ } else {
299
+ format!("{}::{}", module, seg)
300
+ };
301
+ if let Some((sub_file, sub_rest)) = ctx.module_map.resolve_use(&module, "") {
302
+ if sub_rest.is_empty() {
303
+ let sub_pf = ctx.by_rel.get(&sub_file)?;
304
+ if segs.len() == 1 || *seg == name {
305
+ return Some(vec![format!("file::{}", sub_pf.rel_path)]);
306
+ }
307
+ let remaining = segs[segs.iter().position(|s| *s == *seg).unwrap() + 1..].join("::");
308
+ if remaining.is_empty() {
309
+ return Some(vec![format!("file::{}", sub_pf.rel_path)]);
310
+ }
311
+ return resolve_use_target(ctx, sub_pf, &module_of_file, &remaining, seen, visited);
312
+ }
313
+ }
314
+ }
315
+
316
+ None
317
+ }
318
+
319
+ fn collect_surface_into(ctx: &Ctx, pf: &ParsedFile, visited: &mut BTreeSet<String>, ids: &mut Vec<String>) {
320
+ let base_module = crate::walker::module_path_for_file(&pf.rel_path);
321
+ for item in &pf.items {
322
+ if item.is_pub && !item.is_test {
323
+ if let Some(id) = surface_node_id(pf, item) {
324
+ ids.push(id);
325
+ }
326
+ }
327
+ }
328
+ for u in &pf.uses {
329
+ if u.glob {
330
+ continue;
331
+ }
332
+ if let Some(resolved) = resolve_use_target(ctx, pf, &base_module, &u.target, &mut BTreeSet::new(), visited) {
333
+ ids.extend(resolved);
334
+ }
335
+ }
336
+ }
337
+
338
+ fn top_level_item_keys(file: &ParsedFile) -> std::collections::HashSet<(String, String)> {
339
+ let mut out = std::collections::HashSet::new();
340
+ for item in &file.ast.items {
341
+ let key = match item {
342
+ syn::Item::Fn(f) => ("FUNCTION".to_string(), f.sig.ident.to_string()),
343
+ syn::Item::Struct(s) => ("STRUCT".to_string(), s.ident.to_string()),
344
+ syn::Item::Enum(e) => ("ENUM".to_string(), e.ident.to_string()),
345
+ syn::Item::Trait(t) => ("TRAIT".to_string(), t.ident.to_string()),
346
+ _ => continue,
347
+ };
348
+ out.insert(key);
349
+ }
350
+ out
351
+ }
352
+
353
+ fn pub_mods(file: &ParsedFile) -> Vec<String> {
354
+ let mut out = Vec::new();
355
+ for item in &file.ast.items {
356
+ if let syn::Item::Mod(m) = item {
357
+ if matches!(m.vis, syn::Visibility::Public(_)) && m.content.is_none() {
358
+ out.push(m.ident.to_string());
359
+ }
360
+ }
361
+ }
362
+ out
363
+ }
364
+
365
+ fn record(
366
+ exports: &mut BTreeMap<String, Vec<String>>,
367
+ ambiguous: &mut Vec<String>,
368
+ key: &str,
369
+ ids: Vec<String>,
370
+ ) {
371
+ let unique: Vec<String> = {
372
+ let mut s: Vec<String> = ids;
373
+ s.sort();
374
+ s.dedup();
375
+ s
376
+ };
377
+ if unique.is_empty() {
378
+ return;
379
+ }
380
+ if let Some(existing) = exports.get(key) {
381
+ if existing != &unique {
382
+ ambiguous.push(key.to_string());
383
+ }
384
+ return;
385
+ }
386
+ exports.insert(key.to_string(), unique);
387
+ }
@@ -0,0 +1,105 @@
1
+ use crate::contract::CodeNode;
2
+ use crate::exports::build_exports_map;
3
+ use crate::module_map::ModuleMap;
4
+ use crate::parser;
5
+
6
+ fn parse_fixture() -> (Vec<parser::ParsedFile>, ModuleMap) {
7
+ let repo = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/exportsfix");
8
+ let mut files = vec![];
9
+ for rel in crate::walker::collect_rs_files(repo) {
10
+ let abs = format!("{}/{}", repo, rel);
11
+ if let Ok(source) = std::fs::read_to_string(&abs) {
12
+ if let Ok(pf) = parser::parse_file(&rel, &source) {
13
+ files.push(pf);
14
+ }
15
+ }
16
+ }
17
+ let map = ModuleMap::build(&files);
18
+ (files, map)
19
+ }
20
+
21
+ #[test]
22
+ fn root_surface_maps_pub_items_to_proper_node_ids() {
23
+ let (files, mm) = parse_fixture();
24
+ let m = build_exports_map(&files, &mm).expect("map expected");
25
+ let root = m.exports.get(".").expect("root subpath");
26
+
27
+ assert_eq!(root.get("run"), Some(&vec!["src/lib.rs::run".to_string()]));
28
+ assert_eq!(root.get("Engine"), Some(&vec!["src/lib.rs::Engine".to_string()]));
29
+ assert_eq!(root.get("Mode"), Some(&vec!["src/lib.rs::Mode".to_string()]));
30
+ assert_eq!(root.get("Storage"), Some(&vec!["src/lib.rs::Storage".to_string()]));
31
+ assert_eq!(root.get("Engine.start"), Some(&vec!["src/lib.rs::Engine.start".to_string()]));
32
+ assert_eq!(root.get("private_helper"), None);
33
+ assert_eq!(root.get("Hidden"), None);
34
+ }
35
+
36
+ #[test]
37
+ fn pub_mod_opens_a_prefixed_surface() {
38
+ let (files, mm) = parse_fixture();
39
+ let m = build_exports_map(&files, &mm).expect("map expected");
40
+ let root = m.exports.get(".").expect("root subpath");
41
+
42
+ assert_eq!(root.get("models"), Some(&vec!["file::src/models/mod.rs".to_string()]));
43
+ assert_eq!(root.get("models::user"), Some(&vec!["file::src/models/user.rs".to_string()]));
44
+ assert_eq!(root.get("models::user::PublicUser"), Some(&vec!["src/models/user.rs::PublicUser".to_string()]));
45
+ assert_eq!(root.get("models::user::Role"), Some(&vec!["src/models/user.rs::Role".to_string()]));
46
+ assert_eq!(root.get("models::user::PublicUser.new"), Some(&vec!["src/models/user.rs::PublicUser.new".to_string()]));
47
+ assert_eq!(root.get("models::user::PrivateThing"), None);
48
+ assert_eq!(root.get("models::internal::InternalDetail"), None);
49
+ }
50
+
51
+ #[test]
52
+ fn pub_use_aliases_resolve_through_bindings_name_swap_safe() {
53
+ let (files, mm) = parse_fixture();
54
+ let m = build_exports_map(&files, &mm).expect("map expected");
55
+ let root = m.exports.get(".").expect("root subpath");
56
+
57
+ assert_eq!(root.get("VisibleUser"), Some(&vec!["src/models/user.rs::PublicUser".to_string()]));
58
+ // lib.rs re-exports aliasing's names SWAPPED: lib's SwappedA = aliasing's
59
+ // SwappedB = Role; lib's SwappedB = aliasing's SwappedA = PublicUser.
60
+ assert_eq!(root.get("SwappedA"), Some(&vec!["src/models/user.rs::Role".to_string()]));
61
+ assert_eq!(root.get("SwappedB"), Some(&vec!["src/models/user.rs::PublicUser".to_string()]));
62
+ }
63
+
64
+ #[test]
65
+ fn glob_reexport_brings_target_pub_items_to_same_level() {
66
+ let (files, mm) = parse_fixture();
67
+ let m = build_exports_map(&files, &mm).expect("map expected");
68
+ let root = m.exports.get(".").expect("root subpath");
69
+
70
+ assert_eq!(root.get("aliasing::Extra"), Some(&vec!["src/aliasing/extra.rs::Extra".to_string()]));
71
+ }
72
+
73
+ #[test]
74
+ fn ambiguous_names_stay_empty_and_shape_is_uniform() {
75
+ let (files, mm) = parse_fixture();
76
+ let m = build_exports_map(&files, &mm).expect("map expected");
77
+ assert!(m.ambiguous_names.is_empty());
78
+ for (subpath, names) in &m.exports {
79
+ assert_eq!(subpath, ".");
80
+ for (name, ids) in names {
81
+ assert!(!ids.is_empty(), "{} has empty ids", name);
82
+ }
83
+ }
84
+ }
85
+
86
+ #[test]
87
+ fn map_values_all_exist_in_nodes_array() {
88
+ let (files, mm) = parse_fixture();
89
+ let m = build_exports_map(&files, &mm).expect("map expected");
90
+ let mut node_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
91
+ let nodes: Vec<CodeNode> = crate::nodes::collect_code_nodes(&files)
92
+ .into_iter()
93
+ .chain(crate::nodes::collect_file_nodes(&files))
94
+ .collect();
95
+ for n in &nodes {
96
+ node_ids.insert(n.id.clone());
97
+ }
98
+ for names in m.exports.values() {
99
+ for ids in names.values() {
100
+ for id in ids {
101
+ assert!(node_ids.contains(id), "dangling map value {}", id);
102
+ }
103
+ }
104
+ }
105
+ }
@@ -167,11 +167,13 @@ pub fn run(repo_path: &str, opts: &Options) -> ExtractorResult {
167
167
  all_nodes.sort_by(|a, b| a.id.cmp(&b.id));
168
168
 
169
169
  let total_nodes = all_nodes.len() as i64;
170
+ let exports = crate::exports::build_exports_map(&parsed.files, &module_map);
170
171
  ExtractorResult {
171
172
  fingerprint,
172
173
  nodes: all_nodes,
173
174
  edges,
174
175
  routes,
176
+ exports,
175
177
  stats: Stats {
176
178
  total_files: parsed.files.len() as i64,
177
179
  total_nodes,
@@ -69,6 +69,66 @@ impl LookupMaps {
69
69
  .cloned()
70
70
  .or_else(|| ids.first().cloned())
71
71
  }
72
+
73
+ /// Arity-aware plain-name resolution: when several same-name candidates
74
+ /// exist, prefer those whose declared params accept `arg_count` before
75
+ /// falling back to path proximity. Returns (node_id, matched_by).
76
+ /// Rust has no overloading, so this only disambiguates cross-file
77
+ /// same-name functions; arg_count == 0 skips the filter entirely.
78
+ pub fn closest_by_path_arity(
79
+ &self,
80
+ name: &str,
81
+ rel_path: &str,
82
+ arg_count: usize,
83
+ ) -> Option<(String, &'static str)> {
84
+ let ids = self.nodes_by_name.get(name)?;
85
+ if ids.is_empty() {
86
+ return None;
87
+ }
88
+ if ids.len() == 1 {
89
+ return Some((ids[0].clone(), "name"));
90
+ }
91
+ if arg_count > 0 {
92
+ let compatible: Vec<&String> = ids
93
+ .iter()
94
+ .filter(|id| {
95
+ self.node_by_id
96
+ .get(*id)
97
+ .map(|n| params_accept(&n.metadata, arg_count))
98
+ .unwrap_or(true)
99
+ })
100
+ .collect();
101
+ if !compatible.is_empty() && compatible.len() < ids.len() {
102
+ let picked = compatible
103
+ .iter()
104
+ .find(|id| id.starts_with(&format!("{}::", rel_path)))
105
+ .map(|s| (*s).clone())
106
+ .or_else(|| compatible.first().map(|s| (*s).clone()));
107
+ if let Some(id) = picked {
108
+ return Some((id, "arity"));
109
+ }
110
+ }
111
+ }
112
+ let id = ids
113
+ .iter()
114
+ .find(|id| id.starts_with(&format!("{}::", rel_path)))
115
+ .cloned()
116
+ .or_else(|| ids.first().cloned())?;
117
+ Some((id, "name"))
118
+ }
119
+ }
120
+
121
+ /// True when a node's declared params (metadata.params type strings) accept a
122
+ /// call with `arg_count` arguments. Nodes without params metadata are
123
+ /// permissive.
124
+ fn params_accept(metadata: &serde_json::Map<String, serde_json::Value>, arg_count: usize) -> bool {
125
+ let Some(serde_json::Value::Array(params)) = metadata.get("params") else {
126
+ return true;
127
+ };
128
+ if params.is_empty() {
129
+ return arg_count == 0;
130
+ }
131
+ params.len() == arg_count
72
132
  }
73
133
 
74
134
  // ── node id schemes (deterministic, file-scoped — mirrors go) ──
@@ -5,7 +5,11 @@
5
5
  mod calls;
6
6
  mod contract;
7
7
  mod enrich;
8
+ mod exports;
8
9
  mod extractor;
10
+
11
+ #[cfg(test)]
12
+ mod exports_test;
9
13
  mod fingerprint;
10
14
  mod imports;
11
15
  mod inheritance;
@@ -190,5 +190,24 @@ fn item_node(pf: &ParsedFile, item: &ParsedItem) -> CodeNode {
190
190
  let calls: Vec<Value> = item.calls.iter().map(|c| Value::from(c.clone())).collect();
191
191
  n.metadata.insert("calls".to_string(), Value::Array(calls));
192
192
  }
193
+ if !item.params.is_empty() {
194
+ let params: Vec<Value> = item.params.iter().map(|p| Value::from(p.clone())).collect();
195
+ n.metadata.insert("params".to_string(), Value::Array(params));
196
+ }
197
+ if !item.call_sites.is_empty() {
198
+ let sites: Vec<Value> = item
199
+ .call_sites
200
+ .iter()
201
+ .map(|cs| {
202
+ let mut m = serde_json::Map::new();
203
+ m.insert("name".to_string(), Value::from(cs.name.clone()));
204
+ m.insert("argCount".to_string(), Value::from(cs.arg_count as u64));
205
+ let types: Vec<Value> = cs.arg_types.iter().map(|t| Value::from(t.clone())).collect();
206
+ m.insert("argTypes".to_string(), Value::Array(types));
207
+ Value::Object(m)
208
+ })
209
+ .collect();
210
+ n.metadata.insert("callSites".to_string(), Value::Array(sites));
211
+ }
193
212
  n
194
213
  }