Skip to content

Tracking: provenance, cross-flag relationships, and help grouping #88

Open
1 of 3 issues completed
Open

Description

@kjanat

v4 release scope decision

This is a tracking issue, not an unimplemented provenance bug or a v4.0.0 blocker.

Keep this umbrella open until its remaining children are complete. The original report below describes the older 3.0.1 behavior; its provenance and workaround claims no longer describe current v4. Existing .derive() plus sources remains the documented cross-flag workaround.


Original report / design draft (historical)

Two gaps that turn out to be the same gap: a flag knows nothing about how it was set, and nothing about the other flags. Everything cross-cutting therefore has to be hand-rolled imperatively in a handler, where it is untyped and invisible to help, the definition schema, and completions.


Part 1 — Resolved values carry no provenance

The resolver has an ordered, named chain — FLAG_RESOLUTION_ORDER = ['cli', 'env', 'config', 'prompt', 'default'] — and it already models sources on the failure path via FlagDiagnosticSource ({kind: 'env', envVar}, {kind: 'config', configPath}, {kind: 'prompt'}).

On the success path that is dropped. ResolveResult is {flags, args, deprecations}, and ActionParams narrows further to {args, flags, ctx, out, meta}. By the time a handler runs, the stage that won is gone.

A handler can observe what a flag resolved to, never whether the user said it.

Why this is a class of problem

  1. Mutually exclusive flags. Two flags answering the same question, where passing both should be a hard error. Today the only reachable behaviour is a silent precedence rule.
  2. Warning on ignored input. "You passed --x, but it has no effect in this mode" — only honest if --x was actually typed.
  3. Explicit-wins merges. A command layering CLI values over its own project manifest (a package.json field, a framework config it reads itself) needs to know which CLI values were real. A defaulted value silently outranks the manifest.
  4. Derived or conditional defaults. A default computed from another input — e.g. an output directory derived from a positional <input>. Today the only way to express "unset" is to omit .default().
  5. --explain-style diagnostics. Showing where each value came from, which is what FLAG_RESOLUTION_ORDER already names.

(1)–(4) need only a boolean. (5) wants the stage label.

Every workaround is lossy

A. Drop .default(), fall back in the handler (flags.output ?? 'favicon.ico').

Works, but the declared default leaves the schema: presence goes defaulted → optional and defaultValue disappears from the definition document, so --help --json and completions no longer know it. Help loses its (default: …) suffix, and the value gets restated in prose where nothing can consume it.

B. Compare against the default sentinel (flags.output !== 'favicon.ico').

Fails in exactly the case the check exists for: a user who explicitly passes a value equal to the default. A genuine conflict reads as "not provided".

C. .derive() — the workaround the docs currently recommend for mutual exclusion (Limitations → "Exactly One Of" Is Not Built In).

Worth flagging: this workaround is defeated by the same gap. .derive() runs after resolution, so it sees resolved values and cannot tell explicit from default either. The documented example only works because those flags carry no defaults —

flags.socket !== undefined   // reliable only while `socket` has no .default()

Add a default to any flag in an exclusivity rule and the recommended workaround silently stops detecting the conflict. The documented answer to limitation 1 depends on gap 1 not being hit.

Concrete case

kjanat/vite-svg-to-ico's generate has two flags that both answer what is this file called:

  • --output <name> — the ICO filename, default favicon.ico
  • --keep-name — derive it from the source instead (logo.svg → logo.ico)

Passing both should error. -o favicon.ico --keep-name is a real conflict that (B) cannot see and (C) cannot see, so it took (A) — --output lost its recorded defaultValue purely to make an unrelated conflict check possible. The declared-default surface and the explicitness check are currently mutually exclusive, and that trade shows up in tooling output as well as in handler code.


Part 2 — Relationships between flags are not expressible at all

The docs state this plainly:

There is no .exclusive(), no .conflicts(), and no required-one-of group — flags are resolved independently of one another.

with the rationale:

  • the schema models each flag as a standalone, independently resolved value;
  • cross-flag rules are open-ended (exactly-one, at-most-one, all-or-none, implies), and a single declarative knob would cover only a slice of them.

