align-agent 0.4.3__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.
- align_agent/__init__.py +11 -0
- align_agent/_audit/common.py +58 -0
- align_agent/_audit/deepswe.py +260 -0
- align_agent/_audit/excerpts.py +203 -0
- align_agent/_audit/extract.py +798 -0
- align_agent/_audit/materialize.py +624 -0
- align_agent/_audit/score.py +325 -0
- align_agent/_audit/shellparse.py +1141 -0
- align_agent/_audit/tarsur.py +153 -0
- align_agent/_vendor/src/__init__.py +3 -0
- align_agent/_vendor/src/harness/__init__.py +13 -0
- align_agent/_vendor/src/harness/arms.py +524 -0
- align_agent/_vendor/src/harness/completion.py +104 -0
- align_agent/_vendor/src/harness/dependency_notice.py +616 -0
- align_agent/_vendor/src/harness/freshness_hook.py +131 -0
- align_agent/_vendor/src/harness/lean.py +41 -0
- align_agent/_vendor/src/harness/mini_real.py +1040 -0
- align_agent/_vendor/src/harness/miniswe_scaffold.py +661 -0
- align_agent/_vendor/src/harness/tail_diff.py +292 -0
- align_agent/_vendor/src/harness/viewport.py +220 -0
- align_agent/_vendor/src/metrics/__init__.py +38 -0
- align_agent/_vendor/src/metrics/coherence.py +354 -0
- align_agent/_vendor/src/metrics/corruption_oracles.py +139 -0
- align_agent/_vendor/src/metrics/replay.py +399 -0
- align_agent/_vendor/src/metrics/statistics.py +185 -0
- align_agent/_vendor/src/metrics/verification.py +104 -0
- align_agent/_vendor/src/parsers/__init__.py +33 -0
- align_agent/_vendor/src/parsers/base_parser.py +349 -0
- align_agent/_vendor/src/parsers/file_events.py +806 -0
- align_agent/_vendor/src/parsers/miniswe_parser.py +96 -0
- align_agent/_vendor/src/parsers/openhands_parser.py +414 -0
- align_agent/_vendor/src/parsers/sweagent_parser.py +180 -0
- align_agent/_vendor/src/parsers/terminus_parser.py +106 -0
- align_agent/cli.py +101 -0
- align_agent/core.py +161 -0
- align_agent/run.py +84 -0
- align_agent/sessions.py +985 -0
- align_agent/writethrough/__init__.py +4 -0
- align_agent/writethrough/_lib/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/__init__.py +3 -0
- align_agent/writethrough/_lib/wt_src/harness/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/harness/dependency_notice.py +616 -0
- align_agent/writethrough/_lib/wt_src/harness/lean.py +41 -0
- align_agent/writethrough/_lib/wt_src/harness/tail_diff.py +292 -0
- align_agent/writethrough/_lib/wt_src/harness/viewport.py +220 -0
- align_agent/writethrough/_lib/wt_src/metrics/__init__.py +38 -0
- align_agent/writethrough/_lib/wt_src/metrics/replay.py +399 -0
- align_agent/writethrough/_lib/wt_src/parsers/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/parsers/file_events.py +806 -0
- align_agent/writethrough/claude_code.py +81 -0
- align_agent/writethrough/engine.py +647 -0
- align_agent/writethrough/hooks.py +227 -0
- align_agent/writethrough/mcp_server.py +125 -0
- align_agent/writethrough/protocol.py +68 -0
- align_agent-0.4.3.dist-info/METADATA +224 -0
- align_agent-0.4.3.dist-info/RECORD +60 -0
- align_agent-0.4.3.dist-info/WHEEL +5 -0
- align_agent-0.4.3.dist-info/entry_points.txt +6 -0
- align_agent-0.4.3.dist-info/licenses/LICENSE +21 -0
- align_agent-0.4.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Dependency write-through: after a write, name the uses it left stale elsewhere.
|
|
2
|
+
|
|
3
|
+
File-level write-through shows the agent the file it just wrote. It cannot
|
|
4
|
+
show that another file, whose bytes did not change, still uses a name the
|
|
5
|
+
write removed or a function whose parameters it changed: the agent's view of
|
|
6
|
+
that other file matches the disk and is still wrong. This module finds those
|
|
7
|
+
uses and formats a short, append-only notice for the step's own output.
|
|
8
|
+
|
|
9
|
+
Definitions are found with per-language line scanners rather than a parser
|
|
10
|
+
(no tree-sitter in the harness). A scanner reads only declarations at module
|
|
11
|
+
level and members one level inside a module-level class, interface, object or
|
|
12
|
+
struct; anything nested deeper is a local and is ignored. A name is reported
|
|
13
|
+
when the write removed its definition or made its old call sites invalid, and
|
|
14
|
+
a word match for it remains outside the written file.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
|
21
|
+
|
|
22
|
+
# 2: a name other files have always defined (another type's method) is no
|
|
23
|
+
# longer treated as moved; its uses in the written file's directory are listed.
|
|
24
|
+
# 3: JS/TS class members, object methods and `X.y = function`; function-body
|
|
25
|
+
# locals are no longer definitions (JS/TS, Python, Go); a name the agent added
|
|
26
|
+
# in this run is followed only into files it wrote; uses are ranked by whether
|
|
27
|
+
# their file imports the written file or names the owner; a parameter change
|
|
28
|
+
# is one to the parameter names, and a Go return count or field type counts.
|
|
29
|
+
NOTICE_VERSION = 3
|
|
30
|
+
MAX_SYMBOLS = 4
|
|
31
|
+
MAX_CANDIDATES = 12
|
|
32
|
+
MAX_USES_PER_SYMBOL = 4
|
|
33
|
+
MAX_CHARS = 2_000
|
|
34
|
+
MIN_NAME = 3
|
|
35
|
+
# Above this many uses a word match says little: only files tied to the
|
|
36
|
+
# written file (same directory, importing it, naming the owner) are listed.
|
|
37
|
+
MAX_HITS = 40
|
|
38
|
+
MAX_DEFINING = 25
|
|
39
|
+
JOIN_LINES = 12
|
|
40
|
+
|
|
41
|
+
Key = Tuple[str, str] # (owner, name); owner is "" at module level
|
|
42
|
+
# What a declaration promises its users: ("fn", parameters, what it returns),
|
|
43
|
+
# ("field", "", its type) or ("name", "", "") for anything else. Returns and
|
|
44
|
+
# field types are read for Go only, where a changed one breaks the build.
|
|
45
|
+
Sig = Tuple[str, str, str]
|
|
46
|
+
NAME: Sig = ("name", "", "")
|
|
47
|
+
|
|
48
|
+
# Names too generic to report: a word match on them says nothing.
|
|
49
|
+
STOP = {"init", "main", "test", "setup", "self", "this", "config", "data", "name", "value", "type",
|
|
50
|
+
"error", "result", "default", "options", "props", "state", "__init__", "__main__", "run", "get",
|
|
51
|
+
"set", "new", "len", "String", "Error", "Close", "Name", "start", "end", "key", "index", "item",
|
|
52
|
+
"render", "toString", "constructor", "log", "logger"}
|
|
53
|
+
_JS_KEYWORDS = set("if for while switch catch with function return typeof await new else do super "
|
|
54
|
+
"constructor delete void throw case yield import export in of try finally".split())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _balanced(lines: List[str], i: int) -> str:
|
|
58
|
+
"""Line i, joined with the following lines until its parentheses balance."""
|
|
59
|
+
text = lines[i].strip()
|
|
60
|
+
j = i
|
|
61
|
+
while text.count("(") > text.count(")") and j + 1 < len(lines) and j - i < JOIN_LINES:
|
|
62
|
+
j += 1
|
|
63
|
+
text += " " + lines[j].strip()
|
|
64
|
+
return text
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _params(text: str, open_at: int) -> str:
|
|
68
|
+
"""The parameter text of the call or signature whose '(' is at open_at."""
|
|
69
|
+
depth = 0
|
|
70
|
+
for k in range(open_at, len(text)):
|
|
71
|
+
if text[k] == "(":
|
|
72
|
+
depth += 1
|
|
73
|
+
elif text[k] == ")":
|
|
74
|
+
depth -= 1
|
|
75
|
+
if depth == 0:
|
|
76
|
+
return text[open_at + 1:k].strip()
|
|
77
|
+
return text[open_at + 1:].strip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _indent(line: str) -> int:
|
|
81
|
+
return len(line) - len(line.lstrip())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- Python -------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
_PY_DEF = re.compile(r"(?:async\s+)?def\s+(\w+)\s*\(")
|
|
87
|
+
_PY_CLASS = re.compile(r"class\s+(\w+)")
|
|
88
|
+
_PY_ASSIGN = re.compile(r"([A-Za-z_]\w*)\s*(?::[^=]+)?=(?!=)")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _scan_python(lines: List[str]) -> Dict[Key, Sig]:
|
|
92
|
+
out: Dict[Key, Sig] = {}
|
|
93
|
+
owner, member_indent = "", None
|
|
94
|
+
for i, line in enumerate(lines):
|
|
95
|
+
body = line.strip()
|
|
96
|
+
if not body or body.startswith(("#", "@")):
|
|
97
|
+
continue
|
|
98
|
+
ind = _indent(line)
|
|
99
|
+
if ind == 0:
|
|
100
|
+
owner, member_indent = "", None
|
|
101
|
+
m = _PY_CLASS.match(body)
|
|
102
|
+
if m:
|
|
103
|
+
owner = m.group(1)
|
|
104
|
+
out.setdefault(("", owner), NAME)
|
|
105
|
+
continue
|
|
106
|
+
elif owner and member_indent is None:
|
|
107
|
+
member_indent = ind
|
|
108
|
+
if ind == 0 or (owner and ind == member_indent):
|
|
109
|
+
m = _PY_DEF.match(body)
|
|
110
|
+
if m:
|
|
111
|
+
joined = _balanced(lines, i)
|
|
112
|
+
out.setdefault((owner if ind else "", m.group(1)), ("fn", _params(joined, joined.index("(")), ""))
|
|
113
|
+
continue
|
|
114
|
+
m = _PY_ASSIGN.match(body) # a module constant or a class attribute
|
|
115
|
+
if m:
|
|
116
|
+
out.setdefault((owner if ind else "", m.group(1)), NAME)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- Go -----------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
_GO_FUNC = re.compile(r"func\s+(?:\(\s*\w*\s*\*?\s*(\w+)[^)]*\)\s*)?(\w+)\s*(?:\[[^\]]*\])?\s*\(")
|
|
123
|
+
_GO_TYPE = re.compile(r"type\s+(\w+)(?:\s+(struct|interface)\s*\{\s*$)?")
|
|
124
|
+
_GO_VAR = re.compile(r"(?:var|const)\s+(\w+)")
|
|
125
|
+
_GO_BLOCK = re.compile(r"(?:var|const)\s*\(\s*$")
|
|
126
|
+
_GO_FIELD = re.compile(r"\t([A-Z]\w*)\s+([\w\*\[\]\.{}]+)\s*(?:`[^`]*`)?\s*(?://.*)?$")
|
|
127
|
+
_GO_IFACE_METHOD = re.compile(r"\t(\w+)\s*\(")
|
|
128
|
+
_GO_ENTRY = re.compile(r"\t(\w+)\b")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _go_fn(joined: str, open_at: int) -> Sig:
|
|
132
|
+
params = _params(joined, open_at)
|
|
133
|
+
rest = joined[open_at + len(params) + 2:] if joined[open_at + 1:].startswith(params) else ""
|
|
134
|
+
close = joined.find(")", open_at + 1 + len(params)) if not rest else -1
|
|
135
|
+
if close >= 0:
|
|
136
|
+
rest = joined[close + 1:]
|
|
137
|
+
return ("fn", params, rest.split("{")[0].strip())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _scan_go(lines: List[str]) -> Dict[Key, Sig]:
|
|
141
|
+
out: Dict[Key, Sig] = {}
|
|
142
|
+
block, owner = "", "" # block: "", "struct", "interface" or "vars"
|
|
143
|
+
for i, line in enumerate(lines):
|
|
144
|
+
if not line.strip() or line.lstrip().startswith("//"):
|
|
145
|
+
continue
|
|
146
|
+
if line[0] not in " \t":
|
|
147
|
+
block, owner = "", ""
|
|
148
|
+
m = _GO_FUNC.match(line)
|
|
149
|
+
if m:
|
|
150
|
+
out.setdefault((m.group(1) or "", m.group(2)), _go_fn(_balanced(lines, i), m.end() - 1))
|
|
151
|
+
continue
|
|
152
|
+
m = _GO_TYPE.match(line)
|
|
153
|
+
if m:
|
|
154
|
+
out.setdefault(("", m.group(1)), NAME)
|
|
155
|
+
block, owner = (m.group(2) or ""), m.group(1)
|
|
156
|
+
continue
|
|
157
|
+
if _GO_BLOCK.match(line):
|
|
158
|
+
block = "vars"
|
|
159
|
+
continue
|
|
160
|
+
m = _GO_VAR.match(line)
|
|
161
|
+
if m:
|
|
162
|
+
out.setdefault(("", m.group(1)), NAME)
|
|
163
|
+
continue
|
|
164
|
+
if block == "struct":
|
|
165
|
+
m = _GO_FIELD.match(line)
|
|
166
|
+
if m:
|
|
167
|
+
out.setdefault((owner, m.group(1)), ("field", "", m.group(2)))
|
|
168
|
+
elif block == "interface":
|
|
169
|
+
m = _GO_IFACE_METHOD.match(line)
|
|
170
|
+
if m:
|
|
171
|
+
joined = _balanced(lines, i)
|
|
172
|
+
out.setdefault((owner, m.group(1)), _go_fn(joined, joined.index("(")))
|
|
173
|
+
elif block == "vars":
|
|
174
|
+
m = _GO_ENTRY.match(line)
|
|
175
|
+
if m and line.startswith("\t") and not line.startswith("\t\t"):
|
|
176
|
+
out.setdefault(("", m.group(1)), NAME)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --- JavaScript / TypeScript -------------------------------------------
|
|
181
|
+
|
|
182
|
+
_MODS = r"(?:(?:public|private|protected|static|readonly|override|abstract|async|declare)\s+)*"
|
|
183
|
+
_NAME = r"(#?[A-Za-z_$][\w$]*)"
|
|
184
|
+
_ARROW = (r"(?:async\s+)?(?:function\b|(?:<[^>()]*>\s*)?\((?:[^()]|\([^()]*\))*\)\s*(?::(?:[^=]|=>)+?)?=>"
|
|
185
|
+
r"|[A-Za-z_$][\w$]*\s*=>)")
|
|
186
|
+
_JS_FUNC = re.compile(r"(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*(?:<[^>()]*>)?\s*\(")
|
|
187
|
+
_JS_CLASS = re.compile(r"(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:class|interface)\s+(\w+)")
|
|
188
|
+
_JS_TYPE = re.compile(r"(?:export\s+)?(?:declare\s+)?(?:const\s+)?(?:type|enum|namespace)\s+(\w+)")
|
|
189
|
+
_JS_VAR = re.compile(r"(?:export\s+)?(?:declare\s+)?(?:const|let|var)\s+(\w+)\s*[=:]")
|
|
190
|
+
_JS_OBJECT = re.compile(r"(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::[^=]+)?=\s*\{\s*$")
|
|
191
|
+
_JS_EXPORTS = re.compile(r"(?:module\.exports|exports\.\w+|export\s+default)\s*=?\s*\{\s*$")
|
|
192
|
+
# A function that wraps the whole module: its body is the module's top level.
|
|
193
|
+
_JS_WRAPPER = re.compile(r"(?:module\.exports\s*=\s*(?:async\s+)?function|define\s*\(|;?\(\s*(?:async\s+)?function|;?\(\s*\(\s*\)\s*=>)")
|
|
194
|
+
_JS_MEMBER = re.compile(_MODS + r"(?:(?:get|set)\s+(?=[#\w$]))?\*?\s*" + _NAME + r"\s*\??\s*(?:<[^>()]*>)?\s*\(")
|
|
195
|
+
_JS_FIELD_FN = re.compile(_MODS + _NAME + r"\s*[?!]?\s*(?::(?:[^=]|=>)+?)?=\s*" + _ARROW)
|
|
196
|
+
_JS_KEY_FN = re.compile(r"['\"]?([A-Za-z_$][\w$]*)['\"]?\s*:\s*" + _ARROW)
|
|
197
|
+
_JS_ASSIGN_FN = re.compile(r"((?:[A-Za-z_$][\w$]*\.)+)" + _NAME + r"\s*=\s*" + _ARROW)
|
|
198
|
+
_JS_DECORATOR = re.compile(r"(?:@[\w.$]+(?:\([^)]*\))?\s+)+")
|
|
199
|
+
_JS_COMMENT = ("//", "/*", "*")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _js_unit(lines: List[str]) -> int:
|
|
203
|
+
"""The file's indentation step: its smallest indent on a code line."""
|
|
204
|
+
found = [_indent(ln) for ln in lines if ln.strip() and ln[0] in " \t" and not ln.strip().startswith(_JS_COMMENT)]
|
|
205
|
+
return min(found) if found else 1
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _fn_params(joined: str, m: "re.Match") -> str:
|
|
209
|
+
at = joined.find("(", m.start())
|
|
210
|
+
return _params(joined, at) if at >= 0 else ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_JS_ES_MODULE = re.compile(r"(?:export|import)\b")
|
|
214
|
+
_JS_EXPORT_LIST = re.compile(r"export\s*(?:default\s+([\w$]+)\s*;?\s*$|\{([^}]*)\})")
|
|
215
|
+
_JS_REQUIRE = re.compile(r"(?:const|let|var)\s+[\w${},\s:]+=\s*require\s*\(")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _js_private(lines: List[str], out: Dict[Key, Sig], declared: Dict[str, bool]) -> Set[str]:
|
|
219
|
+
"""Module-level names of an ES module that it does not export: no other
|
|
220
|
+
file can refer to them. A CommonJS or AMD file exports by assignment, which
|
|
221
|
+
a line scanner cannot follow, so nothing is dropped there."""
|
|
222
|
+
if not any(_JS_ES_MODULE.match(ln) for ln in lines):
|
|
223
|
+
return set()
|
|
224
|
+
exported = {name for name, flag in declared.items() if flag}
|
|
225
|
+
for ln in lines:
|
|
226
|
+
m = _JS_EXPORT_LIST.match(ln)
|
|
227
|
+
if m:
|
|
228
|
+
exported |= {part.split(" as ")[0].strip() for part in (m.group(1) or m.group(2) or "").split(",")}
|
|
229
|
+
return {name for name in declared if name not in exported}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _scan_js(lines: List[str]) -> Dict[Key, Sig]:
|
|
233
|
+
out: Dict[Key, Sig] = {}
|
|
234
|
+
declared: Dict[str, bool] = {} # module-level name -> declared with `export`
|
|
235
|
+
unit = _js_unit(lines)
|
|
236
|
+
wrapped = any(_JS_WRAPPER.match(ln) for ln in lines)
|
|
237
|
+
owner, in_owner, member_indent = "", False, None
|
|
238
|
+
for i, line in enumerate(lines):
|
|
239
|
+
body = line.strip()
|
|
240
|
+
if not body or body.startswith(_JS_COMMENT):
|
|
241
|
+
continue
|
|
242
|
+
ind = _indent(line)
|
|
243
|
+
if ind == 0:
|
|
244
|
+
if body.startswith("}"):
|
|
245
|
+
owner, in_owner, member_indent = "", False, None
|
|
246
|
+
continue
|
|
247
|
+
if _JS_REQUIRE.match(body):
|
|
248
|
+
continue # an import, whatever it is called here
|
|
249
|
+
m = _JS_CLASS.match(body)
|
|
250
|
+
if m:
|
|
251
|
+
owner, in_owner, member_indent = m.group(1), True, None
|
|
252
|
+
out.setdefault(("", owner), NAME)
|
|
253
|
+
declared.setdefault(owner, body.startswith("export"))
|
|
254
|
+
continue
|
|
255
|
+
m = _JS_OBJECT.match(body)
|
|
256
|
+
if m:
|
|
257
|
+
owner, in_owner, member_indent = m.group(1), True, None
|
|
258
|
+
out.setdefault(("", owner), NAME)
|
|
259
|
+
declared.setdefault(owner, body.startswith("export"))
|
|
260
|
+
continue
|
|
261
|
+
if _JS_EXPORTS.match(body):
|
|
262
|
+
owner, in_owner, member_indent = "", True, None
|
|
263
|
+
continue
|
|
264
|
+
m = _JS_FUNC.match(body)
|
|
265
|
+
if m:
|
|
266
|
+
joined = _balanced(lines, i)
|
|
267
|
+
out.setdefault(("", m.group(1)), ("fn", _fn_params(joined, m), ""))
|
|
268
|
+
declared.setdefault(m.group(1), body.startswith("export"))
|
|
269
|
+
continue
|
|
270
|
+
m = _JS_TYPE.match(body) or _JS_VAR.match(body)
|
|
271
|
+
if m:
|
|
272
|
+
declared.setdefault(m.group(1), body.startswith("export"))
|
|
273
|
+
joined = _balanced(lines, i)
|
|
274
|
+
fn = _JS_FIELD_FN.match(re.sub(r"^(?:export\s+)?(?:const|let|var)\s+", "", joined))
|
|
275
|
+
out.setdefault(("", m.group(1)), ("fn", _fn_params(joined, fn), "") if fn else NAME)
|
|
276
|
+
continue
|
|
277
|
+
if in_owner and member_indent is None and ind > 0:
|
|
278
|
+
member_indent = ind
|
|
279
|
+
if in_owner and ind == member_indent:
|
|
280
|
+
joined = _JS_DECORATOR.sub("", _balanced(lines, i), count=1)
|
|
281
|
+
m = _JS_FIELD_FN.match(joined) or _JS_KEY_FN.match(joined) or _JS_MEMBER.match(joined)
|
|
282
|
+
if m and m.group(1) not in _JS_KEYWORDS:
|
|
283
|
+
at = joined.find("(", m.end(1))
|
|
284
|
+
out.setdefault((owner, m.group(1).lstrip("#")), ("fn", _params(joined, at) if at >= 0 else "", ""))
|
|
285
|
+
continue
|
|
286
|
+
if ind <= unit and not in_owner:
|
|
287
|
+
joined = _balanced(lines, i)
|
|
288
|
+
m = _JS_ASSIGN_FN.match(joined)
|
|
289
|
+
if m:
|
|
290
|
+
path = [p for p in m.group(1).rstrip(".").split(".") if p not in ("prototype", "module", "exports")]
|
|
291
|
+
if path[:1] != ["this"]:
|
|
292
|
+
at = joined.find("(", m.end(2))
|
|
293
|
+
out.setdefault((path[-1] if path else "", m.group(2)), ("fn", _params(joined, at) if at >= 0 else "", ""))
|
|
294
|
+
continue
|
|
295
|
+
if wrapped and ind == unit:
|
|
296
|
+
m = _JS_FUNC.match(body)
|
|
297
|
+
if m:
|
|
298
|
+
out.setdefault(("", m.group(1)), ("fn", _fn_params(joined, m), ""))
|
|
299
|
+
private = _js_private(lines, out, declared)
|
|
300
|
+
return {k: v for k, v in out.items() if k[1] not in private and k[0] not in private}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
SCANNERS = {".py": _scan_python, ".go": _scan_go, ".js": _scan_js, ".jsx": _scan_js, ".ts": _scan_js,
|
|
304
|
+
".tsx": _scan_js, ".mjs": _scan_js, ".cjs": _scan_js}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def scan(text: Optional[str], path: str) -> Dict[Key, Sig]:
|
|
308
|
+
"""(owner, name) -> signature, for the declarations other files can refer to."""
|
|
309
|
+
scanner = SCANNERS.get(os.path.splitext(path)[1])
|
|
310
|
+
if not scanner or text is None:
|
|
311
|
+
return {}
|
|
312
|
+
return {k: v for k, v in scanner(text.splitlines()).items() if len(k[1]) >= MIN_NAME and k[1] not in STOP}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def definitions(text: Optional[str], path: str) -> Dict[str, str]:
|
|
316
|
+
"""Defined name -> its parameter text ('' for non-callables)."""
|
|
317
|
+
out: Dict[str, str] = {}
|
|
318
|
+
for (_, name), sig in scan(text, path).items():
|
|
319
|
+
out.setdefault(name, sig[1])
|
|
320
|
+
return out
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def receivers(text: Optional[str], path: str) -> Dict[str, str]:
|
|
324
|
+
"""Member name -> the class, object or receiver type that owns it."""
|
|
325
|
+
out: Dict[str, str] = {}
|
|
326
|
+
for (owner, name) in scan(text, path):
|
|
327
|
+
if owner:
|
|
328
|
+
out.setdefault(name, owner)
|
|
329
|
+
return out
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _flat(text: str) -> str:
|
|
333
|
+
for _ in range(4): # nested generics and brackets
|
|
334
|
+
text = re.sub(r"<[^<>]*>|\[[^\[\]]*\]|\([^()]*\)|\{[^{}]*\}", "", text)
|
|
335
|
+
return text
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _param_names(params: str) -> List[str]:
|
|
339
|
+
"""Parameter names in order. A changed type or default is not a changed
|
|
340
|
+
name; an added, dropped, renamed or reordered parameter is."""
|
|
341
|
+
names = []
|
|
342
|
+
for part in _flat(params).split(","):
|
|
343
|
+
part = part.strip()
|
|
344
|
+
m = re.match(r"(?:\*{1,2}|\.\.\.)?\s*([A-Za-z_$][\w$]*)", part)
|
|
345
|
+
if part and part not in ("self", "cls"):
|
|
346
|
+
names.append(m.group(1) if m else part)
|
|
347
|
+
return names
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _returns(result: str) -> int:
|
|
351
|
+
"""How many values a Go function returns."""
|
|
352
|
+
result = result.strip()
|
|
353
|
+
if not result:
|
|
354
|
+
return 0
|
|
355
|
+
return _flat(result[1:-1]).count(",") + 1 if result.startswith("(") else 1
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def changes(before: Optional[str], after: Optional[str], path: str) -> List[Tuple[str, str, str]]:
|
|
359
|
+
"""(owner, name, how) for declarations the write removed or changed under
|
|
360
|
+
their users: a parameter added, dropped, renamed or reordered; for Go, a
|
|
361
|
+
different number of return values or a field's type. A rename shows up as
|
|
362
|
+
the removal of the old name. Module-level names come before members."""
|
|
363
|
+
old, new = scan(before, path), scan(after, path)
|
|
364
|
+
out = [(o, n, "removed") for (o, n) in old if (o, n) not in new]
|
|
365
|
+
for key in old:
|
|
366
|
+
if key not in new or old[key] == new[key]:
|
|
367
|
+
continue
|
|
368
|
+
(kind, params, result), (new_kind, new_params, new_result) = old[key], new[key]
|
|
369
|
+
if kind == new_kind == "fn" and _param_names(params) != _param_names(new_params):
|
|
370
|
+
out.append((*key, f"parameters ({params}) -> ({new_params})"))
|
|
371
|
+
elif kind == new_kind == "fn" and _returns(result) != _returns(new_result):
|
|
372
|
+
out.append((*key, f"returns {result or 'nothing'} -> {new_result or 'nothing'}"))
|
|
373
|
+
elif kind == new_kind == "field" and result != new_result:
|
|
374
|
+
out.append((*key, f"type {result} -> {new_result}"))
|
|
375
|
+
return sorted(out, key=lambda c: bool(c[0]))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def changed_symbols(before: Optional[str], after: Optional[str], path: str) -> List[Tuple[str, str]]:
|
|
379
|
+
"""(name, how), without the owner."""
|
|
380
|
+
return [(name, how) for _, name, how in changes(before, after, path)]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _defines(line: str, path: str, name: str) -> bool:
|
|
384
|
+
"""Whether one line, read without its file, declares `name`."""
|
|
385
|
+
body = line.strip()
|
|
386
|
+
ext = os.path.splitext(path)[1]
|
|
387
|
+
n = re.escape(name)
|
|
388
|
+
if ext == ".py":
|
|
389
|
+
return bool(re.match(rf"(?:async\s+)?def\s+{n}\s*\(|class\s+{n}\b|{n}\s*(?::[^=]+)?=(?!=)", body))
|
|
390
|
+
if ext == ".go":
|
|
391
|
+
return bool(re.match(rf"func\s+(?:\([^)]*\)\s*)?{n}\s*[\[(]|type\s+{n}\b|(?:var|const)\s+{n}\b", body))
|
|
392
|
+
if ext not in SCANNERS:
|
|
393
|
+
return False
|
|
394
|
+
body = _JS_DECORATOR.sub("", body, count=1)
|
|
395
|
+
for pat in (_JS_FUNC, _JS_CLASS, _JS_TYPE, _JS_VAR):
|
|
396
|
+
m = pat.match(body)
|
|
397
|
+
if m and m.group(1) == name:
|
|
398
|
+
return True
|
|
399
|
+
m = _JS_ASSIGN_FN.match(body)
|
|
400
|
+
if m and m.group(2) == name:
|
|
401
|
+
return True
|
|
402
|
+
m = _JS_FIELD_FN.match(body) or _JS_KEY_FN.match(body)
|
|
403
|
+
if m and m.group(1).lstrip("#") == name:
|
|
404
|
+
return True
|
|
405
|
+
m = _JS_MEMBER.match(body)
|
|
406
|
+
return bool(m and m.group(1).lstrip("#") == name and body.endswith("{"))
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# Not searched: what the harness itself writes, backups an agent leaves beside
|
|
410
|
+
# a file, and prose. A stale name there breaks nothing.
|
|
411
|
+
NOT_SEARCHED = ["patch.txt", ".materialized", "*.bak", "*.backup", "*.orig", "*.rej", "*.md", "*.rst", "*.txt",
|
|
412
|
+
"*.log", "*.lock", "*.map", "*.snap", "*.min.js"]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# The provisioned environment and what the agent's HOME accumulates in the
|
|
416
|
+
# workspace (run_phase2_experiment.HARNESS_PREFIXES), plus vendored packages:
|
|
417
|
+
# untracked, so `--untracked` would search them.
|
|
418
|
+
NOT_SEARCHED_DIRS = [".venv-task", ".cache", ".local", ".config", ".npm", "node_modules", "vendor"]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _git_grep(workspace: str, args: List[str]) -> List[str]:
|
|
422
|
+
try:
|
|
423
|
+
res = subprocess.run(["git", "-C", workspace, "grep", "-I", "--untracked", *args, "--", ".",
|
|
424
|
+
*[f":(exclude,glob)**/{pat}" for pat in NOT_SEARCHED],
|
|
425
|
+
*[f":(exclude,glob){d}/**" for d in NOT_SEARCHED_DIRS],
|
|
426
|
+
*[f":(exclude,glob)**/{d}/**" for d in ("node_modules", "vendor")]],
|
|
427
|
+
capture_output=True, text=True, timeout=20)
|
|
428
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
429
|
+
return []
|
|
430
|
+
return res.stdout.splitlines()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# Where a name declared in one language can be used: that language's sources,
|
|
434
|
+
# and the templates rendered from them.
|
|
435
|
+
_JS_FAMILY = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte", ".tpl", ".html", ".hbs", ".ejs"}
|
|
436
|
+
FAMILIES = {".py": {".py", ".pyi", ".html", ".j2", ".jinja"}, ".go": {".go", ".tmpl"},
|
|
437
|
+
**{ext: _JS_FAMILY for ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")}}
|
|
438
|
+
# A backslash only as an escape pair (a `.` that could also take it backtracks
|
|
439
|
+
# exponentially on a long line of escapes).
|
|
440
|
+
_STRING = re.compile(r"(['\"`])(?:\\.|(?!\1)[^\\])*\1")
|
|
441
|
+
_COMMENT = {".py": re.compile(r"#.*$")}
|
|
442
|
+
_SLASH_COMMENT = re.compile(r"//.*$")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _in_code(text: str, path: str, name: str) -> bool:
|
|
446
|
+
"""Whether a source line names `name` outside its strings and comments.
|
|
447
|
+
A template is text throughout, so any mention counts there."""
|
|
448
|
+
if os.path.splitext(path)[1] not in SCANNERS:
|
|
449
|
+
return True
|
|
450
|
+
bare = _COMMENT.get(os.path.splitext(path)[1], _SLASH_COMMENT).sub("", _STRING.sub("", text))
|
|
451
|
+
return bool(re.search(rf"\b{re.escape(name)}\b", bare))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _grep(workspace: str, name: str, rel: str) -> List[Tuple[str, int, str]]:
|
|
455
|
+
"""Lines outside `rel`, in its language family, that use the word `name`."""
|
|
456
|
+
family = FAMILIES.get(os.path.splitext(rel)[1], set())
|
|
457
|
+
hits = []
|
|
458
|
+
for line in _git_grep(workspace, ["-n", "-w", "-e", name]):
|
|
459
|
+
path, _, rest = line.partition(":")
|
|
460
|
+
num, _, text = rest.partition(":")
|
|
461
|
+
if num.isdigit() and path != rel and os.path.splitext(path)[1] in family and _in_code(text, path, name):
|
|
462
|
+
hits.append((path, int(num), text))
|
|
463
|
+
return hits
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _importers(workspace: str, rel: str) -> Set[str]:
|
|
467
|
+
"""Files with an import-like line naming the written file's module."""
|
|
468
|
+
stem = os.path.splitext(os.path.basename(rel))[0]
|
|
469
|
+
if rel.endswith(".go") or not stem:
|
|
470
|
+
return set() # Go imports a directory, not a file
|
|
471
|
+
# One line that has both the module's name and an import word; --and keeps
|
|
472
|
+
# this to fixed strings, which every git build matches the same way.
|
|
473
|
+
return set(_git_grep(workspace, ["-l", "-w", "-F", "-e", stem, "--and", "(", "-e", "import", "-e", "require",
|
|
474
|
+
"-e", "from", "-e", "include", ")"]))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def dependency_notice(workspace: str, writes: Iterable[str], before: Dict[str, str],
|
|
478
|
+
viewed: Optional[Set[str]] = None,
|
|
479
|
+
written: Optional[Set[str]] = None) -> Tuple[str, List[Dict[str, object]]]:
|
|
480
|
+
"""The notice text, and a record of what it reported, for the trace.
|
|
481
|
+
|
|
482
|
+
`before` maps each written file to its content before this write;
|
|
483
|
+
`written` is every file the agent has written in this run.
|
|
484
|
+
"""
|
|
485
|
+
viewed = viewed or set()
|
|
486
|
+
written = set(written or ()) | set(writes)
|
|
487
|
+
blocks: List[str] = []
|
|
488
|
+
record: List[Dict[str, object]] = []
|
|
489
|
+
reported: Dict[str, Set[str]] = {} # name -> files its uses are in
|
|
490
|
+
used = 0
|
|
491
|
+
for rel in writes:
|
|
492
|
+
if rel not in before:
|
|
493
|
+
continue
|
|
494
|
+
try:
|
|
495
|
+
with open(os.path.join(workspace, rel), "r", encoding="utf-8", errors="replace") as f:
|
|
496
|
+
after = f.read()
|
|
497
|
+
except OSError:
|
|
498
|
+
after = ""
|
|
499
|
+
changed = changes(before[rel], after, rel)[:MAX_CANDIDATES]
|
|
500
|
+
if not changed:
|
|
501
|
+
continue
|
|
502
|
+
was = scan(before[rel], rel)
|
|
503
|
+
at_head = scan(committed(workspace, rel), rel)
|
|
504
|
+
here = os.path.dirname(rel)
|
|
505
|
+
importers: Optional[Set[str]] = None
|
|
506
|
+
for owner, name, how in changed:
|
|
507
|
+
if len(record) >= MAX_SYMBOLS:
|
|
508
|
+
break
|
|
509
|
+
if name in reported:
|
|
510
|
+
continue # said once, under another owner
|
|
511
|
+
hits = _grep(workspace, name, rel)
|
|
512
|
+
uses = [h for h in hits if not _defines(h[2], h[0], name)] # a definition is not a use
|
|
513
|
+
if rel.endswith(".go") and not (name[:1].isupper() and (not owner or owner[:1].isupper())):
|
|
514
|
+
uses = [h for h in uses if os.path.dirname(h[0]) == here] # unexported: its package only
|
|
515
|
+
# Files that declare the name now, split by whether they did at the last commit.
|
|
516
|
+
new_homes, always = [], set()
|
|
517
|
+
for p in sorted({p for p, _, t in hits if _defines(t, p, name)})[:MAX_DEFINING]:
|
|
518
|
+
if name in definitions(_read(workspace, p), p):
|
|
519
|
+
(always.add if name in definitions(committed(workspace, p), p) else new_homes.append)(p)
|
|
520
|
+
# A file that declares the name itself uses its own.
|
|
521
|
+
uses = [h for h in uses if h[0] not in always and h[0] not in new_homes]
|
|
522
|
+
qualified: Set[Tuple[str, int]] = set()
|
|
523
|
+
if owner:
|
|
524
|
+
# A member is used through a receiver (`x.name`) or, for a Go
|
|
525
|
+
# field, set by key (`name:`); a bare word is something else.
|
|
526
|
+
key_form = rf"|\b{re.escape(name)}\s*:" if rel.endswith(".go") else ""
|
|
527
|
+
as_member = re.compile(rf"\.\s*{re.escape(name)}\b{key_form}")
|
|
528
|
+
uses = [h for h in uses if as_member.search(h[2])]
|
|
529
|
+
# `Owner.name`, or `module.name` for a module object named after its file.
|
|
530
|
+
stem_q = os.path.splitext(os.path.basename(rel))[0]
|
|
531
|
+
exact = re.compile(rf"\b(?:{re.escape(owner)}|{re.escape(stem_q)})\s*\.\s*{re.escape(name)}\b",
|
|
532
|
+
re.IGNORECASE)
|
|
533
|
+
qualified = {(p, n) for p, n, t in uses if exact.search(t)}
|
|
534
|
+
if importers is None:
|
|
535
|
+
importers = _importers(workspace, rel)
|
|
536
|
+
owner_files = set(_git_grep(workspace, ["-l", "-w", "-e", owner])) if owner else set()
|
|
537
|
+
related = {p for p, _, _ in uses if os.path.dirname(p) == here or p in importers or p in owner_files}
|
|
538
|
+
label = f"{owner}.{name}" if owner else name
|
|
539
|
+
note = ""
|
|
540
|
+
if (owner, name) not in at_head:
|
|
541
|
+
# The agent added this name during the run: only files it has
|
|
542
|
+
# written since can use it.
|
|
543
|
+
uses = [h for h in uses if h[0] in written]
|
|
544
|
+
if how == "removed" and new_homes:
|
|
545
|
+
home = new_homes[0]
|
|
546
|
+
same_owner = not owner or receivers(_read(workspace, home), home).get(name) == owner
|
|
547
|
+
if same_owner and rel.endswith(".go") and os.path.dirname(home) == here:
|
|
548
|
+
continue # still in the package: every use resolves
|
|
549
|
+
if same_owner:
|
|
550
|
+
# The definition moved: uses that still reach it through
|
|
551
|
+
# the old module are the stale ones.
|
|
552
|
+
uses = [h for h in uses if h[0] in importers] if not rel.endswith(".go") else \
|
|
553
|
+
[h for h in uses if f"{os.path.basename(here)}.{name}" in h[2]]
|
|
554
|
+
how = f"moved to {home}"
|
|
555
|
+
else:
|
|
556
|
+
note = f" (a `{name}` is now defined in {home})"
|
|
557
|
+
elif always:
|
|
558
|
+
# Other files have always defined this name (another type's
|
|
559
|
+
# method, say): a word match cannot tell whose use it is. A
|
|
560
|
+
# member is reported where it is named with its owner or in
|
|
561
|
+
# the written file's directory; any other name, in files tied
|
|
562
|
+
# to the written one.
|
|
563
|
+
if owner and rel.endswith(".go"):
|
|
564
|
+
uses = [h for h in uses if (h[0], h[1]) in qualified or os.path.dirname(h[0]) == here]
|
|
565
|
+
elif owner: # a directory is not a unit outside Go: the file must name the owner
|
|
566
|
+
uses = [h for h in uses if (h[0], h[1]) in qualified or h[0] in owner_files]
|
|
567
|
+
else:
|
|
568
|
+
uses = [h for h in uses if h[0] in related]
|
|
569
|
+
note = f" (other files define a `{name}` too; check whose uses these are)"
|
|
570
|
+
if len(uses) > MAX_HITS:
|
|
571
|
+
uses = [h for h in uses if (h[0], h[1]) in qualified or os.path.dirname(h[0]) == here]
|
|
572
|
+
if how == "removed" and owner in reported and {h[0] for h in uses} <= reported[owner]:
|
|
573
|
+
continue # its owner's removal, already reported, points at the same files
|
|
574
|
+
if not uses:
|
|
575
|
+
continue
|
|
576
|
+
stem = os.path.splitext(os.path.basename(rel))[0]
|
|
577
|
+
called = re.compile(rf"\b{re.escape(name)}\s*(?:<[^<>()]*>)?\s*\(")
|
|
578
|
+
is_fn = was.get((owner, name), NAME)[0] == "fn"
|
|
579
|
+
uses.sort(key=lambda h: (is_fn and not called.search(h[2]), # a function is used by calling it
|
|
580
|
+
(h[0], h[1]) not in qualified,
|
|
581
|
+
h[0] not in importers and h[0] not in owner_files,
|
|
582
|
+
not os.path.basename(h[0]).startswith(stem), h[0] not in viewed, h[0], h[1]))
|
|
583
|
+
shown = uses[:MAX_USES_PER_SYMBOL]
|
|
584
|
+
lines = [f"{p}:{n}: {t.strip()[:120]}" for p, n, t in shown]
|
|
585
|
+
more = f"\n ... and {len(uses) - len(shown)} more" if len(uses) > len(shown) else ""
|
|
586
|
+
block = f"{rel}: `{label}` {how}{note}; still used at:\n " + "\n ".join(lines) + more
|
|
587
|
+
if used + len(block) > MAX_CHARS:
|
|
588
|
+
break
|
|
589
|
+
blocks.append(block)
|
|
590
|
+
reported[name] = {p for p, _, _ in uses}
|
|
591
|
+
used += len(block)
|
|
592
|
+
record.append({"file": rel, "symbol": name, "owner": owner, "change": how + note, "uses": len(uses),
|
|
593
|
+
"shown": [f"{p}:{n}" for p, n, _ in shown],
|
|
594
|
+
"use_files": sorted({p for p, _, _ in uses})[:20]})
|
|
595
|
+
if not blocks:
|
|
596
|
+
return "", []
|
|
597
|
+
return "\n\n[DEPENDENCY NOTICE: uses in other files that this write may have left stale]\n" + \
|
|
598
|
+
"\n".join(blocks), record
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _read(workspace: str, rel: str) -> Optional[str]:
|
|
602
|
+
try:
|
|
603
|
+
with open(os.path.join(workspace, rel), "r", encoding="utf-8", errors="replace") as f:
|
|
604
|
+
return f.read()
|
|
605
|
+
except OSError:
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def committed(workspace: str, rel: str) -> Optional[str]:
|
|
610
|
+
"""The file as committed, the baseline for a file first written unseen."""
|
|
611
|
+
try:
|
|
612
|
+
res = subprocess.run(["git", "-C", workspace, "show", f"HEAD:{rel}"], capture_output=True,
|
|
613
|
+
text=True, timeout=20)
|
|
614
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
615
|
+
return None
|
|
616
|
+
return res.stdout if res.returncode == 0 else None
|