devlensio 0.6.2 → 1.0.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 (129) hide show
  1. package/README.md +89 -31
  2. package/dist/extractors/detectLanguage.d.ts +2 -0
  3. package/dist/extractors/detectLanguage.js +33 -0
  4. package/dist/extractors/index.d.ts +6 -0
  5. package/dist/extractors/index.js +116 -0
  6. package/dist/extractors/runner.d.ts +7 -0
  7. package/dist/extractors/runner.js +194 -0
  8. package/dist/extractors/types.d.ts +37 -0
  9. package/dist/extractors/types.js +8 -0
  10. package/dist/graph/buildLookup.d.ts +1 -0
  11. package/dist/graph/buildLookup.js +2 -1
  12. package/dist/graph/edges/callEdges.js +19 -5
  13. package/dist/graph/edges/callEdges.test.d.ts +1 -0
  14. package/dist/graph/edges/callEdges.test.js +200 -0
  15. package/dist/graph/edges/importEdges.js +12 -0
  16. package/dist/graph/edges/inheritanceEdges.d.ts +3 -0
  17. package/dist/graph/edges/inheritanceEdges.js +73 -0
  18. package/dist/graph/edges/inheritanceEdges.test.d.ts +1 -0
  19. package/dist/graph/edges/inheritanceEdges.test.js +140 -0
  20. package/dist/graph/index.js +4 -0
  21. package/dist/parser/classes.test.d.ts +1 -0
  22. package/dist/parser/classes.test.js +360 -0
  23. package/dist/parser/extractors/classes.d.ts +5 -0
  24. package/dist/parser/extractors/classes.js +241 -0
  25. package/dist/parser/index.d.ts +2 -0
  26. package/dist/parser/index.js +7 -1
  27. package/dist/pipeline/index.d.ts +2 -1
  28. package/dist/pipeline/index.js +30 -42
  29. package/dist/scoring/index.js +8 -0
  30. package/dist/scoring/index.test.js +43 -0
  31. package/dist/scoring/nodeScorer.js +2 -0
  32. package/dist/scoring/pruneDisconnected.d.ts +8 -0
  33. package/dist/scoring/pruneDisconnected.js +66 -0
  34. package/dist/scoring/pruneDisconnected.test.d.ts +1 -0
  35. package/dist/scoring/pruneDisconnected.test.js +123 -0
  36. package/dist/summarizer/prompts.d.ts +1 -1
  37. package/dist/types.d.ts +5 -5
  38. package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
  39. package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
  40. package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
  41. package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
  42. package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
  43. package/extractors/go/build.mjs +43 -0
  44. package/extractors/go/calls.go +289 -0
  45. package/extractors/go/contract.go +140 -0
  46. package/extractors/go/extractor.go +161 -0
  47. package/extractors/go/fingerprint.go +167 -0
  48. package/extractors/go/go.mod +3 -0
  49. package/extractors/go/imports.go +90 -0
  50. package/extractors/go/inheritance.go +138 -0
  51. package/extractors/go/lookup.go +106 -0
  52. package/extractors/go/main.go +57 -0
  53. package/extractors/go/nodes.go +177 -0
  54. package/extractors/go/orm_edges.go +277 -0
  55. package/extractors/go/parser.go +469 -0
  56. package/extractors/go/routes.go +521 -0
  57. package/extractors/go/tests.go +45 -0
  58. package/extractors/go/thirdparty.go +138 -0
  59. package/extractors/go/typeload.go +178 -0
  60. package/extractors/go/walker.go +67 -0
  61. package/extractors/java/build.mjs +90 -0
  62. package/extractors/java/devlens_java_extractor.jar +0 -0
  63. package/extractors/java/src/devlens/extractor/Contract.java +171 -0
  64. package/extractors/java/src/devlens/extractor/Extractor.java +262 -0
  65. package/extractors/java/src/devlens/extractor/ExtractorResult.java +12 -0
  66. package/extractors/java/src/devlens/extractor/Fingerprint.java +239 -0
  67. package/extractors/java/src/devlens/extractor/LookupMaps.java +123 -0
  68. package/extractors/java/src/devlens/extractor/Main.java +66 -0
  69. package/extractors/java/src/devlens/extractor/Parser.java +522 -0
  70. package/extractors/java/src/devlens/extractor/SourceWalker.java +82 -0
  71. package/extractors/java/src/devlens/extractor/ThirdParty.java +141 -0
  72. package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +43 -0
  73. package/extractors/java/src/devlens/extractor/edges/Calls.java +266 -0
  74. package/extractors/java/src/devlens/extractor/edges/Enrich.java +54 -0
  75. package/extractors/java/src/devlens/extractor/edges/Imports.java +154 -0
  76. package/extractors/java/src/devlens/extractor/edges/Inheritance.java +79 -0
  77. package/extractors/java/src/devlens/extractor/edges/OrmEdges.java +209 -0
  78. package/extractors/java/src/devlens/extractor/edges/Routes.java +143 -0
  79. package/extractors/java/src/devlens/extractor/edges/Tests.java +53 -0
  80. package/extractors/python/devlens_extractors_python/__init__.py +3 -0
  81. package/extractors/python/devlens_extractors_python/__main__.py +33 -0
  82. package/extractors/python/devlens_extractors_python/contract.py +94 -0
  83. package/extractors/python/devlens_extractors_python/edges/__init__.py +28 -0
  84. package/extractors/python/devlens_extractors_python/edges/calls.py +112 -0
  85. package/extractors/python/devlens_extractors_python/edges/enrich.py +44 -0
  86. package/extractors/python/devlens_extractors_python/edges/imports.py +155 -0
  87. package/extractors/python/devlens_extractors_python/edges/inheritance.py +97 -0
  88. package/extractors/python/devlens_extractors_python/edges/orm_edges.py +225 -0
  89. package/extractors/python/devlens_extractors_python/edges/routes/__init__.py +28 -0
  90. package/extractors/python/devlens_extractors_python/edges/routes/common.py +79 -0
  91. package/extractors/python/devlens_extractors_python/edges/routes/decorators.py +212 -0
  92. package/extractors/python/devlens_extractors_python/edges/routes/django_urls.py +213 -0
  93. package/extractors/python/devlens_extractors_python/edges/routes/drf.py +128 -0
  94. package/extractors/python/devlens_extractors_python/edges/tests.py +53 -0
  95. package/extractors/python/devlens_extractors_python/extractor.py +107 -0
  96. package/extractors/python/devlens_extractors_python/fingerprint.py +201 -0
  97. package/extractors/python/devlens_extractors_python/lookup.py +72 -0
  98. package/extractors/python/devlens_extractors_python/parser/__init__.py +72 -0
  99. package/extractors/python/devlens_extractors_python/parser/classes.py +109 -0
  100. package/extractors/python/devlens_extractors_python/parser/functions.py +163 -0
  101. package/extractors/python/devlens_extractors_python/parser/walker.py +30 -0
  102. package/extractors/python/devlens_extractors_python/third_party.py +103 -0
  103. package/extractors/python/pyproject.toml +16 -0
  104. package/extractors/python/setup.mjs +54 -0
  105. package/extractors/rust/Cargo.toml +29 -0
  106. package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
  107. package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
  108. package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
  109. package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
  110. package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
  111. package/extractors/rust/build.mjs +85 -0
  112. package/extractors/rust/src/calls.rs +295 -0
  113. package/extractors/rust/src/contract.rs +236 -0
  114. package/extractors/rust/src/enrich.rs +107 -0
  115. package/extractors/rust/src/extractor.rs +199 -0
  116. package/extractors/rust/src/fingerprint.rs +238 -0
  117. package/extractors/rust/src/imports.rs +101 -0
  118. package/extractors/rust/src/inheritance.rs +233 -0
  119. package/extractors/rust/src/lookup.rs +226 -0
  120. package/extractors/rust/src/main.rs +53 -0
  121. package/extractors/rust/src/module_map.rs +131 -0
  122. package/extractors/rust/src/nodes.rs +174 -0
  123. package/extractors/rust/src/orm_edges.rs +125 -0
  124. package/extractors/rust/src/parser.rs +859 -0
  125. package/extractors/rust/src/routes.rs +1265 -0
  126. package/extractors/rust/src/tests.rs +109 -0
  127. package/extractors/rust/src/thirdparty.rs +114 -0
  128. package/extractors/rust/src/walker.rs +82 -0
  129. package/package.json +22 -4
