From f3f47e8c4ddd55bec36e466a0fbd0d04ab9aad2c Mon Sep 17 00:00:00 2001 From: KVR Vishnu <46535652+VISHNU0906@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:00:23 +0530 Subject: [PATCH] loopguard: read a sed or perl script given inside its option word sed -i --expression='s/a/b/' f.py, sed -i -e's/a/b/' f.py, sed -i --file=x.sed f.py and perl -i -pe's/a/b/' f.py carry the script in the option word. The in-place parser only knew the forms where the script is the next word, so it took f.py for the script and the edit was never counted. Signed-off-by: KVR Vishnu <46535652+VISHNU0906@users.noreply.github.com> --- tests/test_live_core.py | 19 ++++++++++++ tokeneconomizer/loopguard/shell_edits.py | 38 +++++++++++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/tests/test_live_core.py b/tests/test_live_core.py index 4dae487..319fa5c 100644 --- a/tests/test_live_core.py +++ b/tests/test_live_core.py @@ -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 @@ -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 --` diff --git a/tokeneconomizer/loopguard/shell_edits.py b/tokeneconomizer/loopguard/shell_edits.py index a9ae7e9..c2aa20b 100644 --- a/tokeneconomizer/loopguard/shell_edits.py +++ b/tokeneconomizer/loopguard/shell_edits.py @@ -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 @@ -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 @@ -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.""" @@ -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":