9a7be1a1e0
The txt truth files this PR deletes were named as the CI gate for the filetype examples. The gate is integ/truth/*/filetype.json now.
661 lines
58 KiB
Markdown
661 lines
58 KiB
Markdown
# CLAUDE.md
|
|
|
|
MIRAGE is a package that allows you to mount anything as a filesystem and make it usable by AI Agents.
|
|
|
|
## Repo Layout
|
|
|
|
This monorepo hosts two sibling implementations:
|
|
|
|
- `python/` — the Python package (`mirage/`, `tests/`, `pyproject.toml`, `uv.lock`).
|
|
- `typescript/` — the TypeScript monorepo (`packages/core`, `packages/browser`, `packages/node`, etc.).
|
|
- `docs/`, `examples/`, `.github/` — shared across both.
|
|
|
|
Run Python commands from `python/`, TypeScript commands from `typescript/`.
|
|
|
|
### TypeScript packages
|
|
|
|
- `typescript/packages/core` contains runtime-agnostic primitives and shared logic. Code in `core` must work in both browser and Node.js runtimes; do not put browser-only or Node-only APIs there.
|
|
- `typescript/packages/browser` contains browser-only resources, commands, and workspace wiring. It depends on `@struktoai/mirage-core`.
|
|
- `typescript/packages/node` contains Node.js-only resources, commands, and workspace wiring. It depends on `@struktoai/mirage-core`.
|
|
- Put shared TypeScript behavior in `core` only when it works in both runtime packages. Put runtime-specific behavior in `browser` or `node`.
|
|
|
|
## Python/TypeScript Parity
|
|
|
|
- Keep Python and TypeScript layout, architecture, and semantics mirrored as much as practical.
|
|
- When changing one implementation, check the other for the matching pattern or feature. If one side is more correct, use it to improve the weaker side instead of copying a bad design.
|
|
- For major Python or TypeScript changes, consider adding or updating integration coverage under `integ/`.
|
|
- **The layout half of that rule is gated.** `scripts/check_layout_parity.py` diffs the module-name sets of every `mirage/<pkg>/` against its TypeScript counterpart (core/node/browser unioned onto one namespace, plus cli/server/agents by prefix), folding camelCase, hyphens and a leading underscore so a rename reads as a rename rather than a missing module; `__init__.py` and `index.ts` are skipped because only Python needs one per directory. Intentional differences live in `spec/layout_exceptions.json` with a reason, and a stale entry fails as loudly as a new gap. `--strict` (what CI runs) does not demand zero: it fails when the count moves off the committed `baseline` in *either* direction, so new drift is blocked and closing a divergence has to be locked in by lowering the number. Run it without `--strict` for the full advisory report; that report is how layout work gets scoped.
|
|
- **mirage ships no filetype renderers, and no factory for them.** Parquet, ORC, feather/arrow/ipc and hdf5/h5 rendering are gone, along with the `parquet`/`hdf5`/`pdf` extras, the `hyparquet`/`apache-arrow`/`h5wasm` dependencies, and the whole `commands/builtin/filetype_factory/` package in both languages (with its `filetype_read` / `filetypeRead` op knobs). A file with an unregistered extension is read as raw bytes. The one surviving extension point is registration on a mount: a command or op carrying a `filetype` resolves as `(name, filetype)` before `(name, resource)` before `(name,)`. `examples/{python,typescript}/filetype/` register a `.tally` renderer end to end and are gated in CI against `integ/truth/*/filetype.json`; `tests/commands/custom/test_filetype_fns.py` and `test_unregister_removes_all_filetypes` cover the unit path.
|
|
|
|
## Module Layout
|
|
|
|
Packages split by role, one module per concern, the same way in both languages:
|
|
|
|
- **`types.py`** — data shapes only: frozen dataclasses, type aliases, Literal unions (e.g. `runtime/types.py` holds `RunArgs`/`RunResult`/`EvalValue`/`EvalResult`/`ScriptSource`). No logic.
|
|
- **`errors.py`** — the package's exception types (e.g. `runtime/errors.py` holds `EvalError`, `policy/errors.py` holds `PolicyError`).
|
|
- **`config.py`** — configuration knobs and their coercion (e.g. `runtime/config.py` holds `RuntimeConfig`, which fails loud on unknown fields).
|
|
- **`mixin.py`** — opt-in capability mixins: stateless, no constructor, abstract methods only (e.g. `runtime/mixin.py` holds `EvaluatorMixin`). Capability is detected by type (`isinstance`), never by probing for a method.
|
|
- **`base.py`** — the package's core ABC and nothing else (e.g. `runtime/base.py` is just `Runtime`).
|
|
|
|
## History
|
|
|
|
Command history is a recording, not a command log. A hidden `Observer` records every top-level command as timestamp-ordered events (`COMMAND`, `CLEAR`, `DELETE`, op events); the user-facing surfaces are just views of those events.
|
|
|
|
- **Observer + ObserverStore.** The `Observer` owns a storage-agnostic `ObserverStore` (`append`/`write`/`readAll`/`readMatching`/`clear`/`close`), not a mount. Stores: `RAMObserverStore` (core, default), `DiskObserverStore` and `RedisObserverStore` (node). RAM is just the default, history can persist to disk or Redis.
|
|
- **Two views over the same events.** `/.bash_history` is a read-only view mount (`HistoryViewResource`) rendered in GNU bash histfile format (`#<epoch>` line then the command), so `cat`/`grep`/`tail`/`find` work on it for free. The `history` shell builtin (GNU `-c -d -a -n -r -w -s -p` + count) routes through the same mount, so file and builtin never disagree.
|
|
- **Recording scope.** Top-level lines record; nested evals (`$()`, `eval`, `source`, `xargs`) run with `record: false`, so their inner ops bubble to the parent and no spurious command is logged (mirrors GNU's line reader).
|
|
- **Snapshots.** History is captured as events into snapshot state and restored on load.
|
|
- **Format is GNU bash, not zsh** (`#<epoch>`, not `: <ts>:<dur>;<cmd>`).
|
|
|
|
## CLIs
|
|
|
|
An installed CLI is a typed program tree (`CLISpec`) bound to a head word on the
|
|
workspace. It is **dispatched by name, never by operand path**: the VFS is how an
|
|
agent discovers state, the CLI is how it acts.
|
|
|
|
- **An account CLI consults no mount; `git` is the one that does.** The two are
|
|
different tiers, told apart by `config_model`. An account CLI
|
|
(`slack`, `linear`, `gws`, …) declares one, initializes from it, and reaches a
|
|
service, so a mount would be a second source of truth for the same account.
|
|
`git` declares none, because there is nothing to authenticate to, and its
|
|
subject is a repository that lives on a mount, so it reads that repository
|
|
through the op dispatcher like any command. That is what makes `git` work on a
|
|
RAM mount, a disk mount or an object store without knowing which. It reaches
|
|
the dispatcher through `CLIDoors`, one door per state plane: `dispatch` and
|
|
`stat_path` for data, `ns` for the name plane (mount boundaries, links, attr
|
|
overlay), and `session_view` for session state. The mount prefix is
|
|
`ns.mounts.root_of`, not a fourth field. `handle_cli`/`handleCli` puts the
|
|
record on `inv.doors`; the field is None/absent outside a workspace, and a
|
|
verb that never reads it cannot touch a mount, so this stays opt-in per verb
|
|
rather than ambient. The door is one field read rather than a parameter list
|
|
the dispatcher inspects, because every leaf takes exactly one `CLIInvocation`
|
|
and nothing is threaded through keyword injection. Every field is spelled the
|
|
way `CommandOpts` (`commands/config.py`) spells it, so one fact reached from a
|
|
CLI leaf and reached from a command handler has one name; meta-tests pin that
|
|
(`tests/commands/cli/test_doors_parity.py`, and a compile-time mapped type in
|
|
`commands/cli/types.test.ts`).
|
|
Do not give an account CLI a mount, and do not give `git` a `config_model`.
|
|
Nuance: an account CLI verb MAY read an unrelated workspace file the user
|
|
named on the line through `inv.doors.dispatch` (himalaya's `--attach` reads the
|
|
attachment path this way); what it must not do is treat a mount as a second
|
|
view of its own account's data.
|
|
|
|
- **The lifecycle is host-side only.** `register_cli`/`unregister_cli`
|
|
(`workspace.py`, `workspace.ts`) are called by the embedding program, never by
|
|
a line the agent types, and there is no `install`/`uninstall` shell builtin.
|
|
Keep it that way: an agent must not be able to take away the tools it was
|
|
given. Shadowing is the one thing it can do (define a shell function with the
|
|
same name), which is bash's own rule, reversible with `unset -f`, bypassable
|
|
with `command <name>`, and visible in `type -a`. A deployment that needs a head
|
|
word pinned enforces that in the policy layer's `pre_execute`, not in the CLI
|
|
registry.
|
|
|
|
- **Precedence is written down once**, in `_layers`/`layers`
|
|
(`workspace/route/route.py`, `route.ts`): shell builtin, namespace command,
|
|
function, CLI, mount. `route` takes the first match (the winner, which is what
|
|
dispatch runs) and `route_all`/`routeAll` takes all of them (every layer, which
|
|
is what `type -a` prints). The generator is lazy so the winner still costs one
|
|
probe. Do not add a second precedence list.
|
|
|
|
- **A CLI that mimics a real program is gated against that program.**
|
|
`ntn` is the worked example: every case in `integ/cli/ntn.json` is asserted
|
|
twice, once by the shared battery inside a mirage workspace and once by
|
|
`integ/ntn_conformance.ts`, which runs the *same shell line* with the real
|
|
npm `ntn` binary pointed at the same fake through `NOTION_API_BASE_URL`. A
|
|
golden both agree on is by construction what the official CLI prints, so the
|
|
grammar cannot drift from upstream without a red build. Four rules keep it
|
|
honest. The binary's version is pinned (the script refuses any other).
|
|
**Both runs share one environment**, declared as `env` on the target in
|
|
`integ/targets.json` and read from there by both hosts and by the
|
|
conformance runner: an option that reads a variable renders differently
|
|
with and without it, so two environments would mean two different lines
|
|
were being compared. That map must carry `NOTION_API_VERSION`, and the
|
|
runner fails loudly if it does not, because unset `ntn` resolves the newest
|
|
version by fetching developers.notion.com at startup and every case starts
|
|
depending on the network. A case the upstream binary cannot answer carries a
|
|
`conformance_skip` string saying why, never a silent omission; **there are
|
|
none today**, and the only two cases the runner passes over are the ones
|
|
whose line reads a `/notion` mount path, which no bare binary can have. And
|
|
the harness spawns **asynchronously**: the fake is served by the same event
|
|
loop, so a synchronous spawn deadlocks the loop that has to answer the
|
|
child's request, which is the FUSE self-touch trap in another costume.
|
|
|
|
- **`UsageStyle` is how a mimicking CLI answers in its original's voice**, and
|
|
it is read off the ROOT spec at every level, never off the node, because a
|
|
program answers in one voice throughout. It lives in `commands/spec/types.py`
|
|
(`types.ts`), not beside the CLI tree, because the help renderer is the
|
|
spec's and cannot import upward. `ARGPARSE` is the default; `GIT` rewords the
|
|
unknown-option refusal and its exit code; `CLAP` additionally governs help
|
|
layout (bare description line, `[OPTIONS]`/`<COMMAND>`, `Options:` not
|
|
`Flags:`, subcommands in declaration order rather than sorted) and the
|
|
missing-operand refusal. `ntn` is the CLAP one. Adding a dialect means adding
|
|
a member here, not a second knob: help layout, refusal wording and exit code
|
|
are one decision about whose program this imitates.
|
|
|
|
Three spec fields exist only to serve those foreign usage lines, and all
|
|
three hold **bare names with no brackets**, because only the renderer knows
|
|
the dialect that wraps them: `Operand.name` (`PAGE_ID`, rendered `<PAGE_ID>`
|
|
required and `[PAGE_ID]` not), `Operand.required` (the parser reports the
|
|
empty slot rather than each leaf re-discovering and rewording it), and
|
|
`Option.metavar` (`VERSION`, rendered `--notion-version <VERSION>`; derived
|
|
from the long spelling when absent, which covers most options and is why only
|
|
the four upstream overrides declare one). `Option.env` is a fourth and is
|
|
**not** a synonym for `default`: an env-sourced value counts as *supplied*,
|
|
so clap echoes it in a usage line where a defaulted one is invisible, and it
|
|
is read from the session rather than frozen into the spec. The executor fills
|
|
it, so a leaf reads one flag instead of a flag and a fallback.
|
|
|
|
- **Imitating a Rust CLI means imitating serde_json, so the engine parser does
|
|
not get to decide.** `ntn api` echoes its JSON parse failures verbatim, and
|
|
those words are serde_json's (`EOF while parsing an object at line 1 column 1`), which neither `json.loads` nor `JSON.parse` can produce and which the
|
|
two of them word differently from each other anyway. So
|
|
`commands/cli/builtin/ntn/serde.py` (`serde.ts`) is a scanner whose only job
|
|
is that message, and it is the **authority on validity too**: the engine
|
|
parser runs only on input the scanner already accepted, because the two
|
|
disagree about what JSON is (python accepts `NaN`, serde refuses it, and
|
|
silently sending `NaN` is worse than either). Three rules that are easy to
|
|
get wrong and are pinned by a probed table in `test_serde.py` /
|
|
`serde.test.ts`: columns count **bytes**, not characters (`["é"x` fails at
|
|
column 6); a newline advances the line and zeroes the column; and the
|
|
recursion limit is 128, refused at the opening bracket of the 128th
|
|
container. Do not "simplify" this to a try/except around the engine parser.
|
|
|
|
- **`ntn api` has three body sources, and both the order and the exit codes are
|
|
observable.** stdin, `--data` and inline `path=value` / `path:=json` inputs
|
|
are each validated in that order, and only then is "more than one source"
|
|
reported, so a malformed pipe outranks a malformed `--data` and both outrank
|
|
the conflict. The exit codes are two families, neither of them argparse's 2:
|
|
a body that arrived malformed is **1** (`error: Invalid JSON from stdin`),
|
|
while a line the CLI will not interpret at all is **5** (the conflict, an
|
|
unparseable inline input, an empty `--data`), and those carry a second
|
|
` hint:` line. There is **no object check anywhere**: `--data '[]'` posts
|
|
the array, and any body source at all makes the call a POST even when what
|
|
it carries is empty, so `--data '{}'` posts rather than falling back to GET
|
|
on falsiness. `name==value` stays a query parameter whatever the method is.
|
|
|
|
- **Discoverability is part of shipping a CLI**, and it comes from the spec, so
|
|
it works for a user's own registered CLI exactly as for a builtin one. `man <cli>` and `man <cli> <verb>...` render through `node_help`/`nodeHelp`, the
|
|
same renderer `--help` uses, so a manual cannot drift from the program; bare
|
|
`man` lists installs under `# clis`. `type` reports an installed CLI as its own
|
|
kind (`type -t` prints `cli`, a sixth word beside bash's five, because reusing
|
|
`file` would promise `type -p` a path that does not exist). `which` prints the
|
|
bare name, never a fabricated path.
|
|
|
|
## Notion data sources
|
|
|
|
The notion backend speaks **`Notion-Version: 2025-09-03`**, the generation that
|
|
split a database into a container plus one or more *data sources*. The split is
|
|
not cosmetic and it is not optional:
|
|
|
|
- **The database object no longer carries `properties`.** The column schema
|
|
lives only on the data source, so `GET /v1/databases/{id}` answers with
|
|
`data_sources: [{id, name}]` and nothing to render a schema from. Do not
|
|
synthesize one back onto the container; the fake deliberately omits it so
|
|
anything that still reads a column list off a database fails loudly.
|
|
- **The mount nests accordingly**, because that is where the data is:
|
|
`databases/<Title>__<db-id>/database.json` is the container, and
|
|
`databases/<Title>__<db-id>/<Name>__<ds-id>/data_source.json` is the schema,
|
|
with the row pages under the data source. A row page therefore sits at depth
|
|
4 under `databases/`, not 3; `readdir`/`read`/`stat` all key on that in both
|
|
languages. The name stutters for a single-source database because Notion
|
|
names the auto-created data source after its database, which is honest and
|
|
disappears the moment a database holds two.
|
|
- **A row's parent is its data source**: `{type: "data_source_id", data_source_id, database_id}`. The database id rides along because Notion
|
|
kept emitting it, and the fake stores rows keyed by database, which is the
|
|
same fact one derivation away.
|
|
- **`/search` rejects `filter.value = "database"`** at this version; the
|
|
searchable schema-bearing object is `data_source`. Listing databases is
|
|
therefore "search data sources, take the distinct parents, retrieve each",
|
|
which costs one extra call per database and is the only way to get the
|
|
title and url the directory is named and rendered from.
|
|
- ids are **not interchangeable**: a data source id is not its database id.
|
|
The fake derives one from the other (`d5000000` + the database's tail) rather
|
|
than reusing it, precisely so an id mix-up cannot pass.
|
|
|
|
## Mount boundaries
|
|
|
|
A mount root is not an ordinary directory, and a mount nested inside
|
|
another mount's tree is invisible to the backend that owns the parent:
|
|
the child's keys live in a different resource, so the parent's `readdir`
|
|
never lists it. Two mechanisms follow from that, and they are separate.
|
|
|
|
- **`MountView` is how a command sees the boundaries** (`ops/types.py`,
|
|
`ops/types.ts`), and it is offered the way `LinkView` is: a command
|
|
opts in by naming a `mounts` parameter, `execute_cmd`/`executeCmd`
|
|
delivers it only to handlers that do, and there is no list of
|
|
boundary-aware commands anywhere. It carries `descendants` (mount
|
|
roots strictly under a path), `is_root`, and `root_of`.
|
|
A traversal command that renders **lines** does not need it: the
|
|
executor's fan-out (`workspace/executor/fanout.py`) already reruns
|
|
find/du/tree/grep -r per mount and concatenates the output. A command
|
|
whose output is one **binary object** cannot be merged that way, which
|
|
is why `tar` reads the boundaries itself.
|
|
- **Crossing into a descendant mount is refused, not attempted.** `tar`
|
|
and `zip` both keep the mountpoint as a directory entry and drop its
|
|
contents with GNU's own `--one-file-system` wording (`<name>/: file is on a different filesystem; not dumped`; Info-ZIP has no message of
|
|
its own for this, so zip borrows the wording under its own
|
|
`zip warning:` prefix). This is deliberate: descending would archive
|
|
by accident exactly what the mount-root refusal below forbids on
|
|
purpose.
|
|
- **`MountRootPolicy` refuses a mount root in a source slot** for `tar -c`,
|
|
`zip` and `cp`, on top of the POSIX EBUSY rules it already enforces
|
|
for `rm`/`rmdir`/`mv`/`mkdir`/`touch`/`ln`. Real tar and cp allow it;
|
|
mirage does not, because the mount table is the deployment's
|
|
configuration and reading a whole backend into one object is neither
|
|
what the operand looks like it costs nor something an agent should be
|
|
able to do to data it was given a view of. Only **positional** operands
|
|
are tested, which is why `CommandContext` carries `operands` beside
|
|
`paths`: `tar -xf a.tar -C /mnt` extracts INTO a mount and must stay
|
|
legal, while `tar -cf a.tar /mnt` must not. `positional_scopes` /
|
|
`positionalScopes` (`executor/command/routing`) is what tells the two
|
|
apart, since classification turns every path-shaped word into a
|
|
PathSpec whether it filled an operand slot or a flag's value. Mode
|
|
matters too: only `tar -c` reads its operands from the filesystem, so
|
|
`is_create_mode` / `isCreateMode` gates the refusal. Under `-t` and
|
|
`-x` an operand is a member selector matched inside the archive, and
|
|
refusing one that happens to spell a mount root would deny an
|
|
ordinary listing.
|
|
|
|
## `CommandSpec` and `Operand` are not a scratchpad
|
|
|
|
Both dataclasses are shared by every command in the repo, so a field
|
|
added for one command is a field every other command's author has to
|
|
read past, and a fourth one turns the grammar into a pile of per-command
|
|
dialects. **Do not add a field to `CommandSpec`, `Operand` or `Option`
|
|
unless POSIX *and* argparse both already have the concept, and then name
|
|
it after theirs, not after the mechanism it trips inside the parser.**
|
|
|
|
Both, not either. The test is literal, not a judgement call: write the
|
|
equivalent line in argparse and run it. If argparse parses it, the
|
|
concept is borrowed and the field is allowed. If argparse cannot express
|
|
it, the behavior belongs to the one command that needs it and must be
|
|
handled in that command, not in the shared grammar.
|
|
|
|
POSIX alone is not enough, and python3 is exactly why: POSIX specifies
|
|
an option whose argument is a program (`sh -c command_string [command_name [argument...]]`, and python3's own synopsis
|
|
`python [option] ... [-c cmd | -m mod | file | -] [arg] ...`), so an
|
|
either-or rule would license the `Option` field this section exists to
|
|
refuse. argparse is the narrower gate because it is the parser this
|
|
spec layer is modelled on, so a concept it cannot express is one the
|
|
grammar has no shape for.
|
|
|
|
Both halves of python3's command line are the worked example, and they
|
|
come out on opposite sides:
|
|
|
|
- **Allowed: `Operand.remainder`.** It is `nargs=argparse.REMAINDER`,
|
|
which is POSIX's own option order (the first operand ends option
|
|
parsing; GNU's permuting default is the extension, and `POSIXLY_CORRECT=1 ls a -1` shows the difference). argparse spells
|
|
it on the positional slot, so mirage spells it on `Operand` too, and
|
|
`CommandSpec` does not change at all.
|
|
- **Not allowed: an option whose argument is a program.** `python3 -c 'code' -u x` must hand `-u` to the code, but
|
|
`add_argument("-c"); add_argument("rest", nargs=REMAINDER)` answers
|
|
`unrecognized arguments: -u`. CPython's own command line is parsed in C
|
|
for exactly this reason. There is no concept to borrow, so this does
|
|
not become an `Option` field.
|
|
|
|
A `CLISpec` **is** a `CommandSpec` (python: subclass; TypeScript:
|
|
`extends`), and every level of a CLI tree parses with the ordinary spec
|
|
machinery. Moving a command to the CLI tier therefore does not exempt it
|
|
from this rule, because it still parses with the same `Option` and
|
|
`Operand`. The CLI tier is for a program *tree* (a verb the line selects,
|
|
like `git status` or `ntn api`), not for a program with an unusual
|
|
option grammar.
|
|
|
|
## An option that chdirs: `operand_base`
|
|
|
|
`tar -C` is not a flag the command reads once, it is a chdir for the path
|
|
operands typed **after** it, and it is cumulative (`-C d1 x -C ../d2 y`
|
|
reads `d1/x` and `d1/../d2/y`). That is a property of the line, so it is
|
|
declared in the spec (`CommandSpec.operand_base` / `operandBase`, tar's
|
|
only) and resolved by the one component that walks the line
|
|
positionally: `parse_command` / `parseCommand` tracks the base as it
|
|
scans and reports it per word as `word_bases` / `wordBases`, which
|
|
`classify_parts` then resolves each operand against. Doing it anywhere
|
|
later is too late: the classifier has already produced absolute
|
|
PathSpecs, and an operand resolved against the wrong base makes the
|
|
router see a phantom cross-mount span (which is what
|
|
`tar -czf /work/out.tgz -C /work/check my_paper` used to fail as).
|
|
Only path operands and the option's own value move; every other
|
|
path-valued flag keeps resolving against the session cwd, which is what
|
|
GNU does with `-f`.
|
|
|
|
## Symlinks
|
|
|
|
Symlinks are **namespace state, not backend state**. The `Namespace` node table
|
|
owns them (target stored verbatim as typed), no resource stores or reports one,
|
|
and no backend `readdir` or `stat` can see one. Three consequences, in the order
|
|
they bite:
|
|
|
|
- **A new resource needs no symlink code at all.** Links live above every
|
|
backend, so a backend author never implements, stores, or forwards one. This
|
|
is the whole point of keeping them in the namespace; do not push link
|
|
awareness down into a resource or an accessor.
|
|
- **A command consumes a fact by reading the `opts` field, nothing else.**
|
|
Every namespace fact (`links`, `stat_overlay`, `stat_path`, `readdir_path`,
|
|
`child_mounts`, `mounts`) rides `CommandOpts` into every handler, identically
|
|
in both languages; the generic that wants one reads `opts.links` and the rest
|
|
ignore it, so there is no opt-in registry, spec field, or signature
|
|
convention that can fall out of step. `LinkView` bundles every link fact
|
|
(`stat_at`, `children`, `subtree`, `resolve`, `exists`, `target_stat`) so a
|
|
command that grows a new need adds a field read, not a new keyword threaded
|
|
through `execute_cmd`, the builder and the generic. Families reading links
|
|
today: `ls`, `stat`, `find`, `du`, `file` — in the generics, so a bespoke
|
|
wrapper that delegates (as all of them now do) inherits link awareness for
|
|
free. `exists` and `target_stat` answer through the op dispatcher, not one
|
|
backend's stat, so a link that points into another mount resolves correctly.
|
|
- **A CLI verb that walks a tree itself has to lstat through the same door**,
|
|
which on that tier is `CLIDoors.ns.links`. A walk built on `stat_path` alone
|
|
dereferences, so a link reads as its target and a broken one reads as absent.
|
|
`git`'s worktree walk did both until it opted in, which is why `git add`
|
|
stored a symlink as a regular file holding its target's bytes (real git
|
|
stores mode `120000` with the target as the blob) and why `git status` never
|
|
listed a broken one.
|
|
- **Merge links in the generic, above the native-op/walk fork.** `find` and `du`
|
|
each have two paths: a backend with a native op (`find_core`, `du_size`/
|
|
`du_entries`) and a backend walked by `readdir`. Link merging lives in one
|
|
shared place per family (`link_results` in `generic/find.py`, `link_leaves` in
|
|
`generic/du.py`) that both paths call. Merging inside only one path makes a
|
|
mount's symlink behavior depend on whether its backend happens to ship a
|
|
native op, which is the worst kind of divergence to debug.
|
|
|
|
Follow policy is two symmetric tables in `workspace/route/constants.py`, both
|
|
read off the raw command line (operand rewriting happens before flag parsing):
|
|
`NO_FOLLOW_COMMANDS` lists commands that lstat (`rm`, `mv`, `ln`, `readlink`,
|
|
`rmdir`, `unlink`, `stat`, `file`, `du`, `find`, `tar`, `zip`), with
|
|
`DEREFERENCE_FLAGS` naming the flag that turns following back on (`-L`).
|
|
`tar` and `zip` are in that list for a different reason and deliberately carry
|
|
no `DEREFERENCE_FLAGS` entry: they dereference too, but their planner has to be
|
|
the one doing it. Rewriting the operand in the router hands the planner a
|
|
target it can no longer tell was reached through a link, so `tar` stored a
|
|
regular file where GNU stores a symlink member, and neither archiver could
|
|
apply its own cross-mount refusal or ELOOP wording. `tar -h` and `zip -y` are
|
|
read by `scan_operand` instead. `find` states its policy as
|
|
a leading `-P`/`-H`/`-L` option instead, last one wins, so it lives in
|
|
`LAST_WINS_LINK_OPTIONS`;
|
|
`NO_FOLLOW_FLAGS` is the mirror, for a following command that a flag makes lstat
|
|
(`ls -l` and `ls -d` report a command-line link itself, while a bare `ls`
|
|
dereferences a link to a directory, and `ls -L` overrides both).
|
|
|
|
Those tables only cover the *operand*; honoring `-L` below it is the generic's
|
|
job, not the router's.
|
|
|
|
Rendering derives from one fact: `link_stat` builds
|
|
the row with `FileType.SYMLINK` and the target under `FileStat.extra`
|
|
(`LINK_TARGET_KEY`), so `lrwxrwxrwx`, the `name -> target` column, `-F`'s `@`,
|
|
and `file`'s "symbolic link to" all follow from it without a second lookup.
|
|
|
|
`du` sizes a link at its target string's length. This is not a divergence:
|
|
mirage's `du` counts bytes, which is GNU's `--apparent-size --block-size=1`
|
|
(`du -b`) mode, and in that mode GNU reports a symlink as `len(target)` too. The
|
|
familiar `0` comes from GNU's default 1 KiB *block* mode, where a short target
|
|
sits inline in the inode and occupies no data blocks (`stat` reports
|
|
`size=15 blocks=0` for it). mirage has no block mode at all, so `0` is not an
|
|
option it can express; comparing against it would also make every regular file
|
|
look wrong (a 6-byte file is `6` in bytes, `4` in 1 KiB blocks).
|
|
|
|
## FUSE
|
|
|
|
- **The mount layer is split core/adapter in both languages.** `MountCore`
|
|
(`python/mirage/fuse/core.py`, `typescript/packages/node/src/fuse/core.ts`)
|
|
owns all filesystem semantics in POSIX terms and imports nothing from
|
|
mfusepy or `@zkochan/fuse-native`; `MirageFS` in `fs.py`/`fs.ts` is the
|
|
libfuse adapter and owns only the callback signatures plus errno
|
|
translation. Core methods raise ordinary exceptions; adapters classify them
|
|
through `classify_error`/`classifyErrno` (`fuse/errors.py`, `fuse/errors.ts`),
|
|
which is one shared table, not per-method `except` arms. Put new filesystem
|
|
behavior in the core, not the adapter.
|
|
- **One `backend` field per mount: `vfs | fuse | fskit`** (`MountBackend` in
|
|
`mirage/types.py`, beside `MountMode`). `vfs` is the default and means the
|
|
mount lives only inside mirage's own filesystem; `fuse` and `fskit` also
|
|
register a real mountpoint, with `mountpoint` pinning where. There is no
|
|
`fuse=True` boolean any more, and no `auto` backend.
|
|
`Mount(..., backend=MountBackend.FSKIT)` routes through macFUSE 5.x's FSKit
|
|
shim (no kernel extension). Rules live in `fuse/backend.py` and are enforced
|
|
at mount time: macOS-only, mountpoint must be under `/Volumes`, and every
|
|
mounted resource must set `SIZES_ALWAYS_KNOWN` (FSKit has no `direct_io`, so
|
|
a size-unknown resource would serve silent empty files). `resolve_backend`
|
|
rejects `vfs`: reaching it means a kernel mount was requested. In YAML the
|
|
keys are `backend:` and `mountpoint:`.
|
|
TypeScript serves fskit too: `fuse.node` links `/usr/local/lib/libfuse.2.dylib`
|
|
by absolute path (the bundled `libosxfuse.2.dylib` is a stub with that
|
|
install name), so `backend=fskit` + `volname` reach macFUSE 5.x's own
|
|
libfuse via `appendMountOptions` in `mount.ts`. Verified live. Caveat: a TS
|
|
fskit mount intermittently wedged on a write op in testing (probed from
|
|
child processes), and a dead FSKit volume blocks mount-table enumeration
|
|
system-wide until the macFUSE appex process is killed; treat fskit from TS
|
|
as read-mostly and experimental.
|
|
A mount is ready only when `os.path.ismount` says so, never when the
|
|
`/Volumes` entry merely exists: macFUSE creates that directory while
|
|
mounting and leaves it behind if the FSKit handoff fails, which reads as a
|
|
live mount and then fails with ENOENT on the first read.
|
|
**Python fskit mounts have the full write surface, and only because of
|
|
`fuse/darwin.py`.** The FSKit shim finalizes every created item through
|
|
macFUSE's Darwin-only `setattr_x` and routes rename through `renamex`;
|
|
mfusepy leaves those `fuse_operations` slots as reserved NULLs, so without
|
|
the extension module create/mkdir fail with ENOSYS *after* the op already
|
|
applied and rename never reaches userspace (verified by libfuse wire
|
|
trace: CREATE success, then SETATTR -78). Do not remove the
|
|
`install_macfuse_extensions()` call in `mount.py`, and keep the struct
|
|
tail in sync with macFUSE's fuse.h if mfusepy changes layout. Pinned in
|
|
`integ/fuse/truth_fskit.json` on macFUSE 5.3.3 / macOS 26.
|
|
TS fskit stays read-mostly: fuse-native's compiled op table cannot gain
|
|
new C callbacks from JS. Upstream shim caveats that remain for both:
|
|
exec-until-first-read fails (macfuse#1181) and root readdir cache cannot
|
|
be invalidated (macfuse#1165).
|
|
`integ/fuse/fskit.py` is the only coverage of a real FSKit mount, and it only
|
|
runs on a Mac with macFUSE 5.x: on the `integ-fskit-macos` job a hosted
|
|
runner installs and enables everything but the mount request never reaches
|
|
macFUSE, so the job reports that one known timeout signature as a skip and
|
|
stays green; any other failure there is real.
|
|
- **Directory and unknown sizes.** `getattr` reports `st_size` 0 for directories and for API-backed size-unknown files that have not been opened recently. Reads stay correct because Python mounts with `direct_io` (kernel reads to EOF regardless of st_size) AND `attr_timeout=0` (post-open fstat routes to `getattr(path, fh)`, which serves the real size of the open-hydrated content); prefetched bytes live in a 30s TTL cache (`PREFETCH_TTL`) so release-then-stat does not refetch. All three pieces are load-bearing: without `attr_timeout=0`, `wc -c` prints 0, BSD `cp` copies 0 bytes, and `tail -c` dumps the whole file; without `direct_io`, `cat` reads 0 bytes on macOS. Do not "fix" getattr to report real sizes eagerly (one API fetch per `ls -l` entry), and do not report fake sizes: stat-only tools (`tar`, `rsync`, `test -s`) seeing 0 matches procfs precedent. TypeScript uses the same recipe: `@zkochan/fuse-native` doesn't serialize a `direct_io` option, so `mount.ts` appends it to the option string at runtime (`appendDirectIO`; a pnpm patch would not reach consumers), plus `attrTimeout: '0'` + fgetattr. The old 100 MiB sentinel is gone; do not reintroduce it.
|
|
- **TS FUSE mounts are served by the mounting process's event loop.** Never touch your own mountpoint synchronously (`readFileSync`, `statSync`, `execFileSync`) from the process that created the mount: the call deadlocks the loop that must answer it, the kernel times out, and every later op fails with `Device not configured`/`ENOTCONN` — which looks exactly like a broken mount. Probe from a child process or use async APIs (see `examples/typescript/fuse/helper.ts`). Python is immune (FUSE loop runs on a thread).
|
|
- **`FileStat.size` must be the rendered content's byte length or `None`, never a storage-side or source-side number.** A confidently wrong size is worse than an unknown one: over FUSE it makes `wc -c`/`ls -l` lie and risks truncated copies, while `None` rides the unknown-size machinery above. Postgres `rows.jsonl` (on-disk `table_size_bytes` vs rendered JSONL), Dify documents (uploaded source size vs rendered segment text), Gmail messages (`sizeEstimate` vs rendered `.gmail.json`), Drive-rendered google-apps files (Drive storage size vs rendered `.gdoc/.gsheet/.gslide` JSON; raw binary downloads keep Drive's size), and Microsoft Graph folders (OneDrive/SharePoint report an aggregate subtree `size` on the folder facet, not any content length) made this mistake; their storage/source numbers now live in `extra` (`size_bytes` / `source_size` / `size_estimate`). Do not reintroduce it in new backends. Graph's `folder.childCount` rides along in `extra` too, and is what `find -empty` reads for a directory.
|
|
- **macOS allows only one FUSE mount per process.** The second mount dies with `fuse: cannot register signal source` (mfusepy registers libfuse signal handlers, which only the first mount in a process can claim). Multi-mount scenarios (`integ/fuse/fuse.py` mounts two) pass only on Linux; do not debug them as regressions on macOS. A failed run leaks the first mount: list with `mount | grep MirageFS`, clean with `umount <mountpoint>`.
|
|
- **Windows (WinFsp) conventions differ in three ways**, all handled in the python mount path (`_prepare_mountpoint`, `_await_ready`, the teardown branches): the mountpoint must NOT exist (WinFsp creates it; an existing dir fails with "mount point in use"), `os.path.ismount` never sees WinFsp directory mounts (readiness = bare existence), and there is no `fusermount` (WinFsp unmounts when the serving process exits). Ownership: mount with `uid=-1,gid=-1` (WinFsp builtin: files owned by the mounting user); never report raw POSIX ids into the SFU/Cygwin SID mapping, and `os.getuid` does not exist there (MirageFS caches a guarded uid/gid once). Behavior quirk: Windows cannot stat without opening a handle, so size-unknown files hydrate on first stat and report their real size even "pre-open" (multi-mount per process works). The `integ-fuse-windows` job is advisory (not in the gate).
|
|
|
|
## Development Setup
|
|
|
|
This project uses `uv` for Python dependency management. Install dependencies with:
|
|
|
|
```bash
|
|
cd python && uv sync --all-extras --no-extra camel
|
|
```
|
|
|
|
`camel` is declared as conflicting with `openai` (and other extras) in `pyproject.toml`, so `uv sync --all-extras` fails. Exclude `camel` to keep the `openai` stack.
|
|
|
|
### Running examples
|
|
|
|
Examples under `examples/python/` load `.env.development` from the repo root (cwd-relative). To keep cwd at the root while using the `python/` venv, invoke the venv interpreter directly:
|
|
|
|
```bash
|
|
./python/.venv/bin/python examples/python/s3/s3.py
|
|
```
|
|
|
|
Avoid `uv --directory python run ...` for examples — it changes cwd to `python/` and breaks `load_dotenv(".env.development")`.
|
|
|
|
## Backward Compatibility
|
|
|
|
- No need to consider backward compatibility for the code.
|
|
|
|
## Create a PR
|
|
|
|
When asked to create a PR, please follow the following steps:
|
|
|
|
1. Run `pre-commit run --all-files` from the repo root to lint and format the code.
|
|
1. Run `cd python && uv run pytest` to run the Python tests.
|
|
1. Run `git add -A` to add all changes.
|
|
1. Run `git checkout -b <branch-name>` to create a new branch.
|
|
1. Run `git commit -m "<commit-message>"` to commit the changes.
|
|
1. Run `git push origin <branch-name>` to push the changes to the remote repository.
|
|
1. Run `gh pr create --title "<pr-title>" --body "<pr-body>"` to create a PR.
|
|
|
|
## Commands
|
|
|
|
### Linting and Formatting
|
|
|
|
After making major changes, run pre-commit from the repo root to ensure code quality:
|
|
|
|
```bash
|
|
./python/.venv/bin/pre-commit run --all-files
|
|
```
|
|
|
|
Invoke the venv's `pre-commit` binary directly (not via `uv --directory python run`) so cwd stays at the repo root — otherwise `git ls-files` only lists files under `python/` and `examples/` gets silently skipped.
|
|
|
|
## Type Conventions
|
|
|
|
- Paths must always be represented as `PathSpec`, never raw strings. All functions that accept or return paths use `list[str | PathSpec]` where `str` is for text arguments and `PathSpec` is for paths. Never pass a path as a plain `str` — wrap it in `PathSpec`.
|
|
|
|
## Rules
|
|
|
|
- **Shell-style commands** (cat, grep, du, find, head, tail, wc, ls, etc.) follow POSIX / Unix coreutils semantics as much as possible; match BSD/GNU behavior and document any deliberate divergence. Pin exact GNU behavior with docker (`debian:stable-slim`) before changing command semantics.
|
|
- **`find -size` is strict and rounds up.** GNU `+N` keeps `ceil(size/unit) > N`, `-N` keeps `ceil(size/unit) < N`, bare `N` keeps `ceil(size/unit) == N` (so `-size -1k` matches only empty files and `+0c` excludes empty ones). The parsers (`_parse_size` / `parseSize`) translate this once into inclusive byte bounds; backend cores just keep `min_size <= size <= max_size` and must not re-interpret the spec. Deliberate divergence: directories count as size 0 (GNU compares the inode size, e.g. 4096 on ext4), which matches what `find` sees over a mirage FUSE mount.
|
|
- **`find`'s row for the start point is the generic's, not the backend's.** GNU lists a start point before descending into it, and every native find op used to decide that row from its own listing, which is only a proxy for existence: an object store holding no keys under the prefix and no directory marker reported nothing at all for a directory `test -d` and `tree` both saw, and ssh called every directory non-empty so `-empty` never matched one. The generic stats the start point through the dispatcher (`resolve_start`, which asks both channels a backend can answer on via `resolve_path_stat`, since on a prefix store a directory is the set of keys under it rather than an object), takes one readdir for `-empty` (`dir_empty`, wired by the builder), and then replaces whatever row the backend produced for it (`with_root_row` / `withRootRow`). A native find op may still emit the start path and they all do, because it is the answer when no dispatcher probe is wired (a command constructed outside a workspace); with one wired it is discarded. A new backend only has to report descendants.
|
|
- **`find` classifies walked entries through `stat`, never by name.** The
|
|
walk's one in-band proof is a trailing slash on a cold listing (box, gdrive
|
|
and dropbox mark folders that way, and no backend renders a file with one);
|
|
every other entry is classified by the index-backed `stat` that the same
|
|
readdir just populated, so the lookup is RAM, not another API call. There is
|
|
no per-backend `is_dir_name`/`isDirName` hint: those heuristics guessed
|
|
wrong as soon as a child's name was user-controlled (email and gmail
|
|
attachments and slack uploads carry whatever name the sender gave them, and
|
|
were reported as directories, so `find -type f` missed them). Do not
|
|
reintroduce name-based classification in a backend; if stat misclassifies an
|
|
entry, fix that backend's stat.
|
|
- **An archiver walks a directory operand; it does not read it.** `tar`
|
|
and `zip` decide every member first (`plan_create` / `planCreate` in
|
|
`generic/tar/create.*`, `plan_zip` / `planZip` in
|
|
`generic/zip_cmd.*`) and only then write, which is what lets an
|
|
exclusion prune a whole subtree and keeps the ordering stable. 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 (reusing find's `walk_find` / `walkFind`, so
|
|
an archiver classifies an entry through `stat` exactly as find does,
|
|
never by name), the namespace's symlinks, and the mount table. It
|
|
reports paths, never names, because naming is exactly where the two
|
|
formats disagree; the two things they disagree about in the traversal
|
|
itself are parameters (`dereference`, `recurse`), so **a third
|
|
archiver adds a caller, not a second walk**. Members are named from
|
|
`PathSpec.raw_path`, so `tar -C d x` stores `x`, not `d/x`.
|
|
**A directory is its own member**, with GNU's trailing slash and no
|
|
content, which is the only record an empty directory leaves and the
|
|
reason extraction has to `mkdir` for one. **A symlink is a symlink
|
|
member** (`SYMTYPE`, target in `linkname`), never a file of its
|
|
target's bytes, unless `-h` says to follow it.
|
|
**Two links to one target are not a loop**, and both are archived; the
|
|
only loop is one `resolve` refuses to resolve, since the namespace
|
|
already walks the chain under a hop limit and raises `CycleError` at
|
|
the end of it. That arrives as a fatal `Problem` carrying GNU's
|
|
`Too many levels of symbolic links`, reported per member with the
|
|
directory entry kept, rather than as an exception that aborts the
|
|
plan. **Every `-C` is checked, not just the last**: GNU chdirs at each
|
|
one and fails at the first it cannot enter, so the option accumulates
|
|
(`multiple=True`) and the planner walks the list.
|
|
Two deliberate divergences from GNU, both documented in place:
|
|
siblings are sorted rather than emitted in readdir order (the same
|
|
choice `du` makes, for the same reason), and a descendant mount is
|
|
never crossed (see "Mount boundaries"). Everything else is pinned
|
|
against GNU tar 1.35 on `debian:stable-slim`: the leading-slash
|
|
warning, `Cowardly refusing to create an empty archive` (exit 2), a
|
|
per-operand `Cannot stat` plus one trailer (exit 2, and the other
|
|
operands still archive), a `-C` it cannot enter (exit 2, no archive
|
|
written), and `archive cannot contain itself; not dumped` (exit 0).
|
|
A backend error must never reach the user as itself: an unreadable
|
|
operand is reported in virtual path space with tar's wording, because
|
|
the raw `IsADirectoryError` leaked the host path behind a disk mount.
|
|
- **`zip` is Info-ZIP, which inverts tar's two defaults.** A directory
|
|
operand contributes only its own entry unless `-r` says to descend,
|
|
and a symlink is *followed* unless `-y` says to store the link, where
|
|
tar always descends and always stores unless `-h`. Both are just the
|
|
`recurse` / `dereference` arguments to the shared scan. The rest is
|
|
pinned against Info-ZIP 3.0 on `debian:stable-slim`: a leading slash
|
|
is stripped **in silence** (tar warns, zip does not), `-j` junks to
|
|
the basename and drops directory entries entirely, `-x` is
|
|
**anchored** on the whole stored name (`d/sub/*` matches, `sub/*` does
|
|
not) where tar's `--exclude` is unanchored, an unreachable operand is
|
|
`\tzip warning: name not matched: <name>` and does not stop the run,
|
|
and a run that matched nothing prints `zip error: Nothing to do!`,
|
|
exits **12**, and writes no archive. `-q` silences the warnings but
|
|
never that error. Two deliberate divergences: `-x` takes one pattern
|
|
per occurrence (mirage's spec has no variadic option value, and
|
|
`-x a -x b` says the same thing), and the `adding:` line carries no
|
|
`(deflated N%)` suffix, since the ratio depends on the compressor and
|
|
would differ between the two languages.
|
|
- **Tar formats come from a library, not from hand-rolled block code, and
|
|
bzip2 is read-only in TypeScript.** Python builds archives with stdlib
|
|
`tarfile`; TypeScript uses `modern-tar` behind `tar_helper.ts`, which
|
|
keeps the `TarEntry` shape (`name`/`data`/`isFile`/`isDir`/`linkname`)
|
|
the two call sites already speak and is the only place the dependency
|
|
is named. Do not go back to writing ustar blocks by hand: the version
|
|
that did truncated any name past 100 bytes instead of using the ustar
|
|
`prefix` field or a PAX header (so a deep member extracted to the wrong
|
|
path), and read a PAX/GNU extension block as if it were a member (so
|
|
`tar -t` on any archive GNU or Python wrote listed a phantom
|
|
`././@PaxHeader` row). `writeTar`/`readTar` are async for this reason.
|
|
Compression is a registry (`registerCompressionCodec`): gzip is built
|
|
in via `CompressionStream`, and a codec may be **decompress-only**,
|
|
which is the one deliberate py/ts divergence here. Python reads and
|
|
writes `.tar.bz2` because `bz2` is stdlib; TypeScript only reads one,
|
|
because every JavaScript bzip2 *compressor* is GPL (`compressjs`,
|
|
`archive-wasm`) and an Apache-2.0 package cannot ship that, while
|
|
`seek-bzip` (MIT) decodes. `tar -cj` therefore exits 1 with
|
|
`tar: bzip2 not supported`, the same answer browser core already gives
|
|
for an unregistered codec. If a permissively licensed bzip2 compressor
|
|
appears, adding `compress` to that one codec closes the gap with no
|
|
other change.
|
|
- **The TypeScript `walkFind` answers in mount-relative keys; the Python
|
|
`walk_find` answers in virtual paths.** TS's stands in for a backend's
|
|
native find op, so a caller that needs virtual paths (tar does, to
|
|
name members and compare against mount prefixes) lifts them with
|
|
`mountPrefixOf` the way `findGeneric` does. That lift lives once, in
|
|
`generic_bind/archive_io.*`, which is where both archivers get their
|
|
walk. This asymmetry is real and has bitten once: a unit test on an
|
|
unprefixed mount cannot see it, so cover a prefixed mount too.
|
|
- **`du` has one backend contract: `size` and `entries`.** Each backend exposes `core/<backend>/du/size.py` (recursive byte total for one path) and `core/<backend>/du/entries.py` (per-file breakdown), wired as `du_size` / `du_entries` on the adapter. `entries` returns `(entries, total)` where entries are **leaf files only, in mount-relative path space, with no summary row**; the generic lifts them onto virtual paths (`to_virtual`, via `mount_prefix_of`) and re-spells them as the operand was typed (`respell_raw`). A backend that returns backend-key paths, or appends its own roll-up row, makes two mounts holding the same filename render identical lines. Do not reintroduce a second shape; the old flat-list `du_multi` contract is gone.
|
|
- **`du` prints a line per directory, derived not walked.** GNU prints one line per directory with its recursive total, post-order (children before parents), plus one per file under `-a`. Backends only ever report leaf files, so the generic derives the directory rows by summing each leaf into every ancestor (`rollup`, same name both languages), then emits post-order with siblings sorted. Two deliberate divergences: GNU orders siblings by `readdir` (filesystem-dependent), mirage sorts them; and an empty directory is invisible to mirage because no leaf points at it. Sizes are bytes, not GNU's 1 KiB blocks, since an object store has no block size. `--max-depth` prunes only what is printed, never the walk, because every printed total still covers the whole subtree. Verify changes with the differential harness against `debian:stable-slim`: paths, exit codes and stderr must match GNU exactly.
|
|
- **`du` usage errors exit 1, not 2.** `du` is absent from `USAGE_EXIT`, which is correct: GNU du exits 1 for `-s` with `-a` ("cannot both summarize and show all entries"), `-s` with `--max-depth` ("warning: summarizing conflicts with --max-depth=N"), and a bad depth ("invalid maximum depth 'x'"). All three are raised by `parse_flags` / `parseDuFlags` *before* any I/O, mirroring GNU's option-parse order: the depth is parsed as the option is read, so a bad depth wins over the conflict checks. An unreadable operand is not a usage error: GNU names it (`du: cannot access 'x': No such file or directory`), prints every other operand, and exits 1, and still prints `0 total` under `-c` when every operand failed. With no operand at all, du measures the working directory; it never says "missing operand".
|
|
- **`du` walks are bounded.** Backends with no native du op are walked one `readdir` at a time, which on an API tree is one request per directory. `CommandIO.max_du_entries` caps that walk; when it trips, `du` prints what it accounted for, writes a notice to stderr and exits 1 (GNU's behavior for a tree it could not fully read), rather than hanging or silently reporting a wrong number. Slack sets a low cap (`DU_MAX_ENTRIES`) because it exposes a directory per conversation per day against a ~50/minute rate limit.
|
|
- **Async-native by default.** I/O uses `aiofiles` / `redis.asyncio` / `aioboto3`, and command pipelines are async generators.
|
|
- **Python unit tests mirror src 1:1 where reasonable.** Try to have a matching `tests/<path>/test_a.py` for each source file `mirage/<path>/a.py`. `__init__.py`, pure type-stub modules, and trivial re-exports are fine to skip; modules with real logic should have one.
|
|
- **Do not add `__init__.py` files under `tests/`.** Tests are namespace packages and pytest discovers them without `__init__.py`. Don't create one when adding a new test directory.
|
|
- **Monkeypatching a backend command module in tests:** the command imports its helpers by value (`from mirage.core.<backend>.read import read_bytes`), so to intercept them you must rebind the name inside the command module, not the core source module. But the command module is hard to reach: the backend package re-exports the command function in `__init__.py` (`from .cat import cat`), which shadows the submodule of the same name, so `import mirage.commands.builtin.<backend>.cat as mod`, `from ...<backend> import cat as mod`, and even pytest's string target `monkeypatch.setattr("mirage.commands.builtin.<backend>.cat.read_bytes", fake)` all resolve to the function, not the module (`AttributeError`). The command is also wrapped by `@command`, so `cat.__globals__` is the decorator's module. Reach the real command-module namespace through the unwrapped function and patch the dict: `monkeypatch.setitem(cat.__wrapped__.__globals__, "read_bytes", fake)`.
|
|
- Avoid add any comments or docstrings on the top of the file.
|
|
- **A nested function must capture the scope around it.** Nesting is for closures: the def reads a name from the enclosing function, or binds one through a parameter default (`def f(m, _n=n)`, the loop-variable idiom). Everything the architecture nests is that shape — op factories (`ops/generic/factory.py`), provision builders, the read-through cache, and every decorator's wrapper — and those stay. What is banned is the nesting that buys nothing: a helper written inside a function although it reads only its own arguments, which rebuilds a function object per call and hides a testable unit where no test can reach it. Put that one at module level. Enforced for Python by `tests/test_nested_functions_are_closures.py`.
|
|
- Add type to Args for docstring.
|
|
- Do not add comment after each line of code in the format of "# 10MB - trigger segmentation for files larger than this". The most you can add is "# 10MB".
|
|
- For all imports you need to put to the top of the file. Don't have imports within each function.
|
|
- **No circular imports.** If putting an import at the top would cause a cycle, that's a sign the dependency direction is wrong — fix the design (dependency injection, splitting modules, moving the shared piece to a leaf), don't paper over it with function-local lazy imports. Verify by checking that running `cd python && uv run python -c "import <every changed module>"` succeeds without ImportError.
|
|
- **Never silently swallow exceptions.** `try: ... except: pass` (or `except SomeError: pass`) hides real bugs. If you genuinely need to ignore an error, log it (`logger.debug(...)`) or document loudly why it's safe. Default behavior should be: let the exception propagate. Especially never silently swallow `RuntimeError` — it usually signals something deeper (event loop in wrong state, recursion limit, etc.) that you need to actually fix.
|
|
- **Never call `asyncio.run()` inside a sync function that might be invoked under an outer event loop.** It will raise `RuntimeError: asyncio.run() cannot be called from a running event loop`. If you need async behavior from a sync API, either: (a) make the calling function `async`, (b) operate on the underlying sync state directly (e.g. write to a dict instead of calling an async setter), or (c) use a sync alternative of the same library (e.g. `redis.Redis` instead of `redis.asyncio.Redis`). Do NOT wrap with `try/except RuntimeError: pass` — that masks the bug AND leaks the unawaited coroutine.
|
|
- Please don't change any file name unless I ask you to do so.
|
|
- Don't add too many printings or comments in the code.
|
|
- Don't add README.md unless I ask you to do so.
|
|
- Use uv add to install new dependencies.
|
|
- **Command handlers take `(accessor, paths, texts, opts)` — the same four positionals in both languages.** The dispatcher (`Mount.execute_cmd` / `Mount.executeCmd`) constructs one `CommandOpts` per invocation carrying stdin, the flag bag, cwd, the mount prefix, the index, and every namespace fact; the provision path builds the same bag with `command`/`spec` set (`ProvisionFn` has the same four-positional shape). Handlers never declare a flag or an injected fact as a parameter — `tests/commands/test_no_dead_flag_params.py` pins the exact signature. When a wrapper genuinely needs a flag value itself (e.g. a search push-down), read it through a spec-bound `FlagView` (`fl = FlagView(opts.flags, spec=SPECS["grep"])` then `fl.as_bool("F")`, `fl.as_int("m")`, `fl.as_str("type")`, `fl.as_list("e")`) or a shared domain accessor like `pattern_arg` — never raw `flags.get(...)` / isinstance chains, and never a raw `kwargs`/`_extra` read either (`tests/commands/test_no_raw_flag_reads.py`). To override a flag before delegating, pass `dataclasses.replace(opts, flags=bag)` down, never a hand-built `CommandOpts`. **A PATH-typed flag reaches a python command as a `PathSpec`, not a string** — the executor promotes it (`workspace/executor/command/flags.py`), so read it with `fl.as_paths(name)`; `as_str` reads it as absent and the operand is silently never used. TypeScript's bag carries the resolved virtual-path string instead, so its twin is `fl.asStr(name)`.
|
|
- **Generic commands own flag interpretation.** Backend wrappers are wiring only (glob resolution, backend I/O injection, pass-through of `texts` and `opts`); all flag semantics live in the generic command for that family, mirroring the TS generics. Adding or changing a flag should touch the spec and the generic, not N wrappers. Every literal `FlagView` query name must be a dest of a spec bound in the same module — `tests/commands/test_flag_query_names.py` and `commands/flag_query_names.test.ts` fail on a typo'd spelling without needing the code path to run.
|
|
- **Generics parse flags once into a frozen struct.** Each generic defines a `@dataclass(frozen=True, slots=True)` flag struct plus a module-level `parse_flags(fl, ...)` (mirroring the TS `parseFlags` struct); the function body reads only struct attributes, never string keys. Construct the FlagView with the command's spec (`FlagView(flags, spec=SPECS["grep"])`) so a typo in a flag name raises KeyError instead of silently reading as False/None.
|
|
- **Never annotate anything as `object`** — not a parameter, not a return, not a type argument (`dict[str, object]`, `Callable[..., object]`). `object` reads as "we did not decide": it accepts bytes where JSON was meant and a PathSpec where a flag value was meant, so every use site pays for it with an isinstance chain back to the set the author had in mind. Name the real type instead:
|
|
- a parsed command-line flag is `FlagValue` (`mirage.commands.spec.types`) — `**flags: FlagValue`, `Mapping[str, FlagValue]`, `FlagValue | None` for a single raw read. The TypeScript side has always called it `FlagValue` too (`commands/spec/types.ts`); keep the two spellings identical.
|
|
- a decoded JSON payload or an API field is `JsonValue` (`mirage.types`). It is recursive, so it is spelled as a forward-reference string until the floor is 3.12 — a union with it must be quoted: `Awaitable["JsonValue | X"]`.
|
|
- a path is `str | PathSpec`, a backend handle is `accessor: Accessor` (`mirage.accessor.base`), an index is `index: IndexCacheStore | None` (`mirage.cache.index`), a stat function is `StatFn` (`mirage.types`). Ignored variadics are still typed (`*texts: str`).
|
|
- a sentinel is a one-member `Enum`, never `object()`; that keeps it distinguishable from the real values sharing the variable.
|
|
`tests/commands/test_no_object_annotations.py` enforces this and carries the only exemptions: four Python protocol methods (`__setattr__`, `__contains__`, `Mapping.pop`) whose signatures the language fixes, and one guard whose whole job is to catch a value the annotations already claim cannot arrive. Adding to that allowlist needs the same kind of reason.
|
|
- **Every session write goes through `SessionView.set`.** A deployment refuses a
|
|
name (`AWS_*`, a credential) with a `pre_session` rule, and a writer that
|
|
reaches `session.env` directly makes that rule advisory. `export` and a plain
|
|
assignment always cleared the gate; the expansion-time writers did not, so
|
|
`${X:=d}`, `$((X=5))`, `(( ))`, `printf -v` and `for ((X=0; ...))` each wrote
|
|
past every policy. The view is threaded into expansion explicitly rather than
|
|
read from ambient state, and defaults to `None` (correct outside a workspace,
|
|
where the env is the only state there is). Reads (`$X`) stay sync; only the
|
|
writers await. `tests/workspace/expand/test_session_gate.py` and
|
|
`integ/session/writers.json` hold the line in both languages.
|
|
- **The record client is a substrate, not a session detail.** Sessions, the
|
|
namespace node table and workspace metadata are three tables that persist the
|
|
same way, so the keyed-record clients live in `workspace/record/`
|
|
(`DiskRecordClient` with an `O_CREAT|O_EXCL` lockfile and `rename(2)`,
|
|
`S3RecordClient` with If-Match CAS) and import none of the three. They used to
|
|
live inside the session package, which made the other two import upward into
|
|
it; a layering test in each language fails if that returns. The two clients
|
|
deliberately do not merge: different concurrency contract, different blob
|
|
layout, different key shape.
|