@@ -0,0 +1,295 @@
1
+ // calls.rs — the CALLS ladder (syntactic — syn does no type resolution).
2
+ //
3
+ // Ladder (each rung documented; later rungs flagged heuristic where fuzzy):
4
+ // 1. Path call `a::b::c()` / `Type::m()`:
5
+ // a. first segment via the file's use-symbol map (alias → target)
6
+ // b. module_map.resolve → (file, rest)
7
+ // c. rest = single name → FUNCTION in that file (closest_by_path)
8
+ // d. rest = Type::method → STRUCT/ENUM/TRAIT in that file, then
9
+ // methods_by_owner (same file preferred) → METHOD
10
+ // 2. Plain `bar()` → same-file FUNCTION first, then crate-wide name
11
+ // 3. `Type::new()` with the type in scope but NOT imported (same module /
12
+ // no use statement) → type via closest_by_path, then its methods
13
+ // 4. `self.method()` → methods of the enclosing impl's owner type
14
+ // 5. `x.method()` (other receivers) → no type info → metadata only V1
15
+ // 6. External first segment (a Cargo.toml dep, not std/local) → lazy
16
+ // [crate]/name::member node (gated)
17
+ //
18
+ // metadata.calls already carries the raw strings (parser); resolved targets
19
+ // are written back as metadata.resolvedCalls.
20
+
21
+ use crate::contract::{edge, CodeEdge};
22
+ use crate::extractor::{Options, ParsedRepo};
23
+ use crate::fingerprint::ParsedManifest;
24
+ use crate::lookup::LookupMaps;
25
+ use crate::module_map::ModuleMap;
26
+ use crate::parser::ParsedItem;
27
+ use crate::thirdparty::ThirdPartyRegistry;
28
+
29
+ pub struct CallsOutput {
30
+ pub edges: Vec<CodeEdge>,
31
+ }
32
+
33
+ pub fn detect_calls(
34
+ repo: &ParsedRepo,
35
+ module_map: &ModuleMap,
36
+ lookup: &mut LookupMaps,
37
+ mf: &ParsedManifest,
38
+ tp: &mut ThirdPartyRegistry,
39
+ _opts: &Options,
40
+ ) -> CallsOutput {
41
+ let mut edges = vec![];
42
+
43
+ for pf in &repo.files {
44
+ if pf.is_test_file {
45
+ continue;
46
+ }
47
+ for item in &pf.items {
48
+ if item.is_test || item.calls.is_empty() {
49
+ continue;
50
+ }
51
+ let from = crate::lookup::node_id_for_item(&pf.rel_path, item);
52
+ if from.is_empty() {
53
+ continue;
54
+ }
55
+ let base_module = crate::walker::module_path_for_file(&pf.rel_path);
56
+ let mut resolved: Vec<String> = vec![];
57
+ for call in &item.calls {
58
+ if let Some(target) = resolve_call(
59
+ call,
60
+ item,
61
+ &pf.rel_path,
62
+ &base_module,
63
+ module_map,
64
+ lookup,
65
+ mf,
66
+ tp,
67
+ ) {
68
+ edges.push(edge(&from, &target, "CALLS"));
69
+ resolved.push(target);
70
+ }
71
+ }
72
+ if !resolved.is_empty() {
73
+ // write metadata.resolvedCalls back (nodes are owned by the
74
+ // registry; clone → mutate → reinsert)
75
+ if let Some(node) = lookup.node_by_id.get(&from).cloned() {
76
+ let mut n = node;
77
+ let r: Vec<serde_json::Value> = resolved
78
+ .iter()
79
+ .map(|s| serde_json::Value::from(s.clone()))
80
+ .collect();
81
+ n.metadata
82
+ .insert("resolvedCalls".to_string(), serde_json::Value::Array(r));
83
+ lookup.node_by_id.insert(from, n);
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ CallsOutput { edges }
90
+ }
91
+
92
+ /// Resolve one call string → target node id (or None).
93
+ #[allow(clippy::too_many_arguments)]
94
+ fn resolve_call(
95
+ call: &str,
96
+ item: &ParsedItem,
97
+ rel_path: &str,
98
+ base_module: &str,
99
+ module_map: &ModuleMap,
100
+ lookup: &LookupMaps,
101
+ mf: &ParsedManifest,
102
+ tp: &mut ThirdPartyRegistry,
103
+ ) -> Option<String> {
104
+ // `x.method()` — receiver form
105
+ if let Some((recv, method)) = call.split_once('.') {
106
+ if recv == "self" {
107
+ // methods of the enclosing impl's owner type
108
+ let owner = &item.impl_target;
109
+ if owner.is_empty() {
110
+ return None;
111
+ }
112
+ return method_of_owner(owner, method, rel_path, lookup);
113
+ }
114
+ // other receivers: no type resolution in V1 — metadata only
115
+ return None;
116
+ }
117
+ resolve_path_call(call, rel_path, base_module, module_map, lookup, mf, tp)
118
+ }
119
+
120
+ /// Resolve a path-form call (`a::b::c`, `Type::m`, plain `bar`) to a node id.
121
+ /// Shared by the CALLS ladder and utoipa-axum route handlers (routes.rs).
122
+ ///
123
+ /// Ladder (documented Rust semantics): first segment via the file's use-symbol
124
+ /// map → module map resolve (relative-first for call paths) → glob-import
125
+ /// unroll (`use crate::controllers::*` brings `krate` into scope) → external
126
+ /// crate → lazy [crate]/name::member (gated).
127
+ pub fn resolve_path_call(
128
+ call: &str,
129
+ rel_path: &str,
130
+ base_module: &str,
131
+ module_map: &ModuleMap,
132
+ lookup: &LookupMaps,
133
+ mf: &ParsedManifest,
134
+ tp: &mut ThirdPartyRegistry,
135
+ ) -> Option<String> {
136
+ let segs: Vec<&str> = call.split("::").collect();
137
+ if segs.is_empty() {
138
+ return None;
139
+ }
140
+ 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);
144
+ }
145
+ return None;
146
+ }
147
+
148
+ // multi-segment path — first segment via symbol map, then module resolve
149
+ let sym_target = lookup
150
+ .symbol_maps
151
+ .get(rel_path)
152
+ .and_then(|syms| syms.get(segs[0]))
153
+ .cloned();
154
+ let full_path = match &sym_target {
155
+ Some(t) => format!("{}::{}", t, segs[1..].join("::")),
156
+ None => call.to_string(),
157
+ };
158
+
159
+ // Relative-first module resolution (the call-path ladder). NOTE: when
160
+ // the caller's own file IS a module (e.g. src/router.rs → module
161
+ // "router"), bare paths like `krate::search::list_crates` resolve
162
+ // relative-first to `router::krate::...` → matches the file's OWN module
163
+ // → returns (src/router.rs, "krate::search::list_crates") →
164
+ // resolve_in_file finds no item_by_module_name["router::krate"] → None.
165
+ // That None is NOT a hard failure — fall through to glob unroll +
166
+ // crate-root absolute, where utoipa `routes!(krate::search::list_crates)`
167
+ // handlers actually live (brought into scope via `use crate::controllers::*;`).
168
+ if let Some((file, rest)) = module_map.resolve(&full_path, base_module) {
169
+ if let Some(id) = resolve_in_file(&file, &rest, rel_path, lookup) {
170
+ return Some(id);
171
+ }
172
+ // don't return None — fall through to glob unroll + crate-root
173
+ }
174
+
175
+ // glob unroll: `use crate::controllers::*;` brings `krate` into scope, so
176
+ // `krate::search::list_crates` resolves as `crate::controllers::krate::
177
+ // search::list_crates` (documented Rust glob-import semantics)
178
+ if sym_target.is_none() {
179
+ if let Some(globs) = lookup.glob_maps.get(rel_path) {
180
+ for g in globs {
181
+ let cand = format!("{}::{}", g, full_path);
182
+ if let Some((file, rest)) = module_map.resolve(&cand, base_module) {
183
+ if let Some(id) = resolve_in_file(&file, &rest, rel_path, lookup) {
184
+ return Some(id);
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ // crate-root absolute fallback. A bare multi-segment path whose first
192
+ // segment is a local module resolves from the CRATE ROOT in edition 2018
193
+ // (same semantics as use-paths — `krate::search::list_crates` from
194
+ // src/router.rs means `crate::krate::search::list_crates`, NOT
195
+ // `router::krate::...`). resolve_use() is crate-root-absolute and won't
196
+ // misresolve against the caller's own module.
197
+ if let Some((file, rest)) = module_map.resolve_use(&full_path, base_module) {
198
+ if let Some(id) = resolve_in_file(&file, &rest, rel_path, lookup) {
199
+ return Some(id);
200
+ }
201
+ }
202
+
203
+ // external crate (a dep, not std, not local) → lazy member node
204
+ let first = crate::thirdparty::first_segment(&full_path);
205
+ if crate::thirdparty::is_std(first) {
206
+ return None;
207
+ }
208
+ if crate::thirdparty::is_external_crate(first, mf) {
209
+ let member = full_path.split("::").skip(1).collect::<Vec<_>>().join("::");
210
+ return tp.member_node(first, &member).map(|n| n.id.clone());
211
+ }
212
+ None
213
+ }
214
+
215
+ /// Resolve `rest` (item path within `file`) → node id.
216
+ fn resolve_in_file(file: &str, rest: &str, from_rel: &str, lookup: &LookupMaps) -> Option<String> {
217
+ if rest.is_empty() {
218
+ return None;
219
+ }
220
+ let file_module = crate::walker::module_path_for_file(file);
221
+ let parts: Vec<&str> = rest.split("::").collect();
222
+ if parts.len() == 1 {
223
+ // single name — FUNCTION (or struct constructor call `Type(...)`)
224
+ let key = if file_module.is_empty() {
225
+ parts[0].to_string()
226
+ } else {
227
+ format!("{}::{}", file_module, parts[0])
228
+ };
229
+ if let Some(id) = lookup.item_by_module_name.get(&key) {
230
+ return Some(id.clone());
231
+ }
232
+ return lookup.closest_by_path(parts[0], file);
233
+ }
234
+ // Type::method
235
+ let ty_key = if file_module.is_empty() {
236
+ parts[0].to_string()
237
+ } else {
238
+ format!("{}::{}", file_module, parts[0])
239
+ };
240
+ let type_id = lookup.item_by_module_name.get(&ty_key).cloned();
241
+ let method = parts[1];
242
+ if let Some(tid) = type_id {
243
+ if let Some(mid) = method_of_owner_for_type(parts[0], method, file, lookup) {
244
+ let _ = tid;
245
+ return Some(mid);
246
+ }
247
+ // trait-dispatch call: `Trait::method(&x)` — the trait name resolved;
248
+ // find the method among `impl Trait for ...` blocks
249
+ if let Some(mid) = method_of_trait(parts[0], method, lookup) {
250
+ return Some(mid);
251
+ }
252
+ return None;
253
+ }
254
+ // type not found as item — maybe a trait in that module
255
+ let _ = from_rel;
256
+ lookup
257
+ .item_by_module_name
258
+ .get(&format!("{}::{}", file_module, parts[0]))
259
+ .cloned()
260
+ .and_then(|_| method_of_owner(parts[0], method, file, lookup))
261
+ }
262
+
263
+ /// Method of a trait (resolved via `impl Trait for Type` blocks).
264
+ fn method_of_trait(trait_name: &str, method: &str, lookup: &LookupMaps) -> Option<String> {
265
+ let ids = lookup.methods_by_trait.get(trait_name)?;
266
+ // id shapes: `rel::Owner.method` (inherent) / `rel::Owner.method::Trait`
267
+ ids.iter()
268
+ .find(|id| id.ends_with(&format!(".{}", method)) || id.contains(&format!(".{}::", method)))
269
+ .cloned()
270
+ }
271
+
272
+ /// Method of an owner type: same-file preferred, then any file.
273
+ fn method_of_owner(owner: &str, method: &str, file: &str, lookup: &LookupMaps) -> Option<String> {
274
+ let ids = lookup.methods_by_owner.get(owner)?;
275
+ // id shapes: `rel::Owner.method` / `rel::Owner.method::Trait`
276
+ let matches = |id: &String| {
277
+ id.ends_with(&format!(".{}", method)) || id.contains(&format!(".{}::", method))
278
+ };
279
+ let same_file = ids
280
+ .iter()
281
+ .find(|id| id.starts_with(&format!("{}::", file)) && matches(id));
282
+ match same_file {
283
+ Some(id) => Some(id.clone()),
284
+ None => ids.iter().find(|id| matches(id)).cloned(),
285
+ }
286
+ }
287
+
288
+ fn method_of_owner_for_type(
289
+ owner: &str,
290
+ method: &str,
291
+ file: &str,
292
+ lookup: &LookupMaps,
293
+ ) -> Option<String> {
294
+ method_of_owner(owner, method, file, lookup)
295
+ }
@@ -0,0 +1,236 @@
1
+ // contract.rs — the JSON shape DevLens Engine expects.
2
+ //
3
+ // Every key here is camelCase to match the TypeScript interfaces in the
4
+ // devlens-engine (src/types.ts, src/extractors/types.ts). This file is the
5
+ // single source of truth for the output shape; other modules never spell
6
+ // keys by hand. Mirrors extractors/go/contract.go.
7
+ //
8
+ // serde_json::Map for `metadata` (free-form per-node extras) and strongly
9
+ // typed structs for everything else. rawDependencies is a BTreeMap so keys
10
+ // serialize sorted (deterministic output — contract rule).
11
+
12
+ use serde::{Deserialize, Serialize};
13
+ use serde_json::{Map, Value};
14
+ use sha2::{Digest, Sha256};
15
+ use std::collections::BTreeMap;
16
+
17
+ // ── Node types (subset of the engine's NodeType union used by Rust) ──
18
+ pub const NODE_FILE: &str = "FILE";
19
+ pub const NODE_TEST: &str = "TEST";
20
+ pub const NODE_ROUTE: &str = "ROUTE";
21
+ pub const NODE_THIRD_PARTY: &str = "THIRD_PARTY";
22
+
23
+ // ── Edge types (subset of the engine's EdgeType union used by Rust) ──
24
+ pub const EDGE_READS_FROM: &str = "READS_FROM";
25
+ pub const EDGE_WRITES_TO: &str = "WRITES_TO";
26
+
27
+ // codeHash = sha256(rawCode) hex-encoded, first 16 chars (contract rule).
28
+ pub fn code_hash(raw: &str) -> String {
29
+ let mut h = Sha256::new();
30
+ h.update(raw.as_bytes());
31
+ let sum = h.finalize();
32
+ hex16(&sum)
33
+ }
34
+
35
+ fn hex16(bytes: &[u8]) -> String {
36
+ let mut s = String::with_capacity(bytes.len() * 2);
37
+ for b in bytes {
38
+ s.push_str(&format!("{:02x}", b));
39
+ }
40
+ s.chars().take(16).collect()
41
+ }
42
+
43
+ // ── Fingerprint ──
44
+
45
+ #[derive(Serialize, Clone)]
46
+ #[serde(rename_all = "camelCase")]
47
+ pub struct Fingerprint {
48
+ pub language: String,
49
+ pub project_type: String,
50
+ pub framework: String,
51
+ pub router: String,
52
+ pub state_management: Vec<String>,
53
+ pub data_fetching: Vec<String>,
54
+ pub databases: Vec<String>,
55
+ pub raw_dependencies: BTreeMap<String, String>,
56
+ }
57
+
58
+ impl Fingerprint {
59
+ pub fn new() -> Self {
60
+ Fingerprint {
61
+ language: "rust".to_string(),
62
+ project_type: "unknown".to_string(),
63
+ framework: "unknown".to_string(),
64
+ router: "none".to_string(),
65
+ state_management: vec![],
66
+ data_fetching: vec![],
67
+ databases: vec![],
68
+ raw_dependencies: BTreeMap::new(),
69
+ }
70
+ }
71
+ }
72
+
73
+ // ── Stats / Errors / Result ──
74
+
75
+ #[derive(Serialize, Clone)]
76
+ #[serde(rename_all = "camelCase")]
77
+ pub struct Stats {
78
+ pub total_files: i64,
79
+ pub total_nodes: i64,
80
+ pub skipped_files: i64,
81
+ }
82
+
83
+ #[derive(Serialize, Clone)]
84
+ #[serde(rename_all = "camelCase")]
85
+ pub struct ExtractorError {
86
+ #[serde(skip_serializing_if = "String::is_empty")]
87
+ pub file: String,
88
+ pub error: String,
89
+ }
90
+
91
+ #[derive(Serialize, Clone)]
92
+ #[serde(rename_all = "camelCase")]
93
+ pub struct ExtractorResult {
94
+ pub fingerprint: Fingerprint,
95
+ pub nodes: Vec<CodeNode>,
96
+ pub edges: Vec<CodeEdge>,
97
+ pub routes: Vec<RouteNode>,
98
+ pub stats: Stats,
99
+ pub errors: Vec<ExtractorError>,
100
+ }
101
+
102
+ // ── Input (stdin) ──
103
+
104
+ #[derive(Deserialize)]
105
+ #[serde(rename_all = "camelCase")]
106
+ pub struct ExtractorInput {
107
+ pub repo_path: String,
108
+ #[serde(default)]
109
+ pub options: Value,
110
+ }
111
+
112
+ // ── Nodes / edges ──
113
+
114
+ #[derive(Serialize, Clone)]
115
+ #[serde(rename_all = "camelCase")]
116
+ pub struct CodeNode {
117
+ pub id: String,
118
+ pub name: String,
119
+ #[serde(rename = "type")]
120
+ pub node_type: String,
121
+ pub file_path: String,
122
+ pub start_line: i64,
123
+ pub end_line: i64,
124
+ pub parent_file: String,
125
+ #[serde(skip_serializing_if = "String::is_empty")]
126
+ pub code_hash: String,
127
+ #[serde(skip_serializing_if = "String::is_empty")]
128
+ pub raw_code: String,
129
+ pub metadata: Map<String, Value>,
130
+ }
131
+
132
+ #[derive(Serialize, Clone)]
133
+ #[serde(rename_all = "camelCase")]
134
+ pub struct CodeEdge {
135
+ pub from: String,
136
+ pub to: String,
137
+ #[serde(rename = "type")]
138
+ pub edge_type: String,
139
+ #[serde(skip_serializing_if = "Option::is_none")]
140
+ pub metadata: Option<Map<String, Value>>,
141
+ }
142
+
143
+ // ── Routes (the `routes` array — BackendRouteNode shape, mirrors Go) ──
144
+
145
+ #[derive(Serialize, Clone)]
146
+ #[serde(rename_all = "camelCase")]
147
+ pub struct RouteNode {
148
+ #[serde(rename = "type")]
149
+ pub route_type: String,
150
+ pub url_path: String,
151
+ pub file_path: String,
152
+ pub http_method: String,
153
+ pub framework: String,
154
+ pub is_dynamic: bool,
155
+ pub params: Vec<String>,
156
+ pub handler_name: String,
157
+ pub node_id: String,
158
+ }
159
+
160
+ // ── Builders ──
161
+
162
+ // Base metadata every code node carries (mirrors the inline JS extractor).
163
+ pub fn base_metadata() -> Map<String, Value> {
164
+ let mut m = Map::new();
165
+ m.insert("nodeCount".to_string(), Value::from(0));
166
+ m.insert("childNodeIds".to_string(), Value::Array(vec![]));
167
+ m.insert("language".to_string(), Value::from("rust"));
168
+ m
169
+ }
170
+
171
+ // fileNode — FILE (or TEST) node. Id format: file::rel/path.rs (matches
172
+ // src/parser/index.ts). File nodes parent themselves.
173
+ pub fn file_node(rel_path: &str, end_line: i64, node_type: &str) -> CodeNode {
174
+ CodeNode {
175
+ id: format!("file::{}", rel_path),
176
+ name: rel_path.rsplit('/').next().unwrap_or(rel_path).to_string(),
177
+ node_type: node_type.to_string(),
178
+ file_path: rel_path.to_string(),
179
+ start_line: 1,
180
+ end_line,
181
+ parent_file: format!("file::{}", rel_path),
182
+ code_hash: String::new(),
183
+ raw_code: String::new(),
184
+ metadata: base_metadata(),
185
+ }
186
+ }
187
+
188
+ // code_node — FUNCTION/METHOD/STRUCT/ENUM/TRAIT/IMPL_BLOCK/THIRD_PARTY/ROUTE.
189
+ // rawCode on every code node (summarizer input — engine discards after).
190
+ #[allow(clippy::too_many_arguments)]
191
+ pub fn code_node(
192
+ id: &str,
193
+ rel_path: &str,
194
+ name: &str,
195
+ node_type: &str,
196
+ start_line: i64,
197
+ end_line: i64,
198
+ raw_code: &str,
199
+ metadata: Map<String, Value>,
200
+ ) -> CodeNode {
201
+ CodeNode {
202
+ id: id.to_string(),
203
+ name: name.to_string(),
204
+ node_type: node_type.to_string(),
205
+ file_path: rel_path.to_string(),
206
+ start_line,
207
+ end_line,
208
+ parent_file: format!("file::{}", rel_path),
209
+ code_hash: code_hash(raw_code),
210
+ raw_code: raw_code.to_string(),
211
+ metadata,
212
+ }
213
+ }
214
+
215
+ pub fn edge(from: &str, to: &str, edge_type: &str) -> CodeEdge {
216
+ CodeEdge {
217
+ from: from.to_string(),
218
+ to: to.to_string(),
219
+ edge_type: edge_type.to_string(),
220
+ metadata: None,
221
+ }
222
+ }
223
+
224
+ pub fn edge_with_meta(
225
+ from: &str,
226
+ to: &str,
227
+ edge_type: &str,
228
+ metadata: Map<String, Value>,
229
+ ) -> CodeEdge {
230
+ CodeEdge {
231
+ from: from.to_string(),
232
+ to: to.to_string(),
233
+ edge_type: edge_type.to_string(),
234
+ metadata: Some(metadata),
235
+ }
236
+ }
@@ -0,0 +1,107 @@
1
+ // enrich.rs — semantic metadata markers applied as a final pass over the
2
+ // emitted nodes (mirrors go's enrich.go + python's edges/enrich.py).
3
+ //
4
+ // Memo-isolation rule (python incident 10): each marker scan derives from
5
+ // parsed facts independently — no shared memo map that could carry a cached
6
+ // miss between scans.
7
+ //
8
+ // Markers:
9
+ // isModel / modelType / tableName / tableColumns — Diesel structs
10
+ // isHandler / handlerKind — targets of HANDLES edges (routes.rs emitted)
11
+ // isPublic / receiver / isAsync — already set in nodes.rs
12
+
13
+ use crate::contract::CodeNode;
14
+ use crate::extractor::ParsedRepo;
15
+
16
+ pub fn enrich_nodes(
17
+ repo: &ParsedRepo,
18
+ nodes: &mut [CodeNode],
19
+ handles_edges: &[crate::contract::CodeEdge],
20
+ ) {
21
+ // ── isHandler: any node that is a HANDLES target ──
22
+ let mut handler_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
23
+ for e in handles_edges {
24
+ if e.edge_type == "HANDLES" {
25
+ handler_ids.insert(e.to.clone());
26
+ }
27
+ }
28
+ // handlerKind from the HANDLES edge metadata
29
+ let mut handler_kinds: std::collections::HashMap<String, String> =
30
+ std::collections::HashMap::new();
31
+ for e in handles_edges {
32
+ if e.edge_type == "HANDLES" {
33
+ if let Some(meta) = &e.metadata {
34
+ if let Some(k) = meta.get("handlerKind").and_then(|v| v.as_str()) {
35
+ handler_kinds.insert(e.to.clone(), k.to_string());
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ // ── Diesel model facts (re-derived from parsed items — orm_edges.rs
42
+ // computes the same map for edge targets; single source = facts) ──
43
+ let table_to_model = crate::orm_edges::table_to_model_map(repo);
44
+ // table name → columns (for tableColumns metadata)
45
+ let mut table_columns: std::collections::HashMap<String, Vec<String>> =
46
+ std::collections::HashMap::new();
47
+ for pf in &repo.files {
48
+ for t in &pf.tables {
49
+ table_columns
50
+ .entry(t.name.clone())
51
+ .or_default()
52
+ .extend(t.columns.iter().map(|(n, _)| n.clone()));
53
+ }
54
+ }
55
+
56
+ // model id → table name (looked up via the item facts)
57
+ let mut model_table: std::collections::HashMap<String, String> =
58
+ std::collections::HashMap::new();
59
+ for pf in &repo.files {
60
+ if pf.is_test_file {
61
+ continue;
62
+ }
63
+ for item in &pf.items {
64
+ if item.kind == crate::parser::KIND_STRUCT && !item.is_test {
65
+ let id = crate::lookup::struct_node_id(&pf.rel_path, &item.name);
66
+ if table_to_model.values().any(|v| v == &id) {
67
+ let tn = crate::orm_edges::diesel_table_name(item);
68
+ if !tn.is_empty() {
69
+ model_table.insert(id, tn);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ for n in nodes.iter_mut() {
77
+ if n.node_type != "FILE" && n.node_type != "TEST" {
78
+ if handler_ids.contains(&n.id) {
79
+ n.metadata.insert("isHandler".to_string(), true.into());
80
+ if let Some(k) = handler_kinds.get(&n.id) {
81
+ n.metadata.insert(
82
+ "handlerKind".to_string(),
83
+ serde_json::Value::from(k.clone()),
84
+ );
85
+ }
86
+ }
87
+ if table_to_model.values().any(|v| v == &n.id) {
88
+ n.metadata.insert("isModel".to_string(), true.into());
89
+ n.metadata.insert("modelType".to_string(), "diesel".into());
90
+ if let Some(tn) = model_table.get(&n.id) {
91
+ n.metadata
92
+ .insert("tableName".to_string(), serde_json::Value::from(tn.clone()));
93
+ if let Some(cols) = table_columns.get(tn) {
94
+ n.metadata.insert(
95
+ "tableColumns".to_string(),
96
+ serde_json::Value::Array(
97
+ cols.iter()
98
+ .map(|c| serde_json::Value::from(c.clone()))
99
+ .collect(),
100
+ ),
101
+ );
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }