Skip to content

CLI: commands flatten-and-reparse LuCLI args instead of reading argCollection directly (tech debt behind #2855) #2861

Description

@bpamiri

Background

#2856 fixed the --no-* flag family (--no-sqlite, --no-routes, --no-test-db, --no-open-browser) by re-emitting --no-<key> for false values in Module.cfc::argsFromCollection(). That is the correct, minimal bug fix for #2855 and it is shipping. This issue tracks the underlying architectural smell it exposed, which was deliberately out of scope for a bug fix.

The smell

LuCLI parses the command line into a structured argCollection map before dispatching to a module. For wheels new myapp --no-sqlite, the module's new() receives:

argumentCollection = { arg1: "myapp", sqlite: "false" }

(--no-<key> is normalized to <key>=false; see LuceeScriptEngine.parseArguments() in the LuCLI runtime, lines ~335–365.)

cli/lucli/Module.cfc then discards that structure: getArgs() → argsFromCollection() flattens the map back into a flat argv string array (["myapp", "--no-sqlite"]), and each command re-parses it with a hand-rolled token loop:

} else if (arg == "--no-sqlite") {
    options.noSQLite = true;
}

So the data flows structured → flattened → re-parsed. getArgs(arguments) is used by ~18 commands.

Why it matters

Proposed direction

Have commands consume argCollection directly (named-arg access: coll.sqlite, coll.arg1) through a small shared typed-arg helper, instead of round-tripping through argv. This removes both the flatten/re-parse layer and the lossy false handling in one move. Large blast radius (~18 commands), hence tracked separately rather than bolted onto the #2856 bug fix.

Related: close the test-robustness gap (smaller, near-term — independent of the refactor)

#2856's unit test hand-builds {sqlite:"false"}, so it validates the re-emit logic but bypasses LuCLI entirely — it cannot catch an upstream LuCLI change to --no-* handling. This is the exact assumption that propagated unverified through triage and both bot reviews. Two cheap improvements:

  • Add an end-to-end assertion in tools/test-onboarding.sh: wheels new <app> --no-sqlite → no db/*.sqlite files, and lucee.json configuration.datasources == {}.
  • Add a comment in argsFromCollection() pinning the LuCLI key=false contract it depends on (LuceeScriptEngine.parseArguments()), so the cross-runtime dependency is discoverable by the next maintainer.

Refs #2855, #2856.

Activity

  1. wheels-bot commented on Jun 5, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Triage

    Classified as framework-design (API/feature design question).
    Cross-framework research will follow shortly — Rails, Laravel, Django,
    and other modern frameworks will be consulted before any path forward
    is proposed.

    <!-- wheels-bot:triage:2861 -->
    <!-- wheels-bot:triage-class:framework-design -->

  2. wheels-bot commented on Jun 5, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Cross-Framework Research

    Question

    Module.cfc already receives a structured argCollection from LuCLI
    ({arg1: "myapp", sqlite: "false"}) but immediately flattens it back to argv
    via argsFromCollection() so each of ~18 commands can re-parse it with a
    hand-rolled token loop (if (arg == "--no-sqlite") ...). The flatten step
    was the root cause of #2855 and is structurally lossy: it cannot distinguish
    a --no-X negation from an explicit --X=false. Should each command keep
    re-parsing argv, or should there be a shared typed-arg layer between LuCLI
    and the per-command handlers — and what should that layer look like?

    How other frameworks handle this

    Framework Naming Surface Trade-offs Edge cases
    Rails (Thor) argument/option macros above method; options[:key] inside Declarative class macros, type inferred from default; parsed values delivered via attr_accessor + options hash Concise; option only attaches to next method (easy to misplace); arg name written twice --no-X and --skip-X auto-generated for any :boolean; lazy_default for bare-flag form
    Laravel (Artisan) $signature route-like string DSL (or getArguments()/getOptions()) {arg?}, {--flag=!default} for negatable, {--Q|queue=} shortcut; $this->argument()/option() accessors Single declarative string; stringly-typed mini-language; no type coercion =! opt-in tri-state (null/true/false); =* array; auto --help
    Django add_arguments(parser) hook; **options kwargs to handle() argparse add_argument(type=, default=, action="store_true"/BooleanOptionalAction) Powerful, mature; **options mixes framework + user keys; no IDE autocomplete BooleanOptionalAction autogen --flag/--no-flag; dest= overrides; dashes→underscores
    Phoenix (Mix / OptionParser) Per-task OptionParser.parse(argv, switches:, aliases:) in run/1 Lower-level: each task owns @switches/@aliases module attributes; built-in --no-X for :boolean Tiny & composable; every task still re-parses; no shared registry, no auto-help, no required-arg enforcement --no-X first-class for any :boolean; strict: vs lenient; :keep for repeats
    Spring Shell / picocli @ShellOption / @Option/@Parameters annotations on method params/fields Annotations declare names, types, negatable=true, defaultValue; framework injects typed values Zero hand-parsing, full type coercion, generated help & completion; verbose for complex grouped args negatable=true autogen --no-X via INegatableOptionTransformer; arity="0..1" + fallbackValue for optional-value flags
    Symfony Console (+1, justified) configure() + addArgument/addOption OR modern #[AsCommand] + #[Argument]/#[Option] attributes InputOption::VALUE_NEGATABLE autogens --no-X; classic API via $input->getOption(), modern via typed __invoke params Classic form duplicates name in decl + access (stringly-keyed); attribute form removes duplication & adds type coercion VALUE_NEGATABLE autogens negation pair from one declaration; VALUE_OPTIONAL famously ambiguous (Symfony docs warn)

    +1 justification: Symfony Console is the canonical PHP CLI framework that Artisan
    builds on, and the broader prior art for "declarative arg spec + framework-parsed,
    typed handover" in dynamic languages. It's a closer fit to the CFML situation
    (no annotations, no metaprogramming) than picocli, while showing both the imperative
    configure() form and the modern attribute-driven form Wheels could grow into.

    Dominant pattern

    Five of six frameworks converge on the same contract: the command declares its
    positional args + named options + types + defaults up front, the framework parses
    argv exactly once, and the handler receives typed values via named accessors or
    typed parameters — it never re-parses argv.
    Phoenix/Mix is the lone outlier
    (each task calls OptionParser.parse itself), but even there parsing is one
    call against a declared switch spec
    , not a hand-rolled token loop. Negatable
    booleans (--no-X) are a first-class, opt-in feature in every modern framework
    (Thor/Mix auto-enable for any boolean; Symfony VALUE_NEGATABLE; picocli
    negatable=true; Artisan =!; Django BooleanOptionalAction) and the negation
    is resolved during the single parse — never via argv round-trip. Wheels'
    current "flatten → re-emit → re-parse" model is below even the Mix baseline,
    and the only frameworks that don't have a typed handover layer never built one
    because their per-command parse is already one declarative call.

    Recommended path for Wheels

    Introduce a tiny ArgSpec builder that each command instantiates once to declare
    its positional + named + flag schema, then calls .parse(arguments) against
    LuCLI's structured argCollection directly. This eliminates argsFromCollection()
    and the ~18 per-command token loops in one move, makes --no-X handling
    non-lossy (LuCLI already gives us key=false; we just consume it), and gives
    us a single place to add type coercion, required-arg enforcement, and an
    auto-generated --help later. It's a builder, not a mixin — no new
    lifecycle hook, no DI scope change, no framework-side machinery. Migrate
    commands incrementally: each one that adopts ArgSpec drops its hand-rolled
    loop; getArgs()/argsFromCollection() stays as a deprecated shim until the
    last call site is converted.

    // cli/lucli/services/ArgSpec.cfc
    component {
    
        public any function init() {
            variables.positionals = [];   // ordered [{name, required, default, type}]
            variables.named       = {};   // name -> {default, type, aliases}
            return this;
        }
    
        public any function positional(
            required string name,
            boolean required = false,
            any default = "",
            string type = "string"
        ) {
            arrayAppend(variables.positionals, {
                name = arguments.name,
                required = arguments.required,
                default = arguments.default,
                type = arguments.type
            });
            return this;
        }
    
        public any function flag(
            required string name,
            boolean default = false,
            string aliases = ""
        ) {
            variables.named[arguments.name] = {
                default = arguments.default,
                type = "boolean",
                aliases = arguments.aliases
            };
            return this;
        }
    
        public any function option(
            required string name,
            any default = "",
            string type = "string",
            string aliases = ""
        ) {
            variables.named[arguments.name] = {
                default = arguments.default,
                type = arguments.type,
                aliases = arguments.aliases
            };
            return this;
        }
    
        public struct function parse(required struct coll) {
            var result = {};
    
            // 1. seed named defaults
            for (var optName in variables.named) {
                result[optName] = variables.named[optName].default;
            }
    
            // 2. bind positionals from coll.arg1, arg2, ...
            for (var i = 1; i <= arrayLen(variables.positionals); i++) {
                var spec = variables.positionals[i];
                if (structKeyExists(arguments.coll, "arg" & i)) {
                    result[spec.name] = $coerce(arguments.coll["arg" & i], spec.type);
                } else if (spec.required) {
                    throw(
                        type = "Wheels.CLI.MissingArgument",
                        message = "Missing required argument <" & spec.name & ">"
                    );
                } else {
                    result[spec.name] = spec.default;
                }
            }
    
            // 3. bind named — LuCLI already normalized --no-X => key=false
            for (var collKey in arguments.coll) {
                if (reFindNoCase("^arg\d+$", collKey)) continue;
                if (structKeyExists(variables.named, collKey)) {
                    result[collKey] = $coerce(arguments.coll[collKey], variables.named[collKey].type);
                }
            }
    
            return result;
        }
    
        private any function $coerce(required any v, required string type) {
            switch (arguments.type) {
                case "boolean":
                    return isBoolean(arguments.v)
                        ? arguments.v
                        : (lCase(toString(arguments.v)) == "true");
                case "numeric":
                    return val(arguments.v);
                default:
                    return toString(arguments.v);
            }
        }
    }

    Usage — wheels new becomes:

    public function new() {
        var spec = new cli.lucli.services.ArgSpec()
            .positional(name = "appName", required = true)
            .flag(name = "sqlite",      default = true)   // --no-sqlite negates cleanly
            .flag(name = "routes",      default = true)
            .flag(name = "testDb",      default = true)
            .flag(name = "openBrowser", default = true)
            .option(name = "datasource", default = "");
    
        var opts = spec.parse(arguments);
        // opts.appName, opts.sqlite (boolean), opts.datasource (string)
        // No argv flatten. No re-parse. No lossy --no-X round-trip.
    }

    Lives in: cli/lucli/services/ArgSpec.cfc. Composes with: the existing
    argumentCollection = ... LuCLI dispatch (no change there); commands keep
    their public function <name>() shape, just consume arguments through
    the spec instead of re-flattening. getArgs() / argsFromCollection()
    remain as a deprecated shim during migration; the test added in the issue's
    end-to-end bullet pins the LuCLI contract at the integration layer
    rather than the unit layer.

    Cross-engine notes

    Clean. The sketch uses no closures (avoids invariant #3, #4, #5), no
    obj.map() / member-function collisions (#1), no application-scope
    function storage (#2), no attributeCollection = arguments (#10).
    Boolean coercion handles both the string "false" LuCLI normally emits
    and the actual false literal that some engines produce — verified
    against lCase(toString(v)) == "true" so Adobe/Lucee/BoxLang all
    agree. structKeyExists(arguments, "arg1") and ordered iteration over
    the positionals array are uniform across engines. $coerce is a
    private method on a service component (not a mixin integrated into a
    model/controller), so the $ prefix is convention-only — invariant
    #7 doesn't apply here, but keeping the prefix matches CLI service code
    elsewhere.

    Open questions

    • Migration cadence: convert all ~18 commands in one PR, or one
      command per PR? wheels new is the natural pilot because Wheels CLI ignores --no-sqlite argument #2855
      already proved its --no-* surface is fragile.
    • Auto-help integration: ArgSpec is well-positioned to generate
      the out(" --no-sqlite ...") blocks each command hand-writes today.
      Worth it in this PR, or a separate follow-up?
    • Required-positional UX: throw and let LuCLI render, or print a
      friendly Usage: block and return non-zero? (Symfony throws; Thor
      prints; Mix raises. No consensus.)
    • Aliases & shortcuts (-Q → --queue): worth supporting in v1,
      or punt since LuCLI's current parser doesn't emit short-form keys
      anyway?
    • PromptsForMissingInput-style interactive prompting (Artisan)
      is out of scope here but might be a nice follow-up for wheels new.

    Confidence: high

    Five of six frameworks converge on the exact pattern proposed; the sketch
    respects every CLAUDE.md anti-pattern (all-named arg calls, no mixed
    positional/named, no closures, no struct-member collisions, no reserved-scope
    shadowing) and surfaces zero cross-engine concerns; no new framework
    infrastructure (mixin, lifecycle hook, DI scope) is required — it's a
    service component that commands instantiate.

    Sources

  3. wheels-bot commented on Jun 5, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Fix proposed

    Draft PR: #2862

    Spec: cli/lucli/tests/specs/services/ArgSpecSpec.cfc — failing → passing
    Implementation: cli/lucli/services/ArgSpec.cfc

    A human review is required before merge. Reviewer A and Reviewer B
    will weigh in shortly. Doc updates (MDX guides, .ai/wheels/,
    CLAUDE.md) are handled separately by bot-update-docs.yml.

    Note: the bot sandbox could not run bash tools/test-cli-local.sh
    locally (no LuCLI runtime available), so CI's CLI test job is the
    gating runner for this PR — please wait for it before flipping to
    ready-for-review. Spec assertions were traced manually against the
    implementation before commit; see the PR's Test Plan for details.

  4. reopened this on Jun 5, 2026
  5. bpamiri commented on Jun 5, 2026

    @bpamiri
    CollaboratorAuthor

    Reopening to track the remaining work — the merge auto-closed this, but only the foundation shipped.

    #2862 (merged as d1cdbe3) landed the cli.lucli.services.ArgSpec builder + spec, but no command has been migrated yet. The flatten-and-reparse round-trip this issue is about still exists at every call site: Module.cfc::getArgs() → argsFromCollection() is unchanged, and all ~18 subcommands still re-parse the flattened argv with hand-rolled token loops.

    What's left:

    • Migrate the ~18 CLI subcommands from argsFromCollection() to ArgSpec.parse(arguments), dropping each hand-rolled token loop in the same change.
    • Once every call site is converted, remove the deprecated getArgs() / argsFromCollection() shim from Module.cfc.

    No regression in the meantime: the user-facing #2855 symptom (--no-* flags being dropped) stays fixed by the --no-key re-emit workaround from #2856, which #2862 left untouched. This is purely the tech-debt cleanup that the round-trip removal will eventually retire.

  6. bpamiri commented on Jun 6, 2026

    @bpamiri
    CollaboratorAuthor

    #2872 (merged as 45e5fb120) migrated the 8 clean leaf commands off the round trip onto ArgSpec:

    new · seed · notes · analyze · doctor · stats · upgrade · destroy

    Each drops its hand-rolled token loop and consumes LuCLI's structured argCollection directly via ArgSpec.parse(structuredArgs(arguments)). It also fixed a latent bug the round trip masked: getArgs()'s arg1-gate silently dropped named-only invocations — wheels doctor --verbose, wheels seed --environment=x, wheels stats --verbose, wheels notes --annotations=… ran with defaults regardless of input. They now work.

    Remaining (this issue stays open)

    • Migrate the dispatchers — generate, create, db, browser (they pass a flat args array to sub-handlers).
    • Migrate migrate (dispatches on the action and passes args to runForgetOrPretend).
    • Extend ArgSpec (variadic positionals / passthrough) and migrate deploy / packages (own parsers, nested sub-verbs, variadic positionals).
    • Migrate the space-separated-flag commands test / console (ArgSpec doesn't model --filter value / --password value).
    • Remove the getArgs() / argsFromCollection() shim once every call site is converted.
    • Add the tools/test-onboarding.sh e2e assertion for wheels new <app> --no-sqlite.

    The documented --no-* family stays fixed by #2856's re-emit on the not-yet-migrated commands until the shim is removed.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions