contextl 1.2.12 → 1.2.14
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/git_review.py +245 -0
- package/python/import_parser.py +1015 -155
- package/python/main.py +35 -1
- package/python/mcp_server.py +209 -23
package/python/main.py
CHANGED
|
@@ -212,13 +212,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
212
212
|
obsidian_parser.add_argument("repo_path", help="Path to the repository root")
|
|
213
213
|
obsidian_parser.add_argument("output_dir", help="Absolute path to save the Obsidian markdown files")
|
|
214
214
|
|
|
215
|
+
# 5. Git Review
|
|
216
|
+
review_parser = subparsers.add_parser("review", help="Build AI-ready context from your git changes")
|
|
217
|
+
review_parser.add_argument("repo_path", help="Path to the repository root (must be a git repo)")
|
|
218
|
+
review_parser.add_argument("--staged", action="store_true", help="Only include staged changes")
|
|
219
|
+
review_parser.add_argument("--unstaged", action="store_true", help="Only include unstaged changes")
|
|
220
|
+
review_parser.add_argument("--json", action="store_true", help="Output clean JSON instead of human-readable text")
|
|
221
|
+
|
|
215
222
|
return parser
|
|
216
223
|
|
|
217
224
|
|
|
218
225
|
def main():
|
|
219
226
|
# To maintain backward compatibility with old `contextl <repo> <query>`
|
|
220
227
|
# 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", "standalone", "impact", "obsidian", "-h", "--help"]:
|
|
228
|
+
if len(sys.argv) >= 2 and sys.argv[1] not in ["search", "standalone", "impact", "obsidian", "review", "-h", "--help"]:
|
|
222
229
|
sys.argv.insert(1, "search")
|
|
223
230
|
|
|
224
231
|
parser = build_parser()
|
|
@@ -276,6 +283,33 @@ def main():
|
|
|
276
283
|
print(f"Obsidian Vault successfully generated at: {vault_path}")
|
|
277
284
|
print("Open this folder in Obsidian to view your codebase graph!")
|
|
278
285
|
|
|
286
|
+
elif args.command == "review":
|
|
287
|
+
from git_review import _is_git_repo, get_changed_files, build_review_context, format_review_human, format_review_json
|
|
288
|
+
|
|
289
|
+
if not _is_git_repo(args.repo_path):
|
|
290
|
+
print(f"Error: '{args.repo_path}' is not a git repository.", file=sys.stderr)
|
|
291
|
+
sys.exit(1)
|
|
292
|
+
|
|
293
|
+
# Default: both staged + unstaged unless a flag is explicitly set
|
|
294
|
+
include_staged = args.staged or (not args.staged and not args.unstaged)
|
|
295
|
+
include_unstaged = args.unstaged or (not args.staged and not args.unstaged)
|
|
296
|
+
|
|
297
|
+
staged, unstaged = get_changed_files(args.repo_path, include_staged, include_unstaged)
|
|
298
|
+
|
|
299
|
+
if not staged and not unstaged:
|
|
300
|
+
print("No changed files found. Make some edits and try again.")
|
|
301
|
+
sys.exit(0)
|
|
302
|
+
|
|
303
|
+
scan = scan_repo(args.repo_path)
|
|
304
|
+
parse = parse_imports(scan)
|
|
305
|
+
repo_graph = build_graph(scan, parse)
|
|
306
|
+
ctx = build_review_context(args.repo_path, staged, unstaged, repo_graph)
|
|
307
|
+
|
|
308
|
+
if args.json:
|
|
309
|
+
print(format_review_json(ctx))
|
|
310
|
+
else:
|
|
311
|
+
print(format_review_human(ctx))
|
|
312
|
+
|
|
279
313
|
|
|
280
314
|
if __name__ == "__main__":
|
|
281
315
|
main()
|
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
|
|
@@ -37,6 +48,78 @@ from main import _confidence, _reasoning
|
|
|
37
48
|
from impact_analysis import analyze_impact
|
|
38
49
|
from standalone import find_standalone_files
|
|
39
50
|
from obsidian_export import export_obsidian_vault
|
|
51
|
+
from git_review import _is_git_repo, get_changed_files, build_review_context, format_review_json
|
|
52
|
+
from import_parser import extract_skeleton
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Shared graph cache — one pipeline build per repo per session
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
@dataclass
|
|
59
|
+
class _CacheEntry:
|
|
60
|
+
repo_path: str # resolved absolute path
|
|
61
|
+
mtime_hash: float # max mtime across all source files at build time
|
|
62
|
+
scan: object # ScanResult
|
|
63
|
+
repo_graph: object # RepoGraph
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_cache_lock = threading.Lock()
|
|
67
|
+
_cache: Optional[_CacheEntry] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _compute_mtime_hash(repo_path: str) -> float:
|
|
71
|
+
"""Walk repo_path and return the maximum file modification time."""
|
|
72
|
+
max_mtime = 0.0
|
|
73
|
+
for dirpath, _, filenames in os.walk(repo_path):
|
|
74
|
+
# Skip hidden dirs and common noise dirs
|
|
75
|
+
dirpath_obj = Path(dirpath)
|
|
76
|
+
if any(part.startswith(".") or part in ("node_modules", "__pycache__", ".git", "dist", "build", "target")
|
|
77
|
+
for part in dirpath_obj.parts):
|
|
78
|
+
continue
|
|
79
|
+
for fname in filenames:
|
|
80
|
+
try:
|
|
81
|
+
mtime = os.path.getmtime(os.path.join(dirpath, fname))
|
|
82
|
+
if mtime > max_mtime:
|
|
83
|
+
max_mtime = mtime
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
return max_mtime
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _get_graph(repo_path: str):
|
|
90
|
+
"""
|
|
91
|
+
Return (scan, repo_graph) for the given repo_path.
|
|
92
|
+
|
|
93
|
+
On first call (or after any source file changes): runs the full
|
|
94
|
+
4-step pipeline and stores the result in the module-level cache.
|
|
95
|
+
On subsequent calls with unchanged files: returns instantly from cache.
|
|
96
|
+
"""
|
|
97
|
+
global _cache
|
|
98
|
+
|
|
99
|
+
resolved = str(Path(repo_path).resolve())
|
|
100
|
+
mtime_hash = _compute_mtime_hash(resolved)
|
|
101
|
+
|
|
102
|
+
with _cache_lock:
|
|
103
|
+
if (_cache is not None
|
|
104
|
+
and _cache.repo_path == resolved
|
|
105
|
+
and _cache.mtime_hash == mtime_hash):
|
|
106
|
+
print("[contextl] graph cache hit", file=sys.stderr, flush=True)
|
|
107
|
+
return _cache.scan, _cache.repo_graph
|
|
108
|
+
|
|
109
|
+
# Cache miss — build the pipeline
|
|
110
|
+
print("[contextl] building graph...", file=sys.stderr, flush=True)
|
|
111
|
+
scan = scan_repo(resolved)
|
|
112
|
+
parse = parse_imports(scan)
|
|
113
|
+
repo_graph = build_graph(scan, parse)
|
|
114
|
+
|
|
115
|
+
_cache = _CacheEntry(
|
|
116
|
+
repo_path=resolved,
|
|
117
|
+
mtime_hash=mtime_hash,
|
|
118
|
+
scan=scan,
|
|
119
|
+
repo_graph=repo_graph,
|
|
120
|
+
)
|
|
121
|
+
print(f"[contextl] graph built: {scan.total_files} files", file=sys.stderr, flush=True)
|
|
122
|
+
return scan, repo_graph
|
|
40
123
|
|
|
41
124
|
|
|
42
125
|
# ---------------------------------------------------------------------------
|
|
@@ -185,6 +268,60 @@ async def list_tools() -> list[types.Tool]:
|
|
|
185
268
|
"required": ["repo_path", "output_dir"],
|
|
186
269
|
},
|
|
187
270
|
),
|
|
271
|
+
types.Tool(
|
|
272
|
+
name="review_changes",
|
|
273
|
+
description=(
|
|
274
|
+
"Git-aware context builder. Reads the current git diff (staged and/or "
|
|
275
|
+
"unstaged) to find what files you are editing, then automatically runs "
|
|
276
|
+
"impact analysis on each changed file to find every file that depends on "
|
|
277
|
+
"your changes. Returns a structured context bundle containing: the changed "
|
|
278
|
+
"files, their full blast radius (direct + transitive dependents), and a "
|
|
279
|
+
"list of test files that should be re-run. Use this at the START of any "
|
|
280
|
+
"code review or refactor session to instantly understand the full scope "
|
|
281
|
+
"of your changes before touching anything."
|
|
282
|
+
),
|
|
283
|
+
inputSchema={
|
|
284
|
+
"type": "object",
|
|
285
|
+
"properties": {
|
|
286
|
+
"repo_path": {
|
|
287
|
+
"type": "string",
|
|
288
|
+
"description": "Absolute or relative path to the git repository root.",
|
|
289
|
+
},
|
|
290
|
+
"staged": {
|
|
291
|
+
"type": "boolean",
|
|
292
|
+
"description": "Include staged changes (git diff --cached). Default true.",
|
|
293
|
+
"default": True,
|
|
294
|
+
},
|
|
295
|
+
"unstaged": {
|
|
296
|
+
"type": "boolean",
|
|
297
|
+
"description": "Include unstaged working-tree changes (git diff). Default true.",
|
|
298
|
+
"default": True,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
"required": ["repo_path"],
|
|
302
|
+
},
|
|
303
|
+
),
|
|
304
|
+
types.Tool(
|
|
305
|
+
name="get_skeleton",
|
|
306
|
+
description=(
|
|
307
|
+
"Extract the structural skeleton (API surface) of a source file — all "
|
|
308
|
+
"class names, method signatures, function signatures, return types, and "
|
|
309
|
+
"docstrings — without any implementation bodies. "
|
|
310
|
+
"Use this when you need to understand a file's API without consuming its "
|
|
311
|
+
"full token count. A 5,000-line file becomes a ~100-line reference header. "
|
|
312
|
+
"Supports: Python, TypeScript, JavaScript, TSX, JSX, Java, Rust."
|
|
313
|
+
),
|
|
314
|
+
inputSchema={
|
|
315
|
+
"type": "object",
|
|
316
|
+
"properties": {
|
|
317
|
+
"file_path": {
|
|
318
|
+
"type": "string",
|
|
319
|
+
"description": "Absolute path to the source file to extract the skeleton from.",
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
"required": ["file_path"],
|
|
323
|
+
},
|
|
324
|
+
),
|
|
188
325
|
]
|
|
189
326
|
|
|
190
327
|
|
|
@@ -209,6 +346,12 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
|
209
346
|
if name == "export_obsidian_vault":
|
|
210
347
|
return await _handle_export_obsidian(arguments)
|
|
211
348
|
|
|
349
|
+
if name == "review_changes":
|
|
350
|
+
return await _handle_review_changes(arguments)
|
|
351
|
+
|
|
352
|
+
if name == "get_skeleton":
|
|
353
|
+
return await _handle_get_skeleton(arguments)
|
|
354
|
+
|
|
212
355
|
return [types.TextContent(
|
|
213
356
|
type="text",
|
|
214
357
|
text=json.dumps({"error": f"Unknown tool: {name}"}),
|
|
@@ -231,10 +374,8 @@ async def _handle_query(args: dict) -> list[types.TextContent]:
|
|
|
231
374
|
|
|
232
375
|
|
|
233
376
|
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)
|
|
377
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
378
|
+
scan, repo_graph = _get_graph(repo_path)
|
|
238
379
|
ranked = engine_query(query_str, repo_graph, top_n=top_n)
|
|
239
380
|
|
|
240
381
|
return {
|
|
@@ -268,7 +409,8 @@ async def _handle_scan(args: dict) -> list[types.TextContent]:
|
|
|
268
409
|
|
|
269
410
|
|
|
270
411
|
def _run_scan(repo_path: str) -> dict:
|
|
271
|
-
|
|
412
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
413
|
+
scan, _ = _get_graph(repo_path)
|
|
272
414
|
return {
|
|
273
415
|
"repo": scan.root,
|
|
274
416
|
"total_files": scan.total_files,
|
|
@@ -294,10 +436,8 @@ async def _handle_impact(args: dict) -> list[types.TextContent]:
|
|
|
294
436
|
|
|
295
437
|
|
|
296
438
|
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)
|
|
439
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
440
|
+
scan, repo_graph = _get_graph(repo_path)
|
|
301
441
|
report = analyze_impact(target_file, repo_graph, max_depth=max_depth)
|
|
302
442
|
|
|
303
443
|
return {
|
|
@@ -334,10 +474,8 @@ async def _handle_standalone_files(args: dict) -> list[types.TextContent]:
|
|
|
334
474
|
|
|
335
475
|
|
|
336
476
|
def _run_standalone_files(repo_path: str) -> dict:
|
|
337
|
-
"""
|
|
338
|
-
scan =
|
|
339
|
-
parse = parse_imports(scan)
|
|
340
|
-
repo_graph = build_graph(scan, parse)
|
|
477
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
478
|
+
scan, repo_graph = _get_graph(repo_path)
|
|
341
479
|
standalone_files = find_standalone_files(repo_graph)
|
|
342
480
|
|
|
343
481
|
return {
|
|
@@ -362,13 +500,10 @@ async def _handle_export_obsidian(args: dict) -> list[types.TextContent]:
|
|
|
362
500
|
|
|
363
501
|
|
|
364
502
|
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
|
-
|
|
503
|
+
"""Uses cached graph — pipeline runs only on first call or after file changes."""
|
|
504
|
+
scan, repo_graph = _get_graph(repo_path)
|
|
370
505
|
vault_path = export_obsidian_vault(repo_graph, output_dir)
|
|
371
|
-
|
|
506
|
+
|
|
372
507
|
return {
|
|
373
508
|
"repo": scan.root,
|
|
374
509
|
"vault_path": vault_path,
|
|
@@ -377,6 +512,57 @@ def _run_export_obsidian(repo_path: str, output_dir: str) -> dict:
|
|
|
377
512
|
}
|
|
378
513
|
|
|
379
514
|
|
|
515
|
+
async def _handle_review_changes(args: dict) -> list[types.TextContent]:
|
|
516
|
+
repo_path = args.get("repo_path", "")
|
|
517
|
+
include_staged = bool(args.get("staged", True))
|
|
518
|
+
include_unstaged = bool(args.get("unstaged", True))
|
|
519
|
+
|
|
520
|
+
loop = asyncio.get_event_loop()
|
|
521
|
+
try:
|
|
522
|
+
result = await loop.run_in_executor(
|
|
523
|
+
None, _run_review_changes, repo_path, include_staged, include_unstaged
|
|
524
|
+
)
|
|
525
|
+
except Exception as e:
|
|
526
|
+
result = {"error": str(e), "repo": repo_path}
|
|
527
|
+
|
|
528
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _run_review_changes(repo_path: str, include_staged: bool, include_unstaged: bool) -> dict:
|
|
532
|
+
"""Uses cached graph — builds review context from current git diff."""
|
|
533
|
+
import json as _json
|
|
534
|
+
|
|
535
|
+
if not _is_git_repo(repo_path):
|
|
536
|
+
return {"error": f"'{repo_path}' is not a git repository."}
|
|
537
|
+
|
|
538
|
+
staged, unstaged = get_changed_files(repo_path, include_staged, include_unstaged)
|
|
539
|
+
|
|
540
|
+
if not staged and not unstaged:
|
|
541
|
+
return {
|
|
542
|
+
"repo": repo_path,
|
|
543
|
+
"message": "No changed files found.",
|
|
544
|
+
"changed_files": [],
|
|
545
|
+
"all_affected": [],
|
|
546
|
+
"suggested_tests": [],
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
_, repo_graph = _get_graph(repo_path)
|
|
550
|
+
ctx = build_review_context(repo_path, staged, unstaged, repo_graph)
|
|
551
|
+
return _json.loads(format_review_json(ctx))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
async def _handle_get_skeleton(args: dict) -> list[types.TextContent]:
|
|
555
|
+
file_path = args.get("file_path", "")
|
|
556
|
+
|
|
557
|
+
loop = asyncio.get_event_loop()
|
|
558
|
+
try:
|
|
559
|
+
result = await loop.run_in_executor(None, extract_skeleton, file_path)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
result = {"error": str(e), "file": file_path}
|
|
562
|
+
|
|
563
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
|
|
564
|
+
|
|
565
|
+
|
|
380
566
|
# ---------------------------------------------------------------------------
|
|
381
567
|
# Entry point
|
|
382
568
|
# ---------------------------------------------------------------------------
|