Skip to content

fix/ast-function-patching: two blockers before merge (verbatim splice, line-ending rewrite) #81

Description

@Johin2

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,69Path.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions