Skip to content

feat: rewrite cli package with a layered, typed architecture - #6

Merged
Taiizor merged 3 commits into
mainfrom
rewrite
Oct 19, 2026
Merged

Taiizor merged 3 commits into
mainfrom
rewrite

Conversation

@Taiizor

@Taiizor Taiizor commented Oct 19, 2026

Copy link
Copy Markdown
Member

Summary

Rebuilds the cli package from scratch on the ecosystem template. The package now follows the standard layered layout (facade + data models + focused engines) instead of the previous flat implementation, and the whole public API is reworked around typed models, structured diagnostics, and feature-gated slices.

The version is bumped 0.1.0 → 0.2.0 because the public API changes are breaking.

Architecture

src/
├── lib.alya            # Public facade: thin wrappers + fluent struct methods
├── types.alya          # Models, enums, factories, error helpers
└── core/
    ├── spec.alya       # Specification builders (app, command, option, argument)
    ├── parser.alya     # Parsing engine: token classification, binding, validation
    ├── formatter.alya  # Usage screens, version banner, diagnostic report (help slice)
    ├── errors.alya     # Strict parsing slice: throwing and recovering entry points (strict slice)
    └── internal.alya   # Private helpers shared inside the package (no pub symbols)

src/core/internal.alya deliberately declares no pub symbol: an Alya module without public items shares its whole surface with sibling modules, so the shared helpers stay reachable inside the package while remaining invisible to package consumers.

What is new

  • Complete option grammar — long (--output dist), inline (--output=dist), short (-o dist), attached (-oDist), clustered (-abc), negated (--no-color), repeatable (-I a -I b), and the -- terminator.
  • Hierarchical subcommands — nested command trees, per-command options and arguments, short aliases (build ⇄ b).
  • Structured diagnostics — CliError values with code, subject, and message replace the plain error strings; ErrorCode covers unknown options, missing values, missing required options, invalid choices, missing arguments, unknown commands, and invalid usage.
  • Typed accessors — get_int and get_bool convert option values, get_all returns the collected values of a repeatable option.
  • ctx.has(name) reports only the values that actually appeared on the command line, so an option left at its declared default counts as absent.
  • Consistent option names — the short and the long name of an option always share the same bucket, so a value given as -I src is visible through --include as well.
  • Environment fallback — options can read their value from an environment variable when omitted (env_option).
  • Feature-gated slices — help (usage screens), strict (parse_strict / try_parse), and env, each with a descriptive fallback stub so the public surface stays stable in slim builds.
  • Dual API style — a fluent builder (app.flag(...)) next to the functional API (cli::add_flag(app, ...)), both over the same engines.

Migration notes

Old API New API
cli::new(name, desc, ver) cli::new_app(name, desc, ver)
cli::command(name, desc, aliases) cli::new_command(name, desc, aliases)
cli::app_add_flag(app, spec, desc) cli::add_flag(app, spec, desc) / app.flag(spec, desc)
cli::cmd_add_option(cmd, ...) cli::command_add_option(cmd, ...) / cmd.option(...)
cli::ctx_get_flag(ctx, name) cli::get_flag(ctx, name) / ctx.get_flag(name)
cli::ctx_get_all(ctx, name) cli::get_all(ctx, name) / ctx.get_all(name)
cli::ctx_has_errors(ctx) cli::has_errors(ctx) / ctx.has_errors()
cli::help(app, cmd) cli::format_help(app) / cli::format_command_help(app, cmd)
cli::cli_format_version(app) cli::format_version(app)
cli::cli_format_errors(errors) cli::format_errors(errors)
ctx.get_errors() → string[] ctx.get_errors() → CliError[] (err.code, err.subject, err.message)

Verification

Check Result
alya test 6 suites, 148 assertions — pass
alya test --no-default-features 6 suites, 116 assertions — pass
alya fmt . --check 15 files properly formatted
alya lint . --check no violations
alya run examples/demo.alya renders the usage screen and the parsing walkthrough
alya run benches/bench_basic.alya 3 workloads (spec building, parsing, usage screen)
alya doc . -o docs --markdown 8 modules documented

The template compliance audit (check_template_compliance.py --packages cli) reports Compliant with GitHub verification enabled.

Notes

  • The branch is a fresh history (no shared ancestor with the previous main); the old implementation stays available on main and in the v0.1.0 release.
  • The repository description was updated to match the new alya.toml description.

Rebuild the command line toolkit on the ecosystem template:

- facade (src/lib.alya) + data models (src/types.alya) + focused engines
  (src/core/spec.alya, parser.alya, formatter.alya, errors.alya, internal.alya)
- complete option grammar: long, inline, short, attached, clustered,
  negated, repeatable options and the -- terminator
- hierarchical subcommands with aliases and command-scoped options
- structured CliError diagnostics instead of plain strings
- typed accessors (get_int, get_bool) and a provided-value tracker
- feature-gated slices: help (usage screens), strict (throwing and
  recovering parses), env (environment variable fallback)
