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 +1 -1
- package/python/main.py +9 -9
- package/python/mcp_server.py +107 -34
- package/python/{dead_code.py → standalone.py} +13 -9
package/package.json
CHANGED
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.
|
|
202
|
-
|
|
203
|
-
|
|
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", "
|
|
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 == "
|
|
247
|
-
from
|
|
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
|
-
|
|
252
|
-
print(f"Found {len(
|
|
253
|
-
for d in sorted(
|
|
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":
|
package/python/mcp_server.py
CHANGED
|
@@ -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
|
|
15
|
-
scan_repo
|
|
16
|
-
analyze_impact
|
|
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
|
|
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="
|
|
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
|
|
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 == "
|
|
207
|
-
return await
|
|
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
|
-
"""
|
|
235
|
-
scan =
|
|
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
|
-
|
|
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
|
-
"""
|
|
298
|
-
scan =
|
|
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
|
|
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,
|
|
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
|
|
337
|
-
"""
|
|
338
|
-
scan =
|
|
339
|
-
|
|
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
|
-
"
|
|
347
|
-
"
|
|
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
|
-
"""
|
|
366
|
-
scan =
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
standalone_files.append(path)
|
|
43
47
|
|
|
44
|
-
return sorted(
|
|
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
|
|
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
|
-
|
|
65
|
+
standalone = find_standalone_files(repo_graph)
|
|
62
66
|
|
|
63
|
-
print(f"Found {len(
|
|
64
|
-
for f in
|
|
67
|
+
print(f"Found {len(standalone)} standalone files:")
|
|
68
|
+
for f in standalone:
|
|
65
69
|
print(f" - {f}")
|