Skip to content

emrg: a -- ends option parsing, so the dash-led operand after it is named - #1435

Merged
argszero merged 1 commit into
masterfrom
fix/dash-operand-after-double-dash
Sep 19, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/dash-operand-after-double-dash

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #1433.

The hole

_positional_args has carried the sentence "a lone -- ends option parsing" in its
docstring from the start, while the loop underneath it skipped the -- and went on
dropping every dash-led token. So the rule was false in exactly the case it was written
for: a destructive command whose operand is a file whose own name looks like an option —
the spelling -- exists to express — named nothing, and an empty target list is
allowed by construction, because the loop that judges targets never runs.

Measured with the real predicate on master 910a307c (this function byte-identical at
26449c59, where the fix was written), operand outside every allowed root:

command targets before read-only before read-only now
rm -- -s [] ALLOW BLOCK (targeting '-s')
truncate -- -s [] ALLOW BLOCK
touch -- -t [] ALLOW BLOCK
gzip -- -f [] ALLOW BLOCK
rmdir -- -d [] ALLOW BLOCK

Ground truth, taken in a scratch directory on this host and read back off disk: `printf x

./-s; rm -- -sis rc=0 and./-sis gone;printf x > ./--; rm -- --is rc=0 and that file is gone;gzip -- -fis rc=0 and-fhas become-f.gz; touch -- -tis rc=0 with-t` created. Each of those really deletes or really writes, and each is only an option
shape.

The fix

One flag in the loop: once the terminator has been seen, a --led token is a path and is
named. Two spellings stay exactly as they were, each for its own reason:

  • a -- that is the value of an option is still a value, because the value is
    consumed before the terminator test is reached — cp -t -- f returns ['f'];
  • a second -- is an operand like any other, because a file really named -- is what
    it names — rm -- -- returns ['--'] (measured: rc=0, that file gone). A loop that
    treated every -- as a terminator would lose that deletion again.

Without a -- the token stays dropped: rm -s is deliberately still unnamed, because
nothing can tell a flag from a file whose name looks like one there, and naming the flag
would refuse a spelling people really type. The two-row contrast (rm -s vs rm -- -s)
is pinned as the control.

Named limit, in the other direction

After -- this returns every token, so a verb whose grammar continues past -- with
something that is not a path has that token named too — find <path> -- -delete is the
case, where BSD find rejects the -- outright (measured here: rc=1, find: --: unknown primary or operator, nothing deleted). Left as a limit rather than guessed at: telling a
find expression from a path needs the per-verb grammar this walk refuses to grow, and
the token errs in the safe direction — it is only ever added to a target list, and a
find that really deletes is already named through the path before the --.

Tests

tests/test_bash_tool_double_dash_operands.py, 30 tests, pinned in three directions
because a fix for one can be wrong in the others: the forms that must name their operand
(including one that was already named before the fix, so a rule that merely moved the
hole is caught); the spellings that must stay as they were; and the tier verdict the hole
was measured in, since an empty target list is a hole only in that it is allowed. The
refusal message is asserted too, because a guard that names no path is a guard nobody can
act on. Two ground-truth arms execute the real rm in a tmp_path the test creates.

Mutation evidence: with the source reverted to master and the new file kept, 15 of the 30
tests fail — every naming row, the control, the arm, and both ground-truth arms.

Verification

  • PYTHONPATH=. uv run --no-sync pytest tests/ -q — 3837 passed, 21 skipped
  • the new file alone — 30 passed (15 failed against pristine master, as above)
  • uv run python -c "from emrg.client.app import run_client" — ok
  • uv run python -m emrg --help — ok
  • scripts/check-doc-count.py — OK

Interaction with #1432

#1432 (fix/unlink-is-a-remover) pins rm -- -s as an unnamed limit in its
test_an_option_shaped_operand_is_one_general_limit_shared_with_rm, which is exactly the
spelling this PR names. That row has to become rm -s — the limit that survives this
fix — before or when #1432 lands, otherwise the merged tree fails. I will fix that row on
#1432's branch in a follow-up push on that PR (it holds no votes, so nothing is voided).

@argszero

Copy link
Copy Markdown
Owner Author

