diff --git a/tests/test_envfacts.py b/tests/test_envfacts.py index 3d277b0..3ac1b9c 100644 --- a/tests/test_envfacts.py +++ b/tests/test_envfacts.py @@ -71,6 +71,9 @@ def test_classify_forms_and_intents(): # heredoc bodies never become commands assert observe.classify_command("python - <<'PY'\npytest_is_a_word = 1\nPY")[0].intent == \ "run_interpreter" + # a here-string (`<<<`) has no body: the commands after it are still read + assert [o.intent for o in observe.classify_command("read -r x <<< x\npytest -q\nx=1")] == \ + ["run_tests"] def test_error_classes_are_environmental_only(): diff --git a/tests/test_live_core.py b/tests/test_live_core.py index 4dae487..857f857 100644 --- a/tests/test_live_core.py +++ b/tests/test_live_core.py @@ -679,11 +679,22 @@ def test_the_opencode_side_call_adds_only_the_flags_its_cli_lists(monkeypatch): # a quoted `|` in a sed script must not split the command (seen live: sympy-13091) ("sed -i -E 's/x (<|<=|>|>=) y/z/' numbers.py expr.py", ["numbers.py", "expr.py"]), ("sed -i 's/a/b/' x.py 2>&1 | tail -2", ["x.py"]), + # a here-string (`<<<`) is not a heredoc: the lines after it are still commands + ("grep -q ok <<< \"ok\"\nsed -i 's/a/b/' pkg/mod.py", ["mod.py"]), + ("read -r v <<< done\ncat > fix.py <<'EOF'\nx = 1\nEOF", ["fix.py"]), ]) def test_edits_made_through_the_shell_are_recognised(cmd, want): assert guard.shell_edit_targets(cmd) == want +def test_an_edit_after_a_here_string_is_counted(): + """`<<<` feeds one word to a command; reading it as a heredoc tag made every later line of + the command a heredoc body, so a `sed -i` after it was not counted as an edit.""" + g = guard.TurnGuard(turn_key="t") + g.fold("Bash", {"command": "grep -q ok <<< ok\nsed -i 's/a/b/' pkg/mod.py"}, "", 0) + assert g.edits == 1 and g.whole.edit_counts == {"mod.py": 1} + + def test_shell_edits_reach_the_state_the_judge_reads_and_the_ended_green_proxy(): g = guard.TurnGuard(turn_key="t") edit = "python - <<'E'\np='pkg/mod.py'; s=open(p).read()\nopen(p,'w').write(s[::-1])\nE" diff --git a/tests/test_loopguard_productive_turns.py b/tests/test_loopguard_productive_turns.py index 6a4386d..4098c5d 100644 --- a/tests/test_loopguard_productive_turns.py +++ b/tests/test_loopguard_productive_turns.py @@ -26,6 +26,8 @@ ("SP=/tmp/x; cd $SP && python3 - <<'EOF'\nimport json\nprint('error outputs: 3')\nEOF", True), ("python3 - <<'EOF'\nprint(1)\nEOF\npython -m pytest -q | tail -3", False), + ("python repro.py <<< y", True), + ("python repro.py <<< \"y\"\npython -m pytest -q | tail -3", False), ("", False), ]) def test_an_exit_status_covers_a_command_unless_its_work_could_fail_unseen(cmd, covers): diff --git a/tokeneconomizer/loopguard/shell_edits.py b/tokeneconomizer/loopguard/shell_edits.py index a9ae7e9..898f083 100644 --- a/tokeneconomizer/loopguard/shell_edits.py +++ b/tokeneconomizer/loopguard/shell_edits.py @@ -13,7 +13,8 @@ from .state import basename -_HEREDOC_START = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_]\w*)\1") +# `<<<` is a here-string: its word is the input itself, so no body follows on the next lines. +_HEREDOC_START = re.compile(r"(?", ">>", ">|", "&>", "&>>"} # redirects that write their operand _SKIP_OPERAND = {"<", "<<", "<<<", "<&", ">&", "<>"} # input, heredoc tags, fd duplication diff --git a/tokeneconomizer/microskills/observe.py b/tokeneconomizer/microskills/observe.py index a410183..f916330 100644 --- a/tokeneconomizer/microskills/observe.py +++ b/tokeneconomizer/microskills/observe.py @@ -116,7 +116,7 @@ class Vocabulary: _ENV_ASSIGN_RE = re.compile(r"^(?:[A-Za-z_][A-Za-z0-9_]*=(?:\"[^\"]*\"|'[^']*'|\S+)\s+)+") _ASSIGN_TOKEN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") _WRAP_RE = re.compile(r"^/bin/(?:ba)?sh\s+-l?c\s+(.*)$", re.S) -_HEREDOC_RE = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n.*?\n\s*\1\b", re.S) +_HEREDOC_RE = re.compile(r"(?