devlensio 1.0.0 → 1.0.2

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.
@@ -11,6 +11,83 @@ export function defaultParseResult(stdout) {
11
11
  }
12
12
  // Subprocess Extractor Registry
13
13
  // These extractors are spawned as child processes.
14
+ // ── extractor artifact resolution ────────────────────────────────────────────
15
+ // The subprocess extractors (python venv, java jar, go/rust static binaries)
16
+ // are DATA files inside the devlensio package. In a normal install they sit at
17
+ // `<pkg>/extractors/…` and resolve relative to this module. But when devlensio
18
+ // is BUNDLED into a standalone binary (`bun build --compile`, which is how the
19
+ // `@devlensio/cli` and MCP server ship), `import.meta.url` points at the binary
20
+ // itself, so the URL-relative path resolves to `/extractors/…` (nonexistent).
21
+ // The fallbacks below find the real extractor root for that case.
22
+ function resolveExtractorsRoot() {
23
+ // 1. Env override (CI / unusual layouts): DEVLENS_EXTRACTORS_DIR = the
24
+ // `extractors/` directory itself.
25
+ const env = process.env.DEVLENS_EXTRACTORS_DIR;
26
+ if (env) {
27
+ const candidate = path.resolve(env);
28
+ if (fs.existsSync(path.join(candidate, "python")) && fs.existsSync(path.join(candidate, "java"))) {
29
+ return candidate;
30
+ }
31
+ }
32
+ // 2. Normal install: `<pkg>/dist/extractors/../../extractors` = `<pkg>/extractors`.
33
+ const viaModule = fileURLToPath(new URL("../../extractors", import.meta.url));
34
+ if (fs.existsSync(viaModule))
35
+ return viaModule;
36
+ // 3. Bundled binary: walk up from this bundle's own directory AND from the
37
+ // cwd to find `<…>/node_modules/devlensio/extractors`. Covers running the
38
+ // compiled CLI inside a project that depends on devlensio, and global
39
+ // installs where devlensio sits hoisted beside the CLI package.
40
+ const scanRoots = [
41
+ fileURLToPath(new URL(".", import.meta.url)),
42
+ process.cwd(),
43
+ ];
44
+ for (const start of scanRoots) {
45
+ let dir = path.resolve(start);
46
+ for (;;) {
47
+ const candidate = path.join(dir, "node_modules", "devlensio", "extractors");
48
+ if (fs.existsSync(candidate))
49
+ return candidate;
50
+ const parent = path.dirname(dir);
51
+ if (parent === dir)
52
+ break;
53
+ dir = parent;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ const extractorsRoot = resolveExtractorsRoot();
59
+ /** Absolute path to an extractor artifact under the resolved root (or null). */
60
+ function extractorArtifact(rel) {
61
+ return extractorsRoot ? path.join(extractorsRoot, rel) : null;
62
+ }
63
+ function resolvePythonCommand() {
64
+ const venvBin = process.platform === "win32" ? "Scripts/python.exe" : "bin/python";
65
+ const venvPython = extractorArtifact(`python/.venv/${venvBin}`) ??
66
+ // Last resort: URL-relative (pre-fix behavior for direct-from-dist runs).
67
+ fileURLToPath(new URL(`../../extractors/python/.venv/${venvBin}`, import.meta.url));
68
+ return fs.existsSync(venvPython) ? venvPython : "python3";
69
+ }
70
+ function platformDir() {
71
+ return process.platform === "win32"
72
+ ? "windows-amd64"
73
+ : process.platform === "darwin"
74
+ ? `darwin-${process.arch === "arm64" ? "arm64" : "amd64"}`
75
+ : `linux-${process.arch === "arm64" ? "arm64" : "amd64"}`;
76
+ }
77
+ function resolveJavaJarPath() {
78
+ const rel = "java/devlens_java_extractor.jar";
79
+ return extractorArtifact(rel) ?? fileURLToPath(new URL(`../../extractors/${rel}`, import.meta.url));
80
+ }
81
+ function resolveGoBinaryPath() {
82
+ const exe = process.platform === "win32" ? ".exe" : "";
83
+ const rel = `go/bin/${platformDir()}/devlens_go_extractor${exe}`;
84
+ return extractorArtifact(rel) ?? fileURLToPath(new URL(`../../extractors/${rel}`, import.meta.url));
85
+ }
86
+ function resolveRustBinaryPath() {
87
+ const exe = process.platform === "win32" ? ".exe" : "";
88
+ const rel = `rust/bin/${platformDir()}/devlens_rust_extractor${exe}`;
89
+ return extractorArtifact(rel) ?? fileURLToPath(new URL(`../../extractors/${rel}`, import.meta.url));
90
+ }
14
91
  const SUBPROCESS_EXTRACTORS = {
15
92
  python: {
16
93
  language: "python",
@@ -26,7 +103,7 @@ const SUBPROCESS_EXTRACTORS = {
26
103
  // Absolute path — the runner spawns with cwd=repoPath, so a bare jar
27
104
  // name would be looked up inside the analyzed repo. fileURLToPath
28
105
  // also decodes percent-escapes (spaces in the path).
29
- args: ["-jar", fileURLToPath(new URL("../../extractors/java/devlens_java_extractor.jar", import.meta.url))],
106
+ args: ["-jar", resolveJavaJarPath()],
30
107
  parseResult: defaultParseResult,
31
108
  },
32
109
  go: {
@@ -52,44 +129,6 @@ const SUBPROCESS_EXTRACTORS = {
52
129
  export function getExtractor(language) {
53
130
  return SUBPROCESS_EXTRACTORS[language];
54
131
  }
55
- // The python extractor is a pip package installed into a venv that the
56
- // postinstall script creates (extractors/python/setup.mjs). Resolve that
57
- // venv's interpreter absolutely — same pattern as the java jar path —
58
- // because the runner spawns with cwd=repoPath. Platform-aware: Windows
59
- // venvs put the interpreter at Scripts\python.exe, Unix at bin/python.
60
- function resolvePythonCommand() {
61
- const venvBin = process.platform === "win32" ? "Scripts/python.exe" : "bin/python";
62
- const venvPython = fileURLToPath(new URL(`../../extractors/python/.venv/${venvBin}`, import.meta.url));
63
- return fs.existsSync(venvPython) ? venvPython : "python3";
64
- }
65
- // The Go extractor ships as a static binary per platform, cross-compiled at
66
- // publish time (prepack → extractors/go/build.mjs). Resolve the CURRENT
67
- // platform's binary absolutely — same reason as the java jar path (the runner
68
- // spawns with cwd=repoPath). fileURLToPath also decodes percent-escapes
69
- // (spaces in the path).
70
- function resolveGoBinaryPath() {
71
- const platformDir = process.platform === "win32"
72
- ? "windows-amd64"
73
- : process.platform === "darwin"
74
- ? `darwin-${process.arch === "arm64" ? "arm64" : "amd64"}`
75
- : `linux-${process.arch === "arm64" ? "arm64" : "amd64"}`;
76
- const exe = process.platform === "win32" ? ".exe" : "";
77
- return fileURLToPath(new URL(`../../extractors/go/bin/${platformDir}/devlens_go_extractor${exe}`, import.meta.url));
78
- }
79
- // The Rust extractor ships as a static binary per platform, cross-compiled at
80
- // publish time (prepack → extractors/rust/build.mjs). Resolve the CURRENT
81
- // platform's binary absolutely — same reason as the go binary path (the runner
82
- // spawns with cwd=repoPath). fileURLToPath also decodes percent-escapes
83
- // (spaces in the path).
84
- function resolveRustBinaryPath() {
85
- const platformDir = process.platform === "win32"
86
- ? "windows-amd64"
87
- : process.platform === "darwin"
88
- ? `darwin-${process.arch === "arm64" ? "arm64" : "amd64"}`
89
- : `linux-${process.arch === "arm64" ? "arm64" : "amd64"}`;
90
- const exe = process.platform === "win32" ? ".exe" : "";
91
- return fileURLToPath(new URL(`../../extractors/rust/bin/${platformDir}/devlens_rust_extractor${exe}`, import.meta.url));
92
- }
93
132
  // langauges handled inline meaning JS/TS
94
133
  export const INLINE_LANGUAGES = new Set(["javascript", "typescript"]);
95
134
  // Does `command` resolve? Absolute path → existsSync; bare name → PATH scan
@@ -41,7 +41,7 @@ pub fn detect_calls(
41
41
  let mut edges = vec![];
42
42
 
43
43
  for pf in &repo.files {
44
- if pf.is_test_file {
44
+ if pf.is_pure_test_file {
45
45
  continue;
46
46
  }
47
47
  for item in &pf.items {
@@ -57,7 +57,7 @@ pub fn enrich_nodes(
57
57
  let mut model_table: std::collections::HashMap<String, String> =
58
58
  std::collections::HashMap::new();
59
59
  for pf in &repo.files {
60
- if pf.is_test_file {
60
+ if pf.is_pure_test_file {
61
61
  continue;
62
62
  }
63
63
  for item in &pf.items {
@@ -34,7 +34,7 @@ pub fn detect_imports(
34
34
  let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
35
35
 
36
36
  for pf in &repo.files {
37
- if pf.is_test_file {
37
+ if pf.is_pure_test_file {
38
38
  // test files ARE leaf nodes, but they still import — emit edges
39
39
  // from the TEST node (same file id — parentFile covers it)
40
40
  }
@@ -37,7 +37,7 @@ pub fn detect_inheritance(
37
37
  let mut edges = vec![];
38
38
  let mut nodes = vec![];
39
39
  for pf in &repo.files {
40
- if pf.is_test_file {
40
+ if pf.is_pure_test_file {
41
41
  continue;
42
42
  }
43
43
  for item in &pf.items {
@@ -2,9 +2,11 @@
2
2
  //
3
3
  // Contract rules enforced here:
4
4
  // - FILE id `file::rel/path.rs`; file nodes parent themselves
5
- // - TEST files (any #[test] fn) = LEAF nodes: nodes=[] for them, test fns
6
- // ride in metadata.testCases only (the bug that shipped in Python's
7
- // parser — asserted in the bun suite)
5
+ // - PURE test files (tests/ integration files, or files whose every item is
6
+ // a #[test] fn) = LEAF TEST nodes; Rust's idiomatic inline #[cfg(test)]
7
+ // mod tests must NOT collapse a mixed production file — its non-test items
8
+ // emit normally and #[test] fns emit as FUNCTION nodes flagged
9
+ // metadata.isTestFunction (test bodies stay out of prod call edges there)
8
10
  // - rawCode + codeHash on every FUNCTION/METHOD/STRUCT/ENUM/TRAIT/IMPL_BLOCK
9
11
  // - deterministic ids (file-scoped; trait-impl method suffix for the
10
12
  // same-name-different-trait collision class)
@@ -17,7 +19,7 @@ use serde_json::{Map, Value};
17
19
  pub fn collect_file_nodes(files: &[ParsedFile]) -> Vec<CodeNode> {
18
20
  let mut out = vec![];
19
21
  for pf in files {
20
- if pf.is_test_file {
22
+ if pf.is_pure_test_file {
21
23
  let mut n = file_node(&pf.rel_path, pf.line_count, NODE_TEST);
22
24
  let meta = n
23
25
  .metadata
@@ -30,25 +32,43 @@ pub fn collect_file_nodes(files: &[ParsedFile]) -> Vec<CodeNode> {
30
32
  }
31
33
  out.push(n);
32
34
  } else {
33
- out.push(file_node(&pf.rel_path, pf.line_count, NODE_FILE));
35
+ let mut n = file_node(&pf.rel_path, pf.line_count, NODE_FILE);
36
+ // Mixed files keep a testCases index so consumers that never walk
37
+ // FUNCTION nodes still see the file's tests.
38
+ if !pf.test_fns.is_empty() {
39
+ let meta = n
40
+ .metadata
41
+ .entry("testCases".to_string())
42
+ .or_insert_with(|| Value::Array(vec![]));
43
+ if let Value::Array(arr) = meta {
44
+ for t in &pf.test_fns {
45
+ arr.push(Value::from(t.clone()));
46
+ }
47
+ }
48
+ }
49
+ out.push(n);
34
50
  }
35
51
  }
36
52
  out
37
53
  }
38
54
 
39
- /// Code nodes (FUNCTION/METHOD/STRUCT/ENUM/TRAIT/IMPL_BLOCK) — test fns are
40
- /// NOT emitted (leaf rule); they feed metadata.testCases only.
55
+ /// Code nodes. PURE test files stay leaves (their fns ride in testCases).
56
+ /// Mixed files emit ALL items; #[test] fns are flagged metadata.isTestFunction
57
+ /// instead of being dropped, so a test function is addressable without a new
58
+ /// node type.
41
59
  pub fn collect_code_nodes(files: &[ParsedFile]) -> Vec<CodeNode> {
42
60
  let mut out = vec![];
43
61
  for pf in files {
44
- if pf.is_test_file {
62
+ if pf.is_pure_test_file {
45
63
  continue;
46
64
  }
47
65
  for item in &pf.items {
66
+ let mut n = item_node(pf, item);
48
67
  if item.is_test {
49
- continue;
68
+ n.metadata
69
+ .insert("isTestFunction".to_string(), Value::from(true));
50
70
  }
51
- out.push(item_node(pf, item));
71
+ out.push(n);
52
72
  }
53
73
  }
54
74
  out
@@ -21,7 +21,7 @@ use std::collections::HashMap;
21
21
  pub fn table_to_model_map(repo: &ParsedRepo) -> HashMap<String, String> {
22
22
  let mut map = HashMap::new();
23
23
  for pf in &repo.files {
24
- if pf.is_test_file {
24
+ if pf.is_pure_test_file {
25
25
  continue;
26
26
  }
27
27
  for item in &pf.items {
@@ -89,7 +89,7 @@ pub fn detect_orm_edges(
89
89
  let table_to_schema_file = table_to_schema_file_map(repo);
90
90
 
91
91
  for pf in &repo.files {
92
- if pf.is_test_file {
92
+ if pf.is_pure_test_file {
93
93
  continue;
94
94
  }
95
95
  for item in &pf.items {
@@ -162,7 +162,12 @@ pub struct ParsedFile {
162
162
  pub items: Vec<ParsedItem>,
163
163
  pub tables: Vec<TableDecl>,
164
164
  pub test_fns: Vec<String>,
165
- pub is_test_file: bool,
165
+ /// True only when the file is EXCLUSIVELY tests (every top-level item is a
166
+ /// #[test] fn, or the file only holds inline #[cfg(test)] mods). Rust's
167
+ /// idiomatic inline `#[cfg(test)] mod tests` must NOT collapse a mixed
168
+ /// production file into a TEST leaf — only pure test files get leaf
169
+ /// treatment (mirrors the intent of go's _test.go rule).
170
+ pub is_pure_test_file: bool,
166
171
  pub line_count: i64,
167
172
  }
168
173
 
@@ -326,7 +331,7 @@ pub fn parse_file(rel_path: &str, source: &str) -> Result<ParsedFile, String> {
326
331
  items: vec![],
327
332
  tables: vec![],
328
333
  test_fns: vec![],
329
- is_test_file: false,
334
+ is_pure_test_file: false,
330
335
  line_count: line_index.line_count() as i64,
331
336
  };
332
337
 
@@ -342,7 +347,7 @@ pub fn parse_file(rel_path: &str, source: &str) -> Result<ParsedFile, String> {
342
347
  }
343
348
  pf.ast.items = ast_items;
344
349
 
345
- pf.is_test_file = !pf.test_fns.is_empty();
350
+ pf.is_pure_test_file = !pf.test_fns.is_empty() && pf.items.iter().all(|i| i.is_test);
346
351
  Ok(pf)
347
352
  }
348
353
 
@@ -96,7 +96,7 @@ pub fn detect_routes(
96
96
  let mut func_lit_vars: HashMap<(String, String), String> = HashMap::new();
97
97
 
98
98
  for pf in &repo.files {
99
- if pf.is_test_file {
99
+ if pf.is_pure_test_file {
100
100
  continue;
101
101
  }
102
102
  for item in &pf.items {
@@ -195,7 +195,7 @@ pub fn detect_routes(
195
195
  // ── actix-web / rocket: attribute macros ──
196
196
  if framework == "actix-web" || framework == "rocket" {
197
197
  for pf in &repo.files {
198
- if pf.is_test_file {
198
+ if pf.is_pure_test_file {
199
199
  continue;
200
200
  }
201
201
  for item in &pf.items {
@@ -22,7 +22,7 @@ pub fn detect_tests(
22
22
  ) -> Vec<CodeEdge> {
23
23
  let mut edges = vec![];
24
24
  for pf in &repo.files {
25
- if !pf.is_test_file {
25
+ if !pf.is_pure_test_file {
26
26
  continue;
27
27
  }
28
28
  let from = format!("file::{}", pf.rel_path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlensio",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Codebase intelligence engine for TypeScript/JavaScript/React/Next.js, Python, Java, Go, and Rust repositories.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",