Review of fix/ast-function-patching (commit 0c9b7c0, "resolve patch targets via AST instead of first-match").
The rewrite is the right direction and is a clear net improvement: libcst was never installed or declared in pyproject.toml, so the old _try_libcst path was dead code and only _textual_replace ever ran. I verified three constructs where main silently produced invalid Python and the new code correctly refuses instead (version guards, try/except ImportError fallbacks, @overload groups).
Two issues should be fixed before merge. Both have strict-xfail tests already on the branch pinning the expected behaviour, so each fix is turnkey: make the test pass and drop the marker.
1. The whole repaired source is spliced in, not just the target function
src/self_heal/_patch.py — _ast_replace locates repaired_fn to inspect its decorators, but the text it writes is block = dedented, i.e. the entire repaired source including any other top-level statements.
propose.py's system prompt tells the model to return exactly one function definition, but nothing enforces it in code, so a non-compliant response corrupts the target scope.
apply_function_patch(
src, "Foo.bar", "",
"def helper():\n return 42\n\n\ndef bar(self):\n return helper()\n",
)
on class Foo:\n def bar(self):\n return 1 produces:
class Foo:
def helper(): # <-- now Foo.helper, not a module-level function
return 42
def bar(self):
return helper() # NameError at runtime
This is syntactically valid, so the final ast.parse(new_text) guard passes and the CLI prints ✓ APPLIED for a patch that breaks the module. An LLM prepending import re hits the same path and writes a class-body import.
Fix: splice only repaired_fn's own line span out of dedented, or raise PatchError when repaired_tree.body contains anything besides the target def.
Pinned by: tests/test_patch.py::test_extra_statements_in_repair_do_not_leak_into_the_target_scope (accepts either fix — refusing outright is fine).
2. Line endings are rewritten across the whole file
src/self_heal/_patch.py:53,69 — Path.read_text / Path.write_text both use newline=None, so newlines are normalised in both directions.
in: b'def a():\n return 1\n\n\ndef b():\n return 2\n'
out: b'def a():\r\n return 1\r\n\r\n\r\ndef b():\r\n return 3\r\n'
Every line changes, so git diff shows the entire file instead of the one function. That defeats the point of the git-dirty guard and the reviewability the AST approach was meant to buy. On POSIX the same bug runs the other way: a CRLF file is rewritten to LF.
This is pre-existing (the calls are unchanged by the commit), but the branch newly makes byte-fidelity the stated goal, so it belongs here.
Fix: read and write with newline='', preserving the file's detected dominant ending. _ast_replace also hardcodes + "\n" when joining the replacement block.
Pinned by: tests/test_patch.py::test_line_endings_are_preserved (checks LF and CRLF in one test on purpose — the LF case fails on Windows and the CRLF case on POSIX, so a single test stays a deterministic xfail on both).
Already fixed on the branch
For the record, these came out of the same review and are done:
@overload stubs no longer make the implementation ambiguous — the sole non-stub definition resolves.
- Property getter/setter collisions now report the colliding line numbers and decorators instead of advising "pass a qualified name", which could not work for a getter/setter pair.
- Comments between decorators (or between a decorator and
def) are no longer silently deleted when the repair echoes the same decorators back.
- Stale
libcst references removed from README.md, pytest_plugin.py, site/app/page.tsx, and a test name.
- Two false module-docstring claims corrected ("byte-identical"; "falls back only when the file cannot be parsed").
self-heal heal FILE::ClassName.method_name now works, so _patch.py's qualified-name disambiguation is actually reachable. The pytest marker still splits target on its last dot and so remains module-level only; that limitation is now documented in the plugin docstring.
- Tests added for the final
ast.parse guard, comment preservation, overload resolution, the property-collision message, and CLI target resolution; the two collision tests now execute the patched module instead of substring-matching.
Known limitations (working as intended, not filed as bugs)
- Tab-indented files cannot be patched: indentation is rebuilt as
" " * col_offset. Fails safe — the re-parse guard rejects it and the file is left untouched.
- PEP 614 parenthesized decorators (
@(\n d\n)) orphan the @( line. Also fails safe via the same guard.
- Version guards,
try/except ImportError fallbacks, and property accessor pairs are unpatchable by design; the ambiguity error names what collided.
Review of
fix/ast-function-patching(commit0c9b7c0, "resolve patch targets via AST instead of first-match").The rewrite is the right direction and is a clear net improvement:
libcstwas never installed or declared inpyproject.toml, so the old_try_libcstpath was dead code and only_textual_replaceever ran. I verified three constructs wheremainsilently produced invalid Python and the new code correctly refuses instead (version guards,try/except ImportErrorfallbacks,@overloadgroups).Two issues should be fixed before merge. Both have strict-
xfailtests already on the branch pinning the expected behaviour, so each fix is turnkey: make the test pass and drop the marker.1. The whole repaired source is spliced in, not just the target function
src/self_heal/_patch.py—_ast_replacelocatesrepaired_fnto inspect its decorators, but the text it writes isblock = dedented, i.e. the entire repaired source including any other top-level statements.propose.py's system prompt tells the model to return exactly one function definition, but nothing enforces it in code, so a non-compliant response corrupts the target scope.on
class Foo:\n def bar(self):\n return 1produces:This is syntactically valid, so the final
ast.parse(new_text)guard passes and the CLI prints✓ APPLIEDfor a patch that breaks the module. An LLM prependingimport rehits the same path and writes a class-body import.Fix: splice only
repaired_fn's own line span out ofdedented, or raisePatchErrorwhenrepaired_tree.bodycontains anything besides the target def.Pinned by:
tests/test_patch.py::test_extra_statements_in_repair_do_not_leak_into_the_target_scope(accepts either fix — refusing outright is fine).2. Line endings are rewritten across the whole file
src/self_heal/_patch.py:53,69—Path.read_text/Path.write_textboth usenewline=None, so newlines are normalised in both directions.Every line changes, so
git diffshows the entire file instead of the one function. That defeats the point of the git-dirty guard and the reviewability the AST approach was meant to buy. On POSIX the same bug runs the other way: a CRLF file is rewritten to LF.This is pre-existing (the calls are unchanged by the commit), but the branch newly makes byte-fidelity the stated goal, so it belongs here.
Fix: read and write with
newline='', preserving the file's detected dominant ending._ast_replacealso hardcodes+ "\n"when joining the replacement block.Pinned by:
tests/test_patch.py::test_line_endings_are_preserved(checks LF and CRLF in one test on purpose — the LF case fails on Windows and the CRLF case on POSIX, so a single test stays a deterministic xfail on both).Already fixed on the branch
For the record, these came out of the same review and are done:
@overloadstubs no longer make the implementation ambiguous — the sole non-stub definition resolves.def) are no longer silently deleted when the repair echoes the same decorators back.libcstreferences removed fromREADME.md,pytest_plugin.py,site/app/page.tsx, and a test name.self-heal heal FILE::ClassName.method_namenow works, so_patch.py's qualified-name disambiguation is actually reachable. The pytest marker still splitstargeton its last dot and so remains module-level only; that limitation is now documented in the plugin docstring.ast.parseguard, comment preservation, overload resolution, the property-collision message, and CLI target resolution; the two collision tests now execute the patched module instead of substring-matching.Known limitations (working as intended, not filed as bugs)
" " * col_offset. Fails safe — the re-parse guard rejects it and the file is left untouched.@(\n d\n)) orphan the@(line. Also fails safe via the same guard.try/except ImportErrorfallbacks, and property accessor pairs are unpatchable by design; the ambiguity error names what collided.