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.
Files changed (60) hide show
  1. align_agent/__init__.py +11 -0
  2. align_agent/_audit/common.py +58 -0
  3. align_agent/_audit/deepswe.py +260 -0
  4. align_agent/_audit/excerpts.py +203 -0
  5. align_agent/_audit/extract.py +798 -0
  6. align_agent/_audit/materialize.py +624 -0
  7. align_agent/_audit/score.py +325 -0
  8. align_agent/_audit/shellparse.py +1141 -0
  9. align_agent/_audit/tarsur.py +153 -0
  10. align_agent/_vendor/src/__init__.py +3 -0
  11. align_agent/_vendor/src/harness/__init__.py +13 -0
  12. align_agent/_vendor/src/harness/arms.py +524 -0
  13. align_agent/_vendor/src/harness/completion.py +104 -0
  14. align_agent/_vendor/src/harness/dependency_notice.py +616 -0
  15. align_agent/_vendor/src/harness/freshness_hook.py +131 -0
  16. align_agent/_vendor/src/harness/lean.py +41 -0
  17. align_agent/_vendor/src/harness/mini_real.py +1040 -0
  18. align_agent/_vendor/src/harness/miniswe_scaffold.py +661 -0
  19. align_agent/_vendor/src/harness/tail_diff.py +292 -0
  20. align_agent/_vendor/src/harness/viewport.py +220 -0
  21. align_agent/_vendor/src/metrics/__init__.py +38 -0
  22. align_agent/_vendor/src/metrics/coherence.py +354 -0
  23. align_agent/_vendor/src/metrics/corruption_oracles.py +139 -0
  24. align_agent/_vendor/src/metrics/replay.py +399 -0
  25. align_agent/_vendor/src/metrics/statistics.py +185 -0
  26. align_agent/_vendor/src/metrics/verification.py +104 -0
  27. align_agent/_vendor/src/parsers/__init__.py +33 -0
  28. align_agent/_vendor/src/parsers/base_parser.py +349 -0
  29. align_agent/_vendor/src/parsers/file_events.py +806 -0
  30. align_agent/_vendor/src/parsers/miniswe_parser.py +96 -0
  31. align_agent/_vendor/src/parsers/openhands_parser.py +414 -0
  32. align_agent/_vendor/src/parsers/sweagent_parser.py +180 -0
  33. align_agent/_vendor/src/parsers/terminus_parser.py +106 -0
  34. align_agent/cli.py +101 -0
  35. align_agent/core.py +161 -0
  36. align_agent/run.py +84 -0
  37. align_agent/sessions.py +985 -0
  38. align_agent/writethrough/__init__.py +4 -0
  39. align_agent/writethrough/_lib/__init__.py +1 -0
  40. align_agent/writethrough/_lib/wt_src/__init__.py +3 -0
  41. align_agent/writethrough/_lib/wt_src/harness/__init__.py +1 -0
  42. align_agent/writethrough/_lib/wt_src/harness/dependency_notice.py +616 -0
  43. align_agent/writethrough/_lib/wt_src/harness/lean.py +41 -0
  44. align_agent/writethrough/_lib/wt_src/harness/tail_diff.py +292 -0
  45. align_agent/writethrough/_lib/wt_src/harness/viewport.py +220 -0
  46. align_agent/writethrough/_lib/wt_src/metrics/__init__.py +38 -0
  47. align_agent/writethrough/_lib/wt_src/metrics/replay.py +399 -0
  48. align_agent/writethrough/_lib/wt_src/parsers/__init__.py +1 -0
  49. align_agent/writethrough/_lib/wt_src/parsers/file_events.py +806 -0
  50. align_agent/writethrough/claude_code.py +81 -0
  51. align_agent/writethrough/engine.py +647 -0
  52. align_agent/writethrough/hooks.py +227 -0
  53. align_agent/writethrough/mcp_server.py +125 -0
  54. align_agent/writethrough/protocol.py +68 -0
  55. align_agent-0.4.3.dist-info/METADATA +224 -0
  56. align_agent-0.4.3.dist-info/RECORD +60 -0
  57. align_agent-0.4.3.dist-info/WHEEL +5 -0
  58. align_agent-0.4.3.dist-info/entry_points.txt +6 -0
  59. align_agent-0.4.3.dist-info/licenses/LICENSE +21 -0
  60. 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