- fluent builder API next to the functional API
- 148 assertions across six test suites, demo, and benchmarks
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

AI code review (advisory only — never merges)

Risk: 🟢 low

The layered rewrite is internally consistent and every changed .alya file passes fmt/lint/check; the only concrete issues are a stale Alya version (0.0.20 vs the manifest's 0.0.21) in the release/issue tooling and a usage line that drops the parent segment for nested commands. The two lint/check FAILs (src/core/utils.alya, tests/test_flags_advanced.alya) are the files this PR removes/renames to internal.alya and test_flags.alya; no surviving module imports them, so they are expected missing-path noise rather than build breakage.

Static: benches/bench_basic.alya: static clean · examples/demo.alya: static clean · src/core/errors.alya: static clean · src/core/formatter.alya: static clean · src/core/internal.alya: static clean · src/core/parser.alya: static clean · src/core/spec.alya: static clean · src/core/utils.alya: static FAIL(lint,check) · src/lib.alya: static clean · src/types.alya: static clean · tests/test_basic.alya: static clean · tests/test_commands.alya: static clean · tests/test_flags.alya: static clean · tests/test_flags_advanced.alya: static FAIL(lint,check) · tests/test_formatter.alya: static clean · tests/test_strict.alya: static clean · tests/test_validation.alya: static clean

General notes:

  • .github/ISSUE_TEMPLATE/bug_report.yml:40 [minor] The placeholder was bumped to 0.0.20, but alya.toml now sets package.alya-version = "0.0.21". New bug reports will show a toolchain version hint that no longer matches the manifest; keep the value in sync (0.0.21).
  • .github/scripts/generate_release_notes.py:113 [minor] The release-notes fallback default was bumped to 0.0.20 while alya.toml requires alya-version 0.0.21. When a manifest omits alya-version, generated notes will advertise the wrong minimum Alya version; update the fallback to match the manifest.

Fixed since last review:

  • ✅ src/core/errors.alya — In parse_strict, the first error is reconstructed via `make_error(first.code,

Automated review. Treat as untrusted advice: verify before acting.
Model: opencode/big-pickle.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated advisory review (details in the summary comment).

Address the findings of the automated code review on #6:

- a command re-declaring a global option no longer lists it twice in
  the usage screen; the command scope wins, as it does in the parser
- the shadowed global option is validated only once, so a diagnostic
  is never reported twice
- a value given before the command name is no longer reset to the
  command default when the command scope is entered

Adds coverage for the shadowed-option scenario (154 assertions).
@Taiizor

Taiizor commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

Thanks for the automated review. The findings were checked against the code:

internal_join missing (major) — false positive. The review saw a truncated diff of src/core/internal.alya; the helper is defined at line 60 of that file and is used by src/core/formatter.alya. The build, the 154 assertions, and all six CI platforms pass with it in place.

Duplicated option in the command usage screen (info) — real, fixed in 9af6772. A command that re-declares a global option now shadows it consistently across the whole package:

  • the usage screen lists the option once (the command scope wins),
  • the shadowed global option is validated only once, so a diagnostic is never reported twice,
  • a value given before the command name is no longer reset to the command default when the command scope is entered.

The scenario is now covered by the test suite (tests/test_commands.alya, tests/test_formatter.alya), which brings the package to 154 assertions. The three static FAIL entries in the report refer to src/core/utils.alya and tests/test_flags_advanced.alya, both of which this PR deletes.

Audit of the rewritten package found two real defects:

- the demo called \outcome.context()\, but CliResult had no such
  accessor; the compiler silently bound the call to the free facade
  function, so the success path misbehaved at runtime. Adds the
  accessor, exercises both strict branches in the demo, and covers the
  result context in the test suite
- the environment fallback was guarded by an empty value, so an option
  with a non-empty default never fell back to the environment. The
  lookup now keys off whether the option was provided, and the env
  slice gains test coverage (the negative path always, the positive
  path when the variable is set)

Also documents the behaviour decisions (unknown input is reported, the
command scope wins, the command line wins over the environment, and
diagnostics must be rebuilt before they are thrown).
@Taiizor
Taiizor merged commit ba6a614 into main Oct 19, 2026
9 checks passed
@Taiizor
Taiizor deleted the rewrite branch October 19, 2026 18:36

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated advisory review (details in the summary comment).

Comment thread src/core/formatter.alya
end

# Builds the usage line of a command.
function formatter_command_usage(app: CliApp, cmd: CliCommand, arguments: array) -> string

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: formatter_command_usage builds the usage line as app.name + " " + cmd.name, so a nested command such as remote add (see examples/demo.alya) renders as forge add [OPTIONS], dropping the remote parent segment, even though the parser tracks the full command path. When rendering a nested command's usage line, include the parent path segment instead of only the leaf name.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant