contextl 1.1.4 → 1.2.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.
- package/README.md +35 -2
- package/package.json +1 -1
- package/python/dead_code.py +65 -0
- package/python/import_parser.py +88 -24
- package/python/main.py +79 -35
- package/python/mcp_server.py +110 -27
- package/python/obsidian_export.py +120 -0
- package/python/scanner.py +1 -1
package/README.md
CHANGED
|
@@ -74,6 +74,16 @@ Walks the dependency graph upstream from any file to find every direct and trans
|
|
|
74
74
|
|
|
75
75
|
Lists every source file `contextl` can see — useful for the agent to orient itself before doing anything else.
|
|
76
76
|
|
|
77
|
+
### `find_dead_files`
|
|
78
|
+
*"Which files are never imported by anything?"*
|
|
79
|
+
|
|
80
|
+
Finds unused files and dead code by analyzing the dependency graph for files with an in-degree of 0. Automatically filters out standard entry points (like `page.tsx` or `index.ts`) and test files.
|
|
81
|
+
|
|
82
|
+
### `export_obsidian_vault`
|
|
83
|
+
*"Visualize this codebase in Obsidian."*
|
|
84
|
+
|
|
85
|
+
Takes the exact dependency graph built by the intelligence engine and physically writes it to disk as a directory of interconnected Markdown files. Automatically injects file metadata, JSDoc/Docstring explanations, and uses standard `[[wikilinks]]` to map out dependencies. Open the generated folder as an Obsidian vault for a stunning 3D interactive graph of your architecture!
|
|
86
|
+
|
|
77
87
|
---
|
|
78
88
|
|
|
79
89
|
## How the ranking works
|
|
@@ -87,9 +97,32 @@ Lists every source file `contextl` can see — useful for the agent to orient it
|
|
|
87
97
|
|
|
88
98
|
## Supported languages
|
|
89
99
|
|
|
90
|
-
TypeScript, TSX, JavaScript, JSX
|
|
100
|
+
**JavaScript ecosystem**: TypeScript, TSX, JavaScript, JSX.
|
|
101
|
+
**Backend ecosystem**: Python (`.py`) and Java (`.java`).
|
|
102
|
+
|
|
103
|
+
The engine natively understands dot-notation module paths (`from X import Y`, `import com.example.X;`) and correctly resolves them to physical file paths to build the architecture graph.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## The Global CLI
|
|
108
|
+
|
|
109
|
+
`contextl` isn't just an AI tool; it installs a global command-line interface on your system so you can access all the intelligence features natively:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# 1. Search the codebase
|
|
113
|
+
contextl search ./my-repo "fix the auth flow"
|
|
114
|
+
|
|
115
|
+
# 2. Analyze impact of changing a file
|
|
116
|
+
contextl impact ./my-repo src/api.ts
|
|
117
|
+
|
|
118
|
+
# 3. Find dead unused files
|
|
119
|
+
contextl dead-code ./my-repo
|
|
120
|
+
|
|
121
|
+
# 4. Generate an Obsidian vault
|
|
122
|
+
contextl obsidian ./my-repo ./my_vault
|
|
123
|
+
```
|
|
91
124
|
|
|
92
|
-
|
|
125
|
+
*(Note: If you omit the sub-command, `contextl ./my-repo "query"` will automatically default to `search` for backwards compatibility).*
|
|
93
126
|
|
|
94
127
|
---
|
|
95
128
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository Intelligence Engine
|
|
3
|
+
Step 7: Dead Code Analysis
|
|
4
|
+
|
|
5
|
+
Finds files in the dependency graph that have an in-degree of 0
|
|
6
|
+
(meaning they are never imported by any other file), while filtering
|
|
7
|
+
out standard entry points and test files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from graph_builder import RepoGraph
|
|
11
|
+
from impact_analysis import _is_test_file
|
|
12
|
+
|
|
13
|
+
# Common entry points that shouldn't be flagged as dead code
|
|
14
|
+
# even if nothing explicitly imports them
|
|
15
|
+
ENTRY_POINT_MARKERS = {
|
|
16
|
+
"page.tsx", "page.ts", "page.js", "page.jsx",
|
|
17
|
+
"layout.tsx", "layout.ts", "layout.js", "layout.jsx",
|
|
18
|
+
"route.ts", "route.js",
|
|
19
|
+
"globals.css", "global.css", "tailwind.css",
|
|
20
|
+
"index.ts", "index.js", "index.tsx", "index.jsx",
|
|
21
|
+
"main.ts", "main.js", "main.py",
|
|
22
|
+
"__main__.py", "__init__.py", "setup.py", "conftest.py",
|
|
23
|
+
"next.config", "tailwind.config", "postcss.config", "vite.config", "webpack.config"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def _is_entry_point(path: str) -> bool:
|
|
27
|
+
lowered = path.lower()
|
|
28
|
+
for marker in ENTRY_POINT_MARKERS:
|
|
29
|
+
if marker in lowered:
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def find_dead_files(repo_graph: RepoGraph) -> list[str]:
|
|
34
|
+
"""
|
|
35
|
+
Find files with in-degree 0, excluding entry points and tests.
|
|
36
|
+
"""
|
|
37
|
+
dead_files = []
|
|
38
|
+
|
|
39
|
+
for path, node in repo_graph.nodes.items():
|
|
40
|
+
if node.in_degree == 0:
|
|
41
|
+
if not _is_test_file(path) and not _is_entry_point(path):
|
|
42
|
+
dead_files.append(path)
|
|
43
|
+
|
|
44
|
+
return sorted(dead_files)
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
import sys
|
|
48
|
+
from scanner import scan_repo
|
|
49
|
+
from import_parser import parse_imports
|
|
50
|
+
from graph_builder import build_graph
|
|
51
|
+
|
|
52
|
+
if len(sys.argv) < 2:
|
|
53
|
+
print("Usage: python dead_code.py <repo_path>")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
repo_path = sys.argv[1]
|
|
57
|
+
scan = scan_repo(repo_path)
|
|
58
|
+
parse = parse_imports(scan)
|
|
59
|
+
repo_graph = build_graph(scan, parse)
|
|
60
|
+
|
|
61
|
+
dead = find_dead_files(repo_graph)
|
|
62
|
+
|
|
63
|
+
print(f"Found {len(dead)} potentially dead files:")
|
|
64
|
+
for f in dead:
|
|
65
|
+
print(f" - {f}")
|
package/python/import_parser.py
CHANGED
|
@@ -37,6 +37,13 @@ IMPORT_PATTERN = re.compile(
|
|
|
37
37
|
# Also catch: import("./foo") — dynamic imports
|
|
38
38
|
DYNAMIC_IMPORT_PATTERN = re.compile(r"""import\s*\(\s*['"](.*?)['"]\s*\)""")
|
|
39
39
|
|
|
40
|
+
# Python
|
|
41
|
+
PYTHON_FROM_IMPORT_PATTERN = re.compile(r"^from\s+([.\w]+)\s+import", re.MULTILINE)
|
|
42
|
+
PYTHON_IMPORT_PATTERN = re.compile(r"^import\s+([.\w]+)", re.MULTILINE)
|
|
43
|
+
|
|
44
|
+
# Java
|
|
45
|
+
JAVA_IMPORT_PATTERN = re.compile(r"^import\s+(?:static\s+)?([\w.]+);", re.MULTILINE)
|
|
46
|
+
|
|
40
47
|
|
|
41
48
|
@dataclass
|
|
42
49
|
class ImportRelationship:
|
|
@@ -66,19 +73,33 @@ class ParseResult:
|
|
|
66
73
|
return "\n".join(lines)
|
|
67
74
|
|
|
68
75
|
|
|
69
|
-
def _extract_raw_imports(source_code: str) -> list[str]:
|
|
70
|
-
"""Pull all import paths out of a
|
|
76
|
+
def _extract_raw_imports(source_code: str, extension: str) -> list[str]:
|
|
77
|
+
"""Pull all import paths out of a source file based on its extension."""
|
|
71
78
|
paths = []
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
if extension in {".ts", ".tsx", ".js", ".jsx"}:
|
|
80
|
+
for match in IMPORT_PATTERN.finditer(source_code):
|
|
81
|
+
paths.append(match.group(1))
|
|
82
|
+
for match in DYNAMIC_IMPORT_PATTERN.finditer(source_code):
|
|
83
|
+
paths.append(match.group(1))
|
|
84
|
+
elif extension == ".py":
|
|
85
|
+
for match in PYTHON_FROM_IMPORT_PATTERN.finditer(source_code):
|
|
86
|
+
paths.append(match.group(1))
|
|
87
|
+
for match in PYTHON_IMPORT_PATTERN.finditer(source_code):
|
|
88
|
+
paths.append(match.group(1))
|
|
89
|
+
elif extension == ".java":
|
|
90
|
+
for match in JAVA_IMPORT_PATTERN.finditer(source_code):
|
|
91
|
+
paths.append(match.group(1))
|
|
92
|
+
return list(set(paths))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_external(import_path: str, extension: str) -> bool:
|
|
96
|
+
"""Return True if this is an external package rather than a local file."""
|
|
97
|
+
if extension in {".py", ".java"}:
|
|
98
|
+
# For Python and Java, it's hard to tell without resolving,
|
|
99
|
+
# so assume it's local and let the resolver fail if it's external.
|
|
100
|
+
return False
|
|
78
101
|
|
|
79
|
-
|
|
80
|
-
"""Return True if this is an npm package rather than a local file."""
|
|
81
|
-
# Local files start with . (relative) or @ followed by / (alias like @/)
|
|
102
|
+
# Node.js: Local files start with . (relative) or @ followed by / (alias like @/)
|
|
82
103
|
# but NOT @scope/package style from npm — those don't contain our alias prefix
|
|
83
104
|
if import_path.startswith("./") or import_path.startswith("../"):
|
|
84
105
|
return False
|
|
@@ -119,6 +140,7 @@ def _find_file_in_repo(
|
|
|
119
140
|
candidate: str,
|
|
120
141
|
file_index: dict[str, str],
|
|
121
142
|
root: str,
|
|
143
|
+
extension: str = "",
|
|
122
144
|
) -> str | None:
|
|
123
145
|
"""
|
|
124
146
|
Given a candidate path (without extension), find the matching file
|
|
@@ -142,13 +164,38 @@ def _find_file_in_repo(
|
|
|
142
164
|
if candidate_str in file_index:
|
|
143
165
|
return candidate_str
|
|
144
166
|
|
|
145
|
-
|
|
167
|
+
if extension == ".java":
|
|
168
|
+
# Java candidate is like 'com/example/Config'
|
|
169
|
+
# We search for any file ending in this candidate + .java
|
|
170
|
+
suffix = candidate_str + ".java"
|
|
171
|
+
for path in file_index:
|
|
172
|
+
if path.endswith(suffix):
|
|
173
|
+
return path
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
if extension == ".py":
|
|
177
|
+
# Python: try exact candidate + .py
|
|
178
|
+
py_ext = candidate_str + ".py"
|
|
179
|
+
if py_ext in file_index:
|
|
180
|
+
return py_ext
|
|
181
|
+
# Try python package index
|
|
182
|
+
py_idx = candidate_str + "/__init__.py"
|
|
183
|
+
if py_idx in file_index:
|
|
184
|
+
return py_idx
|
|
185
|
+
# Fallback: search for any file ending in candidate + .py (handles src/ prefix omit)
|
|
186
|
+
suffix = "/" + py_ext
|
|
187
|
+
for path in file_index:
|
|
188
|
+
if path.endswith(suffix) or path == py_ext:
|
|
189
|
+
return path
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
# Node.js: Try adding each supported extension
|
|
146
193
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
147
194
|
with_ext = candidate_str + ext
|
|
148
195
|
if with_ext in file_index:
|
|
149
196
|
return with_ext
|
|
150
197
|
|
|
151
|
-
# Try as a directory index file
|
|
198
|
+
# Node.js: Try as a directory index file
|
|
152
199
|
for ext in [".tsx", ".ts", ".jsx", ".js"]:
|
|
153
200
|
index_path = candidate_str + "/index" + ext
|
|
154
201
|
if index_path in file_index:
|
|
@@ -225,26 +272,43 @@ def parse_imports(scan_result: ScanResult) -> ParseResult:
|
|
|
225
272
|
except Exception:
|
|
226
273
|
continue
|
|
227
274
|
|
|
228
|
-
raw_imports = _extract_raw_imports(source_code)
|
|
275
|
+
raw_imports = _extract_raw_imports(source_code, scanned_file.extension)
|
|
229
276
|
|
|
230
277
|
for raw in raw_imports:
|
|
231
|
-
if _is_external(raw):
|
|
278
|
+
if _is_external(raw, scanned_file.extension):
|
|
232
279
|
result.unresolved.append((scanned_file.path, raw))
|
|
233
280
|
continue
|
|
234
281
|
|
|
235
282
|
# Resolve to a candidate path string
|
|
236
|
-
if
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
283
|
+
if scanned_file.extension == ".java":
|
|
284
|
+
candidate = raw.replace(".", "/")
|
|
285
|
+
elif scanned_file.extension == ".py":
|
|
286
|
+
if raw.startswith("."):
|
|
287
|
+
# Relative python import: from .utils import foo
|
|
288
|
+
# Count leading dots
|
|
289
|
+
dots = len(raw) - len(raw.lstrip("."))
|
|
290
|
+
base_dir = Path(scanned_file.path).parent
|
|
291
|
+
# For every dot beyond the first, go up one directory
|
|
292
|
+
for _ in range(dots - 1):
|
|
293
|
+
base_dir = base_dir.parent
|
|
294
|
+
mod_path = raw.lstrip(".").replace(".", "/")
|
|
295
|
+
candidate = str(base_dir / mod_path) if mod_path else str(base_dir)
|
|
296
|
+
else:
|
|
297
|
+
# Absolute python import from root
|
|
298
|
+
candidate = raw.replace(".", "/")
|
|
242
299
|
else:
|
|
243
|
-
#
|
|
244
|
-
|
|
300
|
+
# Node.js
|
|
301
|
+
if raw.startswith("@/"):
|
|
302
|
+
resolved_alias = _resolve_alias(raw, alias_map)
|
|
303
|
+
if resolved_alias is None:
|
|
304
|
+
result.unresolved.append((scanned_file.path, raw))
|
|
305
|
+
continue
|
|
306
|
+
candidate = resolved_alias
|
|
307
|
+
else:
|
|
308
|
+
candidate = _resolve_relative(raw, scanned_file.path)
|
|
245
309
|
|
|
246
310
|
# Find the actual file in the repo
|
|
247
|
-
matched = _find_file_in_repo(candidate, file_index, scan_result.root)
|
|
311
|
+
matched = _find_file_in_repo(candidate, file_index, scan_result.root, scanned_file.extension)
|
|
248
312
|
|
|
249
313
|
if matched:
|
|
250
314
|
result.relationships.append(
|
package/python/main.py
CHANGED
|
@@ -184,53 +184,97 @@ def run_engine(repo_path: str, query_str: str, top_n: int):
|
|
|
184
184
|
# ---------------------------------------------------------------------------
|
|
185
185
|
def build_parser() -> argparse.ArgumentParser:
|
|
186
186
|
parser = argparse.ArgumentParser(
|
|
187
|
-
prog="
|
|
188
|
-
description=
|
|
189
|
-
"Repository Intelligence Engine — find the most relevant files "
|
|
190
|
-
"for a given change request without LLMs or embeddings."
|
|
191
|
-
),
|
|
187
|
+
prog="contextl",
|
|
188
|
+
description="Repository Intelligence Engine",
|
|
192
189
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
193
|
-
epilog=__doc__,
|
|
194
|
-
)
|
|
195
|
-
parser.add_argument("repo_path", help="Path to the repository root")
|
|
196
|
-
parser.add_argument("query", help="Natural-language query (e.g. 'fix the upload error handler')")
|
|
197
|
-
parser.add_argument(
|
|
198
|
-
"--top", "-n",
|
|
199
|
-
type=int,
|
|
200
|
-
default=5,
|
|
201
|
-
metavar="N",
|
|
202
|
-
help="Number of results to return (default: 5)",
|
|
203
|
-
)
|
|
204
|
-
parser.add_argument(
|
|
205
|
-
"--json",
|
|
206
|
-
action="store_true",
|
|
207
|
-
default=False,
|
|
208
|
-
help="Output clean JSON instead of human-readable text",
|
|
209
190
|
)
|
|
191
|
+
|
|
192
|
+
subparsers = parser.add_subparsers(dest="command", required=True, help="Available commands")
|
|
193
|
+
|
|
194
|
+
# 1. Search
|
|
195
|
+
search_parser = subparsers.add_parser("search", help="Search the codebase using natural language")
|
|
196
|
+
search_parser.add_argument("repo_path", help="Path to the repository root")
|
|
197
|
+
search_parser.add_argument("query", help="Natural-language query (e.g. 'fix the upload error handler')")
|
|
198
|
+
search_parser.add_argument("--top", "-n", type=int, default=5, help="Number of results to return (default: 5)")
|
|
199
|
+
search_parser.add_argument("--json", action="store_true", help="Output clean JSON instead of human-readable text")
|
|
200
|
+
|
|
201
|
+
# 2. Dead Code
|
|
202
|
+
dead_parser = subparsers.add_parser("dead-code", help="Find files with 0 in-degree dependencies")
|
|
203
|
+
dead_parser.add_argument("repo_path", help="Path to the repository root")
|
|
204
|
+
|
|
205
|
+
# 3. Impact Analysis
|
|
206
|
+
impact_parser = subparsers.add_parser("impact", help="Analyze the impact of changing a file")
|
|
207
|
+
impact_parser.add_argument("repo_path", help="Path to the repository root")
|
|
208
|
+
impact_parser.add_argument("target_file", help="Relative path to the file being changed")
|
|
209
|
+
|
|
210
|
+
# 4. Obsidian Export
|
|
211
|
+
obsidian_parser = subparsers.add_parser("obsidian", help="Export the repository graph to an Obsidian Vault")
|
|
212
|
+
obsidian_parser.add_argument("repo_path", help="Path to the repository root")
|
|
213
|
+
obsidian_parser.add_argument("output_dir", help="Absolute path to save the Obsidian markdown files")
|
|
214
|
+
|
|
210
215
|
return parser
|
|
211
216
|
|
|
212
217
|
|
|
213
218
|
def main():
|
|
219
|
+
# To maintain backward compatibility with old `contextl <repo> <query>`
|
|
220
|
+
# we manually inject "search" if the first argument isn't a known command.
|
|
221
|
+
if len(sys.argv) >= 2 and sys.argv[1] not in ["search", "dead-code", "impact", "obsidian", "-h", "--help"]:
|
|
222
|
+
sys.argv.insert(1, "search")
|
|
223
|
+
|
|
214
224
|
parser = build_parser()
|
|
215
225
|
args = parser.parse_args()
|
|
216
226
|
|
|
217
|
-
|
|
227
|
+
if args.command == "search":
|
|
228
|
+
results, repo_graph, elapsed = run_engine(args.repo_path, args.query, args.top)
|
|
229
|
+
|
|
230
|
+
if not results:
|
|
231
|
+
if args.json:
|
|
232
|
+
print(json.dumps({
|
|
233
|
+
"query": args.query,
|
|
234
|
+
"repo": str(Path(args.repo_path).resolve()),
|
|
235
|
+
"results": [],
|
|
236
|
+
}, indent=2))
|
|
237
|
+
else:
|
|
238
|
+
print(f"\nNo relevant files found for: '{args.query}'")
|
|
239
|
+
sys.exit(0)
|
|
218
240
|
|
|
219
|
-
if not results:
|
|
220
241
|
if args.json:
|
|
221
|
-
print(
|
|
222
|
-
"query": args.query,
|
|
223
|
-
"repo": str(Path(args.repo_path).resolve()),
|
|
224
|
-
"results": [],
|
|
225
|
-
}, indent=2))
|
|
242
|
+
print(_format_json(args.query, args.repo_path, results, repo_graph))
|
|
226
243
|
else:
|
|
227
|
-
print(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
244
|
+
print(_format_human(args.query, args.repo_path, results, repo_graph, elapsed))
|
|
245
|
+
|
|
246
|
+
elif args.command == "dead-code":
|
|
247
|
+
from dead_code import find_dead_files
|
|
248
|
+
scan = scan_repo(args.repo_path)
|
|
249
|
+
parse = parse_imports(scan)
|
|
250
|
+
repo_graph = build_graph(scan, parse)
|
|
251
|
+
dead = find_dead_files(repo_graph)
|
|
252
|
+
print(f"Found {len(dead)} potentially dead files:")
|
|
253
|
+
for d in sorted(dead):
|
|
254
|
+
print(f" {d}")
|
|
255
|
+
|
|
256
|
+
elif args.command == "impact":
|
|
257
|
+
from impact_analysis import analyze_impact
|
|
258
|
+
scan = scan_repo(args.repo_path)
|
|
259
|
+
parse = parse_imports(scan)
|
|
260
|
+
repo_graph = build_graph(scan, parse)
|
|
261
|
+
try:
|
|
262
|
+
report = analyze_impact(args.target_file, repo_graph)
|
|
263
|
+
print(report.summary())
|
|
264
|
+
print("\nDependency tree:")
|
|
265
|
+
print(report.to_tree_string())
|
|
266
|
+
except ValueError as e:
|
|
267
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
elif args.command == "obsidian":
|
|
271
|
+
from obsidian_export import export_obsidian_vault
|
|
272
|
+
scan = scan_repo(args.repo_path)
|
|
273
|
+
parse = parse_imports(scan)
|
|
274
|
+
repo_graph = build_graph(scan, parse)
|
|
275
|
+
vault_path = export_obsidian_vault(repo_graph, args.output_dir)
|
|
276
|
+
print(f"Obsidian Vault successfully generated at: {vault_path}")
|
|
277
|
+
print("Open this folder in Obsidian to view your codebase graph!")
|
|
234
278
|
|
|
235
279
|
|
|
236
280
|
if __name__ == "__main__":
|
package/python/mcp_server.py
CHANGED
|
@@ -35,6 +35,8 @@ from graph_builder import build_graph
|
|
|
35
35
|
from query_engine import query as engine_query
|
|
36
36
|
from main import _confidence, _reasoning
|
|
37
37
|
from impact_analysis import analyze_impact
|
|
38
|
+
from dead_code import find_dead_files
|
|
39
|
+
from obsidian_export import export_obsidian_vault
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
# ---------------------------------------------------------------------------
|
|
@@ -139,6 +141,47 @@ async def list_tools() -> list[types.Tool]:
|
|
|
139
141
|
"required": ["repo_path", "target_file"],
|
|
140
142
|
},
|
|
141
143
|
),
|
|
144
|
+
types.Tool(
|
|
145
|
+
name="find_dead_files",
|
|
146
|
+
description=(
|
|
147
|
+
"Find files in the repository that are never imported by any other file "
|
|
148
|
+
"(in-degree of 0 in the dependency graph). Automatically filters out "
|
|
149
|
+
"standard entry points and test files. Use this to identify dead code "
|
|
150
|
+
"and unused files that can potentially be deleted."
|
|
151
|
+
),
|
|
152
|
+
inputSchema={
|
|
153
|
+
"type": "object",
|
|
154
|
+
"properties": {
|
|
155
|
+
"repo_path": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
"description": "Absolute or relative path to the repository root.",
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
"required": ["repo_path"],
|
|
161
|
+
},
|
|
162
|
+
),
|
|
163
|
+
types.Tool(
|
|
164
|
+
name="export_obsidian_vault",
|
|
165
|
+
description=(
|
|
166
|
+
"Generates an Obsidian Vault from the repository graph. "
|
|
167
|
+
"Creates a folder of interconnected Markdown files using wikilinks "
|
|
168
|
+
"so the codebase can be visualized in Obsidian's 3D graph view."
|
|
169
|
+
),
|
|
170
|
+
inputSchema={
|
|
171
|
+
"type": "object",
|
|
172
|
+
"properties": {
|
|
173
|
+
"repo_path": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"description": "Absolute or relative path to the repository root.",
|
|
176
|
+
},
|
|
177
|
+
"output_dir": {
|
|
178
|
+
"type": "string",
|
|
179
|
+
"description": "Absolute path to a folder where the vault should be created.",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
"required": ["repo_path", "output_dir"],
|
|
183
|
+
},
|
|
184
|
+
),
|
|
142
185
|
]
|
|
143
186
|
|
|
144
187
|
|
|
@@ -157,6 +200,12 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
|
157
200
|
if name == "analyze_impact":
|
|
158
201
|
return await _handle_impact(arguments)
|
|
159
202
|
|
|
203
|
+
if name == "find_dead_files":
|
|
204
|
+
return await _handle_dead_files(arguments)
|
|
205
|
+
|
|
206
|
+
if name == "export_obsidian_vault":
|
|
207
|
+
return await _handle_export_obsidian(arguments)
|
|
208
|
+
|
|
160
209
|
return [types.TextContent(
|
|
161
210
|
type="text",
|
|
162
211
|
text=json.dumps({"error": f"Unknown tool: {name}"}),
|
|
@@ -269,6 +318,62 @@ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
|
|
|
269
318
|
}
|
|
270
319
|
|
|
271
320
|
|
|
321
|
+
async def _handle_dead_files(args: dict) -> list[types.TextContent]:
|
|
322
|
+
repo_path = args.get("repo_path", "")
|
|
323
|
+
|
|
324
|
+
loop = asyncio.get_event_loop()
|
|
325
|
+
try:
|
|
326
|
+
result = await loop.run_in_executor(None, _run_dead_files, repo_path)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
result = {"error": str(e), "repo": repo_path}
|
|
329
|
+
|
|
330
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _run_dead_files(repo_path: str) -> dict:
|
|
334
|
+
"""Synchronous pipeline — called from a thread executor."""
|
|
335
|
+
scan = scan_repo(repo_path)
|
|
336
|
+
parse = parse_imports(scan)
|
|
337
|
+
repo_graph = build_graph(scan, parse)
|
|
338
|
+
dead_files = find_dead_files(repo_graph)
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"repo": scan.root,
|
|
342
|
+
"total_files_scanned": scan.total_files,
|
|
343
|
+
"total_dead_files": len(dead_files),
|
|
344
|
+
"dead_files": dead_files
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
|
|
349
|
+
repo_path = args.get("repo_path", "")
|
|
350
|
+
output_dir = args.get("output_dir", "")
|
|
351
|
+
|
|
352
|
+
loop = asyncio.get_event_loop()
|
|
353
|
+
try:
|
|
354
|
+
result = await loop.run_in_executor(None, _run_export_obsidian, repo_path, output_dir)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
result = {"error": str(e), "repo": repo_path, "output_dir": output_dir}
|
|
357
|
+
|
|
358
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _run_export_obsidian(repo_path: str, output_dir: str) -> dict:
|
|
362
|
+
"""Synchronous pipeline — called from a thread executor."""
|
|
363
|
+
scan = scan_repo(repo_path)
|
|
364
|
+
parse = parse_imports(scan)
|
|
365
|
+
repo_graph = build_graph(scan, parse)
|
|
366
|
+
|
|
367
|
+
vault_path = export_obsidian_vault(repo_graph, output_dir)
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
"repo": scan.root,
|
|
371
|
+
"vault_path": vault_path,
|
|
372
|
+
"total_files_exported": scan.total_files,
|
|
373
|
+
"success": True
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
272
377
|
# ---------------------------------------------------------------------------
|
|
273
378
|
# Entry point
|
|
274
379
|
# ---------------------------------------------------------------------------
|
|
@@ -283,33 +388,11 @@ async def main():
|
|
|
283
388
|
|
|
284
389
|
if __name__ == "__main__":
|
|
285
390
|
if len(sys.argv) > 1:
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
parser.add_argument("query", help="Natural language query")
|
|
289
|
-
parser.add_argument("--top", type=int, default=5, help="Maximum number of files to return")
|
|
290
|
-
parser.add_argument("--json", action="store_true", help="Output results in JSON format")
|
|
291
|
-
|
|
292
|
-
args = parser.parse_args()
|
|
293
|
-
|
|
391
|
+
# Fallback for CLI users: route all arguments to the main.py CLI engine
|
|
392
|
+
from main import main as cli_main
|
|
294
393
|
try:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
print(json.dumps(result, indent=2))
|
|
299
|
-
else:
|
|
300
|
-
print(f"\n> Query: {args.query}")
|
|
301
|
-
print(f"> Repository: {args.repo_path}\n")
|
|
302
|
-
|
|
303
|
-
for res in result["results"]:
|
|
304
|
-
print(f"[{res['rank']}] {res['path']}")
|
|
305
|
-
print(f" Score: {res['score']} ({res['confidence']})")
|
|
306
|
-
print(f" Terms: {', '.join(res['matched_terms'])}")
|
|
307
|
-
print(f" Reasoning: {res['reasoning']}\n")
|
|
308
|
-
except Exception as e:
|
|
309
|
-
if args.json:
|
|
310
|
-
print(json.dumps({"error": str(e)}, indent=2))
|
|
311
|
-
else:
|
|
312
|
-
print(f"Error: {str(e)}", file=sys.stderr)
|
|
313
|
-
sys.exit(1)
|
|
394
|
+
cli_main()
|
|
395
|
+
except KeyboardInterrupt:
|
|
396
|
+
sys.exit(0)
|
|
314
397
|
else:
|
|
315
398
|
asyncio.run(main())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository Intelligence Engine
|
|
3
|
+
Step 7: Obsidian Exporter
|
|
4
|
+
|
|
5
|
+
Exports the RepoGraph into a folder of interconnected Markdown files.
|
|
6
|
+
By opening the folder as an Obsidian Vault, users get an instant 3D interactive
|
|
7
|
+
visualization of their entire codebase.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from scanner import scan_repo
|
|
16
|
+
from import_parser import parse_imports
|
|
17
|
+
from graph_builder import build_graph, RepoGraph
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
PYTHON_DOCSTRING_PATTERN = re.compile(r'^\s*["\']{3}(.*?)["\']{3}', re.DOTALL)
|
|
21
|
+
JS_DOC_PATTERN = re.compile(r'^\s*/\*\*(.*?)\*/', re.DOTALL)
|
|
22
|
+
|
|
23
|
+
def _extract_explanation(file_path: str, extension: str, root_dir: str) -> str:
|
|
24
|
+
"""Reads the top-level docstring or JSDoc block from the file."""
|
|
25
|
+
try:
|
|
26
|
+
content = (Path(root_dir) / file_path).read_text(encoding="utf-8")
|
|
27
|
+
if extension == ".py":
|
|
28
|
+
match = PYTHON_DOCSTRING_PATTERN.search(content)
|
|
29
|
+
if match:
|
|
30
|
+
return match.group(1).strip()
|
|
31
|
+
elif extension in {".ts", ".tsx", ".js", ".jsx", ".java"}:
|
|
32
|
+
match = JS_DOC_PATTERN.search(content)
|
|
33
|
+
if match:
|
|
34
|
+
# Clean up leading asterisks from JSDoc lines
|
|
35
|
+
lines = match.group(1).split('\n')
|
|
36
|
+
cleaned = [line.strip().lstrip('*').strip() for line in lines]
|
|
37
|
+
return "\n".join(cleaned).strip()
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
return "*No explanation provided in source code.*"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def export_obsidian_vault(repo_graph: RepoGraph, output_dir: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Generates a markdown file for every node in the graph, with
|
|
46
|
+
Obsidian wikilinks connecting them based on imports.
|
|
47
|
+
"""
|
|
48
|
+
out_path = Path(output_dir).resolve()
|
|
49
|
+
|
|
50
|
+
# Create the output directory (clean it if it exists)
|
|
51
|
+
if out_path.exists():
|
|
52
|
+
shutil.rmtree(out_path)
|
|
53
|
+
out_path.mkdir(parents=True)
|
|
54
|
+
|
|
55
|
+
for path, node in repo_graph.nodes.items():
|
|
56
|
+
# We replace slashes with dashes to keep a flat folder structure
|
|
57
|
+
# so Obsidian graph view looks clean, but keep original name as title
|
|
58
|
+
safe_name = path.replace("/", "-").replace("\\", "-")
|
|
59
|
+
md_file = out_path / f"{safe_name}.md"
|
|
60
|
+
|
|
61
|
+
dependencies = repo_graph.get_dependencies(path)
|
|
62
|
+
dependents = repo_graph.get_dependents(path)
|
|
63
|
+
|
|
64
|
+
explanation = _extract_explanation(path, node.extension, repo_graph.root)
|
|
65
|
+
|
|
66
|
+
content = [
|
|
67
|
+
f"# {path}",
|
|
68
|
+
"",
|
|
69
|
+
"## Explanation",
|
|
70
|
+
explanation,
|
|
71
|
+
"",
|
|
72
|
+
"## Metrics",
|
|
73
|
+
f"**Extension:** `{node.extension}`",
|
|
74
|
+
f"**Size:** {node.size_bytes} bytes",
|
|
75
|
+
f"**Centrality Score:** {node.centrality:.4f}",
|
|
76
|
+
"",
|
|
77
|
+
"## Imports (Dependencies)",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
if dependencies:
|
|
81
|
+
for dep in sorted(dependencies):
|
|
82
|
+
dep_safe = dep.replace("/", "-").replace("\\", "-")
|
|
83
|
+
content.append(f"- [[{dep_safe}]]")
|
|
84
|
+
else:
|
|
85
|
+
content.append("*No internal imports*")
|
|
86
|
+
|
|
87
|
+
content.extend([
|
|
88
|
+
"",
|
|
89
|
+
"## Imported By (Dependents)"
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
if dependents:
|
|
93
|
+
for dep in sorted(dependents):
|
|
94
|
+
dep_safe = dep.replace("/", "-").replace("\\", "-")
|
|
95
|
+
content.append(f"- [[{dep_safe}]]")
|
|
96
|
+
else:
|
|
97
|
+
content.append("*Not imported by any file*")
|
|
98
|
+
|
|
99
|
+
md_file.write_text("\n".join(content), encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
return str(out_path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
import sys
|
|
106
|
+
|
|
107
|
+
if len(sys.argv) < 3:
|
|
108
|
+
print("Usage: python obsidian_export.py <repo_path> <output_dir>")
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
repo_path = sys.argv[1]
|
|
112
|
+
output_dir = sys.argv[2]
|
|
113
|
+
|
|
114
|
+
scan = scan_repo(repo_path)
|
|
115
|
+
parse = parse_imports(scan)
|
|
116
|
+
repo_graph = build_graph(scan, parse)
|
|
117
|
+
|
|
118
|
+
vault_path = export_obsidian_vault(repo_graph, output_dir)
|
|
119
|
+
print(f"Obsidian Vault successfully generated at: {vault_path}")
|
|
120
|
+
print("Open this folder in Obsidian to view your codebase graph!")
|
package/python/scanner.py
CHANGED