devlensio 0.6.2 → 1.0.1

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 +155 -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,163 @@
1
+ """Function extraction — FUNCTION/METHOD nodes + metadata.calls + ormOps.
2
+
3
+ The scope rule lives here: calls inside nested functions belong to the
4
+ inner function, not the outer one.
5
+
6
+ ORM call patterns are COLLECTED here at parse time (one AST walk) and
7
+ RESOLVED later by edges/orm_edges.py via lookup maps — no re-parsing.
8
+ """
9
+ from __future__ import annotations
10
+ import ast
11
+
12
+ from ..contract import code_node
13
+
14
+ NESTED_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
15
+
16
+ # ── ORM patterns (parse-time facts; resolution lives in edges/orm_edges.py) ──
17
+ DJANGO_QUERY_METHODS = {
18
+ "get", "filter", "all", "exclude", "first", "last", "count", "exists",
19
+ "values", "values_list", "only", "defer", "select_related",
20
+ "prefetch_related", "aggregate", "annotate", "distinct", "order_by",
21
+ "reverse", "raw", "iterator", "latest", "earliest", "in_bulk",
22
+ }
23
+ DJANGO_WRITE_METHODS = {
24
+ "create", "update", "get_or_create", "update_or_create",
25
+ "bulk_create", "bulk_update", "delete",
26
+ }
27
+ SQLA_STATEMENT_FUNCS = {"select", "insert", "update", "delete"}
28
+ SESSION_MUTATORS = {"add", "add_all", "merge", "delete"} # session.add(obj) etc.
29
+ INSTANCE_MUTATORS = {"save", "delete"} # obj.save() / obj.delete()
30
+
31
+
32
+ def _walk_scope(body: list[ast.stmt], visit) -> None:
33
+ """Walk statements, applying the scope rule (never descend into nested
34
+ FunctionDef/ClassDef/Lambda). Shared by extract_calls + extract_orm_ops."""
35
+ def walk(node: ast.AST) -> None:
36
+ if isinstance(node, NESTED_SCOPES):
37
+ return
38
+ visit(node)
39
+ for child in ast.iter_child_nodes(node):
40
+ walk(child)
41
+ for stmt in body:
42
+ walk(stmt)
43
+
44
+
45
+ def call_name(func: ast.AST) -> str | None:
46
+ """'foo()' → 'foo' · 'obj.method()' → 'obj.method' · 'requests.get()' → 'requests.get'.
47
+ Subscript callees (foo[0]()) and other exotic forms → None."""
48
+ if isinstance(func, ast.Name):
49
+ return func.id
50
+ if isinstance(func, ast.Attribute):
51
+ parts: list[str] = []
52
+ node = func
53
+ while isinstance(node, ast.Attribute):
54
+ parts.append(node.attr)
55
+ node = node.value
56
+ if isinstance(node, ast.Name):
57
+ parts.append(node.id)
58
+ return ".".join(reversed(parts))
59
+ return None
60
+
61
+
62
+ def extract_calls(body: list[ast.stmt]) -> list[str]:
63
+ """Collect every called name in a function body, deduped."""
64
+ calls: list[str] = []
65
+
66
+ 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)
71
+
72
+ _walk_scope(body, visit)
73
+ return list(dict.fromkeys(calls)) # dedupe, keep first-seen order
74
+
75
+
76
+ def _match_orm_call(call: ast.Call) -> dict | None:
77
+ """One Call → ORM op dict {kind, pattern, target?, arg?, root?} or None.
78
+
79
+ Patterns:
80
+ X.objects.<query|write>(...) django_objects (target = X)
81
+ var.query(X) sqlalchemy_query (target = X)
82
+ var.get(X, pk) session_get (target = X)
83
+ var.add/delete/merge(X) session_mutate (arg = instance var)
84
+ inst.save()/delete() instance_mutate (arg = var, heuristic)
85
+ select/insert/update/delete(X) sqlalchemy_<fn> (root verified at resolve)
86
+ """
87
+ func = call.func
88
+
89
+ # X.objects.<method>(...) — Django ORM queryset/manager
90
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Attribute) \
91
+ and func.value.attr == "objects" and isinstance(func.value.value, ast.Name):
92
+ m = func.attr
93
+ if m in DJANGO_QUERY_METHODS:
94
+ return {"kind": "read", "pattern": "django_objects", "target": func.value.value.id}
95
+ if m in DJANGO_WRITE_METHODS:
96
+ return {"kind": "write", "pattern": "django_objects", "target": func.value.value.id}
97
+ return None
98
+
99
+ # var.<attr>(...) — session / instance patterns
100
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
101
+ var, m = func.value.id, func.attr
102
+ if m == "query" and call.args and isinstance(call.args[0], ast.Name):
103
+ return {"kind": "read", "pattern": "sqlalchemy_query", "target": call.args[0].id}
104
+ if m == "get" and len(call.args) >= 2 and isinstance(call.args[0], ast.Name):
105
+ return {"kind": "read", "pattern": "session_get", "target": call.args[0].id}
106
+ if m in SESSION_MUTATORS and call.args and isinstance(call.args[0], ast.Name):
107
+ return {"kind": "write", "pattern": "session_mutate", "arg": call.args[0].id}
108
+ if m in INSTANCE_MUTATORS and not call.args:
109
+ return {"kind": "write", "pattern": "instance_mutate", "arg": var}
110
+ return None
111
+
112
+ # select(X) / insert(X) / update(X) / delete(X) — SQLAlchemy 2.0 statements
113
+ if isinstance(func, ast.Name) and func.id in SQLA_STATEMENT_FUNCS \
114
+ and call.args and isinstance(call.args[0], ast.Name):
115
+ kind = "read" if func.id == "select" else "write"
116
+ return {"kind": kind, "pattern": f"sqlalchemy_{func.id}",
117
+ "target": call.args[0].id, "root": func.id}
118
+ return None
119
+
120
+
121
+ def extract_orm_ops(body: list[ast.stmt]) -> list[dict]:
122
+ """Collect ORM access patterns in a function body (scope-guarded)."""
123
+ ops: list[dict] = []
124
+
125
+ def visit(node: ast.AST) -> None:
126
+ if isinstance(node, ast.Call):
127
+ op = _match_orm_call(node)
128
+ if op:
129
+ ops.append(op)
130
+
131
+ _walk_scope(body, visit)
132
+ return ops
133
+
134
+
135
+ def extract_function(node: ast.FunctionDef | ast.AsyncFunctionDef, rel_path: str,
136
+ source: str, parent_class: str | None = None) -> dict:
137
+ is_async = isinstance(node, ast.AsyncFunctionDef)
138
+ name = f"{parent_class}.{node.name}" if parent_class else node.name
139
+
140
+ raw = ast.get_source_segment(source, node) or ""
141
+ params = [a.arg for a in node.args.posonlyargs + node.args.args + node.args.kwonlyargs]
142
+ decorators = [ast.unparse(d) for d in node.decorator_list]
143
+
144
+ # hasErrorHandling/throws use ast.walk deliberately — mirrors the JS
145
+ # extractor, which scans the whole function subtree for try/raise.
146
+ metadata = {
147
+ "params": params,
148
+ "calls": extract_calls(node.body),
149
+ "ormOps": extract_orm_ops(node.body),
150
+ "isAsync": is_async,
151
+ "hasErrorHandling": any(isinstance(n, (ast.Try, ast.TryStar)) for n in ast.walk(node)),
152
+ "throws": any(isinstance(n, ast.Raise) for n in ast.walk(node)),
153
+ "lineCount": (node.end_lineno or node.lineno) - node.lineno,
154
+ "decorators": decorators,
155
+ }
156
+ if parent_class:
157
+ metadata["parentClass"] = parent_class
158
+
159
+ return code_node(
160
+ rel_path, name,
161
+ "METHOD" if parent_class else "FUNCTION",
162
+ node.lineno, node.end_lineno or node.lineno, raw, metadata,
163
+ )
@@ -0,0 +1,30 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+
5
+ # Directories to Ingore
6
+ IGNORE_DIRS = {
7
+ ".git", "__pycache__", ".venv", "venv", "env", "node_modules",
8
+ "dist", "build", ".next", "coverage", ".mypy_cache", ".pytest_cache",
9
+ ".tox", ".idea", ".vscode", ".eggs", "site-packages", "migrations",
10
+ }
11
+
12
+ _TEST_FILE_RE = re.compile(r"(?:^|/)(?:test_[^/]+|[^/]+_test)\.py$")
13
+
14
+
15
+ def walk_python_files(repo_path: str):
16
+ """Yield relative paths of every .py file, pruning ignored dirs.
17
+
18
+ dirnames[:] = [...] is the in-place prune that makes os.walk skip
19
+ those directories entirely (same role as the JS parser's IGNORE_DIRS)."""
20
+ root = Path(repo_path)
21
+ for dirpath, dirnames, filenames in os.walk(root):
22
+ dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS]
23
+ for fn in sorted(filenames):
24
+ if fn.endswith(".py"):
25
+ rel = Path(dirpath).relative_to(root).as_posix()
26
+ yield f"{rel}/{fn}" if rel != "." else fn
27
+
28
+ def is_test_file(rel_path: str) -> bool:
29
+ """test_foo.py or foo_test.py, at any depth."""
30
+ return bool(_TEST_FILE_RE.search(rel_path))
@@ -0,0 +1,103 @@
1
+ """Third-party node creation — [pip]/pkg and [pip]/pkg::name nodes.
2
+
3
+ Mirrors the JS side's [npm]/... convention (src/graph/thirdPartyLibs.ts +
4
+ importEdges.ts): one package node per import, per-name method nodes for
5
+ named imports, all deduped in a single registry.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ # Exact stdlib module set from the running interpreter (Python 3.10+).
12
+ STDLIB_MODULES = frozenset(sys.stdlib_module_names)
13
+
14
+ # Runtime libraries — real architectural decisions (mirrors RUNTIME_INCLUSION_SET)
15
+ RUNTIME_PACKAGES = {
16
+ "fastapi", "flask", "django", "starlette", "uvicorn", "gunicorn",
17
+ "sqlalchemy", "pydantic", "alembic", "psycopg2", "psycopg", "asyncpg",
18
+ "pymongo", "motor", "redis", "celery", "requests", "httpx", "aiohttp",
19
+ "numpy", "pandas", "scipy", "scikit-learn", "tensorflow", "torch",
20
+ "pyjwt", "passlib", "bcrypt", "cryptography", "boto3", "stripe",
21
+ "openai", "anthropic", "langchain", "sentry-sdk", "loguru",
22
+ "beautifulsoup4", "scrapy", "pillow", "opencv-python", "matplotlib",
23
+ }
24
+
25
+ # Dev tooling — low signal (mirrors the JS devtool category)
26
+ DEVTOOL_PACKAGES = {
27
+ "pytest", "black", "flake8", "mypy", "ruff", "isort", "coverage",
28
+ "tox", "nox", "pre-commit", "sphinx", "mkdocs", "hypothesis",
29
+ "responses", "vcrpy", "faker", "factory-boy", "freezegun",
30
+ }
31
+
32
+ class ThirdPartyRegistry:
33
+ """Creates and caches [pip]/... nodes. One registry per extraction —
34
+ the same package imported in 20 files yields ONE node.
35
+
36
+ `allowed` gates third-party inclusion: only packages in the set get
37
+ nodes. Empty set → NO third-party nodes at all (the engine controls
38
+ this via options.includedThirdPartyLibs)."""
39
+
40
+ def __init__(self, raw_dependencies: dict[str, str],
41
+ allowed: set[str] | None = None):
42
+ self._nodes: dict[str, dict] = {}
43
+ self.raw_dependencies = raw_dependencies
44
+ self.allowed = allowed if allowed is not None else set()
45
+
46
+ def _permitted(self, pkg: str) -> bool:
47
+ return pkg in self.allowed
48
+
49
+ def _base_metadata(self, pkg: str) -> dict:
50
+ version = self.raw_dependencies.get(pkg, "unknown")
51
+ if pkg in RUNTIME_PACKAGES:
52
+ category = "runtime"
53
+ elif pkg in DEVTOOL_PACKAGES:
54
+ category = "devtool"
55
+ else:
56
+ category = "unknown"
57
+ return {"isThirdParty": True, "packageVersion": version, "category": category}
58
+
59
+ def package_node(self, pkg: str) -> dict | None:
60
+ """[pip]/pkg node, or None when the package is not permitted."""
61
+ if not self._permitted(pkg):
62
+ return None
63
+ node_id = f"[pip]/{pkg}"
64
+ if node_id not in self._nodes:
65
+ self._nodes[node_id] = {
66
+ "id": node_id,
67
+ "name": pkg,
68
+ "type": "THIRD_PARTY",
69
+ "filePath": node_id,
70
+ "startLine": 0,
71
+ "endLine": 0,
72
+ "metadata": self._base_metadata(pkg),
73
+ }
74
+ return self._nodes[node_id]
75
+
76
+ def method_node(self, pkg: str, name: str) -> dict | None:
77
+ """Named import member: from flask import Flask → [pip]/flask::Flask.
78
+ None when the package is not permitted."""
79
+ if not self._permitted(pkg):
80
+ return None
81
+ node_id = f"[pip]/{pkg}::{name}"
82
+ if node_id not in self._nodes:
83
+ pkg_node = self.package_node(pkg)
84
+ if pkg_node is None:
85
+ return None
86
+ self._nodes[node_id] = {
87
+ "id": node_id,
88
+ "name": f"{pkg}.{name}",
89
+ "type": "THIRD_PARTY",
90
+ "filePath": f"[pip]/{pkg}",
91
+ "startLine": 0,
92
+ "endLine": 0,
93
+ "metadata": {
94
+ **pkg_node["metadata"],
95
+ "parentPackageId": pkg_node["id"],
96
+ "methodName": name,
97
+ },
98
+ }
99
+ return self._nodes[node_id]
100
+
101
+ @property
102
+ def nodes(self) -> list[dict]:
103
+ return list(self._nodes.values())
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "devlens-extractors-python"
7
+ version = "0.1.0"
8
+ description = "DevLens Python extractor — AST-based code analysis producing the DevLens JSON contract"
9
+ requires-python = ">=3.11"
10
+ dependencies = []
11
+
12
+ [project.optional-dependencies]
13
+ dev = ["pytest>=7"]
14
+
15
+ [tool.setuptools.packages.find]
16
+ include = ["devlens_extractors_python*"]
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // DevLens Python extractor — venv bootstrap (cross-platform: Linux/macOS/Windows).
3
+ //
4
+ // Runs on `npm install` (postinstall) on the INSTALLING machine: a venv is
5
+ // machine-specific (absolute paths, platform binaries) so it can never live
6
+ // in the npm tarball — it must be created here. Node is guaranteed present
7
+ // (it's an npm package), so this works everywhere bash isn't.
8
+ //
9
+ // Idempotent + non-fatal:
10
+ // - python3 missing → warn, exit 0 (Python analysis unavailable)
11
+ // - .venv already present → skip
12
+ // - otherwise → create venv + editable-install the package
13
+
14
+ import { existsSync, mkdirSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { spawnSync } from "node:child_process";
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const IS_WIN = process.platform === "win32";
21
+ const VENV_PYTHON = join(HERE, ".venv", IS_WIN ? "Scripts/python.exe" : "bin/python");
22
+
23
+ function which(cmd) {
24
+ const r = spawnSync(IS_WIN ? "where" : "which", [cmd], { stdio: "pipe" });
25
+ return r.status === 0;
26
+ }
27
+
28
+ // python3 on Windows is usually `python` (the Store alias is python3 too,
29
+ // but `python` is the safe bet on CI images).
30
+ const pythonCmd = which("python3") ? "python3" : which("python") ? "python" : null;
31
+
32
+ if (!pythonCmd) {
33
+ console.error("devlens: python3 not found — Python extraction unavailable (install Python 3.11+)");
34
+ process.exit(0);
35
+ }
36
+
37
+ if (existsSync(VENV_PYTHON)) {
38
+ console.error("devlens: python extractor venv already present — skipping");
39
+ process.exit(0);
40
+ }
41
+
42
+ console.error(`devlens: creating python extractor venv at ${join(HERE, ".venv")}`);
43
+ mkdirSync(join(HERE, ".venv"), { recursive: true });
44
+ let r = spawnSync(pythonCmd, ["-m", "venv", ".venv"], { cwd: HERE, stdio: "inherit" });
45
+ if (r.status !== 0) {
46
+ console.error("devlens: `python -m venv` failed — Python extraction unavailable");
47
+ process.exit(0); // non-fatal: don't break npm install
48
+ }
49
+ r = spawnSync(VENV_PYTHON, ["-m", "pip", "install", "--quiet", "-e", "."], { cwd: HERE, stdio: "inherit" });
50
+ if (r.status !== 0) {
51
+ console.error("devlens: pip install failed — Python extraction unavailable");
52
+ process.exit(0);
53
+ }
54
+ console.error("devlens: python extractor ready");
@@ -0,0 +1,29 @@
1
+ [package]
2
+ name = "devlens_rust_extractor"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "DevLens Rust codebase graph extractor (JSON over stdin/stdout)"
6
+ license = "MIT"
7
+
8
+ # 100% pure-Rust dependency tree — no C code. This is what makes the
9
+ # no-sudo cross-compile matrix possible (rust-lld links musl/windows-gnu,
10
+ # zig links darwin). Do NOT add any crate with C/cc dependencies.
11
+ [dependencies]
12
+ syn = { version = "2", features = ["full", "visit", "extra-traits"] }
13
+ proc-macro2 = { version = "1", features = ["span-locations"] }
14
+ quote = "1"
15
+ serde = { version = "1", features = ["derive"] }
16
+ serde_json = "1"
17
+ toml = "0.8"
18
+ sha2 = "0.10"
19
+
20
+ # Static-binary hygiene: panic=abort avoids unwinding runtime bloat; strip
21
+ # symbols in release. lto keeps the binary lean.
22
+ [profile.release]
23
+ panic = "abort"
24
+ lto = true
25
+ codegen-units = 1
26
+ strip = "symbols"
27
+
28
+ [profile.release.package."*"]
29
+ opt-level = 3
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // DevLens Rust extractor — cross-compiled static binaries (Node script, so it
3
+ // runs on Windows too; never bash).
4
+ //
5
+ // The Rust extractor ships as per-platform static binaries inside the
6
+ // devlensio tarball (files whitelist). Node is guaranteed on every machine
7
+ // that installs an npm package; the Rust toolchain is a build-time dep (same
8
+ // class as JDK for java / Go toolchain for go).
9
+ //
10
+ // Cross-compile matrix (all sudo-free — the dep tree is 100% pure Rust):
11
+ // linux-amd64/arm64 → musl targets linked by rust-lld: true static
12
+ // binaries (no glibc), zero runtime deps — matches
13
+ // Go's CGO_ENABLED=0 claim
14
+ // windows-amd64 → x86_64-pc-windows-gnu via cargo-zigbuild: zig
15
+ // bundles the mingw-w64 import libs (rust-lld alone
16
+ // can't see kernel32.lib etc. without a system mingw)
17
+ // darwin-amd64/arm64 → cargo-zigbuild: zig is the Mach-O linker, no macOS
18
+ // SDK needed for pure-Rust crates
19
+ //
20
+ // Prereqs on the publishing machine: rustup (stable + targets added),
21
+ // cargo-zigbuild, and a zig binary (found via env ZIG, ~/zig/zig-*/,
22
+ // or PATH).
23
+ //
24
+ // Usage: node build.mjs (idempotent — run from this directory)
25
+
26
+ import { execFileSync } from "node:child_process";
27
+ import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from "node:fs";
28
+ import { dirname, join } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { homedir } from "node:os";
31
+
32
+ const HERE = dirname(fileURLToPath(import.meta.url));
33
+
34
+ // [platform dir, cargo target triple, build kind]
35
+ const TARGETS = [
36
+ ["linux-amd64", "x86_64-unknown-linux-musl", "rust-lld"],
37
+ ["linux-arm64", "aarch64-unknown-linux-musl", "rust-lld"],
38
+ ["windows-amd64", "x86_64-pc-windows-gnu", "zig"],
39
+ ["darwin-amd64", "x86_64-apple-darwin", "zig"],
40
+ ["darwin-arm64", "aarch64-apple-darwin", "zig"],
41
+ ];
42
+
43
+ const cargo =
44
+ process.env.CARGO ||
45
+ (existsSync(join(homedir(), ".cargo", "bin", "cargo"))
46
+ ? join(homedir(), ".cargo", "bin", "cargo")
47
+ : "cargo");
48
+
49
+ function findZig() {
50
+ if (process.env.ZIG && existsSync(process.env.ZIG)) return process.env.ZIG;
51
+ const zigDir = join(homedir(), "zig");
52
+ if (existsSync(zigDir)) {
53
+ for (const entry of readdirSync(zigDir).sort().reverse()) {
54
+ const cand = join(zigDir, entry, "zig");
55
+ if (existsSync(cand)) return cand;
56
+ }
57
+ }
58
+ return "zig"; // fall back to PATH
59
+ }
60
+
61
+ function run(cmd, args, opts = {}) {
62
+ execFileSync(cmd, args, { stdio: "inherit", ...opts });
63
+ }
64
+
65
+ const zig = findZig();
66
+ for (const [platformDir, triple, kind] of TARGETS) {
67
+ const outDir = join(HERE, "bin", platformDir);
68
+ mkdirSync(outDir, { recursive: true });
69
+ const exe = platformDir.startsWith("windows") ? "devlens_rust_extractor.exe" : "devlens_rust_extractor";
70
+ console.log(`==> building ${platformDir} (${triple}, ${kind})`);
71
+ const env = { ...process.env };
72
+ let args;
73
+ if (kind === "rust-lld") {
74
+ // plain cargo build: rust-lld is the linker for pure-Rust musl targets
75
+ env.RUSTFLAGS = "-C linker=rust-lld";
76
+ args = ["build", "--target", triple, "--release"];
77
+ } else {
78
+ // zigbuild: zig must be on PATH (it spawns `zig`)
79
+ env.PATH = `${dirname(zig)}${process.platform === "win32" ? ";" : ":"}${env.PATH}`;
80
+ args = ["zigbuild", "--target", triple, "--release"];
81
+ }
82
+ run(cargo, args, { cwd: HERE, env });
83
+ copyFileSync(join(HERE, "target", triple, "release", exe), join(outDir, exe));
84
+ }
85
+ console.log("==> Done: extractors/rust/bin/<platform>/devlens_rust_extractor[.exe]");