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.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. 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()}")