The second point is correct about a single knob. An open-ended schema seam (shape E below) answers it directly: open-endedness is an argument for picking the right seam. The first point is an invariant worth keeping, and shapes D and E are built to keep it.

What is missing beyond exclusivity:

  • conflicts — --output × --keep-name
  • requires / implies — --encoding is meaningless without --embed; --asset-dir only applies under --embed
  • exactly-one-of / at-most-one-of — transport selection
  • all-or-none — --cert and --key together or neither
  • conditional requirement — --token required only when --remote is set

Today each is a hand-written if in .derive(). The if itself is fine. The cost is that the rule then exists only in the handler:

  • help cannot say "conflicts with --keep-name"
  • the definition schema / --help --json cannot express it, so no downstream tool can
  • shell completions still offer a flag that conflicts with one already on the command line — dreamcli generates completions from the schema, so this is a payoff it is uniquely positioned to deliver and currently cannot
  • error codes and messages are re-invented per project, with no consistency

That last consequence sits awkwardly against a value the same page states elsewhere, under "Shortcuts Like Automatic Negated Booleans Are Not Built In":

the library prefers schema-declared behavior over convention-heavy magic.

A rule that exists only as an if inside a handler is the least schema-declared construct available. It cannot be introspected, no consumer of the schema can see it, and every project reinvents its wording and error code. Declining to synthesize --no-verbose on the grounds that spellings should be explicitly declared, while cross-flag rules remain undeclarable at all, points in two directions at once. The consistent position is that relationships should be declarable too. The open question is which seam.

And: no flag/arg grouping in help

group(name: string): CommandBuilder exists, but it groups commands. There is no equivalent for flags or args — .help({flagOrder}) and sortFlags order a single flat Flags: table, they do not section it.

A command with a dozen flags spanning input, output, rasterizing, and diagnostics renders as one undifferentiated list. Grouping is the cheapest readability win available for wide commands, and it is schema data, so it should reach --help --json as well as the rendered text.


Possible shapes

Sketches only. They compose; E subsumes C/D for power users while C/D stay the ergonomic path.

A. Provenance on ActionParams

interface ResolveResult {
  readonly flags: Readonly<Record<string, unknown>>;
  readonly args: Readonly<Record<string, unknown>>;
  readonly deprecations: readonly DeprecationWarning[];
  readonly flagSources: Readonly<Record<string, FlagResolutionStage>>;
  readonly argSources: Readonly<Record<string, ArgResolutionStage>>;
}
.action(({ flags, sources }) => {
  if (sources.flags.output === 'cli' && flags['keep-name']) { /* … */ }
});

FlagResolutionStage / ArgResolutionStage would join the root exports — today only ResolveResult is re-exported from @kjanat/dreamcli, the stage types stay in core/resolve/contracts.

B. Narrower: an explicitness predicate

.action(({ flags, wasExplicit }) => {
  if (wasExplicit('output') && flags['keep-name']) { /* … */ }
});

Covers (1)–(4) without committing the stage vocabulary to the public API. Loses (5).

C. Flag-level relationship modifiers

.flag('keep-name', flag.boolean().conflicts('output'))
.flag('encoding', flag.enum([...]).requires('embed'))

Reads best, but cannot be typed against sibling names: flag.boolean() builds in isolation and never sees the command's accumulated F, so 'output' is an unchecked string and a typo is a runtime surprise. Viable if untyped names are acceptable, or if the final .action() type-checks accumulated references.

D. Command-level constraint block — typed

Placing constraints after the .flag() calls means the full keyof F / keyof A is known, so names are compile-checked and typos fail the build:

command('generate')
  .flag('output', flag.string())
  .flag('keep-name', flag.boolean())
  .flag('embed', flag.boolean())
  .flag('encoding', flag.enum(['base64', 'utf8']))
  .constrain((c) => [
    c.conflicts('output', 'keep-name'),
    c.requires('encoding', 'embed'),
    c.exactlyOne('stdio', 'socket', 'node-ipc'),
    c.allOrNone('cert', 'key'),
    c.typo,                              // ← compile error
  ]);

Each constraint is schema data, so help, --help --json, and completions can all read it, and violations carry framework-standard CLIError codes (CONFLICTING_FLAGS, MISSING_DEPENDENT_FLAG, …) that every project shares.

