Files
bytecii ebec2d6926 refactor(ts): four shared factories mirroring the Python originals (#609 item 24 / T2-9) (#792)
* refactor(ts): four shared factories mirroring the Python originals (#609 item 24 / T2-9)

TypeScript had grown four copy-drift sites where Python already has one
shared module. Each is extracted onto the Python shape, so the two trees
line up module-for-module.

builtins/shared.ts (twin of workspace/executor/builtins/shared.py). Three
independent errorResult()s with three different signatures — capacity.ts
hardcoded the 'df' command, links.ts hardcoded exit 1, only metadata.ts
took both — so a GNU exit-code fix could not be shared. They collapse onto
result/ok/fail/finish, plus operandText/absPath/splitFlags/splitValueFlags/
expandOperands. Drops links.ts's re-implemented abs+splitFlags and
capacity.ts's import of splitValueFlags from metadata.ts (df reaching into
chmod/touch). `Result` moves here from scope.ts, where Python keeps it in
shared.py; the other 12 builtins change only their import line.

The chmod/chown/chgrp/touch handlers each tracked an exitCode variable
that was only ever set to 1 next to an errors.push, so finish() derives it
from `errors` exactly as Python does and the variable is gone.

workspace/executor/interpreter.ts. python/handle.ts and js/handle.ts were
the same 130 lines apart from the label, the unavailable-error class, the
payload flag, Python's -m guard and -x, and js's .mjs module flag.
makeInterpreterHandler takes those as a spec; both handle.ts keep their
path, their exported name and their signature, so callers and
runtime.test.ts's dynamic import are untouched. The .mjs check moves into
the js wrapper, which is where Python's js.py keeps it. Kept in
workspace/executor rather than beside commands/builtin/general/
interpreter.ts because no commands/ module imports workspace/ today and
this should not be the first.

makeRm. generic/rm_command.ts already existed and gcal already used it;
the gdocs/gsheets/gslides bodies were verified character-identical to it
modulo indentation, so the three 79-line files become 20-line bindings.

core/google/tree_ops.ts (twin of core/google/tree_ops.py). makeStat and
makeUnlink over a backend's readdir; the six modules stay as real files
exporting `stat`/`unlink`, so the barrel and the provision/io importers do
not move. The three local eisdir() copies give way to the shared one in
utils/errors.ts: isFsError keys on `code`, which is unchanged, and its
message (the bare virtual path) is what Python's IsADirectoryError(raw)
already produced.

No behavior change intended. -995/+147 across the 26 edited files.

* fix(js): honor the explicit `-` stdin operand, like python3 and node

`js - a b` read a file literally named `-` and failed with
"js: /-: No such file", while `python3 - a b` on the same tree read the
program from stdin. Python has no such split: resolve_source's
STDIN_OPERAND branch (interpreter.py) is unconditional, so both
interpreters honor the operand, and js.py inherits it by taking the
default Argv0Rules. Real `node -` agrees — it reads the program from
stdin and keeps the following words as argv.

Verified against Python by calling resolve_source directly for the js
call shape: `js - a b` yields mode=stdin, args=['a', 'b'], no script
path. Both quickjs hosts then build scriptArgs the same way (an absent
prog leaves the args alone), so the operand lands identically.

Found while extracting the shared interpreter handler in the previous
commit; kept separate because it is a behavior change, not a move.

The unit test fails without this fix (exit 1, not 0). The integ case
pins py=ts.

* test(ts): mirror the Python suites for the two new shared modules

Python has tests/workspace/executor/builtins/test_shared.py and
tests/core/google/test_tree_ops.py; TypeScript had neither twin, and both
modules are now shared chokepoints rather than per-command code.

shared.test.ts ports test_shared.py case for case: the result triple
(ok/fail/finish, including finish keeping a carried IOResult's writes and
deriving the exit code from `errors`), operandText/absPath, the three
splitFlags cases, the three splitValueFlags cases, and the expandOperands
glob expansion over a real RAM mount.

tree_ops.test.ts covers makeStat over a stub readdir: the synthetic
owned/shared roots answer as directories without a readdir round trip, an
indexed document is served straight from the index, a miss warms the
parent and then raises ENOENT, and a call with no index raises ENOENT
without reaching the backend.

It deliberately does not port test_tree_ops.py's one case — that a readdir
raising a non-fs error propagates out of stat. TypeScript swallows it in a
bare catch and reports ENOENT instead, which is a real pre-existing
divergence in the T1-G "honest errors" family; pinning current TS behavior
here would entrench it. Filed separately.

* refactor(ts): keep the interpreter opt interfaces module-private

Nothing outside interpreter.ts imports them — both handle.ts wrappers pass
object literals — so knip reported them as unused exports.

* fix(google): only an absent parent may collapse into ENOENT (py+ts)

Every Drive-family stat/read/unlink warms a cold index by listing the
parent, then retries. Python narrows that call to FileNotFoundError, so
an auth failure or a dropped connection reaches the caller; TypeScript
swallowed it in a bare `catch {}` and reported "No such file" instead.
Five TS sites did this (google/tree_ops stat+unlink, gdrive stat, gdrive
read, gdocs/gsheets/gslides read) — seven catch blocks in all.

tree_ops.unlink was wrong in the other direction: Python called readdir
completely unguarded, so removing a path under a missing directory
reported the parent rather than the operand. GNU names the operand:

    $ rm nodir/file.txt
    rm: cannot remove 'nodir/file.txt': No such file or directory

(pinned against coreutils in debian:stable-slim). Both sides now use the
shape Python's make_stat already had: swallow a not-found parent, let
everything else through. TypeScript spells it with the existing
isEnoent(), the twin of `except FileNotFoundError`.

gdrive/readdir warms a parent unguarded in both languages — already
aligned, left alone.

Tests fail without the fix. Two of them mirror Python suites that had
pinned this since before the TS port (test_stat_propagates_parent_
refresh_failure, test_read_propagates_parent_refresh_failure); the
tree_ops pair is new on both sides.

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

* refactor(cache): one entry_or_warm for every cold-index lookup (py+ts)

Twelve call sites across eight backends had grown their own copy of
"ask the index, list the parent if cold, retry, else ENOENT", each with
its own idea of what a failed listing means. They now share one function
in cache/index/warm, so exactly one catch in the tree decides that.

The previous commit narrowed seven of those catches by hand; this makes
the narrowing structural instead, and sweeps the five that commit did
not reach:

  box/read (x2)     bare catch, py narrowed  -> divergence
  gmail/read        bare catch, py narrowed  -> divergence
  dropbox/read (x2) bare catch, py caught (FileNotFoundError,
                    DropboxApiError) -- but readdir already turns the
                    API's 409 for a missing path into ENOENT, so that
                    second arm only ever swallowed live API failures.
                    Python had the same bug here, from the other side.

gdrive/readdir warms a parent unguarded in BOTH languages; already
aligned, deliberately left alone.

Also folds eight private eisdir() copies into the shared helper. The
copies built `EISDIR: ${p}` and never stamped virtualPath, and
execute.ts renders errorVirtualPath(err) straight into stderr, so a
directory read printed

    cat: EISDIR: /docs/owned: Is a directory

where Python printed the GNU form. Two TS tests asserted on that
message with /EISDIR/ and so had been holding the bug in place; they now
assert the stamped code and virtualPath, which is what Python's
IsADirectoryError(virtual) carries.

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

* test(integ): pin the Drive-family cold-lookup error paths on both hosts

Five cases over the gapps and gdrive targets, all curated by running the
battery and reading the real output rather than guessing the strings:

  gapp_cat_dir_is_gnu       cat /docs/owned          -> Is a directory
  gapp_cat_missing_parent   cat .../nope/x.gdoc.json -> names the operand
  gapp_ls_missing_parent    ls  .../nope             -> ENOENT, exit 2
  gapp_rm_missing_parent    rm  .../nope/x.gdoc.json -> names the operand
  gdrive_cat_dir_is_gnu     cat /data/sub            -> Is a directory

clear_cache forces the cold index, which is the branch entry_or_warm
now owns. Both wordings match GNU pinned in debian:stable-slim
(`cat: <path>: Is a directory`, exit 1).

Two things these cases do NOT prove, so nobody reads more into them:

  - rm builds its own diagnostic from p.rawPath, so it reports the
    operand whatever the backend error carries. The unlink
    operand-naming fix is pinned by test_tree_ops.py, not here.
  - `cat` on a directory is answered by the read generic off stat, so
    the eisdir consolidation is pinned by the gdrive/dropbox unit tests
    (which assert virtualPath, the field errorVirtualPath reads), not by
    these.

They sit at seq 930030+ deliberately: at 520100 their clear_cache wiped
the index that gdocs_prov_cat (930023) expects warm, and that case
started failing on hits=1 vs hits=0.

Verified: gapps + gdrive, 2193 passed / 0 failed, on the typescript-node
host and again on the python host.

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

* style: formatters over the warm-helper sweep, + the py eisdir message pin

isort regroups the hand-inserted `partial` / `entry_or_warm` imports and
prettier rewraps one dropbox thunk; no behavior change.

Also completes the eisdir work on the Python side: the gdrive read test
asserted only the exception type, so it would not have caught the "EISDIR: "
prefix the deleted TS copies were adding. It now asserts the message is the
bare operand, which is the string the TS twin pins through virtualPath.

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

* chore(gate): lower the layout-parity baseline to 301

The ratchet fails on a drop as well as a rise, so a closed divergence has
to be locked in. Net -1 from three real movements:

  -1  core/google: python-only ['tree_ops']        tree_ops.ts now exists
  -1  workspace/executor/builtins: python-only ['shared']   shared.ts now exists
  +1  workspace/executor: typescript-only [... 'interpreter']

The +1 is the deliberate layering call from this PR: the shared interpreter
handler lives under workspace/ rather than beside Python's twin in
commands/builtin/general/, because no commands/ module imports workspace/
today and this should not be the first. Recording it here so the net -1 does
not hide that one improvement paid for one new divergence.

cache/index/warm does not appear: it landed on both sides, so it is neutral.

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

* fix(spec): js/node end option parsing at the first operand, like python

`node - -e x` ran `x` instead of the piped program, and `node s.js -m`
flipped the interpreter into module mode instead of handing s.js its own
-m. Both real interpreters disagree, pinned locally:

    $ echo 'console.log(process.argv.slice(1))' | node - -e 'nope'
    [ '-', '-e', 'nope' ]
    $ echo 'import sys; print(sys.argv)' | python3 - -c 'nope'
    ['-', '-c', 'nope']

(node 22.8.0, CPython 3.13)

`Operand.remainder` (argparse nargs=REMAINDER) already exists and both
parsers already honor it in one line — the first operand ends option
parsing, so every word after it is an operand verbatim. `python`/`python3`
have carried it all along; js/node simply never got it, in EITHER
language, so this is a symmetric py+ts spec fix rather than a TS-only one.

Verified by calling parse_command directly: js/node/python/python3 now
agree on `- -e PROG` and on `s.js -m --module`, while a flag BEFORE the
first operand is still the interpreter's (`js -m -e CODE a`).

One consequence beyond the reported case: remainder also turns off
lenient_dash_operands, so `js -Z foo` now reports an invalid option
instead of silently treating `-Z` as the script path (which produced
"js: /-Z: No such file"). Real node agrees — `node -Z foo` prints
"node: bad option: -Z". Nothing in integ or the suites relied on the old
leniency.

Reported by codex on #792. Its premise was wrong in one respect — it said
to mark the rest operand "as the Python specs do", but Python's js/node
spec is Operand(type="str") with no remainder, so Python had the same bug.
Fixing TS alone would have created a divergence.

Tests: 4 parametrized parser cases per language over the real js/node/
python/python3 specs, plus a quickjs end-to-end case. All 4 py and all 5
TS fail without the spec change (python/python3 pass either way, which is
what makes them the control). integ/runtime/quickjs.json `stdin_operand`
gains the -e and -m steps.

Verified: pytest 14159 passed / 0 failed; pnpm -r typecheck clean;
core suite 8129 passed (the one python_repl timeout is the known
Pyodide-boot-under-load flake, 10/10 alone); runtime/run.ts quickjs
11 passed / 0 failed on the typescript host; spec drift, spec parity
(93 match), layout parity (301 = baseline) and width table all clean.

The python host's quickjs suite needs the MIRAGE_QUICKJS_HOME WASI blob
that CI downloads, so that leg is covered by test_integ.yml rather than
locally.

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

* fix(quickjs): end qjs's own option parsing before the program's argv

The python host's quickjs runtime appends the program's arguments
straight onto the qjs command line:

    argv += ["-e", args.code, *named, *args.args]

Unlike CPython's -c, qjs's -e does NOT end option parsing, so any
program argument that spells a qjs switch was read as one. Pinned
against the build CI uses (quickjs-ng v0.15.1 qjs.c): the option loop
runs while `*argv[optind] == '-'`, breaks only on a bare `--`, and
scriptArgs start at `argv + optind`.

That is what made integ-runtime red on the previous commit, and only on
the python host:

    node - -e 'console.log("flag")'   ->  flag        (second -e wins)
    node - -m                         ->  (empty)     (module mode flipped)

Both are now `--`-separated, so they land in scriptArgs where the parser
already put them. qjs consumes the `--` itself, and the -e branch still
wins over any filename, so a named program keeps scriptArgs[0].

Surfaced by the remainder fix in the previous commit rather than caused
by it: before that, mirage's own parser swallowed those words, so they
never reached the runtime. Any program argument spelling a qjs switch
had this hole -- `node script.js -m` included.

TypeScript needs no twin. Its quickjs runtime is in-process and assigns
scriptArgs as a global (`quickjs.ts:387`), never building a qjs command
line, which is why the TS host passed the same integ steps. Checked the
other python runtimes too: wasi.py and local.py both go through CPython
`-c`, which DOES end option parsing (pinned: `python3 -c prog -e FLAG -m`
puts `-e FLAG -m` in sys.argv), and monty passes argv as a data global.
quickjs was the only one.

Verified with the real engine, after downloading the pinned
qjs-wasi.wasm: `runtime/run.py quickjs` on the python host is 11 passed
/ 0 failed with this change and reproduces CI's exact two-line failure
without it; the whole python runtime battery is 99 passed / 0 failed
(bridge/docker/wasi skip on unmet env). pytest 14192 passed / 0 failed
-- 33 more than the previous run, because the @live quickjs tests now
execute instead of skipping.

The three new unit tests pin the argv shape without needing the build,
and all three fail without this change.

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

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:27:37 -07:00

36 lines
763 B
JSON

{
"_meta": {
"by_resource": {
"": {
"filetypes": [],
"has_aggregate": false,
"has_provision": false,
"has_write": false
}
},
"filetypes": [],
"has_aggregate": false,
"has_provision": false,
"has_write": false,
"resources": []
},
"description": "Run JavaScript on a sandboxed quickjs engine.",
"options": [
{
"description": "Evaluate the next argument as a script.",
"short": "-e",
"type": "str"
},
{
"description": "Run as an ES module (top-level import/export/await); .mjs files select this automatically.",
"long": "--module",
"short": "-m",
"type": "bool"
}
],
"rest": {
"remainder": true,
"type": "str"
}
}