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/package.json
CHANGED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository Intelligence Engine
|
|
3
|
+
Feature #6: Git-Aware Context
|
|
4
|
+
|
|
5
|
+
Reads git diff to find what you've changed, then builds a full blast-radius
|
|
6
|
+
context package showing every file that depends on your changes — ready for
|
|
7
|
+
AI review.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Data structures
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FileReview:
|
|
22
|
+
path: str
|
|
23
|
+
status: str # "staged", "unstaged", or "staged+unstaged"
|
|
24
|
+
direct_dependents: list[str] = field(default_factory=list)
|
|
25
|
+
transitive_dependents: list[str] = field(default_factory=list)
|
|
26
|
+
test_files: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ReviewContext:
|
|
31
|
+
repo: str
|
|
32
|
+
changed_files: list[FileReview]
|
|
33
|
+
all_affected: list[str] # union of all dependents, deduped, sorted
|
|
34
|
+
suggested_tests: list[str] # union of all test files, deduped, sorted
|
|
35
|
+
staged_count: int
|
|
36
|
+
unstaged_count: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Git helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _run_git(args: list[str], cwd: str) -> list[str]:
|
|
44
|
+
"""Run a git command and return non-empty lines of stdout."""
|
|
45
|
+
try:
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
["git"] + args,
|
|
48
|
+
cwd=cwd,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=15,
|
|
52
|
+
)
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
return []
|
|
55
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
56
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_git_repo(repo_path: str) -> bool:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
63
|
+
cwd=repo_path,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
return result.returncode == 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_changed_files(
|
|
71
|
+
repo_path: str,
|
|
72
|
+
include_staged: bool = True,
|
|
73
|
+
include_unstaged: bool = True,
|
|
74
|
+
) -> tuple[set[str], set[str]]:
|
|
75
|
+
"""
|
|
76
|
+
Returns (staged_files, unstaged_files) as sets of relative paths.
|
|
77
|
+
Only includes files that actually exist on disk (deleted files are excluded).
|
|
78
|
+
"""
|
|
79
|
+
staged: set[str] = set()
|
|
80
|
+
unstaged: set[str] = set()
|
|
81
|
+
|
|
82
|
+
if include_staged:
|
|
83
|
+
for f in _run_git(["diff", "--cached", "--name-only", "--diff-filter=ACMRT"], repo_path):
|
|
84
|
+
full = Path(repo_path) / f
|
|
85
|
+
if full.exists():
|
|
86
|
+
staged.add(f)
|
|
87
|
+
|
|
88
|
+
if include_unstaged:
|
|
89
|
+
for f in _run_git(["diff", "--name-only", "--diff-filter=ACMRT"], repo_path):
|
|
90
|
+
full = Path(repo_path) / f
|
|
91
|
+
if full.exists():
|
|
92
|
+
unstaged.add(f)
|
|
93
|
+
|
|
94
|
+
return staged, unstaged
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Core review engine
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def build_review_context(
|
|
102
|
+
repo_path: str,
|
|
103
|
+
staged: set[str],
|
|
104
|
+
unstaged: set[str],
|
|
105
|
+
repo_graph,
|
|
106
|
+
) -> ReviewContext:
|
|
107
|
+
"""
|
|
108
|
+
For each changed file, run impact analysis and aggregate into a ReviewContext.
|
|
109
|
+
"""
|
|
110
|
+
# Avoid circular import — import here
|
|
111
|
+
from impact_analysis import analyze_impact
|
|
112
|
+
|
|
113
|
+
resolved = str(Path(repo_path).resolve())
|
|
114
|
+
all_changed = staged | unstaged
|
|
115
|
+
|
|
116
|
+
all_affected: set[str] = set()
|
|
117
|
+
suggested_tests: set[str] = set()
|
|
118
|
+
file_reviews: list[FileReview] = []
|
|
119
|
+
|
|
120
|
+
for rel_path in sorted(all_changed):
|
|
121
|
+
# Determine status label
|
|
122
|
+
in_staged = rel_path in staged
|
|
123
|
+
in_unstaged = rel_path in unstaged
|
|
124
|
+
if in_staged and in_unstaged:
|
|
125
|
+
status = "staged+unstaged"
|
|
126
|
+
elif in_staged:
|
|
127
|
+
status = "staged"
|
|
128
|
+
else:
|
|
129
|
+
status = "unstaged"
|
|
130
|
+
|
|
131
|
+
# Run impact analysis (may raise ValueError if file not in graph)
|
|
132
|
+
direct: list[str] = []
|
|
133
|
+
transitive: list[str] = []
|
|
134
|
+
tests: list[str] = []
|
|
135
|
+
try:
|
|
136
|
+
report = analyze_impact(rel_path, repo_graph, max_depth=5)
|
|
137
|
+
direct = [f.path for f in report.directly_affected]
|
|
138
|
+
transitive = [f.path for f in report.transitively_affected]
|
|
139
|
+
tests = report.test_files
|
|
140
|
+
except (ValueError, KeyError):
|
|
141
|
+
# File isn't tracked in graph (new file, binary, etc.) — still include it
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
file_reviews.append(FileReview(
|
|
145
|
+
path=rel_path,
|
|
146
|
+
status=status,
|
|
147
|
+
direct_dependents=direct,
|
|
148
|
+
transitive_dependents=transitive,
|
|
149
|
+
test_files=tests,
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
all_affected.update(direct)
|
|
153
|
+
all_affected.update(transitive)
|
|
154
|
+
suggested_tests.update(tests)
|
|
155
|
+
|
|
156
|
+
# Don't list changed files as "affected by themselves"
|
|
157
|
+
all_affected -= all_changed
|
|
158
|
+
suggested_tests -= all_changed
|
|
159
|
+
|
|
160
|
+
return ReviewContext(
|
|
161
|
+
repo=resolved,
|
|
162
|
+
changed_files=file_reviews,
|
|
163
|
+
all_affected=sorted(all_affected),
|
|
164
|
+
suggested_tests=sorted(suggested_tests),
|
|
165
|
+
staged_count=len(staged),
|
|
166
|
+
unstaged_count=len(unstaged),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Output formatters
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def format_review_human(ctx: ReviewContext) -> str:
|
|
175
|
+
BOLD = "\033[1m"
|
|
176
|
+
DIM = "\033[2m"
|
|
177
|
+
GREEN = "\033[32m"
|
|
178
|
+
YELLOW= "\033[33m"
|
|
179
|
+
CYAN = "\033[36m"
|
|
180
|
+
RED = "\033[31m"
|
|
181
|
+
RESET = "\033[0m"
|
|
182
|
+
|
|
183
|
+
total = ctx.staged_count + ctx.unstaged_count
|
|
184
|
+
lines = [
|
|
185
|
+
"",
|
|
186
|
+
f"{BOLD}Repository Intelligence Engine — Git Review{RESET}",
|
|
187
|
+
f"{DIM}{'─' * 60}{RESET}",
|
|
188
|
+
f" Repo : {ctx.repo}",
|
|
189
|
+
f" Changed : {total} file(s) "
|
|
190
|
+
f"({ctx.staged_count} staged, {ctx.unstaged_count} unstaged)",
|
|
191
|
+
f"{DIM}{'─' * 60}{RESET}",
|
|
192
|
+
"",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
# Changed files section
|
|
196
|
+
lines.append(f" {BOLD}CHANGED FILES{RESET} {DIM}(what you edited){RESET}")
|
|
197
|
+
if not ctx.changed_files:
|
|
198
|
+
lines.append(f" {DIM}No changed files found.{RESET}")
|
|
199
|
+
for fr in ctx.changed_files:
|
|
200
|
+
label_color = GREEN if fr.status == "staged" else YELLOW if fr.status == "unstaged" else CYAN
|
|
201
|
+
label = f"{label_color}[{fr.status}]{RESET}"
|
|
202
|
+
lines.append(f" {CYAN}{fr.path}{RESET} {label}")
|
|
203
|
+
lines.append("")
|
|
204
|
+
|
|
205
|
+
# Blast radius section
|
|
206
|
+
lines.append(f" {BOLD}BLAST RADIUS{RESET} {DIM}(files that depend on your changes){RESET}")
|
|
207
|
+
if not ctx.all_affected:
|
|
208
|
+
lines.append(f" {DIM}No dependent files found.{RESET}")
|
|
209
|
+
else:
|
|
210
|
+
for path in ctx.all_affected:
|
|
211
|
+
is_test = any(t in path for t in [".test.", ".spec.", "_test.", "test_"])
|
|
212
|
+
test_badge = f" {RED}[TEST]{RESET}" if is_test else ""
|
|
213
|
+
lines.append(f" {DIM}→{RESET} {path}{test_badge}")
|
|
214
|
+
lines.append("")
|
|
215
|
+
|
|
216
|
+
# Suggested tests
|
|
217
|
+
lines.append(f" {BOLD}SUGGESTED TESTS{RESET} ({len(ctx.suggested_tests)} file(s) to re-run)")
|
|
218
|
+
if not ctx.suggested_tests:
|
|
219
|
+
lines.append(f" {DIM}No test files identified.{RESET}")
|
|
220
|
+
for t in ctx.suggested_tests:
|
|
221
|
+
lines.append(f" {RED}→{RESET} {t}")
|
|
222
|
+
lines.append("")
|
|
223
|
+
|
|
224
|
+
return "\n".join(lines)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def format_review_json(ctx: ReviewContext) -> str:
|
|
228
|
+
import json
|
|
229
|
+
return json.dumps({
|
|
230
|
+
"repo": ctx.repo,
|
|
231
|
+
"staged_count": ctx.staged_count,
|
|
232
|
+
"unstaged_count": ctx.unstaged_count,
|
|
233
|
+
"changed_files": [
|
|
234
|
+
{
|
|
235
|
+
"path": fr.path,
|
|
236
|
+
"status": fr.status,
|
|
237
|
+
"direct_dependents": fr.direct_dependents,
|
|
238
|
+
"transitive_dependents": fr.transitive_dependents,
|
|
239
|
+
"test_files": fr.test_files,
|
|
240
|
+
}
|
|
241
|
+
for fr in ctx.changed_files
|
|
242
|
+
],
|
|
243
|
+
"all_affected": ctx.all_affected,
|
|
244
|
+
"suggested_tests": ctx.suggested_tests,
|
|
245
|
+
}, indent=2)
|