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/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()
@@ -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
@@ -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
- """Synchronous pipelinecalled from a thread executor."""
235
- scan = scan_repo(repo_path)
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
- scan = scan_repo(repo_path)
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
- """Synchronous pipelinecalled from a thread executor."""
298
- scan = scan_repo(repo_path)
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
- """Synchronous pipelinecalled from a thread executor."""
338
- scan = scan_repo(repo_path)
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
- """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
-
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
  # ---------------------------------------------------------------------------