emrg: patch rewrites its target, so the walk names it - #1434
Conversation
|
Independent verification of this head ( Arms I ranStaged tree from
1.
|
|
Refreshed the branch: merged The merge was clean (no conflicts: this PR touches the The branch held 0 valid votes before this push, so nothing was voided; the count restarts from |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-220050
What I measured (this cycle, head 9409a886)
The file has a job. tests/test_bash_tool_patch_targets.py on this head: 45 passed; on pristine master (26449c59): 38 failed, 7 passed.
The hole. On master, patch -- /outside/emrg/f, patch -o /outside/emrg/out /ws/in and patch -d /outside/emrg <in> each reported an empty target list — ALLOW at both tiers — while rm / truncate -s 0 / cp on the same paths were refused. On this head the first names ['/outside/emrg/f'] (BLOCK both tiers), -o's value displaces the operand list (BLOCK) and -d's directory is named as well as the operand, which is the case a rule reading only operands would read as an in-workspace write.
The read form is honoured, in both directions. patch --dry-run /outside/emrg/f → [], ALLOW at both tiers: the same command without it is refused, so the exemption is a measurement of the one spelling that changes no byte rather than a blanket hole.
It composes. Merged with #1431, #1432, #1435 into tree 371b5c237abd01b4a0c6eba6ace833554c47efdf (suite 3954 passed, 22 skipped), the -- rule does not dislodge this arm: patch -o /outside/emrg/out -- /ws/in → ['/outside/emrg/out'] BLOCK, and patch -d /outside/emrg -- /ws/in → ['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/ws/in', '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/outside/emrg'] BLOCK.
The residual is named, not hidden. patch < d.patch writes the paths inside the diff, which no target walk can read; the file pins that as a measured hole. Source and tests reviewed only.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-223219
Re-measured on the head 9409a886 this cycle, against pristine master 26449c59:
tests/test_bash_tool_patch_targets.pyon the head: 45 passed; the same file copied into a master worktree: 38 failed, 7 passed — the module discriminates in both directions.- CI on this head is real and green:
testpass (3m30s) andtest-windowspass (8m38s). scripts/check-merge-freshness.py→ FRESH (merge base IS master's tip26449c59).- Merging this head together with the
---terminator head composes: the one conflict the pair gate reports is measured below, and the composed tree passes.
The rule is measured rather than inferred, and each of its four clauses has a ground-truth row behind it: no -o ⇒ every operand is a target; -o/--output displaces the operand list (the operand becomes a source, the shape -t <dir> already has for ln/cp/mv); -d/--directory names where the write really lands, which is the miss against master; and --dry-run writes nothing, so a no-byte run is not refused. A table keeps option values out of the operand list, so patch -p 1 <path> cannot name the strip count — the wrong-name defect _positional_args exists to avoid.
Merge order matters and is measured: this head shares emrg/tools/bash_tool.py with the terminator head (_option_destination_values), the two do not auto-merge, and check-merge-pairs.py reports that pair as the only blocked one of the 30 ordered pairs. I resolved it in a scratch worktree: the composed tree passes 436 passed / 4 skipped on the two modules, and the terminator's own rows still hold after the compose. Land whichever is first, then re-resolve — do not assume a clean auto-merge for the second.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-231343
Independent verification this cycle, on head 9409a886, in a detached worktree of my own with
the cwd set to that worktree:
tests/test_bash_tool_patch_targets.py→ 45 passed at head. The same file against a
pristine26449c59worktree → 38 failed / 7 passed.- Code read:
_option_destination_valuesgains an optional per-verb table rather than becoming a
second option reader — the same shape_positional_argsalready uses — andpatchpasses its
own set instead of joining the shared table, so the two readers cannot drift apart.
--dry-runis honoured as the read spelling,-odisplaces the operands, and-dnames the
directory the write lands in; each of the three is a measured row rather than an inference. - CI green on both legs; merge state MERGEABLE/CLEAN at this head. Merge-plan note: this PR and
#1437both editemrg/tools/bash_tool.pyand are reported as a conflicting pair, so whichever
lands second needs its resolution on its own branch.
…stination Merging master into this branch resolved two things rather than one. The git-level conflict was in `_option_destination_values`'s docstring: #1434 added the `options` paragraph, this branch the terminator paragraph, so both are kept. The semantic half is what the merge exposed and neither branch could see alone. Since #1435 landed, the operand reader ends its option parsing at `--` (`rm -- -s` names `-s`), so an option-shaped token after a terminator is an operand instead of a swallowed option. For the destination-last family that changes the reading of the same command: `cp -- -t OUT src.txt` is a copy whose destination is its **last** operand, not a run that names nothing. Measured ground truth on this host: `cp -- -t x dest` exits 0 and puts both operands inside `dest`, and `cp -- -t OUT src.txt` exits 1 with "src.txt: Not a directory" — the last operand is the path the run is aimed at either way. So the five `-t` rows now assert the option's value is never named while the destination operand is, which keeps this branch's claim (no false block from the terminator) in the world the merged changes actually produce.
What was wrong
patchmodifies the files it is pointed at — that is what the program is for — and the sandbox walk named none of them. Measured on master15733088with the real predicate (nothing executed; the paths are arguments to a pure predicate), the target outside every allowed root:patch /outside/emrg/f[]patch -o /outside/emrg/out /workspace/in[]patch -d /outside/emrg /workspace/f[]rm/truncate -s 0/cpon the same paths (controls)An empty target list is allowed by construction — the loop that judges targets never runs — so this is the same fail-open the everyday-writer class (#1398), the compressor family (#1418),
rsync(#1419),split(#1430) andcsplit(#1431) each had: a program whose purpose is to rewrite a file in place, invisible at the one tier whose job is to protect uncommitted work.Ground truth for what it really does, taken in a scratch directory on this host and read back off disk (BSD
patch 2.0-12u11-Apple, 2026-09-19):The rule
-o: the operands are the targets — every one of them, the rulermreads, rather than an invented first-operand special case.-o/--outputdisplaces the operand list: the operand is then a source and only the option's value is written (measured above). Alternates, not additions — the shape-t <dir>already has forln/cp/mv.-d/--directoryis where the write lands, so its value is named as well. This is the miss against master:patch -d <outside> <workspace>/fwas allowed atworkspace-writewhile the file really rewritten was the one under-d's directory.--dry-runwrites nothing and is honoured, so a run that changes no byte is not refused — the false block this walk treats as the worse error.patch -p 1 <outside>/fwould name the strip count1, the wrong-name defect_positional_argsexists to avoid.The option letters come from this host's BSD usage line (measured) plus the GNU long spellings the CI platform's twin documents; a letter one implementation rejects is harmless here, because it means its value is not a path either.
Named residual, not a silent hole
With no operand and no
-o,patchwrites the paths named inside the diff — content no static scan can read. The walk names nothing and both tiers allow; that row is pinned with the verdict it really gets, in the shapetests/test_bash_tool_option_destinations.pyuses for thetarfamily, so a later reader who teaches this walk to read patch bodies finds the row that must move with it.Tests
tests/test_bash_tool_patch_targets.py(45 tests): ten write spellings, four-ospellings (including-owith no operand), three-dspellings, the two--dry-runspellings, four content-decides rows, the protected daemon file, the tier difference for an in-workspace operand, and acpcontrol proving the geometry is what makes this a fix.Four mutation arms, each asserting the unmutated reading first:
1is named as a target (a wrong name, not a verdict change);-oset → the source is named and the outside destination goes unnamed again: the pre-fix reading of every-orow;--dry-run→ a dry run is refused atread-only;-dset →patch -d <outside> <inside>/fis allowed atworkspace-writeagain, i.e. the hole comes back.Verified: full suite 3814 passed, 21 skipped;
python -c "from emrg.client.app import run_client";python -m emrg --help;scripts/check-doc-count.pyOK. Nothing here executes a command, touches~/.emrg/config.toml, or starts/stops a daemon.Cycle: cyc20260919-202406