13 Commits

Author SHA1 Message Date
Zecheng Zhang 003753c21a feat(python3): parse CPython's option table, and seed sys.path on pyodide (#743)
* fix(ids): stamp uuid7 from the clock, not from the previous id

uuid6's uuid7 orders ids inside one millisecond by bumping the
timestamp past the previous one. The borrowed value becomes the next
call's floor, so the error compounds: a burst of 5000 ids stamps every
later one 4991 ms into the future, and it stays there until the clock
catches up. These are workspace and session ids, used as time-ordered
database keys.

Mint the id here instead, with RFC 9562's dedicated counter in rand_a:
48-bit timestamp, 12-bit intra-millisecond counter, 62 random bits. The
timestamp is only ever a real clock reading, and an exhausted
millisecond waits for the next rather than borrowing.

Drops the uuid6 dependency, which nothing else used. TypeScript already
had this right, via the uuid package's counter.

* feat(python3): parse CPython's option table, and seed sys.path on pyodide

python3's spec carried one option, -c, and a free-text rest, so every
other switch fell through to the operand slot: 'python3 -u s.py' ran
'/-u' as the script and failed, 'python3 -zz -c ...' exited 0 with the
flag swallowed, and -V and -h were read as paths.

Two spec knobs fix the class rather than the command. stop_at_operand
turns off the parser's dash-operand leniency and ends option parsing at
the first operand, which is CPython's own rule; ends_options marks the
options that carry a program (-c, -m), so the words after them are the
program's argv rather than python3's. Both mirror in TypeScript.

On top of that: -m runs through runpy, with a find_spec probe so a
missing module is CPython's one line instead of a traceback; the four
source doors each report the argv[0] CPython gives them; and the
interpreter-init switches ride in RunArgs.flags, honored where the
engine can and reported on stderr where it cannot.

argv[0] needed one more piece. The local and wasi tiers hand CPython
the program through -c, so it hardcoded argv[0] to '-c' and named every
frame '<string>'. bootstrap() re-compiles under the real name, which
fixes argv[0] and the traceback filename together.

Pyodide gains sysPath, which glob-expands and prepends mount paths once
the mounts are in place, so a vendored wheel imports without the
sys.path.append incantation. A glob that matches nothing is reported on
the run's stderr; the seed cannot report it itself, since it runs
before stderr is captured. packages, packageBaseUrl and lockFileURL
reach a prebuilt distribution, and the wrapper honors -O, -B, -W, -X,
-E and -I.

Verified against CPython 3.13.7: all 14 probed lines match on stdout,
stderr and exit code.

* refactor(spec): borrow argparse's REMAINDER instead of two bespoke parser knobs

python3's line needs opposite treatment of an unknown dash word on
either side of the script: `-zz` before it is python3's usage error,
`--foo` after it is data the script must receive. The first pass added
CommandSpec.stop_at_operand and Option.ends_options to say that, which
put two per-command dialect fields on dataclasses every command shares.

The first half is not ours to invent: it is argparse's
nargs=argparse.REMAINDER, and POSIX's own option order (GNU's permuting
default is the extension, which `POSIXLY_CORRECT=1 ls a -1` shows).
argparse spells it on the positional slot, so it goes on Operand under
argparse's name, and CommandSpec is untouched.

The second half argparse cannot express at all: `add_argument('-c')`
beside nargs=REMAINDER answers `unrecognized arguments: -u`, and CPython
parses its own command line in C for that reason. So it stays out of the
grammar. POSIX already spells the handoff `--`, and the parser already
consumes one, so a table in workspace/route inserts it after -c/-m's
value, beside the other command-name-keyed line rules. The marker is
added unconditionally: CPython passes a typed `--` through as data
(`python3 -c p -- -u` gives the program ['-c','--','-u']), so the parser
eats exactly the one added here.

Net: Option and CommandSpec return to what they were, Operand gains one
borrowed field. CLAUDE.md now states the rule as a literal test, since a
CLISpec is a CommandSpec and cannot be used to escape it.

* docs: require POSIX and argparse, not either, for a shared grammar field

POSIX alone specifies an option whose argument is a program (sh -c, and
python3's own synopsis), so an either-or rule would license the Option
field the section exists to refuse.

* fix(python3): address review on init switches, and declare the four missing ones

- pyodide restores warnings.filters after a run, so -W error no longer
  follows every later line on the warm interpreter
- -OOO saturates at compile()'s max of 2 rather than raising ValueError
- -E/-I stop deleting PYTHON* from os.environ: CPython's -E stops those
  variables configuring startup, it does not hide them from the program
- pyodide reports -E/-I/-s/-S through unhonoredNotice instead of faking
  them; unhonored() grew a honored subset, and now reports -OO too
- declare -b, -P, -x and --check-hash-based-pycs, which CPython accepts
  and mirage was refusing. -x is answered by the source resolver, since
  handing it to an engine that runs code via -c would honor it nowhere
- end_options_after_program walks a short cluster letter by letter, so
  -uc 'p' -v hands -v to the program, and steps over long value options

* fix(integ): argv[0] for a payload is -c on every engine, monty included

Monty's DEFAULT_PROG placeholder only applied because nothing ever told
it a name. The python3 command now supplies CPython's own answer, so
these four policy-routing steps read -c. They still distinguish monty
from local, since argv is a monty global and CPython would NameError.

Also picks up the formatting of the new flags test: it was untracked
when pre-commit ran, and --all-files goes through git ls-files.

* fix(python3): do not rewrite a shadowed name, and stop over-claiming -W and -X

- the `--` handoff is skipped when a shell function shadows python3.
  bash's rule gives the function the line, and it has no CPython option
  table to read a marker with. `command python3` masks the function for
  its inner run, so the rewrite applies there again. A CLI cannot reach
  this at all: register_cli refuses a shell builtin's name.
- an invalid -W filter is reported as CPython reports it and the program
  still runs, instead of _OptionError escaping the wrapper and killing a
  line every other runtime completes.
- a known -X name is reported as unhonored, since populating
  sys._xoptions is all a warm interpreter can do for one. An arbitrary
  name stays silent, which is all CPython does with it either.
- -O's reach is written down: the payload is compiled at the requested
  level, a module imported from sys.path is not, and sys.flags stays 0.
2026-08-10 02:07:12 -07:00
Zecheng Zhang 07bca1b503 fix(archive): tar and zip archive a directory, and -C re-bases the operands after it (#738)
* fix(archive): tar and zip archive a directory, and -C re-bases the operands after it

tar -cf d.tar d exited 1 with "tar: <hostpath>/d: Is a directory": every
operand went straight to read_bytes, with no isdir check and no
recursion, so the most common tar invocation there is could not work.
zip -r <dir> had the identical defect. Both now walk.

Create is a two-phase pass in each: decide every member, then write.
Both plans are built on one traversal, scan_operand / scanOperand
(generic/archive/walk), which merges three sources no single one can
see: the backend walk (find's walk_find, so an entry is classified
through stat and never by name), the namespace's symlinks, and the
mount table. It reports paths, never names, because naming is where the
two formats part company; the two things they disagree about in the
traversal itself are parameters, so tar passes recurse=True and
dereference=-h while zip passes recurse=-r and dereference=not -y.

A directory is its own member, so an empty one survives a round trip,
and both extractors now mkdir for one. A symlink is a symlink member
under tar (SYMTYPE, target in linkname) and under zip -y (mode 0120777).
An unreadable operand is reported in virtual path space with the
archiver's own wording, because the raw IsADirectoryError was leaking
the host path behind a disk mount.

tar -C was ignored: `tar -czf /work/out.tgz -C /work/check my_paper`
failed as "paths span multiple mounts", the same defect class as unzip
-p in #725, because the operand resolved against the session cwd and
the router then saw a phantom mount span. -C is not a flag the command
reads once, it is a chdir for the operands typed after it, so it is now
declared in the spec (CommandSpec.operand_base) and resolved by the one
component that walks the line positionally: the parser reports a base
per word and the classifier resolves each operand against it.

MountView is how a command sees mount boundaries, offered the way
LinkView is (name a `mounts` parameter, nothing else). A traversal that
renders lines gets this free from the executor's fan-out; one that
emits a single binary object cannot, which is why the archivers read
the table themselves. A descendant mount is not crossed: the mountpoint
stays an entry and its contents are dropped with GNU's
--one-file-system wording, since descending would archive by accident
what MountRootPolicy now refuses on purpose for tar, zip and cp in a
source slot.

Semantics pinned against GNU tar 1.35 and Info-ZIP 3.0 on
debian:stable-slim, including Info-ZIP's inverted defaults, its
anchored -x, its silent leading-slash strip, and "Nothing to do!"
exiting 12 with no archive written.

* chore(spec): the dumps state what a command declares, not what it defaulted to

Both generators emitted every field of every dataclass, so `truncate`,
which declares one thing (`-s`/`--size` takes a string), spent 27 lines
of spec body restating 21 defaults that read identically in all 93
files. `zip` was 198 lines for five flags, 11 of the 13 keys on each
option being defaults.

Anything equal to its default is now dropped on both sides. `type`
survives even at its default, because what a token is is the first
thing a reader looks for, and `"rest": {}` says less than
`"rest": {"type": "path"}`.

The defaults come from the dataclass fields in python and from a
default-constructed instance in typescript, rather than from a table
either side could let drift. The two must drop exactly the same keys,
and the parity gate reports every command if they do not.

This narrows that gate's blast radius rather than widening it. When
`operand_base` was missing from gen-specs.ts, python emitted the key in
all 93 files and typescript in none, so parity reported ~95 divergences
to sift. The same bug now reports one, on tar, which is the only
command that sets it.

check_spec_parity's option-diff renderer keyed options by `o["long"] or
o["short"]`, which raises once an option carries only the spelling it
declared.

279 files, 22113 lines of restated defaults gone.

* fix(archive): strip the mount prefix by scan, not by a backtracking regex

childSpec measured the backend key with `.replace(/^\/+|\/+$/g, '')`.
The trailing alternative backtracks on a run of slashes, which CodeQL
flags as js/polynomial-redos, and the input is a resource path a mount
supplies.

utils/slash.ts already has stripSlash, which walks the two ends by
charCode and cannot backtrack. Use it. slash.test.ts pins it against
the regex it replaces.

* fix(archive): the five symlink and mode bugs the codex review found

All five reproduced against a live workspace first, then pinned against
GNU tar 1.35 and Info-ZIP 3.0 on debian:stable-slim.

A symlink operand never reached the planner as a link. tar and zip were
absent from NO_FOLLOW_COMMANDS, so the router rewrote the operand
through the link table and `tar -cf o.tar link` stored a regular file
holding the target's bytes where GNU stores a symlink member of size 0.
It also skipped the planner's cross-mount refusal, since by then there
was no link left to refuse. Both archivers now lstat, and carry no
DEREFERENCE_FLAGS entry on purpose: -h and -y are the planner's to read.

Only the last -C was checked. `tar -cf m.tar -C missing x -C good y`
reported `x: Cannot stat` and still wrote an archive holding y, where
GNU chdirs at each -C and dies at the first it cannot enter. The option
accumulates now and the planner walks the list, so the first bad one is
fatal and no members are written.

Two links to one target were called a loop, and a real loop was not
caught at all. Both were the same mistake: an operand-wide `seen` set
doing detection the namespace already does properly under a hop limit.
Deleting it archives both names, as GNU and Info-ZIP do, and catching
the CycleError that resolve raises turns a genuine cycle into one fatal
problem per member with GNU's "Too many levels of symbolic links",
keeping the directory entry and exiting 2 instead of throwing out of
the planner for a bare exit 1.

The mount-root refusal denied member selectors. Under -t and -x an
operand names something inside the archive, so `tar -tf a.tar data`
was refused as busy when `data` happened to spell a mount. Gated on
create mode now, dashless first word included.

A test asserted the loop bug rather than catching it; it is replaced.
Five integ cases cover the lot end to end in both languages.

* test(archive): build the test LinkView's stat through FileStat, not a cast
2026-08-09 07:58:38 -07:00
Zecheng Zhang fdfb8a075b fix(tar): accept GNU's old option style (#734)
* fix(tar): accept GNU's old option style

`tar xzf a.tgz` failed with "must specify -c, -x, or -t" because a first
word with no leading dash was read as an operand. It is a cluster of
option letters whose arguments follow as separate words, expanded now by
expand_old_style / expandOldStyle before anything else reads the line.

Per letter, not one -xzf token: a cluster may hold several
argument-taking letters (tar xfC a.tgz out) and may continue past one
(tar cfz a.tgz f gzips), neither of which getopt clustering can express.

* chore(spec): regenerate spec dumps for old_option_style
2026-08-08 23:33:20 -07:00
Zecheng Zhang 745044f5d2 feat(jq): support the rest of jq's flag surface (#713)
* feat(jq): support the rest of jq's flag surface

Only -r, -c and -s were accepted, with no long forms at all. Adds
-n/-R/-j/-a/-S/-e/-M/-f/-h, every long spelling, --raw-output0, --tab,
--indent, --unbuffered, --arg, --argjson, --rawfile, --slurpfile,
--args, --jsonargs, --stream and --seq, in Python and TypeScript.

Two spec-parser additions carry it: Option(pair=True) for two-token
options like --arg name value, and Operand(text_when=...) so --args
turns later operands into positional strings instead of input files.

Also fixes -s to slurp across every operand rather than per file, and
an empty input to print nothing instead of erroring.

* fix(jq): read inputs as a call, not as the word

The detector matched `.inputs`, `{inputs}`, `$inputs`, a module member
and any string or comment, and a match switches the run into drain mode,
so `jq -c '.inputs'` over a two-document stream printed one line where
jq prints two. Both languages now blank string bodies, comments and
object-shorthand keys before searching for the token, keeping
interpolations as code. $ARGS is read the same way.

The native fixture also tolerates EPIPE on the child's stdin: `jq -n`
exits without reading, which raced the fixture's write and failed the
whole node package on unhandled errors with every test passing.

Adds integ coverage for the rest of the flag surface, including the long
spellings, the refused options and the usage errors.
2026-08-05 11:45:15 -07:00
Zecheng Zhang edd20a6544 feat(spec): one ValueType axis: merge value_kind and type (#689)
* feat(spec): merge value_kind and type into one ValueType axis (python)

* feat(spec): merge value_kind and type into one ValueType axis (typescript + dumps)

* test(spec): float coverage, lint fixpoint

* fix(spec): linear-time FLOAT_VALUE regex (CodeQL polynomial-redos)

* fix(spec): numeric refusal before choices in option_error, add FlagView.as_float
2026-08-02 21:11:07 -07:00
Zecheng Zhang a039a174b8 feat(spec): mirror argparse: aliases, long-option abbreviation, typed int values (#687)
* feat(spec): mirror argparse: subcommand aliases, long-option abbreviation, typed int values

* fix(spec): coalesce synonym longs by signature, report scan errors in encounter order
2026-08-02 20:00:13 -07:00
Zecheng Zhang 03a38a0493 Declarative count, multiple, choices, required and default option fields (#679)
* feat(spec): declarative count, multiple, choices, required and default option fields

* fix(types): widen fanout depth-flag helper to the int-carrying flag union

* fix(spec): a default on a multiple option lands as a one-element list
2026-08-02 01:12:20 -07:00
Zecheng Zhang 640f4200ab Cross-language command spec parity check (#666)
* feat(spec): cross-language command spec parity check

Add scripts/check_spec_parity.py and wire it into CI next to the existing
spec drift gate. It diffs the generated python and typescript spec trees
command by command: every option (help text, value kind, repeatability,
shorthand), every operand, the _meta flags, and the resource set each
command registers under. Structural divergences live in
spec/parity_exceptions.json with a reason, and a stale entry fails the
check so an exception cannot outlive what it documents.

gen-specs.ts could only see command groups the package index re-exports,
so HISTORY_COMMANDS and GRIDFS_COMMANDS were silently missing from the
dump. It now asserts every builtin *_COMMANDS is reachable, and both
backends are exported.

The check found four real bugs:

shuf required a write op it only needs for -o. TypeScript marked the
builder write:true and threw before doing anything, so shuf did not
register on 21 read-only backends. Python registered it but eagerly
called ops.require(WRITE), so every invocation raised. Both now resolve
the write op lazily, matching sed -i / sort -o / uniq -o.

The TypeScript factory only gated on the generic write op, so truncate
registered on databricks_volume, dropbox and the hf backends and rmdir on
the hf backends, none of which have those ops. Ported Python's
requirements mechanism; the tables are now identical in both languages.

TypeScript search carried no cost provisioning on qdrant, lancedb or
mem0, where Python does.

js and node had no help text in TypeScript, and curl, history and seq
differed. Aligned per option.

* fix(spec): compare spec metadata per resource, not as unions

Addresses two review findings on the parity gate.

gen-specs.ts treated any index.ts read failure as an absent file, so a
permission or IO error silently dropped every command group in that
directory, defeating the reachability assertion exactly when the source
scan was incomplete. Only ENOENT continues now.

The _meta union flags cannot say which resource carries a provision, an
aggregate, the write flag or a filetype, so dropping one backend's
provision while another kept it left every union unchanged and the gate
accepted the regression. Both generators now emit _meta.by_resource and
the checker compares keyed by resource, falling back to the unions only
once the per-resource entries agree.

That found six more divergences the unions had masked:

Python's history cat, grep, head, tail and wc carried no aggregate where
TypeScript did, and the same for github and email grep. Added them.

TypeScript had no github du provision where Python does. Added it.

TypeScript still has no github grep provision. Python's is a bespoke cost
model that walks the index and reaches IndexCacheStore internals, so it
is recorded in parity_exceptions.json for its own change rather than
ported here.

Exemptions are now granular: by_resource names one resource and one key,
so exempting github grep's provision cannot also hide the aggregate
divergences on the same command. An exemption counts as used only when it
suppresses a live divergence, so the stale check no longer misreports an
exemption that fully covers its diff.
2026-07-31 17:17:48 -07:00
Zecheng Zhang e8e2f5f14a fix(jq,gws): evaluate multi-document input per value, paginate gws GETs (#650)
* fix(jq,gws): evaluate multi-document input per value, paginate gws GETs

jq computed output arity with a naive "[]" substring test while jq_eval used a
depth-and-string-aware predicate, so any program with a nested [] inside a
collector had its single array exploded one element per line. Reuse the careful
predicate in both places.

jq also read .json files with strict orjson, so a file holding several JSON
values failed, and the stdin path collapsed a multi-document stream into one
list. Real jq reads a stream of values from any input and evaluates the program
per document. Both paths now do that; -s still slurps.

gws list methods made exactly one HTTP call and dropped nextPageToken, so
'gws drive files list' silently returned the first 100 of N at exit 0.
Pagination is now the default (a truncated listing is indistinguishable from a
complete one) with --page-limit to opt out. Single-response GETs keep their
exact bytes; only real multi-page streams are newline-delimited NDJSON.

Adds 'gws --help' and 'gws <service> --help' matching googleworkspace/cli, and
teaches the integ mock server to paginate. Two existing goldens encoded the jq
bug and are corrected.

* fix(gws): port pagination and help to TypeScript, regenerate specs

The epilog field added to CommandSpec was missing from the committed spec
dumps, which is what the pre-commit spec-drift step caught. Regenerated
both trees and taught the TypeScript side about the field, so the two
dumps keep the same shape.

The PR description claimed TypeScript had no gws passthroughs. It has all
25, so the unpaginated-GET bug was live there too. Ported pagination,
--page-limit, the per-method --help descriptions, and the
gws --help / gws <service> --help surface. render_services() and
render_service_methods() output is byte-identical between the two.

Two defects surfaced during the port:

- withHelpSupport rebuilds the spec from a hand-listed init object to
  inject --help, so it dropped epilog on every command. Python is immune
  because it uses dataclasses.replace. Typecheck and unit tests both
  passed; only the integ case caught it.
- gws --help was registered for gdrive and gsheets only, so a gdocs-,
  gslides- or gmail-only mount had the passthroughs but no help. Now
  registered for all five in both languages.

Structural alignment between the two implementations:

- drop the orphaned GWS_API_SPEC, replaced by gws_method_spec
- add _parse_page_limit mirroring parsePageLimit, so a bad --page-limit
  reports the same message instead of leaking int()'s ValueError text
- re-export the help commands from the gws package so backends stop
  reaching into gws.help, matching gws/index.ts
- make the four help description constants module-private on both sides
- drop the now-dead non-GET guard before invalidate_mount_listing
- give each page its own params dict instead of mutating one across calls

gws integ coverage is no longer blocked: g_help_lists_services,
g_help_lists_service_methods, g_files_list_paginates_by_default and
g_files_list_page_limit_truncates run on both hosts.

Docs: a Pagination section on the Drive page covering the default-on
behavior, the NDJSON output, --page-limit and the deliberate divergence
from googleworkspace/cli; the same note on the Gmail passthrough section;
and a JSON with jq section in the bash docs for the stream-of-values
semantics, verified against the jq binary.

* fix(spec): trim the help epilog without a polynomial regex

CodeQL flagged js/polynomial-redos (high) on the `/\n+$/` I used to strip
the epilog's trailing newlines: it backtracks on a long run of '\n'. The
trim now walks backwards, which is what Python's rstrip('\n') already did,
so that side was never affected. Verified identical output on 'Services:',
a single trailing newline, three trailing newlines, and an all-newline
epilog; a 100k-newline epilog now renders instantly.

Also align --page-limit validation across the two. Python used bare
isdigit(), which accepts non-ASCII digits that TypeScript's /^\d+$/
rejects: '١٢' parsed as 12 on one side and errored on the other, and '²'
passed the check then crashed int(). Python now requires isascii() too,
and both suites reject the same five inputs.

* fix(gws): register help commands per resource, drop the nested handler

Both findings from the codex review on #650.

P2, resource filtering. MountEntry.register keys commands by
(name, filetype) and ignores RegisteredCommand.resource, so registering
the whole GWS_SERVICE_HELP_COMMANDS list on every backend made a
gdocs-only mount answer `gws gmail --help` and `gws drive --help` with a
method listing it cannot execute. Reproduced on a gdocs-only workspace
before the fix. gws_help_commands(resource) / gwsHelpCommands(resource)
now build one registration per reachable service, bound to the single
resource asked for, which is what the TypeScript backends were doing with
an explicit filter. A drive mount still reaches docs, sheets and slides.

P1, nested function. make_service_help_command defined its handler inline
and closed over the body, against the repo's no-nested-functions rule.
The handler is now module-scope run_help, bound to its listing with
functools.partial, the same way run_gws_method is bound to its method.

Both languages end up with the same shape, so the per-backend filter
duplication in TypeScript is gone too. ROOT_DESCRIPTION is module-private
on both sides.
2026-07-28 02:16:14 -07:00
bytecii d813bd342a feat(cp/mv): GNU update/backup/target-dir/exchange flag semantics (#609 Tier 1) (#629)
* feat(cp/mv): GNU update/backup/target-dir/exchange flag semantics (#609 Tier 1)

Implement the cp/mv "semantics flags" slice of #609 Tier 1 with Python/
TypeScript parity, pinned against GNU coreutils 9.7.

cp gains -u/--update[=all|none|none-fail|older], -b/--backup[=CONTROL]
+ -S/--suffix, -t/--target-directory, -T/--no-target-directory, the long
spellings of -r/-a/-f/-n/-v, and -f/-i/--strip-trailing-slashes as
documented no-ops, plus GNU overwrite-type guards and arity errors.

mv additionally gains --exchange (atomic swap via three renames),
--no-copy (cross-mount refusal), and rename-level -n/-u/-b gating, sharing
cp's transfer/backup/policy engine.

Supporting fixes that fell out of end-to-end testing:
- New Option.short_value flag so an optional-value short (cp -b/-u) stays a
  clusterable boolean (`-bv`) instead of eating the cluster remainder as a
  value; only --backup=/--update= carry values (GNU).
- cp/mv now route path-valued flags (cp -t /other/mount/dir) through
  cross-mount detection, and see the touch/chmod stat overlay so -u
  freshness matches ls/stat.
- Single per-source entryKind probe + overwrite-gate early-out so API
  backends pay no extra stat per entry when no gating flag is set.

New shared backup helper (utils/backup) implements GNU version-control
naming (simple/numbered/existing, ~ and .~N~ suffixes); env-less default
is `existing`, VERSION_CONTROL/SIMPLE_BACKUP_SUFFIX deliberately not read.

Coverage: Python + TS unit tests for every new flag and error path, and
26 new integ cases across integ/unix/{cp,mv}. Full integ battery green on
ram (1849) and disk (1837), 0 failures, on both hosts.

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

* fix(cp/mv): address codex review + green the CI (#609 Tier 1 follow-up)

CI fixes
- Spec drift: the new cp/mv options were never regenerated, and the new
  Option.short_value field leaked into spec/python via asdict() while the
  explicit TS serializer omitted it, breaking the two trees' key parity.
  Emit short_value from gen-specs.ts too and regenerate all three trees.
- integ-shared-{py,ts}: the 26 new cp/mv cases listed hf/hf-prefix, whose
  backend rejects these mutations ("Operation not supported"). Every
  pre-existing cp/mv case excludes it; drop it to match the canonical set.
- integ-shared-ts opfs: OPFS_CP/OPFS_MV passed the raw backend stat, so -u
  freshness could not see touch -d (OPFS has no setattr; touched times live
  in the namespace overlay). Read opts.statOverlay like OPFS_LS already
  does. Fixed properly rather than by excluding the target — mtime cases
  include opfs by convention (the meta_overlay facet exists for it).

Codex findings, each pinned against GNU coreutils 9.7
- P1 --exchange staged through a fixed `<target>.~xchg~`, silently
  clobbering a real file of that name, and left the operands half-moved on
  a mid-sequence failure. GNU's renameat2(RENAME_EXCHANGE) touches nothing
  else, so probe for a free staging name and roll back on failure; report
  the leftover path when the rollback itself fails. Docstrings no longer
  claim three renames are atomic.
- P1 A files-only find loop dropped every directory holding no files once
  any update/backup mode was set, and copied an empty tree to nothing.
  Narrow per_entry_native so the no-op modes (--update=all, --backup=none)
  keep the whole-tree dir_copy, and recreate directories via a new optional
  NativeCopy.mkdir on the genuinely-gating path.
- P1 The backup version scan swallowed readdir failures and read them as
  "no numbered backups", which then picks .~1~ and overwrites backup
  history. Propagate, and abort the overwrite instead.
- P2 -u and --update are one GNU option, so the last spelling wins; flags
  are stored per spelling, which let a fixed read order override
  command-line order. Mirror aliases in the parser for optional-value
  options only — repeatable ones accumulate (sort -k/--key concatenates
  both lists and would double).
- P2 mv -b -T refused a nonempty directory target before the backup could
  displace it; GNU renames it aside and installs the source.
- P2 A cross-mount directory backup called read_bytes on a directory.
  Walk the tree for the primitive strategies and defer to dir_copy on the
  native one. Also fixes the same swallowed-readdir shape in mv's -T
  emptiness probe, where "empty" was the clobbering direction.

Coverage
- 18 new integ cases across both hosts: the behaviors above plus gaps the
  PR left uncovered — mv --no-copy (cross-mount refusal, source kept,
  same-mount no-op), mv --backup=numbered, mv -S, mv --update=none, cp
  --backup=none, and the long spellings (--suffix, --target-directory,
  --no-target-directory, --recursive, --verbose, --strip-trailing-slashes).
- 20 new unit tests (py+ts) for the staging-name collision, both rollback
  paths, dir preservation, no-op-mode dir_copy retention, backup-scan
  failure, the -b -T backup, and last-wins aliasing.

Verified: pre-commit clean, full py suite, ts core 4991 + browser 167,
integ ram/disk/opfs green and byte-identical across the py and ts hosts.

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

* fix(test): satisfy tsc --noEmit in the new cp/mv tests

vitest transpiles without typechecking, so these slipped past a local
`pnpm --filter mirage-core test` and only the CI Typecheck step caught them.

- cp.test.ts: the typed-find helper declared its own `{ type?: string }`
  options shape, which is not assignable to FindFn's FindOptions under
  exactOptionalPropertyTypes (FindOptions.type is `string | null`). Use
  FindOptions itself.
- mv.test.ts: eacces() takes one argument, not two.

Verified with the recursive typecheck CI runs (`pnpm -r typecheck`, all 7
packages clean) rather than the single-package filter.

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

* fix: scope two new integ cases to capable backends; mirror attached short values

integ (the 6 remaining integ-shared failures, identical on both hosts)
- mv_b_T_backs_up_nonempty_dir backs the target up by *renaming a
  directory*, which the dirless object stores cannot do — a directory there
  is a key prefix with no object of its own, so s3 reports "The specified
  key does not exist" and gridfs "No such file or directory". Dropped s3,
  s3-prefix, gridfs, gridfs-prefix (20 -> 16 targets). No existing case
  asserts a successful same-mount directory rename, which is consistent
  with this being a backend limitation rather than a regression.
- xm_mv_no_copy_same_mount_ok needs a same-mount mv, i.e. a rename op,
  which hf does not register; its sibling cases pass there only because
  they refuse before touching the backend. Dropped hf/hf-prefix.
- Both crossmount groups now work inside their own subdirectories instead
  of writing to the top level of /data and /data2, so they leave no residue
  in the shared battery session.

parser
- The attached-short-value path (`-d10`) was the one write site my alias
  mirroring missed, so last-wins held for `--long=` but not for the short
  form: `split --numeric-suffixes=3 -d10` resolved to 3 instead of 10.
  Python only — the TS regex already covered all 7 call sites. Regression
  test on both sides pins the mirror and both orderings.

Verified: full py suite (8782 passed), all 7 packages typecheck, integ
ram/disk/opfs green on both hosts with identical counts.

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

* style: yapf line-wrapping on the alias-mirroring call sites

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

* refactor(cp/mv): drop the object-typed backup param, share the flag helpers

Follow-up to the review round. Claude's review listed the `object`-typed
backup helper params among what it examined; it ruled them out as a bug, but
CLAUDE.md forbids `object` on a parameter outright ("only acceptable as the
value type of an opaque flag bag"), and chasing it surfaced a py/ts
divergence next to it.

- backup_control's `value` was `object`. FlagView.raw() legitimately returns
  object (the bag IS opaque), but that stops at the boundary: for -b/--backup
  the real type is `str | bool | None`. Narrow it in a new shared
  `backup_raw()` and type the parameter accordingly. TS mirrors it —
  backupRaw/backupControl were `unknown`.
- mv.py had reimplemented backup_raw and target_flags inline, 15 lines of
  duplicated flag interpretation, while TS already exported backupRaw and
  targetFlags and mv.ts used both. Un-privatized the two cp.py helpers and
  used them from mv.py, so the two languages now share the same shape and
  flag semantics live in one place per CLAUDE.md ("adding or changing a flag
  should touch the spec and the generic, not N wrappers").

No behavior change: mypy clean (1555 files), pre-commit clean, full py suite
8782 passed, ts core 4992, integ ram/disk/opfs green on both hosts with
identical counts.

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

---------

Co-authored-by: bytecii <bytecii@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:06:52 -07:00
Zecheng Zhang 33ac93684c Cross-mount shared parse, --color no-ops, GNU option errors (#474)
* fix(executor): cross-mount parses through the shared flag helper, warnings survive

* feat(spec): optional-value long options, declare grep/rg/ls --color as GNU no-ops (#471)

* feat(spec): unknown options refuse with GNU errors, cat display flags, sort -b (#470)

* style: pre-commit formatting, displayLines module-private

* refactor(spec): USAGE_EXIT table lives in spec constants
2026-07-11 16:05:59 -07:00
Zecheng Zhang b2d41a4b1d chore(spec): regenerate JSON specs; emit repeatable and provided_by, fix py collector for factory commands 2026-07-10 22:31:25 -07:00
Zecheng Zhang f1747921fa feat(spec): export command specs to JSON for python and typescript (#71) 2026-05-19 18:00:44 -07:00