Files
bytecii 391ef1aebd feat(shell): GNU control-flow, set-option, builtin, and test/[[ semantics + 147 integ cases (#577)
* feat(shell): GNU control-flow/set-option/builtin semantics + 89 integ cases

set -u/-x/-f enforcement, set -e GNU list semantics, pipeline negation,
break/continue levels, readonly fatal assignment, declare/typeset scoping,
GNU select, base#value arithmetic, subshell background jobs (private job
table), per-line stdin buffer reset (read no longer poisoned after the
first line), GNU read IFS-whitespace trimming. Both languages + mirrored
unit tests. 89 docker-pinned integ cases across bash/control.json,
setopt.json, builtin.json, jobs.json, read.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(integ): run the new control/setopt/builtin/jobs/read cases on the box target

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(shell): complete test/[/[[ condition evaluator + return/fg fixes, 58 integ cases

The old evaluator silently returned false for every operator outside
-z/-n/-f/-d, string =/!=, and the six numeric comparisons: [ -e f ],
[ -s f ], [[ x == pat* ]], [[ x =~ re ]], [[ a && b ]], and -a/-o
combinators all evaluated false with no error, and [ -f dir ] was true.
Same silently-wrong class as brace expansion — core agent idioms
steering control flow the wrong way.

Both languages, GNU-pinned via a 107-probe docker battery (bash 5.2,
106/107 identical) plus 58 exact-command pins:

- test/[: bash arity rules (1-4 args) + recursive-descent -o/-a/!/()
  parser beyond four; file ops -e -f -d -s -r -w -x -L -h via a
  stat->readdir kind probe and the namespace symlink table; GNU
  diagnostics ("integer expression expected", "binary operator
  expected", "too many arguments") at exit 2; unquoted expansions
  word-split; -1 negative numbers reassembled from the parser's
  unary split; ERROR-recovery nodes flattened or truncated at ;.
- [[ ]]: structured tree evaluator — glob ==/!= with quoted-RHS
  literal semantics, =~ ERE with BASH_REMATCH capture groups, && ||
  ! ( ), string < >, arithmetic coercion for -eq family
  ([[ n -eq 3 ]], [[ 1+1 -eq 2 ]]), no word splitting; a bad operator
  is a parse error killing the whole line (exit 2) like bash.
- Operators mirage cannot answer (-p -S -b -c -g -k -u -O -G -N -t,
  -nt -ot -ef) fail loudly as capability errors instead of silent
  false.
- return: outside function/source now errors-and-continues ($?=2,
  GNU message) instead of leaking a stray signal; bare return
  propagates $? (function bodies track last_exit_code per statement);
  return inside a sourced file stops the source with its status.
- fg: split from wait — "fg: current: no such job" exit 1 with no
  jobs; with a job prints the resumed command and adopts its exit.

Coverage: 58 new integ cases (test.json +46, builtin.json +8 incl.
disown/complete/compgen pins and return semantics, setopt.json +2
set +u / bare set, jobs.json +2 fg) across all 19 targets; +89 py and
+82 TS mirrored unit tests. Harness 1364/1364 on python-ram,
python-disk, and typescript-ram with zero curation.

Documented divergences: fg follows interactive bash (non-interactive
GNU always fails "no job control"); [[ error text is
"mirage: conditional binary operator expected"; error messages drop
bash's "line N:" prefix (existing convention). New parser limitations:
[ a \< b ] is an ERROR node (use [[ a < b ]]); a malformed [ a = ]
swallows trailing statements into the test node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retrigger after transient GitHub API outage in paths-filter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(shell): test -e/-s truth on prefix stores and size-unknown backends

CI on the cloud-backend targets exposed two condition-evaluator bugs:

- The readdir existence probe treated an empty listing as an existing
  directory, but prefix stores (s3, gridfs, hf, nextcloud) list a
  missing path as [] instead of raising, so -e/-d/-s answered true for
  nonexistent paths. The probe now demands a non-empty listing; those
  stores cannot hold an empty directory, so nothing real is lost.

- -s counted a size-unknown stat as non-empty, but dropbox/gdrive/box
  stat freshly written empty files as size-unknown, so [ -s empty ]
  answered true (also breaking the pre-existing redirect_* cases that
  assert via test ! -s). A size-unknown regular file is now read to
  answer exactly; the prefetch TTL cache keeps repeat tests cheap.

Adds stub-dispatch regression tests for both backend shapes in Python
and TypeScript.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(shell): -d stub in builtins.test lists an entry (empty listing now means missing)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(shell): split the condition evaluator into a package (py+ts)

condition.py / condition.ts had grown to ~500 lines mixing node types,
operator tables, two evaluators, and the builtin wrapper. Both languages
now mirror the same package layout:

- types: CondNode dataclasses/union, CondError, CondContext
- constants: operator classification sets + INT_COMPARATORS table
  (operator.eq/... in Python, bigint lambdas in TS) replacing the
  -eq/-ne/-lt if-chains in both evaluators
- operators: operand scoping, path-kind probe, unary/binary application
- flat: test/[ arity rules + the >4-arg recursive-descent parser
- tree: the [[ ]] expression-tree evaluator
- handle: the handle_test/handleTest builtin entry point

Public surface is unchanged: the package __init__ (TS index.ts)
re-exports what importers used before, so call sites are untouched
apart from the TS relative-path bump. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:25:12 -07:00

58 lines
2.0 KiB
Python

# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
SELECT_CMD = "select x in aa bb; do echo got:$x; break; done"
def test_select_picks_choice_from_stdin(shell):
code, out, err = shell.mirage_result(f"printf '2\\n' | {SELECT_CMD}")
assert code == 0
assert out == "got:bb\n"
assert err == "1) aa\n2) bb\n#? "
def test_select_invalid_choice_sets_empty(shell):
out = shell.mirage(
"printf '9\\n' | select x in aa bb; do echo got:${x:-none}; break; "
"done")
assert out == "got:none\n"
def test_select_reply_holds_raw_line(shell):
out = shell.mirage(
"printf 'zz\\n' | select x in aa bb; do echo r:$REPLY; break; done")
assert out == "r:zz\n"
def test_select_empty_line_redisplays_menu(shell):
_, out, err = shell.mirage_result(f"printf '\\n2\\n' | {SELECT_CMD}")
assert out == "got:bb\n"
assert err == "1) aa\n2) bb\n#? 1) aa\n2) bb\n#? "
def test_select_eof_ends_loop(shell):
# bash terminates the prompt line with a newline at EOF.
code, out, err = shell.mirage_result(
"printf '' | select x in aa bb; do echo body; done; echo after")
assert code == 0
assert out == "\nafter\n"
assert err == "1) aa\n2) bb\n#? "
def test_select_loops_until_break(shell):
out = shell.mirage(
"printf '1\\n2\\n' | select x in aa bb; do echo got:$x; "
"if [ $x = bb ]; then break; fi; done")
assert out == "got:aa\ngot:bb\n"