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 +5 -0
- agentedit/analyze/__init__.py +3 -0
- agentedit/analyze/changes.py +110 -0
- agentedit/analyze/impact.py +355 -0
- agentedit/audit.py +189 -0
- agentedit/backends/__init__.py +56 -0
- agentedit/backends/base.py +72 -0
- agentedit/backends/go.py +354 -0
- agentedit/backends/java.py +239 -0
- agentedit/backends/python.py +703 -0
- agentedit/backends/rust.py +302 -0
- agentedit/backends/typescript.py +603 -0
- agentedit/cli.py +567 -0
- agentedit/defaults.py +19 -0
- agentedit/graphs.py +273 -0
- agentedit/index/__init__.py +3 -0
- agentedit/index/indexer.py +471 -0
- agentedit/index/resolver.py +522 -0
- agentedit/mcp_server.py +450 -0
- agentedit/model.py +175 -0
- agentedit/multirepo.py +160 -0
- agentedit/store/__init__.py +3 -0
- agentedit/store/sqlite.py +453 -0
- agentedit/watch.py +95 -0
- agentedit/workspace/__init__.py +3 -0
- agentedit/workspace/git.py +45 -0
- agentedit-1.0.0.dist-info/METADATA +86 -0
- agentedit-1.0.0.dist-info/RECORD +32 -0
- agentedit-1.0.0.dist-info/WHEEL +4 -0
- agentedit-1.0.0.dist-info/entry_points.txt +2 -0
- agentedit-1.0.0.dist-info/licenses/LICENSE +186 -0
- agentedit-1.0.0.dist-info/licenses/NOTICE +19 -0
agentedit/__init__.py
ADDED
|
@@ -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
|
+
}
|