diffcontext 0.5.1__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.
- diffcontext/__init__.py +233 -0
- diffcontext/_warn_once.py +112 -0
- diffcontext/cache.py +216 -0
- diffcontext/cli/__init__.py +655 -0
- diffcontext/context/__init__.py +1 -0
- diffcontext/context/compiler.py +643 -0
- diffcontext/context/selector.py +258 -0
- diffcontext/diff/__init__.py +1 -0
- diffcontext/diff/git_diff.py +298 -0
- diffcontext/diff/state_manager.py +75 -0
- diffcontext/graph_builder.py +1026 -0
- diffcontext/history.py +154 -0
- diffcontext/impact/__init__.py +1 -0
- diffcontext/impact/blast_radius.py +58 -0
- diffcontext/impact/scoring.py +223 -0
- diffcontext/impact/traversal.py +58 -0
- diffcontext/impact/visualizer.py +338 -0
- diffcontext/languages/__init__.py +80 -0
- diffcontext/languages/typescript.py +960 -0
- diffcontext/lexical.py +108 -0
- diffcontext/models.py +180 -0
- diffcontext/parser.py +183 -0
- diffcontext/pipeline.py +887 -0
- diffcontext/py.typed +0 -0
- diffcontext/rerank/__init__.py +17 -0
- diffcontext/rerank/features.py +356 -0
- diffcontext/rerank/model.py +175 -0
- diffcontext/resolver.py +288 -0
- diffcontext/scanner.py +153 -0
- diffcontext/symbols.py +254 -0
- diffcontext/verify/__init__.py +68 -0
- diffcontext/verify/cases.py +631 -0
- diffcontext/verify/history.py +396 -0
- diffcontext/verify/sufficiency.py +324 -0
- diffcontext-0.5.1.dist-info/METADATA +219 -0
- diffcontext-0.5.1.dist-info/RECORD +40 -0
- diffcontext-0.5.1.dist-info/WHEEL +5 -0
- diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
- diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
- diffcontext-0.5.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""
|
|
2
|
+
compiler.py — Compile selected symbols into an LLM-ready context package.
|
|
3
|
+
|
|
4
|
+
Key additions over the naive "dump code blocks" approach:
|
|
5
|
+
- META header: LLM knows repo size, what was dropped, graph confidence,
|
|
6
|
+
broken files, and scoring basis BEFORE reading any code.
|
|
7
|
+
- Per-symbol relationship annotations: callers / callees, and explicit
|
|
8
|
+
"NOT IN CONTEXT" tags so the LLM doesn't hallucinate missing deps.
|
|
9
|
+
- Dropped-symbol manifest: explicit list of symbols cut by token budget.
|
|
10
|
+
- Graph confidence score: fraction of edges that resolved to known symbols.
|
|
11
|
+
- Auto-generated suggestions: rule-based hints derived from graph data.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from typing import Callable, Dict, List, Optional, Set, Tuple
|
|
18
|
+
|
|
19
|
+
from ..models import Symbol, ContextPackage, ContextItem
|
|
20
|
+
from ..impact.scoring import describe_scoring_basis, ScoringConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# (path -> ((mtime_ns, size), first_line)). Every compile renders the
|
|
24
|
+
# architecture snapshot, which needs one docstring per repo file; without
|
|
25
|
+
# this memo each compile re-parses the whole repo (measured: 67% of
|
|
26
|
+
# `verify --from-history` runtime). mtime+size invalidation keeps it
|
|
27
|
+
# correct when a harness edits files between compiles in one process.
|
|
28
|
+
_DOCSTRING_CACHE: Dict[str, Tuple[Tuple[int, int], str]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_module_docstring(abs_file_path: str) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Read the first line of the module-level docstring from an ABSOLUTE path.
|
|
34
|
+
Returns "" if file is unreadable or has no docstring.
|
|
35
|
+
Must receive sym.file (absolute), NOT the relative path from sym_id.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
st = os.stat(abs_file_path)
|
|
39
|
+
stamp = (st.st_mtime_ns, st.st_size)
|
|
40
|
+
cached = _DOCSTRING_CACHE.get(abs_file_path)
|
|
41
|
+
if cached is not None and cached[0] == stamp:
|
|
42
|
+
return cached[1]
|
|
43
|
+
with open(abs_file_path, "r", encoding="utf-8") as f:
|
|
44
|
+
source = f.read()
|
|
45
|
+
tree = ast.parse(source)
|
|
46
|
+
doc = ast.get_docstring(tree)
|
|
47
|
+
first_line = ""
|
|
48
|
+
if doc:
|
|
49
|
+
first_line = doc.strip().split("\n")[0]
|
|
50
|
+
first_line = re.sub(r'^[\w/\-\.]+\.py\s*[—\-]+\s*', '', first_line).strip()
|
|
51
|
+
if len(first_line) > 60:
|
|
52
|
+
first_line = first_line[:57] + "..."
|
|
53
|
+
_DOCSTRING_CACHE[abs_file_path] = (stamp, first_line)
|
|
54
|
+
return first_line
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Per-symbol rendering — shared by compiler (emission) and selector (budgeting)
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def relationship_cap(max_tokens: Optional[int]) -> int:
|
|
65
|
+
"""
|
|
66
|
+
How many caller/callee entries a relationship block may list per symbol.
|
|
67
|
+
Hub symbols carry long annotations; under a tight budget cap them harder
|
|
68
|
+
so annotations can't crowd out actual code. Single source of truth so the
|
|
69
|
+
selector budgets with the same cap the compiler renders with.
|
|
70
|
+
"""
|
|
71
|
+
return 3 if (max_tokens and max_tokens < 2000) else 6
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_symbol_block(
|
|
75
|
+
sym_id: str,
|
|
76
|
+
symbols: Dict[str, Symbol],
|
|
77
|
+
score: float,
|
|
78
|
+
graph: Optional[Dict[str, List[str]]],
|
|
79
|
+
reverse: Dict[str, Set[str]],
|
|
80
|
+
selected_set: Set[str],
|
|
81
|
+
rel_cap: int = 6,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Render one symbol exactly as it appears in the compiled code section:
|
|
85
|
+
|
|
86
|
+
FILE: {file}
|
|
87
|
+
FUNCTION: {name} (score: {score})
|
|
88
|
+
{CALLERS/CALLEES relationship block}
|
|
89
|
+
{code}
|
|
90
|
+
|
|
91
|
+
The selector calls this too (with a pessimistic empty selected_set, so
|
|
92
|
+
every relationship entry carries the longer " [NOT IN CONTEXT]" tag) to
|
|
93
|
+
budget against what will actually be emitted — budgeting on bare
|
|
94
|
+
symbol.code alone undercounted headers + annotations and produced a
|
|
95
|
+
systematic 25-41% budget overshoot (see CHANGELOG).
|
|
96
|
+
"""
|
|
97
|
+
sym = symbols[sym_id]
|
|
98
|
+
file_name, func_name = sym_id.split(":", 1)
|
|
99
|
+
rel_block = _build_relationship_block(
|
|
100
|
+
sym_id, graph, reverse, selected_set, symbols, cap=rel_cap
|
|
101
|
+
)
|
|
102
|
+
return (
|
|
103
|
+
f"FILE: {file_name}\n"
|
|
104
|
+
f"FUNCTION: {func_name} (score: {score:.0f})\n"
|
|
105
|
+
+ rel_block +
|
|
106
|
+
f"\n{sym.code}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_reverse_graph(
|
|
111
|
+
graph: Optional[Dict[str, List[str]]],
|
|
112
|
+
) -> Dict[str, Set[str]]:
|
|
113
|
+
"""callee -> set(callers). Shared by compiler and selector."""
|
|
114
|
+
reverse: Dict[str, Set[str]] = {}
|
|
115
|
+
if graph:
|
|
116
|
+
for caller, callees in graph.items():
|
|
117
|
+
for callee in callees:
|
|
118
|
+
reverse.setdefault(callee, set()).add(caller)
|
|
119
|
+
return reverse
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Public entry point
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def compile_context(
|
|
127
|
+
symbols: Dict[str, Symbol],
|
|
128
|
+
selected_ids: List[str],
|
|
129
|
+
changed_ids: List[str],
|
|
130
|
+
scores: Dict[str, float],
|
|
131
|
+
graph: Optional[Dict[str, List[str]]] = None,
|
|
132
|
+
reverse: Optional[Dict[str, Set[str]]] = None,
|
|
133
|
+
dropped_ids: Optional[List[str]] = None,
|
|
134
|
+
skipped_files: Optional[List[str]] = None,
|
|
135
|
+
notes: Optional[str] = None,
|
|
136
|
+
token_counter: Optional[Callable[[str], int]] = None,
|
|
137
|
+
scoring_config: Optional[ScoringConfig] = None,
|
|
138
|
+
max_tokens: Optional[int] = None,
|
|
139
|
+
) -> ContextPackage:
|
|
140
|
+
"""
|
|
141
|
+
Build the final context package from selected symbols.
|
|
142
|
+
|
|
143
|
+
The structured `items` list is the base representation; the formatted
|
|
144
|
+
`text` (meta-header + sections + suggestions) is rendered from it.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
symbols: Full symbol table for the repo.
|
|
148
|
+
selected_ids: Symbols chosen for this context (respects token budget).
|
|
149
|
+
changed_ids: The symbols that actually changed (always selected).
|
|
150
|
+
scores: Impact scores for all scored symbols.
|
|
151
|
+
graph: Full call graph (id -> [dep ids]). Enables relationship
|
|
152
|
+
annotations and confidence calculation.
|
|
153
|
+
reverse: Pre-built reverse graph (callee -> callers), e.g.
|
|
154
|
+
RepositoryIndex.reverse_graph. Derived from `graph`
|
|
155
|
+
when absent.
|
|
156
|
+
dropped_ids: Symbols that were scored but cut by token budget.
|
|
157
|
+
skipped_files: Files that raised SyntaxError (graph has holes here).
|
|
158
|
+
notes: Optional user notes injected into meta header.
|
|
159
|
+
token_counter: Optional text -> token count callable (real tokenizer);
|
|
160
|
+
defaults to the len//4 heuristic.
|
|
161
|
+
scoring_config: The ScoringConfig used for scoring, so the meta-header
|
|
162
|
+
describes the actual run; defaults when None.
|
|
163
|
+
max_tokens: The symbol-code budget the selection ran under (None =
|
|
164
|
+
unlimited). Used to keep the meta-header proportionate:
|
|
165
|
+
under tight budgets the architecture snapshot is
|
|
166
|
+
compacted so meta can't dwarf the code it annotates.
|
|
167
|
+
"""
|
|
168
|
+
dropped_ids = dropped_ids or []
|
|
169
|
+
skipped_files = skipped_files or []
|
|
170
|
+
count = token_counter or (lambda text: max(1, len(text) // 4))
|
|
171
|
+
|
|
172
|
+
changed_set = set(changed_ids)
|
|
173
|
+
|
|
174
|
+
# Reverse graph for caller annotation (build only if not supplied)
|
|
175
|
+
if reverse is None:
|
|
176
|
+
reverse = build_reverse_graph(graph)
|
|
177
|
+
|
|
178
|
+
# Graph confidence: fraction of edges that point to a known symbol
|
|
179
|
+
graph_confidence = _compute_confidence(graph, symbols)
|
|
180
|
+
|
|
181
|
+
# Token bookkeeping
|
|
182
|
+
total_repo_code = "\n\n".join(s.code for s in symbols.values())
|
|
183
|
+
total_repo_tokens = count(total_repo_code)
|
|
184
|
+
|
|
185
|
+
rel_cap = relationship_cap(max_tokens)
|
|
186
|
+
|
|
187
|
+
def _assemble(sel_ids: List[str], drop_ids: List[str]):
|
|
188
|
+
"""Build items + full text (with the {FULL_OUTPUT_TOKENS} placeholder
|
|
189
|
+
unsubstituted) for one candidate selection."""
|
|
190
|
+
sel_set = set(sel_ids)
|
|
191
|
+
|
|
192
|
+
items: List[ContextItem] = []
|
|
193
|
+
for sym_id in sel_ids:
|
|
194
|
+
if sym_id not in symbols:
|
|
195
|
+
continue
|
|
196
|
+
sym = symbols[sym_id]
|
|
197
|
+
score = scores.get(sym_id, 0)
|
|
198
|
+
if sym_id in changed_set:
|
|
199
|
+
role = "changed"
|
|
200
|
+
elif score >= 70:
|
|
201
|
+
role = "impacted"
|
|
202
|
+
else:
|
|
203
|
+
role = "dependency"
|
|
204
|
+
items.append(ContextItem(
|
|
205
|
+
symbol_id = sym_id,
|
|
206
|
+
code = sym.code,
|
|
207
|
+
score = score,
|
|
208
|
+
role = role,
|
|
209
|
+
callers = sorted(reverse.get(sym_id, set())),
|
|
210
|
+
callees = list(graph.get(sym_id, [])) if graph else [],
|
|
211
|
+
token_estimate = count(sym.code),
|
|
212
|
+
))
|
|
213
|
+
|
|
214
|
+
sections: Dict[str, List[str]] = {"CHANGED": [], "IMPACTED": [], "DEPENDENCIES": []}
|
|
215
|
+
section_of_role = {"changed": "CHANGED", "impacted": "IMPACTED", "dependency": "DEPENDENCIES"}
|
|
216
|
+
|
|
217
|
+
for item in items:
|
|
218
|
+
entry = render_symbol_block(
|
|
219
|
+
item.symbol_id, symbols, item.score, graph, reverse,
|
|
220
|
+
sel_set, rel_cap=rel_cap,
|
|
221
|
+
)
|
|
222
|
+
sections[section_of_role[item.role]].append(entry)
|
|
223
|
+
|
|
224
|
+
parts = []
|
|
225
|
+
for label, entries in sections.items():
|
|
226
|
+
if entries:
|
|
227
|
+
parts.append(f"=== {label} SYMBOLS ===\n")
|
|
228
|
+
parts.append("\n\n---\n\n".join(entries))
|
|
229
|
+
|
|
230
|
+
code_text = "\n\n".join(parts)
|
|
231
|
+
context_tokens = count(code_text)
|
|
232
|
+
|
|
233
|
+
meta = _build_meta_header(
|
|
234
|
+
symbols = symbols,
|
|
235
|
+
selected_ids = sel_ids,
|
|
236
|
+
dropped_ids = drop_ids,
|
|
237
|
+
skipped_files = skipped_files,
|
|
238
|
+
changed_ids = changed_ids,
|
|
239
|
+
graph = graph,
|
|
240
|
+
reverse = reverse,
|
|
241
|
+
graph_confidence = graph_confidence,
|
|
242
|
+
token_budget = total_repo_tokens, # not the budget cap; just total repo
|
|
243
|
+
context_tokens = context_tokens,
|
|
244
|
+
scores = scores,
|
|
245
|
+
notes = notes,
|
|
246
|
+
scoring_config = scoring_config,
|
|
247
|
+
max_tokens = max_tokens,
|
|
248
|
+
count = count,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
suggestions = _build_suggestions(
|
|
252
|
+
changed_ids = changed_ids,
|
|
253
|
+
dropped_ids = drop_ids,
|
|
254
|
+
skipped_files = skipped_files,
|
|
255
|
+
graph = graph,
|
|
256
|
+
reverse = reverse,
|
|
257
|
+
graph_confidence = graph_confidence,
|
|
258
|
+
scores = scores,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
full_text = meta + "\n\n" + code_text
|
|
262
|
+
if suggestions:
|
|
263
|
+
full_text += "\n\n" + suggestions
|
|
264
|
+
return items, full_text
|
|
265
|
+
|
|
266
|
+
def _finalize_tokens(full_text: str) -> Tuple[str, int]:
|
|
267
|
+
# token_estimate is the FULL output (meta + annotated code +
|
|
268
|
+
# suggestions) — the number an agent harness actually pays, not just
|
|
269
|
+
# the code portion. Substituting the number changes the text length,
|
|
270
|
+
# so iterate to a fixed point (stabilizes after one or two rounds).
|
|
271
|
+
full_tokens = count(full_text.replace("{FULL_OUTPUT_TOKENS}", "0", 1))
|
|
272
|
+
candidate = full_text
|
|
273
|
+
for _ in range(3):
|
|
274
|
+
candidate = full_text.replace(
|
|
275
|
+
"{FULL_OUTPUT_TOKENS}", f"{full_tokens:,}", 1
|
|
276
|
+
)
|
|
277
|
+
recount = count(candidate)
|
|
278
|
+
if recount == full_tokens:
|
|
279
|
+
break
|
|
280
|
+
full_tokens = recount
|
|
281
|
+
return candidate, full_tokens
|
|
282
|
+
|
|
283
|
+
# --- Budget enforcement: trim AFTER rendering, against real output ---
|
|
284
|
+
# The selector budgets per-symbol rendered blocks, but the meta header,
|
|
285
|
+
# section separators, and suggestions are only knowable post-render.
|
|
286
|
+
# Enforce max_tokens against the final full output by dropping the
|
|
287
|
+
# lowest-scored non-changed symbols until it fits. Changed symbols and
|
|
288
|
+
# the meta header are never dropped: the diff is the reason we're here,
|
|
289
|
+
# and the meta is the disclosure layer — so when meta + changed symbols
|
|
290
|
+
# alone exceed the budget, that floor is emitted as-is (and the meta's
|
|
291
|
+
# own token lines report the real number, so the overshoot is visible,
|
|
292
|
+
# never silent).
|
|
293
|
+
selected_work = [s for s in selected_ids if s in symbols]
|
|
294
|
+
dropped_work = list(dropped_ids)
|
|
295
|
+
|
|
296
|
+
while True:
|
|
297
|
+
items, full_text = _assemble(selected_work, dropped_work)
|
|
298
|
+
full_text, full_tokens = _finalize_tokens(full_text)
|
|
299
|
+
|
|
300
|
+
if max_tokens is None or full_tokens <= max_tokens:
|
|
301
|
+
break
|
|
302
|
+
|
|
303
|
+
droppable = [s for s in selected_work if s not in changed_set]
|
|
304
|
+
if not droppable:
|
|
305
|
+
break # non-compressible floor: meta + changed symbols only
|
|
306
|
+
|
|
307
|
+
# Drop enough of the lowest-scored symbols to cover the overshoot in
|
|
308
|
+
# one pass (re-checked next iteration), so the loop converges fast.
|
|
309
|
+
overshoot = full_tokens - max_tokens
|
|
310
|
+
droppable.sort(key=lambda s: scores.get(s, 0))
|
|
311
|
+
removed, freed = [], 0
|
|
312
|
+
for s in droppable:
|
|
313
|
+
removed.append(s)
|
|
314
|
+
freed += count(render_symbol_block(
|
|
315
|
+
s, symbols, scores.get(s, 0), graph, reverse,
|
|
316
|
+
set(selected_work), rel_cap=rel_cap,
|
|
317
|
+
))
|
|
318
|
+
if freed >= overshoot:
|
|
319
|
+
break
|
|
320
|
+
removed_set = set(removed)
|
|
321
|
+
selected_work = [s for s in selected_work if s not in removed_set]
|
|
322
|
+
# Keep the dropped manifest ranked: trimmed symbols scored higher
|
|
323
|
+
# than selection-time drops, so they go first.
|
|
324
|
+
dropped_work = (
|
|
325
|
+
sorted(removed, key=lambda s: -scores.get(s, 0)) + dropped_work
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return ContextPackage(
|
|
329
|
+
text = full_text,
|
|
330
|
+
symbol_count = len(selected_work),
|
|
331
|
+
token_estimate = full_tokens,
|
|
332
|
+
total_repo_tokens = total_repo_tokens,
|
|
333
|
+
items = items,
|
|
334
|
+
dropped_symbols = dropped_work,
|
|
335
|
+
skipped_files = skipped_files,
|
|
336
|
+
graph_confidence = graph_confidence,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Meta-header
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
def _build_meta_header(
|
|
345
|
+
symbols: Dict[str, Symbol],
|
|
346
|
+
selected_ids: List[str],
|
|
347
|
+
dropped_ids: List[str],
|
|
348
|
+
skipped_files: List[str],
|
|
349
|
+
changed_ids: List[str],
|
|
350
|
+
graph: Optional[Dict[str, List[str]]],
|
|
351
|
+
reverse: Dict[str, Set[str]],
|
|
352
|
+
graph_confidence: float,
|
|
353
|
+
token_budget: int,
|
|
354
|
+
context_tokens: int,
|
|
355
|
+
scores: Dict[str, float],
|
|
356
|
+
notes: Optional[str] = None,
|
|
357
|
+
scoring_config: Optional[ScoringConfig] = None,
|
|
358
|
+
max_tokens: Optional[int] = None,
|
|
359
|
+
count: Optional[Callable[[str], int]] = None,
|
|
360
|
+
) -> str:
|
|
361
|
+
count = count or (lambda text: max(1, len(text) // 4))
|
|
362
|
+
total_syms = len(symbols)
|
|
363
|
+
selected_cnt = len(selected_ids)
|
|
364
|
+
dropped_cnt = len(dropped_ids)
|
|
365
|
+
scored_cnt = len(scores)
|
|
366
|
+
total_edges = sum(len(v) for v in graph.values()) if graph else 0
|
|
367
|
+
|
|
368
|
+
direct_callers = sum(
|
|
369
|
+
1 for s in changed_ids
|
|
370
|
+
for c in reverse.get(s, set())
|
|
371
|
+
)
|
|
372
|
+
direct_callees = sum(
|
|
373
|
+
len(graph.get(s, []))
|
|
374
|
+
for s in changed_ids
|
|
375
|
+
) if graph else 0
|
|
376
|
+
|
|
377
|
+
lines = [
|
|
378
|
+
"=== DIFFCONTEXT META ===",
|
|
379
|
+
f"Repo symbols total : {total_syms}",
|
|
380
|
+
f"Symbols scored : {scored_cnt}",
|
|
381
|
+
f"Symbols IN context : {selected_cnt}",
|
|
382
|
+
f"Symbols DROPPED : {dropped_cnt} ← you cannot see these",
|
|
383
|
+
f"Graph edges total : {total_edges}",
|
|
384
|
+
f"Graph confidence : {graph_confidence * 100:.0f}%"
|
|
385
|
+
+ (" ✓" if graph_confidence >= 0.9 else " ⚠ incomplete"),
|
|
386
|
+
# Always-present disclosure, same category as the DROPPED manifest:
|
|
387
|
+
# benchmarked cross-subsystem conceptual co-changes score 0% recall
|
|
388
|
+
# for every static method (see EVAL_V2_REPORT.md failure taxonomy),
|
|
389
|
+
# so a confident-looking 100% must not read as "nothing was missed".
|
|
390
|
+
"Note: graph confidence = STRUCTURAL completeness only. Static "
|
|
391
|
+
"analysis cannot see cross-subsystem conceptual coupling (e.g. a "
|
|
392
|
+
"settings flag and the unrelated code that reads it) — such "
|
|
393
|
+
"related code may exist and not be listed anywhere above.",
|
|
394
|
+
f"Changed symbols : {len(changed_ids)}",
|
|
395
|
+
f"Direct callers found : {direct_callers}",
|
|
396
|
+
f"Direct callees found : {direct_callees}",
|
|
397
|
+
f"Context tokens (code) : {context_tokens:,}",
|
|
398
|
+
"Output tokens (full) : {FULL_OUTPUT_TOKENS}",
|
|
399
|
+
f"Scoring basis : {describe_scoring_basis(scoring_config)}",
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
# --- Repository Architecture Snapshot ---
|
|
403
|
+
# Build rel_file -> absolute_path mapping from symbol table.
|
|
404
|
+
# sym_id gives us relative path; sym.file gives us the absolute path we
|
|
405
|
+
# need to actually open the file for its docstring.
|
|
406
|
+
rel_to_abs: Dict[str, str] = {}
|
|
407
|
+
for sym_id, sym in symbols.items():
|
|
408
|
+
rel_file = sym_id.split(":", 1)[0]
|
|
409
|
+
if rel_file not in rel_to_abs:
|
|
410
|
+
rel_to_abs[rel_file] = sym.file # sym.file is always absolute
|
|
411
|
+
|
|
412
|
+
modules_total = {}
|
|
413
|
+
modules_selected = {}
|
|
414
|
+
for sym_id in symbols:
|
|
415
|
+
file_name = sym_id.split(":", 1)[0]
|
|
416
|
+
modules_total[file_name] = modules_total.get(file_name, 0) + 1
|
|
417
|
+
|
|
418
|
+
for sym_id in selected_ids:
|
|
419
|
+
if sym_id in symbols:
|
|
420
|
+
file_name = sym_id.split(":", 1)[0]
|
|
421
|
+
modules_selected[file_name] = modules_selected.get(file_name, 0) + 1
|
|
422
|
+
|
|
423
|
+
lines.append("")
|
|
424
|
+
lines.append("=== REPOSITORY ARCHITECTURE SNAPSHOT ===")
|
|
425
|
+
|
|
426
|
+
loaded_files = []
|
|
427
|
+
blind_files = []
|
|
428
|
+
|
|
429
|
+
for file_name, total in sorted(modules_total.items()):
|
|
430
|
+
selected = modules_selected.get(file_name, 0)
|
|
431
|
+
|
|
432
|
+
doc_snippet = ""
|
|
433
|
+
if file_name.endswith(".py"):
|
|
434
|
+
# FIX: use absolute path, not the relative file_name
|
|
435
|
+
abs_path = rel_to_abs.get(file_name, "")
|
|
436
|
+
doc_str = _get_module_docstring(abs_path) if abs_path else ""
|
|
437
|
+
if doc_str:
|
|
438
|
+
doc_snippet = f" — {doc_str}"
|
|
439
|
+
|
|
440
|
+
if selected > 0:
|
|
441
|
+
loaded_files.append(f" - {file_name} ({selected}/{total} symbols loaded){doc_snippet}")
|
|
442
|
+
else:
|
|
443
|
+
blind_files.append(f" - {file_name} ({total} symbols){doc_snippet}")
|
|
444
|
+
|
|
445
|
+
# Budget proportionality: the snapshot scales with repo size, not with
|
|
446
|
+
# the requested budget. Under a tight budget an uncapped snapshot can
|
|
447
|
+
# cost multiples of the code it annotates (measured: --max-tokens 500 on
|
|
448
|
+
# black produced ~2,600 total tokens, 5x the request). Compact it when
|
|
449
|
+
# it would exceed ~25% of the symbol budget.
|
|
450
|
+
snapshot_cost = count("\n".join(loaded_files + blind_files))
|
|
451
|
+
snapshot_budget = max(max_tokens // 4, 150) if max_tokens else None
|
|
452
|
+
if snapshot_budget is not None and snapshot_cost > snapshot_budget:
|
|
453
|
+
n_loaded = len(loaded_files)
|
|
454
|
+
n_blind = len(blind_files)
|
|
455
|
+
lines.append(
|
|
456
|
+
f"MODULES: {len(modules_total)} files — {n_loaded} in context, "
|
|
457
|
+
f"{n_blind} blind spots"
|
|
458
|
+
)
|
|
459
|
+
lines.append(
|
|
460
|
+
" (per-module snapshot omitted under tight budget — raise "
|
|
461
|
+
"--max-tokens to see it)"
|
|
462
|
+
)
|
|
463
|
+
else:
|
|
464
|
+
lines.append("MODULES IN CONTEXT:")
|
|
465
|
+
if loaded_files:
|
|
466
|
+
lines.extend(loaded_files)
|
|
467
|
+
else:
|
|
468
|
+
lines.append(" (none)")
|
|
469
|
+
|
|
470
|
+
lines.append("")
|
|
471
|
+
lines.append("KNOWN MODULES (NOT IN CONTEXT - BLIND SPOTS):")
|
|
472
|
+
if blind_files:
|
|
473
|
+
_BLIND_CAP = 25
|
|
474
|
+
lines.extend(blind_files[:_BLIND_CAP])
|
|
475
|
+
if len(blind_files) > _BLIND_CAP:
|
|
476
|
+
lines.append(f" ... and {len(blind_files) - _BLIND_CAP} more modules")
|
|
477
|
+
else:
|
|
478
|
+
lines.append(" (none)")
|
|
479
|
+
|
|
480
|
+
if skipped_files:
|
|
481
|
+
lines.append("")
|
|
482
|
+
lines.append(f"FILES WITH SYNTAXERROR ({len(skipped_files)}) — graph has holes here:")
|
|
483
|
+
for f in skipped_files:
|
|
484
|
+
lines.append(f" ✗ {f}")
|
|
485
|
+
|
|
486
|
+
if dropped_cnt > 0:
|
|
487
|
+
# Under a tight budget, the top-15 manifest itself costs more than
|
|
488
|
+
# some requested budgets — show the top 5 and keep the count honest.
|
|
489
|
+
drop_cap = 5 if (max_tokens and max_tokens < 2000) else 15
|
|
490
|
+
lines.append("")
|
|
491
|
+
lines.append(f"DROPPED SYMBOLS ({dropped_cnt}) — scored but cut by token budget:")
|
|
492
|
+
for d in dropped_ids[:drop_cap]:
|
|
493
|
+
lines.append(f" - {d} (score: {scores.get(d, 0):.0f})")
|
|
494
|
+
if dropped_cnt > drop_cap:
|
|
495
|
+
lines.append(f" ... and {dropped_cnt - drop_cap} more")
|
|
496
|
+
lines.append(" → If any of these are critical, re-run with a higher --max-tokens.")
|
|
497
|
+
|
|
498
|
+
warnings = []
|
|
499
|
+
if skipped_files:
|
|
500
|
+
warnings.append(
|
|
501
|
+
f"⚠ {len(skipped_files)} file(s) had SyntaxErrors. "
|
|
502
|
+
"Call graph may be incomplete for those files."
|
|
503
|
+
)
|
|
504
|
+
if dropped_cnt > 0:
|
|
505
|
+
warnings.append(
|
|
506
|
+
f"⚠ {dropped_cnt} symbol(s) were dropped. "
|
|
507
|
+
"References to them in the code below are NOT backed by visible implementations."
|
|
508
|
+
)
|
|
509
|
+
if graph_confidence < 0.8:
|
|
510
|
+
warnings.append(
|
|
511
|
+
f"⚠ Graph confidence is {graph_confidence * 100:.0f}%. "
|
|
512
|
+
"Many calls could not be resolved — likely external/stdlib deps or dynamic dispatch."
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
if warnings:
|
|
516
|
+
lines.append("")
|
|
517
|
+
for w in warnings:
|
|
518
|
+
lines.append(w)
|
|
519
|
+
|
|
520
|
+
if notes:
|
|
521
|
+
lines.append(f"\n=== DEVELOPER NOTES ===\n{notes}")
|
|
522
|
+
|
|
523
|
+
lines.append("=== END META ===")
|
|
524
|
+
return "\n".join(lines)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ---------------------------------------------------------------------------
|
|
528
|
+
# Per-symbol relationship block
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
def _build_relationship_block(
|
|
532
|
+
sym_id: str,
|
|
533
|
+
graph: Optional[Dict[str, List[str]]],
|
|
534
|
+
reverse: Dict[str, Set[str]],
|
|
535
|
+
selected_set: Set[str],
|
|
536
|
+
symbols: Dict[str, Symbol],
|
|
537
|
+
cap: int = 6,
|
|
538
|
+
) -> str:
|
|
539
|
+
if not graph:
|
|
540
|
+
return ""
|
|
541
|
+
|
|
542
|
+
lines = []
|
|
543
|
+
|
|
544
|
+
callers = sorted(reverse.get(sym_id, set()))
|
|
545
|
+
callees = graph.get(sym_id, [])
|
|
546
|
+
|
|
547
|
+
if callers:
|
|
548
|
+
caller_parts = []
|
|
549
|
+
for c in callers[:cap]:
|
|
550
|
+
tag = "" if c in selected_set else " [NOT IN CONTEXT]"
|
|
551
|
+
caller_parts.append(c + tag)
|
|
552
|
+
if len(callers) > cap:
|
|
553
|
+
caller_parts.append(f"... +{len(callers) - cap} more")
|
|
554
|
+
lines.append(f"CALLERS: {', '.join(caller_parts)}")
|
|
555
|
+
|
|
556
|
+
if callees:
|
|
557
|
+
callee_parts = []
|
|
558
|
+
for c in callees[:cap]:
|
|
559
|
+
tag = "" if c in selected_set else " [NOT IN CONTEXT]"
|
|
560
|
+
callee_parts.append(c + tag)
|
|
561
|
+
if len(callees) > cap:
|
|
562
|
+
callee_parts.append(f"... +{len(callees) - cap} more")
|
|
563
|
+
lines.append(f"CALLEES: {', '.join(callee_parts)}")
|
|
564
|
+
|
|
565
|
+
if not callers and not callees:
|
|
566
|
+
lines.append("CALLERS: (none found in repo)")
|
|
567
|
+
lines.append("CALLEES: (none found in repo)")
|
|
568
|
+
|
|
569
|
+
return "\n".join(lines) + "\n" if lines else ""
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# ---------------------------------------------------------------------------
|
|
573
|
+
# Suggestions block
|
|
574
|
+
# ---------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
def _build_suggestions(
|
|
577
|
+
changed_ids, dropped_ids, skipped_files,
|
|
578
|
+
graph, reverse, graph_confidence, scores,
|
|
579
|
+
) -> str:
|
|
580
|
+
tips = []
|
|
581
|
+
|
|
582
|
+
if skipped_files:
|
|
583
|
+
tips.append(
|
|
584
|
+
f"Fix SyntaxErrors in {len(skipped_files)} file(s) to improve graph accuracy."
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
if graph_confidence < 0.7:
|
|
588
|
+
tips.append(
|
|
589
|
+
f"Graph confidence is {graph_confidence * 100:.0f}%. "
|
|
590
|
+
"Consider running diffcontext on installed site-packages too, "
|
|
591
|
+
"or add the dependency source to your repo path."
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
for sym_id in changed_ids:
|
|
595
|
+
callers = list(reverse.get(sym_id, set()))
|
|
596
|
+
if len(callers) > 20:
|
|
597
|
+
tips.append(
|
|
598
|
+
f"'{sym_id.split(':')[-1]}' has {len(callers)} callers — "
|
|
599
|
+
"unusually high blast radius. Review changes carefully."
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if dropped_ids:
|
|
603
|
+
tips.append(
|
|
604
|
+
f"{len(dropped_ids)} symbol(s) were dropped by token budget. "
|
|
605
|
+
"Run with --max-tokens=0 (unlimited) to see full context."
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if not tips:
|
|
609
|
+
return ""
|
|
610
|
+
|
|
611
|
+
lines = ["=== DIFFCONTEXT SUGGESTIONS ==="]
|
|
612
|
+
for i, tip in enumerate(tips, 1):
|
|
613
|
+
lines.append(f" {i}. {tip}")
|
|
614
|
+
lines.append("=== END SUGGESTIONS ===")
|
|
615
|
+
return "\n".join(lines)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
# Graph confidence
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
def _compute_confidence(
|
|
623
|
+
graph: Optional[Dict[str, List[str]]],
|
|
624
|
+
symbols: Dict[str, Symbol],
|
|
625
|
+
) -> float:
|
|
626
|
+
"""
|
|
627
|
+
Fraction of graph edges that resolve to a known symbol.
|
|
628
|
+
Edges to external/stdlib deps count as unresolved.
|
|
629
|
+
Returns 1.0 if graph is empty or None (no data = no known holes).
|
|
630
|
+
"""
|
|
631
|
+
if not graph:
|
|
632
|
+
return 1.0
|
|
633
|
+
|
|
634
|
+
total = 0
|
|
635
|
+
resolved = 0
|
|
636
|
+
|
|
637
|
+
for deps in graph.values():
|
|
638
|
+
for d in deps:
|
|
639
|
+
total += 1
|
|
640
|
+
if d in symbols:
|
|
641
|
+
resolved += 1
|
|
642
|
+
|
|
643
|
+
return resolved / total if total > 0 else 1.0
|