# mooncram CLI behavior specification

[中文版本](CLI_SPEC_CN.md)

This document describes the CLI behavior of the `0.0.7` module.
It covers the command line, Markdown input format, process execution, matching,
reporting, and updates. The English and Chinese versions have the same section
structure and should be maintained together.

Examples containing `mooncram` fences are enclosed in larger Markdown example
fences so that this specification does not itself introduce executable cases.
Paths such as `./hello.mbtx` in those examples are illustrative.

## 1. Invocation and command-line parsing

```text
mooncram [global-options] check [paths...] [global-options]
mooncram [global-options] test [paths...] [global-options]
mooncram [global-options] update [paths...] [global-options] [--dry-run]
mooncram (-h | --help)
mooncram (-V | --version)
mooncram help [check|test|update]
mooncram check (-h | --help)
mooncram test (-h | --help)
mooncram update (-h | --help)
```

From the repository checkout, the executable can be launched with:

```text
moon run . -- check README.md
moon run . -- test README.md
moon run . -- update examples --dry-run
```

The `--` in these examples separates `moon` arguments from mooncram
arguments. A later `--` can terminate mooncram's own option parsing.

| Command | Behavior |
| --- | --- |
| `check` | Validate documents and resolve local targets without building or executing cases. |
| `test` | Execute cases and compare routed output and exit status with the expectations. |
| `update` | Execute cases, then update and normalize their expectations from observed results, including passing cases. |
| `update --dry-run` | Execute cases and print proposed document changes without writing them. |
| `help`, `-h`, `--help` | Print the relevant help to stdout and exit with `0`, without discovering or executing cases. |
| `-V`, `--version` | At the top level, print the executable's version string followed by a newline to stdout and exit with `0`. |

A subcommand is required unless a help or version request is handled. An empty
command line is an argument error, not an implicit `test`. Version flags are
not available after `check`, `test`, or `update`; for example,
`mooncram test --version` is an argument error.

### 1.1 Options and defaults

| Option | Scope | Default | Accepted values and effect |
| --- | --- | --- | --- |
| `--target VALUE` | Global, `test`/`update` only | `wasm` | Exactly `wasm` or `native`; default backend for local package cases. |
| `--timeout-ms N` | Global, `test`/`update` only | `60000` | Positive signed 32-bit integer in milliseconds; one deadline for each case's build and execution. |
| `--color VALUE` | Global | `auto` | Exactly `auto`, `always`, or `never`; controls mooncram's report coloring. |
| `--dry-run` | `update` only | Off | Print proposed document diffs instead of replacing documents. Takes no value. |

Global options can appear before the subcommand, before paths, between paths,
or after paths. Both `--target native` and `--target=native` forms are accepted;
the other value options also accept `--name=value`. These options have no short
aliases. Each global value option may be supplied only once, including across
both sides of the subcommand; repeated occurrences are errors rather than
overrides. Repeating `--dry-run` keeps it enabled.

`--dry-run` must occur after `update`. It is invalid at the top level and for
`check` or `test`. `check` also rejects explicit `--target` and `--timeout-ms`,
even when they equal the defaults or appear before the subcommand. All three
commands accept `--color`. Unknown commands/options, missing option values,
and invalid values produce exit status `2`.

For `--timeout-ms`, only ASCII decimal digits are accepted, with value in
`1..2147483647`. Leading zeroes are allowed. Signs, spaces, fractions, zero,
and overflow are rejected. Validation after argument parsing checks target,
then timeout, then color.

`paths` accepts zero or more values. With no paths, it becomes `.`. After the
subcommand, `--` makes all remaining arguments paths, including names starting
with `-`:

```text
mooncram --target native test docs README.md --color never
mooncram update docs --timeout-ms=120000 --dry-run
mooncram test -- -example.md
```

Mooncram does not expand path globs itself. Expansion by the shell launching
mooncram happens before the CLI receives its arguments. A fence can declare its
module; target and timeout are controlled by the CLI options.

### 1.2 Check without execution

`check` shares file discovery, Markdown parsing, command and expectation
validation, and document-level module/directory validation with `test` and
`update`. Configuration-only fences are validated too. It then resolves each
pipeline segment against the current filesystem, checking script existence,
full executable package names, module boundaries, and exact remote coordinates.
It reads local files and configuration without building,
downloading dependencies, starting subprocesses, or modifying Markdown.

A document parse or declaration-validation failure counts as one error and
prevents checking its cases. A target-resolution failure counts as one error for
that case, skips its remaining segments, and continues with later cases and files.
The summary is `N cases, E errors`; a run with no cases and no other errors uses
the no-cases error described in section 2.

Command parsing does not query the environment. Arguments and assignment values
remain literal after quoting and escaping. Check results depend on the current
filesystem. A target generated by an earlier case
may be reported missing by `check`; `test` and `update` resolve targets at
execution time and support this pattern. `check` does not verify source
compilation, backend build support, remote-coordinate existence, or program
assertions. It has no per-case execution timeout.

## 2. File discovery and ordering

Discovery completes before any document is read or any case is executed.

1. Resolve each supplied path against mooncram's invocation directory.
2. Recurse into actual directories. During enumeration, skip entries beginning
   with `.`, entries hidden according to the filesystem API, and entries named
   exactly `_build` or `target`. This includes Windows hidden attributes.
3. Select regular files whose encountered path ends in the case-sensitive
   suffix `.md`. A directly supplied regular file with another suffix is ignored.
4. For a file symlink with an encountered `.md` suffix, include its target if
   it is a regular file. Do not traverse directory symlinks.
5. Resolve selected files to canonical absolute paths, deduplicate those paths,
   and sort them lexicographically.

The skip rules apply to directory entries during traversal. An explicitly
supplied hidden file, hidden directory, `_build`, or `target` is not rejected
just because of its name; its contents still follow the normal discovery rules.
Deduplication uses canonical paths, not file contents or inode identity.
The suffix test is on the encountered path, so an `alias.md` symlink can select
a regular target whose own name has another suffix.

A missing input or a filesystem error during discovery aborts the entire run
with status `2`; already discovered files are not executed. Symlinks ending in
`.md` whose targets cannot be inspected can also produce discovery errors.

Documents run in sorted canonical-path order, independently of argument order.
Cases run serially in document order. Segments of the same pipeline run
concurrently. Filesystem side effects from earlier cases remain visible to later
cases. Each process starts in the block's working
directory (the canonical document's parent by default) and inherits mooncram's
environment, overridden by the segment's temporary assignments.

A document without cases is allowed when other documents contain cases. If
there are no attempted cases (checked or executed) and no recorded errors, the
CLI reports `ERROR no mooncram cases found` and exits with `2` without a summary.

## 3. Markdown recognition and case boundaries

Documents are read as UTF-8; decoding failures are document errors. Markdown
is parsed structurally with `cmark`, with source locations and layout retained.
Fenced code blocks with a backtick opening fence and the exact, case-sensitive
language `mooncram` contain tests. The info string accepts
optional whitespace and a single module name, such as `mooncram user/foo`.
The declaration is scoped to that fence; ordinary fences have no module name.
Multiple words or invalid module names are document parsing errors at the
opening line. Module/package names use nonempty slash-separated
components with ASCII letters, digits, `_`, `-`, or `.`; `.` and `..` components
are prohibited. Valid longer backtick fences work as well as triple backticks.

Other languages, indented code blocks, tilde fences, and apparent fences inside
larger code examples are silently ignored. Cases inside block quotes and lists
are supported; the Markdown container prefix is not part of the command or
output. Normal Markdown indentation rules apply before parsing lines.

An eligible fence must have a closing fence and at least one command or working
directory declaration. An unclosed or empty eligible fence is a document parsing
error. Ignored fences do not
produce mooncram parsing errors.

````markdown
```mooncram user/foo
$ moonx ./hello.mbtx "Moon Bit"
Hello, Moon Bit!

$ moonx user/foo/cmd/main --version
my-cli * (glob)
```
````

Within the parsed code block:

- A line beginning exactly with `#cwd(` starts a working directory declaration
  and ends any preceding expectation span. It must be the first nonempty line.
- A line beginning exactly with `$ ` starts a command and a case. A line consisting
  only of `$` is also treated as a command start, then rejected as an empty command.
- Before the first command, only completely empty lines and a valid directory
  declaration are accepted. Any other line is an error. `$` followed only by a
  tab is not the `$ ` command prefix.
- Every following line up to the next command, directory declaration or closing
  fence is an expectation line, except completely empty lines at the end of that span.
- Trailing empty lines are separators and are excluded from expectations.
  Empty lines inside the remaining expectation span are real output lines;
  whitespace-only lines are not empty separators.
- Commands occupy one source line. There is no multiline command continuation,
  comment syntax, or separate setup/teardown syntax inside a test block.

A line of output that would look like a command or begin with `#cwd(` must use a
JSON string with `(escaped)`.

The whole document, including all commands and expectations, is parsed before
executing any case in that document. A parsing error prevents
every case in that document from running, even cases before the error. Later
documents are still processed. Reported source line numbers are one-based
positions in the original Markdown document.

### 3.1 Remote package coordinates

Use `$ moonx user/module[/package]@version <args...>` directly in a test fence.
Coordinates require at least two nonempty path components, following the
module/package name rules above. Versions must be exact SemVer `MAJOR.MINOR.PATCH`,
optionally followed by `-prerelease` and `+build`. Core numbers and numeric
prerelease identifiers have no leading zeros; build identifiers may have them.
Identifiers contain ASCII letters, digits and hyphens, separated by dots, with
no empty identifiers. `latest`, ranges, wildcards and malformed versions are
rejected during target resolution. Unversioned targets are local and require a
matching module declaration. Remote coordinates are independent of fence module
declarations; `check` does not verify their registry existence.

````markdown
```mooncram
$ moonx moonbit-community/moongrep@0.3.5 --version
* (glob)
```
````

## 4. Command tokenization and executable selection

The text after the leading `$` is tokenized directly into an array of pipeline
segments. It is not passed to a shell. The command parser returns
`Array[PipelineSegment]`. Optional `case_insensitive : Bool = false` normalizes
temporary assignment names to ASCII uppercase when enabled; the document parser
enables it on Windows. Returned arguments and assignment values have quotes and
escapes processed, with variable-like text kept literal.
`PipelineSegment` has `argv : Array[String]` and `extra_env : Map[String, String]`,
with `Debug` and `Eq`. `argv` includes `moonx`, the target and program arguments.
`Case.pipeline` stores these segments. Segment `extra_env` contains temporary
assignments, which override inherited variables for that segment's build and run.
The original command text is retained for reports, update and dry-run.

| Syntax | Meaning |
| --- | --- |
| Unquoted, unescaped `\|` | Separate pipeline segments, even without surrounding spaces. |
| Unquoted space or tab | Separate arguments; consecutive separators are ignored. |
| `'text'` | Literal text until the next single quote; backslashes are literal. |
| `"text"` | Text until the next unescaped double quote. |
| Backslash outside single quotes | Quote exactly the next character, including inside double quotes. |
| `''` or `""` | An empty argument, which is preserved. |
| Adjacent quoted/unquoted fragments | Concatenated into the same argument; `a"b"c` becomes `abc`. |
| `${NAME}` or `$NAME` | Ordinary text, using the same splitting, quoting and escaping rules as other text. |

Backslash does not interpret C/JSON escape sequences: `\n` contributes `n`,
not a newline, unless the backslash is inside single quotes. A trailing
backslash or an unclosed quote is a parsing error. The underlying command parser
rejects NUL, CR and LF. Raw NUL characters in Markdown are first replaced by cmark
with `U+FFFD` (`�`), so they do not trigger the NUL check.

Empty pipeline segments, including leading/trailing `|` and `||`, are parsing
errors. Quoted or escaped `|` is an ordinary argument character. For example:

```text
$ moonx ./producer.mbtx|moonx user/foo/cmd/filter|moonx ./consumer.mbtx
```

Unquoted, unescaped `&`, `;`, `<`, `>`, backticks, `(` and `)` are rejected
wherever they occur in a word. Output redirection, including `2>&1` and
`1>/dev/null`, is unsupported in every pipeline segment. Quoting or escaping
operators passes them literally: `'2>&1'`, `2\>\&1` and `1\>/dev/null` are ordinary
arguments. There is no wildcard or tilde expansion or command substitution.
For example, `'$HOME'`, `*`, `~` and `'$(cmd)'` are literal arguments; unquoted
`$(cmd)` is rejected because of its parentheses. Errors retain the document
filename and command line number.

`#cwd(path)` declares one working directory for the current fence. The Markdown
block parser recognizes a whole logical line starting exactly with `#cwd(` and
ending with `)`. It takes all text between the opening prefix and the final `)`
as the nonempty path, without trimming, tokenization, escaping, variable expansion
or parenthesis matching. Spaces, backslashes, quotes, parentheses, Chinese text and
`${NAME}` remain literal. For example, `#cwd(../fixtures with spaces)` needs no
quotes; `#cwd("foo")` includes the double quotes in its path, and `#cwd(a)b(c))`
stores `a)b(c)`. Each block permits at most one declaration on its first nonempty
logical line; leading empty lines are allowed. Empty paths, missing final `)`,
duplicates and declarations after a test command are parsing errors with source
locations. A declaration accepts no expected output or exit code. It is not a case;
configuration-only fences are legal, with the usual no-cases rule for a whole run.

Output beginning with `#cwd(` must use a JSON string with `(escaped)`, even when
it would be a malformed declaration. The updater automatically escapes such
output so generated documents parse again and a second update is unchanged.
Updates preserve the declaration's original text and Markdown layout.

The parser does not inspect the filesystem. `WorkingDirectoryDeclaration`
contains `directory : String` and its one-based `line : Int`. `Case.working_directory`
is optional; `Document.working_directory_declarations` retains every declaration,
including those in fences without cases. Configuration resets at each fence.

After parsing and module validation, before any case or build in that document,
`validate_document_working_directories` checks every declaration. Relative paths
resolve against the canonical Markdown file's parent, never another block's cwd;
absolute paths are accepted. Targets must already be directories, including
symlink targets, and are resolved to real absolute paths. Earlier cases cannot
create these directories. The returned `Map[Int, String]` maps declaration lines
to validated directories. The CLI selects each case's directory from that map,
using the document directory when no declaration applies.

Validation failures are one document error, located at the directive and showing
the resolved path: zero cases execute and no update occurs in that document,
while later documents continue. A directory removed after validation follows the
normal execution-error rules. Local modules resolve from startup cwd and package
builds use `moon -C <module-root>`; their subprocess cwd is the configured block
directory.

Environment variable expansion is not supported. `${NAME}`, `$NAME`, `${}`,
`${A-B}` and `${UNCLOSED` are ordinary text and do not trigger environment lookup
or reference validation. Normal splitting, quoting and escaping still apply:
`${A B}` is two arguments, while `"${A B}"` is one literal argument. Assignment
values follow the same rules. Expected output is literal too. The whole document
must parse before any case runs.

`$ NAME=value OTHER="${NAME}" moonx ./program.mbtx` sets temporary variables for one
pipeline segment and its build. Only consecutive assignments before the executable
are recognized, with an unquoted, unescaped name and `=`; names must match
`[A-Za-z_][A-Za-z0-9_]*`. Values follow the command quoting
and escaping rules, including empty values and additional `=`.
Assignments after the executable are ordinary arguments.
Words with invalid variable names, such as `9A=x` and `A-B=x`, are not recognized
as temporary assignments. They are ordinary words; before `moonx` they cause
a missing-moonx parsing error, and after the target they remain program arguments.

Temporary values override the parent environment, including with an empty string.
Values and arguments remain literal: `A=new B="${A}" moonx ./program.mbtx "${B}"` sets `B` to `${A}` and
passes `${B}` as its argument. Repeated names keep the last value. Each pipeline
segment has its own temporary environment, and later commands inherit the parent environment.
Windows names are normalized to ASCII uppercase and matched case-insensitively;
Linux and macOS names remain case-sensitive. Assignments without a command
are parsing errors.

After temporary assignments, every executable segment must begin with `moonx`
and a nonempty target. Missing prefixes, bare targets and empty targets are
parsing errors. No moonx options are accepted before the target. Target paths,
working directories and file arguments keep variable-like text literal.
An `export` executable is rejected with `command must start with moonx: export`.

A target ending in `.mbtx` is a local script, relative to the block's working
directory or absolute. This includes script filenames containing `@`.
Otherwise a target containing `@` must be a valid remote coordinate with exact
SemVer, even if its package name matches the declared local module.

Before processing any case, all module declarations must exactly match `name`
in the `moon.mod` directly in mooncram's startup cwd, including configuration-only
fences. Missing/invalid configuration or mismatches are document errors at the
opening fence. There is no parent-directory search. `Case.module_name` stores the
optional name; `Document.module_declarations` stores names and opening lines.

Unversioned package targets require a module-declared fence and the full package
name. For module `user/foo`, `user/foo` selects the root package and
`user/foo/cmd/boo` selects that subpackage. `foo`, `cmd/boo`, other module names
and paths such as `./foo` are rejected. Missing, empty or null `source` means
the module root; otherwise the source root must remain inside the module.
Full names are mapped relative to this source root. Packages must declare
`pkgtype(kind: "executable")` or `options("is-main": true)`. Compilation and
backend errors propagate. Traversal, empty path components, nested `moon.mod`
boundaries and symlink escapes are prohibited. `moon.pkg` is parsed with
`moonbitlang/moon_config@0.4.2`; invalid configurations are errors.

Arbitrary binaries, shell commands and `.mbt` files are not targets, and user
targets are not searched through `PATH`. Every argument after the target,
including `--`, `--version` and `--target`, is forwarded unchanged to the program.
`Case.command` retains the original source command.

## 5. Build, execution, capture, and timeout

Mooncram uses external MoonBit tools. `moon` must be available to build cases;
scripts and remote packages also require `moonx`, Wasm packages require `moonrun`, and native
package builds require the corresponding native toolchain. The backend used
to run mooncram itself is separate from the backend selected for a test case.

The following argument vectors use resolved absolute paths. Angle-bracket
items are placeholders, not shell syntax:

| Case kind | Build preflight | Program invocation |
| --- | --- | --- |
| `.mbtx` script | `moon run --build-only --target wasm <script>` | `moonx <script> -- <args...>` |
| Wasm package | `moon -C <module-root> run --build-only --target wasm <package>` | `moonrun <artifact> -- <args...>` |
| Native package | `moon -C <module-root> run --build-only --target native <package>` | `<artifact> <args...>` |
| Remote package | None; moonx fetches/builds it | `moonx --target wasm <coordinate> -- <args...>` |

Scripts always use Wasm, including when the CLI specifies `native`.
Mooncram requests a build for every local segment of every case, in segment order.
All local segments must pass preflight before any tested program starts; build
reuse is performed by the underlying tools. Script preflight checks compilation
before `moonx` runs so that preflight compiler failures cannot be accepted as
expected program exits.

A successful build must return stdout containing JSON with an `artifacts_path`
array containing exactly one string. Additional object fields are allowed.
Relative artifact paths are resolved against that build's working directory.
The script preflight validates this response too, although `moonx` receives the script path.
Nonzero build status, invalid JSON, or a missing/invalid artifact array is an
execution error. Build stdout/stderr are included in build-error diagnostics;
successful build output is not part of the program expectation.

Pipeline segments run concurrently. OS pipes pass intermediate stdout to the
next stdin as raw bytes, without decoding or newline normalization. Only the
final stdout is captured for stdout assertions. Every segment's stderr is
captured concurrently and concatenated in pipeline order without separator
bytes for diagnostics and `@STDERR` assertions. Unmarked cases do not match stderr.
All captured streams must decode successfully. Pipefail returns the rightmost
nonzero exit status in pipeline order, or `0` if all segments succeed; completion
order does not affect the result. Ordinary nonzero statuses remain assertable
and do not stop later cases. Reserved remote statuses follow the rules below.
There is no configurable output-size limit or live relay of program output.

Builds and every tested pipeline segment inherit mooncram's environment and
override names using that segment's temporary assignments. The same environment
is used for its build and execution, including script preflight and remote moonx
tools. Their working directory is the
validated block directory, or the canonical document's parent by default.
Build processes and the first pipeline segment receive immediate EOF on stdin rather than
mooncram's interactive input. Later segments receive the preceding stdout.

Captured output must be valid UTF-8. Invalid UTF-8 in either captured stream
is an execution error. Intermediate stdout bytes remain undecoded. Every CRLF pair is normalized to LF before
comparison and reporting. Standalone CR, NUL, ANSI escapes, trailing spaces, blank lines,
and the presence or absence of a final newline otherwise remain significant.

One deadline wraps path inspection, build, remote fetching, execution, and output capture for
each case. It is not a fresh timeout per subprocess or a timeout for the whole
run. On expiry the case reports
`timed out after N ms (including build)` as an execution error. Directly managed
processes use hard cancellation. A startup or capture error also cancels every
started segment, and all unconsumed pipe handles are closed on exit. The
implementation does not promise cleanup of detached descendants or rollback of
their effects. Discovery, document parsing, declaration validation, reporting,
and document replacement are outside this per-case deadline.

Cases execute code with the invoking user's permissions, in the configured
working directory. Mooncram provides no sandbox or temporary workspace
isolation. This also applies to `test` and `update --dry-run`.

Only remote commands reserve statuses `255` and `-1` as execution errors,
even if the remote program itself returns them. Statuses are monitored
concurrently for all segments. Any remote segment returning either reserved
status raises an error naming the coordinate, one-based pipeline segment
and status; remaining processes are immediately cancelled and reaped. A final
success cannot hide an earlier reserved status, and waiting on a slow upstream
must not delay detection of a later one. Other statuses follow the pipefail
rule above. Reserved statuses, startup/capture
failures and timeouts cancel all document updates and dry-run document diffs.

mooncram adds no remote dependency installation or cache mechanism. moonx owns
fetching/building/caching, which begins after its process starts. Other segments
may run and produce effects before remote fetching fails; no pre-fetch guarantee
is provided. Remote fetching shares the case deadline with local builds
and execution. `test`, `update` and `update --dry-run` use the same flow.

## 6. Expectations, line matching, and exit status

For unmarked cases, a case passes only when all of the following match:

1. Program exit status.
2. Number of final stdout lines.
3. Every corresponding output line's matcher.
4. Whether the final output line lacks a newline.

The default exit status is `0`. After removing trailing empty separators, a
final line exactly shaped as `[N]` supplies the expected signed 32-bit status.
`N` is ASCII digits with an optional leading `-`; signs such as `+`, internal
spaces, and other contents do not make a status marker. Leading zeroes are
accepted. A syntactically numeric marker outside the integer range is a parse
error. A status-looking line earlier in the expectation is ordinary output.
Negative statuses represent signal termination as reported by the process
library. A nonzero program exit is a normal matchable result, except for remote `255`
or `-1`; a build failure
or timeout is not.

No expectation lines require empty selected output. Empty output, one newline,
and a nonempty final line without a newline are distinct:

| Actual selected output, in JSON notation | Expectation text |
| --- | --- |
| `""` | No output lines. |
| `"\n"` | One `"" (escaped)` line. |
| `"hello\n"` | `hello` |
| `"hello"` | `hello (no-eol)` |
| `"hello\n\n"` | `hello` followed by `"" (escaped)`. |

### 6.1 Output annotations

Annotations are case-sensitive suffixes, including the separating ASCII
space. Trailing spaces after an annotation stop it from being a suffix.

| Suffix | Semantics |
| --- | --- |
| None | Exact line equality, including whitespace. |
| ` (escaped)` | Parse the preceding text as a complete JSON string, then use exact equality. |
| ` (glob)` | Match the entire line with the glob syntax below. |
| ` (regex)` | Match the entire line with the installed standard-library `Regex`. |
| ` (no-eol)` | Require no terminating LF on the last output line; follows the matcher suffix, if any. |

Only one matcher suffix is interpreted, after removing a final `(no-eol)`.
For example, `"literal (glob)" (escaped)` matches the literal text `literal (glob)`.
Unknown parenthesized suffixes, including `(equal)`, are ordinary text and match
exactly. For example, `hello (equal)`, `hello (foo)`, and `hello (glob) (foo)`
each match their entire literal line.
`hello (foo) (no-eol)` matches `hello (foo)` without a terminating LF.
Use a JSON string with `(escaped)` to match a recognized annotation suffix
literally.
There are no optional-line, repetition, or line-skipping annotations.

`(escaped)` supports control characters and syntax-looking output through JSON
escapes. Its decoded value must not contain LF; use separate expectation lines
for separate output lines. `(no-eol)` is allowed only on the final output line,
in that stream, optionally followed by the final case exit-status marker. An exact empty line with
`(no-eol)` is invalid: represent empty output by omitting output lines.

````markdown
```mooncram
$ moonx ./special.mbtx
"$ this is output" (escaped)
"[2]" (escaped)
"```" (escaped)
"\u001b[31mred\u001b[0m" (escaped)
"" (escaped)
final text (no-eol)
[3]
```
````

### 6.2 Glob and regex details

Globs operate on Unicode characters, not bytes. `*` matches zero or more
characters, `?` matches one character, and sets/ranges include `[abc]`, `[a-z]`,
`[!a-z]`, and `[^a-z]`. `!` or `^` negates a set only at its beginning. Backslash
quotes the next character, including inside sets. A leading `]` in a set can
be literal, and `-` can be literal when not forming a range; `[]-]` matches `]`
or `-`. Reversed ranges, unclosed/empty sets, and dangling escapes are errors.
There is no filesystem expansion or special directory-separator treatment.

Regex patterns are compiled as `^(?:PATTERN)$`, and the match's content must
equal the entire actual line. This also anchors alternatives such as
`foo|bar`. Supported regex syntax and compilation diagnostics come from the
installed MoonBit standard library. Invalid patterns are document parse errors.
Neither glob nor regex matching spans multiple output lines.

### 6.3 Paired stdout and stderr assertions

Use `@STDOUT` and `@STDERR` to assert both streams in one execution:

````markdown
```mooncram
$ moonx ./cli.mbtx
@STDOUT
result
@STDERR (empty)

$ moonx ./cli.mbtx --invalid
@STDOUT (ignore)
@STDERR
error: invalid argument
[2]
```
````

Both case-sensitive markers must appear exactly once, in either order. A missing
partner, duplicate marker, nonempty expectation before the first marker, or
unknown marker annotation is a parse error with a file and line number.
Marker forms are exactly `@STDOUT` / `@STDERR`, optionally followed by
` (ignore)` or ` (empty)`. To match a marker-looking output line literally, use
JSON escaping, for example `"@STDOUT" (escaped)`.

A plain section uses the usual exact, glob, regex and escaped line matchers;
`(no-eol)` applies independently to the last line of each stream. A plain section
with no output lines requires empty output. `(ignore)` skips content assertions;
`(empty)` requires exactly zero characters, so even one newline fails. Neither
annotated form accepts output lines. Trailing bare empty lines in each section
are separators; use `"" (escaped)` to assert an empty output line. The final
`[N]` belongs to the whole case, with default exit code `0`.

`@STDOUT` selects the final segment's stdout. `@STDERR` selects all
stderr, concatenated in pipeline order without added separators.
There is no comparison of timing between streams. Ignored streams are still
captured and checked for UTF-8, with the same deadlines and process cleanup.
Cases without stream markers assert only the final segment's stdout and the
pipeline exit status; stderr is available for diagnostics.

The two stream constraints and the case exit code must all match. Markers are
recognized only when the exact uppercase name is followed by end of line, a
space or a tab; any following text other than the exact supported annotation
is an error. `@stdout` and `@STDOUTish` are ordinary output. Bare empty lines
before the first marker are allowed separators. Bare empty lines within a
plain section remain output when followed by more output in that section.
An earlier `[N]` is output; only the final case line sets the exit code.

## 7. Reports and color

Passing cases produce no individual report. Expectation mismatches produce a report
on **stdout** in both `test` and `update`, including dry runs:

```text
FAIL <canonical-file>:<command-line>
  $ <original command text>
  exit: expected <expected-status>, actual <actual-status>
