contextl 1.2.13 → 1.2.15

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()
@@ -48,6 +48,8 @@ from main import _confidence, _reasoning
48
48
  from impact_analysis import analyze_impact
49
49
  from standalone import find_standalone_files
50
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
51
53
 
52
54
 
53
55
  # ---------------------------------------------------------------------------
@@ -266,6 +268,60 @@ async def list_tools() -> list[types.Tool]:
266
268
  "required": ["repo_path", "output_dir"],
267
269
  },
268
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
+ ),
269
325
  ]
270
326
 
271
327
 
@@ -290,6 +346,12 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
290
346
  if name == "export_obsidian_vault":
291
347
  return await _handle_export_obsidian(arguments)
292
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
+
293
355
  return [types.TextContent(
294
356
  type="text",
295
357
  text=json.dumps({"error": f"Unknown tool: {name}"}),
@@ -450,6 +512,57 @@ def _run_export_obsidian(repo_path: str, output_dir: str) -> dict:
450
512
  }
451
513
 
452
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
+
453
566
  # ---------------------------------------------------------------------------
454
567
  # Entry point
455
568
  # ---------------------------------------------------------------------------
package/python/scanner.py CHANGED
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from dataclasses import dataclass, field
13
13
 
14
14
 
15
- SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java"}
15
+ SUPPORTED_EXTENSIONS = {".tsx", ".ts", ".jsx", ".js", ".py", ".java", ".go", ".rs", ".cpp", ".cc", ".c", ".h", ".hpp"}
16
16
 
17
17
  ignore_dirs = {
18
18
  ".git", "node_modules", "__pycache__", "venv", ".venv",