contextl 1.0.1 → 1.1.0

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.0.1",
3
+ "version": "1.1.0",
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,196 @@
1
+ """
2
+ Repository Intelligence Engine
3
+ Step 6: Change Impact Analysis
4
+
5
+ Answers: "If I modify this file, what else is affected?"
6
+
7
+ Walks the dependency graph upstream (predecessors) from a given file to find
8
+ every file that directly or transitively depends on it. Also flags likely
9
+ test files so the impact report highlights what needs re-testing.
10
+
11
+ Pure graph traversal — no LLM, no embeddings. Uses the same RepoGraph
12
+ built in graph_builder.py.
13
+ """
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ from scanner import scan_repo
18
+ from import_parser import parse_imports
19
+ from graph_builder import build_graph, RepoGraph
20
+
21
+
22
+ # Heuristics for detecting test files by path/name
23
+ TEST_MARKERS = ("test", "spec", "__tests__", "__mocks__")
24
+
25
+
26
+ @dataclass
27
+ class ImpactedFile:
28
+ """A single file affected by a change, with its distance from the source."""
29
+ path: str
30
+ distance: int # 1 = directly imports the changed file, 2 = imports something that does, etc.
31
+ is_test_file: bool
32
+ via: str # the immediate file that pulled this one in (for tracing the chain)
33
+
34
+
35
+ @dataclass
36
+ class ImpactReport:
37
+ """Full result of an impact analysis for one target file."""
38
+ target: str
39
+ directly_affected: list[ImpactedFile] = field(default_factory=list)
40
+ transitively_affected: list[ImpactedFile] = field(default_factory=list)
41
+ test_files: list[str] = field(default_factory=list)
42
+ total_affected: int = 0
43
+
44
+ def summary(self) -> str:
45
+ lines = [
46
+ f"Impact analysis for: {self.target}",
47
+ f"Total affected files: {self.total_affected}",
48
+ "",
49
+ ]
50
+
51
+ if self.directly_affected:
52
+ lines.append("Directly affected (import this file):")
53
+ for f in self.directly_affected:
54
+ marker = " [test]" if f.is_test_file else ""
55
+ lines.append(f" {f.path}{marker}")
56
+ lines.append("")
57
+
58
+ if self.transitively_affected:
59
+ lines.append("Transitively affected (depend on a direct dependent):")
60
+ for f in self.transitively_affected:
61
+ marker = " [test]" if f.is_test_file else ""
62
+ lines.append(f" {f.path} (via {f.via}, distance {f.distance}){marker}")
63
+ lines.append("")
64
+
65
+ if self.test_files:
66
+ lines.append("Suggested tests to run:")
67
+ for t in self.test_files:
68
+ lines.append(f" {t}")
69
+ else:
70
+ lines.append("No test files found in the impact radius.")
71
+
72
+ return "\n".join(lines)
73
+
74
+ def to_tree_string(self) -> str:
75
+ """Render as an ASCII dependency tree, similar to `tree` output."""
76
+ lines = [self.target]
77
+ direct = self.directly_affected
78
+ for i, f in enumerate(direct):
79
+ is_last_direct = (i == len(direct) - 1)
80
+ connector = "└──" if is_last_direct else "├──"
81
+ marker = " [test]" if f.is_test_file else ""
82
+ lines.append(f"{connector} {f.path}{marker}")
83
+
84
+ # Find transitive files that came via this direct file
85
+ children = [t for t in self.transitively_affected if t.via == f.path]
86
+ for j, child in enumerate(children):
87
+ is_last_child = (j == len(children) - 1)
88
+ prefix = " " if is_last_direct else "│ "
89
+ child_connector = "└──" if is_last_child else "├──"
90
+ child_marker = " [test]" if child.is_test_file else ""
91
+ lines.append(f"{prefix}{child_connector} {child.path}{child_marker}")
92
+
93
+ return "\n".join(lines)
94
+
95
+
96
+ def _is_test_file(path: str) -> bool:
97
+ lowered = path.lower()
98
+ return any(marker in lowered for marker in TEST_MARKERS)
99
+
100
+
101
+ def analyze_impact(
102
+ target_file: str,
103
+ repo_graph: RepoGraph,
104
+ max_depth: int = 5,
105
+ ) -> ImpactReport:
106
+ """
107
+ Find every file affected by a change to target_file.
108
+
109
+ Args:
110
+ target_file: Relative path of the file being changed (must exist in repo_graph.nodes).
111
+ repo_graph: Built graph from build_graph().
112
+ max_depth: Maximum number of hops to traverse upstream (default 5).
113
+
114
+ Returns:
115
+ ImpactReport listing direct and transitive dependents.
116
+
117
+ Raises:
118
+ ValueError: If target_file is not found in the graph.
119
+ """
120
+ if target_file not in repo_graph.nodes:
121
+ raise ValueError(
122
+ f"File not found in repository graph: {target_file}. "
123
+ f"Make sure the path is relative to the repo root."
124
+ )
125
+
126
+ report = ImpactReport(target=target_file)
127
+
128
+ visited: set[str] = {target_file}
129
+ direct_dependents = repo_graph.get_dependents(target_file)
130
+
131
+ # --- Distance 1: direct dependents ---
132
+ frontier: list[tuple[str, str]] = [] # (path, via)
133
+ for dep in direct_dependents:
134
+ if dep in visited:
135
+ continue
136
+ visited.add(dep)
137
+ is_test = _is_test_file(dep)
138
+ report.directly_affected.append(
139
+ ImpactedFile(path=dep, distance=1, is_test_file=is_test, via=target_file)
140
+ )
141
+ if is_test:
142
+ report.test_files.append(dep)
143
+ frontier.append((dep, dep))
144
+
145
+ # --- Distance 2+: transitive dependents (BFS upstream) ---
146
+ distance = 2
147
+ while frontier and distance <= max_depth:
148
+ next_frontier: list[tuple[str, str]] = []
149
+
150
+ for current_path, origin_direct_file in frontier:
151
+ for dep in repo_graph.get_dependents(current_path):
152
+ if dep in visited:
153
+ continue
154
+ visited.add(dep)
155
+ is_test = _is_test_file(dep)
156
+ report.transitively_affected.append(
157
+ ImpactedFile(
158
+ path=dep,
159
+ distance=distance,
160
+ is_test_file=is_test,
161
+ via=origin_direct_file, # trace back to the original direct dependent
162
+ )
163
+ )
164
+ if is_test:
165
+ report.test_files.append(dep)
166
+ next_frontier.append((dep, origin_direct_file))
167
+
168
+ frontier = next_frontier
169
+ distance += 1
170
+
171
+ report.total_affected = len(report.directly_affected) + len(report.transitively_affected)
172
+
173
+ return report
174
+
175
+
176
+ if __name__ == "__main__":
177
+ import sys
178
+
179
+ if len(sys.argv) < 3:
180
+ print("Usage: python impact_analysis.py <repo_path> <target_file>")
181
+ print("Example: python impact_analysis.py ../sample_repo types/files.ts")
182
+ sys.exit(1)
183
+
184
+ repo_path = sys.argv[1]
185
+ target = sys.argv[2]
186
+
187
+ scan = scan_repo(repo_path)
188
+ parse = parse_imports(scan)
189
+ repo_graph = build_graph(scan, parse)
190
+
191
+ report = analyze_impact(target, repo_graph)
192
+
193
+ print(report.summary())
194
+ print()
195
+ print("Dependency tree:")
196
+ print(report.to_tree_string())
@@ -11,8 +11,9 @@ 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
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
16
17
  """
17
18
 
18
19
  import asyncio
@@ -32,6 +33,7 @@ from import_parser import parse_imports
32
33
  from graph_builder import build_graph
33
34
  from query_engine import query as engine_query
34
35
  from main import _confidence, _reasoning
36
+ from impact_analysis import analyze_impact
35
37
 
36
38
 
37
39
  # ---------------------------------------------------------------------------
@@ -100,6 +102,42 @@ async def list_tools() -> list[types.Tool]:
100
102
  "required": ["repo_path"],
101
103
  },
102
104
  ),
105
+ types.Tool(
106
+ name="analyze_impact",
107
+ description=(
108
+ "Find every file affected by changing a specific file. "
109
+ "Walks the dependency graph upstream to find direct and transitive "
110
+ "dependents — files that import the target file, and files that import "
111
+ "those files. Flags likely test files so you know what to re-test. "
112
+ "Use this BEFORE modifying a shared file (types, utils, config) to "
113
+ "understand the blast radius of the change."
114
+ ),
115
+ inputSchema={
116
+ "type": "object",
117
+ "properties": {
118
+ "repo_path": {
119
+ "type": "string",
120
+ "description": "Absolute or relative path to the repository root.",
121
+ },
122
+ "target_file": {
123
+ "type": "string",
124
+ "description": (
125
+ "Path of the file being changed, relative to repo_path. "
126
+ "Example: 'src/types/index.ts' or 'lib/api.ts'. "
127
+ "Use scan_repo first if unsure of the exact path."
128
+ ),
129
+ },
130
+ "max_depth": {
131
+ "type": "integer",
132
+ "description": "Maximum hops to traverse upstream (default: 5).",
133
+ "default": 5,
134
+ "minimum": 1,
135
+ "maximum": 10,
136
+ },
137
+ },
138
+ "required": ["repo_path", "target_file"],
139
+ },
140
+ ),
103
141
  ]
104
142
 
105
143
 
@@ -115,6 +153,9 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
115
153
  if name == "scan_repo":
116
154
  return await _handle_scan(arguments)
117
155
 
156
+ if name == "analyze_impact":
157
+ return await _handle_impact(arguments)
158
+
118
159
  return [types.TextContent(
119
160
  type="text",
120
161
  text=json.dumps({"error": f"Unknown tool: {name}"}),
@@ -185,6 +226,48 @@ def _run_scan(repo_path: str) -> dict:
185
226
  }
186
227
 
187
228
 
229
+ async def _handle_impact(args: dict) -> list[types.TextContent]:
230
+ repo_path = args.get("repo_path", "")
231
+ target_file = args.get("target_file", "")
232
+ max_depth = min(int(args.get("max_depth", 5)), 10)
233
+
234
+ loop = asyncio.get_event_loop()
235
+ try:
236
+ result = await loop.run_in_executor(None, _run_impact, repo_path, target_file, max_depth)
237
+ except Exception as e:
238
+ result = {"error": str(e), "repo": repo_path, "target_file": target_file}
239
+
240
+ return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
241
+
242
+
243
+ def _run_impact(repo_path: str, target_file: str, max_depth: int) -> dict:
244
+ """Synchronous pipeline — called from a thread executor."""
245
+ scan = scan_repo(repo_path)
246
+ parse = parse_imports(scan)
247
+ repo_graph = build_graph(scan, parse)
248
+ report = analyze_impact(target_file, repo_graph, max_depth=max_depth)
249
+
250
+ return {
251
+ "target_file": report.target,
252
+ "total_affected": report.total_affected,
253
+ "directly_affected": [
254
+ {"path": f.path, "is_test_file": f.is_test_file}
255
+ for f in report.directly_affected
256
+ ],
257
+ "transitively_affected": [
258
+ {
259
+ "path": f.path,
260
+ "distance": f.distance,
261
+ "via": f.via,
262
+ "is_test_file": f.is_test_file,
263
+ }
264
+ for f in report.transitively_affected
265
+ ],
266
+ "suggested_tests": report.test_files,
267
+ "dependency_tree": report.to_tree_string(),
268
+ }
269
+
270
+
188
271
  # ---------------------------------------------------------------------------
189
272
  # Entry point
190
273
  # ---------------------------------------------------------------------------