--- expected
+++ actual
<diff hunks>
```

Diffs use three lines of context. The actual side is rendered in expectation
syntax, including escaping, `(no-eol)`, nonzero status markers, and retained
matching patterns. It is not a raw dump of program output. If diagnostic stderr is
nonempty, the report appends `--- diagnostic stderr` and its normalized text,
ensuring a final newline. Successful cases do not print diagnostic stderr.

For paired cases, only failed content constraints produce diffs. Their headers
are `--- expected @STDOUT` / `+++ actual @STDOUT`, or the equivalent `@STDERR`
headers. Ignored streams produce no content diff. Exit status is shown once in
the case header. Stderr shown in its own diff is not repeated as diagnostic
stderr; ignored stderr remains available for diagnosis if another assertion
fails.

Errors are written to **stderr**:

| Error scope | Report form |
| --- | --- |
| Arguments or discovery | `ERROR <message>` |
| Read, parse, or update of a document | `ERROR <canonical-file>: <message>` |
| Target resolution during `check`, or execution of one case | `ERROR <canonical-file>:<command-line>: <message>`, followed by the indented command. |

Argument-parser errors include contextual help. A document parse message
already contains its file and source line; the outer document error handler
currently adds the file prefix again. Dependency/OS error wording and build
diagnostics are passed through and can vary with the environment.

Color selection is evaluated once after discovery:

- `always` enables mooncram's colors, even with `NO_COLOR` or `TERM=dumb`.
- `never` disables mooncram's colors.
- `auto` disables them if `NO_COLOR` exists, even with an empty value, or if
  `TERM` equals `dumb`. Otherwise it enables colors only when inspecting
  `/dev/stdout` reports a character device; inspection errors disable colors.

`FAIL` headings and deleted diff lines are red; added diff lines are green.
Summaries and `ERROR` prefixes are not colorized. The color option does not
strip ANSI bytes emitted by tested programs or control their own color policy.

## 8. Update rendering and preservation

`update` uses the same parser, execution, comparisons, and failure reports as
`test`. Every successfully executed case is rendered, including passing cases.
A document is written or shown as a dry-run diff only when its bytes change. Repeated
updates leave the file unchanged and report `updated 0`.

Within each stream, alignment prefers the current expectation when it matches.
Otherwise it first searches later expectations for one matching the current
actual line, then searches later actual lines for one matching the current
expectation. Deleted expectations disappear; inserted lines become exact
expectations. Matching glob/regex spellings are retained. Exact matches, including
redundant `(escaped)` spellings, are rendered afresh. `(no-eol)` always follows
the actual stream's final newline.

Ordinary text, including indentation, `$HOME`, `[INFO] ready`, `[literal]`, and
inline backticks, is written directly. JSON strings with `(escaped)` protect
control characters below U+0020 or U+007F, command forms `$` / `$ `, reserved
stream markers, and the known annotation suffixes ` (escaped)`, ` (glob)`,
` (regex)`, and ` (no-eol)`. Numeric bracket lines, including negative and
out-of-range numbers, are escaped only when they would occupy the final case
exit-status position. Thus `[2] (no-eol)` can become `"[2]" (escaped)` when a
newline is added, but stays literal if followed by another output line, stream
marker, or nonzero exit status. Zero exit statuses are omitted even when
originally explicit; all nonzero statuses use canonical `[N]` spelling.

Internal empty output lines stay bare. Only the final trailing empty line is
written as `"" (escaped)` when needed to prevent trimming: at the end of each
stream section, or at the end of stdout-only output without a following nonzero
status. Earlier empty lines in the same trailing run remain bare. Empty output
produces no output lines. An internal blank also uses `"" (escaped)` if its bare
LF would merge with a preceding CR; this preserves both source newline spellings.

All cases in a fence share the shortest safe opening/closing backtick length,
with a minimum of three; overly long fences shrink too. Only lines that could
be parsed as closing fences require longer markers. The calculation accounts
for source indentation before the parser strips optional fence indentation.
Inline backticks, backticks followed by non-whitespace, and sufficiently
indented backtick lines do not lengthen the fence.

Aligned rows retain their source container prefix and LF/CRLF/CR newline spelling,
including mixed newlines. New rows use the command's layout. Underindented blank
rows receive a complete container prefix when replaced by nonempty text; leading
output spaces receive enough optional fence indentation to survive parsing.
Unchanged rows retain their original bytes. Commands, surrounding prose,
separator lines, and text outside edited spans stay intact, including a missing
final document newline. Only fence character spans and expectation spans change.

Paired cases retain marker order, leading and section separator blanks,
`(ignore)`, and satisfied `(empty)` constraints. A violated `(empty)` becomes a
plain marker with rendered output. Alignment runs independently within each
stream; new lines never borrow another stream's source layout.

Before writing or showing a diff, mooncram re-parses the entire generated document
and verifies case count, commands, configuration, and each captured result's
expectation match. Failure cancels the entire document update. Each changed case
counts once, including pure normalization; changing shared fence markers counts
every case in that fence.

In dry-run mode, each document with eligible edits additionally prints:

```text
--- <canonical-file>
+++ <canonical-file> (updated)
<document diff hunks>
```

This is in addition to individual failure reports. Dry runs do not replace
documents or perform the final source-recheck/write sequence. They still build
and run every case, so program effects and build artifacts can be created.

## 9. Error isolation and safe replacement

After a case execution error, later cases in the same document still run.
However, any execution/build/timeout error suppresses **all** expectation edits
for that document. If any successful case was captured for update, stderr also
receives:

```text
Not updating <canonical-file>: execution errors in this document
```

The same suppression applies to proposed dry-run document diffs. Parsing
errors prevent execution altogether. Errors in one document do not prevent
other discovered documents from running and updating, and do not undo earlier
updates or program effects.

For an actual update with edits and no case execution errors, mooncram:

1. Creates an exclusive `.mooncram-<stamp>-<attempt>.tmp` sibling file, trying
   up to 100 names.
2. Writes the updated document, syncs the file, and closes it.
3. Rereads the destination and compares its content with the original source.
4. Refuses replacement if it changed, reporting
   `source changed during execution; refusing to update <path>`.
5. Otherwise renames the temporary file over the destination on the same
   filesystem. Temporary-file removal is attempted on exit, including errors.

Replacement is atomic per file; updates across documents are not a transaction.
The content check is not a filesystem lock, and there is a window between the
check and rename. File symlinks were resolved during discovery, so replacement
targets the canonical document. Original permissions, inode identity, hard-link
relationships, and extended metadata are not preserved. On Unix, the replacement
uses new-file permissions `0644`, subject to umask; the filesystem library
ignores this permission parameter on Windows.

## 10. Counters, summaries, and process exit status

After processing all discovered files, normal summaries go to stdout:

```text
<total> cases, <errors> errors
<total> cases, <failed> failed, <errors> errors
<total> cases, updated <updated>, <errors> errors
<total> cases, would update <updated>, <errors> errors
```

These formats correspond to `check`, `test`, `update`, and `update --dry-run`,
respectively. For example:

```text
3 cases, 0 errors
3 cases, 1 failed, 0 errors
3 cases, updated 1, 0 errors
3 cases, would update 1, 0 errors
```

| Counter | Meaning |
| --- | --- |
| `total` | Cases whose checking or execution was attempted, incremented before processing each case. Includes cases with target-resolution or execution errors; excludes configuration directives and cases in documents that failed parsing or module/directory declaration validation. |
| `failed` | Completed cases whose output and/or exit status mismatched. Execution errors are counted separately. Printed only for `test`. |
| `errors` | Caught per-case target-resolution or execution errors plus document read/parse/declaration-validation/update errors. A parse or declaration-validation failure counts as one document error. |
| `updated` | Changed cases in documents successfully written, or eligible for a dry-run document diff, including normalization and every case sharing changed fence markers. Each case counts once, not once per changed line. |

Documents whose updates are suppressed or whose write fails do not contribute
to `updated`. An update can print `FAIL` reports and still have `updated 0`
because a later execution error suppressed that document's changes.

| Exit status | Condition |
| --- | --- |
| `0` | Help/version succeeded; or `check` checked at least one case with no errors; or at least one case ran with no errors and all `test` expectations passed; or `update`/dry-run finished without errors, regardless of mismatches. |
| `1` | `test` has at least one mismatch and no errors. |
| `2` | Argument/discovery failure, any recorded document or case error, or no cases found. |

Errors take precedence over mismatches. A document error with zero attempted
cases still produces a summary such as `0 cases, 1 errors` for `check` or
`0 cases, 0 failed, 1 errors` for `test`; the special no-cases message applies
only when both total and errors are zero.
Argument/discovery failures, help/version, and that no-cases path have no normal
summary. These statuses belong to mooncram, independently of tested programs'
expected exit statuses.

## 11. Implementation and verification map

| Behavior | Implementation | Existing verification |
| --- | --- | --- |
| Entry point and arguments | [main.mbt](../main.mbt), [cli/cli.mbt](cli/cli.mbt), [cli/run.mbt](cli/run.mbt) | [cli/cli_wbtest.mbt](cli/cli_wbtest.mbt) |
| Scheduling, counters, error isolation | [cli/runner.mbt](cli/runner.mbt) | [e2etests/integration.mjs](../e2etests/integration.mjs) |
| Discovery and atomic replacement | [files/files.mbt](files/files.mbt), [files/path.mbt](files/path.mbt) | [files/files_test.mbt](files/files_test.mbt) |
| Markdown and commands | [markdown/markdown.mbt](markdown/markdown.mbt), [markdown/command/command.mbt](markdown/command/command.mbt) | [markdown/parser_wbtest.mbt](markdown/parser_wbtest.mbt) |
| Remote coordinates | [resolve/coordinate.mbt](resolve/coordinate.mbt), [resolve/document.mbt](resolve/document.mbt) | [resolve/coordinate_wbtest.mbt](resolve/coordinate_wbtest.mbt), [e2etests/integration/remote.spec.mjs](../e2etests/integration/remote.spec.mjs) |
| Document validation and target resolution | [resolve/document.mbt](resolve/document.mbt), [resolve/module.mbt](resolve/module.mbt), [resolve/working_directory.mbt](resolve/working_directory.mbt) | [resolve/document_wbtest.mbt](resolve/document_wbtest.mbt), [resolve/module_wbtest.mbt](resolve/module_wbtest.mbt), [resolve/working_directory_wbtest.mbt](resolve/working_directory_wbtest.mbt), [e2etests/integration/check.spec.mjs](../e2etests/integration/check.spec.mjs) |
| Execution and artifact parsing | [execute/execute.mbt](execute/execute.mbt), [execute/pipeline.mbt](execute/pipeline.mbt) | [execute/execute_wbtest.mbt](execute/execute_wbtest.mbt), [e2etests/integration.mjs](../e2etests/integration.mjs) |
| Matching and output rendering | [output/expectation.mbt](output/expectation.mbt), [output/glob.mbt](output/glob.mbt), [output/render.mbt](output/render.mbt) | [output/matcher_test.mbt](output/matcher_test.mbt), [update/update_test.mbt](update/update_test.mbt) |
| Reports and local edits | [report/report.mbt](report/report.mbt), [update/update.mbt](update/update.mbt) | [report/report_test.mbt](report/report_test.mbt), [update/update_test.mbt](update/update_test.mbt) |

The CLI also inherits help, option parsing, and regex details from the installed
MoonBit standard library, and process/filesystem details from its declared
dependencies. When those change, verify the observable behavior as well as the
project sources before updating this specification.

Existing project checks are:

```text
moon test
node e2etests/integration.mjs
moon info && moon fmt
```

The integration runner exercises both Wasm and native mooncram executables by
default, using real scripts and packages. `--target wasm` or `--target native`
selects one CLI backend; those integration-runner options are separate from
mooncram's case-target option.

Remote integration checks replace moonx with a locally built fixture executable,
so pinned remote tools are tested without registry/network dependencies. The
fixture validates the exact wasm invocation, reserved exits and cancellation.
