refactor: close the cleanup plan — execute_node split, candidates() deletion, integ facets (+2 cmp fixes)
Items 32, 34 and 37 of the 37-item cleanup plan.
Item 37b: execute_node's two oversized arms (377 of ~820 lines) move to
siblings named for their node kind — workspace/node/declaration.{py,ts}
and workspace/node/assignment.{py,ts}. Verbatim move, two-line
delegations left behind; execute_node.py 1464 -> 715, .ts 1617 -> 842.
expand_array_items becomes a named cross-module seam. The setAttr
allowlist gates (py+ts) follow _stamp_export to its new module.
Item 34: delete candidates() from runtime/table.ts — zero non-test
callers, no Python counterpart. RUNTIMES stays (py derives NAMED from
it; TS can't, Runtime.name is an instance field).
Item 32: the missing integ facets — cmp/{n,b,i}, df/units, gzip/f,
ls/t, checksum/tag. mktemp was already covered (stale plan entry) and
the checksum --check companions are covered collectively, so the
per-command fact pinned instead is the --tag algorithm name.
The cmp facets found two real bugs, both GNU-pinned before the fix:
-b was ignored under -l (and the octal column is right-aligned to 3 in
both modes), and -i N:M crashed with a leaked ValueError. Fixing them
pulled in GNU size suffixes for -n/-i, cat -v byte rendering, the
byte/char wording switch, and the EOF notice as a stderr diagnostic
naming byte and line. Python's cross-mount relay re-derived flags
instead of calling parse_flags, which is how it kept reading -i as a
bare int; it now delegates like the TS relay already did.
cmp had no unit tests in either tree; 19 mirrored ones per side now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"baseline": 2295,
|
||||
"baseline_reason": "Every dropped target counted here predates the gate and each needs its own decision, so --strict fails on a rise rather than demanding zero. Lower this number whenever a case file regains a backend; the gate fails on a drop too, so an improvement cannot be silently spent. The unit is one (case, target) pair, never (file, target): a sequence-style file states one scenario across prep/act/verify cases, so a backend dropped from only the verifying case still runs every step that cannot fail and silently skips the assertion, and a union over the file would report that backend as covered. What this gate exists to catch is the failure mode that hid two real bugs: a backend that cannot pass a case is normally just deleted from that case's `targets`, which turns a divergence into an omission no reviewer can see. integ/unix/mv/empty_dir.json had dropbox and dropbox-root removed for a folder-conflict divergence, and s3/gridfs removed because a directory rename was unimplemented on both -- neither omission said so anywhere. A large share of the remaining count is the tier2/unicode/escape family, whose cases pin text-processing behavior that cannot vary by backend and so run on the core four; those are excusable in bulk once someone confirms that file by file, which is exactly the decision this gate is meant to force rather than assume.",
|
||||
"files": {},
|
||||
"baseline": 2289,
|
||||
"baseline_reason": "Every dropped target counted here predates the gate and each needs its own decision, so --strict fails on a rise rather than demanding zero. Lower this number whenever a case file regains a backend; the gate fails on a drop too, so an improvement cannot be silently spent. The unit is one (case, target) pair, never (file, target): a sequence-style file states one scenario across prep/act/verify cases, so a backend dropped from only the verifying case still runs every step that cannot fail and silently skips the assertion, and a union over the file would report that backend as covered. What this gate exists to catch is the failure mode that hid two real bugs: a backend that cannot pass a case is normally just deleted from that case's `targets`, which turns a divergence into an omission no reviewer can see. integ/unix/mv/empty_dir.json had dropbox and dropbox-root removed for a folder-conflict divergence, and s3/gridfs removed because a directory rename was unimplemented on both -- neither omission said so anywhere. A large share of the remaining count is the tier2/unicode/escape family, whose cases pin text-processing behavior that cannot vary by backend and so run on the core four; those are excusable in bulk once someone confirms that file by file, which is exactly the decision this gate is meant to force rather than assume. Lowered 2295 -> 2289 when cmp/{n,b,i}.json landed: the gate compares each case against its directory's MODAL target set, and cmp's mode was decided by a 2-vs-2 tie broken toward the larger set, so the two fixture-reading cases (basic.json, which only reads /data/a.txt) set the expectation and the two write-seeding ones (l.json, s.json) each reported a 3-target omission. The new cases seed the same way l.json does -- `mkdir -p` plus `printf >` -- which databricks, databricks-prefix and sharepoint-prefix cannot serve, so they carry l.json's list and the write-seeding shape is now the majority. Those 6 pairs are a real capability limit rather than a parked divergence: cmp itself runs on all 22 (basic.json proves it), only the seeding does not. Nothing was narrowed to make a case pass.",
|
||||
"files": {
|
||||
"integ/unix/gzip/f.json": "Same list as gzip/k.json, the sibling that pins -k. -f only decides whether an existing archive is overwritten, which every backend in that list already exercises through the write primitive.",
|
||||
"integ/unix/ls/t.json": "-t sorts on mtime, so the case has to set three distinct mtimes with touch -d first; the list is touch/d.json's, the backends that support setting one."
|
||||
},
|
||||
"entries": {
|
||||
"integ/bash/assign/export_attr.json :: cd_exports_pwd_and_oldpwd": {
|
||||
"hf": "hf holds no empty directory: the service refuses a directory marker client-side (create_dir=false, probed on opendal 0.47.1), so mkdir is a no-op and a directory exists exactly while it holds a key. This case has to cd into a directory it just made and never keyed, so on hf the cd fails and PWD/OLDPWD never take the values being asserted. What the case pins is the export attribute on the two variables cd writes, which is shell state and cannot vary by backend; hf runs the rest of this file, including the bare `declare -p PWD` case that reads the same attribute without needing a directory.",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "checksum_tag_names_md5",
|
||||
"seq": 910042,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "mkdir -p /data/qcktag && printf 'hello' > /data/qcktag/h.txt && md5sum --tag /data/qcktag/h.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "MD5 (/data/qcktag/h.txt) = 5d41402abc4b2a76b9719d911017c592\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"tag"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checksum_tag_names_sha1",
|
||||
"seq": 910043,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "sha1sum --tag /data/qcktag/h.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "SHA1 (/data/qcktag/h.txt) = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"tag"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checksum_tag_names_sha384",
|
||||
"seq": 910044,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "sha384sum --tag /data/qcktag/h.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "SHA384 (/data/qcktag/h.txt) = 59e1748777448c69de6b800d7a33bbfb9ff1b463e44354c3553bcdb9c666fa90125a3c79f90397bdf5f6a13de828684f\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"tag"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checksum_tag_names_sha512",
|
||||
"seq": 910045,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "sha512sum --tag /data/qcktag/h.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "SHA512 (/data/qcktag/h.txt) = 9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"tag"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checksum_short_binary_and_text_markers",
|
||||
"seq": 910046,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "sha256sum -b /data/qcktag/h.txt; sha256sum -t /data/qcktag/h.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 */data/qcktag/h.txt\n2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 /data/qcktag/h.txt\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"b",
|
||||
"t"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checksum_tag_output_checks_back",
|
||||
"seq": 910047,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "mkdir -p /data/qckrt && sha512sum --tag /data/qcktag/h.txt > /data/qckrt/sums && sha512sum -c /data/qckrt/sums; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/qcktag/h.txt: OK\nrc=0\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"tag",
|
||||
"c"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "cmp_b_names_the_byte_and_its_character",
|
||||
"seq": 601136,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/qcmpb && printf 'abc' > /data/qcmpb/b1 && printf 'aXc' > /data/qcmpb/b2 && printf 'a\\001c' > /data/qcmpb/c1 && printf 'a\\177c' > /data/qcmpb/c2 && cmp -b /data/qcmpb/b1 /data/qcmpb/b2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/qcmpb/b1 /data/qcmpb/b2 differ: byte 2, line 1 is 142 b 130 X\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_b_renders_control_bytes_cat_v_style",
|
||||
"seq": 601137,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -b /data/qcmpb/c1 /data/qcmpb/c2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/qcmpb/c1 /data/qcmpb/c2 differ: byte 2, line 1 is 1 ^A 177 ^?\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_bl_adds_a_character_column_to_each_octal",
|
||||
"seq": 601138,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -bl /data/qcmpb/b1 /data/qcmpb/b2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2 142 b 130 X\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"b",
|
||||
"l"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_bl_pads_the_octal_of_a_control_byte",
|
||||
"seq": 601139,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -bl /data/qcmpb/c1 /data/qcmpb/c2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2 1 ^A 177 ^?\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"b",
|
||||
"l"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "cmp_i_skips_both_files",
|
||||
"seq": 601140,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/qcmpi && printf 'abcdefgh' > /data/qcmpi/i1 && printf 'abcXefgh' > /data/qcmpi/i2 && cmp -i 3 /data/qcmpi/i1 /data/qcmpi/i2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/qcmpi/i1 /data/qcmpi/i2 differ: char 1, line 1\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"i"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_i_pair_skips_one_count_per_file",
|
||||
"seq": 601141,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -i 0:3 /data/qcmpi/i1 /data/qcmpi/i2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/qcmpi/i1 /data/qcmpi/i2 differ: char 1, line 1\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"i"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_i_equal_skips_realign_the_files",
|
||||
"seq": 601142,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -i 1:1 /data/qcmpi/i1 /data/qcmpi/i2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/qcmpi/i1 /data/qcmpi/i2 differ: char 3, line 1\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"i"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_i_past_both_files_compares_nothing",
|
||||
"seq": 601143,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -i 99 /data/qcmpi/i1 /data/qcmpi/i2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "rc=0\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"i"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_i_junk_is_a_usage_error",
|
||||
"seq": 601144,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -i abc /data/qcmpi/i1 /data/qcmpi/i2 2>&1; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "cmp: invalid --ignore-initial value 'abc'\nTry 'cmp --help' for more information.\nrc=2\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"i"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_eof_is_a_stderr_diagnostic",
|
||||
"seq": 601145,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/qcmpe && printf 'ab\\nc' > /data/qcmpe/e1 && printf 'ab\\ncdef' > /data/qcmpe/e2 && cmp /data/qcmpe/e1 /data/qcmpe/e2 2>&1; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "cmp: EOF on /data/qcmpe/e1 after byte 4, in line 2\nrc=1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cmp_l_eof_drops_the_line_clause",
|
||||
"seq": 601146,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/qcmpd && printf 'aXc' > /data/qcmpd/d1 && printf 'aYcdef' > /data/qcmpd/d2 && cmp -l /data/qcmpd/d1 /data/qcmpd/d2 2>&1; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2 130 131\ncmp: EOF on /data/qcmpd/d1 after byte 3\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"l"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "cmp_n_within_the_limit_is_equal",
|
||||
"seq": 601132,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/qcmpn && printf 'abcdef' > /data/qcmpn/n1 && printf 'abcXef' > /data/qcmpn/n2 && cmp -n 2 /data/qcmpn/n1 /data/qcmpn/n2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "rc=0\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_n_past_the_difference_reports_it",
|
||||
"seq": 601133,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -n 4 /data/qcmpn/n1 /data/qcmpn/n2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/qcmpn/n1 /data/qcmpn/n2 differ: char 4, line 1\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_n_takes_a_size_suffix",
|
||||
"seq": 601134,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -n 1K /data/qcmpn/n1 /data/qcmpn/n2; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/data/qcmpn/n1 /data/qcmpn/n2 differ: char 4, line 1\nrc=1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cmp_n_junk_is_a_usage_error",
|
||||
"seq": 601135,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "cmp -n abc /data/qcmpn/n1 /data/qcmpn/n2 2>&1; echo rc=$?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "cmp: invalid --bytes value 'abc'\nTry 'cmp --help' for more information.\nrc=2\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"n"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "df_kilo",
|
||||
"seq": 612013,
|
||||
"targets": [
|
||||
"ram"
|
||||
],
|
||||
"command": "df -k /data",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "Filesystem 1K-blocks Used Available Use% Mounted on\nram - - - - /data\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"k"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "df_all",
|
||||
"seq": 612014,
|
||||
"targets": [
|
||||
"ram"
|
||||
],
|
||||
"command": "df -a /data",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "Filesystem 1K-blocks Used Available Use% Mounted on\nram - - - - /data\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "df_si_human",
|
||||
"seq": 612015,
|
||||
"targets": [
|
||||
"ram"
|
||||
],
|
||||
"command": "df -H /data",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "Filesystem Size Used Avail Use% Mounted on\nram - - - - /data\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"H"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "gzip_f_overwrites_an_existing_archive",
|
||||
"seq": 601307,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"s3",
|
||||
"gridfs",
|
||||
"s3-prefix",
|
||||
"gridfs-prefix",
|
||||
"hf",
|
||||
"hf-prefix",
|
||||
"dropbox",
|
||||
"dropbox-root",
|
||||
"onedrive",
|
||||
"sharepoint",
|
||||
"ssh",
|
||||
"nextcloud",
|
||||
"gdrive",
|
||||
"gdrive-folder",
|
||||
"box"
|
||||
],
|
||||
"command": "mkdir -p /data/qgzf && printf 'first' > /data/qgzf/g.txt && gzip -k /data/qgzf/g.txt && printf 'second' > /data/qgzf/g.txt && gzip -f /data/qgzf/g.txt && gzip -dc /data/qgzf/g.txt.gz",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "second",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"f"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "ls_t_sorts_newest_first",
|
||||
"seq": 960096,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis"
|
||||
],
|
||||
"command": "mkdir -p /data/qlst && printf 'x' > /data/qlst/old.txt && printf 'x' > /data/qlst/mid.txt && printf 'x' > /data/qlst/new.txt && touch -d '2021-01-01T00:00:00' /data/qlst/old.txt && touch -d '2022-01-01T00:00:00' /data/qlst/mid.txt && touch -d '2023-01-01T00:00:00' /data/qlst/new.txt && ls -1t /data/qlst",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "new.txt\nmid.txt\nold.txt\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"t"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ls_rt_reverses_the_mtime_sort",
|
||||
"seq": 960097,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk",
|
||||
"redis"
|
||||
],
|
||||
"command": "ls -1rt /data/qlst",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "old.txt\nmid.txt\nnew.txt\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"flags": [
|
||||
"r",
|
||||
"t"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mirage.commands.builtin.utils.output import format_records
|
||||
from mirage.commands.builtin.utils.size_suffix import size_suffixes
|
||||
from mirage.commands.config import CommandOpts
|
||||
from mirage.commands.errors import UsageError
|
||||
from mirage.commands.spec import SPECS
|
||||
@@ -11,16 +13,79 @@ from mirage.io.types import ByteSource, IOResult
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.errors import FS_ERRORS, format_fs_error
|
||||
|
||||
_UNITS = size_suffixes("bkKMGTPEZY")
|
||||
_TRY_HELP = "\nTry 'cmp --help' for more information."
|
||||
_COUNT = re.compile(r"^([0-9]+)([A-Za-z]*)$")
|
||||
|
||||
|
||||
def parse_count(raw: str, option: str) -> int:
|
||||
"""One GNU ``cmp`` byte count: digits and an optional size suffix.
|
||||
|
||||
GNU reads ``-n``/``-i`` operands through xstrtoumax, so ``1K`` and
|
||||
``1kB`` are accepted and anything else is a usage error naming the
|
||||
long option, not a crash.
|
||||
|
||||
Args:
|
||||
raw (str): the operand as typed.
|
||||
option (str): the long option name for the diagnostic, e.g.
|
||||
``--bytes``.
|
||||
|
||||
Raises:
|
||||
UsageError: the operand is not digits plus a known suffix.
|
||||
"""
|
||||
match = _COUNT.match(raw)
|
||||
suffix = match.group(2) if match is not None else ""
|
||||
if match is None or (suffix and suffix not in _UNITS):
|
||||
raise UsageError(f"cmp: invalid {option} value '{raw}'{_TRY_HELP}")
|
||||
return int(match.group(1)) * (_UNITS[suffix] if suffix else 1)
|
||||
|
||||
|
||||
def parse_skip(raw: str) -> tuple[int, int]:
|
||||
"""The ``-i`` operand as one skip per file.
|
||||
|
||||
GNU takes ``SKIP`` for both files or ``SKIP1:SKIP2`` for one each,
|
||||
so ``-i 0:3`` compares all of the first file against the fourth
|
||||
byte onward of the second.
|
||||
|
||||
Args:
|
||||
raw (str): the ``-i`` operand as typed.
|
||||
"""
|
||||
first, sep, second = raw.partition(":")
|
||||
head = parse_count(first, "--ignore-initial")
|
||||
if not sep:
|
||||
return head, head
|
||||
return head, parse_count(second, "--ignore-initial")
|
||||
|
||||
|
||||
def visible(byte: int) -> str:
|
||||
"""One byte rendered the way GNU ``cmp -b`` renders it.
|
||||
|
||||
The cat -v alphabet: a control byte becomes ``^X`` (so tab is
|
||||
``^I``, unlike ``cat -v`` itself), DEL becomes ``^?``, and a high
|
||||
byte becomes ``M-`` followed by the same rules on its low seven
|
||||
bits.
|
||||
|
||||
Args:
|
||||
byte (int): the byte value.
|
||||
"""
|
||||
if byte >= 128:
|
||||
return "M-" + visible(byte - 128)
|
||||
if byte == 127:
|
||||
return "^?"
|
||||
if byte < 32:
|
||||
return f"^{chr(byte + 64)}"
|
||||
return chr(byte)
|
||||
|
||||
|
||||
async def cmp_cmd(
|
||||
paths: list[PathSpec],
|
||||
*,
|
||||
read_bytes: Callable[..., Awaitable[bytes]],
|
||||
silent: bool = False,
|
||||
verbose: bool = False,
|
||||
limit: int | None = None,
|
||||
print_bytes: bool = False,
|
||||
skip: int | None = None,
|
||||
paths: list[PathSpec],
|
||||
*,
|
||||
read_bytes: Callable[..., Awaitable[bytes]],
|
||||
silent: bool = False,
|
||||
verbose: bool = False,
|
||||
limit: int | None = None,
|
||||
print_bytes: bool = False,
|
||||
skip: tuple[int, int] = (0, 0),
|
||||
) -> tuple[ByteSource | None, IOResult]:
|
||||
if len(paths) > 2:
|
||||
raise extra_operand_error(CommandName.CMP, paths[2].raw_path
|
||||
@@ -36,9 +101,8 @@ async def cmp_cmd(
|
||||
# or unreadable operand) is exit 2.
|
||||
return None, IOResult(exit_code=2,
|
||||
stderr=format_fs_error("cmp", exc, paths))
|
||||
if skip is not None:
|
||||
data1 = data1[skip:]
|
||||
data2 = data2[skip:]
|
||||
data1 = data1[skip[0]:]
|
||||
data2 = data2[skip[1]:]
|
||||
if limit is not None:
|
||||
data1 = data1[:limit]
|
||||
data2 = data2[:limit]
|
||||
@@ -46,24 +110,62 @@ async def cmp_cmd(
|
||||
return None, IOResult()
|
||||
if silent:
|
||||
return None, IOResult(exit_code=1)
|
||||
common = min(len(data1), len(data2))
|
||||
if verbose:
|
||||
out_lines: list[str] = []
|
||||
for idx in range(min(len(data1), len(data2))):
|
||||
for idx in range(common):
|
||||
if data1[idx] != data2[idx]:
|
||||
out_lines.append(f"{idx + 1} {data1[idx]:o} {data2[idx]:o}")
|
||||
return format_records(out_lines), IOResult(exit_code=1)
|
||||
for idx in range(min(len(data1), len(data2))):
|
||||
row = f"{idx + 1} {data1[idx]:>3o}"
|
||||
if print_bytes:
|
||||
row += f" {visible(data1[idx]):<4}"
|
||||
row += f" {data2[idx]:>3o}"
|
||||
if print_bytes:
|
||||
row += f" {visible(data2[idx])}"
|
||||
out_lines.append(row)
|
||||
io = IOResult(exit_code=1)
|
||||
if len(data1) != len(data2):
|
||||
io.stderr = _eof_error(paths, data1, data2, verbose)
|
||||
return format_records(out_lines), io
|
||||
for idx in range(common):
|
||||
if data1[idx] != data2[idx]:
|
||||
line = 1 + data1[:idx].count(ord(b"\n"))
|
||||
# GNU counts in `byte` under -b and in `char` otherwise, on
|
||||
# the same offset -- the word tracks the flag, not a unit.
|
||||
unit = "byte" if print_bytes else "char"
|
||||
msg = (f"{p0.virtual} {p1.virtual}"
|
||||
f" differ: char {idx + 1}, line {line}")
|
||||
f" differ: {unit} {idx + 1}, line {line}")
|
||||
if print_bytes:
|
||||
msg += (f" is {data1[idx]:o} {chr(data1[idx])}"
|
||||
f" {data2[idx]:o} {chr(data2[idx])}")
|
||||
msg += (f" is {data1[idx]:>3o} {visible(data1[idx])}"
|
||||
f" {data2[idx]:>3o} {visible(data2[idx])}")
|
||||
return format_records([msg]), IOResult(exit_code=1)
|
||||
shorter = p0.virtual if len(data1) < len(data2) else p1.virtual
|
||||
msg = f"cmp: EOF on {shorter}"
|
||||
return format_records([msg]), IOResult(exit_code=1)
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=_eof_error(paths, data1, data2, verbose))
|
||||
|
||||
|
||||
def _eof_error(
|
||||
paths: list[PathSpec],
|
||||
data1: bytes,
|
||||
data2: bytes,
|
||||
verbose: bool,
|
||||
) -> bytes:
|
||||
"""GNU's ``EOF on FILE`` diagnostic for a common-prefix difference.
|
||||
|
||||
It is a diagnostic, not output: GNU writes it to stderr and still
|
||||
exits 1. ``-l`` reports the byte only, every other mode adds the
|
||||
line the count lands in.
|
||||
|
||||
Args:
|
||||
paths (list[PathSpec]): the two operands, in order.
|
||||
data1 (bytes): the first file's compared bytes.
|
||||
data2 (bytes): the second file's compared bytes.
|
||||
verbose (bool): whether ``-l`` is in effect.
|
||||
"""
|
||||
shorter = paths[0] if len(data1) < len(data2) else paths[1]
|
||||
held = data1 if len(data1) < len(data2) else data2
|
||||
msg = f"cmp: EOF on {shorter.virtual} after byte {len(held)}"
|
||||
if not verbose:
|
||||
msg += f", in line {1 + held.count(ord(b'\n'))}"
|
||||
return (msg + "\n").encode()
|
||||
|
||||
|
||||
__all__ = ["cmp_cmd"]
|
||||
@@ -75,7 +177,7 @@ class CmpFlags:
|
||||
verbose: bool = False
|
||||
limit: int | None = None
|
||||
print_bytes: bool = False
|
||||
skip: int | None = None
|
||||
skip: tuple[int, int] = (0, 0)
|
||||
|
||||
|
||||
def parse_flags(flags: Mapping[str, FlagValue]) -> CmpFlags:
|
||||
@@ -85,9 +187,9 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> CmpFlags:
|
||||
return CmpFlags(
|
||||
silent=fl.as_bool("s"),
|
||||
verbose=fl.as_bool("args_l"),
|
||||
limit=int(n_raw) if n_raw is not None else None,
|
||||
limit=parse_count(n_raw, "--bytes") if n_raw is not None else None,
|
||||
print_bytes=fl.as_bool("b"),
|
||||
skip=int(i_raw) if i_raw is not None else None,
|
||||
skip=parse_skip(i_raw) if i_raw is not None else (0, 0),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
import functools
|
||||
|
||||
from mirage.commands.builtin.generic.cmp import cmp_cmd as generic_cmp
|
||||
from mirage.commands.builtin.generic.cmp import parse_flags
|
||||
from mirage.commands.builtin.generic.crossmount.types import CrossResult
|
||||
from mirage.commands.builtin.generic.crossmount.utils import flat_scopes, relay
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.commands.spec.types import FlagValue, FlagView
|
||||
from mirage.commands.spec.types import FlagValue
|
||||
from mirage.runtime.types import DispatchFn
|
||||
from mirage.types import PathSpec
|
||||
|
||||
@@ -27,21 +27,22 @@ async def run_cmp(scopes: list[PathSpec], flag_kwargs: dict[str, FlagValue],
|
||||
dispatch: DispatchFn) -> CrossResult:
|
||||
"""Byte-compare two files on different mounts via the shared generic.
|
||||
|
||||
Pure wiring: both sides are read through dispatch-relayed primitives.
|
||||
Pure wiring: both sides are read through dispatch-relayed primitives,
|
||||
and the flags go through the generic's own ``parse_flags`` -- reading
|
||||
them a second time here is how the relay came to take ``-i`` as a
|
||||
bare int while the generic had moved on to GNU's ``SKIP1:SKIP2``.
|
||||
|
||||
Args:
|
||||
scopes (list[PathSpec]): The two path operands.
|
||||
flag_kwargs (dict): Flags parsed against the shared cmp spec.
|
||||
dispatch (DispatchFn): Workspace operation dispatcher.
|
||||
"""
|
||||
fl = FlagView(flag_kwargs, spec=SPECS["cmp"])
|
||||
limit = fl.as_str("n")
|
||||
skip = fl.as_str("i")
|
||||
parsed = parse_flags(flag_kwargs)
|
||||
return await generic_cmp(flat_scopes(scopes),
|
||||
read_bytes=functools.partial(
|
||||
relay, dispatch, "read"),
|
||||
silent=fl.as_bool("s"),
|
||||
verbose=fl.as_bool("args_l"),
|
||||
limit=int(limit) if limit is not None else None,
|
||||
print_bytes=fl.as_bool("b"),
|
||||
skip=int(skip) if skip is not None else None)
|
||||
silent=parsed.silent,
|
||||
verbose=parsed.verbose,
|
||||
limit=parsed.limit,
|
||||
print_bytes=parsed.print_bytes,
|
||||
skip=parsed.skip)
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# ========= 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. =========
|
||||
|
||||
import functools
|
||||
from typing import Any, Callable
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.ops.types import SessionView
|
||||
from mirage.policy import PolicyDenied
|
||||
from mirage.shell.array import (array_extent, array_get, array_set,
|
||||
build_assoc_literal, build_indexed_literal)
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.errors import ArithError, ExitSignal
|
||||
from mirage.shell.helpers import get_text
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.variable import ShellValue, VarAttr
|
||||
from mirage.shell.xtrace import trace_assignment
|
||||
from mirage.types import word_text
|
||||
from mirage.workspace.executor.statement import assignment_status
|
||||
from mirage.workspace.expand import expand_and_classify, expand_node
|
||||
from mirage.workspace.expand.globs import glob_options, resolve_globs
|
||||
from mirage.workspace.expand.variable import _array_index
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.session.state import (deref, element_index,
|
||||
session_elements, session_view,
|
||||
visible_env)
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def _assign_var(view: SessionView, key: str, value: ShellValue) -> None:
|
||||
"""One assignment through the session door; denial is fatal.
|
||||
|
||||
Every assignment spelling (scalar, array literal, subscript,
|
||||
append) computes its resulting value and stores through
|
||||
``view.set``, so the gate and the storage invariant live in the
|
||||
door, not here. Denial mirrors the readonly case: a fatal
|
||||
variable-assignment error that abandons the rest of the line.
|
||||
|
||||
Args:
|
||||
view (SessionView): the session plane's gated door.
|
||||
key (str): the variable being written.
|
||||
value (ShellValue): the resulting value to store.
|
||||
"""
|
||||
try:
|
||||
await view.set(key, value)
|
||||
except PolicyDenied as exc:
|
||||
err = f"{exc.strerror}\n".encode()
|
||||
raise ExitSignal(1, stderr=err, contained_code=1) from exc
|
||||
except ArithError as exc:
|
||||
# The `-i` coercion refused the text. GNU aborts the line the
|
||||
# way a bad subscript does, voicing the evaluator's own message
|
||||
# after the offending value: `bash: 1+: syntax error: ...`.
|
||||
err = f"bash: {exc}\n".encode()
|
||||
raise ExitSignal(1, stderr=err, contained_code=1) from exc
|
||||
|
||||
|
||||
async def expand_array_items(
|
||||
array_node: Any,
|
||||
session: Session,
|
||||
execute_fn: Callable[..., Any],
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
cs: CallStack | None,
|
||||
) -> list[str]:
|
||||
"""Expand an array literal into its element words.
|
||||
|
||||
Elements behave like any other shell word list: command
|
||||
substitutions word-split and globs resolve to matches
|
||||
(``a=($(cmd) /data/*.txt)``), with zero-match globs kept literal.
|
||||
|
||||
Args:
|
||||
array_node (Any): the tree-sitter ``array`` node.
|
||||
session (Session): shell session.
|
||||
execute_fn (Callable): workspace execute for substitutions.
|
||||
registry (MountRegistry): mount registry for glob resolution.
|
||||
namespace (Namespace): addressing authority holding the links.
|
||||
cs (CallStack | None): function-call scope, if any.
|
||||
"""
|
||||
# The session plane's door, bound once for the line: every
|
||||
# expansion-time write (`${X:=d}`, `$((X=5))`) lands through it,
|
||||
# so a pre_session rule governs those exactly as it governs `X=d`.
|
||||
view = session_view(session, registry.policies)
|
||||
values = list(array_node.named_children)
|
||||
classified = await expand_and_classify(values,
|
||||
session,
|
||||
execute_fn,
|
||||
registry,
|
||||
session.cwd,
|
||||
cs,
|
||||
view=view)
|
||||
resolved = await resolve_globs(classified,
|
||||
registry,
|
||||
noglob=bool(
|
||||
session.shell_options.get("noglob")),
|
||||
links=namespace,
|
||||
options=glob_options(session))
|
||||
return [word_text(w) for w in resolved]
|
||||
|
||||
|
||||
_SUBSCRIPT_LITERAL_TYPES = frozenset({NT.WORD, NT.NUMBER, NT.ERROR})
|
||||
|
||||
|
||||
async def _subscript_key_text(
|
||||
subscript_node: Any,
|
||||
name: str,
|
||||
session: Session,
|
||||
execute_fn: Callable[..., Any],
|
||||
cs: CallStack | None,
|
||||
view: SessionView | None,
|
||||
) -> str:
|
||||
"""The expanded subscript text of one ``name[...]=`` assignment.
|
||||
|
||||
A purely literal subscript keeps its raw spelling, spaces included
|
||||
(bash stores ``m[ k ]`` under the key ``" k "``); anything carrying
|
||||
an expansion or quoting expands node by node so ``m[$k]`` and
|
||||
``m["a b"]`` resolve with quote removal. The associative path uses
|
||||
the result as the key verbatim; the indexed path evaluates it as
|
||||
arithmetic.
|
||||
|
||||
Args:
|
||||
subscript_node (Any): the tree-sitter ``subscript`` node.
|
||||
name (str): the array variable's name, for the raw slice.
|
||||
session (Session): shell session state.
|
||||
execute_fn (Callable): evaluator for command substitutions.
|
||||
cs (CallStack | None): shell call stack.
|
||||
view (SessionView | None): the session plane's gated door.
|
||||
"""
|
||||
inner = [
|
||||
sc for sc in subscript_node.named_children
|
||||
if sc.type != NT.VARIABLE_NAME
|
||||
]
|
||||
raw = get_text(subscript_node)[len(name) + 1:-1]
|
||||
if not inner or all(sc.type in _SUBSCRIPT_LITERAL_TYPES for sc in inner):
|
||||
return raw
|
||||
parts = []
|
||||
for sc in inner:
|
||||
parts.append(await expand_node(sc, session, execute_fn, cs, view=view))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def execute_assignment(
|
||||
node: Any,
|
||||
session: Session,
|
||||
execute_fn: Callable[..., Any],
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
cs: CallStack | None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Execute one top-level variable assignment (`a=1`, `a[i]+=v`).
|
||||
|
||||
Every spelling -- scalar, array literal, subscript, append -- is
|
||||
computed with bash's own mechanics on a copy of the held value and
|
||||
then stored through the session door, which owns the admission gate
|
||||
and the scalar/array invariant.
|
||||
|
||||
Args:
|
||||
node (Any): the tree-sitter ``variable_assignment`` node.
|
||||
session (Session): shell session state.
|
||||
execute_fn (Callable): recursive execute for substitutions.
|
||||
registry (MountRegistry): mount registry for glob resolution.
|
||||
namespace (Namespace): addressing authority holding the links.
|
||||
cs (CallStack | None): function-call scope, if any.
|
||||
"""
|
||||
text = get_text(node)
|
||||
if "=" not in text:
|
||||
return None, IOResult(), ExecutionNode(command=text, exit_code=0)
|
||||
sub_seq = session._cmdsub_seq
|
||||
subscript_node = next(
|
||||
(c for c in node.named_children if c.type == "subscript"), None)
|
||||
name_source = subscript_node if subscript_node is not None else node
|
||||
name_node = next(
|
||||
(c for c in name_source.named_children if c.type == NT.VARIABLE_NAME),
|
||||
None)
|
||||
spelled = (get_text(name_node)
|
||||
if name_node is not None else text.partition("=")[0])
|
||||
# A name reference assigns to its target, whatever the shape of
|
||||
# the assignment; an unaimed one (`declare -n r; r=v`) resolves
|
||||
# to itself and takes the value as the target's name. The
|
||||
# spelling is kept for slicing the subscript out of the source.
|
||||
key = deref(session, spelled) or spelled
|
||||
append = any(c.type == "+=" for c in node.children)
|
||||
if key in session.readonly_vars:
|
||||
# A bare assignment to a readonly variable is a fatal
|
||||
# variable-assignment error in non-interactive bash: the
|
||||
# rest of the line is abandoned (builtins like `export`
|
||||
# merely fail with 1 and continue).
|
||||
err = f"bash: {key}: readonly variable\n".encode()
|
||||
raise ExitSignal(1, stderr=err, contained_code=1)
|
||||
val_nodes = [
|
||||
c for c in node.named_children
|
||||
if c.type not in (NT.VARIABLE_NAME, "subscript")
|
||||
]
|
||||
# Every branch below computes its resulting value with bash's
|
||||
# own mechanics on a copy, then stores through the one session
|
||||
# door, which owns the gate and the scalar/array invariant.
|
||||
view = session_view(session, namespace.registry.policies)
|
||||
if val_nodes and val_nodes[0].type == NT.ARRAY:
|
||||
items = await expand_array_items(val_nodes[0], session, execute_fn,
|
||||
registry, namespace, cs)
|
||||
amap = session.assocs.get(key)
|
||||
if amap is not None:
|
||||
built, bad_words = build_assoc_literal(amap, items, append)
|
||||
await _assign_var(view, key, built)
|
||||
if bad_words:
|
||||
err = ("\n".join(
|
||||
f"bash: {key}: '{word}': must use subscript when "
|
||||
"assigning associative array"
|
||||
for word in bad_words) + "\n").encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=text,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
code = assignment_status(session, sub_seq)
|
||||
return None, IOResult(exit_code=code), ExecutionNode(
|
||||
command=text, exit_code=code)
|
||||
held = session.arrays.get(key)
|
||||
if append and held is None:
|
||||
scalar = session.env.get(key)
|
||||
held = None if scalar is None else [scalar]
|
||||
# `arr+=(...)` starts at the extent, so it fills the hole a
|
||||
# trailing `unset arr[last]` left but skips interior ones;
|
||||
# a `[i]=v` element places at i and the next plain word
|
||||
# continues from there.
|
||||
base = build_indexed_literal(
|
||||
held, items, append,
|
||||
functools.partial(element_index,
|
||||
env=visible_env(session),
|
||||
elements=session_elements(session)))
|
||||
await _assign_var(view, key, base)
|
||||
code = assignment_status(session, sub_seq)
|
||||
return None, IOResult(exit_code=code), ExecutionNode(command=text,
|
||||
exit_code=code)
|
||||
if val_nodes:
|
||||
val = await expand_node(val_nodes[0],
|
||||
session,
|
||||
execute_fn,
|
||||
cs,
|
||||
view=view)
|
||||
else:
|
||||
val = text.partition("=")[2]
|
||||
if subscript_node is not None:
|
||||
sub_text = await _subscript_key_text(subscript_node, spelled, session,
|
||||
execute_fn, cs, view)
|
||||
amap = session.assocs.get(key)
|
||||
raw_sub = get_text(subscript_node)[len(spelled) + 1:-1]
|
||||
if not raw_sub.strip() or (amap is not None and sub_text == ""):
|
||||
# bash aborts the whole line on a bad assignment
|
||||
# subscript (status 1), naming the raw spelling
|
||||
# (`m[$e]: bad array subscript`). An indexed subscript
|
||||
# that merely *expands* empty stays legal (arithmetic
|
||||
# on nothing is 0), so only the associative kind checks
|
||||
# the expanded text.
|
||||
name_text = text.partition("=")[0].removesuffix("+")
|
||||
raise ExitSignal(1,
|
||||
stderr=(f"bash: {name_text}: "
|
||||
"bad array subscript\n").encode(),
|
||||
contained_code=1)
|
||||
if amap is not None:
|
||||
# The subscript is the key: no arithmetic, `m[1+1]`
|
||||
# writes the key "1+1".
|
||||
new_map = dict(amap)
|
||||
new_map[sub_text] = (amap.get(sub_text, "") +
|
||||
val) if append else val
|
||||
await _assign_var(view, key, new_map)
|
||||
code = assignment_status(session, sub_seq)
|
||||
return None, IOResult(exit_code=code), ExecutionNode(
|
||||
command=text, exit_code=code)
|
||||
arr = session.arrays.get(key)
|
||||
if arr is None:
|
||||
scalar = session.env.get(key)
|
||||
arr = [] if scalar is None else [scalar]
|
||||
else:
|
||||
arr = list(arr)
|
||||
idx = _array_index(sub_text, visible_env(session),
|
||||
session_elements(session))
|
||||
if idx < 0:
|
||||
idx += array_extent(arr)
|
||||
if idx < 0:
|
||||
# Same fatal shape as the empty subscript above.
|
||||
name_text = text.partition("=")[0].removesuffix("+")
|
||||
raise ExitSignal(1,
|
||||
stderr=(f"bash: {name_text}: "
|
||||
"bad array subscript\n").encode(),
|
||||
contained_code=1)
|
||||
array_set(arr, idx, array_get(arr, idx) + val if append else val)
|
||||
await _assign_var(view, key, arr)
|
||||
code = assignment_status(session, sub_seq)
|
||||
return None, IOResult(exit_code=code), ExecutionNode(command=text,
|
||||
exit_code=code)
|
||||
held_map = session.assocs.get(key)
|
||||
held_arr = session.arrays.get(key)
|
||||
if held_map is not None:
|
||||
# `m=x` on an associative array writes the literal key "0"
|
||||
# and keeps every other key, as bash does.
|
||||
new_map = dict(held_map)
|
||||
new_map["0"] = (held_map.get("0", "") + val) if append else val
|
||||
await _assign_var(view, key, new_map)
|
||||
elif held_arr is not None:
|
||||
# `a=x` writes element 0 and keeps the rest; `a+=x` appends
|
||||
# onto element 0.
|
||||
new_arr = list(held_arr)
|
||||
array_set(new_arr, 0, (array_get(new_arr, 0) + val) if append else val)
|
||||
await _assign_var(view, key, new_arr)
|
||||
else:
|
||||
held_var = session.vars.get(key)
|
||||
if (append and held_var is not None
|
||||
and VarAttr.INTEGER in held_var.attrs):
|
||||
# `n+=3` on an integer name adds: the door evaluates
|
||||
# `old + new`, so `declare -i n=5; n+=3` stores 8, not 53.
|
||||
new_val = f"{session.env.get(key, '0')} + ({val})"
|
||||
else:
|
||||
new_val = session.env.get(key, "") + val if append else val
|
||||
await _assign_var(view, key, new_val)
|
||||
# Reassigning OPTIND (even to its current value) restarts the
|
||||
# getopts scan, matching bash's internal char pointer.
|
||||
if key == "OPTIND":
|
||||
session._getopts_optind = None
|
||||
code = assignment_status(session, sub_seq)
|
||||
io = IOResult(exit_code=code)
|
||||
if session.shell_options.get("xtrace"):
|
||||
io.stderr = trace_assignment(key, val, append)
|
||||
return None, io, ExecutionNode(command=text, exit_code=code)
|
||||
@@ -0,0 +1,544 @@
|
||||
# ========= 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. =========
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.ops.types import SessionView
|
||||
from mirage.policy import PolicyDenied
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.errors import ExitSignal
|
||||
from mirage.shell.helpers import get_declaration_keyword, get_text
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.variable import VarAttr
|
||||
from mirage.workspace.expand import expand_node
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.node.assignment import expand_array_items
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.session.state import (ensure_var_visible, seed_var,
|
||||
session_view, set_attr)
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
from mirage.workspace.executor.builtins import ( # isort: skip
|
||||
handle_declare_functions, handle_declare_print, handle_export,
|
||||
handle_local, handle_readonly, note_local_array)
|
||||
|
||||
|
||||
def _merge_conversion_errors(
|
||||
result: tuple[Any, IOResult, ExecutionNode],
|
||||
errors: list[str],
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Fold kind-conversion refusals into a declaration's result.
|
||||
|
||||
GNU reports `cannot convert indexed to associative array` per
|
||||
refused name on stderr and fails the builtin with 1 while the other
|
||||
operands still declare, so the refusals ride the handler's own
|
||||
result rather than replacing it.
|
||||
|
||||
Args:
|
||||
result (tuple): the handler's (stream, io, node) answer.
|
||||
errors (list[str]): the refusal lines, in operand order.
|
||||
"""
|
||||
if not errors:
|
||||
return result
|
||||
stream, io, node = result
|
||||
extra = ("\n".join(errors) + "\n").encode()
|
||||
prior = io.stderr if isinstance(io.stderr, bytes) else b""
|
||||
merged = prior + extra
|
||||
new_io = IOResult(exit_code=1,
|
||||
stderr=merged,
|
||||
reads=io.reads,
|
||||
writes=io.writes,
|
||||
cache=io.cache)
|
||||
new_node = ExecutionNode(command=node.command, exit_code=1, stderr=merged)
|
||||
return stream, new_io, new_node
|
||||
|
||||
|
||||
# Every letter GNU's `declare` accepts, so a typo refuses with the usage
|
||||
# line instead of being silently dropped. `-a`/`-A` are kinds, not
|
||||
# attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
|
||||
# /`-I` are modes the handlers read. `-n` stores the reference and every
|
||||
# reader and writer resolves through it (`deref` in `session/state`).
|
||||
_DECLARE_LETTERS = frozenset("aAfFgiIlnprtux")
|
||||
_DECLARE_USAGE = (
|
||||
"declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] "
|
||||
"or declare -p [-aAfFilnrtux] [name ...]")
|
||||
# The stored attributes a `-letter` / `+letter` toggles.
|
||||
_ATTR_LETTERS = {
|
||||
"i": VarAttr.INTEGER,
|
||||
"l": VarAttr.LOWER,
|
||||
"u": VarAttr.UPPER,
|
||||
"n": VarAttr.NAMEREF,
|
||||
"t": VarAttr.TRACE,
|
||||
"x": VarAttr.EXPORT,
|
||||
"r": VarAttr.READONLY,
|
||||
}
|
||||
|
||||
|
||||
def _declare_option_refusal(
|
||||
cmd: str,
|
||||
flag_chars: set[str],
|
||||
plus_chars: set[str],
|
||||
session: Session,
|
||||
) -> tuple[Any, IOResult, ExecutionNode] | None:
|
||||
"""The refusal a `declare` family option cluster earns, if any.
|
||||
|
||||
An unknown letter is GNU's `invalid option` plus the usage line,
|
||||
exit 2, and it wins over every other check because bash refuses
|
||||
the cluster before it looks at a single operand.
|
||||
|
||||
Args:
|
||||
cmd (str): the builtin's own name for the diagnostic.
|
||||
flag_chars (set[str]): the `-` letters, `--` excluded.
|
||||
plus_chars (set[str]): the `+` letters.
|
||||
session (Session): shell session state (unused today, kept so
|
||||
a later check that reads it does not change the signature).
|
||||
"""
|
||||
bad = next((c for c in sorted(flag_chars | plus_chars)
|
||||
if c not in _DECLARE_LETTERS), None)
|
||||
if bad is None:
|
||||
return None
|
||||
sign = "-" if bad in flag_chars else "+"
|
||||
err = (f"bash: {cmd}: {sign}{bad}: invalid option\n"
|
||||
f"{_DECLARE_USAGE}\n").encode()
|
||||
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(command=cmd,
|
||||
exit_code=2,
|
||||
stderr=err)
|
||||
|
||||
|
||||
async def _plus_refusals(
|
||||
cmd: str,
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
plus_chars: set[str],
|
||||
assignments: list[str],
|
||||
staged: list[tuple[str, bool, list[str]]] | None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode] | None:
|
||||
"""The per-name refusals a `+letter` earns after the operands are
|
||||
known.
|
||||
|
||||
Two letters cannot be taken off. `+r` on a readonly name is
|
||||
`declare: R: readonly variable`, exit 1, and the name stays frozen.
|
||||
`+a` / `+A` on an array is `cannot destroy array variables in this
|
||||
way`, exit 1, since the kind is what the value is, not a mark. Both
|
||||
are pinned on 5.2.37 and neither stops the other operands from
|
||||
declaring; the first refusal is what the builtin reports.
|
||||
|
||||
Args:
|
||||
cmd (str): the builtin's own name for the diagnostic.
|
||||
session (Session): shell session state.
|
||||
view (SessionView): the session plane's gated door.
|
||||
plus_chars (set[str]): the `+` letters.
|
||||
assignments (list[str]): `NAME` / `NAME=value` operands.
|
||||
staged (list[tuple[str, bool, list[str]]] | None): staged array
|
||||
literals from the same declaration.
|
||||
"""
|
||||
if not (plus_chars & {"r", "a", "A"}):
|
||||
return None
|
||||
names = [a.partition("=")[0] for a in assignments]
|
||||
names += [name for name, _, _ in staged or []]
|
||||
for name in names:
|
||||
if "r" in plus_chars and view.is_readonly(name):
|
||||
err = f"bash: {cmd}: {name}: readonly variable\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
if (("a" in plus_chars and name in session.arrays)
|
||||
or ("A" in plus_chars and name in session.assocs)):
|
||||
err = (f"bash: {cmd}: {name}: cannot destroy array variables "
|
||||
"in this way\n").encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return None
|
||||
|
||||
|
||||
async def _stamp_attrs(
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
flag_chars: set[str],
|
||||
plus_chars: set[str],
|
||||
assignments: list[str],
|
||||
staged: list[tuple[str, bool, list[str]]] | None,
|
||||
stored: list[str],
|
||||
) -> tuple[Any, IOResult, ExecutionNode] | None:
|
||||
"""Apply every `-attr` / `+attr` letter to the names a declaration
|
||||
stored, on top of the export stamp.
|
||||
|
||||
The letters that shape a value (`-i -l -u`) are stored as
|
||||
attributes and applied by the door on every *later* write, which is
|
||||
GNU's rule: `v=MiXeD; declare -l v` keeps `MiXeD`, and the next
|
||||
`v=ABC` stores `abc`. So this stamps and never rewrites. `-l` and
|
||||
`-u` are exclusive: setting one clears the other, and a cluster
|
||||
naming both (`-lu`, `-ul`) sets neither, both pinned on 5.2.37.
|
||||
A `+` letter clears; `+r` is refused by the door as a readonly write
|
||||
would be, in the builtin's voice.
|
||||
|
||||
Args:
|
||||
session (Session): shell session state.
|
||||
view (SessionView): the session plane's gated door.
|
||||
flag_chars (set[str]): the `-` letters.
|
||||
plus_chars (set[str]): the `+` letters.
|
||||
assignments (list[str]): `NAME` / `NAME=value` operands.
|
||||
staged (list[tuple[str, bool, list[str]]] | None): staged array
|
||||
literals from the same declaration.
|
||||
stored (list[str]): the names the handler actually stored.
|
||||
"""
|
||||
refused = await _stamp_export(session, view, flag_chars, assignments,
|
||||
staged, stored)
|
||||
if refused is not None:
|
||||
return refused
|
||||
on_attrs = [
|
||||
_ATTR_LETTERS[c] for c in "ilunt"
|
||||
if c in flag_chars and c not in plus_chars
|
||||
]
|
||||
if "l" in flag_chars and "u" in flag_chars:
|
||||
on_attrs = [
|
||||
a for a in on_attrs if a not in (VarAttr.LOWER, VarAttr.UPPER)
|
||||
]
|
||||
# `+r` is refused earlier on a readonly name and a no-op otherwise,
|
||||
# so it is not an off toggle; every other stored letter clears.
|
||||
off_attrs = [_ATTR_LETTERS[c] for c in "iluntx" if c in plus_chars]
|
||||
if not on_attrs and not off_attrs:
|
||||
return None
|
||||
# Through the gated mark door for every name, covered or not: the
|
||||
# handler already cleared the gate for these names, so this is one
|
||||
# redundant policy call per attribute, and it keeps this stamp out
|
||||
# of the ungated-write allowlist that `set_attr` sites must justify.
|
||||
try:
|
||||
for name in stored:
|
||||
for attr in on_attrs:
|
||||
await view.mark(name, attr, True)
|
||||
# `-l` displaces `-u` and vice versa; the record keeps one.
|
||||
if attr == VarAttr.LOWER:
|
||||
await view.mark(name, VarAttr.UPPER, False)
|
||||
elif attr == VarAttr.UPPER:
|
||||
await view.mark(name, VarAttr.LOWER, False)
|
||||
for attr in off_attrs:
|
||||
await view.mark(name, attr, False)
|
||||
except PolicyDenied as exc:
|
||||
err = f"{exc.strerror}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command="declare",
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return None
|
||||
|
||||
|
||||
async def _stamp_export(
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
flag_chars: set[str],
|
||||
assignments: list[str],
|
||||
staged: list[tuple[str, bool, list[str]]] | None,
|
||||
stored: list[str],
|
||||
) -> tuple[Any, IOResult, ExecutionNode] | None:
|
||||
"""Mark every name a `-x` declaration stored as exported.
|
||||
|
||||
`declare -x NAME` marks an existing name without touching its value
|
||||
and `declare -x NAME=v` assigns then marks, so the stamp lands after
|
||||
the assignment either way. Staged array literals are stamped too,
|
||||
since an array is as exportable as a scalar: GNU answers
|
||||
`declare -x A=(a b)` with `declare -ax A=([0]="a" [1]="b")`, and
|
||||
reading only `assignments` left every `declare -x NAME=(...)`
|
||||
unmarked.
|
||||
|
||||
Shared by the readonly and the plain declaration branch because
|
||||
`declare -rx X=1` goes down the readonly one and still owes the
|
||||
export attribute.
|
||||
|
||||
Only the names the handler reports storing are marked, and marking
|
||||
is not gated on the aggregate status: a declaration keeps its valid
|
||||
operands when a sibling refuses, so `declare -x GOOD=1 1BAD=x` exits
|
||||
1 and still answers `declare -x GOOD="1"`. Reading the exit code
|
||||
instead left `GOOD` unexported.
|
||||
|
||||
A name that carried a value went through `view.set`, so its mark
|
||||
rides on that decision; a bare name did not, and on an *existing*
|
||||
name the handler writes nothing at all, so the mark is the only
|
||||
session write there is and has to clear `pre_session` itself.
|
||||
Stamping it through `set_attr` let `declare -x AWS_TOKEN` export a
|
||||
host-seeded credential the deployment had refused.
|
||||
|
||||
Args:
|
||||
session (Session): shell session state.
|
||||
view (SessionView): the session plane's gated door.
|
||||
flag_chars (set[str]): the declaration's collected flag letters.
|
||||
assignments (list[str]): `NAME` / `NAME=value` operands.
|
||||
staged (list[tuple[str, bool, list[str]]] | None): staged array
|
||||
literals from the same declaration.
|
||||
stored (list[str]): the names the handler actually stored.
|
||||
|
||||
Returns:
|
||||
A refusal result when the gate denied a mark, else None.
|
||||
"""
|
||||
if "x" not in flag_chars:
|
||||
return None
|
||||
covered = {a.partition("=")[0] for a in assignments if "=" in a}
|
||||
covered |= {name for name, _, _ in staged or []}
|
||||
for name in stored:
|
||||
if name in covered:
|
||||
set_attr(session, name, VarAttr.EXPORT)
|
||||
continue
|
||||
try:
|
||||
await view.mark(name, VarAttr.EXPORT, True)
|
||||
except PolicyDenied as exc:
|
||||
err = f"{exc.strerror}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command="declare",
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return None
|
||||
|
||||
|
||||
async def execute_declaration(
|
||||
node: Any,
|
||||
session: Session,
|
||||
execute_fn: Callable[..., Any],
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
cs: CallStack | None,
|
||||
view: SessionView,
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Execute one declaration statement (export/local/declare/readonly).
|
||||
|
||||
The executor only reads the operands: it expands them, sorts them
|
||||
into option letters, plain names and staged array literals, then
|
||||
hands the result to the builtin handler that owns the keyword. The
|
||||
attribute letters (`-x`, `-i`, `-l`) are stamped afterwards through
|
||||
the same gated door, so `declare -rx X=1` keeps both marks.
|
||||
|
||||
Args:
|
||||
node (Any): the tree-sitter ``declaration_command`` node.
|
||||
session (Session): shell session state.
|
||||
execute_fn (Callable): recursive execute for substitutions.
|
||||
registry (MountRegistry): mount registry for glob resolution.
|
||||
namespace (Namespace): addressing authority holding the links.
|
||||
cs (CallStack | None): function-call scope, if any.
|
||||
view (SessionView): the session plane's gated door, bound once
|
||||
for the line so a pre_session rule governs an
|
||||
expansion-time write exactly as it governs `X=d`.
|
||||
"""
|
||||
keyword = get_declaration_keyword(node)
|
||||
assignments = []
|
||||
# Array literals are staged, not stored: `readonly -a a=(y)` on an
|
||||
# already-readonly name has to fail with the old value intact.
|
||||
staged: list[tuple[str, bool, list[str]]] = []
|
||||
# Option words are kept verbatim, in order, so `--` survives as an
|
||||
# end-of-options marker and the handlers can name the *first* bad
|
||||
# option letter the way bash does.
|
||||
flag_words: list[str] = []
|
||||
flag_chars: set[str] = set()
|
||||
plus_chars: set[str] = set()
|
||||
opts_done = False
|
||||
for child in node.named_children:
|
||||
if child.type == NT.VARIABLE_ASSIGNMENT:
|
||||
val_nodes = [
|
||||
c for c in child.named_children if c.type != NT.VARIABLE_NAME
|
||||
]
|
||||
if val_nodes and val_nodes[0].type == NT.ARRAY:
|
||||
key = get_text(child).partition("=")[0]
|
||||
items = await expand_array_items(val_nodes[0], session,
|
||||
execute_fn, registry,
|
||||
namespace, cs)
|
||||
staged.append(
|
||||
(key.removesuffix("+"), key.endswith("+"), items))
|
||||
continue
|
||||
expanded = await expand_node(child,
|
||||
session,
|
||||
execute_fn,
|
||||
cs,
|
||||
view=view)
|
||||
assignments.append(expanded)
|
||||
elif child.type in (NT.SIMPLE_EXPANSION, NT.EXPANSION,
|
||||
NT.CONCATENATION, NT.WORD, NT.VARIABLE_NAME,
|
||||
NT.STRING, NT.RAW_STRING, NT.ANSI_C_STRING,
|
||||
NT.TRANSLATED_STRING):
|
||||
# A bare `readonly NAME` / `export NAME` operand parses as
|
||||
# a variable_name, not a word, and a quoted assignment
|
||||
# (`export 'FOO=bar'`) as a plain string operand.
|
||||
expanded = await expand_node(child,
|
||||
session,
|
||||
execute_fn,
|
||||
cs,
|
||||
view=view)
|
||||
if not expanded and child.type in (NT.SIMPLE_EXPANSION,
|
||||
NT.EXPANSION):
|
||||
# An *unquoted* expansion that came back empty is
|
||||
# removed by word splitting, so `export $UNSET` is a
|
||||
# bare `export` and prints the listing. A quoted one
|
||||
# is a real, empty operand: GNU answers both
|
||||
# `export ""` and `export "$UNSET"` with
|
||||
# ``export: `': not a valid identifier``, so it has
|
||||
# to reach the builtin rather than vanish here.
|
||||
continue
|
||||
if (not opts_done and expanded.startswith("-")
|
||||
and len(expanded) > 1):
|
||||
flag_words.append(expanded)
|
||||
if expanded == "--":
|
||||
opts_done = True
|
||||
else:
|
||||
flag_chars.update(expanded[1:])
|
||||
elif (not opts_done and expanded.startswith("+")
|
||||
and len(expanded) > 1
|
||||
and keyword in (NT.LOCAL, "declare", "typeset")):
|
||||
# `+attr` turns an attribute off. Only the declare
|
||||
# family reads it: `export +x` and `readonly +r` are
|
||||
# `not a valid identifier` in GNU, so for those two
|
||||
# the word falls through as an operand and refuses
|
||||
# there.
|
||||
plus_chars.update(expanded[1:])
|
||||
else:
|
||||
assignments.append(expanded)
|
||||
cmd_word = "local" if keyword == NT.LOCAL else str(keyword)
|
||||
if keyword in (NT.LOCAL, "declare", "typeset"):
|
||||
refused = _declare_option_refusal(cmd_word, flag_chars, plus_chars,
|
||||
session)
|
||||
if refused is not None:
|
||||
return refused
|
||||
if (("f" in flag_chars or "F" in flag_chars)
|
||||
and keyword in (NT.LOCAL, "declare", "typeset")):
|
||||
# `-f`/`-F` select functions, not variables: `-rf` freezes,
|
||||
# `-f NAME` prints the body, `-F NAME` prints the name, and
|
||||
# a missing name is exit 1 without a word.
|
||||
return handle_declare_functions(cmd_word, session, flag_chars,
|
||||
assignments)
|
||||
is_readonly = keyword == "readonly" or "r" in flag_chars
|
||||
# `-l` and `-u` cannot both hold; a cluster naming both sets
|
||||
# neither (pinned: `declare -lu s=aBc` prints `declare -- s`).
|
||||
shaping = frozenset(_ATTR_LETTERS[c] for c in "ilu"
|
||||
if c in flag_chars and c not in plus_chars)
|
||||
if VarAttr.LOWER in shaping and VarAttr.UPPER in shaping:
|
||||
shaping = shaping - {VarAttr.LOWER, VarAttr.UPPER}
|
||||
conversion_errors: list[str] = []
|
||||
if "A" in flag_chars or "a" in flag_chars:
|
||||
# `declare -a NAME` / `declare -A NAME` with no value declare
|
||||
# an empty array of that kind, so ${#NAME[@]} is 0 and an
|
||||
# element write leaves the other slots unassigned. GNU
|
||||
# refuses to convert between the two kinds and says so per
|
||||
# name while the rest of the operands still declare.
|
||||
want_assoc = "A" in flag_chars
|
||||
for bare in assignments:
|
||||
if "=" in bare:
|
||||
continue
|
||||
# Both branches below write array storage raw (the
|
||||
# top-level one migrates an existing scalar), so a
|
||||
# hidden name refuses like any assignment spelling
|
||||
# before either lands.
|
||||
try:
|
||||
ensure_var_visible(session, bare)
|
||||
except PolicyDenied as exc:
|
||||
err = f"{exc.strerror}\n".encode()
|
||||
raise ExitSignal(1, stderr=err, contained_code=1) from exc
|
||||
if want_assoc and bare in session.arrays:
|
||||
conversion_errors.append(
|
||||
f"bash: {cmd_word}: {bare}: cannot convert indexed "
|
||||
"to associative array")
|
||||
continue
|
||||
if not want_assoc and bare in session.assocs:
|
||||
conversion_errors.append(
|
||||
f"bash: {cmd_word}: {bare}: cannot convert "
|
||||
"associative to indexed array")
|
||||
continue
|
||||
if "g" not in flag_chars and note_local_array(session, bare):
|
||||
# Inside a function this shadows whatever the caller
|
||||
# had with a fresh empty array of the declared kind;
|
||||
# `-g` declares at global scope instead.
|
||||
seed_var(session, bare, {} if want_assoc else [])
|
||||
elif want_assoc and bare not in session.assocs:
|
||||
# At top level an existing scalar becomes the value
|
||||
# at the literal key "0" (GNU allows scalar-to-
|
||||
# associative conversion, unlike indexed).
|
||||
scalar = session.env.get(bare)
|
||||
seed_var(session, bare,
|
||||
{} if scalar is None else {"0": scalar})
|
||||
elif not want_assoc and bare not in session.arrays:
|
||||
# At top level an existing scalar becomes element 0.
|
||||
scalar = session.env.get(bare)
|
||||
seed_var(session, bare, [] if scalar is None else [scalar])
|
||||
# Array literals travel as data: the handler stores them through
|
||||
# the session door and owns both refusal voices, so the executor
|
||||
# only expands and stages.
|
||||
if is_readonly:
|
||||
decl_view = session_view(session, namespace.registry.policies)
|
||||
stored: list[str] = []
|
||||
# Only the `readonly` keyword owns -p / illegal-option
|
||||
# handling; `declare -r` keeps names only.
|
||||
if keyword == "readonly":
|
||||
result = await handle_readonly(flag_words + assignments,
|
||||
session,
|
||||
decl_view,
|
||||
arrays=staged,
|
||||
stored=stored,
|
||||
assoc="A" in flag_chars,
|
||||
shaping=shaping)
|
||||
else:
|
||||
result = await handle_readonly(assignments,
|
||||
session,
|
||||
decl_view,
|
||||
arrays=staged,
|
||||
stored=stored,
|
||||
assoc="A" in flag_chars,
|
||||
shaping=shaping)
|
||||
# `declare -rx X=1` carries both attributes: GNU prints
|
||||
# `declare -rx X="1"`. Readonly answers first, so the export
|
||||
# stamp has to land here too, or `-r` silently ate the `-x`.
|
||||
refused = await _stamp_attrs(session, decl_view, flag_chars,
|
||||
plus_chars, assignments, staged, stored)
|
||||
if refused is not None:
|
||||
return refused
|
||||
return _merge_conversion_errors(result, conversion_errors)
|
||||
# declare/typeset scope like `local` inside a function (bash
|
||||
# semantics) and assign globally at top level, which is exactly
|
||||
# handle_local's fallback when no function scope is active.
|
||||
if keyword in (NT.LOCAL, "declare", "typeset"):
|
||||
# `-p` prints rather than declares, so it is answered before
|
||||
# the assignment path runs at all.
|
||||
if (("p" in flag_chars or "p" in plus_chars)
|
||||
and keyword in ("declare", "typeset")):
|
||||
return await handle_declare_print(assignments, session)
|
||||
decl_view = session_view(session, namespace.registry.policies)
|
||||
stored = []
|
||||
result = await handle_local(
|
||||
assignments,
|
||||
session,
|
||||
decl_view,
|
||||
arrays=staged,
|
||||
# `declare`/`typeset` share this handler but have to name
|
||||
# themselves in a diagnostic rather than say `local`.
|
||||
cmd=cmd_word,
|
||||
stored=stored,
|
||||
assoc="A" in flag_chars,
|
||||
shaping=shaping,
|
||||
nameref="n" in flag_chars and "n" not in plus_chars,
|
||||
global_scope="g" in flag_chars)
|
||||
plus_refused = await _plus_refusals(cmd_word, session, decl_view,
|
||||
plus_chars, assignments, staged)
|
||||
if plus_refused is not None:
|
||||
return plus_refused
|
||||
refused = await _stamp_attrs(session, decl_view, flag_chars,
|
||||
plus_chars, assignments, staged, stored)
|
||||
if refused is not None:
|
||||
return refused
|
||||
return _merge_conversion_errors(result, conversion_errors)
|
||||
# Pass export flags through so -p / bare print and bad options work.
|
||||
result = await handle_export(flag_words + assignments,
|
||||
session,
|
||||
session_view(session,
|
||||
namespace.registry.policies),
|
||||
arrays=staged)
|
||||
return _merge_conversion_errors(result, conversion_errors)
|
||||
@@ -13,7 +13,6 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from functools import partial
|
||||
from typing import Any, Callable
|
||||
|
||||
@@ -24,21 +23,17 @@ from mirage.policy import PolicyDenied
|
||||
from mirage.runtime.policy import PolicyDecision
|
||||
from mirage.runtime.types import DispatchFn
|
||||
from mirage.shell.arith import evaluate_arith
|
||||
from mirage.shell.array import (array_extent, array_get, array_set,
|
||||
build_assoc_literal, build_indexed_literal)
|
||||
from mirage.shell.barrier import BarrierPolicy, apply_barrier
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.console import Channel, JobConsole
|
||||
from mirage.shell.errors import ArithError, ExitSignal, ReadonlyError
|
||||
from mirage.shell.errors import ArithError, ReadonlyError
|
||||
from mirage.shell.job_table import JobTable
|
||||
from mirage.shell.node_kind import NodeKind, node_kind
|
||||
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.types import Redirect, RedirectKind
|
||||
from mirage.shell.variable import ShellValue, VarAttr
|
||||
from mirage.shell.xtrace import trace_assignment
|
||||
from mirage.types import word_text
|
||||
from mirage.workspace.abort import MirageAbortError
|
||||
from mirage.workspace.executor.builtins import handle_test, handle_unset
|
||||
from mirage.workspace.executor.builtins.exec_cmd import install_exec_redirects
|
||||
from mirage.workspace.executor.control import (handle_case, handle_cfor,
|
||||
handle_for, handle_if,
|
||||
@@ -55,56 +50,26 @@ from mirage.workspace.expand import (expand_and_classify, expand_node,
|
||||
from mirage.workspace.expand.globs import glob_options, resolve_globs
|
||||
from mirage.workspace.expand.node import expand_arith
|
||||
from mirage.workspace.expand.pattern import expand_pattern
|
||||
from mirage.workspace.expand.variable import _array_index
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.node.assignment import execute_assignment
|
||||
from mirage.workspace.node.command_dispatch import execute_command
|
||||
from mirage.workspace.node.declaration import execute_declaration
|
||||
from mirage.workspace.node.program import execute_program
|
||||
from mirage.workspace.node.test_expr import (expand_double_bracket,
|
||||
expand_test_expr)
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.session.elements import assign_element
|
||||
from mirage.workspace.session.state import (deref, element_index,
|
||||
ensure_var_visible, seed_var,
|
||||
from mirage.workspace.session.state import (ensure_var_visible,
|
||||
session_elements, session_view,
|
||||
set_attr, visible_env)
|
||||
visible_env)
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
from mirage.shell.helpers import ( # isort: skip
|
||||
get_case_items, get_case_word, get_cfor_parts, get_declaration_keyword,
|
||||
get_for_parts, get_function_body, get_function_name, get_if_branches,
|
||||
get_list_parts, get_negated_command, get_pipeline_commands, get_redirects,
|
||||
get_text, get_unset_args, get_while_parts)
|
||||
from mirage.workspace.executor.builtins import ( # isort: skip
|
||||
handle_declare_functions, handle_declare_print, handle_export,
|
||||
handle_local, handle_readonly, handle_test, handle_unset, note_local_array)
|
||||
|
||||
|
||||
async def _assign_var(view: SessionView, key: str, value: ShellValue) -> None:
|
||||
"""One assignment through the session door; denial is fatal.
|
||||
|
||||
Every assignment spelling (scalar, array literal, subscript,
|
||||
append) computes its resulting value and stores through
|
||||
``view.set``, so the gate and the storage invariant live in the
|
||||
door, not here. Denial mirrors the readonly case: a fatal
|
||||
variable-assignment error that abandons the rest of the line.
|
||||
|
||||
Args:
|
||||
view (SessionView): the session plane's gated door.
|
||||
key (str): the variable being written.
|
||||
value (ShellValue): the resulting value to store.
|
||||
"""
|
||||
try:
|
||||
await view.set(key, value)
|
||||
except PolicyDenied as exc:
|
||||
err = f"{exc.strerror}\n".encode()
|
||||
raise ExitSignal(1, stderr=err, contained_code=1) from exc
|
||||
except ArithError as exc:
|
||||
# The `-i` coercion refused the text. GNU aborts the line the
|
||||
# way a bad subscript does, voicing the evaluator's own message
|
||||
# after the offending value: `bash: 1+: syntax error: ...`.
|
||||
err = f"bash: {exc}\n".encode()
|
||||
raise ExitSignal(1, stderr=err, contained_code=1) from exc
|
||||
get_case_items, get_case_word, get_cfor_parts, get_for_parts,
|
||||
get_function_body, get_function_name, get_if_branches, get_list_parts,
|
||||
get_negated_command, get_pipeline_commands, get_redirects, get_text,
|
||||
get_unset_args, get_while_parts)
|
||||
|
||||
|
||||
async def _eval_cfor_expr(
|
||||
@@ -177,49 +142,6 @@ STREAMING_KINDS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
async def _expand_array_items(
|
||||
array_node: Any,
|
||||
session: Session,
|
||||
execute_fn: Callable[..., Any],
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
cs: CallStack | None,
|
||||
) -> list[str]:
|
||||
"""Expand an array literal into its element words.
|
||||
|
||||
Elements behave like any other shell word list: command
|
||||
substitutions word-split and globs resolve to matches
|
||||
(``a=($(cmd) /data/*.txt)``), with zero-match globs kept literal.
|
||||
|
||||
Args:
|
||||
array_node (Any): the tree-sitter ``array`` node.
|
||||
session (Session): shell session.
|
||||
execute_fn (Callable): workspace execute for substitutions.
|
||||
registry (MountRegistry): mount registry for glob resolution.
|
||||
namespace (Namespace): addressing authority holding the links.
|
||||
cs (CallStack | None): function-call scope, if any.
|
||||
"""
|
||||
# The session plane's door, bound once for the line: every
|
||||
# expansion-time write (`${X:=d}`, `$((X=5))`) lands through it,
|
||||
# so a pre_session rule governs those exactly as it governs `X=d`.
|
||||
view = session_view(session, registry.policies)
|
||||
values = list(array_node.named_children)
|
||||
classified = await expand_and_classify(values,
|
||||
session,
|
||||
execute_fn,
|
||||
registry,
|
||||
session.cwd,
|
||||
cs,
|
||||
view=view)
|
||||
resolved = await resolve_globs(classified,
|
||||
registry,
|
||||
noglob=bool(
|
||||
session.shell_options.get("noglob")),
|
||||
links=namespace,
|
||||
options=glob_options(session))
|
||||
return [word_text(w) for w in resolved]
|
||||
|
||||
|
||||
async def _recurse_reassociated(
|
||||
recurse: Callable[..., Any],
|
||||
dispatch: DispatchFn,
|
||||
@@ -310,316 +232,6 @@ async def _recurse_pipe_stderr(
|
||||
return stdout, io, exec_node
|
||||
|
||||
|
||||
_SUBSCRIPT_LITERAL_TYPES = frozenset({NT.WORD, NT.NUMBER, NT.ERROR})
|
||||
|
||||
|
||||
async def _subscript_key_text(
|
||||
subscript_node: Any,
|
||||
name: str,
|
||||
session: Session,
|
||||
execute_fn: Callable[..., Any],
|
||||
cs: CallStack | None,
|
||||
view: SessionView | None,
|
||||
) -> str:
|
||||
"""The expanded subscript text of one ``name[...]=`` assignment.
|
||||
|
||||
A purely literal subscript keeps its raw spelling, spaces included
|
||||
(bash stores ``m[ k ]`` under the key ``" k "``); anything carrying
|
||||
an expansion or quoting expands node by node so ``m[$k]`` and
|
||||
``m["a b"]`` resolve with quote removal. The associative path uses
|
||||
the result as the key verbatim; the indexed path evaluates it as
|
||||
arithmetic.
|
||||
|
||||
Args:
|
||||
subscript_node (Any): the tree-sitter ``subscript`` node.
|
||||
name (str): the array variable's name, for the raw slice.
|
||||
session (Session): shell session state.
|
||||
execute_fn (Callable): evaluator for command substitutions.
|
||||
cs (CallStack | None): shell call stack.
|
||||
view (SessionView | None): the session plane's gated door.
|
||||
"""
|
||||
inner = [
|
||||
sc for sc in subscript_node.named_children
|
||||
if sc.type != NT.VARIABLE_NAME
|
||||
]
|
||||
raw = get_text(subscript_node)[len(name) + 1:-1]
|
||||
if not inner or all(sc.type in _SUBSCRIPT_LITERAL_TYPES for sc in inner):
|
||||
return raw
|
||||
parts = []
|
||||
for sc in inner:
|
||||
parts.append(await expand_node(sc, session, execute_fn, cs, view=view))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _merge_conversion_errors(
|
||||
result: tuple[Any, IOResult, ExecutionNode],
|
||||
errors: list[str],
|
||||
) -> tuple[Any, IOResult, ExecutionNode]:
|
||||
"""Fold kind-conversion refusals into a declaration's result.
|
||||
|
||||
GNU reports `cannot convert indexed to associative array` per
|
||||
refused name on stderr and fails the builtin with 1 while the other
|
||||
operands still declare, so the refusals ride the handler's own
|
||||
result rather than replacing it.
|
||||
|
||||
Args:
|
||||
result (tuple): the handler's (stream, io, node) answer.
|
||||
errors (list[str]): the refusal lines, in operand order.
|
||||
"""
|
||||
if not errors:
|
||||
return result
|
||||
stream, io, node = result
|
||||
extra = ("\n".join(errors) + "\n").encode()
|
||||
prior = io.stderr if isinstance(io.stderr, bytes) else b""
|
||||
merged = prior + extra
|
||||
new_io = IOResult(exit_code=1,
|
||||
stderr=merged,
|
||||
reads=io.reads,
|
||||
writes=io.writes,
|
||||
cache=io.cache)
|
||||
new_node = ExecutionNode(command=node.command, exit_code=1, stderr=merged)
|
||||
return stream, new_io, new_node
|
||||
|
||||
|
||||
# Every letter GNU's `declare` accepts, so a typo refuses with the usage
|
||||
# line instead of being silently dropped. `-a`/`-A` are kinds, not
|
||||
# attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
|
||||
# /`-I` are modes the handlers read. `-n` stores the reference and every
|
||||
# reader and writer resolves through it (`deref` in `session/state`).
|
||||
_DECLARE_LETTERS = frozenset("aAfFgiIlnprtux")
|
||||
_DECLARE_USAGE = (
|
||||
"declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] "
|
||||
"or declare -p [-aAfFilnrtux] [name ...]")
|
||||
# The stored attributes a `-letter` / `+letter` toggles.
|
||||
_ATTR_LETTERS = {
|
||||
"i": VarAttr.INTEGER,
|
||||
"l": VarAttr.LOWER,
|
||||
"u": VarAttr.UPPER,
|
||||
"n": VarAttr.NAMEREF,
|
||||
"t": VarAttr.TRACE,
|
||||
"x": VarAttr.EXPORT,
|
||||
"r": VarAttr.READONLY,
|
||||
}
|
||||
|
||||
|
||||
def _declare_option_refusal(
|
||||
cmd: str,
|
||||
flag_chars: set[str],
|
||||
plus_chars: set[str],
|
||||
session: Session,
|
||||
) -> tuple[Any, IOResult, ExecutionNode] | None:
|
||||
"""The refusal a `declare` family option cluster earns, if any.
|
||||
|
||||
An unknown letter is GNU's `invalid option` plus the usage line,
|
||||
exit 2, and it wins over every other check because bash refuses
|
||||
the cluster before it looks at a single operand.
|
||||
|
||||
Args:
|
||||
cmd (str): the builtin's own name for the diagnostic.
|
||||
flag_chars (set[str]): the `-` letters, `--` excluded.
|
||||
plus_chars (set[str]): the `+` letters.
|
||||
session (Session): shell session state (unused today, kept so
|
||||
a later check that reads it does not change the signature).
|
||||
"""
|
||||
bad = next((c for c in sorted(flag_chars | plus_chars)
|
||||
if c not in _DECLARE_LETTERS), None)
|
||||
if bad is None:
|
||||
return None
|
||||
sign = "-" if bad in flag_chars else "+"
|
||||
err = (f"bash: {cmd}: {sign}{bad}: invalid option\n"
|
||||
f"{_DECLARE_USAGE}\n").encode()
|
||||
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(command=cmd,
|
||||
exit_code=2,
|
||||
stderr=err)
|
||||
|
||||
|
||||
async def _plus_refusals(
|
||||
cmd: str,
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
plus_chars: set[str],
|
||||
assignments: list[str],
|
||||
staged: list[tuple[str, bool, list[str]]] | None,
|
||||
) -> tuple[Any, IOResult, ExecutionNode] | None:
|
||||
"""The per-name refusals a `+letter` earns after the operands are
|
||||
known.
|
||||
|
||||
Two letters cannot be taken off. `+r` on a readonly name is
|
||||
`declare: R: readonly variable`, exit 1, and the name stays frozen.
|
||||
`+a` / `+A` on an array is `cannot destroy array variables in this
|
||||
way`, exit 1, since the kind is what the value is, not a mark. Both
|
||||
are pinned on 5.2.37 and neither stops the other operands from
|
||||
declaring; the first refusal is what the builtin reports.
|
||||
|
||||
Args:
|
||||
cmd (str): the builtin's own name for the diagnostic.
|
||||
session (Session): shell session state.
|
||||
view (SessionView): the session plane's gated door.
|
||||
plus_chars (set[str]): the `+` letters.
|
||||
assignments (list[str]): `NAME` / `NAME=value` operands.
|
||||
staged (list[tuple[str, bool, list[str]]] | None): staged array
|
||||
literals from the same declaration.
|
||||
"""
|
||||
if not (plus_chars & {"r", "a", "A"}):
|
||||
return None
|
||||
names = [a.partition("=")[0] for a in assignments]
|
||||
names += [name for name, _, _ in staged or []]
|
||||
for name in names:
|
||||
if "r" in plus_chars and view.is_readonly(name):
|
||||
err = f"bash: {cmd}: {name}: readonly variable\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
if (("a" in plus_chars and name in session.arrays)
|
||||
or ("A" in plus_chars and name in session.assocs)):
|
||||
err = (f"bash: {cmd}: {name}: cannot destroy array variables "
|
||||
"in this way\n").encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command=cmd,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return None
|
||||
|
||||
|
||||
async def _stamp_attrs(
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
flag_chars: set[str],
|
||||
plus_chars: set[str],
|
||||
assignments: list[str],
|
||||
staged: list[tuple[str, bool, list[str]]] | None,
|
||||
stored: list[str],
|
||||
) -> tuple[Any, IOResult, ExecutionNode] | None:
|
||||
"""Apply every `-attr` / `+attr` letter to the names a declaration
|
||||
stored, on top of the export stamp.
|
||||
|
||||
The letters that shape a value (`-i -l -u`) are stored as
|
||||
attributes and applied by the door on every *later* write, which is
|
||||
GNU's rule: `v=MiXeD; declare -l v` keeps `MiXeD`, and the next
|
||||
`v=ABC` stores `abc`. So this stamps and never rewrites. `-l` and
|
||||
`-u` are exclusive: setting one clears the other, and a cluster
|
||||
naming both (`-lu`, `-ul`) sets neither, both pinned on 5.2.37.
|
||||
A `+` letter clears; `+r` is refused by the door as a readonly write
|
||||
would be, in the builtin's voice.
|
||||
|
||||
Args:
|
||||
session (Session): shell session state.
|
||||
view (SessionView): the session plane's gated door.
|
||||
flag_chars (set[str]): the `-` letters.
|
||||
plus_chars (set[str]): the `+` letters.
|
||||
assignments (list[str]): `NAME` / `NAME=value` operands.
|
||||
staged (list[tuple[str, bool, list[str]]] | None): staged array
|
||||
literals from the same declaration.
|
||||
stored (list[str]): the names the handler actually stored.
|
||||
"""
|
||||
refused = await _stamp_export(session, view, flag_chars, assignments,
|
||||
staged, stored)
|
||||
if refused is not None:
|
||||
return refused
|
||||
on_attrs = [
|
||||
_ATTR_LETTERS[c] for c in "ilunt"
|
||||
if c in flag_chars and c not in plus_chars
|
||||
]
|
||||
if "l" in flag_chars and "u" in flag_chars:
|
||||
on_attrs = [
|
||||
a for a in on_attrs if a not in (VarAttr.LOWER, VarAttr.UPPER)
|
||||
]
|
||||
# `+r` is refused earlier on a readonly name and a no-op otherwise,
|
||||
# so it is not an off toggle; every other stored letter clears.
|
||||
off_attrs = [_ATTR_LETTERS[c] for c in "iluntx" if c in plus_chars]
|
||||
if not on_attrs and not off_attrs:
|
||||
return None
|
||||
# Through the gated mark door for every name, covered or not: the
|
||||
# handler already cleared the gate for these names, so this is one
|
||||
# redundant policy call per attribute, and it keeps this stamp out
|
||||
# of the ungated-write allowlist that `set_attr` sites must justify.
|
||||
try:
|
||||
for name in stored:
|
||||
for attr in on_attrs:
|
||||
await view.mark(name, attr, True)
|
||||
# `-l` displaces `-u` and vice versa; the record keeps one.
|
||||
if attr == VarAttr.LOWER:
|
||||
await view.mark(name, VarAttr.UPPER, False)
|
||||
elif attr == VarAttr.UPPER:
|
||||
await view.mark(name, VarAttr.LOWER, False)
|
||||
for attr in off_attrs:
|
||||
await view.mark(name, attr, False)
|
||||
except PolicyDenied as exc:
|
||||
err = f"{exc.strerror}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command="declare",
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return None
|
||||
|
||||
|
||||
async def _stamp_export(
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
flag_chars: set[str],
|
||||
assignments: list[str],
|
||||
staged: list[tuple[str, bool, list[str]]] | None,
|
||||
stored: list[str],
|
||||
) -> tuple[Any, IOResult, ExecutionNode] | None:
|
||||
"""Mark every name a `-x` declaration stored as exported.
|
||||
|
||||
`declare -x NAME` marks an existing name without touching its value
|
||||
and `declare -x NAME=v` assigns then marks, so the stamp lands after
|
||||
the assignment either way. Staged array literals are stamped too,
|
||||
since an array is as exportable as a scalar: GNU answers
|
||||
`declare -x A=(a b)` with `declare -ax A=([0]="a" [1]="b")`, and
|
||||
reading only `assignments` left every `declare -x NAME=(...)`
|
||||
unmarked.
|
||||
|
||||
Shared by the readonly and the plain declaration branch because
|
||||
`declare -rx X=1` goes down the readonly one and still owes the
|
||||
export attribute.
|
||||
|
||||
Only the names the handler reports storing are marked, and marking
|
||||
is not gated on the aggregate status: a declaration keeps its valid
|
||||
operands when a sibling refuses, so `declare -x GOOD=1 1BAD=x` exits
|
||||
1 and still answers `declare -x GOOD="1"`. Reading the exit code
|
||||
instead left `GOOD` unexported.
|
||||
|
||||
A name that carried a value went through `view.set`, so its mark
|
||||
rides on that decision; a bare name did not, and on an *existing*
|
||||
name the handler writes nothing at all, so the mark is the only
|
||||
session write there is and has to clear `pre_session` itself.
|
||||
Stamping it through `set_attr` let `declare -x AWS_TOKEN` export a
|
||||
host-seeded credential the deployment had refused.
|
||||
|
||||
Args:
|
||||
session (Session): shell session state.
|
||||
view (SessionView): the session plane's gated door.
|
||||
flag_chars (set[str]): the declaration's collected flag letters.
|
||||
assignments (list[str]): `NAME` / `NAME=value` operands.
|
||||
staged (list[tuple[str, bool, list[str]]] | None): staged array
|
||||
literals from the same declaration.
|
||||
stored (list[str]): the names the handler actually stored.
|
||||
|
||||
Returns:
|
||||
A refusal result when the gate denied a mark, else None.
|
||||
"""
|
||||
if "x" not in flag_chars:
|
||||
return None
|
||||
covered = {a.partition("=")[0] for a in assignments if "=" in a}
|
||||
covered |= {name for name, _, _ in staged or []}
|
||||
for name in stored:
|
||||
if name in covered:
|
||||
set_attr(session, name, VarAttr.EXPORT)
|
||||
continue
|
||||
try:
|
||||
await view.mark(name, VarAttr.EXPORT, True)
|
||||
except PolicyDenied as exc:
|
||||
err = f"{exc.strerror}\n".encode()
|
||||
return None, IOResult(exit_code=1,
|
||||
stderr=err), ExecutionNode(command="declare",
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
return None
|
||||
|
||||
|
||||
def _is_bare_exec(command: Any) -> bool:
|
||||
"""Whether a redirected statement's command is a bare `exec`.
|
||||
|
||||
@@ -1011,219 +623,8 @@ async def execute_node(
|
||||
|
||||
# ── declaration (export/local/declare/readonly) ──
|
||||
if kind == NodeKind.DECLARATION:
|
||||
keyword = get_declaration_keyword(node)
|
||||
assignments = []
|
||||
# Array literals are staged, not stored: `readonly -a a=(y)` on an
|
||||
# already-readonly name has to fail with the old value intact.
|
||||
staged: list[tuple[str, bool, list[str]]] = []
|
||||
# Option words are kept verbatim, in order, so `--` survives as an
|
||||
# end-of-options marker and the handlers can name the *first* bad
|
||||
# option letter the way bash does.
|
||||
flag_words: list[str] = []
|
||||
flag_chars: set[str] = set()
|
||||
plus_chars: set[str] = set()
|
||||
opts_done = False
|
||||
for child in node.named_children:
|
||||
if child.type == NT.VARIABLE_ASSIGNMENT:
|
||||
val_nodes = [
|
||||
c for c in child.named_children
|
||||
if c.type != NT.VARIABLE_NAME
|
||||
]
|
||||
if val_nodes and val_nodes[0].type == NT.ARRAY:
|
||||
key = get_text(child).partition("=")[0]
|
||||
items = await _expand_array_items(val_nodes[0], session,
|
||||
execute_fn, registry,
|
||||
namespace, cs)
|
||||
staged.append(
|
||||
(key.removesuffix("+"), key.endswith("+"), items))
|
||||
continue
|
||||
expanded = await expand_node(child,
|
||||
session,
|
||||
execute_fn,
|
||||
cs,
|
||||
view=view)
|
||||
assignments.append(expanded)
|
||||
elif child.type in (NT.SIMPLE_EXPANSION, NT.EXPANSION,
|
||||
NT.CONCATENATION, NT.WORD, NT.VARIABLE_NAME,
|
||||
NT.STRING, NT.RAW_STRING, NT.ANSI_C_STRING,
|
||||
NT.TRANSLATED_STRING):
|
||||
# A bare `readonly NAME` / `export NAME` operand parses as
|
||||
# a variable_name, not a word, and a quoted assignment
|
||||
# (`export 'FOO=bar'`) as a plain string operand.
|
||||
expanded = await expand_node(child,
|
||||
session,
|
||||
execute_fn,
|
||||
cs,
|
||||
view=view)
|
||||
if not expanded and child.type in (NT.SIMPLE_EXPANSION,
|
||||
NT.EXPANSION):
|
||||
# An *unquoted* expansion that came back empty is
|
||||
# removed by word splitting, so `export $UNSET` is a
|
||||
# bare `export` and prints the listing. A quoted one
|
||||
# is a real, empty operand: GNU answers both
|
||||
# `export ""` and `export "$UNSET"` with
|
||||
# ``export: `': not a valid identifier``, so it has
|
||||
# to reach the builtin rather than vanish here.
|
||||
continue
|
||||
if (not opts_done and expanded.startswith("-")
|
||||
and len(expanded) > 1):
|
||||
flag_words.append(expanded)
|
||||
if expanded == "--":
|
||||
opts_done = True
|
||||
else:
|
||||
flag_chars.update(expanded[1:])
|
||||
elif (not opts_done and expanded.startswith("+")
|
||||
and len(expanded) > 1
|
||||
and keyword in (NT.LOCAL, "declare", "typeset")):
|
||||
# `+attr` turns an attribute off. Only the declare
|
||||
# family reads it: `export +x` and `readonly +r` are
|
||||
# `not a valid identifier` in GNU, so for those two
|
||||
# the word falls through as an operand and refuses
|
||||
# there.
|
||||
plus_chars.update(expanded[1:])
|
||||
else:
|
||||
assignments.append(expanded)
|
||||
cmd_word = "local" if keyword == NT.LOCAL else str(keyword)
|
||||
if keyword in (NT.LOCAL, "declare", "typeset"):
|
||||
refused = _declare_option_refusal(cmd_word, flag_chars, plus_chars,
|
||||
session)
|
||||
if refused is not None:
|
||||
return refused
|
||||
if (("f" in flag_chars or "F" in flag_chars)
|
||||
and keyword in (NT.LOCAL, "declare", "typeset")):
|
||||
# `-f`/`-F` select functions, not variables: `-rf` freezes,
|
||||
# `-f NAME` prints the body, `-F NAME` prints the name, and
|
||||
# a missing name is exit 1 without a word.
|
||||
return handle_declare_functions(cmd_word, session, flag_chars,
|
||||
assignments)
|
||||
is_readonly = keyword == "readonly" or "r" in flag_chars
|
||||
# `-l` and `-u` cannot both hold; a cluster naming both sets
|
||||
# neither (pinned: `declare -lu s=aBc` prints `declare -- s`).
|
||||
shaping = frozenset(_ATTR_LETTERS[c] for c in "ilu"
|
||||
if c in flag_chars and c not in plus_chars)
|
||||
if VarAttr.LOWER in shaping and VarAttr.UPPER in shaping:
|
||||
shaping = shaping - {VarAttr.LOWER, VarAttr.UPPER}
|
||||
conversion_errors: list[str] = []
|
||||
if "A" in flag_chars or "a" in flag_chars:
|
||||
# `declare -a NAME` / `declare -A NAME` with no value declare
|
||||
# an empty array of that kind, so ${#NAME[@]} is 0 and an
|
||||
# element write leaves the other slots unassigned. GNU
|
||||
# refuses to convert between the two kinds and says so per
|
||||
# name while the rest of the operands still declare.
|
||||
want_assoc = "A" in flag_chars
|
||||
for bare in assignments:
|
||||
if "=" in bare:
|
||||
continue
|
||||
# Both branches below write array storage raw (the
|
||||
# top-level one migrates an existing scalar), so a
|
||||
# hidden name refuses like any assignment spelling
|
||||
# before either lands.
|
||||
try:
|
||||
ensure_var_visible(session, bare)
|
||||
except PolicyDenied as exc:
|
||||
err = f"{exc.strerror}\n".encode()
|
||||
raise ExitSignal(1, stderr=err, contained_code=1) from exc
|
||||
if want_assoc and bare in session.arrays:
|
||||
conversion_errors.append(
|
||||
f"bash: {cmd_word}: {bare}: cannot convert indexed "
|
||||
"to associative array")
|
||||
continue
|
||||
if not want_assoc and bare in session.assocs:
|
||||
conversion_errors.append(
|
||||
f"bash: {cmd_word}: {bare}: cannot convert "
|
||||
"associative to indexed array")
|
||||
continue
|
||||
if "g" not in flag_chars and note_local_array(session, bare):
|
||||
# Inside a function this shadows whatever the caller
|
||||
# had with a fresh empty array of the declared kind;
|
||||
# `-g` declares at global scope instead.
|
||||
seed_var(session, bare, {} if want_assoc else [])
|
||||
elif want_assoc and bare not in session.assocs:
|
||||
# At top level an existing scalar becomes the value
|
||||
# at the literal key "0" (GNU allows scalar-to-
|
||||
# associative conversion, unlike indexed).
|
||||
scalar = session.env.get(bare)
|
||||
seed_var(session, bare,
|
||||
{} if scalar is None else {"0": scalar})
|
||||
elif not want_assoc and bare not in session.arrays:
|
||||
# At top level an existing scalar becomes element 0.
|
||||
scalar = session.env.get(bare)
|
||||
seed_var(session, bare, [] if scalar is None else [scalar])
|
||||
# Array literals travel as data: the handler stores them through
|
||||
# the session door and owns both refusal voices, so the executor
|
||||
# only expands and stages.
|
||||
if is_readonly:
|
||||
decl_view = session_view(session, namespace.registry.policies)
|
||||
stored: list[str] = []
|
||||
# Only the `readonly` keyword owns -p / illegal-option
|
||||
# handling; `declare -r` keeps names only.
|
||||
if keyword == "readonly":
|
||||
result = await handle_readonly(flag_words + assignments,
|
||||
session,
|
||||
decl_view,
|
||||
arrays=staged,
|
||||
stored=stored,
|
||||
assoc="A" in flag_chars,
|
||||
shaping=shaping)
|
||||
else:
|
||||
result = await handle_readonly(assignments,
|
||||
session,
|
||||
decl_view,
|
||||
arrays=staged,
|
||||
stored=stored,
|
||||
assoc="A" in flag_chars,
|
||||
shaping=shaping)
|
||||
# `declare -rx X=1` carries both attributes: GNU prints
|
||||
# `declare -rx X="1"`. Readonly answers first, so the export
|
||||
# stamp has to land here too, or `-r` silently ate the `-x`.
|
||||
refused = await _stamp_attrs(session, decl_view, flag_chars,
|
||||
plus_chars, assignments, staged,
|
||||
stored)
|
||||
if refused is not None:
|
||||
return refused
|
||||
return _merge_conversion_errors(result, conversion_errors)
|
||||
# declare/typeset scope like `local` inside a function (bash
|
||||
# semantics) and assign globally at top level, which is exactly
|
||||
# handle_local's fallback when no function scope is active.
|
||||
if keyword in (NT.LOCAL, "declare", "typeset"):
|
||||
# `-p` prints rather than declares, so it is answered before
|
||||
# the assignment path runs at all.
|
||||
if (("p" in flag_chars or "p" in plus_chars)
|
||||
and keyword in ("declare", "typeset")):
|
||||
return await handle_declare_print(assignments, session)
|
||||
decl_view = session_view(session, namespace.registry.policies)
|
||||
stored = []
|
||||
result = await handle_local(
|
||||
assignments,
|
||||
session,
|
||||
decl_view,
|
||||
arrays=staged,
|
||||
# `declare`/`typeset` share this handler but have to name
|
||||
# themselves in a diagnostic rather than say `local`.
|
||||
cmd=cmd_word,
|
||||
stored=stored,
|
||||
assoc="A" in flag_chars,
|
||||
shaping=shaping,
|
||||
nameref="n" in flag_chars and "n" not in plus_chars,
|
||||
global_scope="g" in flag_chars)
|
||||
plus_refused = await _plus_refusals(cmd_word, session, decl_view,
|
||||
plus_chars, assignments,
|
||||
staged)
|
||||
if plus_refused is not None:
|
||||
return plus_refused
|
||||
refused = await _stamp_attrs(session, decl_view, flag_chars,
|
||||
plus_chars, assignments, staged,
|
||||
stored)
|
||||
if refused is not None:
|
||||
return refused
|
||||
return _merge_conversion_errors(result, conversion_errors)
|
||||
# Pass export flags through so -p / bare print and bad options work.
|
||||
result = await handle_export(flag_words + assignments,
|
||||
session,
|
||||
session_view(session,
|
||||
namespace.registry.policies),
|
||||
arrays=staged)
|
||||
return _merge_conversion_errors(result, conversion_errors)
|
||||
return await execute_declaration(node, session, execute_fn, registry,
|
||||
namespace, cs, view)
|
||||
|
||||
# ── unset ───────────────────────────────────
|
||||
if kind == NodeKind.UNSET:
|
||||
@@ -1276,166 +677,8 @@ async def execute_node(
|
||||
|
||||
# ── variable assignment at top level ────────
|
||||
if kind == NodeKind.VAR_ASSIGN:
|
||||
text = get_text(node)
|
||||
if "=" not in text:
|
||||
return None, IOResult(), ExecutionNode(command=text, exit_code=0)
|
||||
sub_seq = session._cmdsub_seq
|
||||
subscript_node = next(
|
||||
(c for c in node.named_children if c.type == "subscript"), None)
|
||||
name_source = subscript_node if subscript_node is not None else node
|
||||
name_node = next((c for c in name_source.named_children
|
||||
if c.type == NT.VARIABLE_NAME), None)
|
||||
spelled = (get_text(name_node)
|
||||
if name_node is not None else text.partition("=")[0])
|
||||
# A name reference assigns to its target, whatever the shape of
|
||||
# the assignment; an unaimed one (`declare -n r; r=v`) resolves
|
||||
# to itself and takes the value as the target's name. The
|
||||
# spelling is kept for slicing the subscript out of the source.
|
||||
key = deref(session, spelled) or spelled
|
||||
append = any(c.type == "+=" for c in node.children)
|
||||
if key in session.readonly_vars:
|
||||
# A bare assignment to a readonly variable is a fatal
|
||||
# variable-assignment error in non-interactive bash: the
|
||||
# rest of the line is abandoned (builtins like `export`
|
||||
# merely fail with 1 and continue).
|
||||
err = f"bash: {key}: readonly variable\n".encode()
|
||||
raise ExitSignal(1, stderr=err, contained_code=1)
|
||||
val_nodes = [
|
||||
c for c in node.named_children
|
||||
if c.type not in (NT.VARIABLE_NAME, "subscript")
|
||||
]
|
||||
# Every branch below computes its resulting value with bash's
|
||||
# own mechanics on a copy, then stores through the one session
|
||||
# door, which owns the gate and the scalar/array invariant.
|
||||
view = session_view(session, namespace.registry.policies)
|
||||
if val_nodes and val_nodes[0].type == NT.ARRAY:
|
||||
items = await _expand_array_items(val_nodes[0], session,
|
||||
execute_fn, registry, namespace,
|
||||
cs)
|
||||
amap = session.assocs.get(key)
|
||||
if amap is not None:
|
||||
built, bad_words = build_assoc_literal(amap, items, append)
|
||||
await _assign_var(view, key, built)
|
||||
if bad_words:
|
||||
err = ("\n".join(
|
||||
f"bash: {key}: '{word}': must use subscript when "
|
||||
"assigning associative array"
|
||||
for word in bad_words) + "\n").encode()
|
||||
return None, IOResult(
|
||||
exit_code=1, stderr=err), ExecutionNode(command=text,
|
||||
exit_code=1,
|
||||
stderr=err)
|
||||
code = assignment_status(session, sub_seq)
|
||||
return None, IOResult(exit_code=code), ExecutionNode(
|
||||
command=text, exit_code=code)
|
||||
held = session.arrays.get(key)
|
||||
if append and held is None:
|
||||
scalar = session.env.get(key)
|
||||
held = None if scalar is None else [scalar]
|
||||
# `arr+=(...)` starts at the extent, so it fills the hole a
|
||||
# trailing `unset arr[last]` left but skips interior ones;
|
||||
# a `[i]=v` element places at i and the next plain word
|
||||
# continues from there.
|
||||
base = build_indexed_literal(
|
||||
held, items, append,
|
||||
functools.partial(element_index,
|
||||
env=visible_env(session),
|
||||
elements=session_elements(session)))
|
||||
await _assign_var(view, key, base)
|
||||
code = assignment_status(session, sub_seq)
|
||||
return None, IOResult(exit_code=code), ExecutionNode(
|
||||
command=text, exit_code=code)
|
||||
if val_nodes:
|
||||
val = await expand_node(val_nodes[0],
|
||||
session,
|
||||
execute_fn,
|
||||
cs,
|
||||
view=view)
|
||||
else:
|
||||
val = text.partition("=")[2]
|
||||
if subscript_node is not None:
|
||||
sub_text = await _subscript_key_text(subscript_node, spelled,
|
||||
session, execute_fn, cs, view)
|
||||
amap = session.assocs.get(key)
|
||||
raw_sub = get_text(subscript_node)[len(spelled) + 1:-1]
|
||||
if not raw_sub.strip() or (amap is not None and sub_text == ""):
|
||||
# bash aborts the whole line on a bad assignment
|
||||
# subscript (status 1), naming the raw spelling
|
||||
# (`m[$e]: bad array subscript`). An indexed subscript
|
||||
# that merely *expands* empty stays legal (arithmetic
|
||||
# on nothing is 0), so only the associative kind checks
|
||||
# the expanded text.
|
||||
name_text = text.partition("=")[0].removesuffix("+")
|
||||
raise ExitSignal(1,
|
||||
stderr=(f"bash: {name_text}: "
|
||||
"bad array subscript\n").encode(),
|
||||
contained_code=1)
|
||||
if amap is not None:
|
||||
# The subscript is the key: no arithmetic, `m[1+1]`
|
||||
# writes the key "1+1".
|
||||
new_map = dict(amap)
|
||||
new_map[sub_text] = (amap.get(sub_text, "") +
|
||||
val) if append else val
|
||||
await _assign_var(view, key, new_map)
|
||||
code = assignment_status(session, sub_seq)
|
||||
return None, IOResult(exit_code=code), ExecutionNode(
|
||||
command=text, exit_code=code)
|
||||
arr = session.arrays.get(key)
|
||||
if arr is None:
|
||||
scalar = session.env.get(key)
|
||||
arr = [] if scalar is None else [scalar]
|
||||
else:
|
||||
arr = list(arr)
|
||||
idx = _array_index(sub_text, visible_env(session),
|
||||
session_elements(session))
|
||||
if idx < 0:
|
||||
idx += array_extent(arr)
|
||||
if idx < 0:
|
||||
# Same fatal shape as the empty subscript above.
|
||||
name_text = text.partition("=")[0].removesuffix("+")
|
||||
raise ExitSignal(1,
|
||||
stderr=(f"bash: {name_text}: "
|
||||
"bad array subscript\n").encode(),
|
||||
contained_code=1)
|
||||
array_set(arr, idx, array_get(arr, idx) + val if append else val)
|
||||
await _assign_var(view, key, arr)
|
||||
code = assignment_status(session, sub_seq)
|
||||
return None, IOResult(exit_code=code), ExecutionNode(
|
||||
command=text, exit_code=code)
|
||||
held_map = session.assocs.get(key)
|
||||
held_arr = session.arrays.get(key)
|
||||
if held_map is not None:
|
||||
# `m=x` on an associative array writes the literal key "0"
|
||||
# and keeps every other key, as bash does.
|
||||
new_map = dict(held_map)
|
||||
new_map["0"] = (held_map.get("0", "") + val) if append else val
|
||||
await _assign_var(view, key, new_map)
|
||||
elif held_arr is not None:
|
||||
# `a=x` writes element 0 and keeps the rest; `a+=x` appends
|
||||
# onto element 0.
|
||||
new_arr = list(held_arr)
|
||||
array_set(new_arr, 0,
|
||||
(array_get(new_arr, 0) + val) if append else val)
|
||||
await _assign_var(view, key, new_arr)
|
||||
else:
|
||||
held_var = session.vars.get(key)
|
||||
if (append and held_var is not None
|
||||
and VarAttr.INTEGER in held_var.attrs):
|
||||
# `n+=3` on an integer name adds: the door evaluates
|
||||
# `old + new`, so `declare -i n=5; n+=3` stores 8, not 53.
|
||||
new_val = f"{session.env.get(key, '0')} + ({val})"
|
||||
else:
|
||||
new_val = session.env.get(key, "") + val if append else val
|
||||
await _assign_var(view, key, new_val)
|
||||
# Reassigning OPTIND (even to its current value) restarts the
|
||||
# getopts scan, matching bash's internal char pointer.
|
||||
if key == "OPTIND":
|
||||
session._getopts_optind = None
|
||||
code = assignment_status(session, sub_seq)
|
||||
io = IOResult(exit_code=code)
|
||||
if session.shell_options.get("xtrace"):
|
||||
io.stderr = trace_assignment(key, val, append)
|
||||
return None, io, ExecutionNode(command=text, exit_code=code)
|
||||
return await execute_assignment(node, session, execute_fn, registry,
|
||||
namespace, cs)
|
||||
|
||||
# ── assignment-only statement (a=1 b=2) ─────
|
||||
if kind == NodeKind.VAR_ASSIGNS:
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# ========= 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. =========
|
||||
"""cmp's byte counts, byte rendering and diagnostics, pinned on GNU 9.1."""
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.generic.cmp import (cmp_cmd, parse_count,
|
||||
parse_skip, visible)
|
||||
from mirage.commands.errors import UsageError
|
||||
from mirage.io.stream import materialize
|
||||
from mirage.types import PathSpec
|
||||
|
||||
P1 = PathSpec.from_str_path("/F/one", "")
|
||||
P2 = PathSpec.from_str_path("/F/two", "")
|
||||
|
||||
|
||||
def _reader(first: bytes, second: bytes):
|
||||
|
||||
async def read_bytes(path: PathSpec) -> bytes:
|
||||
return first if path.virtual == P1.virtual else second
|
||||
|
||||
return read_bytes
|
||||
|
||||
|
||||
async def _run(first: bytes, second: bytes, **kwargs):
|
||||
src, io = await cmp_cmd([P1, P2],
|
||||
read_bytes=_reader(first, second),
|
||||
**kwargs)
|
||||
out = b"" if src is None else await materialize(src)
|
||||
return out.decode(), (io.stderr or b"").decode(), io.exit_code
|
||||
|
||||
|
||||
def test_parse_count_takes_digits_and_gnu_size_suffixes():
|
||||
assert parse_count("4", "--bytes") == 4
|
||||
assert parse_count("1K", "--bytes") == 1024
|
||||
assert parse_count("1kB", "--bytes") == 1000
|
||||
assert parse_count("1b", "--bytes") == 512
|
||||
|
||||
|
||||
def test_parse_count_names_the_long_option_it_was_given():
|
||||
# GNU says `invalid --bytes value` for -n and `invalid
|
||||
# --ignore-initial value` for -i, with the Try-help line, exit 2.
|
||||
with pytest.raises(UsageError) as excinfo:
|
||||
parse_count("abc", "--bytes")
|
||||
assert str(excinfo.value) == ("cmp: invalid --bytes value 'abc'\n"
|
||||
"Try 'cmp --help' for more information.")
|
||||
assert excinfo.value.exit_code == 2
|
||||
|
||||
|
||||
def test_parse_count_rejects_an_unknown_suffix():
|
||||
with pytest.raises(UsageError):
|
||||
parse_count("1Q", "--bytes")
|
||||
|
||||
|
||||
def test_parse_skip_takes_one_count_for_both_files():
|
||||
assert parse_skip("3") == (3, 3)
|
||||
|
||||
|
||||
def test_parse_skip_takes_a_colon_pair_for_one_each():
|
||||
assert parse_skip("0:3") == (0, 3)
|
||||
assert parse_skip("1K:2") == (1024, 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("byte,rendered", [
|
||||
(ord("b"), "b"),
|
||||
(9, "^I"),
|
||||
(1, "^A"),
|
||||
(127, "^?"),
|
||||
(0xC3, "M-C"),
|
||||
(0xA9, "M-)"),
|
||||
(0x80, "M-^@"),
|
||||
])
|
||||
def test_visible_renders_one_byte_the_cat_v_way(byte, rendered):
|
||||
assert visible(byte) == rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_print_bytes_switches_the_word_to_byte():
|
||||
# GNU counts in `byte` under -b and in `char` otherwise.
|
||||
plain, _, _ = await _run(b"abc", b"aXc")
|
||||
tagged, _, _ = await _run(b"abc", b"aXc", print_bytes=True)
|
||||
assert plain == "/F/one /F/two differ: char 2, line 1\n"
|
||||
assert tagged == ("/F/one /F/two differ: byte 2, line 1"
|
||||
" is 142 b 130 X\n")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbose_pads_the_octal_to_three_columns():
|
||||
out, _, _ = await _run(b"a\x01c", b"a\x7fc", verbose=True)
|
||||
assert out == "2 1 177\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbose_with_print_bytes_adds_a_four_wide_char_column():
|
||||
out, _, _ = await _run(b"abc", b"aXc", verbose=True, print_bytes=True)
|
||||
assert out == "2 142 b 130 X\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_is_applied_per_file():
|
||||
# `-i 0:3` keeps all of the first file and drops three bytes of the
|
||||
# second, so the very first compared byte differs.
|
||||
out, _, code = await _run(b"abcdefgh", b"abcXefgh", skip=(0, 3))
|
||||
assert out == "/F/one /F/two differ: char 1, line 1\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eof_is_a_stderr_diagnostic_naming_the_byte_and_line():
|
||||
out, err, code = await _run(b"ab\nc", b"ab\ncdef")
|
||||
assert out == ""
|
||||
assert err == "cmp: EOF on /F/one after byte 4, in line 2\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbose_eof_reports_the_byte_without_the_line():
|
||||
out, err, code = await _run(b"aXc", b"aYcdef", verbose=True)
|
||||
assert out == "2 130 131\n"
|
||||
assert err == "cmp: EOF on /F/one after byte 3\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_limit_inside_the_common_prefix_reports_no_difference():
|
||||
assert await _run(b"abcdef", b"abcXef", limit=2) == ("", "", 0)
|
||||
@@ -187,7 +187,7 @@ async def test_cmp_verbose_lists_all_diffs():
|
||||
async def test_cmp_skip_offset():
|
||||
rb, _ = _make_backend({"/a.txt": b"xxhello", "/b.txt": b"yyhello"})
|
||||
output, io = await cmp_cmd(
|
||||
[_spec("/a.txt"), _spec("/b.txt")], read_bytes=rb, skip=2)
|
||||
[_spec("/a.txt"), _spec("/b.txt")], read_bytes=rb, skip=(2, 2))
|
||||
assert output is None
|
||||
assert io.exit_code == 0
|
||||
|
||||
@@ -204,5 +204,8 @@ async def test_cmp_eof_on_shorter():
|
||||
rb, _ = _make_backend({"/a.txt": b"abc", "/b.txt": b"abcdef"})
|
||||
output, io = await cmp_cmd(
|
||||
[_spec("/a.txt"), _spec("/b.txt")], read_bytes=rb)
|
||||
assert output == b"cmp: EOF on /a.txt\n"
|
||||
# GNU writes this to stderr, not stdout, and names the byte it
|
||||
# stopped at plus the line that byte sits in.
|
||||
assert output is None
|
||||
assert io.stderr == b"cmp: EOF on /a.txt after byte 3, in line 1\n"
|
||||
assert io.exit_code == 1
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# ========= 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. =========
|
||||
"""Top-level assignment spellings, pinned against bash 5.2.37.
|
||||
|
||||
Scalar, array literal, subscript and append all compute their result
|
||||
with bash's own mechanics and store through the one session door.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _ws() -> Workspace:
|
||||
return Workspace({"data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
async def _run(ws: Workspace, cmd: str) -> tuple[str, str, int]:
|
||||
io = await ws.execute(cmd)
|
||||
return (await io.stdout_str()), (await io.stderr_str()), io.exit_code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_array_literal_then_append_continues_at_the_extent():
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(ws, "a=(x y); a+=(z); declare -p a")
|
||||
assert out == 'declare -a a=([0]="x" [1]="y" [2]="z")\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scalar_on_an_indexed_array_writes_element_zero():
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(ws, "a=(x y); a=q; declare -p a")
|
||||
assert out == 'declare -a a=([0]="q" [1]="y")\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscript_is_arithmetic_when_indexed():
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(ws, "a=(x y z); a[1+1]=Q; declare -p a")
|
||||
assert out == 'declare -a a=([0]="x" [1]="y" [2]="Q")\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscript_is_a_literal_key_when_associative():
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(ws, "declare -A m; m[1+1]=v; declare -p m")
|
||||
assert out == 'declare -A m=([1+1]="v" )\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scalar_on_an_associative_array_writes_key_zero():
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(ws, "declare -A m; m[k]=v; m=x; declare -p m")
|
||||
assert '[0]="x"' in out and '[k]="v"' in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_on_an_integer_name_adds_rather_than_concatenates():
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(ws, "declare -i n=5; n+=3; echo $n")
|
||||
assert out == "8\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_associative_subscript_that_expands_empty_aborts_the_line():
|
||||
# GNU 5.2.37 names the raw spelling, not the expanded key, and the
|
||||
# rest of the line is abandoned.
|
||||
ws = _ws()
|
||||
out, err, code = await _run(ws, "declare -A m; e=; m[$e]=v; echo REACHED")
|
||||
assert out == ""
|
||||
assert err == "bash: m[$e]: bad array subscript\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_indexed_subscript_that_expands_empty_stays_legal():
|
||||
# The asymmetry above: arithmetic on nothing is 0, so only the
|
||||
# associative kind checks the expanded text.
|
||||
ws = _ws()
|
||||
out, _, code = await _run(ws, "a=(x y); e=; a[$e]=Q; declare -p a")
|
||||
assert out == 'declare -a a=([0]="Q" [1]="y")\n'
|
||||
assert code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assigning_a_readonly_name_aborts_the_line():
|
||||
ws = _ws()
|
||||
out, err, code = await _run(ws, "readonly r=1; r=2; echo REACHED")
|
||||
assert out == ""
|
||||
assert err == "bash: r: readonly variable\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_assignment_only_statement_takes_the_last_substitution():
|
||||
# The statement's status follows the last command substitution across
|
||||
# ALL its assignments, not the last child's.
|
||||
ws = _ws()
|
||||
_, _, code = await _run(ws, "a=$(true) b=$(false)")
|
||||
assert code == 1
|
||||
@@ -0,0 +1,132 @@
|
||||
# ========= 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. =========
|
||||
"""export/local/declare/readonly, pinned against bash 5.2.37.
|
||||
|
||||
The executor only reads the operands -- option letters, plain names and
|
||||
staged array literals -- and the keyword's handler owns the storing;
|
||||
the attribute letters are stamped afterwards through the same door.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _ws() -> Workspace:
|
||||
return Workspace({"data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
async def _run(ws: Workspace, cmd: str) -> tuple[str, str, int]:
|
||||
io = await ws.execute(cmd)
|
||||
return (await io.stdout_str()), (await io.stderr_str()), io.exit_code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_option_letter_refuses_before_any_operand():
|
||||
ws = _ws()
|
||||
out, err, code = await _run(ws, "declare -q NAME")
|
||||
assert out == ""
|
||||
assert err == ("bash: declare: -q: invalid option\n"
|
||||
"declare: usage: declare [-aAfFgiIlnrtux] [name[=value] "
|
||||
"...] or declare -p [-aAfFilnrtux] [name ...]\n")
|
||||
assert code == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readonly_and_export_both_land_on_one_declaration():
|
||||
# Readonly answers first, so the export stamp has to land in the
|
||||
# readonly branch too or `-r` silently eats the `-x`.
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(ws, "declare -rx X=1; declare -p X")
|
||||
assert out == 'declare -rx X="1"\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lower_and_upper_in_one_cluster_set_neither():
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(ws, "declare -lu s=aBc; declare -p s")
|
||||
assert out == 'declare -- s="aBc"\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_shaping_letter_applies_to_later_writes_not_the_held_value():
|
||||
ws = _ws()
|
||||
out, _, _ = await _run(
|
||||
ws, "v=MiXeD; declare -l v; declare -p v; v=ABC; declare -p v")
|
||||
assert out == 'declare -l v="MiXeD"\ndeclare -l v="abc"\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_two_array_kinds_refuse_to_convert():
|
||||
ws = _ws()
|
||||
_, err, code = await _run(ws, "declare -a a; declare -A a")
|
||||
assert err == ("bash: declare: a: cannot convert indexed to "
|
||||
"associative array\n")
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plus_r_on_a_readonly_name_refuses_and_keeps_it_frozen():
|
||||
ws = _ws()
|
||||
_, err, code = await _run(ws, "readonly r=1; declare +r r")
|
||||
assert err == "bash: declare: r: readonly variable\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plus_a_cannot_destroy_an_indexed_array():
|
||||
ws = _ws()
|
||||
_, err, code = await _run(ws, "a=(x); declare +a a")
|
||||
assert err == ("bash: declare: a: cannot destroy array variables "
|
||||
"in this way\n")
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_refused_operand_does_not_cost_its_siblings_their_marks():
|
||||
# `declare -x GOOD=1 1BAD=x` exits 1 and still exports GOOD: the
|
||||
# stamp reads the names the handler stored, not the exit code.
|
||||
ws = _ws()
|
||||
out, err, _ = await _run(ws, "declare -x GOOD=1 1BAD=x; declare -p GOOD")
|
||||
assert "not a valid identifier" in err
|
||||
assert out == 'declare -x GOOD="1"\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unquoted_empty_expansion_is_removed_by_word_splitting():
|
||||
# `export $UNSET` is a bare `export` and prints the listing; the
|
||||
# quoted form is a real, empty operand and refuses.
|
||||
ws = _ws()
|
||||
_, _, bare = await _run(ws, "export $NOPE")
|
||||
assert bare == 0
|
||||
_, err, code = await _run(ws, 'export "$NOPE"')
|
||||
assert err == "bash: export: `': not a valid identifier\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_staged_array_literal_leaves_the_old_value_intact():
|
||||
# Array literals are staged, not stored, so `readonly -a a=(y)` on
|
||||
# an already-readonly name fails with the old value intact. GNU
|
||||
# treats it as a fatal variable-assignment error, so the rest of
|
||||
# that line never runs -- the value is read back on the next one.
|
||||
ws = _ws()
|
||||
_, err, code = await _run(
|
||||
ws, "readonly -a a=(x); readonly -a a=(y); "
|
||||
"echo REACHED")
|
||||
assert err == "bash: a: readonly variable\n"
|
||||
assert code == 1
|
||||
out, _, _ = await _run(ws, "declare -p a")
|
||||
assert out == 'declare -ar a=([0]="x")\n'
|
||||
@@ -38,7 +38,7 @@ ALLOWED = {
|
||||
("mirage/workspace/executor/builtins/vars.py", "handle_readonly"):
|
||||
"the `=` branch only; `await view.set(key, val)` runs first and "
|
||||
"the bare form uses `view.mark`",
|
||||
("mirage/workspace/node/execute_node.py", "_stamp_export"):
|
||||
("mirage/workspace/node/declaration.py", "_stamp_export"):
|
||||
"the `covered` branch only, which is the names that carried a "
|
||||
"value or a staged array literal; a bare name has no gated write "
|
||||
"to ride on and goes through `view.mark`",
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// ========= 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. =========
|
||||
|
||||
// cmp's byte counts, byte rendering and diagnostics, pinned on GNU 9.1.
|
||||
// Mirrors python/tests/commands/builtin/generic/test_cmp.py.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cmpGeneric, parseCount, parseSkip, visible } from './cmp.ts'
|
||||
import { UsageError } from '../../errors.ts'
|
||||
import { PathSpec } from '../../../types.ts'
|
||||
import { materialize } from '../../../io/types.ts'
|
||||
import type { CommandOpts } from '../../config.ts'
|
||||
|
||||
const DEC = new TextDecoder()
|
||||
const ENC = new TextEncoder()
|
||||
const P1 = new PathSpec({ virtual: '/F/one', directory: '/F', resourcePath: 'one' })
|
||||
const P2 = new PathSpec({ virtual: '/F/two', directory: '/F', resourcePath: 'two' })
|
||||
|
||||
function bytes(...values: number[]): Uint8Array {
|
||||
return new Uint8Array(values)
|
||||
}
|
||||
|
||||
async function run(
|
||||
first: Uint8Array,
|
||||
second: Uint8Array,
|
||||
flags: Record<string, unknown> = {},
|
||||
): Promise<{ out: string; err: string; code: number }> {
|
||||
const stream = (p: PathSpec): AsyncIterable<Uint8Array> => {
|
||||
const held = p.virtual === P1.virtual ? first : second
|
||||
return (async function* gen() {
|
||||
await Promise.resolve()
|
||||
yield held
|
||||
})()
|
||||
}
|
||||
const [src, io] = await cmpGeneric([P1, P2], { flags } as unknown as CommandOpts, stream)
|
||||
return {
|
||||
out: DEC.decode(await materialize(src)),
|
||||
err: DEC.decode(await materialize(io.stderr)),
|
||||
code: io.exitCode,
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseCount', () => {
|
||||
it('takes digits and GNU size suffixes', () => {
|
||||
expect(parseCount('4', '--bytes')).toBe(4)
|
||||
expect(parseCount('1K', '--bytes')).toBe(1024)
|
||||
expect(parseCount('1kB', '--bytes')).toBe(1000)
|
||||
expect(parseCount('1b', '--bytes')).toBe(512)
|
||||
})
|
||||
|
||||
it('names the long option it was given', () => {
|
||||
// GNU says `invalid --bytes value` for -n and `invalid
|
||||
// --ignore-initial value` for -i, with the Try-help line, exit 2.
|
||||
let caught: unknown
|
||||
try {
|
||||
parseCount('abc', '--bytes')
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UsageError)
|
||||
expect((caught as UsageError).message).toBe(
|
||||
"cmp: invalid --bytes value 'abc'\nTry 'cmp --help' for more information.",
|
||||
)
|
||||
expect((caught as UsageError).exitCode).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects an unknown suffix', () => {
|
||||
expect(() => parseCount('1Q', '--bytes')).toThrow(UsageError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSkip', () => {
|
||||
it('takes one count for both files', () => {
|
||||
expect(parseSkip('3')).toEqual([3, 3])
|
||||
})
|
||||
|
||||
it('takes a colon pair for one each', () => {
|
||||
expect(parseSkip('0:3')).toEqual([0, 3])
|
||||
expect(parseSkip('1K:2')).toEqual([1024, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('visible', () => {
|
||||
it.each([
|
||||
['b'.charCodeAt(0), 'b'],
|
||||
[9, '^I'],
|
||||
[1, '^A'],
|
||||
[127, '^?'],
|
||||
[0xc3, 'M-C'],
|
||||
[0xa9, 'M-)'],
|
||||
[0x80, 'M-^@'],
|
||||
])('renders %i the cat -v way', (byte, rendered) => {
|
||||
expect(visible(byte)).toBe(rendered)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmpGeneric', () => {
|
||||
it('switches the word to byte under -b', async () => {
|
||||
// GNU counts in `byte` under -b and in `char` otherwise.
|
||||
const plain = await run(ENC.encode('abc'), ENC.encode('aXc'))
|
||||
const tagged = await run(ENC.encode('abc'), ENC.encode('aXc'), { b: true })
|
||||
expect(plain.out).toBe('/F/one /F/two differ: char 2, line 1\n')
|
||||
expect(tagged.out).toBe('/F/one /F/two differ: byte 2, line 1 is 142 b 130 X\n')
|
||||
})
|
||||
|
||||
it('pads the octal to three columns under -l', async () => {
|
||||
const r = await run(bytes(97, 1, 99), bytes(97, 127, 99), { args_l: true })
|
||||
expect(r.out).toBe('2 1 177\n')
|
||||
})
|
||||
|
||||
it('adds a four-wide char column under -bl', async () => {
|
||||
const r = await run(ENC.encode('abc'), ENC.encode('aXc'), { args_l: true, b: true })
|
||||
expect(r.out).toBe('2 142 b 130 X\n')
|
||||
})
|
||||
|
||||
it('applies the skip per file', async () => {
|
||||
// `-i 0:3` keeps all of the first file and drops three bytes of the
|
||||
// second, so the very first compared byte differs.
|
||||
const r = await run(ENC.encode('abcdefgh'), ENC.encode('abcXefgh'), { i: '0:3' })
|
||||
expect(r.out).toBe('/F/one /F/two differ: char 1, line 1\n')
|
||||
expect(r.code).toBe(1)
|
||||
})
|
||||
|
||||
it('reports EOF on stderr naming the byte and the line', async () => {
|
||||
const r = await run(ENC.encode('ab\nc'), ENC.encode('ab\ncdef'))
|
||||
expect(r.out).toBe('')
|
||||
expect(r.err).toBe('cmp: EOF on /F/one after byte 4, in line 2\n')
|
||||
expect(r.code).toBe(1)
|
||||
})
|
||||
|
||||
it('drops the line clause from the EOF diagnostic under -l', async () => {
|
||||
const r = await run(ENC.encode('aXc'), ENC.encode('aYcdef'), { args_l: true })
|
||||
expect(r.out).toBe('2 130 131\n')
|
||||
expect(r.err).toBe('cmp: EOF on /F/one after byte 3\n')
|
||||
expect(r.code).toBe(1)
|
||||
})
|
||||
|
||||
it('reports no difference for a limit inside the common prefix', async () => {
|
||||
const r = await run(ENC.encode('abcdef'), ENC.encode('abcXef'), { n: '2' })
|
||||
expect(r).toEqual({ out: '', err: '', code: 0 })
|
||||
})
|
||||
})
|
||||
@@ -19,13 +19,89 @@ import type { PathSpec } from '../../../types.ts'
|
||||
import type { CommandOpts } from '../../config.ts'
|
||||
import { formatFsError, isFsError } from '../../../utils/errors.ts'
|
||||
import { formatRecords } from '../utils/output.ts'
|
||||
import { sizeSuffixes } from '../utils/size_suffix.ts'
|
||||
import { extraOperandError } from '../../spec/usage.ts'
|
||||
import { CommandName } from '../../spec/types.ts'
|
||||
import { UsageError } from '../../errors.ts'
|
||||
|
||||
const ENC = new TextEncoder()
|
||||
|
||||
function octal(n: number): string {
|
||||
return n.toString(8)
|
||||
const UNITS = sizeSuffixes('bkKMGTPEZY')
|
||||
const TRY_HELP = "\nTry 'cmp --help' for more information."
|
||||
const COUNT = /^([0-9]+)([A-Za-z]*)$/
|
||||
|
||||
function octal(n: number, width = 0): string {
|
||||
return n.toString(8).padStart(width)
|
||||
}
|
||||
|
||||
/**
|
||||
* One GNU `cmp` byte count: digits and an optional size suffix.
|
||||
*
|
||||
* GNU reads `-n`/`-i` operands through xstrtoumax, so `1K` and `1kB`
|
||||
* are accepted and anything else is a usage error naming the long
|
||||
* option, not a crash.
|
||||
*/
|
||||
export function parseCount(raw: string, option: string): number {
|
||||
const match = COUNT.exec(raw)
|
||||
const suffix = match?.[2] ?? ''
|
||||
const unit = suffix === '' ? 1 : UNITS[suffix]
|
||||
if (match === null || unit === undefined) {
|
||||
throw new UsageError(`cmp: invalid ${option} value '${raw}'${TRY_HELP}`)
|
||||
}
|
||||
return Number(match[1]) * unit
|
||||
}
|
||||
|
||||
/**
|
||||
* The `-i` operand as one skip per file.
|
||||
*
|
||||
* GNU takes `SKIP` for both files or `SKIP1:SKIP2` for one each, so
|
||||
* `-i 0:3` compares all of the first file against the fourth byte
|
||||
* onward of the second.
|
||||
*/
|
||||
export function parseSkip(raw: string): [number, number] {
|
||||
const cut = raw.indexOf(':')
|
||||
if (cut === -1) {
|
||||
const both = parseCount(raw, '--ignore-initial')
|
||||
return [both, both]
|
||||
}
|
||||
return [
|
||||
parseCount(raw.slice(0, cut), '--ignore-initial'),
|
||||
parseCount(raw.slice(cut + 1), '--ignore-initial'),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* One byte rendered the way GNU `cmp -b` renders it.
|
||||
*
|
||||
* The cat -v alphabet: a control byte becomes `^X` (so tab is `^I`,
|
||||
* unlike `cat -v` itself), DEL becomes `^?`, and a high byte becomes
|
||||
* `M-` followed by the same rules on its low seven bits.
|
||||
*/
|
||||
export function visible(byte: number): string {
|
||||
if (byte >= 128) return `M-${visible(byte - 128)}`
|
||||
if (byte === 127) return '^?'
|
||||
if (byte < 32) return `^${String.fromCharCode(byte + 64)}`
|
||||
return String.fromCharCode(byte)
|
||||
}
|
||||
|
||||
interface CmpFlags {
|
||||
readonly silent: boolean
|
||||
readonly verbose: boolean
|
||||
readonly limit: number | null
|
||||
readonly printBytes: boolean
|
||||
readonly skip: readonly [number, number]
|
||||
}
|
||||
|
||||
function parseFlags(fl: FlagView): CmpFlags {
|
||||
const nRaw = fl.asStr('n')
|
||||
const iRaw = fl.asStr('i')
|
||||
return {
|
||||
silent: fl.asBool('s'),
|
||||
verbose: fl.asBool('args_l'),
|
||||
limit: nRaw === undefined ? null : parseCount(nRaw, '--bytes'),
|
||||
printBytes: fl.asBool('b'),
|
||||
skip: iRaw === undefined ? [0, 0] : parseSkip(iRaw),
|
||||
}
|
||||
}
|
||||
|
||||
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
@@ -34,12 +110,37 @@ function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* GNU's `EOF on FILE` diagnostic for a common-prefix difference.
|
||||
*
|
||||
* It is a diagnostic, not output: GNU writes it to stderr and still
|
||||
* exits 1. `-l` reports the byte only, every other mode adds the line
|
||||
* the count lands in.
|
||||
*/
|
||||
function eofError(
|
||||
paths: PathSpec[],
|
||||
data1: Uint8Array,
|
||||
data2: Uint8Array,
|
||||
verbose: boolean,
|
||||
): Uint8Array {
|
||||
const firstShorter = data1.byteLength < data2.byteLength
|
||||
const shorter = firstShorter ? paths[0] : paths[1]
|
||||
const held = firstShorter ? data1 : data2
|
||||
let msg = `cmp: EOF on ${shorter?.virtual ?? ''} after byte ${String(held.byteLength)}`
|
||||
if (!verbose) {
|
||||
let lines = 1
|
||||
for (const byte of held) if (byte === 0x0a) lines += 1
|
||||
msg += `, in line ${String(lines)}`
|
||||
}
|
||||
return ENC.encode(`${msg}\n`)
|
||||
}
|
||||
|
||||
export async function cmpGeneric(
|
||||
paths: PathSpec[],
|
||||
opts: CommandOpts,
|
||||
stream: (p: PathSpec) => AsyncIterable<Uint8Array>,
|
||||
): Promise<[ByteSource | null, IOResult]> {
|
||||
const fl = new FlagView(opts.flags, specOf('cmp'))
|
||||
const parsed = parseFlags(new FlagView(opts.flags, specOf('cmp')))
|
||||
if (paths.length > 2) throw extraOperandError(CommandName.CMP, paths[2]?.rawPath ?? '')
|
||||
if (paths.length < 2) {
|
||||
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode('cmp: requires two paths\n') })]
|
||||
@@ -58,43 +159,50 @@ export async function cmpGeneric(
|
||||
// unreadable operand) is exit 2.
|
||||
return [null, new IOResult({ exitCode: 2, stderr: formatFsError('cmp', err, paths) })]
|
||||
}
|
||||
const skipRaw = fl.asInt('i')
|
||||
if (skipRaw !== undefined) {
|
||||
const skip = skipRaw
|
||||
data1 = data1.slice(skip)
|
||||
data2 = data2.slice(skip)
|
||||
}
|
||||
const limitRaw = fl.asInt('n')
|
||||
if (limitRaw !== undefined) {
|
||||
const limit = limitRaw
|
||||
data1 = data1.slice(0, limit)
|
||||
data2 = data2.slice(0, limit)
|
||||
data1 = data1.slice(parsed.skip[0])
|
||||
data2 = data2.slice(parsed.skip[1])
|
||||
if (parsed.limit !== null) {
|
||||
data1 = data1.slice(0, parsed.limit)
|
||||
data2 = data2.slice(0, parsed.limit)
|
||||
}
|
||||
if (arraysEqual(data1, data2)) return [null, new IOResult()]
|
||||
if (fl.asBool('s')) return [null, new IOResult({ exitCode: 1 })]
|
||||
if (fl.asBool('args_l')) {
|
||||
if (parsed.silent) return [null, new IOResult({ exitCode: 1 })]
|
||||
const common = Math.min(data1.byteLength, data2.byteLength)
|
||||
if (parsed.verbose) {
|
||||
const outLines: string[] = []
|
||||
const limit = Math.min(data1.byteLength, data2.byteLength)
|
||||
for (let idx = 0; idx < limit; idx++) {
|
||||
if (data1[idx] !== data2[idx]) {
|
||||
outLines.push(`${String(idx + 1)} ${octal(data1[idx] ?? 0)} ${octal(data2[idx] ?? 0)}`)
|
||||
}
|
||||
for (let idx = 0; idx < common; idx++) {
|
||||
const a = data1[idx] ?? 0
|
||||
const b = data2[idx] ?? 0
|
||||
if (a === b) continue
|
||||
let row = `${String(idx + 1)} ${octal(a, 3)}`
|
||||
if (parsed.printBytes) row += ` ${visible(a).padEnd(4)}`
|
||||
row += ` ${octal(b, 3)}`
|
||||
if (parsed.printBytes) row += ` ${visible(b)}`
|
||||
outLines.push(row)
|
||||
}
|
||||
const out: ByteSource = formatRecords(outLines)
|
||||
return [out, new IOResult({ exitCode: 1 })]
|
||||
const io =
|
||||
data1.byteLength === data2.byteLength
|
||||
? new IOResult({ exitCode: 1 })
|
||||
: new IOResult({ exitCode: 1, stderr: eofError(paths, data1, data2, true) })
|
||||
return [formatRecords(outLines), io]
|
||||
}
|
||||
const limit = Math.min(data1.byteLength, data2.byteLength)
|
||||
for (let idx = 0; idx < limit; idx++) {
|
||||
if (data1[idx] !== data2[idx]) {
|
||||
let line = 1
|
||||
for (let k = 0; k < idx; k++) if (data1[k] === 0x0a) line += 1
|
||||
let msg = `${p0.virtual} ${p1.virtual} differ: char ${String(idx + 1)}, line ${String(line)}`
|
||||
if (fl.asBool('b')) {
|
||||
msg += ` is ${octal(data1[idx] ?? 0)} ${String.fromCharCode(data1[idx] ?? 0)} ${octal(data2[idx] ?? 0)} ${String.fromCharCode(data2[idx] ?? 0)}`
|
||||
}
|
||||
return [formatRecords([msg]), new IOResult({ exitCode: 1 })]
|
||||
for (let idx = 0; idx < common; idx++) {
|
||||
const a = data1[idx] ?? 0
|
||||
const b = data2[idx] ?? 0
|
||||
if (a === b) continue
|
||||
let line = 1
|
||||
for (let k = 0; k < idx; k++) if (data1[k] === 0x0a) line += 1
|
||||
// GNU counts in `byte` under -b and in `char` otherwise, on the
|
||||
// same offset -- the word tracks the flag, not a unit.
|
||||
const unit = parsed.printBytes ? 'byte' : 'char'
|
||||
let msg = `${p0.virtual} ${p1.virtual} differ: ${unit} ${String(idx + 1)}, line ${String(line)}`
|
||||
if (parsed.printBytes) {
|
||||
msg += ` is ${octal(a, 3)} ${visible(a)} ${octal(b, 3)} ${visible(b)}`
|
||||
}
|
||||
return [formatRecords([msg]), new IOResult({ exitCode: 1 })]
|
||||
}
|
||||
const shorter = data1.byteLength < data2.byteLength ? p0 : p1
|
||||
return [formatRecords([`cmp: EOF on ${shorter.virtual}`]), new IOResult({ exitCode: 1 })]
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: eofError(paths, data1, data2, parsed.verbose) }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { Runtime } from './base.ts'
|
||||
import {
|
||||
bindCommands,
|
||||
buildRuntime,
|
||||
candidates,
|
||||
DEFAULT_ENTRIES,
|
||||
DEFAULT_PYTHON,
|
||||
runtimeBindingsFor,
|
||||
@@ -37,12 +36,6 @@ class FakeRuntime extends Runtime {
|
||||
}
|
||||
|
||||
describe('runtime table', () => {
|
||||
it('candidates are ordered, derived from captures', () => {
|
||||
expect(candidates('python3')).toEqual([PyodideRuntime, MontyRuntime])
|
||||
expect(candidates('node')).toEqual([QuickJsRuntime])
|
||||
expect(candidates('grep')).toEqual([])
|
||||
})
|
||||
|
||||
it('captures default is declared once per tier', () => {
|
||||
// The head words are a language fact like `language` itself: the
|
||||
// tier declares them, engines inherit, an instance still overrides.
|
||||
|
||||
@@ -113,11 +113,6 @@ const PYTHON_ONLY_HINTS: Record<string, string> = {
|
||||
'JavaScript)',
|
||||
}
|
||||
|
||||
/** The runtime classes that capture a command, preference order. */
|
||||
export function candidates(command: string): (typeof RUNTIMES)[number][] {
|
||||
return RUNTIMES.filter((cls) => cls.commands.includes(command))
|
||||
}
|
||||
|
||||
// Every runtime is constructed the same way; config keys are checked
|
||||
// inside each class (its config key list), so the entry level only
|
||||
// knows the uniform options. Python gets this check for free
|
||||
|
||||
@@ -247,11 +247,16 @@ describe('handleCrossMount — cmp', () => {
|
||||
})
|
||||
|
||||
it('EOF on shorter file → exit 1', async () => {
|
||||
// GNU writes the EOF notice to stderr, not stdout, and names both
|
||||
// the byte it stopped at and the line that byte sits in.
|
||||
const d = dispatchWithContents(new TextEncoder().encode('ab'), new TextEncoder().encode('abc'))
|
||||
const paths = [PathSpec.fromStrPath('/ram/a'), PathSpec.fromStrPath('/disk/b')]
|
||||
const [out, io] = await handleCrossMount('cmp', paths, [], {}, d, runSingleNoop, null, 'cmp')
|
||||
expect(io.exitCode).toBe(1)
|
||||
expect(decode(out as Uint8Array)).toMatch(/EOF on/)
|
||||
expect(out).toBeNull()
|
||||
expect(decode(await materialize(io.stderr))).toBe(
|
||||
'cmp: EOF on /ram/a after byte 2, in line 1\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('missing operand → GNU strerror line', async () => {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// ========= 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. =========
|
||||
|
||||
// Top-level assignment spellings, pinned against bash 5.2.37. Mirrors
|
||||
// python/tests/workspace/node/test_assignment.py.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RAMResource } from '../../resource/ram/ram.ts'
|
||||
import { MountMode } from '../../types.ts'
|
||||
import { getTestParser, stderrStr, stdoutStr } from '../fixtures/workspace_fixture.ts'
|
||||
import { Workspace } from '../workspace/workspace.ts'
|
||||
|
||||
async function makeWs(): Promise<Workspace> {
|
||||
const parser = await getTestParser()
|
||||
return new Workspace(
|
||||
{ '/data': new RAMResource() },
|
||||
{ mode: MountMode.WRITE, shellParser: parser },
|
||||
)
|
||||
}
|
||||
|
||||
describe('executeAssignment', () => {
|
||||
it('appends an array literal at the extent', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('a=(x y); a+=(z); declare -p a')
|
||||
expect(stdoutStr(io)).toBe('declare -a a=([0]="x" [1]="y" [2]="z")\n')
|
||||
})
|
||||
|
||||
it('writes element zero for a scalar on an indexed array', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('a=(x y); a=q; declare -p a')
|
||||
expect(stdoutStr(io)).toBe('declare -a a=([0]="q" [1]="y")\n')
|
||||
})
|
||||
|
||||
it('evaluates an indexed subscript as arithmetic', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('a=(x y z); a[1+1]=Q; declare -p a')
|
||||
expect(stdoutStr(io)).toBe('declare -a a=([0]="x" [1]="y" [2]="Q")\n')
|
||||
})
|
||||
|
||||
it('keeps an associative subscript as a literal key', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -A m; m[1+1]=v; declare -p m')
|
||||
expect(stdoutStr(io)).toBe('declare -A m=([1+1]="v" )\n')
|
||||
})
|
||||
|
||||
it('writes key zero for a scalar on an associative array', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -A m; m[k]=v; m=x; declare -p m')
|
||||
expect(stdoutStr(io)).toContain('[0]="x"')
|
||||
expect(stdoutStr(io)).toContain('[k]="v"')
|
||||
})
|
||||
|
||||
it('adds rather than concatenates when appending to an integer name', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -i n=5; n+=3; echo $n')
|
||||
expect(stdoutStr(io)).toBe('8\n')
|
||||
})
|
||||
|
||||
it('aborts the line on an associative subscript that expands empty', async () => {
|
||||
// GNU 5.2.37 names the raw spelling, not the expanded key, and the
|
||||
// rest of the line is abandoned.
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -A m; e=; m[$e]=v; echo REACHED')
|
||||
expect(stdoutStr(io)).toBe('')
|
||||
expect(stderrStr(io)).toBe('bash: m[$e]: bad array subscript\n')
|
||||
expect(io.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps an indexed subscript that expands empty legal', async () => {
|
||||
// The asymmetry above: arithmetic on nothing is 0, so only the
|
||||
// associative kind checks the expanded text.
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('a=(x y); e=; a[$e]=Q; declare -p a')
|
||||
expect(stdoutStr(io)).toBe('declare -a a=([0]="Q" [1]="y")\n')
|
||||
expect(io.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('aborts the line when assigning a readonly name', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('readonly r=1; r=2; echo REACHED')
|
||||
expect(stdoutStr(io)).toBe('')
|
||||
expect(stderrStr(io)).toBe('bash: r: readonly variable\n')
|
||||
expect(io.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('takes the last substitution across every assignment of a statement', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('a=$(true) b=$(false)')
|
||||
expect(io.exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
// ========= 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. =========
|
||||
|
||||
import { type ByteSource, IOResult } from '../../io/types.ts'
|
||||
import type { CallStack } from '../../shell/call_stack.ts'
|
||||
import {
|
||||
type ShellArray,
|
||||
arrayExtent,
|
||||
arrayGet,
|
||||
arraySet,
|
||||
buildAssocLiteral,
|
||||
buildIndexedLiteral,
|
||||
} from '../../shell/array.ts'
|
||||
import { ArithError, ExitSignal } from '../../shell/errors.ts'
|
||||
import { getText } from '../../shell/helpers.ts'
|
||||
import { NodeType as NT, type TSNodeLike } from '../../shell/types.ts'
|
||||
import { type ShellValue, VarAttr } from '../../shell/variable.ts'
|
||||
import { traceAssignment } from '../../shell/xtrace.ts'
|
||||
import { PolicyDenied } from '../../policy/errors.ts'
|
||||
import type { SessionView } from '../../ops/types.ts'
|
||||
import { wordText } from '../../types.ts'
|
||||
import { assignmentStatus } from '../executor/statement.ts'
|
||||
import { type ExecuteFn, expandNode } from '../expand/node.ts'
|
||||
import { globOptions, resolveGlobs } from '../expand/globs.ts'
|
||||
import { expandAndClassify } from '../expand/parts.ts'
|
||||
import { arrayIndex } from '../expand/variable.ts'
|
||||
import type { Namespace } from '../mount/namespace/namespace.ts'
|
||||
import type { MountRegistry } from '../mount/registry.ts'
|
||||
import type { Session } from '../session/session.ts'
|
||||
import { deref, elementIndex, sessionElements, sessionView, visibleEnv } from '../session/state.ts'
|
||||
import { ExecutionNode } from '../types.ts'
|
||||
|
||||
type Result = [ByteSource | null, IOResult, ExecutionNode]
|
||||
|
||||
/**
|
||||
* One assignment through the session door; denial is fatal.
|
||||
*
|
||||
* Every assignment spelling (scalar, array literal, subscript, append)
|
||||
* computes its resulting value and stores through `view.set`, so the
|
||||
* gate and the storage invariant live in the door, not here. Denial
|
||||
* mirrors the readonly case: a fatal variable-assignment error that
|
||||
* abandons the rest of the line.
|
||||
*/
|
||||
async function assignVar(view: SessionView, key: string, value: ShellValue): Promise<void> {
|
||||
try {
|
||||
await view.set(key, value)
|
||||
} catch (err) {
|
||||
if (err instanceof PolicyDenied) {
|
||||
const denied = new TextEncoder().encode(`${err.message}\n`)
|
||||
throw new ExitSignal(1, denied, null, 1)
|
||||
}
|
||||
if (err instanceof ArithError) {
|
||||
// The `-i` coercion refused the text. GNU aborts the line the way
|
||||
// a bad subscript does, in the evaluator's voice with the text led.
|
||||
throw new ExitSignal(1, new TextEncoder().encode(`bash: ${err.message}\n`), null, 1)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Array-literal elements behave like any other shell word list: command
|
||||
// substitutions word-split and globs resolve to matches
|
||||
// (`a=($(cmd) /data/*.txt)`), with zero-match globs kept literal.
|
||||
export async function expandArrayItems(
|
||||
arrayNode: TSNodeLike,
|
||||
session: Session,
|
||||
executeFn: ExecuteFn,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
callStack: CallStack | null,
|
||||
): Promise<string[]> {
|
||||
const classified = await expandAndClassify(
|
||||
arrayNode.namedChildren,
|
||||
session,
|
||||
executeFn,
|
||||
registry,
|
||||
session.cwd,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
)
|
||||
const resolved = await resolveGlobs(
|
||||
classified,
|
||||
registry,
|
||||
session.shellOptions.noglob === true,
|
||||
namespace,
|
||||
globOptions(session),
|
||||
)
|
||||
return resolved.map((w) => wordText(w))
|
||||
}
|
||||
|
||||
const SUBSCRIPT_LITERAL_TYPES: ReadonlySet<string> = new Set([NT.WORD, NT.NUMBER, NT.ERROR])
|
||||
|
||||
/**
|
||||
* The expanded subscript text of one `name[...]=` assignment.
|
||||
*
|
||||
* A purely literal subscript keeps its raw spelling, spaces included
|
||||
* (bash stores `m[ k ]` under the key `" k "`); anything carrying an
|
||||
* expansion or quoting expands node by node so `m[$k]` and `m["a b"]`
|
||||
* resolve with quote removal. The associative path uses the result as
|
||||
* the key verbatim; the indexed path evaluates it as arithmetic.
|
||||
*/
|
||||
async function subscriptKeyText(
|
||||
subscriptNode: TSNodeLike,
|
||||
name: string,
|
||||
session: Session,
|
||||
executeFn: ExecuteFn,
|
||||
callStack: CallStack | null,
|
||||
view?: SessionView,
|
||||
): Promise<string> {
|
||||
const inner = subscriptNode.namedChildren.filter((sc) => sc.type !== NT.VARIABLE_NAME)
|
||||
const raw = subscriptNode.text.slice(name.length + 1, -1)
|
||||
if (inner.length === 0 || inner.every((sc) => SUBSCRIPT_LITERAL_TYPES.has(sc.type))) {
|
||||
return raw
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const sc of inner) {
|
||||
parts.push(await expandNode(sc, session, executeFn, callStack, view))
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one top-level variable assignment (`a=1`, `a[i]+=v`).
|
||||
*
|
||||
* Every spelling — scalar, array literal, subscript, append — is
|
||||
* computed with bash's own mechanics on a copy of the held value and
|
||||
* then stored through the session door, which owns the admission gate
|
||||
* and the scalar/array invariant.
|
||||
*/
|
||||
export async function executeAssignment(
|
||||
node: TSNodeLike,
|
||||
session: Session,
|
||||
executeFn: ExecuteFn,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
callStack: CallStack | null,
|
||||
): Promise<Result> {
|
||||
const text = getText(node)
|
||||
if (!text.includes('=')) {
|
||||
return [null, new IOResult(), new ExecutionNode({ command: text, exitCode: 0 })]
|
||||
}
|
||||
const subSeq = session.cmdsubSeq
|
||||
const subscriptNode = node.namedChildren.find((c) => c.type === 'subscript') ?? null
|
||||
const nameSource = subscriptNode ?? node
|
||||
const nameNode = nameSource.namedChildren.find((c) => c.type === NT.VARIABLE_NAME)
|
||||
const eq = text.indexOf('=')
|
||||
const spelled = nameNode !== undefined ? nameNode.text : text.slice(0, eq)
|
||||
// A name reference assigns to its target, whatever the shape of the
|
||||
// assignment; an unaimed one (`declare -n r; r=v`) resolves to itself
|
||||
// and takes the value as the target's name. The spelling is kept for
|
||||
// slicing the subscript out of the source.
|
||||
const key = deref(session, spelled) || spelled
|
||||
const append = node.children.some((c) => c.type === '+=')
|
||||
if (session.readonlyVars.has(key)) {
|
||||
// A bare assignment to a readonly variable is a fatal
|
||||
// variable-assignment error in non-interactive bash: the rest of
|
||||
// the line is abandoned (builtins like `export` merely fail with
|
||||
// 1 and continue).
|
||||
const err = new TextEncoder().encode(`bash: ${key}: readonly variable\n`)
|
||||
throw new ExitSignal(1, err, null, 1)
|
||||
}
|
||||
const valNodes = node.namedChildren.filter(
|
||||
(c) => c.type !== NT.VARIABLE_NAME && c.type !== 'subscript',
|
||||
)
|
||||
// Every branch below computes its resulting value with bash's own
|
||||
// mechanics on a copy, then stores through the one session door,
|
||||
// which owns the gate and the scalar/array invariant.
|
||||
const view = sessionView(session, registry.policies)
|
||||
const firstVal = valNodes[0]
|
||||
if (firstVal?.type === NT.ARRAY) {
|
||||
const items = await expandArrayItems(
|
||||
firstVal,
|
||||
session,
|
||||
executeFn,
|
||||
registry,
|
||||
namespace,
|
||||
callStack,
|
||||
)
|
||||
const heldMap = session.assocs[key]
|
||||
if (heldMap !== undefined) {
|
||||
const { map, badWords } = buildAssocLiteral(heldMap, items, append)
|
||||
await assignVar(view, key, map)
|
||||
if (badWords.length > 0) {
|
||||
const errBytes = new TextEncoder().encode(
|
||||
badWords
|
||||
.map(
|
||||
(word) =>
|
||||
`bash: ${key}: '${word}': must use subscript when assigning associative array`,
|
||||
)
|
||||
.join('\n') + '\n',
|
||||
)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: errBytes }),
|
||||
new ExecutionNode({ command: text, exitCode: 1, stderr: errBytes }),
|
||||
]
|
||||
}
|
||||
const mapCode = assignmentStatus(session, subSeq)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: mapCode }),
|
||||
new ExecutionNode({ command: text, exitCode: mapCode }),
|
||||
]
|
||||
}
|
||||
let held: ShellArray | null = session.arrays[key] ?? null
|
||||
if (append && held === null) {
|
||||
const scalar = session.env[key]
|
||||
held = scalar === undefined ? null : [scalar]
|
||||
}
|
||||
// `arr+=(...)` starts at the extent, so it fills the hole a
|
||||
// trailing `unset arr[last]` left but skips interior ones; a
|
||||
// `[i]=v` element places at i and the next plain word continues
|
||||
// from there.
|
||||
const base = buildIndexedLiteral(held, items, append, (sub) =>
|
||||
elementIndex(sub, visibleEnv(session), sessionElements(session)),
|
||||
)
|
||||
await assignVar(view, key, base)
|
||||
const arrCode = assignmentStatus(session, subSeq)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: arrCode }),
|
||||
new ExecutionNode({ command: text, exitCode: arrCode }),
|
||||
]
|
||||
}
|
||||
let val = text.slice(eq + 1)
|
||||
if (firstVal !== undefined) {
|
||||
val = await expandNode(
|
||||
firstVal,
|
||||
session,
|
||||
executeFn,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
)
|
||||
}
|
||||
if (subscriptNode !== null) {
|
||||
const subText = await subscriptKeyText(
|
||||
subscriptNode,
|
||||
spelled,
|
||||
session,
|
||||
executeFn,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
)
|
||||
const heldMap = session.assocs[key]
|
||||
const rawSub = subscriptNode.text.slice(spelled.length + 1, -1)
|
||||
if (rawSub.trim() === '' || (heldMap !== undefined && subText === '')) {
|
||||
// bash aborts the whole line on a bad assignment subscript
|
||||
// (status 1), naming the raw spelling (`m[$e]: bad array
|
||||
// subscript`). An indexed subscript that merely *expands*
|
||||
// empty stays legal (arithmetic on nothing is 0), so only the
|
||||
// associative kind checks the expanded text.
|
||||
const nameText = text.slice(0, eq).replace(/\+$/, '')
|
||||
throw new ExitSignal(
|
||||
1,
|
||||
new TextEncoder().encode(`bash: ${nameText}: bad array subscript\n`),
|
||||
null,
|
||||
1,
|
||||
)
|
||||
}
|
||||
if (heldMap !== undefined) {
|
||||
// The subscript is the key: no arithmetic, `m[1+1]` writes the
|
||||
// key "1+1".
|
||||
const newMap = { ...heldMap }
|
||||
newMap[subText] = append ? (heldMap[subText] ?? '') + val : val
|
||||
await assignVar(view, key, newMap)
|
||||
const mapCode = assignmentStatus(session, subSeq)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: mapCode }),
|
||||
new ExecutionNode({ command: text, exitCode: mapCode }),
|
||||
]
|
||||
}
|
||||
const existing = session.arrays[key]
|
||||
let arr: ShellArray
|
||||
if (existing === undefined) {
|
||||
const scalar = session.env[key]
|
||||
arr = scalar === undefined ? [] : [scalar]
|
||||
} else {
|
||||
arr = [...existing]
|
||||
}
|
||||
let idx = arrayIndex(subText, visibleEnv(session), sessionElements(session))
|
||||
if (idx < 0) idx += arrayExtent(arr)
|
||||
if (idx < 0) {
|
||||
// Same fatal shape as the empty subscript above.
|
||||
const nameText = text.slice(0, eq).replace(/\+$/, '')
|
||||
throw new ExitSignal(
|
||||
1,
|
||||
new TextEncoder().encode(`bash: ${nameText}: bad array subscript\n`),
|
||||
null,
|
||||
1,
|
||||
)
|
||||
}
|
||||
arraySet(arr, idx, append ? arrayGet(arr, idx) + val : val)
|
||||
await assignVar(view, key, arr)
|
||||
const subCode = assignmentStatus(session, subSeq)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: subCode }),
|
||||
new ExecutionNode({ command: text, exitCode: subCode }),
|
||||
]
|
||||
}
|
||||
const heldMap = session.assocs[key]
|
||||
const heldArr = session.arrays[key]
|
||||
if (heldMap !== undefined) {
|
||||
// `m=x` on an associative array writes the literal key "0" and
|
||||
// keeps every other key, as bash does.
|
||||
const newMap = { ...heldMap }
|
||||
newMap['0'] = append ? (heldMap['0'] ?? '') + val : val
|
||||
await assignVar(view, key, newMap)
|
||||
} else if (heldArr !== undefined) {
|
||||
// `a=x` writes element 0 and keeps the rest; `a+=x` appends onto
|
||||
// element 0.
|
||||
const newArr = [...heldArr]
|
||||
arraySet(newArr, 0, append ? arrayGet(newArr, 0) + val : val)
|
||||
await assignVar(view, key, newArr)
|
||||
} else {
|
||||
const heldVar = session.vars[key]
|
||||
let newVal: string
|
||||
if (append && heldVar?.attrs.has(VarAttr.Integer) === true) {
|
||||
// `n+=3` on an integer name adds: the door evaluates `old + new`,
|
||||
// so `declare -i n=5; n+=3` stores 8, not 53.
|
||||
newVal = `${session.env[key] ?? '0'} + (${val})`
|
||||
} else {
|
||||
newVal = append ? (session.env[key] ?? '') + val : val
|
||||
}
|
||||
await assignVar(view, key, newVal)
|
||||
}
|
||||
// Reassigning OPTIND (even to its current value) restarts the getopts
|
||||
// scan, matching bash's internal char pointer.
|
||||
if (key === 'OPTIND') session.getoptsOptind = null
|
||||
const code = assignmentStatus(session, subSeq)
|
||||
const assignIo = new IOResult({ exitCode: code })
|
||||
if (session.shellOptions.xtrace === true) {
|
||||
assignIo.stderr = traceAssignment(key, val, append)
|
||||
}
|
||||
return [null, assignIo, new ExecutionNode({ command: text, exitCode: code })]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// ========= 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. =========
|
||||
|
||||
// export/local/declare/readonly, pinned against bash 5.2.37. Mirrors
|
||||
// python/tests/workspace/node/test_declaration.py.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RAMResource } from '../../resource/ram/ram.ts'
|
||||
import { MountMode } from '../../types.ts'
|
||||
import { getTestParser, stderrStr, stdoutStr } from '../fixtures/workspace_fixture.ts'
|
||||
import { Workspace } from '../workspace/workspace.ts'
|
||||
|
||||
async function makeWs(): Promise<Workspace> {
|
||||
const parser = await getTestParser()
|
||||
return new Workspace(
|
||||
{ '/data': new RAMResource() },
|
||||
{ mode: MountMode.WRITE, shellParser: parser },
|
||||
)
|
||||
}
|
||||
|
||||
describe('executeDeclaration', () => {
|
||||
it('refuses an unknown option letter before any operand', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -q NAME')
|
||||
expect(stdoutStr(io)).toBe('')
|
||||
expect(stderrStr(io)).toBe(
|
||||
'bash: declare: -q: invalid option\n' +
|
||||
'declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] ' +
|
||||
'or declare -p [-aAfFilnrtux] [name ...]\n',
|
||||
)
|
||||
expect(io.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
it('lands readonly and export on one declaration', async () => {
|
||||
// Readonly answers first, so the export stamp has to land in the
|
||||
// readonly branch too or `-r` silently eats the `-x`.
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -rx X=1; declare -p X')
|
||||
expect(stdoutStr(io)).toBe('declare -rx X="1"\n')
|
||||
})
|
||||
|
||||
it('sets neither when lower and upper share a cluster', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -lu s=aBc; declare -p s')
|
||||
expect(stdoutStr(io)).toBe('declare -- s="aBc"\n')
|
||||
})
|
||||
|
||||
it('applies a shaping letter to later writes, not the held value', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('v=MiXeD; declare -l v; declare -p v; v=ABC; declare -p v')
|
||||
expect(stdoutStr(io)).toBe('declare -l v="MiXeD"\ndeclare -l v="abc"\n')
|
||||
})
|
||||
|
||||
it('refuses to convert between the two array kinds', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -a a; declare -A a')
|
||||
expect(stderrStr(io)).toBe('bash: declare: a: cannot convert indexed to associative array\n')
|
||||
expect(io.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses +r on a readonly name and keeps it frozen', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('readonly r=1; declare +r r')
|
||||
expect(stderrStr(io)).toBe('bash: declare: r: readonly variable\n')
|
||||
expect(io.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('cannot destroy an indexed array with +a', async () => {
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('a=(x); declare +a a')
|
||||
expect(stderrStr(io)).toBe('bash: declare: a: cannot destroy array variables in this way\n')
|
||||
expect(io.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('does not cost siblings their marks when one operand refuses', async () => {
|
||||
// `declare -x GOOD=1 1BAD=x` exits 1 and still exports GOOD: the
|
||||
// stamp reads the names the handler stored, not the exit code.
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('declare -x GOOD=1 1BAD=x; declare -p GOOD')
|
||||
expect(stderrStr(io)).toContain('not a valid identifier')
|
||||
expect(stdoutStr(io)).toBe('declare -x GOOD="1"\n')
|
||||
})
|
||||
|
||||
it('drops an unquoted empty expansion by word splitting', async () => {
|
||||
// `export $UNSET` is a bare `export` and prints the listing; the
|
||||
// quoted form is a real, empty operand and refuses.
|
||||
const ws = await makeWs()
|
||||
expect((await ws.execute('export $NOPE')).exitCode).toBe(0)
|
||||
const io = await ws.execute('export "$NOPE"')
|
||||
expect(stderrStr(io)).toBe("bash: export: `': not a valid identifier\n")
|
||||
expect(io.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves the old value intact when a staged array literal refuses', async () => {
|
||||
// Array literals are staged, not stored, so `readonly -a a=(y)` on
|
||||
// an already-readonly name fails with the old value intact. GNU
|
||||
// treats it as a fatal variable-assignment error, so the rest of
|
||||
// that line never runs -- the value is read back on the next one.
|
||||
const ws = await makeWs()
|
||||
const io = await ws.execute('readonly -a a=(x); readonly -a a=(y); echo REACHED')
|
||||
expect(stderrStr(io)).toBe('bash: a: readonly variable\n')
|
||||
expect(io.exitCode).toBe(1)
|
||||
expect(stdoutStr(await ws.execute('declare -p a'))).toBe('declare -ar a=([0]="x")\n')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,564 @@
|
||||
// ========= 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. =========
|
||||
|
||||
import { type ByteSource, IOResult } from '../../io/types.ts'
|
||||
import type { CallStack } from '../../shell/call_stack.ts'
|
||||
import { ExitSignal } from '../../shell/errors.ts'
|
||||
import { getDeclarationKeyword, getText } from '../../shell/helpers.ts'
|
||||
import { NodeType as NT, type TSNodeLike } from '../../shell/types.ts'
|
||||
import { VarAttr } from '../../shell/variable.ts'
|
||||
import { PolicyDenied } from '../../policy/errors.ts'
|
||||
import { compareCodePoints } from '../../utils/sort.ts'
|
||||
import type { SessionView } from '../../ops/types.ts'
|
||||
import {
|
||||
handleDeclareFunctions,
|
||||
handleDeclarePrint,
|
||||
handleExport,
|
||||
handleLocal,
|
||||
handleReadonly,
|
||||
noteLocalArray,
|
||||
} from '../executor/builtins/index.ts'
|
||||
import { type ExecuteFn, expandNode } from '../expand/node.ts'
|
||||
import type { Namespace } from '../mount/namespace/namespace.ts'
|
||||
import type { MountRegistry } from '../mount/registry.ts'
|
||||
import type { Session } from '../session/session.ts'
|
||||
import { ensureVarVisible, seedVar, sessionView, setAttr } from '../session/state.ts'
|
||||
import { ExecutionNode } from '../types.ts'
|
||||
import { expandArrayItems } from './assignment.ts'
|
||||
|
||||
type Result = [ByteSource | null, IOResult, ExecutionNode]
|
||||
|
||||
/**
|
||||
* Fold kind-conversion refusals into a declaration's result.
|
||||
*
|
||||
* GNU reports `cannot convert indexed to associative array` per refused
|
||||
* name on stderr and fails the builtin with 1 while the other operands
|
||||
* still declare, so the refusals ride the handler's own result rather
|
||||
* than replacing it.
|
||||
*/
|
||||
function mergeConversionErrors(result: Result, errors: readonly string[]): Result {
|
||||
if (errors.length === 0) return result
|
||||
const [stream, io, node] = result
|
||||
const extra = new TextEncoder().encode(errors.join('\n') + '\n')
|
||||
const prior = io.stderr instanceof Uint8Array ? io.stderr : new Uint8Array(0)
|
||||
const merged = new Uint8Array(prior.length + extra.length)
|
||||
merged.set(prior, 0)
|
||||
merged.set(extra, prior.length)
|
||||
const newIo = new IOResult({
|
||||
exitCode: 1,
|
||||
stderr: merged,
|
||||
reads: io.reads,
|
||||
writes: io.writes,
|
||||
cache: io.cache,
|
||||
})
|
||||
return [stream, newIo, new ExecutionNode({ command: node.command, exitCode: 1, stderr: merged })]
|
||||
}
|
||||
|
||||
// Every letter GNU's `declare` accepts, so a typo refuses with the usage
|
||||
// line instead of being silently dropped. `-a`/`-A` are kinds, not
|
||||
// attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
|
||||
// /`-I` are modes the handlers read. `-n` is accepted and stored, but
|
||||
// aliasing (reads and writes through the reference) is not wired: it is
|
||||
// a separate seam through every expansion site, so a name carrying it
|
||||
// declares and prints, and nothing more, rather than a partial alias
|
||||
// that works in some spellings and not others.
|
||||
// `-n` stores the reference and every reader and writer resolves through
|
||||
// it (`deref` in `session/state`).
|
||||
const DECLARE_LETTERS: ReadonlySet<string> = new Set('aAfFgiIlnprtux')
|
||||
const DECLARE_USAGE =
|
||||
'declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] or declare -p [-aAfFilnrtux] [name ...]'
|
||||
// The stored attributes a `-letter` / `+letter` toggles.
|
||||
const ATTR_LETTERS: ReadonlyMap<string, VarAttr> = new Map([
|
||||
['i', VarAttr.Integer],
|
||||
['l', VarAttr.Lower],
|
||||
['u', VarAttr.Upper],
|
||||
['n', VarAttr.Nameref],
|
||||
['t', VarAttr.Trace],
|
||||
['x', VarAttr.Export],
|
||||
['r', VarAttr.Readonly],
|
||||
])
|
||||
|
||||
/** The attributes the given letters name, in the order given, skipping
|
||||
* letters that name none (kinds and modes are not attributes). */
|
||||
function attrsFor(letters: string, has: (c: string) => boolean): VarAttr[] {
|
||||
const out: VarAttr[] = []
|
||||
for (const c of letters) {
|
||||
const attr = ATTR_LETTERS.get(c)
|
||||
if (attr !== undefined && has(c)) out.push(attr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal a `declare` family option cluster earns, if any.
|
||||
*
|
||||
* An unknown letter is GNU's `invalid option` plus the usage line, exit
|
||||
* 2, and it wins over every other check because bash refuses the
|
||||
* cluster before it looks at a single operand.
|
||||
*/
|
||||
function declareOptionRefusal(
|
||||
cmd: string,
|
||||
flagChars: ReadonlySet<string>,
|
||||
plusChars: ReadonlySet<string>,
|
||||
): Result | null {
|
||||
const bad = [...flagChars, ...plusChars]
|
||||
.sort(compareCodePoints)
|
||||
.find((c) => !DECLARE_LETTERS.has(c))
|
||||
if (bad === undefined) return null
|
||||
const sign = flagChars.has(bad) ? '-' : '+'
|
||||
const err = new TextEncoder().encode(
|
||||
`bash: ${cmd}: ${sign}${bad}: invalid option\n${DECLARE_USAGE}\n`,
|
||||
)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 2, stderr: err }),
|
||||
new ExecutionNode({ command: cmd, exitCode: 2, stderr: err }),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-name refusals a `+letter` earns after the operands are known.
|
||||
*
|
||||
* Two letters cannot be taken off. `+r` on a readonly name is
|
||||
* `declare: R: readonly variable`, exit 1, and the name stays frozen.
|
||||
* `+a` / `+A` on an array is `cannot destroy array variables in this
|
||||
* way`, exit 1, since the kind is what the value is, not a mark. Both
|
||||
* are pinned on 5.2.37 and neither stops the other operands from
|
||||
* declaring; the first refusal is what the builtin reports.
|
||||
*/
|
||||
function plusRefusals(
|
||||
cmd: string,
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
plusChars: ReadonlySet<string>,
|
||||
assignments: readonly string[],
|
||||
staged: readonly { name: string }[] | null,
|
||||
): Result | null {
|
||||
if (!plusChars.has('r') && !plusChars.has('a') && !plusChars.has('A')) return null
|
||||
const names = assignments.map((a) => a.split('=')[0] ?? a)
|
||||
for (const { name } of staged ?? []) names.push(name)
|
||||
for (const name of names) {
|
||||
if (plusChars.has('r') && view.isReadonly(name)) {
|
||||
const err = new TextEncoder().encode(`bash: ${cmd}: ${name}: readonly variable\n`)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: err }),
|
||||
new ExecutionNode({ command: cmd, exitCode: 1, stderr: err }),
|
||||
]
|
||||
}
|
||||
if (
|
||||
(plusChars.has('a') && Object.hasOwn(session.arrays, name)) ||
|
||||
(plusChars.has('A') && Object.hasOwn(session.assocs, name))
|
||||
) {
|
||||
const err = new TextEncoder().encode(
|
||||
`bash: ${cmd}: ${name}: cannot destroy array variables in this way\n`,
|
||||
)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: err }),
|
||||
new ExecutionNode({ command: cmd, exitCode: 1, stderr: err }),
|
||||
]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every `-attr` / `+attr` letter to the names a declaration
|
||||
* stored, on top of the export stamp.
|
||||
*
|
||||
* The letters that shape a value (`-i -l -u`) are stored as attributes
|
||||
* and applied by the door on every *later* write, which is GNU's rule:
|
||||
* `v=MiXeD; declare -l v` keeps `MiXeD`, and the next `v=ABC` stores
|
||||
* `abc`. So this stamps and never rewrites. `-l` and `-u` are exclusive:
|
||||
* setting one clears the other, and a cluster naming both (`-lu`, `-ul`)
|
||||
* sets neither, both pinned on 5.2.37. A `+` letter clears; `+r` is
|
||||
* refused earlier on a readonly name and a no-op otherwise, so it is not
|
||||
* an off toggle. Through the gated mark door for every name, covered or
|
||||
* not: the handler already cleared the gate for these names, so this is
|
||||
* one redundant policy call per attribute, and it keeps this stamp out
|
||||
* of the ungated-write allowlist that `setAttr` sites must justify.
|
||||
*/
|
||||
async function stampAttrs(
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
flagChars: ReadonlySet<string>,
|
||||
plusChars: ReadonlySet<string>,
|
||||
assignments: readonly string[],
|
||||
staged: readonly { name: string }[] | null,
|
||||
stored: readonly string[],
|
||||
): Promise<Result | null> {
|
||||
const refused = await stampExport(session, view, flagChars, assignments, staged, stored)
|
||||
if (refused !== null) return refused
|
||||
let onAttrs = attrsFor('ilunt', (c) => flagChars.has(c) && !plusChars.has(c))
|
||||
if (flagChars.has('l') && flagChars.has('u')) {
|
||||
onAttrs = onAttrs.filter((a) => a !== VarAttr.Lower && a !== VarAttr.Upper)
|
||||
}
|
||||
const offAttrs = attrsFor('iluntx', (c) => plusChars.has(c))
|
||||
if (onAttrs.length === 0 && offAttrs.length === 0) return null
|
||||
try {
|
||||
for (const name of stored) {
|
||||
for (const attr of onAttrs) {
|
||||
await view.mark(name, attr, true)
|
||||
// `-l` displaces `-u` and vice versa; the record keeps one.
|
||||
if (attr === VarAttr.Lower) await view.mark(name, VarAttr.Upper, false)
|
||||
else if (attr === VarAttr.Upper) await view.mark(name, VarAttr.Lower, false)
|
||||
}
|
||||
for (const attr of offAttrs) await view.mark(name, attr, false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof PolicyDenied)) throw err
|
||||
const denied = new TextEncoder().encode(`${err.message}\n`)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: denied }),
|
||||
new ExecutionNode({ command: 'declare', exitCode: 1, stderr: denied }),
|
||||
]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark every name a `-x` declaration stored as exported.
|
||||
*
|
||||
* `declare -x NAME` marks an existing name without touching its value and
|
||||
* `declare -x NAME=v` assigns then marks, so the stamp lands after the
|
||||
* assignment either way. Staged array literals are stamped too, since an
|
||||
* array is as exportable as a scalar: GNU answers `declare -x A=(a b)`
|
||||
* with `declare -ax A=([0]="a" [1]="b")`, and reading only `assignments`
|
||||
* left every `declare -x NAME=(...)` unmarked.
|
||||
*
|
||||
* Shared by the readonly and the plain declaration branch because
|
||||
* `declare -rx X=1` goes down the readonly one and still owes the export
|
||||
* attribute.
|
||||
*
|
||||
* Only the names the handler reports storing are marked, and marking is
|
||||
* not gated on the aggregate status: a declaration keeps its valid
|
||||
* operands when a sibling refuses, so `declare -x GOOD=1 1BAD=x` exits 1
|
||||
* and still answers `declare -x GOOD="1"`.
|
||||
*
|
||||
* A name that carried a value went through `view.set`, so its mark rides
|
||||
* on that decision; a bare name did not, and on an *existing* name the
|
||||
* handler writes nothing at all, so the mark is the only session write
|
||||
* there is and has to clear `pre_session` itself. Stamping it through
|
||||
* `setAttr` let `declare -x AWS_TOKEN` export a host-seeded credential
|
||||
* the deployment had refused.
|
||||
*/
|
||||
async function stampExport(
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
flagChars: ReadonlySet<string>,
|
||||
assignments: readonly string[],
|
||||
staged: readonly { name: string }[] | null,
|
||||
stored: readonly string[],
|
||||
): Promise<Result | null> {
|
||||
if (!flagChars.has('x')) return null
|
||||
const covered = new Set<string>()
|
||||
for (const a of assignments) {
|
||||
const eq = a.indexOf('=')
|
||||
if (eq >= 0) covered.add(a.slice(0, eq))
|
||||
}
|
||||
for (const { name } of staged ?? []) covered.add(name)
|
||||
for (const name of stored) {
|
||||
if (covered.has(name)) {
|
||||
setAttr(session, name, VarAttr.Export)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await view.mark(name, VarAttr.Export, true)
|
||||
} catch (err) {
|
||||
if (!(err instanceof PolicyDenied)) throw err
|
||||
const encoded = new TextEncoder().encode(`${err.message}\n`)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: encoded }),
|
||||
new ExecutionNode({ command: 'declare', exitCode: 1, stderr: encoded }),
|
||||
]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one declaration statement (export/local/declare/readonly).
|
||||
*
|
||||
* The executor only reads the operands: it expands them, sorts them
|
||||
* into option letters, plain names and staged array literals, then
|
||||
* hands the result to the builtin handler that owns the keyword. The
|
||||
* attribute letters (`-x`, `-i`, `-l`) are stamped afterwards through
|
||||
* the same gated door, so `declare -rx X=1` keeps both marks.
|
||||
*/
|
||||
export async function executeDeclaration(
|
||||
node: TSNodeLike,
|
||||
session: Session,
|
||||
executeFn: ExecuteFn,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
callStack: CallStack | null,
|
||||
): Promise<Result> {
|
||||
const keyword = getDeclarationKeyword(node)
|
||||
const assignments: string[] = []
|
||||
// Array literals are staged, not stored: `readonly -a a=(y)` on an
|
||||
// already-readonly name has to fail with the old value intact.
|
||||
const staged: { name: string; append: boolean; items: string[] }[] = []
|
||||
// Option words are kept verbatim, in order, so `--` survives as an
|
||||
// end-of-options marker and the handlers can name the *first* bad option
|
||||
// letter the way bash does.
|
||||
const flagWords: string[] = []
|
||||
const flagChars = new Set<string>()
|
||||
const plusChars = new Set<string>()
|
||||
let optsDone = false
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === NT.VARIABLE_ASSIGNMENT) {
|
||||
const valNodes = child.namedChildren.filter((c) => c.type !== NT.VARIABLE_NAME)
|
||||
const firstVal = valNodes[0]
|
||||
if (firstVal?.type === NT.ARRAY) {
|
||||
const text = getText(child)
|
||||
const eq = text.indexOf('=')
|
||||
const key = eq >= 0 ? text.slice(0, eq) : text
|
||||
const append = key.endsWith('+')
|
||||
staged.push({
|
||||
name: append ? key.slice(0, -1) : key,
|
||||
append,
|
||||
items: await expandArrayItems(
|
||||
firstVal,
|
||||
session,
|
||||
executeFn,
|
||||
registry,
|
||||
namespace,
|
||||
callStack,
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
assignments.push(
|
||||
await expandNode(
|
||||
child,
|
||||
session,
|
||||
executeFn,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
),
|
||||
)
|
||||
} else if (
|
||||
child.type === NT.SIMPLE_EXPANSION ||
|
||||
child.type === NT.EXPANSION ||
|
||||
child.type === NT.CONCATENATION ||
|
||||
child.type === NT.WORD ||
|
||||
// A bare `readonly NAME` / `export NAME` operand parses as a
|
||||
// variable_name, not a word, and a quoted assignment
|
||||
// (`export 'FOO=bar'`) as a plain string operand.
|
||||
child.type === NT.VARIABLE_NAME ||
|
||||
child.type === NT.STRING ||
|
||||
child.type === NT.RAW_STRING ||
|
||||
child.type === NT.ANSI_C_STRING ||
|
||||
child.type === NT.TRANSLATED_STRING
|
||||
) {
|
||||
const expanded = await expandNode(
|
||||
child,
|
||||
session,
|
||||
executeFn,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
)
|
||||
// An *unquoted* expansion that came back empty is removed by
|
||||
// word splitting, so `export $UNSET` is a bare `export` and
|
||||
// prints the listing. A quoted one is a real, empty operand:
|
||||
// GNU answers both `export ""` and `export "$UNSET"` with
|
||||
// ``export: `': not a valid identifier``, so it has to reach
|
||||
// the builtin rather than vanish here.
|
||||
if (expanded === '' && (child.type === NT.SIMPLE_EXPANSION || child.type === NT.EXPANSION))
|
||||
continue
|
||||
if (!optsDone && expanded.startsWith('-') && expanded.length > 1) {
|
||||
flagWords.push(expanded)
|
||||
if (expanded === '--') optsDone = true
|
||||
else for (const ch of expanded.slice(1)) flagChars.add(ch)
|
||||
} else if (
|
||||
!optsDone &&
|
||||
expanded.startsWith('+') &&
|
||||
expanded.length > 1 &&
|
||||
(keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset')
|
||||
) {
|
||||
// `+attr` turns an attribute off. Only the declare family
|
||||
// reads it: `export +x` and `readonly +r` are `not a valid
|
||||
// identifier` in GNU, so for those two the word falls through
|
||||
// as an operand and refuses there.
|
||||
for (const ch of expanded.slice(1)) plusChars.add(ch)
|
||||
} else {
|
||||
assignments.push(expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
const cmdWord = keyword === NT.LOCAL ? 'local' : keyword
|
||||
if (keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset') {
|
||||
const refused = declareOptionRefusal(cmdWord, flagChars, plusChars)
|
||||
if (refused !== null) return refused
|
||||
}
|
||||
if (
|
||||
(flagChars.has('f') || flagChars.has('F')) &&
|
||||
(keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset')
|
||||
) {
|
||||
// `-f`/`-F` select functions, not variables: `-rf` freezes, `-f
|
||||
// NAME` prints the body, `-F NAME` prints the name, and a missing
|
||||
// name is exit 1 without a word.
|
||||
return handleDeclareFunctions(cmdWord, session, flagChars, assignments)
|
||||
}
|
||||
const isReadonly = keyword === 'readonly' || flagChars.has('r')
|
||||
// `-l` and `-u` cannot both hold; a cluster naming both sets neither
|
||||
// (pinned: `declare -lu s=aBc` prints `declare -- s`).
|
||||
let shaping = new Set(attrsFor('ilu', (c) => flagChars.has(c) && !plusChars.has(c)))
|
||||
if (shaping.has(VarAttr.Lower) && shaping.has(VarAttr.Upper)) {
|
||||
shaping = new Set([...shaping].filter((a) => a !== VarAttr.Lower && a !== VarAttr.Upper))
|
||||
}
|
||||
const conversionErrors: string[] = []
|
||||
if (flagChars.has('A') || flagChars.has('a')) {
|
||||
// `declare -a NAME` / `declare -A NAME` with no value declare an
|
||||
// empty array of that kind, so ${#NAME[@]} is 0 and an element
|
||||
// write leaves the other slots unassigned. GNU refuses to
|
||||
// convert between the two kinds and says so per name while the
|
||||
// rest of the operands still declare.
|
||||
const wantAssoc = flagChars.has('A')
|
||||
for (const bare of assignments) {
|
||||
if (bare.includes('=')) continue
|
||||
// Both branches below write array storage raw (the top-level
|
||||
// one migrates an existing scalar), so a hidden name refuses
|
||||
// like any assignment spelling before either lands.
|
||||
try {
|
||||
ensureVarVisible(session, bare)
|
||||
} catch (err) {
|
||||
if (!(err instanceof PolicyDenied)) throw err
|
||||
throw new ExitSignal(1, new TextEncoder().encode(`${err.message}\n`), null, 1)
|
||||
}
|
||||
if (wantAssoc && Object.hasOwn(session.arrays, bare)) {
|
||||
conversionErrors.push(
|
||||
`bash: ${cmdWord}: ${bare}: cannot convert indexed to associative array`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!wantAssoc && Object.hasOwn(session.assocs, bare)) {
|
||||
conversionErrors.push(
|
||||
`bash: ${cmdWord}: ${bare}: cannot convert associative to indexed array`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!flagChars.has('g') && noteLocalArray(session, bare)) {
|
||||
// Inside a function this shadows whatever the caller had with
|
||||
// a fresh empty array of the declared kind; `-g` declares at
|
||||
// global scope instead.
|
||||
seedVar(session, bare, wantAssoc ? {} : [])
|
||||
} else if (wantAssoc && !Object.hasOwn(session.assocs, bare)) {
|
||||
// At top level an existing scalar becomes the value at the
|
||||
// literal key "0" (GNU allows scalar-to-associative
|
||||
// conversion, unlike indexed).
|
||||
const scalar = session.env[bare]
|
||||
seedVar(session, bare, scalar === undefined ? {} : { '0': scalar })
|
||||
} else if (!wantAssoc && !Object.hasOwn(session.arrays, bare)) {
|
||||
// At top level an existing scalar becomes element 0.
|
||||
const scalar = session.env[bare]
|
||||
seedVar(session, bare, scalar === undefined ? [] : [scalar])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Array literals travel as data: the handler stores them through
|
||||
// the session door and owns both refusal voices, so the executor
|
||||
// only expands and stages.
|
||||
if (isReadonly) {
|
||||
// Only the `readonly` keyword owns -p / illegal-option handling;
|
||||
// `declare -r` keeps names only.
|
||||
const declView = sessionView(session, registry.policies)
|
||||
const stored: string[] = []
|
||||
const result =
|
||||
keyword === 'readonly'
|
||||
? await handleReadonly(
|
||||
[...flagWords, ...assignments],
|
||||
session,
|
||||
declView,
|
||||
staged,
|
||||
stored,
|
||||
flagChars.has('A'),
|
||||
shaping,
|
||||
)
|
||||
: await handleReadonly(
|
||||
assignments,
|
||||
session,
|
||||
declView,
|
||||
staged,
|
||||
stored,
|
||||
flagChars.has('A'),
|
||||
shaping,
|
||||
)
|
||||
// `declare -rx X=1` carries both attributes: GNU prints
|
||||
// `declare -rx X="1"`. Readonly answers first, so the export stamp
|
||||
// has to land here too, or `-r` silently ate the `-x`.
|
||||
const refused = await stampAttrs(
|
||||
session,
|
||||
declView,
|
||||
flagChars,
|
||||
plusChars,
|
||||
assignments,
|
||||
staged,
|
||||
stored,
|
||||
)
|
||||
return refused ?? mergeConversionErrors(result, conversionErrors)
|
||||
}
|
||||
// declare/typeset scope like `local` inside a function (bash
|
||||
// semantics) and assign globally at top level, which is exactly
|
||||
// handleLocal's fallback when no function scope is active.
|
||||
if (keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset') {
|
||||
// `-p` prints rather than declares, so it is answered before the
|
||||
// assignment path runs at all.
|
||||
if (
|
||||
(flagChars.has('p') || plusChars.has('p')) &&
|
||||
(keyword === 'declare' || keyword === 'typeset')
|
||||
) {
|
||||
return handleDeclarePrint(assignments, session)
|
||||
}
|
||||
const declView2 = sessionView(session, registry.policies)
|
||||
const stored2: string[] = []
|
||||
const result = await handleLocal(
|
||||
assignments,
|
||||
session,
|
||||
declView2,
|
||||
staged,
|
||||
// `declare`/`typeset` share this handler but have to name
|
||||
// themselves in a diagnostic rather than say `local`.
|
||||
cmdWord,
|
||||
stored2,
|
||||
flagChars.has('A'),
|
||||
shaping,
|
||||
flagChars.has('n') && !plusChars.has('n'),
|
||||
flagChars.has('g'),
|
||||
)
|
||||
const plusRefused = plusRefusals(cmdWord, session, declView2, plusChars, assignments, staged)
|
||||
if (plusRefused !== null) return plusRefused
|
||||
const refused2 = await stampAttrs(
|
||||
session,
|
||||
declView2,
|
||||
flagChars,
|
||||
plusChars,
|
||||
assignments,
|
||||
staged,
|
||||
stored2,
|
||||
)
|
||||
return refused2 ?? mergeConversionErrors(result, conversionErrors)
|
||||
}
|
||||
// Pass export flags through so -p / bare print and illegal options work.
|
||||
const exportResult = await handleExport(
|
||||
[...flagWords, ...assignments],
|
||||
session,
|
||||
sessionView(session, registry.policies),
|
||||
staged,
|
||||
)
|
||||
return mergeConversionErrors(exportResult, conversionErrors)
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { deref, seedVar } from '../session/state.ts'
|
||||
import type { Runtime } from '../../runtime/base.ts'
|
||||
import type { PolicyDecision } from '../../runtime/policy/index.ts'
|
||||
import { asyncChain } from '../../io/stream.ts'
|
||||
@@ -25,7 +24,6 @@ import { assignmentStatus, finishStatement } from '../executor/statement.ts'
|
||||
import {
|
||||
getCaseItems,
|
||||
getCaseWord,
|
||||
getDeclarationKeyword,
|
||||
getCforParts,
|
||||
getForParts,
|
||||
getFunctionBody,
|
||||
@@ -46,21 +44,10 @@ import { expandRedirects } from '../expand/redirects.ts'
|
||||
import { type ExecuteFn, expandArith, expandNode } from '../expand/node.ts'
|
||||
import { expandPattern } from '../expand/pattern.ts'
|
||||
import { evaluateArith } from '../../shell/arith.ts'
|
||||
import {
|
||||
type ShellArray,
|
||||
arrayExtent,
|
||||
arrayGet,
|
||||
arraySet,
|
||||
buildAssocLiteral,
|
||||
buildIndexedLiteral,
|
||||
} from '../../shell/array.ts'
|
||||
import { ArithError, ExitSignal, ReadonlyError } from '../../shell/errors.ts'
|
||||
import { ArithError, ReadonlyError } from '../../shell/errors.ts'
|
||||
import { expandAndClassify } from '../expand/parts.ts'
|
||||
import { arrayIndex } from '../expand/variable.ts'
|
||||
import { assignElement } from '../session/elements.ts'
|
||||
import type { ArithResult, TSNodeLike } from '../../shell/types.ts'
|
||||
import { wordText } from '../../types.ts'
|
||||
import { compareCodePoints } from '../../utils/sort.ts'
|
||||
import {
|
||||
type CforEval,
|
||||
handleCase,
|
||||
@@ -72,16 +59,7 @@ import {
|
||||
handleWhile,
|
||||
} from '../executor/control.ts'
|
||||
import type { DispatchFn } from '../../runtime/types.ts'
|
||||
import {
|
||||
handleExport,
|
||||
handleDeclareFunctions,
|
||||
handleDeclarePrint,
|
||||
handleLocal,
|
||||
handleReadonly,
|
||||
handleTest,
|
||||
handleUnset,
|
||||
noteLocalArray,
|
||||
} from '../executor/builtins/index.ts'
|
||||
import { handleTest, handleUnset } from '../executor/builtins/index.ts'
|
||||
import { handleConnection, handlePipe, handleSubshell } from '../executor/pipes.ts'
|
||||
import { handleRedirect } from '../executor/redirect.ts'
|
||||
import type { Namespace } from '../mount/namespace/namespace.ts'
|
||||
@@ -93,18 +71,11 @@ import { expandDoubleBracket, expandTestExpr } from './test_expr.ts'
|
||||
import { executeProgram } from './program.ts'
|
||||
import { installExecRedirects } from '../executor/builtins/exec_cmd.ts'
|
||||
import { executeCommand } from './command_dispatch.ts'
|
||||
import { executeAssignment } from './assignment.ts'
|
||||
import { executeDeclaration } from './declaration.ts'
|
||||
import { PolicyDenied } from '../../policy/errors.ts'
|
||||
import type { SessionView } from '../../ops/types.ts'
|
||||
import {
|
||||
elementIndex,
|
||||
ensureVarVisible,
|
||||
sessionElements,
|
||||
sessionView,
|
||||
setAttr,
|
||||
visibleEnv,
|
||||
} from '../session/state.ts'
|
||||
import { type ShellValue, VarAttr } from '../../shell/variable.ts'
|
||||
import { traceAssignment } from '../../shell/xtrace.ts'
|
||||
import { ensureVarVisible, sessionElements, sessionView, visibleEnv } from '../session/state.ts'
|
||||
import { Channel, type JobConsole } from '../../shell/console/index.ts'
|
||||
import { type ExecuteNodeOpts, pump } from '../executor/jobs.ts'
|
||||
|
||||
@@ -146,32 +117,6 @@ function withOpts(base: ExecuteNodeDeps, opts?: ExecuteNodeOpts): ExecuteNodeDep
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* One assignment through the session door; denial is fatal.
|
||||
*
|
||||
* Every assignment spelling (scalar, array literal, subscript, append)
|
||||
* computes its resulting value and stores through `view.set`, so the
|
||||
* gate and the storage invariant live in the door, not here. Denial
|
||||
* mirrors the readonly case: a fatal variable-assignment error that
|
||||
* abandons the rest of the line.
|
||||
*/
|
||||
async function assignVar(view: SessionView, key: string, value: ShellValue): Promise<void> {
|
||||
try {
|
||||
await view.set(key, value)
|
||||
} catch (err) {
|
||||
if (err instanceof PolicyDenied) {
|
||||
const denied = new TextEncoder().encode(`${err.message}\n`)
|
||||
throw new ExitSignal(1, denied, null, 1)
|
||||
}
|
||||
if (err instanceof ArithError) {
|
||||
// The `-i` coercion refused the text. GNU aborts the line the way
|
||||
// a bad subscript does, in the evaluator's voice with the text led.
|
||||
throw new ExitSignal(1, new TextEncoder().encode(`bash: ${err.message}\n`), null, 1)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one C-style for expression slot: the slot's integer value,
|
||||
* or the default for an empty slot (1 for the condition so `for
|
||||
@@ -213,36 +158,6 @@ async function evalCforExpr(
|
||||
return Number(result.value)
|
||||
}
|
||||
|
||||
// Array-literal elements behave like any other shell word list: command
|
||||
// substitutions word-split and globs resolve to matches
|
||||
// (`a=($(cmd) /data/*.txt)`), with zero-match globs kept literal.
|
||||
async function expandArrayItems(
|
||||
arrayNode: TSNodeLike,
|
||||
session: Session,
|
||||
executeFn: ExecuteFn,
|
||||
registry: MountRegistry,
|
||||
namespace: Namespace,
|
||||
callStack: CallStack | null,
|
||||
): Promise<string[]> {
|
||||
const classified = await expandAndClassify(
|
||||
arrayNode.namedChildren,
|
||||
session,
|
||||
executeFn,
|
||||
registry,
|
||||
session.cwd,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
)
|
||||
const resolved = await resolveGlobs(
|
||||
classified,
|
||||
registry,
|
||||
session.shellOptions.noglob === true,
|
||||
namespace,
|
||||
globOptions(session),
|
||||
)
|
||||
return resolved.map((w) => wordText(w))
|
||||
}
|
||||
|
||||
async function recurseReassociated(
|
||||
recurse: Recurse,
|
||||
dispatch: DispatchFn,
|
||||
@@ -354,288 +269,6 @@ export interface ExecuteNodeDeps {
|
||||
sink?: JobConsole
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark every name a `-x` declaration stored as exported.
|
||||
*
|
||||
* `declare -x NAME` marks an existing name without touching its value and
|
||||
* `declare -x NAME=v` assigns then marks, so the stamp lands after the
|
||||
* assignment either way. Staged array literals are stamped too, since an
|
||||
* array is as exportable as a scalar: GNU answers `declare -x A=(a b)`
|
||||
* with `declare -ax A=([0]="a" [1]="b")`, and reading only `assignments`
|
||||
* left every `declare -x NAME=(...)` unmarked.
|
||||
*
|
||||
* Shared by the readonly and the plain declaration branch because
|
||||
* `declare -rx X=1` goes down the readonly one and still owes the export
|
||||
* attribute.
|
||||
*
|
||||
* Only the names the handler reports storing are marked, and marking is
|
||||
* not gated on the aggregate status: a declaration keeps its valid
|
||||
* operands when a sibling refuses, so `declare -x GOOD=1 1BAD=x` exits 1
|
||||
* and still answers `declare -x GOOD="1"`.
|
||||
*
|
||||
* A name that carried a value went through `view.set`, so its mark rides
|
||||
* on that decision; a bare name did not, and on an *existing* name the
|
||||
* handler writes nothing at all, so the mark is the only session write
|
||||
* there is and has to clear `pre_session` itself. Stamping it through
|
||||
* `setAttr` let `declare -x AWS_TOKEN` export a host-seeded credential
|
||||
* the deployment had refused.
|
||||
*/
|
||||
const SUBSCRIPT_LITERAL_TYPES: ReadonlySet<string> = new Set([NT.WORD, NT.NUMBER, NT.ERROR])
|
||||
|
||||
/**
|
||||
* The expanded subscript text of one `name[...]=` assignment.
|
||||
*
|
||||
* A purely literal subscript keeps its raw spelling, spaces included
|
||||
* (bash stores `m[ k ]` under the key `" k "`); anything carrying an
|
||||
* expansion or quoting expands node by node so `m[$k]` and `m["a b"]`
|
||||
* resolve with quote removal. The associative path uses the result as
|
||||
* the key verbatim; the indexed path evaluates it as arithmetic.
|
||||
*/
|
||||
async function subscriptKeyText(
|
||||
subscriptNode: TSNodeLike,
|
||||
name: string,
|
||||
session: Session,
|
||||
executeFn: ExecuteFn,
|
||||
callStack: CallStack | null,
|
||||
view?: SessionView,
|
||||
): Promise<string> {
|
||||
const inner = subscriptNode.namedChildren.filter((sc) => sc.type !== NT.VARIABLE_NAME)
|
||||
const raw = subscriptNode.text.slice(name.length + 1, -1)
|
||||
if (inner.length === 0 || inner.every((sc) => SUBSCRIPT_LITERAL_TYPES.has(sc.type))) {
|
||||
return raw
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const sc of inner) {
|
||||
parts.push(await expandNode(sc, session, executeFn, callStack, view))
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold kind-conversion refusals into a declaration's result.
|
||||
*
|
||||
* GNU reports `cannot convert indexed to associative array` per refused
|
||||
* name on stderr and fails the builtin with 1 while the other operands
|
||||
* still declare, so the refusals ride the handler's own result rather
|
||||
* than replacing it.
|
||||
*/
|
||||
function mergeConversionErrors(result: Result, errors: readonly string[]): Result {
|
||||
if (errors.length === 0) return result
|
||||
const [stream, io, node] = result
|
||||
const extra = new TextEncoder().encode(errors.join('\n') + '\n')
|
||||
const prior = io.stderr instanceof Uint8Array ? io.stderr : new Uint8Array(0)
|
||||
const merged = new Uint8Array(prior.length + extra.length)
|
||||
merged.set(prior, 0)
|
||||
merged.set(extra, prior.length)
|
||||
const newIo = new IOResult({
|
||||
exitCode: 1,
|
||||
stderr: merged,
|
||||
reads: io.reads,
|
||||
writes: io.writes,
|
||||
cache: io.cache,
|
||||
})
|
||||
return [stream, newIo, new ExecutionNode({ command: node.command, exitCode: 1, stderr: merged })]
|
||||
}
|
||||
|
||||
// Every letter GNU's `declare` accepts, so a typo refuses with the usage
|
||||
// line instead of being silently dropped. `-a`/`-A` are kinds, not
|
||||
// attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
|
||||
// /`-I` are modes the handlers read. `-n` is accepted and stored, but
|
||||
// aliasing (reads and writes through the reference) is not wired: it is
|
||||
// a separate seam through every expansion site, so a name carrying it
|
||||
// declares and prints, and nothing more, rather than a partial alias
|
||||
// that works in some spellings and not others.
|
||||
// `-n` stores the reference and every reader and writer resolves through
|
||||
// it (`deref` in `session/state`).
|
||||
const DECLARE_LETTERS: ReadonlySet<string> = new Set('aAfFgiIlnprtux')
|
||||
const DECLARE_USAGE =
|
||||
'declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] or declare -p [-aAfFilnrtux] [name ...]'
|
||||
// The stored attributes a `-letter` / `+letter` toggles.
|
||||
const ATTR_LETTERS: ReadonlyMap<string, VarAttr> = new Map([
|
||||
['i', VarAttr.Integer],
|
||||
['l', VarAttr.Lower],
|
||||
['u', VarAttr.Upper],
|
||||
['n', VarAttr.Nameref],
|
||||
['t', VarAttr.Trace],
|
||||
['x', VarAttr.Export],
|
||||
['r', VarAttr.Readonly],
|
||||
])
|
||||
|
||||
/** The attributes the given letters name, in the order given, skipping
|
||||
* letters that name none (kinds and modes are not attributes). */
|
||||
function attrsFor(letters: string, has: (c: string) => boolean): VarAttr[] {
|
||||
const out: VarAttr[] = []
|
||||
for (const c of letters) {
|
||||
const attr = ATTR_LETTERS.get(c)
|
||||
if (attr !== undefined && has(c)) out.push(attr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal a `declare` family option cluster earns, if any.
|
||||
*
|
||||
* An unknown letter is GNU's `invalid option` plus the usage line, exit
|
||||
* 2, and it wins over every other check because bash refuses the
|
||||
* cluster before it looks at a single operand.
|
||||
*/
|
||||
function declareOptionRefusal(
|
||||
cmd: string,
|
||||
flagChars: ReadonlySet<string>,
|
||||
plusChars: ReadonlySet<string>,
|
||||
): Result | null {
|
||||
const bad = [...flagChars, ...plusChars]
|
||||
.sort(compareCodePoints)
|
||||
.find((c) => !DECLARE_LETTERS.has(c))
|
||||
if (bad === undefined) return null
|
||||
const sign = flagChars.has(bad) ? '-' : '+'
|
||||
const err = new TextEncoder().encode(
|
||||
`bash: ${cmd}: ${sign}${bad}: invalid option\n${DECLARE_USAGE}\n`,
|
||||
)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 2, stderr: err }),
|
||||
new ExecutionNode({ command: cmd, exitCode: 2, stderr: err }),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-name refusals a `+letter` earns after the operands are known.
|
||||
*
|
||||
* Two letters cannot be taken off. `+r` on a readonly name is
|
||||
* `declare: R: readonly variable`, exit 1, and the name stays frozen.
|
||||
* `+a` / `+A` on an array is `cannot destroy array variables in this
|
||||
* way`, exit 1, since the kind is what the value is, not a mark. Both
|
||||
* are pinned on 5.2.37 and neither stops the other operands from
|
||||
* declaring; the first refusal is what the builtin reports.
|
||||
*/
|
||||
function plusRefusals(
|
||||
cmd: string,
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
plusChars: ReadonlySet<string>,
|
||||
assignments: readonly string[],
|
||||
staged: readonly { name: string }[] | null,
|
||||
): Result | null {
|
||||
if (!plusChars.has('r') && !plusChars.has('a') && !plusChars.has('A')) return null
|
||||
const names = assignments.map((a) => a.split('=')[0] ?? a)
|
||||
for (const { name } of staged ?? []) names.push(name)
|
||||
for (const name of names) {
|
||||
if (plusChars.has('r') && view.isReadonly(name)) {
|
||||
const err = new TextEncoder().encode(`bash: ${cmd}: ${name}: readonly variable\n`)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: err }),
|
||||
new ExecutionNode({ command: cmd, exitCode: 1, stderr: err }),
|
||||
]
|
||||
}
|
||||
if (
|
||||
(plusChars.has('a') && Object.hasOwn(session.arrays, name)) ||
|
||||
(plusChars.has('A') && Object.hasOwn(session.assocs, name))
|
||||
) {
|
||||
const err = new TextEncoder().encode(
|
||||
`bash: ${cmd}: ${name}: cannot destroy array variables in this way\n`,
|
||||
)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: err }),
|
||||
new ExecutionNode({ command: cmd, exitCode: 1, stderr: err }),
|
||||
]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every `-attr` / `+attr` letter to the names a declaration
|
||||
* stored, on top of the export stamp.
|
||||
*
|
||||
* The letters that shape a value (`-i -l -u`) are stored as attributes
|
||||
* and applied by the door on every *later* write, which is GNU's rule:
|
||||
* `v=MiXeD; declare -l v` keeps `MiXeD`, and the next `v=ABC` stores
|
||||
* `abc`. So this stamps and never rewrites. `-l` and `-u` are exclusive:
|
||||
* setting one clears the other, and a cluster naming both (`-lu`, `-ul`)
|
||||
* sets neither, both pinned on 5.2.37. A `+` letter clears; `+r` is
|
||||
* refused earlier on a readonly name and a no-op otherwise, so it is not
|
||||
* an off toggle. Through the gated mark door for every name, covered or
|
||||
* not: the handler already cleared the gate for these names, so this is
|
||||
* one redundant policy call per attribute, and it keeps this stamp out
|
||||
* of the ungated-write allowlist that `setAttr` sites must justify.
|
||||
*/
|
||||
async function stampAttrs(
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
flagChars: ReadonlySet<string>,
|
||||
plusChars: ReadonlySet<string>,
|
||||
assignments: readonly string[],
|
||||
staged: readonly { name: string }[] | null,
|
||||
stored: readonly string[],
|
||||
): Promise<Result | null> {
|
||||
const refused = await stampExport(session, view, flagChars, assignments, staged, stored)
|
||||
if (refused !== null) return refused
|
||||
let onAttrs = attrsFor('ilunt', (c) => flagChars.has(c) && !plusChars.has(c))
|
||||
if (flagChars.has('l') && flagChars.has('u')) {
|
||||
onAttrs = onAttrs.filter((a) => a !== VarAttr.Lower && a !== VarAttr.Upper)
|
||||
}
|
||||
const offAttrs = attrsFor('iluntx', (c) => plusChars.has(c))
|
||||
if (onAttrs.length === 0 && offAttrs.length === 0) return null
|
||||
try {
|
||||
for (const name of stored) {
|
||||
for (const attr of onAttrs) {
|
||||
await view.mark(name, attr, true)
|
||||
// `-l` displaces `-u` and vice versa; the record keeps one.
|
||||
if (attr === VarAttr.Lower) await view.mark(name, VarAttr.Upper, false)
|
||||
else if (attr === VarAttr.Upper) await view.mark(name, VarAttr.Lower, false)
|
||||
}
|
||||
for (const attr of offAttrs) await view.mark(name, attr, false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof PolicyDenied)) throw err
|
||||
const denied = new TextEncoder().encode(`${err.message}\n`)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: denied }),
|
||||
new ExecutionNode({ command: 'declare', exitCode: 1, stderr: denied }),
|
||||
]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function stampExport(
|
||||
session: Session,
|
||||
view: SessionView,
|
||||
flagChars: ReadonlySet<string>,
|
||||
assignments: readonly string[],
|
||||
staged: readonly { name: string }[] | null,
|
||||
stored: readonly string[],
|
||||
): Promise<Result | null> {
|
||||
if (!flagChars.has('x')) return null
|
||||
const covered = new Set<string>()
|
||||
for (const a of assignments) {
|
||||
const eq = a.indexOf('=')
|
||||
if (eq >= 0) covered.add(a.slice(0, eq))
|
||||
}
|
||||
for (const { name } of staged ?? []) covered.add(name)
|
||||
for (const name of stored) {
|
||||
if (covered.has(name)) {
|
||||
setAttr(session, name, VarAttr.Export)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await view.mark(name, VarAttr.Export, true)
|
||||
} catch (err) {
|
||||
if (!(err instanceof PolicyDenied)) throw err
|
||||
const encoded = new TextEncoder().encode(`${err.message}\n`)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: encoded }),
|
||||
new ExecutionNode({ command: 'declare', exitCode: 1, stderr: encoded }),
|
||||
]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a redirected statement's command is a bare `exec`: a command
|
||||
* name and no arguments, so its redirects are the shell's own rather
|
||||
@@ -1085,260 +718,7 @@ export async function executeNode(
|
||||
}
|
||||
|
||||
if (kind === NodeKind.DECLARATION) {
|
||||
const keyword = getDeclarationKeyword(node)
|
||||
const assignments: string[] = []
|
||||
// Array literals are staged, not stored: `readonly -a a=(y)` on an
|
||||
// already-readonly name has to fail with the old value intact.
|
||||
const staged: { name: string; append: boolean; items: string[] }[] = []
|
||||
// Option words are kept verbatim, in order, so `--` survives as an
|
||||
// end-of-options marker and the handlers can name the *first* bad option
|
||||
// letter the way bash does.
|
||||
const flagWords: string[] = []
|
||||
const flagChars = new Set<string>()
|
||||
const plusChars = new Set<string>()
|
||||
let optsDone = false
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === NT.VARIABLE_ASSIGNMENT) {
|
||||
const valNodes = child.namedChildren.filter((c) => c.type !== NT.VARIABLE_NAME)
|
||||
const firstVal = valNodes[0]
|
||||
if (firstVal?.type === NT.ARRAY) {
|
||||
const text = getText(child)
|
||||
const eq = text.indexOf('=')
|
||||
const key = eq >= 0 ? text.slice(0, eq) : text
|
||||
const append = key.endsWith('+')
|
||||
staged.push({
|
||||
name: append ? key.slice(0, -1) : key,
|
||||
append,
|
||||
items: await expandArrayItems(
|
||||
firstVal,
|
||||
session,
|
||||
executeFn,
|
||||
registry,
|
||||
deps.namespace,
|
||||
callStack,
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
assignments.push(
|
||||
await expandNode(
|
||||
child,
|
||||
session,
|
||||
executeFn,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
),
|
||||
)
|
||||
} else if (
|
||||
child.type === NT.SIMPLE_EXPANSION ||
|
||||
child.type === NT.EXPANSION ||
|
||||
child.type === NT.CONCATENATION ||
|
||||
child.type === NT.WORD ||
|
||||
// A bare `readonly NAME` / `export NAME` operand parses as a
|
||||
// variable_name, not a word, and a quoted assignment
|
||||
// (`export 'FOO=bar'`) as a plain string operand.
|
||||
child.type === NT.VARIABLE_NAME ||
|
||||
child.type === NT.STRING ||
|
||||
child.type === NT.RAW_STRING ||
|
||||
child.type === NT.ANSI_C_STRING ||
|
||||
child.type === NT.TRANSLATED_STRING
|
||||
) {
|
||||
const expanded = await expandNode(
|
||||
child,
|
||||
session,
|
||||
executeFn,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
)
|
||||
// An *unquoted* expansion that came back empty is removed by
|
||||
// word splitting, so `export $UNSET` is a bare `export` and
|
||||
// prints the listing. A quoted one is a real, empty operand:
|
||||
// GNU answers both `export ""` and `export "$UNSET"` with
|
||||
// ``export: `': not a valid identifier``, so it has to reach
|
||||
// the builtin rather than vanish here.
|
||||
if (expanded === '' && (child.type === NT.SIMPLE_EXPANSION || child.type === NT.EXPANSION))
|
||||
continue
|
||||
if (!optsDone && expanded.startsWith('-') && expanded.length > 1) {
|
||||
flagWords.push(expanded)
|
||||
if (expanded === '--') optsDone = true
|
||||
else for (const ch of expanded.slice(1)) flagChars.add(ch)
|
||||
} else if (
|
||||
!optsDone &&
|
||||
expanded.startsWith('+') &&
|
||||
expanded.length > 1 &&
|
||||
(keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset')
|
||||
) {
|
||||
// `+attr` turns an attribute off. Only the declare family
|
||||
// reads it: `export +x` and `readonly +r` are `not a valid
|
||||
// identifier` in GNU, so for those two the word falls through
|
||||
// as an operand and refuses there.
|
||||
for (const ch of expanded.slice(1)) plusChars.add(ch)
|
||||
} else {
|
||||
assignments.push(expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
const cmdWord = keyword === NT.LOCAL ? 'local' : keyword
|
||||
if (keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset') {
|
||||
const refused = declareOptionRefusal(cmdWord, flagChars, plusChars)
|
||||
if (refused !== null) return refused
|
||||
}
|
||||
if (
|
||||
(flagChars.has('f') || flagChars.has('F')) &&
|
||||
(keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset')
|
||||
) {
|
||||
// `-f`/`-F` select functions, not variables: `-rf` freezes, `-f
|
||||
// NAME` prints the body, `-F NAME` prints the name, and a missing
|
||||
// name is exit 1 without a word.
|
||||
return handleDeclareFunctions(cmdWord, session, flagChars, assignments)
|
||||
}
|
||||
const isReadonly = keyword === 'readonly' || flagChars.has('r')
|
||||
// `-l` and `-u` cannot both hold; a cluster naming both sets neither
|
||||
// (pinned: `declare -lu s=aBc` prints `declare -- s`).
|
||||
let shaping = new Set(attrsFor('ilu', (c) => flagChars.has(c) && !plusChars.has(c)))
|
||||
if (shaping.has(VarAttr.Lower) && shaping.has(VarAttr.Upper)) {
|
||||
shaping = new Set([...shaping].filter((a) => a !== VarAttr.Lower && a !== VarAttr.Upper))
|
||||
}
|
||||
const conversionErrors: string[] = []
|
||||
if (flagChars.has('A') || flagChars.has('a')) {
|
||||
// `declare -a NAME` / `declare -A NAME` with no value declare an
|
||||
// empty array of that kind, so ${#NAME[@]} is 0 and an element
|
||||
// write leaves the other slots unassigned. GNU refuses to
|
||||
// convert between the two kinds and says so per name while the
|
||||
// rest of the operands still declare.
|
||||
const wantAssoc = flagChars.has('A')
|
||||
for (const bare of assignments) {
|
||||
if (bare.includes('=')) continue
|
||||
// Both branches below write array storage raw (the top-level
|
||||
// one migrates an existing scalar), so a hidden name refuses
|
||||
// like any assignment spelling before either lands.
|
||||
try {
|
||||
ensureVarVisible(session, bare)
|
||||
} catch (err) {
|
||||
if (!(err instanceof PolicyDenied)) throw err
|
||||
throw new ExitSignal(1, new TextEncoder().encode(`${err.message}\n`), null, 1)
|
||||
}
|
||||
if (wantAssoc && Object.hasOwn(session.arrays, bare)) {
|
||||
conversionErrors.push(
|
||||
`bash: ${cmdWord}: ${bare}: cannot convert indexed to associative array`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!wantAssoc && Object.hasOwn(session.assocs, bare)) {
|
||||
conversionErrors.push(
|
||||
`bash: ${cmdWord}: ${bare}: cannot convert associative to indexed array`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!flagChars.has('g') && noteLocalArray(session, bare)) {
|
||||
// Inside a function this shadows whatever the caller had with
|
||||
// a fresh empty array of the declared kind; `-g` declares at
|
||||
// global scope instead.
|
||||
seedVar(session, bare, wantAssoc ? {} : [])
|
||||
} else if (wantAssoc && !Object.hasOwn(session.assocs, bare)) {
|
||||
// At top level an existing scalar becomes the value at the
|
||||
// literal key "0" (GNU allows scalar-to-associative
|
||||
// conversion, unlike indexed).
|
||||
const scalar = session.env[bare]
|
||||
seedVar(session, bare, scalar === undefined ? {} : { '0': scalar })
|
||||
} else if (!wantAssoc && !Object.hasOwn(session.arrays, bare)) {
|
||||
// At top level an existing scalar becomes element 0.
|
||||
const scalar = session.env[bare]
|
||||
seedVar(session, bare, scalar === undefined ? [] : [scalar])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Array literals travel as data: the handler stores them through
|
||||
// the session door and owns both refusal voices, so the executor
|
||||
// only expands and stages.
|
||||
if (isReadonly) {
|
||||
// Only the `readonly` keyword owns -p / illegal-option handling;
|
||||
// `declare -r` keeps names only.
|
||||
const declView = sessionView(session, registry.policies)
|
||||
const stored: string[] = []
|
||||
const result =
|
||||
keyword === 'readonly'
|
||||
? await handleReadonly(
|
||||
[...flagWords, ...assignments],
|
||||
session,
|
||||
declView,
|
||||
staged,
|
||||
stored,
|
||||
flagChars.has('A'),
|
||||
shaping,
|
||||
)
|
||||
: await handleReadonly(
|
||||
assignments,
|
||||
session,
|
||||
declView,
|
||||
staged,
|
||||
stored,
|
||||
flagChars.has('A'),
|
||||
shaping,
|
||||
)
|
||||
// `declare -rx X=1` carries both attributes: GNU prints
|
||||
// `declare -rx X="1"`. Readonly answers first, so the export stamp
|
||||
// has to land here too, or `-r` silently ate the `-x`.
|
||||
const refused = await stampAttrs(
|
||||
session,
|
||||
declView,
|
||||
flagChars,
|
||||
plusChars,
|
||||
assignments,
|
||||
staged,
|
||||
stored,
|
||||
)
|
||||
return refused ?? mergeConversionErrors(result, conversionErrors)
|
||||
}
|
||||
// declare/typeset scope like `local` inside a function (bash
|
||||
// semantics) and assign globally at top level, which is exactly
|
||||
// handleLocal's fallback when no function scope is active.
|
||||
if (keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset') {
|
||||
// `-p` prints rather than declares, so it is answered before the
|
||||
// assignment path runs at all.
|
||||
if (
|
||||
(flagChars.has('p') || plusChars.has('p')) &&
|
||||
(keyword === 'declare' || keyword === 'typeset')
|
||||
) {
|
||||
return handleDeclarePrint(assignments, session)
|
||||
}
|
||||
const declView2 = sessionView(session, registry.policies)
|
||||
const stored2: string[] = []
|
||||
const result = await handleLocal(
|
||||
assignments,
|
||||
session,
|
||||
declView2,
|
||||
staged,
|
||||
// `declare`/`typeset` share this handler but have to name
|
||||
// themselves in a diagnostic rather than say `local`.
|
||||
cmdWord,
|
||||
stored2,
|
||||
flagChars.has('A'),
|
||||
shaping,
|
||||
flagChars.has('n') && !plusChars.has('n'),
|
||||
flagChars.has('g'),
|
||||
)
|
||||
const plusRefused = plusRefusals(cmdWord, session, declView2, plusChars, assignments, staged)
|
||||
if (plusRefused !== null) return plusRefused
|
||||
const refused2 = await stampAttrs(
|
||||
session,
|
||||
declView2,
|
||||
flagChars,
|
||||
plusChars,
|
||||
assignments,
|
||||
staged,
|
||||
stored2,
|
||||
)
|
||||
return refused2 ?? mergeConversionErrors(result, conversionErrors)
|
||||
}
|
||||
// Pass export flags through so -p / bare print and illegal options work.
|
||||
const exportResult = await handleExport(
|
||||
[...flagWords, ...assignments],
|
||||
session,
|
||||
sessionView(session, registry.policies),
|
||||
staged,
|
||||
)
|
||||
return mergeConversionErrors(exportResult, conversionErrors)
|
||||
return await executeDeclaration(node, session, executeFn, registry, deps.namespace, callStack)
|
||||
}
|
||||
|
||||
if (kind === NodeKind.UNSET) {
|
||||
@@ -1386,205 +766,7 @@ export async function executeNode(
|
||||
}
|
||||
|
||||
if (kind === NodeKind.VAR_ASSIGN) {
|
||||
const text = getText(node)
|
||||
if (!text.includes('=')) {
|
||||
return [null, new IOResult(), new ExecutionNode({ command: text, exitCode: 0 })]
|
||||
}
|
||||
const subSeq = session.cmdsubSeq
|
||||
const subscriptNode = node.namedChildren.find((c) => c.type === 'subscript') ?? null
|
||||
const nameSource = subscriptNode ?? node
|
||||
const nameNode = nameSource.namedChildren.find((c) => c.type === NT.VARIABLE_NAME)
|
||||
const eq = text.indexOf('=')
|
||||
const spelled = nameNode !== undefined ? nameNode.text : text.slice(0, eq)
|
||||
// A name reference assigns to its target, whatever the shape of the
|
||||
// assignment; an unaimed one (`declare -n r; r=v`) resolves to itself
|
||||
// and takes the value as the target's name. The spelling is kept for
|
||||
// slicing the subscript out of the source.
|
||||
const key = deref(session, spelled) || spelled
|
||||
const append = node.children.some((c) => c.type === '+=')
|
||||
if (session.readonlyVars.has(key)) {
|
||||
// A bare assignment to a readonly variable is a fatal
|
||||
// variable-assignment error in non-interactive bash: the rest of
|
||||
// the line is abandoned (builtins like `export` merely fail with
|
||||
// 1 and continue).
|
||||
const err = new TextEncoder().encode(`bash: ${key}: readonly variable\n`)
|
||||
throw new ExitSignal(1, err, null, 1)
|
||||
}
|
||||
const valNodes = node.namedChildren.filter(
|
||||
(c) => c.type !== NT.VARIABLE_NAME && c.type !== 'subscript',
|
||||
)
|
||||
// Every branch below computes its resulting value with bash's own
|
||||
// mechanics on a copy, then stores through the one session door,
|
||||
// which owns the gate and the scalar/array invariant.
|
||||
const view = sessionView(session, registry.policies)
|
||||
const firstVal = valNodes[0]
|
||||
if (firstVal?.type === NT.ARRAY) {
|
||||
const items = await expandArrayItems(
|
||||
firstVal,
|
||||
session,
|
||||
executeFn,
|
||||
registry,
|
||||
deps.namespace,
|
||||
callStack,
|
||||
)
|
||||
const heldMap = session.assocs[key]
|
||||
if (heldMap !== undefined) {
|
||||
const { map, badWords } = buildAssocLiteral(heldMap, items, append)
|
||||
await assignVar(view, key, map)
|
||||
if (badWords.length > 0) {
|
||||
const errBytes = new TextEncoder().encode(
|
||||
badWords
|
||||
.map(
|
||||
(word) =>
|
||||
`bash: ${key}: '${word}': must use subscript when assigning associative array`,
|
||||
)
|
||||
.join('\n') + '\n',
|
||||
)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: 1, stderr: errBytes }),
|
||||
new ExecutionNode({ command: text, exitCode: 1, stderr: errBytes }),
|
||||
]
|
||||
}
|
||||
const mapCode = assignmentStatus(session, subSeq)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: mapCode }),
|
||||
new ExecutionNode({ command: text, exitCode: mapCode }),
|
||||
]
|
||||
}
|
||||
let held: ShellArray | null = session.arrays[key] ?? null
|
||||
if (append && held === null) {
|
||||
const scalar = session.env[key]
|
||||
held = scalar === undefined ? null : [scalar]
|
||||
}
|
||||
// `arr+=(...)` starts at the extent, so it fills the hole a
|
||||
// trailing `unset arr[last]` left but skips interior ones; a
|
||||
// `[i]=v` element places at i and the next plain word continues
|
||||
// from there.
|
||||
const base = buildIndexedLiteral(held, items, append, (sub) =>
|
||||
elementIndex(sub, visibleEnv(session), sessionElements(session)),
|
||||
)
|
||||
await assignVar(view, key, base)
|
||||
const arrCode = assignmentStatus(session, subSeq)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: arrCode }),
|
||||
new ExecutionNode({ command: text, exitCode: arrCode }),
|
||||
]
|
||||
}
|
||||
let val = text.slice(eq + 1)
|
||||
if (firstVal !== undefined) {
|
||||
val = await expandNode(
|
||||
firstVal,
|
||||
session,
|
||||
executeFn,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
)
|
||||
}
|
||||
if (subscriptNode !== null) {
|
||||
const subText = await subscriptKeyText(
|
||||
subscriptNode,
|
||||
spelled,
|
||||
session,
|
||||
executeFn,
|
||||
callStack,
|
||||
sessionView(session, registry.policies),
|
||||
)
|
||||
const heldMap = session.assocs[key]
|
||||
const rawSub = subscriptNode.text.slice(spelled.length + 1, -1)
|
||||
if (rawSub.trim() === '' || (heldMap !== undefined && subText === '')) {
|
||||
// bash aborts the whole line on a bad assignment subscript
|
||||
// (status 1), naming the raw spelling (`m[$e]: bad array
|
||||
// subscript`). An indexed subscript that merely *expands*
|
||||
// empty stays legal (arithmetic on nothing is 0), so only the
|
||||
// associative kind checks the expanded text.
|
||||
const nameText = text.slice(0, eq).replace(/\+$/, '')
|
||||
throw new ExitSignal(
|
||||
1,
|
||||
new TextEncoder().encode(`bash: ${nameText}: bad array subscript\n`),
|
||||
null,
|
||||
1,
|
||||
)
|
||||
}
|
||||
if (heldMap !== undefined) {
|
||||
// The subscript is the key: no arithmetic, `m[1+1]` writes the
|
||||
// key "1+1".
|
||||
const newMap = { ...heldMap }
|
||||
newMap[subText] = append ? (heldMap[subText] ?? '') + val : val
|
||||
await assignVar(view, key, newMap)
|
||||
const mapCode = assignmentStatus(session, subSeq)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: mapCode }),
|
||||
new ExecutionNode({ command: text, exitCode: mapCode }),
|
||||
]
|
||||
}
|
||||
const existing = session.arrays[key]
|
||||
let arr: ShellArray
|
||||
if (existing === undefined) {
|
||||
const scalar = session.env[key]
|
||||
arr = scalar === undefined ? [] : [scalar]
|
||||
} else {
|
||||
arr = [...existing]
|
||||
}
|
||||
let idx = arrayIndex(subText, visibleEnv(session), sessionElements(session))
|
||||
if (idx < 0) idx += arrayExtent(arr)
|
||||
if (idx < 0) {
|
||||
// Same fatal shape as the empty subscript above.
|
||||
const nameText = text.slice(0, eq).replace(/\+$/, '')
|
||||
throw new ExitSignal(
|
||||
1,
|
||||
new TextEncoder().encode(`bash: ${nameText}: bad array subscript\n`),
|
||||
null,
|
||||
1,
|
||||
)
|
||||
}
|
||||
arraySet(arr, idx, append ? arrayGet(arr, idx) + val : val)
|
||||
await assignVar(view, key, arr)
|
||||
const subCode = assignmentStatus(session, subSeq)
|
||||
return [
|
||||
null,
|
||||
new IOResult({ exitCode: subCode }),
|
||||
new ExecutionNode({ command: text, exitCode: subCode }),
|
||||
]
|
||||
}
|
||||
const heldMap = session.assocs[key]
|
||||
const heldArr = session.arrays[key]
|
||||
if (heldMap !== undefined) {
|
||||
// `m=x` on an associative array writes the literal key "0" and
|
||||
// keeps every other key, as bash does.
|
||||
const newMap = { ...heldMap }
|
||||
newMap['0'] = append ? (heldMap['0'] ?? '') + val : val
|
||||
await assignVar(view, key, newMap)
|
||||
} else if (heldArr !== undefined) {
|
||||
// `a=x` writes element 0 and keeps the rest; `a+=x` appends onto
|
||||
// element 0.
|
||||
const newArr = [...heldArr]
|
||||
arraySet(newArr, 0, append ? arrayGet(newArr, 0) + val : val)
|
||||
await assignVar(view, key, newArr)
|
||||
} else {
|
||||
const heldVar = session.vars[key]
|
||||
let newVal: string
|
||||
if (append && heldVar?.attrs.has(VarAttr.Integer) === true) {
|
||||
// `n+=3` on an integer name adds: the door evaluates `old + new`,
|
||||
// so `declare -i n=5; n+=3` stores 8, not 53.
|
||||
newVal = `${session.env[key] ?? '0'} + (${val})`
|
||||
} else {
|
||||
newVal = append ? (session.env[key] ?? '') + val : val
|
||||
}
|
||||
await assignVar(view, key, newVal)
|
||||
}
|
||||
// Reassigning OPTIND (even to its current value) restarts the getopts
|
||||
// scan, matching bash's internal char pointer.
|
||||
if (key === 'OPTIND') session.getoptsOptind = null
|
||||
const code = assignmentStatus(session, subSeq)
|
||||
const assignIo = new IOResult({ exitCode: code })
|
||||
if (session.shellOptions.xtrace === true) {
|
||||
assignIo.stderr = traceAssignment(key, val, append)
|
||||
}
|
||||
return [null, assignIo, new ExecutionNode({ command: text, exitCode: code })]
|
||||
return await executeAssignment(node, session, executeFn, registry, deps.namespace, callStack)
|
||||
}
|
||||
|
||||
// Assignment-only statement (a=1 b=2).
|
||||
|
||||
@@ -42,7 +42,7 @@ const ALLOWED: Record<string, string> = {
|
||||
'workspace/executor/builtins/vars.ts::handleReadonly':
|
||||
'the `=` branch only; `await view.set(key, val)` runs first and ' +
|
||||
'the bare form uses `view.mark`',
|
||||
'workspace/node/execute_node.ts::stampExport':
|
||||
'workspace/node/declaration.ts::stampExport':
|
||||
'the `covered` branch only, which is the names that carried a ' +
|
||||
'value or a staged array literal; a bare name has no gated write ' +
|
||||
'to ride on and goes through `view.mark`',
|
||||
|
||||
Reference in New Issue
Block a user