Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions tests/test_live_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,15 @@ def test_the_opencode_side_call_adds_only_the_flags_its_cli_lists(monkeypatch):
("cd /t && sed -i.bak -e 's/a/b/' -e 's/c/d/' x.py y.py && pytest -q", ["x.py", "y.py"]),
("sed -i '' 's/a/b/' x.py", ["x.py"]), # BSD sed's empty suffix
("perl -pi -e 's/foo/bar/' src/a.rs", ["a.rs"]),
# the script inside the option word, not in the next word
("sed -i --expression='s/a/b/' pkg/mod.py", ["mod.py"]),
("sed --in-place --expression=s/a/b/ a.py b.py", ["a.py", "b.py"]),
("sed -i -e's/a/b/' x.py", ["x.py"]),
("sed -i --file=fix.sed x.py", ["x.py"]),
("sed -ne 's/a/b/p' -i x.py", ["x.py"]),
("perl -i -pe's/foo/bar/' src/a.rs", ["a.rs"]),
("perl -i.orig -pe 's/foo/bar/' src/a.rs", ["a.rs"]),
("perl -Mre=eval -i -pe 's/foo/bar/' src/a.rs", ["a.rs"]),
("cat > repro.py <<'EOF'\nprint(1 > 0)\nEOF\npython repro.py", ["repro.py"]),
("echo 'x' | tee -a setup.cfg", ["setup.cfg"]),
# how Sonnet 5.5 edits: an inline script that reads the file and writes it back
Expand Down Expand Up @@ -698,6 +707,16 @@ def test_shell_edits_reach_the_state_the_judge_reads_and_the_ended_green_proxy()
assert g.edits == 3 and g.last_verify > g.last_edit and not g.resolved_proxy()


def test_a_sed_script_given_inside_its_option_word_still_counts_the_edit():
"""`sed -i --expression=...` and `perl -i -pe'...'` carry the script in the option word.
The file after it is the edited file, not the script, so the turn records an edit."""
g = guard.TurnGuard(turn_key="t")
g.fold("Bash", {"command": "sed -i --expression='s/a/b/' pkg/mod.py"}, "", 0)
g.fold("Bash", {"command": "perl -i -pe's/b/c/' pkg/mod.py"}, "", 0)
assert g.edits == 2 and g.whole.edit_counts == {"mod.py": 2}
assert "[edits mod.py]" in g.st.recent_digest()


def test_an_edit_named_by_a_windows_path_counts_by_its_basename():
"""Claude Code on Windows names the file an edit tool changed with backslashes. The guard
counts that edit under the file's basename, as it counts a shell edit or a `git checkout --`
Expand Down
38 changes: 34 additions & 4 deletions tokeneconomizer/loopguard/shell_edits.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ def _commands(shell):


def _inplace_files(tokens, inplace_flag, script_flag):
"""File operands of an in-place editor (`sed -i`, `perl -pi`), or [] if not in place."""
"""File operands of an in-place editor (`sed -i`, `perl -pi`), or [] if not in place.
`script_flag(t)` says where an option puts the script: "next" for the following word
(`-e`, `--file`), "inline" for the option itself (`-e's/a/b/'`, `--expression=...`)."""
if not any(inplace_flag(t) for t in tokens[1:]):
return []
files, have_script, skip = [], False, False
Expand All @@ -115,8 +117,11 @@ def _inplace_files(tokens, inplace_flag, script_flag):
skip, have_script = False, True
continue
if not t or t.startswith("-"):
if t and script_flag(t):
where = script_flag(t) if t else None
if where == "next":
skip = True # the next token is the script
elif where == "inline":
have_script = True
continue
if not have_script:
have_script = True # the first operand is the script
Expand All @@ -125,6 +130,31 @@ def _inplace_files(tokens, inplace_flag, script_flag):
return files


def _short_script_flag(t, script_letters, stop_letters):
"""Where a short-option cluster such as `-ne`, `-pe's/a/b/'` or `-i.bak` puts the script.
A letter in `stop_letters` takes the rest of the word as its own value (`-i.bak`)."""
if t.startswith("--") or len(t) < 2:
return None
for k, c in enumerate(t[1:], start=2):
if c in stop_letters:
return None
if c in script_letters:
return "inline" if t[k:] else "next"
return None


def _sed_script_flag(t):
if t in ("--expression", "--file"):
return "next"
if t.startswith(("--expression=", "--file=")):
return "inline"
return _short_script_flag(t, "ef", "i")


def _perl_script_flag(t):
return _short_script_flag(t, "eE", "iIMmxdDC") # `-i.bak`, `-Mmod`, `-Idir` ...


def _py_writes(text):
"""Paths an inline Python script writes; "(script)" for a write whose path this cannot
resolve."""
Expand Down Expand Up @@ -193,10 +223,10 @@ def shell_edit_targets(cmd):
if head == "sed":
found += _inplace_files(
tokens, lambda t: bool(re.match(r"^(?:-[a-zA-Z]*i|--in-place)", t)),
lambda t: t in ("-e", "-f", "--expression", "--file"))
_sed_script_flag)
elif head == "perl":
found += _inplace_files(tokens, lambda t: bool(re.match(r"^-[a-zA-Z]*i", t)),
lambda t: bool(re.match(r"^-[a-zA-Z]*e$", t)))
_perl_script_flag)
elif head == "tee":
found += [t for t in tokens[1:] if not t.startswith("-") and _SOURCE_FILE.search(t)]
elif head == "apply_patch":
Expand Down
Loading