agentedit 1.0.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.
agentedit/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 thetaroot
3
+
4
+ """agentedit — a local change-intelligence layer for coding agents."""
5
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 thetaroot
3
+
@@ -0,0 +1,110 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 thetaroot
3
+
4
+ """Working-tree change surface.
5
+
6
+ Given the set of files touched in the working tree (vs HEAD), report which
7
+ symbols in *unchanged* files depend on symbols defined in the changed files —
8
+ i.e. what the current edit might ripple into. Operates on the last indexed
9
+ (pre-change) graph, which is exactly the baseline an agent edits against.
10
+
11
+ Language behaviour comes exclusively from the backend registry: which files to
12
+ consider and how to name an (as yet unindexed) file's module.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Any
18
+
19
+ from agentedit.backends import backend_for_path
20
+ from agentedit.store.sqlite import GraphStore
21
+ from agentedit.workspace import git
22
+
23
+ # Usage edges + static import-name edges: a symbol referenced by name is a
24
+ # compile-level dependant whether or not it is called.
25
+ _DEPENDANT_EDGES: tuple[str, ...] = ("calls", "inherits", "renders", "imports")
26
+
27
+
28
+ def changes(
29
+ store: GraphStore,
30
+ repo: str,
31
+ changed_files: list[str] | None = None,
32
+ ) -> dict[str, Any]:
33
+ files = changed_files or git.changed_files(repo)
34
+ files = [f for f in files if _supported(f)]
35
+ changed_set = set(files)
36
+ indexed = store.index_files()
37
+
38
+ affected: list[dict[str, Any]] = []
39
+ affected_files: list[str] = []
40
+ seen: set[tuple[str, str]] = set()
41
+
42
+ for path in changed_set:
43
+ # 1) dependants (by usage or by name-import) of exported symbols in the
44
+ # changed file — unless the dependant is itself in the change set.
45
+ for sym in store.symbols_in_file(path):
46
+ if not sym["exported"]:
47
+ continue
48
+ for edge in store.edges_to(sym["qname"], _DEPENDANT_EDGES):
49
+ dep = store.get_symbol(edge["source_qname"])
50
+ if dep is None or dep["file_path"] in changed_set:
51
+ continue
52
+ relation = "file-level" if edge["kind"] == "imports" else "direct"
53
+ _add(affected, seen, affected_files,
54
+ qname=dep["qname"], kind=dep["kind"], file_path=dep["file_path"],
55
+ relation=relation, edge_kind=edge["kind"])
56
+ # 2) whole-file dependants: only when the file was *deleted* does every
57
+ # module importer break (they import a module that no longer exists).
58
+ if not os.path.isfile(os.path.join(repo, path)):
59
+ module = indexed[path]["module_qname"] if path in indexed else None
60
+ if module is None:
61
+ backend = backend_for_path(path)
62
+ module = backend.module_id(path) if backend else None
63
+ if module:
64
+ for edge in store.edges_to(module, ("imports",)):
65
+ dep = store.get_symbol(edge["source_qname"])
66
+ if dep is None or dep["file_path"] in changed_set:
67
+ continue
68
+ _add(affected, seen, affected_files,
69
+ qname=dep["qname"], kind=dep["kind"], file_path=dep["file_path"],
70
+ relation="file-level", edge_kind="imports")
71
+
72
+ affected.sort(key=lambda a: (a["qname"], a["relation"]))
73
+ return {
74
+ "changed_files": files,
75
+ "affected": affected,
76
+ "affected_files": affected_files,
77
+ }
78
+
79
+
80
+ def _supported(path: str) -> bool:
81
+ backend = backend_for_path(path)
82
+ if backend is None:
83
+ return False
84
+ return not backend.should_skip(path)
85
+
86
+
87
+ def _add(
88
+ affected: list[dict[str, Any]],
89
+ seen: set[tuple[str, str]],
90
+ affected_files: list[str],
91
+ *,
92
+ qname: str,
93
+ kind: str,
94
+ file_path: str,
95
+ relation: str,
96
+ edge_kind: str,
97
+ ) -> None:
98
+ key = (relation, qname)
99
+ if key in seen:
100
+ return
101
+ seen.add(key)
102
+ affected.append({
103
+ "qname": qname,
104
+ "kind": kind,
105
+ "file_path": file_path,
106
+ "relation": relation,
107
+ "edge_kind": edge_kind,
108
+ })
109
+ if file_path not in affected_files:
110
+ affected_files.append(file_path)
@@ -0,0 +1,355 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 thetaroot
3
+
4
+ """Impact analysis on top of the persisted graph.
5
+
6
+ The engine answers the questions that matter *before* a change is made:
7
+
8
+ * ``dependents`` — who statically references this symbol?
9
+ * ``impact`` — dependents + module importers + transitive ripple.
10
+ * ``would_break`` — of those, which ones a concrete change (removed / renamed /
11
+ signature / type) would actually break, graded with a confidence.
12
+
13
+ Confidence is structural honesty, not a guess: an edge that was resolved in the
14
+ same file or via an explicit import binding is near-certain; a global-unique
15
+ name match is weaker. Every prediction ships its basis so an agent can decide
16
+ how much to trust it.
17
+
18
+ Nothing here knows a language: module identity is read from the store
19
+ (``files.module_qname``), never re-derived from paths.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import Any
25
+
26
+ from agentedit.model import (
27
+ DEPENDENCY_EDGES,
28
+ Affected,
29
+ ImpactReport,
30
+ )
31
+ from agentedit.store.sqlite import GraphStore
32
+
33
+ _METHOD_CONFIDENCE: dict[str, float] = {
34
+ "same": 0.9,
35
+ "import": 0.95,
36
+ "global": 0.6,
37
+ "attr": 0.6, # flow-lite dispatch: name bound to a real symbol, but
38
+ # the variable may be reassigned at runtime -> not 1.0
39
+ "static": 0.5,
40
+ }
41
+ # Edges that break a *compiling* dependant when the target symbol is
42
+ # removed/renamed: usage edges plus static import-name references (an
43
+ # ``import { x }`` that is never called still fails to compile once ``x`` is
44
+ # gone). Signature/type changes only break call sites, not mere imports.
45
+ _USAGE_EDGES: tuple[str, ...] = ("calls", "inherits", "renders")
46
+ _DEPENDANT_EDGES: tuple[str, ...] = (*_USAGE_EDGES, "imports", "uses_type")
47
+
48
+
49
+ def _affected_from_edge(store: GraphStore, qname: str, edge: dict[str, Any]) -> Affected | None:
50
+ node = store.get_symbol(edge["source_qname"])
51
+ if node is None:
52
+ return None
53
+ conf = _METHOD_CONFIDENCE.get(edge["method"], 0.5)
54
+ kind = edge["kind"]
55
+ if kind == "imports":
56
+ evidence = f"imports {edge['target_qname']} by name ({edge['method']})"
57
+ else:
58
+ evidence = f"{kind} {qname} ({edge['method']} resolution)"
59
+ return Affected(
60
+ qname=edge["source_qname"],
61
+ kind=node["kind"],
62
+ file_path=node["file_path"],
63
+ relation="direct",
64
+ edge_kind=kind,
65
+ confidence=round(conf, 2),
66
+ evidence=evidence,
67
+ )
68
+
69
+
70
+ def _populate_surfaces(store: GraphStore, report: ImpactReport, qname: str) -> None:
71
+ """Surface what a plain dependents query cannot see.
72
+
73
+ * A **framework/plugin entry** (route-decorated function) is externally
74
+ reachable even when nothing in the repo calls it — never report that as
75
+ silent "no risk".
76
+ * When no *resolved* dependents exist, name-matching **unresolved**
77
+ references are listed as suspected (low-confidence pointers to check,
78
+ never fabricated edges).
79
+ """
80
+ row = store.get_symbol(qname)
81
+ if row is None:
82
+ return
83
+ if row.get("external_entry"):
84
+ report.external_entry = True
85
+ report.external_hint = row.get("external_hint")
86
+ report.notes.append(
87
+ "framework-registered entry (external surface): reachable via "
88
+ "decorator/registration; zero static in-repo dependents is expected, "
89
+ "not proof of safety"
90
+ )
91
+ if report.direct or report.transitive:
92
+ return
93
+ suspected = store.unresolved_suspected(str(row["name"]))
94
+ if suspected:
95
+ report.suspected = suspected
96
+ report.notes.append(
97
+ f"{len(suspected)} unresolved in-repo/dynamic reference(s) name-match "
98
+ f"this symbol (suspected, low confidence — verify in code, not edges)"
99
+ )
100
+ if (not report.direct and not report.transitive
101
+ and (report.suspected or report.external_entry)
102
+ and report.risk == "none"):
103
+ # Not provably safe: either framework-reachable or name-matched refs exist.
104
+ report.risk = "low"
105
+
106
+
107
+ def _why_chain(root: str, node: str, parent: dict[str, str]) -> list[str]:
108
+ """Root -> node path reconstructed from the BFS parent map."""
109
+ chain: list[str] = [node]
110
+ current = node
111
+ while current != root:
112
+ prev = parent.get(current)
113
+ if prev is None or prev == current:
114
+ break
115
+ chain.append(prev)
116
+ current = prev
117
+ return list(reversed(chain))
118
+
119
+
120
+ def _member_names(text: str) -> set[str]:
121
+ """Approximate set of member names in a type/class body text."""
122
+ return set(re.findall(r"([A-Za-z_$][\w$]*)\s*(?=:\s|\(|\?)", text))
123
+
124
+
125
+ def dependents(store: GraphStore, qname: str, *, max_depth: int = 2) -> ImpactReport:
126
+ """Who depends on ``qname`` — direct + transitive, with why-chains.
127
+
128
+ Each :class:`Affected` carries its full ``path`` from the root symbol to
129
+ itself (e.g. ``auth.authenticate -> controller.login -> routes.handle``),
130
+ so an agent sees *why* something is affected, not just that it is.
131
+ """
132
+ report = ImpactReport(root=qname)
133
+ visited: set[str] = set()
134
+ frontier = {qname}
135
+ parent: dict[str, str] = {}
136
+
137
+ depth = 0
138
+ while frontier and depth < max_depth:
139
+ next_frontier: set[str] = set()
140
+ for current in sorted(frontier):
141
+ for edge in store.edges_to(current, DEPENDENCY_EDGES):
142
+ source = edge["source_qname"]
143
+ if source in visited or source == qname:
144
+ continue
145
+ node = store.get_symbol(source)
146
+ if node is None:
147
+ continue
148
+ conf = _METHOD_CONFIDENCE.get(edge["method"], 0.5)
149
+ relation = "direct" if depth == 0 else "transitive"
150
+ parent.setdefault(source, current)
151
+ chain = _why_chain(qname, source, parent)
152
+ evidence = f"{edge['kind']} {current} ({edge['method']})"
153
+ aff = Affected(
154
+ qname=source,
155
+ kind=node["kind"],
156
+ file_path=node["file_path"],
157
+ relation=relation,
158
+ edge_kind=edge["kind"],
159
+ confidence=round(conf, 2),
160
+ evidence=evidence,
161
+ path=chain,
162
+ )
163
+ if relation == "direct":
164
+ report.direct.append(aff)
165
+ else:
166
+ report.transitive.append(aff)
167
+ next_frontier.add(source)
168
+ visited.update(frontier)
169
+ frontier = next_frontier - visited
170
+ depth += 1
171
+
172
+ report.risk = "high" if report.direct else "none"
173
+ if report.direct:
174
+ report.confidence = round(max(a.confidence for a in report.direct), 2)
175
+ report.notes.append(f"{len(report.direct)} direct, {len(report.transitive)} transitive dependents")
176
+ report.direct.sort(key=lambda a: a.qname)
177
+ report.transitive.sort(key=lambda a: a.qname)
178
+ _populate_surfaces(store, report, qname)
179
+ return report
180
+
181
+
182
+ def impact(store: GraphStore, qname: str) -> ImpactReport:
183
+ """Who depends on a symbol — structural + import-name dependants.
184
+
185
+ Import-name edges (a file statically ``import { x }`` of this symbol) are
186
+ direct dependants: removing/renaming the symbol breaks the import even if
187
+ it is never called. Transitive ripple = dependants of the direct set.
188
+ """
189
+ report = dependents(store, qname)
190
+ if store.get_symbol(qname) is None:
191
+ report.notes.append(f"symbol not found: {qname}")
192
+ return report
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # would_break
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ def would_break(
201
+ store: GraphStore,
202
+ qname: str,
203
+ *,
204
+ change: str = "removed",
205
+ new_signature: str | None = None,
206
+ ) -> ImpactReport:
207
+ """Predict breakage for a concrete change to ``qname``.
208
+
209
+ removed/renamed/type — every static dependant breaks, including files that
210
+ only ``import { x }`` the symbol (compile error even when unused).
211
+ signature/return_type — only *call sites* are at risk; a mere import of the
212
+ (still existing) name does not break.
213
+ """
214
+ root = store.get_symbol(qname)
215
+ if root is None:
216
+ return ImpactReport(root=qname, notes=[f"symbol not found: {qname}"])
217
+
218
+ report = ImpactReport(root=qname, change=change)
219
+ change = change.lower()
220
+
221
+ # Which dependants does this kind of change actually break?
222
+ if change in ("removed", "renamed", "type", "members"):
223
+ kinds = _DEPENDANT_EDGES
224
+ elif change in ("signature", "return_type"):
225
+ kinds = _USAGE_EDGES
226
+ else:
227
+ kinds = _USAGE_EDGES
228
+ for edge in store.edges_to(qname, kinds):
229
+ affected = _affected_from_edge(store, qname, edge)
230
+ if affected is not None:
231
+ report.direct.append(affected)
232
+
233
+ # Risk from the change semantics.
234
+ sig_note = ""
235
+ if change == "signature" and new_signature:
236
+ old = root["signature"] or ""
237
+ diff = _diff_signature(old, new_signature)
238
+ required_added = [p for p in diff["added"] if not _is_optional(p)]
239
+ if required_added:
240
+ report.risk = "high"
241
+ sig_note = f"adds required param(s): {', '.join(required_added)}"
242
+ elif diff["added"] or diff["removed"]:
243
+ report.risk = "medium"
244
+ sig_note = "signature change without required-param addition"
245
+ else:
246
+ report.risk = "low"
247
+ sig_note = "signature unchanged"
248
+ report.notes.append(
249
+ f"signature {diff['old_count']}->{diff['new_count']} params "
250
+ f"(+{len(diff['added'])} / -{len(diff['removed'])}); {sig_note}"
251
+ )
252
+ elif change in ("removed", "renamed"):
253
+ report.risk = "high"
254
+ report.notes.append(f"{change} of a referenced symbol breaks every static dependant")
255
+ elif change in ("type", "return_type"):
256
+ report.risk = "medium"
257
+ report.notes.append("type/return change: dependants using the value may need updates")
258
+ elif change == "members" and new_signature is not None:
259
+ old_members = _member_names(root["signature"] or "")
260
+ new_members = _member_names(new_signature)
261
+ removed = sorted(old_members - new_members)
262
+ added = sorted(new_members - old_members)
263
+ if removed:
264
+ report.risk = "high"
265
+ report.notes.append(f"removes member(s): {', '.join(removed)}")
266
+ elif added:
267
+ report.risk = "medium"
268
+ report.notes.append(f"adds member(s): {', '.join(added)}")
269
+ else:
270
+ report.risk = "low"
271
+ report.notes.append("member set unchanged")
272
+ report.notes.append(f"members {len(old_members)} -> {len(new_members)}")
273
+ else:
274
+ report.risk = "high" if report.direct else "none"
275
+ report.notes.append(f"change type '{change}'")
276
+
277
+ if report.direct:
278
+ report.confidence = round(max(a.confidence for a in report.direct), 2)
279
+
280
+ report.direct.sort(key=lambda a: a.qname)
281
+ report.notes.append(f"{len(report.direct)} direct break-risks")
282
+ _populate_surfaces(store, report, qname)
283
+ return report
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # signature diff (semantics lifted from SwiftGate's skelett.graph_query)
288
+ # ---------------------------------------------------------------------------
289
+
290
+
291
+ def _top_level_params(s: str) -> list[str]:
292
+ """Split a parameter list on top-level commas, ignoring nested brackets."""
293
+ inner = _paren_content(s)
294
+ out: list[str] = []
295
+ depth = 0
296
+ current: list[str] = []
297
+ for ch in inner:
298
+ if ch in "([{":
299
+ depth += 1
300
+ current.append(ch)
301
+ elif ch in ")]}":
302
+ depth -= 1
303
+ current.append(ch)
304
+ elif ch == "," and depth == 0:
305
+ out.append("".join(current).strip())
306
+ current = []
307
+ else:
308
+ current.append(ch)
309
+ if current and "".join(current).strip():
310
+ out.append("".join(current).strip())
311
+ return out
312
+
313
+
314
+ def _paren_content(s: str) -> str:
315
+ start = s.find("(")
316
+ if start < 0:
317
+ return ""
318
+ depth = 0
319
+ for i in range(start, len(s)):
320
+ if s[i] == "(":
321
+ depth += 1
322
+ elif s[i] == ")":
323
+ depth -= 1
324
+ if depth == 0:
325
+ return s[start + 1 : i]
326
+ return ""
327
+
328
+
329
+ def _param_name(token: str) -> str:
330
+ token = token.strip()
331
+ if "=" in token:
332
+ token = token.split("=", 1)[0]
333
+ if ":" in token:
334
+ token = token.split(":", 1)[0]
335
+ return token.strip().removeprefix("...").removesuffix("?")
336
+
337
+
338
+ def _is_optional(token: str) -> bool:
339
+ t = token.strip()
340
+ return "?" in t.split(":")[0] or "=" in t.split(":")[0] or t.startswith("...")
341
+
342
+
343
+ def _diff_signature(old: str, new: str) -> dict[str, Any]:
344
+ old_params = [_param_name(t) for t in _top_level_params(old)]
345
+ new_params = [_param_name(t) for t in _top_level_params(new)]
346
+ old_full = _top_level_params(old)
347
+ new_full = _top_level_params(new)
348
+ old_set = {_param_name(t) for t in old_full}
349
+ new_set = {_param_name(t) for t in new_full}
350
+ return {
351
+ "added": [_param_name(t) for t in new_full if _param_name(t) not in old_set],
352
+ "removed": [_param_name(t) for t in old_full if _param_name(t) not in new_set],
353
+ "old_count": len(old_params),
354
+ "new_count": len(new_params),
355
+ }
agentedit/audit.py ADDED
@@ -0,0 +1,189 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 thetaroot
3
+
4
+ """Crash-audit (flagship) and git-rationale (``why``).
5
+
6
+ An agent is about to edit. Instead of running several single-purpose queries
7
+ and starting its own costly audit, it calls ``audit`` once and receives a
8
+ deterministic brief:
9
+
10
+ * what would a change to the target ripple into (impact + would_break),
11
+ * grouped affected files with relation + confidence (a cheap read-set),
12
+ * suspected unresolved references (never silent "0 risk"),
13
+ * git rationale for the code it is about to touch (facts + candidates),
14
+ * resolution health of the repo (how much of the answer is import-precise).
15
+
16
+ ``why`` is the same git-rationale as a standalone answer: deterministic facts
17
+ (commit/author/date/subject anchored to the symbol's line range) plus candidate
18
+ observations mined from those commit messages.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import re
24
+ import subprocess
25
+ from typing import Any
26
+
27
+ from agentedit.analyze.impact import impact, would_break
28
+ from agentedit.store.sqlite import GraphStore
29
+
30
+ _CANDIDATE_HINTS = re.compile(
31
+ r"\b(fix(es|ed)?|break(s|ing|s)?|must|never|always|because|migration|"
32
+ r"renam(e|ed)|remov(e|ed)|deprecat(e|ed)|refactor(ed)?|revert(ed)?|why|"
33
+ r"behaviour change|behavior change|contract)\b",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+
38
+ def is_file_target(target: str) -> bool:
39
+ return ("/" in target or target.endswith((".ts", ".tsx", ".js", ".jsx",
40
+ ".py", ".go", ".rs", ".java")))
41
+
42
+
43
+ def _git_facts(repo: str, file_path: str, start: int, end: int,
44
+ limit: int = 12) -> list[dict[str, Any]]:
45
+ """Deterministic commit facts touching a line range of a file."""
46
+ if not os.path.isdir(os.path.join(repo, ".git")):
47
+ return []
48
+ try:
49
+ proc = subprocess.run(
50
+ ["git", "-C", repo, "log", "-L", f"{start},{end}:{file_path}",
51
+ "--format=%H%x1f%an%x1f%ad%x1f%s", "--date=short", "-n", str(limit)],
52
+ capture_output=True, text=True, timeout=30,
53
+ )
54
+ except (subprocess.SubprocessError, FileNotFoundError):
55
+ return []
56
+ facts: list[dict[str, Any]] = []
57
+ for raw in proc.stdout.splitlines():
58
+ if not raw or "\x1f" not in raw:
59
+ continue
60
+ parts = raw.split("\x1f")
61
+ if len(parts) < 4:
62
+ continue
63
+ facts.append({"commit": parts[0], "author": parts[1],
64
+ "date": parts[2], "subject": parts[3]})
65
+ return facts
66
+
67
+
68
+ def _candidates_from_facts(facts: list[dict[str, Any]]) -> list[dict[str, Any]]:
69
+ """Candidate observations mined from commit subjects (never confirmed)."""
70
+ out: list[dict[str, Any]] = []
71
+ for f in facts:
72
+ if _CANDIDATE_HINTS.search(f["subject"]):
73
+ out.append({
74
+ "commit": f["commit"],
75
+ "author": f["author"],
76
+ "text": f"{f['subject']} ({f['date']})",
77
+ "status": "candidate",
78
+ "origin": "git",
79
+ })
80
+ return out
81
+
82
+
83
+ def why(store: GraphStore, repo: str, qname: str,
84
+ limit: int = 12) -> dict[str, Any]:
85
+ """Deterministic git rationale for a symbol + in-memory candidates."""
86
+ symbol = store.get_symbol_with_lines(qname)
87
+ if symbol is None:
88
+ return {"symbol": qname, "facts": [], "candidates": [],
89
+ "notes": ["symbol not found"]}
90
+ file_path = str(symbol["file_path"])
91
+ start = int(symbol.get("line_start") or 1)
92
+ end = int(symbol.get("line_end") or start)
93
+ facts = _git_facts(repo, file_path, start, end, limit=limit)
94
+ return {
95
+ "symbol": qname,
96
+ "file": file_path,
97
+ "lines": [start, end],
98
+ "facts": facts,
99
+ "candidates": _candidates_from_facts(facts),
100
+ "git": os.path.isdir(os.path.join(repo, ".git")),
101
+ }
102
+
103
+
104
+ def audit(store: GraphStore, repo: str, target: str,
105
+ *, limit_files: int = 15) -> dict[str, Any]:
106
+ """One-call crash-audit brief for a symbol or a file."""
107
+ if is_file_target(target):
108
+ return _audit_file(store, repo, target, limit_files=limit_files)
109
+ return _audit_symbol(store, repo, target, limit_files=limit_files)
110
+
111
+
112
+ def _audit_symbol(store: GraphStore, repo: str, qname: str, *,
113
+ limit_files: int) -> dict[str, Any]:
114
+ report = impact(store, qname)
115
+ wb = would_break(store, qname, change="removed")
116
+ files: dict[str, dict[str, Any]] = {}
117
+ for node in [*report.direct, *report.transitive]:
118
+ bucket = files.setdefault(node.file_path, {"relations": [], "conf": 0.0})
119
+ bucket["relations"].append(node.relation)
120
+ bucket["conf"] = max(bucket["conf"], node.confidence)
121
+ file_list = [
122
+ {"file": path, "relations": sorted(set(b["relations"])),
123
+ "confidence": round(b["conf"], 2)}
124
+ for path, b in sorted(files.items())
125
+ ]
126
+ read_set = [f["file"] for f in file_list][:limit_files]
127
+ root_row = store.get_symbol(qname)
128
+ external_entry = bool(root_row is not None and root_row.get("external_entry"))
129
+ if not read_set and report.root and root_row is not None:
130
+ read_set = [str(root_row["file_path"])]
131
+ return {
132
+ "kind": "symbol",
133
+ "target": qname,
134
+ "change": "removed",
135
+ "risk": wb.risk,
136
+ "confidence": wb.confidence,
137
+ "symbol_notes": report.notes,
138
+ "files": file_list,
139
+ "affected_files_count": len(file_list),
140
+ "read_set": read_set,
141
+ "suspected": report.suspected,
142
+ "external_entry": external_entry,
143
+ "external_hint": (root_row.get("external_hint")
144
+ if root_row is not None else None),
145
+ "why": why(store, repo, qname),
146
+ "resolution": store.edges_by_method(),
147
+ "root_symbol": qname,
148
+ }
149
+
150
+
151
+ def _audit_file(store: GraphStore, repo: str, rel_path: str, *,
152
+ limit_files: int) -> dict[str, Any]:
153
+ rows = store.symbols_in_file(rel_path)
154
+ if not rows:
155
+ return {"kind": "file", "target": rel_path, "notes": ["file not indexed"]}
156
+ qnames = [str(r["qname"]) for r in rows if r["kind"] != "module"]
157
+ if not qnames:
158
+ qnames = [str(r["qname"]) for r in rows]
159
+ all_files: dict[str, dict[str, Any]] = {}
160
+ worst: dict[str, Any] = {"risk": "low", "confidence": 0.0}
161
+ for qname in qnames:
162
+ report = impact(store, qname)
163
+ wb = would_break(store, qname, change="removed")
164
+ if wb.confidence > worst["confidence"]:
165
+ worst = {"risk": wb.risk, "confidence": wb.confidence}
166
+ for node in [*report.direct, *report.transitive]:
167
+ bucket = all_files.setdefault(node.file_path, {"relations": [], "conf": 0.0})
168
+ bucket["relations"].append(node.relation)
169
+ bucket["conf"] = max(bucket["conf"], node.confidence)
170
+ file_list = [
171
+ {"file": path, "relations": sorted(set(b["relations"])),
172
+ "confidence": round(b["conf"], 2)}
173
+ for path, b in sorted(all_files.items())
174
+ ]
175
+ read_set = [f["file"] for f in file_list][:limit_files]
176
+ if not read_set:
177
+ read_set = [rel_path]
178
+ return {
179
+ "kind": "file",
180
+ "target": rel_path,
181
+ "change": "removed",
182
+ "risk": worst["risk"],
183
+ "confidence": round(worst["confidence"], 2),
184
+ "symbols_audited": len(qnames),
185
+ "files": file_list,
186
+ "affected_files_count": len(file_list),
187
+ "read_set": read_set,
188
+ "resolution": store.edges_by_method(),
189
+ }