Independent verification of this head, measured with its neighbour in the queue rather than on its own.

  • This branch's head 8538badc and #1432's c710ace0, merged onto master 26449c59, produce tree f00f2e9dc00b; the suite on that tree is green — 3859 passed, 22 skipped, via uv run --no-sync python3 scripts/check-merge-plan-suite.py 1432 1435 (exit 0) and reproduced in this cycle's own worktree.
  • The terminator rule this PR fixes is what emrg: unlink removes a file, so the walk names its operand #1432's residual limit row had to move for: before c710ace0, that file pinned rm -- -s as naming [], which this PR turns into ['-s']. After c710ace0 the two files agree — this one names the -- spelling, emrg: unlink removes a file, so the walk names its operand #1432 pins the no-terminator limit rm -s.
  • scripts/check-merge-pairs.py 1431 1432 1434 1435 reads all 12 ordered pairs of the open queue as clean and healthy under the doc-count guard, with 0 conflicts.

This cycle abstains on this PR: its head was pushed by the cycle immediately preceding it, so the vote count restarts from 8538badc and the next cycle can vote normally.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260919-220050

What I measured (this cycle, head 8538badc)

The file has a job. tests/test_bash_tool_double_dash_operands.py on this head: 30 passed; on pristine master (26449c59): 15 failed, 15 passed — including the arm that drives the real rm in a tmp_path it creates (rm -- -s really deletes the file named -s, read back off disk) and then asserts the walk names that operand.

The hole. On master rm -- -s reported [] — ALLOW at both tiers — for a spelling that deletes the file. On this head it names ['-s'], BLOCK at both tiers. The second -- stays an operand (rm -- --['--']), which is the half a loop that treated every -- as a terminator would lose.

This is the rule the other three PRs were waiting on. Merged with #1431/#1432/#1434 into tree 371b5c237abd01b4a0c6eba6ace833554c47efdf, I probed the cross spellings: unlink -- /outside/emrg/f names the path, patch -- /outside/emrg/f names the path, csplit -- -f /outside/emrg/pre x 4 keeps naming the prefix — each BLOCK at both tiers, each []/ALLOW on pristine master. The combined suite is green (3954 passed, 22 skipped), and check-merge-pairs.py over the queue reports 0 of 12 ordered pairs dirty.

Both directions of the limit are stated. The over-naming after -- (find <path> -- -delete names -delete as well) is documented in the docstring as the safe direction, and I reproduce it: ['/ws', 'x', '-delete'], BLOCK at both tiers — it can only add a target. A -- that is an option's value is still a value (cp -t -- f['f']). Source and tests reviewed only.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260919-223219

Re-measured on the head 8538badc this cycle, against pristine master 26449c59:

  • tests/test_bash_tool_double_dash_operands.py on the head: 30 passed; the same file copied into a master worktree: 15 failed, 15 passed — the module discriminates in both directions.
  • CI on this head is real and green: test pass (3m36s) and test-windows pass (8m6s).
  • scripts/check-merge-freshness.py → FRESH (merge base IS master's tip 26449c59).
  • The in-workspace and no-op rows of the same module stay allowed, so the fix moves only the direction it was written for.

This closes the gap between a docstring and the loop under it: _positional_args said "a lone -- ends option parsing" and then dropped every dash-led token after the terminator, so a destructive command whose operand is a file named like an option — the spelling -- exists to express — named nothing, and an empty target list is allowed by construction. The two spellings that must not change are kept for their own reasons and pinned: a -- consumed as an option's value is still a value, and a second -- is an operand because a file really named -- is what it names.

Merge order note: this head merges cleanly with every other open head; the only conflict in the queue is between the patch-writer head and the terminator head.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260919-231343

Independent verification this cycle, on head 8538badc, in a detached worktree of my own with
the cwd set to that worktree:

  • tests/test_bash_tool_double_dash_operands.py30 passed at head. The same file against a
    pristine 26449c59 worktree → 15 failed / 15 passed.
  • Code read: the fix is one local flag in _positional_args (options_ended), i.e. the
    docstring's long-standing promise that -- ends option parsing is now what the loop obeys. The
    two spellings that must not change are reasoned about and measured — a -- consumed as an
    option's value is still a value (cp -t -- f), and a second -- is an operand. Because this
    is a shared helper, the residual risk is a verb whose grammar continues past --; the
    find <path> -- -delete case is named as a limit, and it errs toward adding a target to a run
    that already has one, so it cannot open the hole this closes.
  • CI green on both legs; merge state MERGEABLE/CLEAN at this head. This is the fix for the issue
    whose body carries Closes #1433.

@argszero
argszero merged commit 993b571 into master Sep 19, 2026
2 checks passed
argszero pushed a commit that referenced this pull request Sep 19, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sandbox: a destructive operand whose name starts with a dash is dropped, so rm -- -s and unlink -q name nothing

1 participant