code-context-control 2.28.0__py3-none-any.whl
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.
- cli/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- tui/theme.tcss +335 -0
cli/tools/filter.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""c3_filter — Terminal/log noise reduction with 3-depth pipeline.
|
|
2
|
+
|
|
3
|
+
Depths:
|
|
4
|
+
fast — regex only (pass-1)
|
|
5
|
+
smart — regex + heuristic collapsing (pass-1 + pass-1.5) [default]
|
|
6
|
+
deep — regex + heuristics + LLM summarization (pass-1 + pass-1.5 + pass-2)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from core import count_tokens
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def handle_filter(file_path: str, text: str, pattern: str, max_lines: int,
|
|
17
|
+
depth: str, use_llm: bool, svc, finalize) -> str:
|
|
18
|
+
# Backward compat: use_llm=True maps to "deep", use_llm=False maps to "smart"
|
|
19
|
+
if depth == "smart" and use_llm is False:
|
|
20
|
+
depth = "fast"
|
|
21
|
+
elif depth == "smart" and use_llm is True:
|
|
22
|
+
pass # keep smart as default
|
|
23
|
+
|
|
24
|
+
# Text mode
|
|
25
|
+
if text and not file_path:
|
|
26
|
+
return _filter_text(text, depth, svc, finalize)
|
|
27
|
+
|
|
28
|
+
# File mode
|
|
29
|
+
full = Path(svc.project_path) / file_path
|
|
30
|
+
if not full.exists():
|
|
31
|
+
full = Path(file_path)
|
|
32
|
+
if not full.exists():
|
|
33
|
+
return finalize("c3_filter", {"file": file_path}, "[filter:error] not found", "error")
|
|
34
|
+
|
|
35
|
+
return _filter_file(full, file_path, pattern, max_lines, svc, finalize)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _filter_text(text: str, depth: str, svc, finalize) -> str:
|
|
39
|
+
"""Filter terminal output with configurable depth."""
|
|
40
|
+
if depth == "fast":
|
|
41
|
+
# Pass-1 only (regex)
|
|
42
|
+
res = svc.output_filter.filter(text, use_llm=False)
|
|
43
|
+
# Skip pass-1.5 heuristics
|
|
44
|
+
method = "pass1"
|
|
45
|
+
result_text = res['filtered']
|
|
46
|
+
elif depth == "deep":
|
|
47
|
+
# Full pipeline including LLM
|
|
48
|
+
res = svc.output_filter.filter(text, use_llm=True)
|
|
49
|
+
method = f"pass{res['pass_used']}" + ("+llm" if res['llm_used'] else "")
|
|
50
|
+
result_text = res['filtered']
|
|
51
|
+
else:
|
|
52
|
+
# "smart" — pass-1 + heuristic pass-1.5 (no LLM)
|
|
53
|
+
res = svc.output_filter.filter(text, use_llm=False)
|
|
54
|
+
method = "pass1"
|
|
55
|
+
result_text = res['filtered']
|
|
56
|
+
|
|
57
|
+
# Pass-1.5: heuristic collapsing
|
|
58
|
+
enhanced = _heuristic_collapse(result_text)
|
|
59
|
+
if enhanced and count_tokens(enhanced) < count_tokens(result_text):
|
|
60
|
+
result_text = enhanced
|
|
61
|
+
method = "pass1.5"
|
|
62
|
+
|
|
63
|
+
filtered_tokens = count_tokens(result_text)
|
|
64
|
+
raw_tokens = res['raw_tokens']
|
|
65
|
+
savings_pct = round((1 - filtered_tokens / raw_tokens) * 100, 1) if raw_tokens > 0 else 0
|
|
66
|
+
|
|
67
|
+
header = f"[filter:{method}] {raw_tokens}->{filtered_tokens}tok ({savings_pct}%saved)"
|
|
68
|
+
resp = f"{header}\n{result_text}"
|
|
69
|
+
return finalize("c3_filter", {"depth": depth},
|
|
70
|
+
resp, f"{raw_tokens}->{filtered_tokens}tok",
|
|
71
|
+
response_tokens=filtered_tokens)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _heuristic_collapse(text: str) -> str | None:
|
|
75
|
+
"""Pass-1.5: Pattern-based collapsing without LLM.
|
|
76
|
+
|
|
77
|
+
- Collapse repeated similar lines (e.g., download progress, compilation units)
|
|
78
|
+
- Deduplicate stack traces
|
|
79
|
+
- Smart truncation: preserve first error + last N lines
|
|
80
|
+
- Summarize test pass/fail counts
|
|
81
|
+
"""
|
|
82
|
+
lines = text.splitlines()
|
|
83
|
+
if len(lines) <= 15:
|
|
84
|
+
return None # Too short to benefit
|
|
85
|
+
|
|
86
|
+
result = []
|
|
87
|
+
# Track patterns for collapsing
|
|
88
|
+
_download_re = re.compile(
|
|
89
|
+
r'^\s*(downloading|fetching|resolving|compiling|building|installing)\s+',
|
|
90
|
+
re.IGNORECASE)
|
|
91
|
+
_test_result_re = re.compile(
|
|
92
|
+
r'^\s*(PASS|PASSED|OK|FAIL|FAILED|ERROR|SKIP)\s+', re.IGNORECASE)
|
|
93
|
+
_repeated_traceback_re = re.compile(r'^\s*File\s+"')
|
|
94
|
+
|
|
95
|
+
# Group consecutive similar lines
|
|
96
|
+
i = 0
|
|
97
|
+
while i < len(lines):
|
|
98
|
+
line = lines[i]
|
|
99
|
+
|
|
100
|
+
# Collapse consecutive download/compile lines
|
|
101
|
+
if _download_re.match(line):
|
|
102
|
+
group = [line]
|
|
103
|
+
j = i + 1
|
|
104
|
+
while j < len(lines) and _download_re.match(lines[j]):
|
|
105
|
+
group.append(lines[j])
|
|
106
|
+
j += 1
|
|
107
|
+
if len(group) > 3:
|
|
108
|
+
result.append(group[0])
|
|
109
|
+
result.append(f"[{len(group) - 1} similar lines collapsed]")
|
|
110
|
+
else:
|
|
111
|
+
result.extend(group)
|
|
112
|
+
i = j
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# Collapse repeated "File" lines in tracebacks (keep first + last)
|
|
116
|
+
if _repeated_traceback_re.match(line):
|
|
117
|
+
group = [line]
|
|
118
|
+
j = i + 1
|
|
119
|
+
while j < len(lines) and (_repeated_traceback_re.match(lines[j])
|
|
120
|
+
or lines[j].startswith(" ")):
|
|
121
|
+
group.append(lines[j])
|
|
122
|
+
j += 1
|
|
123
|
+
if len(group) > 6:
|
|
124
|
+
result.extend(group[:2])
|
|
125
|
+
result.append(f"[{len(group) - 4} stack frames collapsed]")
|
|
126
|
+
result.extend(group[-2:])
|
|
127
|
+
else:
|
|
128
|
+
result.extend(group)
|
|
129
|
+
i = j
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
result.append(line)
|
|
133
|
+
i += 1
|
|
134
|
+
|
|
135
|
+
# Smart truncation: if still too long, keep first error region + last 20 lines
|
|
136
|
+
error_re = re.compile(
|
|
137
|
+
r'ERROR|FAIL|FAILED|Exception|Traceback|panic|CRITICAL',
|
|
138
|
+
re.IGNORECASE)
|
|
139
|
+
|
|
140
|
+
if len(result) > 80:
|
|
141
|
+
# Find first error
|
|
142
|
+
first_error_idx = None
|
|
143
|
+
for idx, line in enumerate(result):
|
|
144
|
+
if error_re.search(line):
|
|
145
|
+
first_error_idx = idx
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
if first_error_idx is not None:
|
|
149
|
+
# Keep context around first error + tail
|
|
150
|
+
error_region = result[max(0, first_error_idx - 2):first_error_idx + 10]
|
|
151
|
+
tail = result[-20:]
|
|
152
|
+
omitted = len(result) - len(error_region) - len(tail)
|
|
153
|
+
if omitted > 5:
|
|
154
|
+
result = (error_region
|
|
155
|
+
+ [f"[{omitted} lines omitted]"]
|
|
156
|
+
+ tail)
|
|
157
|
+
else:
|
|
158
|
+
# No errors — keep head + tail
|
|
159
|
+
head = result[:10]
|
|
160
|
+
tail = result[-20:]
|
|
161
|
+
omitted = len(result) - 30
|
|
162
|
+
if omitted > 5:
|
|
163
|
+
result = head + [f"[{omitted} lines omitted]"] + tail
|
|
164
|
+
|
|
165
|
+
collapsed = "\n".join(result)
|
|
166
|
+
return collapsed
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _filter_file(full: Path, file_path: str, pattern: str, max_lines: int,
|
|
170
|
+
svc, finalize) -> str:
|
|
171
|
+
text = full.read_text(encoding="utf-8", errors="replace")
|
|
172
|
+
lines = text.splitlines()
|
|
173
|
+
orig_tok = count_tokens(text)
|
|
174
|
+
ext = full.suffix.lower()
|
|
175
|
+
extracted = ""
|
|
176
|
+
|
|
177
|
+
if pattern:
|
|
178
|
+
try:
|
|
179
|
+
pat = re.compile(pattern, re.IGNORECASE)
|
|
180
|
+
except re.error as e:
|
|
181
|
+
return f"[extract:error] invalid regex: {e}"
|
|
182
|
+
matched = []
|
|
183
|
+
for i, line in enumerate(lines):
|
|
184
|
+
if pat.search(line):
|
|
185
|
+
for j in range(max(0, i - 2), min(len(lines), i + 5)):
|
|
186
|
+
marker = ">" if j == i else " "
|
|
187
|
+
entry = f"{marker}L{j+1}: {lines[j][:200]}"
|
|
188
|
+
if entry not in matched:
|
|
189
|
+
matched.append(entry)
|
|
190
|
+
if matched and matched[-1] != "---":
|
|
191
|
+
matched.append("---")
|
|
192
|
+
if len(matched) >= max_lines:
|
|
193
|
+
break
|
|
194
|
+
extracted = f"[grep:{pattern}] {len(matched)} lines\n" + "\n".join(matched[:max_lines])
|
|
195
|
+
elif ext in ('.log', '.txt'):
|
|
196
|
+
error_keywords = ['error', 'exception', 'traceback', 'fatal', 'critical', 'fail', 'warn']
|
|
197
|
+
errs = {k.upper(): 0 for k in ['ERROR', 'WARN', 'Exception', 'Traceback', 'FATAL', 'CRITICAL']}
|
|
198
|
+
error_line_indices = []
|
|
199
|
+
for i, line in enumerate(lines):
|
|
200
|
+
ll = line.lower()
|
|
201
|
+
for k in errs:
|
|
202
|
+
if k.lower() in ll:
|
|
203
|
+
errs[k] += 1
|
|
204
|
+
if any(kw in ll for kw in error_keywords):
|
|
205
|
+
error_line_indices.append(i)
|
|
206
|
+
freq = " | ".join(f"{k}:{v}" for k, v in errs.items() if v > 0)
|
|
207
|
+
header = f"[log] {len(lines)} lines | {freq or 'no errors'} | {len(error_line_indices)} error lines"
|
|
208
|
+
if error_line_indices:
|
|
209
|
+
emitted = set()
|
|
210
|
+
context_parts = []
|
|
211
|
+
budget = max_lines - 2
|
|
212
|
+
for idx in error_line_indices:
|
|
213
|
+
for j in range(max(0, idx - 3), min(len(lines), idx + 4)):
|
|
214
|
+
if j not in emitted:
|
|
215
|
+
emitted.add(j)
|
|
216
|
+
marker = ">" if j == idx else " "
|
|
217
|
+
context_parts.append(f"{marker}L{j+1}: {lines[j][:200]}")
|
|
218
|
+
context_parts.append("---")
|
|
219
|
+
if len(context_parts) >= budget:
|
|
220
|
+
break
|
|
221
|
+
extracted = header + "\n" + "\n".join(context_parts[:max_lines])
|
|
222
|
+
else:
|
|
223
|
+
extracted = header + "\n" + "\n".join(lines[:max_lines])
|
|
224
|
+
elif ext in ('.jsonl', '.ndjson'):
|
|
225
|
+
records = 0
|
|
226
|
+
schema_keys = set()
|
|
227
|
+
error_records = []
|
|
228
|
+
sample_lines = []
|
|
229
|
+
for i, line in enumerate(lines):
|
|
230
|
+
stripped = line.strip()
|
|
231
|
+
if not stripped:
|
|
232
|
+
continue
|
|
233
|
+
records += 1
|
|
234
|
+
try:
|
|
235
|
+
obj = json.loads(stripped)
|
|
236
|
+
if isinstance(obj, dict):
|
|
237
|
+
schema_keys.update(obj.keys())
|
|
238
|
+
level_val = str(obj.get("level", obj.get("severity",
|
|
239
|
+
obj.get("log_level", "")))).lower()
|
|
240
|
+
msg = str(obj.get("message", obj.get("msg",
|
|
241
|
+
obj.get("error", "")))).lower()
|
|
242
|
+
if any(kw in level_val or kw in msg
|
|
243
|
+
for kw in ("error", "fatal", "exception", "traceback")):
|
|
244
|
+
if len(error_records) < 10:
|
|
245
|
+
error_records.append(f"L{i+1}: {stripped[:200]}")
|
|
246
|
+
if len(sample_lines) < 3:
|
|
247
|
+
sample_lines.append(f"L{i+1}: {stripped[:200]}")
|
|
248
|
+
except Exception:
|
|
249
|
+
if len(sample_lines) < 3:
|
|
250
|
+
sample_lines.append(f"L{i+1}: {stripped[:200]}")
|
|
251
|
+
schema_str = ", ".join(sorted(schema_keys)[:20]) if schema_keys else "unknown"
|
|
252
|
+
header = f"[jsonl] {records} records | keys: [{schema_str}] | {len(error_records)} errors"
|
|
253
|
+
parts = [header, "--- sample ---"] + sample_lines
|
|
254
|
+
if error_records:
|
|
255
|
+
parts += ["--- errors ---"] + error_records
|
|
256
|
+
extracted = "\n".join(parts)
|
|
257
|
+
elif ext in ('.csv', '.tsv'):
|
|
258
|
+
sep = "\t" if ext == '.tsv' else ","
|
|
259
|
+
if lines:
|
|
260
|
+
header_line = lines[0]
|
|
261
|
+
columns = [c.strip().strip('"') for c in header_line.split(sep)]
|
|
262
|
+
data_lines = len(lines) - 1
|
|
263
|
+
null_counts = {c: 0 for c in columns}
|
|
264
|
+
for row in lines[1:min(101, len(lines))]:
|
|
265
|
+
cells = row.split(sep)
|
|
266
|
+
for j, col in enumerate(columns):
|
|
267
|
+
if j < len(cells) and not cells[j].strip():
|
|
268
|
+
null_counts[col] += 1
|
|
269
|
+
sample_size = min(100, data_lines)
|
|
270
|
+
col_info = " | ".join(
|
|
271
|
+
f"{c}{'('+str(round(null_counts[c]/sample_size*100))+'% null)' if sample_size and null_counts[c] else ''}"
|
|
272
|
+
for c in columns[:15]
|
|
273
|
+
)
|
|
274
|
+
header_str = f"[csv] {data_lines} rows, {len(columns)} cols | {col_info}"
|
|
275
|
+
extracted = header_str + "\n" + "\n".join(lines[:min(max_lines, 20)])
|
|
276
|
+
else:
|
|
277
|
+
extracted = "[csv] empty file"
|
|
278
|
+
else:
|
|
279
|
+
extracted = "\n".join(lines[:max_lines])
|
|
280
|
+
|
|
281
|
+
res_tok = count_tokens(extracted)
|
|
282
|
+
saved = round((1 - res_tok / orig_tok) * 100) if orig_tok > 0 else 0
|
|
283
|
+
return finalize("c3_filter", {"file": file_path, "pattern": pattern},
|
|
284
|
+
f"[extract:{ext}] {orig_tok}->{res_tok}tok ({saved}% saved)\n{extracted}",
|
|
285
|
+
f"{orig_tok}->{res_tok}tok")
|
cli/tools/impact.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""c3_impact — Blast-radius analysis for a symbol or file before edits.
|
|
2
|
+
|
|
3
|
+
Inspired by flytohub/flyto-indexer. Pure Python + git grep, zero deps.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_SKIP_DIRS = frozenset({
|
|
13
|
+
".git", ".c3", "__pycache__", "node_modules", ".venv", "venv",
|
|
14
|
+
".pytest_cache", ".mypy_cache", "dist", "build", ".eggs",
|
|
15
|
+
})
|
|
16
|
+
_CODE_EXTS = frozenset({
|
|
17
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
|
|
18
|
+
".rb", ".php", ".cs", ".cpp", ".c", ".h", ".lua", ".swift",
|
|
19
|
+
".kt", ".scala", ".r", ".sh", ".bash",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Grep helpers ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
def _grep_git(target: str, project: Path, timeout: int = 15) -> list:
|
|
26
|
+
"""Try git grep -n -w. Returns list of (rel_path, lineno) or None on failure."""
|
|
27
|
+
try:
|
|
28
|
+
kw: dict = {}
|
|
29
|
+
if sys.platform == "win32":
|
|
30
|
+
kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["git", "grep", "-n", "-w", "--", target],
|
|
33
|
+
capture_output=True, text=True, timeout=timeout,
|
|
34
|
+
stdin=subprocess.DEVNULL, cwd=str(project), **kw,
|
|
35
|
+
)
|
|
36
|
+
if result.returncode not in (0, 1): # 0=found, 1=no matches, else error
|
|
37
|
+
return None
|
|
38
|
+
refs = []
|
|
39
|
+
for line in result.stdout.splitlines():
|
|
40
|
+
parts = line.split(":", 2)
|
|
41
|
+
if len(parts) >= 2:
|
|
42
|
+
try:
|
|
43
|
+
refs.append((parts[0].replace("\\", "/"), int(parts[1])))
|
|
44
|
+
except ValueError:
|
|
45
|
+
pass
|
|
46
|
+
return refs
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _grep_python(target: str, project: Path) -> list:
|
|
52
|
+
"""Pure-Python fallback grep. Returns list of (rel_path, lineno)."""
|
|
53
|
+
pattern = re.compile(r"\b" + re.escape(target) + r"\b")
|
|
54
|
+
refs = []
|
|
55
|
+
for root, dirs, files in os.walk(str(project)):
|
|
56
|
+
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS and not d.startswith(".")]
|
|
57
|
+
for fname in files:
|
|
58
|
+
fpath = Path(root) / fname
|
|
59
|
+
if fpath.suffix.lower() not in _CODE_EXTS:
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
63
|
+
for i, line in enumerate(content.splitlines(), 1):
|
|
64
|
+
if pattern.search(line):
|
|
65
|
+
try:
|
|
66
|
+
rel = str(fpath.relative_to(project)).replace("\\", "/")
|
|
67
|
+
except ValueError:
|
|
68
|
+
rel = str(fpath).replace("\\", "/")
|
|
69
|
+
refs.append((rel, i))
|
|
70
|
+
except Exception:
|
|
71
|
+
continue
|
|
72
|
+
return refs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_unstaged_files(project: Path) -> set:
|
|
76
|
+
"""Return set of rel-paths with uncommitted changes."""
|
|
77
|
+
try:
|
|
78
|
+
kw: dict = {}
|
|
79
|
+
if sys.platform == "win32":
|
|
80
|
+
kw["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
83
|
+
capture_output=True, text=True, timeout=10,
|
|
84
|
+
stdin=subprocess.DEVNULL, cwd=str(project), **kw,
|
|
85
|
+
)
|
|
86
|
+
if result.returncode == 0:
|
|
87
|
+
return {l.strip().replace("\\", "/") for l in result.stdout.splitlines() if l.strip()}
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
return set()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Risk scoring ─────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def _risk(n_files: int) -> str:
|
|
96
|
+
if n_files == 0:
|
|
97
|
+
return "SAFE"
|
|
98
|
+
if n_files <= 2:
|
|
99
|
+
return "LOW"
|
|
100
|
+
if n_files <= 6:
|
|
101
|
+
return "MEDIUM"
|
|
102
|
+
return "HIGH"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── Main handler ─────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def handle_impact(target: str, file_path: str, mode: str, svc, finalize) -> str:
|
|
108
|
+
if not target:
|
|
109
|
+
return "[impact:error] target required — provide a symbol name, function, or class"
|
|
110
|
+
|
|
111
|
+
project = Path(svc.project_path)
|
|
112
|
+
|
|
113
|
+
# Gather references
|
|
114
|
+
refs = _grep_git(target, project)
|
|
115
|
+
if refs is None:
|
|
116
|
+
refs = _grep_python(target, project)
|
|
117
|
+
|
|
118
|
+
# Group by file, tracking line numbers
|
|
119
|
+
by_file: dict = {}
|
|
120
|
+
for rel, lineno in refs:
|
|
121
|
+
by_file.setdefault(rel, []).append(lineno)
|
|
122
|
+
|
|
123
|
+
# Exclude the source file itself
|
|
124
|
+
if file_path:
|
|
125
|
+
norm = file_path.replace("\\", "/").lstrip("./")
|
|
126
|
+
by_file = {k: v for k, v in by_file.items() if not k.endswith(norm)}
|
|
127
|
+
|
|
128
|
+
# Unstaged overlay for mode='unstaged'
|
|
129
|
+
unstaged: set = set()
|
|
130
|
+
if mode == "unstaged":
|
|
131
|
+
unstaged = _get_unstaged_files(project)
|
|
132
|
+
|
|
133
|
+
n = len(by_file)
|
|
134
|
+
risk = _risk(n)
|
|
135
|
+
|
|
136
|
+
# ── Format output ──────────────────────────────────────────────────────
|
|
137
|
+
lines = [f"[impact] '{target}' — {n} file(s) affected, risk: {risk}"]
|
|
138
|
+
|
|
139
|
+
if not by_file:
|
|
140
|
+
lines.append(" No references found outside source file.")
|
|
141
|
+
lines.append(" Safe to rename/remove.")
|
|
142
|
+
else:
|
|
143
|
+
# Sort: unstaged first (most relevant), then by hit count desc
|
|
144
|
+
def _sort_key(item):
|
|
145
|
+
fp, lnos = item
|
|
146
|
+
return (0 if fp in unstaged else 1, -len(lnos))
|
|
147
|
+
|
|
148
|
+
for fp, lnos in sorted(by_file.items(), key=_sort_key)[:20]:
|
|
149
|
+
marker = " [unstaged]" if fp in unstaged else ""
|
|
150
|
+
sample = ",".join(str(l) for l in lnos[:4])
|
|
151
|
+
more = f"+{len(lnos) - 4}" if len(lnos) > 4 else ""
|
|
152
|
+
lines.append(f" {fp}:{sample}{more}{marker}")
|
|
153
|
+
|
|
154
|
+
if n > 20:
|
|
155
|
+
lines.append(f" ... and {n - 20} more files")
|
|
156
|
+
|
|
157
|
+
if mode == "unstaged" and not unstaged:
|
|
158
|
+
lines.append(" (no uncommitted changes detected)")
|
|
159
|
+
|
|
160
|
+
lines.append(f"Risk: {risk}" + (" — review all call sites before editing" if risk in ("MEDIUM", "HIGH") else ""))
|
|
161
|
+
|
|
162
|
+
return finalize("c3_impact", {"target": target, "mode": mode},
|
|
163
|
+
"\n".join(lines), f"{n}f/{risk.lower()}")
|