contextl 1.1.4 → 1.2.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.
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 — built for Next.js and React codebases first.
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
- Python, Go, and Rust support are on the roadmap.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.1.4",
3
+ "version": "1.2.1",
4
4
  "description": "contextl — finds the most relevant files in your codebase for any change request. MCP server for AI coding agents.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -14,11 +14,14 @@
14
14
  "vscode",
15
15
  "typescript",
16
16
  "nextjs",
17
+ "python",
18
+ "java",
19
+ "obsidian",
17
20
  "repository"
18
21
  ],
19
- "homepage": "https://github.com/dev7shah/prune#readme",
22
+ "homepage": "https://github.com/DS0710-coder/contextl#readme",
20
23
  "bugs": {
21
- "url": "https://github.com/dev7shah/prune/issues"
24
+ "url": "https://github.com/DS0710-coder/contextl/issues"
22
25
  },
23
26
  "license": "MIT",
24
27
  "author": "dev7shah",
@@ -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}")
@@ -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 TypeScript/JSX file."""
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
- for match in IMPORT_PATTERN.finditer(source_code):
73
- paths.append(match.group(1))
74
- for match in DYNAMIC_IMPORT_PATTERN.finditer(source_code):
75
- paths.append(match.group(1))
76
- return paths
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
- def _is_external(import_path: str) -> bool:
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
- # Try adding each supported extension
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 (e.g. components/Button/index.tsx)
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 raw.startswith("@/"):
237
- resolved_alias = _resolve_alias(raw, alias_map)
238
- if resolved_alias is None:
239
- result.unresolved.append((scanned_file.path, raw))
240
- continue
241
- candidate = resolved_alias
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
- # Relative import
244
- candidate = _resolve_relative(raw, scanned_file.path)
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="prune",
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
- results, repo_graph, elapsed = run_engine(args.repo_path, args.query, args.top)
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(json.dumps({
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(f"\nNo relevant files found for: '{args.query}'")
228
- sys.exit(0)
229
-
230
- if args.json:
231
- print(_format_json(args.query, args.repo_path, results, repo_graph))
232
- else:
233
- print(_format_human(args.query, args.repo_path, results, repo_graph, elapsed))
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__":
@@ -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
- parser = argparse.ArgumentParser(description="ContextL Repository Intelligence Engine")
287
- parser.add_argument("repo_path", help="Path to the repository to query")
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
- result = _run_query(args.repo_path, args.query, args.top)
296
-
297
- if args.json:
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
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  from dataclasses import dataclass, field
12
12
 
13
13
 
14
- SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js"}
14
+ SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java"}
15
15
 
16
16
  IGNORED_DIRS = {
17
17
  "node_modules",