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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contextl",
3
- "version": "1.2.12",
3
+ "version": "1.2.14",
4
4
  "description": "contextl — finds the most relevant files in your codebase for any change request. MCP server for AI coding agents.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -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)