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/README.md +61 -4
- package/bin/contextl.js +8 -0
- package/package.json +5 -2
- package/python/git_review.py +245 -0
- package/python/import_parser.py +1146 -156
- package/python/main.py +35 -1
- package/python/mcp_server.py +113 -0
- package/python/scanner.py +1 -1
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
|
@@ -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",
|