383 lines
22 KiB
Markdown
383 lines
22 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# Run all specs. `make` / `make spec` is the same thing with -v;
|
|
# `make spec_silent` is the bare form.
|
|
crystal spec
|
|
|
|
# Run a single spec file
|
|
crystal spec spec/cligen/flag_spec.cr
|
|
|
|
# Type-check without codegen — fastest way to validate macro expansion
|
|
crystal build src/cligen.cr --no-codegen
|
|
|
|
# End-to-end flag resolution matrix (default/env/CLI, 16 cases)
|
|
./utils/flag_matrix.sh
|
|
|
|
# API docs
|
|
make doc && make doc_show
|
|
```
|
|
|
|
Setting `DEBUG=1` in the environment turns on `{% debug %}` / `{% puts %}` macro tracing
|
|
in `app/generate.cr`, `command/argument.cr`, `command/help_template.cr`, and
|
|
`command_node.cr#help`. Very noisy, but it's the only way to see generated code.
|
|
|
|
## What This Is
|
|
|
|
`cligen` is a Crystal **shard (library)** — not a standalone application. Consumers
|
|
subclass `CliGen::Command`, declare flags with the `argument` macro and subcommands with
|
|
the `subcommand` macro, and the library builds the whole CLI tree at compile time. There
|
|
is **no runtime registration and no `OptionParser`** — `cligen` implements its own
|
|
argument scanner.
|
|
|
|
`lib/cligen` is a symlink back to the repo root so that `require "cligen"` resolves in
|
|
this project's own test programs.
|
|
|
|
## Architecture
|
|
|
|
Compile-time flow, in order:
|
|
|
|
1. `Command.inherited` installs a `macro finished` hook on each subclass, which calls
|
|
`validate_command_tree`, `define_command_initializer`, `generate_gather_handler`,
|
|
`generate_register_command`, `generate_gather_handler` (and `define_singleton_init`
|
|
when `@[CommandInfo(singleton_init: true)]`).
|
|
2. Each subclass therefore gets its own `self.register_command(array, parent:)` that
|
|
builds *its* `CommandNode(T)` + `Flag(T)` objects and recurses into its children.
|
|
3. `CliGen::App`'s `macro finished` (in `app/generate.cr`) selects the **root** commands —
|
|
those whose `@[CommandInfo]` has no `parent:` — and calls `register_command` on each.
|
|
4. At runtime `App.process` lazily calls `generate`, then walks `ARGV`.
|
|
|
|
Nesting is expressed by **annotation, not inheritance**: a subcommand subclasses
|
|
`CliGen::Command` directly and names its parent via `@[CommandInfo(parent: Other)]`.
|
|
Subclassing a `Command` instead would give it subclasses, which turns `T` into the
|
|
virtual type `T+` and makes `T.has_constant?`/`T.methods` fail with a compiler BUG.
|
|
|
|
### Entry point: `src/cligen.cr`
|
|
|
|
Requires everything and defines `CliGen::VERSION`, `CliGen::APPNAME`
|
|
(`File.basename(PROGRAM_NAME)`), and `override_help_template` (sets
|
|
`CliGen::HELP_OVERRIDE_TEMPLATE` to an absolute path, checked with `file_exists?`).
|
|
|
|
### Object model
|
|
|
|
The runtime tree is built from four object families. Two of them have a **non-generic
|
|
abstract base** so heterogeneous children can live in one array — this is load-bearing
|
|
and comes up constantly:
|
|
|
|
| Generic | Base | Why the base exists |
|
|
|---|---|---|
|
|
| `Flag(T)` | `BaseFlag` | `Array(BaseFlag)` holds flags of mixed `T` |
|
|
| `CommandNode(T)` | `BaseCommandNode` | `Array(BaseCommandNode)` holds the command tree |
|
|
|
|
- **`src/cligen/flag.cr`** — `Flag(T)`. Owns `@value`, `@default`, `@options`, `@validate`,
|
|
`@on_match`, `@format`. Its `process`, `coerce`, and `validate!` are giant compile-time
|
|
`{% if %}` chains over `T`. Supported `T`: `Bool`, `String`, `Int*` (signed and unsigned),
|
|
`Float*`, `Time`, `Array(Int*|Float*|String|Coercable)`, plus any type that
|
|
`extend`s `CliGen::Coercable` or `CliGen::Parsable`. Unsupported `T` is a `{% raise %}`.
|
|
- **`src/cligen/flag/base.cr`** — `BaseFlag`: `var`, `short`, `long`, `long_key`,
|
|
`env_var`, `description`, `delimiter`, `meta`. `@long_key` is `@long` split on `\s|=`,
|
|
so a declaration like `long: "--help TOPIC"` still keys off `--help`.
|
|
- **`src/cligen/flag/meta.cr`** — `FlagMeta` record (type/array/format/default/options),
|
|
stringified metadata used solely by the ECR help template.
|
|
- **`src/cligen/command_node/base.cr`** — `BaseCommandNode`: the tree walk, `find_match`,
|
|
`get(long:)`/`get(short:)`, `all_flags`, and the duplicate checks.
|
|
- **`src/cligen/command_node.cr`** — `CommandNode(T)`: `subcommands`, `help`, `check!`,
|
|
and the main `process(Array(Arg))` loop, all of which need `T`.
|
|
- **`src/cligen/app.cr`** — `App < CommandNode(Nil)`. Singleton (`@@instance`), root of
|
|
the tree, adds env-var collision detection and the error boundary.
|
|
|
|
### Argument scanning: `src/cligen/arg.cr`
|
|
|
|
`CliGen::Arg` wraps `(value, index)` and tracks a one-way `processed?` flag. Calling
|
|
`#processed` twice raises `ArgReprocessedError` — a deliberate fail-fast so double-consumption
|
|
bugs surface during development rather than silently eating an argument. The parser
|
|
never does index math; it filters with `args.reject(&.processed?)`.
|
|
|
|
`Arg` also holds the class-level predicates `flag?`, `int?`, `uint?`, `float?`, which
|
|
delegate to `CliGen::Regex`.
|
|
|
|
### The dispatch loop: `CommandNode(T)#process`
|
|
|
|
`find_match(token)` returns a `BaseCommandNode`, a `BaseFlag`, or a `MatchType` enum
|
|
member. `process` `case`s over that:
|
|
|
|
- **`BaseCommandNode`** — a child command matched. Hands the unprocessed args off to the
|
|
child and `exit 0`. The cast back to a concrete `CommandNode(T)` is done by a
|
|
macro-generated `case` over `CliGen::Command.subclasses`; falling through raises
|
|
`UnknownCommandNodeError`.
|
|
- **`BaseFlag`** — if `requires_arg?` (i.e. `T != Bool`), it is handed the run of following
|
|
args that either match nothing or are in the flag's `options`; otherwise `process` with no args.
|
|
- **`MatchType::SubCommand`** — records `matched_subcommand`; a second one raises.
|
|
- **`MatchType::Help`** — raises `HelpRequestedError` carrying the rendered help.
|
|
- **`MatchType::FlagWithArg`** — `--flag=value`, re-split and dispatched.
|
|
- **`MatchType::FlagMultipleShort`** — `-abc` bundles. Only the last flag in a bundle may
|
|
take an argument; otherwise `FlagBundleError`. If the second char isn't a known flag,
|
|
raises `FlagArgumentError` (inline short args like `-n5` are not supported).
|
|
- **`MatchType::NoMatch`** — raises `HelpRequestedError` with an "unknown token" preamble.
|
|
|
|
After the loop, if no child command took over: instantiate `T`, run its
|
|
`@[PreRunCommand]` methods, then dispatch to the matched `@[SubCommand]` method or `main`.
|
|
`App` itself (`T == Nil`) just prints help.
|
|
|
|
### Value resolution
|
|
|
|
`Flag(T)#value!` resolves in strict priority order — **CLI arg → env var → default →
|
|
raise `MissingRequiredFlagError`** — and then runs `validate!(v)` on whatever it got, so
|
|
env-var and default values are validated on exactly the same path as CLI input.
|
|
|
|
Two footguns here, both previously live bugs:
|
|
|
|
- `validate!(v : T? = nil)` must use `v = value! if v.nil?`, **not** `v ||= value!`.
|
|
With `Flag(Bool)` and `default: false`, `||=` treats `false` as absent and recurses
|
|
into `value!` forever.
|
|
- The `MissingRequiredFlagError` raise must stay **above** the `validate!(v)` call in
|
|
`value!`, or the same infinite recursion occurs when nothing resolved.
|
|
|
|
Env vars for command arguments are **namespaced** `<COMMAND>_<VAR>` (e.g. `GREET_LEVEL`,
|
|
not `LEVEL`) unless an explicit `env_var:` is given. Global flags are un-namespaced,
|
|
derived as `long.gsub(/--/,"").gsub(/-/,"_").upcase`. Both macros reject an explicit
|
|
`env_var:` containing `-`.
|
|
|
|
### Validation: `check!`
|
|
|
|
Run at the top of every `process`, so misconfiguration fails on first invocation:
|
|
|
|
- **Compile time** — `check_flag_vars` validates every declaration (both `argument` and
|
|
`add_global_flag` route through it); `validate_command_tree` rejects a missing
|
|
`@[CommandInfo]`, a self-parent, and `parent:` cycles.
|
|
- **Load time** — `add_global_flag` constructs the flag, then checks `GLOBAL_FLAGS` for a
|
|
`long_key`/`short` collision and `abort`s with `__FILE__:__LINE__` of the call site.
|
|
Runs during module init, so it is outside `handle_command_raises`.
|
|
- `Flag#check!` — validates `short` against `FLAG_SHORT` and `long_key` against
|
|
`FLAG_LONG`. Only invoked on `@flags`, never on `GLOBAL_FLAGS`, which is why the
|
|
built-in `--help`/`--verbose` don't trip their own checks.
|
|
- `CommandNode#check!` — duplicate shorts/longs across `@flags + GLOBAL_FLAGS`
|
|
(`DuplicateFlagError`), duplicate child command names (`DuplicateCommandError`), and
|
|
(when `T != Nil`) requires either subcommands or a `#main` (`MissingDispatchError`).
|
|
- `App#check!` — `super`, then env-var collisions across `all_flags.uniq + GLOBAL_FLAGS`.
|
|
`all_flags` recurses the whole tree. **The `.uniq` is load-bearing**: a parent's flag
|
|
object can appear in several nodes, and `uniq` collapses it via `Reference` identity
|
|
(`Flag` overrides neither `==` nor `hash`). Adding a custom `==` to `Flag` would
|
|
silently make this collapse *distinct* flags and stop catching real collisions.
|
|
|
|
### Errors: `src/cligen/exceptions.cr`
|
|
|
|
Everything derives from `CliGen::Error`, in three buckets plus a signal:
|
|
|
|
- **`InternalError`** — framework invariant broken; should never reach a user
|
|
(`ArgReprocessedError`, `RegexInvariantError`, `UnknownCommandNodeError`).
|
|
- **`ConfigurationError`** — the shard *consumer* wired something wrong
|
|
(`ReservedFlagError`, `DuplicateFlagError`, `DuplicateCommandError`,
|
|
`MissingDispatchError`, `FlagNotFoundError`, `FlagMissingArgumentError`,
|
|
`ParseableInvariantError`).
|
|
- **`RuntimeError`** — bad end-user input (`MissingRequiredFlagError`, `ValidationError`,
|
|
`FlagArgumentError`, `InvalidFlagValueError`, `InvalidOptionError`, `UnknownFlagError`,
|
|
`FlagBundleError`, `TimeParseError`).
|
|
- **`HelpRequestedError`** — not an error; carries rendered help, caught and `exit 0`.
|
|
|
|
`App.handle_command_raises` is the single error boundary: `RuntimeError` and
|
|
`ConfigurationError` `abort` with the message, `HelpRequestedError` prints and exits 0.
|
|
Each `rescue` does `Fiber.yield` first to let buffered `Log` output flush — a known-fragile
|
|
workaround, not a design.
|
|
|
|
**Never let a non-`CliGen` exception escape.** The whole point of the typed hierarchy is
|
|
that `handle_command_raises` catches everything; a stray stdlib exception (e.g.
|
|
`Time::Location::InvalidTimezoneOffsetError`) reaches the user as a stack trace. Wrap and
|
|
re-raise at the boundary — `Flag(Time)` does exactly this, translating
|
|
`TimeParseError` into `InvalidFlagValueError`.
|
|
|
|
### Time parsing: `src/cligen/timeparse.cr`
|
|
|
|
`CliGen::Timeparse.parse(raw) : Time` is a single `case` over four anchored matchers from
|
|
`CliGen::Regex`, each branching on whether `match["timezone"]?` is present (offset-aware
|
|
vs. `parse_local`). Supported: `%Y-%m-%d %H:%M:%S [%z]`, `%Y-%m-%d [%z]`, `@<epoch> [%z]`,
|
|
and one-or-more relative operations (`"+1 day -2 hours"`).
|
|
|
|
`timeparse/relative_operation.cr` — `RelativeOperation` struct. `get_operations` `scan`s
|
|
with `RELATIVE_OPERATION` and applies each in sequence. `apply` is macro-generated from
|
|
`OperationUnit.constants` using `case ... in` (exhaustive, so no `else` and the return
|
|
type collapses to `Time`).
|
|
|
|
### Regex: `src/cligen/regex.cr`
|
|
|
|
Two tiers, and the distinction matters:
|
|
|
|
- **Components** (`TIMEZONE`, `TIME`, `DATE`, `EPOCH`, `RELATIVE`) are **unanchored** and
|
|
exist only to be interpolated. Interpolating a Crystal `Regex` renders it as
|
|
`(?-imsx:...)`, so an anchor here would end up buried mid-pattern in the composite and
|
|
could never match.
|
|
- **Matchers** (`INPUT_DATE_FULL`, `INPUT_DATE_SIMPLE`, `INPUT_DATE_EPOCH`,
|
|
`INPUT_RELATIVE_OPERATIONS`) are fully `^...$` anchored. Match user input only against these.
|
|
|
|
`TIMEZONE`'s offset is deliberately bounded to `23:59` so it can't produce an offset
|
|
outside `Time::Location.fixed`'s ±24h limit.
|
|
|
|
Also here: `FLAG_REGEX`, `FLAG_WITH_ARG`, `FLAG_MULTIPLE_SHORT`, `INT`, `UINT`, `FLOAT`.
|
|
|
|
### Help output
|
|
|
|
`CommandNode#help` picks a template at compile time, in priority order: the command's own
|
|
`HELP_TEMPLATE` (set by the `help_template` macro) → `CliGen::HELP_OVERRIDE_TEMPLATE`
|
|
(set by `CliGen.override_help_template`) → the bundled default.
|
|
|
|
The default path is **hardcoded relative to the CWD**:
|
|
`ECR.render("lib/cligen/src/cligen/template/cmd_help.ecr")`. Anything that runs a cligen
|
|
binary must therefore run from a directory with a `lib/cligen` — which is why
|
|
`utils/flag_matrix.sh` `cd`s to the project root.
|
|
|
|
The template renders per-flag detail (type, env var, format, delimiter) only when
|
|
`verbose?`, which reads the `--verbose` global flag.
|
|
|
|
## Public macro API
|
|
|
|
Called inside a `CliGen::Command` subclass:
|
|
|
|
| Macro | File | Purpose |
|
|
|---|---|---|
|
|
| `argument(var : T, description, ...)` | `command/argument.cr` | Declares a flag-backed ivar. Options: `long`, `short`, `validation`, `on_match`, `def_setter`, `def_getter`, `options`, `delimiter`, `format`, `allow_no_verification`, `env_var` |
|
|
| `subcommand(func, description, examples) { ... }` | `command/subcommand.cr` | Defines a `@[SubCommand]` method from a block |
|
|
| `resolve_value(var, default: nil)` | `command/resolve_value.cr` | Reads a flag value from this command **or any ancestor**. Walks the `parent:` chain at compile time to find the declaring class, then walks `handler.parent?` at runtime to find its node. Returns the exact `T`, not a union. If the declaring ivar has no default, `default:` is **required** — the emitted `%default : T = ...` makes Crystal type-check it at the call site, and a `rescue MissingRequiredFlagError` uses it as the fallback |
|
|
| `help_template(filepath)` | `command/help_template.cr` | Per-command ECR override |
|
|
|
|
Module-level:
|
|
|
|
| Macro | File | Purpose |
|
|
|---|---|---|
|
|
| `CliGen.add_global_flag(T, long:, description:, ...)` | `global_flag/add_global_flag.cr` | Appends to `GLOBAL_FLAGS`; visible on every command |
|
|
| `CliGen.override_help_template(filepath)` | `cligen.cr` | Project-wide ECR override |
|
|
|
|
`global_flag.cr` dogfoods `add_global_flag` for the built-in `-v/--verbose` and
|
|
`-h/--help`, both passing `internal: true` to bypass the reserved-name check.
|
|
|
|
`CliGen::MAX_COMMAND_DEPTH` (default 32, user-overridable by defining it before the
|
|
requires) bounds every parent-chain walk — macros have no `while`, so the walks are
|
|
`{% for i in (1..MAX_COMMAND_DEPTH) %}` with a sentinel. It doubles as the cycle guard.
|
|
|
|
### Extension points
|
|
|
|
| Module | Contract | Used for |
|
|
|---|---|---|
|
|
| `CliGen::Coercable` | `self.coerce(arg : String)` | Building `T` from a single string (also used for env vars and array elements) |
|
|
| `CliGen::Parsable` | `self.parse_args(args : Array(CliGen::Arg))` | Multi-arg consumption; **must** mark at least one `Arg` as `processed` or `ParseableInvariantError` is raised |
|
|
|
|
Both are `extend`ed, not `include`d — hence the metaclass checks `T.class < CliGen::Parsable`
|
|
in `flag.cr`.
|
|
|
|
## Annotations
|
|
|
|
`src/cligen/annotations.cr` declares six; only four are wired:
|
|
|
|
| Annotation | Applied to | Status |
|
|
|---|---|---|
|
|
| `@[CommandInfo(description:, parent:, singleton_init:)]` | Command subclass | **Required.** `description` must be a `StringLiteral`. `parent:` names the command this is a subcommand of (see nesting above). `singleton_init: true` generates a no-arg initializer + `self.get` accessor — note the check is `== true`, so any other value silently does nothing |
|
|
| `@[Argument(short:, long:, description:, validation:, on_match:, options:, delimiter:, format:, env_var:)]` | ivar | Emitted by `argument`; read by `App.generate` and `define_command_initializer` |
|
|
| `@[SubCommand(description:, examples:)]` | method | Emitted by `subcommand`; read by `CommandNode#subcommands` and the dispatch `case` |
|
|
| `@[PreRunCommand]` | method | Run unconditionally before subcommand dispatch |
|
|
| `@[ProxyCommand]` | — | Declared only; unused |
|
|
| `@[Trigger]` | — | Declared only; unused |
|
|
|
|
## Crystal macro gotchas
|
|
|
|
These have each caused real bugs in this codebase — check for them before touching a macro:
|
|
|
|
- **Macro *arguments* arrive as unresolved AST** (`Path`, `Generic`), not `TypeNode`.
|
|
`==` silently returns false and `<` raises `undefined macro method 'Path#<'`. Call
|
|
`.resolve` first: `validation.return_type.resolve == Bool`, `type.resolve <= Array`.
|
|
Generic type *parameters* (`T` inside `Flag(T)`) are already `TypeNode` and are safe as-is.
|
|
This is the single most frequent bug in this codebase — it has appeared five separate
|
|
times, and it usually fails *silently* (a `Path == TypeNode` comparison is just always
|
|
false) or with an unrelated-looking error like `undefined macro method 'Path#annotation'`.
|
|
Resolve once at the point of extraction, not at each use.
|
|
- **`{% cond %}` failing inside a macro reports the wrong line.** When a macro expression
|
|
raises, the error points at the line that *consumes* the variable, not the assignment
|
|
that blew up — "undefined macro variable `x`" almost always means the line assigning `x`
|
|
errored. Look one line up.
|
|
- **Macros cannot be called from macro scope.** `{% if some_macro(x) %}` gives
|
|
`undefined macro method`. Macros emit code; they don't return values to the evaluator.
|
|
To share logic, either precompute into a constant both can read, or accept duplication.
|
|
- **`@type.instance_vars` only works in method scope.** In class-body scope (including a
|
|
`macro finished` directly in a class body) it raises "instance vars are not yet
|
|
initialized". Declaring an ivar requires class-body scope, so a macro can never read one
|
|
class's ivars *and* declare ivars in another. Constants are readable in both scopes and
|
|
are the way around it (`@type.constant("X") << ...` mutates one).
|
|
- **Macro `for` has no `break`**, and reassigning the loop variable does nothing. Bound the
|
|
loop and guard the body with a sentinel (`{% unless done || found %}`).
|
|
- **Emitted locals leak into the caller's scope.** `flg = ...` in a macro body clobbers a
|
|
user's `flg`. Use `%flg`, which is unique per expansion (and avoids type unions when the
|
|
same macro is called with different `T` in one scope).
|
|
- **`{% verbatim do %}`** is required whenever a macro body must emit macro code that runs
|
|
in the *subclass's* `macro finished` context (see `command.cr`,
|
|
`define_singleton_init.cr`, `define_command_initializer.cr`).
|
|
- Signed/unsigned dispatch is done by string inspection, since there's no `UInt` supertype
|
|
to test against: `{% int_case = T.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}`.
|
|
- `macro finished` ordering is why `App.generate` can see every `Command` subclass.
|
|
|
|
## Spec structure
|
|
|
|
- `spec/cligen/arg_spec.cr` — plain specs for `Arg`.
|
|
- `spec/cligen/flag_spec.cr` — reopens `CliGen::Flag(T)` to expose `test_coerce` and a
|
|
`String`-array `process` overload, then generates most `it` blocks with `{% for int in
|
|
Int.subclasses %}` etc. so every numeric width is covered. `MyGoodData` / `MyBadData`
|
|
exercise `Coercable` / `Parsable`, including the "didn't mark anything processed" failure.
|
|
- `spec/cligen/command_spec.cr` — `resolve_value` at depth 1/2/3, both construction paths
|
|
(`define_singleton_init`'s no-arg `new` and `new(handler:)`), and the required-flag raise.
|
|
- `spec/cligen/timeparse_spec.cr` / `relative_operation_spec.cr` — the time subsystem.
|
|
- `spec/cligen/regex_spec.cr` — the flag and date matchers.
|
|
|
|
**Two spec hazards, both already hit:** env vars set in one example leak into others, so
|
|
anything touching `ENV` needs a `before_each { ENV.delete(...) }` — run `crystal spec
|
|
--order=<n>` for a few seeds before trusting a green suite. And time assertions must not
|
|
compare two independently-sampled clocks; `RelativeOperation#apply` takes an explicit
|
|
`Time`, so assert against a fixed `Time.utc` and reserve bracket assertions for
|
|
`Timeparse.parse`'s single `Time.local` call. Check `TZ=UTC crystal spec` too.
|
|
|
|
`utils/flag_matrix.cr` + `utils/flag_matrix.sh` cover what specs can't: env vars must be
|
|
set before process startup, so each of the 16 cases needs its own process. Run this after
|
|
touching value resolution, `check!`, or help rendering.
|
|
|
|
## Current state (branch `object_rework`)
|
|
|
|
Green: `crystal build --no-codegen` clean, `crystal spec` 276 examples / 0 failures,
|
|
`./utils/flag_matrix.sh` 16/16. `shard.yml` and `CliGen::VERSION` are both bumped to
|
|
`0.2.0`; no git tag exists yet.
|
|
|
|
**Verifying a change needs a consuming app, not just the lib build.** `crystal build
|
|
src/cligen.cr --no-codegen` does not instantiate `App.process`, `check_for_env_duplicates`,
|
|
or `satisfied?`, so type errors there stay invisible. Write a throwaway `require "cligen"`
|
|
program with a `Command` subclass and build *that*.
|
|
|
|
Known gaps, all deliberate:
|
|
|
|
- **Enum support** — deferred to v0.2.1; requires reworking five type-dispatch chains.
|
|
DESIGN.md marks it as planned.
|
|
- **`Array(Time)`** — unsupported; the three array element chains have no `Time` case.
|
|
- **Colon-based relative time formats** (`[-+]%H:%M:%S`) — documented in DESIGN.md as planned.
|
|
- `@[ProxyCommand]` and `@[Trigger]` are declared but unused (see above).
|
|
- The `Fiber.yield` in `app.cr`'s `handle_command_raises` is **load-bearing, not
|
|
superstition** — Crystal's default `Log` backend dispatches async at INFO even with no
|
|
`Log.setup` call, so without the yield `abort`'s synchronous stderr write beats the log
|
|
lines that explain it. A sync dispatcher would be deterministic but is the *consumer's*
|
|
global setting, so the library can't impose it.
|
|
- `resolve_value` can no longer raise `MissingRequiredFlagError` — the declaring ivar
|
|
either has a default or the call site must supply one, so both paths are covered. The
|
|
gate applies only to the parent-chain branch; a local ivar was already resolved during
|
|
`initialize`, and the not-found branch raises at compile time regardless.
|
|
- `resolve_value`'s own cycle detection is unreachable: `validate_command_tree` runs in
|
|
`macro finished` and catches cycles before any method body instantiates. The `commands`
|
|
array it accumulates is still live — it feeds the "valid options are..." error listing.
|
|
|
|
## Repo conventions
|
|
|
|
- Every `.cr` file starts with `# SPDX-License-Identifier: MIT` and
|
|
`# Copyright 2026 Tristan Ancelet`. Add these to new files.
|
|
- **`.gitignore` is a deny-all allowlist** (`*` followed by `!` exceptions). New top-level
|
|
files and directories are ignored silently and fail closed — add an explicit `!` entry
|
|
when creating one. This has bitten `DESIGN.md` and `spec/`.
|
|
- `Log` is stdlib (`::Log.for(...)` on `BaseFlag` and `BaseCommandNode`), deliberately
|
|
chosen over a dependency. Always use the block form — it's zero-cost when the level is disabled.
|
|
- Licensed MIT.
|