contextl 1.2.11 → 1.2.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.11",
3
+ "version": "1.2.13",
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",
package/python/main.py CHANGED
@@ -198,9 +198,9 @@ def build_parser() -> argparse.ArgumentParser:
198
198
  search_parser.add_argument("--top", "-n", type=int, default=5, help="Number of results to return (default: 5)")
199
199
  search_parser.add_argument("--json", action="store_true", help="Output clean JSON instead of human-readable text")
200
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")
201
+ # 2. Standalone Files
202
+ standalone_parser = subparsers.add_parser("standalone", help="Find files with 0 in-degree dependencies (unimported)")
203
+ standalone_parser.add_argument("repo_path", help="Path to the repository root")
204
204
 
205
205
  # 3. Impact Analysis
206
206
  impact_parser = subparsers.add_parser("impact", help="Analyze the impact of changing a file")
@@ -218,7 +218,7 @@ def build_parser() -> argparse.ArgumentParser:
218
218
  def main():
219
219
  # To maintain backward compatibility with old `contextl <repo> <query>`
220
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"]:
221
+ if len(sys.argv) >= 2 and sys.argv[1] not in ["search", "standalone", "impact", "obsidian", "-h", "--help"]:
222
222
  sys.argv.insert(1, "search")
223
223
 
224
224
  parser = build_parser()
@@ -243,14 +243,14 @@ def main():
243
243
  else:
244
244
  print(_format_human(args.query, args.repo_path, results, repo_graph, elapsed))
245
245
 
246
- elif args.command == "dead-code":
247
- from dead_code import find_dead_files
246
+ elif args.command == "standalone":
247
+ from standalone import find_standalone_files
248
248
  scan = scan_repo(args.repo_path)
249
249
  parse = parse_imports(scan)
250
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):
251
+ standalone = find_standalone_files(repo_graph)
252
+ print(f"Found {len(standalone)} standalone files:")
253
+ for d in sorted(standalone):
254
254
  print(f" {d}")
255
255
 
256
256
  elif args.command == "impact":
@@ -11,16 +11,27 @@ Usage (the IDE does this for you, but you can test manually):
11
11
  python mcp_server.py
12
12
 
13
13
  Tools exposed:
14
- query_repo — rank files by relevance to a natural-language query
15
- scan_repo — list all source files the engine can see in a repo
16
- analyze_impact — find every file affected by changing a target file
14
+ query_repo — rank files by relevance to a natural-language query
15
+ scan_repo — list all source files the engine can see in a repo
16
+ analyze_impact — find every file affected by changing a target file
17
+ find_standalone_files — find unimported files with in-degree 0
18
+ export_obsidian_vault — export repo graph to an Obsidian markdown vault
19
+
20
+ Performance:
21
+ A shared graph cache is maintained per repo path + file mtime fingerprint.
22
+ The 4-step build pipeline (scan → parse → graph → query) runs ONCE per
23
+ session and is reused by all tool calls. Subsequent calls on an unchanged
24
+ repo return from cache in <1ms.
17
25
  """
18
26
 
19
- import argparse
20
27
  import asyncio
21
28
  import json
29
+ import os
22
30
  import sys
31
+ import threading
32
+ from dataclasses import dataclass
23
33
  from pathlib import Path
34
+ from typing import Optional
24
35
 
25
36
  import mcp.types as types
26
37
  from mcp.server import Server
@@ -35,10 +46,80 @@ from graph_builder import build_graph
35
46
  from query_engine import query as engine_query
36
47
  from main import _confidence, _reasoning
37
48
  from impact_analysis import analyze_impact
38
- from dead_code import find_dead_files
49
+ from standalone import find_standalone_files
39
50
  from obsidian_export import export_obsidian_vault
40
51
 
41
52
 
53
+ # ---------------------------------------------------------------------------
54
+ # Shared graph cache — one pipeline build per repo per session
55
+ # ---------------------------------------------------------------------------
56
+ @dataclass
57
+ class _CacheEntry:
58
+ repo_path: str # resolved absolute path
59
+ mtime_hash: float # max mtime across all source files at build time
60
+ scan: object # ScanResult
61
+ repo_graph: object # RepoGraph
62
+
63
+
64
+ _cache_lock = threading.Lock()
65
+ _cache: Optional[_CacheEntry] = None
66
+
67
+
68
+ def _compute_mtime_hash(repo_path: str) -> float:
69
+ """Walk repo_path and return the maximum file modification time."""
70
+ max_mtime = 0.0
71
+ for dirpath, _, filenames in os.walk(repo_path):
72
+ # Skip hidden dirs and common noise dirs
73
+ dirpath_obj = Path(dirpath)
74
+ if any(part.startswith(".") or part in ("node_modules", "__pycache__", ".git", "dist", "build", "target")
75
+ for part in dirpath_obj.parts):
76
+ continue
77
+ for fname in filenames:
78
+ try:
79
+ mtime = os.path.getmtime(os.path.join(dirpath, fname))
80
+ if mtime > max_mtime:
81
+ max_mtime = mtime
82
+ except OSError:
83
+ pass
84
+ return max_mtime
85
+
86
+
87
+ def _get_graph(repo_path: str):
88
+ """
89
+ Return (scan, repo_graph) for the given repo_path.
90
+
91
+ On first call (or after any source file changes): runs the full
92
+ 4-step pipeline and stores the result in the module-level cache.
93
+ On subsequent calls with unchanged files: returns instantly from cache.
94
+ """
95
+ global _cache
96
+
97
+ resolved = str(Path(repo_path).resolve())
98
+ mtime_hash = _compute_mtime_hash(resolved)
99
+
100
+ with _cache_lock:
101
+ if (_cache is not None
102
+ and _cache.repo_path == resolved
103
+ and _cache.mtime_hash == mtime_hash):
104
+ print("[contextl] graph cache hit", file=sys.stderr, flush=True)
105
+ return _cache.scan, _cache.repo_graph
106
+
107
+ # Cache miss — build the pipeline
108
+ print("[contextl] building graph...", file=sys.stderr, flush=True)
109
+ scan = scan_repo(resolved)
110
+ parse = parse_imports(scan)
111
+ repo_graph = build_graph(scan, parse)
112
+
113
+ _cache = _CacheEntry(
114
+ repo_path=resolved,
115
+ mtime_hash=mtime_hash,
116
+ scan=scan,
117
+ repo_graph=repo_graph,
118
+ )
119
+ print(f"[contextl] graph built: {scan.total_files} files", file=sys.stderr, flush=True)
120
+ return scan, repo_graph
121
+
122
+
42
123
  # ---------------------------------------------------------------------------
43
124
  # Server setup
44
125
  # ---------------------------------------------------------------------------
@@ -145,11 +226,11 @@ async def list_tools() -> list[types.Tool]:
145
226
  },
146
227
  ),
147
228
  types.Tool(
148
- name="find_dead_files",
229
+ name="find_standalone_files",
149
230
  description=(
150
231
  "Find files in the repository that are never imported by any other file "
151
232
  "(in-degree of 0 in the dependency graph). Automatically filters out "
152
- "standard entry points and test files. Use this to identify dead code "
233
+ "standard entry points and test files. Use this to identify standalone code "
153
234
  "and unused files that can potentially be deleted."
154
235
  ),
155
236
  inputSchema={
@@ -203,8 +284,8 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
203
284
  if name == "analyze_impact":
204
285
  return await _handle_impact(arguments)
205
286
 
206
- if name == "find_dead_files":
207
- return await _handle_dead_files(arguments)
287
+ if name == "find_standalone_files":
288
+ return await _handle_standalone_files(arguments)
208
289
 
209
290
  if name == "export_obsidian_vault":
210
291
  return await _handle_export_obsidian(arguments)
@@ -231,10 +312,8 @@ async def _handle_query(args: dict) -> list[types.TextContent]:
231
312
 
232
313
 
233
314
  def _run_query(repo_path: str, query_str: str, top_n: int) -> dict:
234
- """Synchronous pipelinecalled from a thread executor."""
235
- scan = scan_repo(repo_path)
236
- parse = parse_imports(scan)
237
- repo_graph = build_graph(scan, parse)
315
+ """Uses cached graph pipeline runs only on first call or after file changes."""
316
+ scan, repo_graph = _get_graph(repo_path)
238
317
  ranked = engine_query(query_str, repo_graph, top_n=top_n)
239
318
 
240
319
  return {
@@ -268,7 +347,8 @@ async def _handle_scan(args: dict) -> list[types.TextContent]:
268
347
 
269
348
 
270
349
  def _run_scan(repo_path: str) -> dict:
271
- scan = scan_repo(repo_path)
350
+ """Uses cached graph — pipeline runs only on first call or after file changes."""
351
+ scan, _ = _get_graph(repo_path)
272
352
  return {
273
353
  "repo": scan.root,
274
354
  "total_files": scan.total_files,
@@ -294,10 +374,8 @@ async def _handle_impact(args: dict) -> list[types.TextContent]:
294
374
 
295
375
 
296
376
  def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
297
- """Synchronous pipelinecalled from a thread executor."""
298
- scan = scan_repo(repo_path)
299
- parse = parse_imports(scan)
300
- repo_graph = build_graph(scan, parse)
377
+ """Uses cached graph pipeline runs only on first call or after file changes."""
378
+ scan, repo_graph = _get_graph(repo_path)
301
379
  report = analyze_impact(target_file, repo_graph, max_depth=max_depth)
302
380
 
303
381
  return {
@@ -321,30 +399,28 @@ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
321
399
  }
322
400
 
323
401
 
324
- async def _handle_dead_files(args: dict) -> list[types.TextContent]:
402
+ async def _handle_standalone_files(args: dict) -> list[types.TextContent]:
325
403
  repo_path = args.get("repo_path", "")
326
404
 
327
405
  loop = asyncio.get_event_loop()
328
406
  try:
329
- result = await loop.run_in_executor(None, _run_dead_files, repo_path)
407
+ result = await loop.run_in_executor(None, _run_standalone_files, repo_path)
330
408
  except Exception as e:
331
409
  result = {"error": str(e), "repo": repo_path}
332
410
 
333
411
  return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
334
412
 
335
413
 
336
- def _run_dead_files(repo_path: str) -> dict:
337
- """Synchronous pipelinecalled from a thread executor."""
338
- scan = scan_repo(repo_path)
339
- parse = parse_imports(scan)
340
- repo_graph = build_graph(scan, parse)
341
- dead_files = find_dead_files(repo_graph)
414
+ def _run_standalone_files(repo_path: str) -> dict:
415
+ """Uses cached graph pipeline runs only on first call or after file changes."""
416
+ scan, repo_graph = _get_graph(repo_path)
417
+ standalone_files = find_standalone_files(repo_graph)
342
418
 
343
419
  return {
344
420
  "repo": scan.root,
345
421
  "total_files_scanned": scan.total_files,
346
- "total_dead_files": len(dead_files),
347
- "dead_files": dead_files
422
+ "total_standalone_files": len(standalone_files),
423
+ "standalone_files": standalone_files
348
424
  }
349
425
 
350
426
 
@@ -362,13 +438,10 @@ async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
362
438
 
363
439
 
364
440
  def _run_export_obsidian(repo_path: str, output_dir: str) -> dict:
365
- """Synchronous pipelinecalled from a thread executor."""
366
- scan = scan_repo(repo_path)
367
- parse = parse_imports(scan)
368
- repo_graph = build_graph(scan, parse)
369
-
441
+ """Uses cached graph pipeline runs only on first call or after file changes."""
442
+ scan, repo_graph = _get_graph(repo_path)
370
443
  vault_path = export_obsidian_vault(repo_graph, output_dir)
371
-
444
+
372
445
  return {
373
446
  "repo": scan.root,
374
447
  "vault_path": vault_path,
@@ -1,6 +1,6 @@
1
1
  """
2
2
  Repository Intelligence Engine
3
- Step 7: Dead Code Analysis
3
+ Step 7: Standalone Files Analysis
4
4
 
5
5
  Finds files in the dependency graph that have an in-degree of 0
6
6
  (meaning they are never imported by any other file), while filtering
@@ -19,29 +19,33 @@ ENTRY_POINT_MARKERS = {
19
19
  "globals.css", "global.css", "tailwind.css",
20
20
  "index.ts", "index.js", "index.tsx", "index.jsx",
21
21
  "main.ts", "main.js", "main.py",
22
+ "app.ts", "app.js", "app.tsx", "app.jsx", "app.py",
23
+ "mcp_server.py", "server.ts", "server.js", "server.py",
22
24
  "__main__.py", "__init__.py", "setup.py", "conftest.py",
23
25
  "next.config", "tailwind.config", "postcss.config", "vite.config", "webpack.config"
24
26
  }
25
27
 
26
28
  def _is_entry_point(path: str) -> bool:
27
29
  lowered = path.lower()
30
+ if lowered.endswith(".d.ts"):
31
+ return True
28
32
  for marker in ENTRY_POINT_MARKERS:
29
33
  if marker in lowered:
30
34
  return True
31
35
  return False
32
36
 
33
- def find_dead_files(repo_graph: RepoGraph) -> list[str]:
37
+ def find_standalone_files(repo_graph: RepoGraph) -> list[str]:
34
38
  """
35
39
  Find files with in-degree 0, excluding entry points and tests.
36
40
  """
37
- dead_files = []
41
+ standalone_files = []
38
42
 
39
43
  for path, node in repo_graph.nodes.items():
40
44
  if node.in_degree == 0:
41
45
  if not _is_test_file(path) and not _is_entry_point(path):
42
- dead_files.append(path)
46
+ standalone_files.append(path)
43
47
 
44
- return sorted(dead_files)
48
+ return sorted(standalone_files)
45
49
 
46
50
  if __name__ == "__main__":
47
51
  import sys
@@ -50,7 +54,7 @@ if __name__ == "__main__":
50
54
  from graph_builder import build_graph
51
55
 
52
56
  if len(sys.argv) < 2:
53
- print("Usage: python dead_code.py <repo_path>")
57
+ print("Usage: python standalone.py <repo_path>")
54
58
  sys.exit(1)
55
59
 
56
60
  repo_path = sys.argv[1]
@@ -58,8 +62,8 @@ if __name__ == "__main__":
58
62
  parse = parse_imports(scan)
59
63
  repo_graph = build_graph(scan, parse)
60
64
 
61
- dead = find_dead_files(repo_graph)
65
+ standalone = find_standalone_files(repo_graph)
62
66
 
63
- print(f"Found {len(dead)} potentially dead files:")
64
- for f in dead:
67
+ print(f"Found {len(standalone)} standalone files:")
68
+ for f in standalone:
65
69
  print(f" - {f}")