This keeps the architectural invariant intact. The stated objection — "the schema models each flag as a standalone, independently resolved value" — remains true. Constraints declared here sit as a layer over the schema: no flag learns about its siblings, resolution stays per-flag and independent, and the constraint pass runs afterwards with the resolved set in hand. That is also the deeper reason to prefer D over C — C pushes relationship knowledge down into individual flag builders, which is precisely what the invariant rules out. C's typing problem is a symptom of that violation.

E. Object-level Standard Schema — the open-ended seam

Standard Schema v1 support currently reaches individual values (flag.custom(schema), arg.custom(schema), per element for arrays/variadics). There is no seam for validating the resolved object as a whole — which is precisely the shape every cross-field rule needs.

command('generate')
  .flag('output', flag.string())
  .flag('keep-name', flag.boolean())
  .validate(
    z.object({ output: z.string().optional(), 'keep-name': z.boolean() })
      .refine((v) => !(v.output !== undefined && v['keep-name']), {
        message: '--output and --keep-name both name the ICO',
        path: ['output'],
      }),
  );

This directly answers the documented objection: exactly-one, at-most-one, all-or-none, implies, and anything else nobody has thought of are all just refinements, using validators consumers already depend on. No knob per rule. It preserves the same invariant as D — the schema is validated as a whole, after resolution, without any flag gaining knowledge of another.

StandardSchemaV1Issue.path maps onto flag names, so each issue can render against the flag it belongs to — StandardSchemaV1PathSegment is already exported, so the machinery exists.

Caveat worth designing around: an object-level schema sees resolved values, so on its own it hits the Part 1 wall too. It only fully works if the validated object carries provenance — e.g. validating {flags, args, sources}, or exposing explicitness to refinements some other way. Part 2 needs Part 1 underneath.

F. Flag/arg grouping

.flag('output',  flag.string().group('Output'))
.flag('out-dir', flag.path().group('Output'))
.flag('sizes',   sizesFlag().group('Rasterizing'))
Output options:
  -o, --output <string>     …
  -d, --out-dir <path>      …

Rasterizing options:
  -s, --sizes <number>...   …

Ungrouped flags fall back to today's Flags: table, so it stays additive. Group order could follow declaration or an explicit .help({ groupOrder }), consistent with the existing flagOrder. As schema data it belongs in the definition document too.


Observed on npm:@kjanat/dreamcli@3.0.1.

Activity

  1. coderabbitai commented on Aug 1, 2026

    @coderabbitai
    🔗 Related PRs

    #1 - feat: shell completions, config discovery, custom flags, deprecation, and nested subcommand trees (v0.5–v0.7) [closed]
    #7 - Rework default commands, help subcommand, plugin hooks, and package discovery [closed]
    #43 - Add eager --completions flag and .help() config builder [merged]
    #57 - feat: themed help output + negatable/duplicates/case-parity flags (rc.8) [merged]
    #58 - feat: compile-time array-element narrowing + JSON help output [merged]
    #74 - feat(help): configurable flag order in help output (#68) [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. added
    area: cliCLI dispatch, planning, root UX, or plugins
    area: resolveArgv, env, config, prompt, and default resolution
    kind: featureNew capability or product enhancement
    on Aug 1, 2026
  3. changed the title [-]Resolved values carry no provenance — a handler can't tell an explicit flag from its default[/-] [+]Flags are resolved in isolation: no value provenance, no declarative relationships, no grouping[/+] on Aug 1, 2026
  4. self-assigned this
    on Aug 1, 2026
  5. added
    area: schemaSchema builders, schema DSL, and JSON Schema export
    on Aug 1, 2026
  6. added this to the v4 milestone on Aug 12, 2026
  7. modified the milestones: v4, v4.x on Sep 6, 2026
  8. changed the title [-]Flags are resolved in isolation: no value provenance, no declarative relationships, no grouping[/-] [+]Tracking: provenance, cross-flag relationships, and help grouping[/+] on Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area: cliCLI dispatch, planning, root UX, or pluginsarea: resolveArgv, env, config, prompt, and default resolutionarea: schemaSchema builders, schema DSL, and JSON Schema exportkind: featureNew capability or product enhancement

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions