Skip to content

Declarative cross-flag relationships: typed .constrain() block and object-level .validate() seam #91

Open

Description

@kjanat

v4 release scope decision

Deferred from v4.0.0 to v4.x. #90 has shipped, so provenance is available; this issue no longer blocks stable.

Implement as opt-in command-level constraints and object-level validation, preserving behavior for commands that do not register them. Keep per-input resolution and existing derive/middleware ordering unchanged. Add optional definition-document fields; do not change required consumer input shapes or existing closed unions/DTOs. Constraint failures can use the open ErrorCode contract without extending the closed ValidationErrorCode union in a minor release.

This issue owns the .validate() design also discussed in #15. Do not independently ship two incompatible APIs with that name. The typed constraint builder and Standard Schema object-validation seam remain the direction; reconcile any convenience callback before introducing the new API. If implementation requires changing an existing stable contract, split that change into a future major release instead of treating it as v4.x work.


Sub-issue of #88 — Part 2. Depends on #90.

Problem

Flags are resolved independently and relationships between them are not expressible at all. The docs say so plainly: there is no .exclusive(), no .conflicts(), and no required-one-of group.

What is missing:

  • 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 cost is not the if; it is that the rule exists only in the handler:

  • help cannot say "conflicts with --keep-name"
  • the definition document / --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 reinvented per project

That sits awkwardly against the stated preference for "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.

Two seams, one design decision

The documented objection — cross-flag rules are open-ended, and a single knob would cover only a slice — is an argument for the right seam, not against having one. Both proposals below run after resolution, over the resolved set, so no flag learns about its siblings and the per-flag independence invariant holds.

D. Command-level typed constraint block

The ergonomic path. Declared after the .flag() calls, so the full keyof F / keyof A is known 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'),
  ]);

Flag-level modifiers (flag.boolean().conflicts('output'), shape C in #88) are explicitly not the chosen route: they push relationship knowledge down into individual flag builders, which is precisely what the invariant rules out, and their inability to type sibling names is a symptom of that rather than the root problem.

E. Object-level Standard Schema

The open-ended escape hatch. Standard Schema v1 support currently reaches individual values only (flag.custom, arg.custom, per array element); there is no seam for validating the resolved object as a whole, which is the shape every cross-field rule needs:

.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'],
    }),
);

Exactly-one, at-most-one, all-or-none, implies, and anything nobody has thought of yet are all just refinements, using validators consumers already depend on. StandardSchemaV1Issue.path maps onto flag names so issues can render as per-flag diagnostics — StandardSchemaV1PathSegment is already exported.

D and E share one decision: where the post-resolution validation pass runs and how violations become framework CLIErrors. That is why they are tracked together, even if they land as separate PRs.

Acceptance criteria

  • Post-resolution constraint pass with access to provenance — needs Value provenance: track resolution stage and expose explicitness to handlers #90, since an object-level schema on its own sees resolved values and hits the Part 1 wall
  • Constraint names compile-checked against the declared flags/args
  • Framework-standard error codes (CONFLICTING_FLAGS, MISSING_DEPENDENT_FLAG, …) instead of per-project ad-hoc ones
  • Constraints serialized into the definition document / --help --json
  • Help renders relationships, e.g. "conflicts with --keep-name"
  • Completions suppress flags that conflict with one already on the command line
  • Docs: replace the Limitations "Exactly One Of Is Not Built In" .derive() workaround

Concrete case from #88: generate in kjanat/vite-svg-to-ico has --output and --keep-name, both answering what the file is called. -o favicon.ico --keep-name is a real conflict that no current workaround can detect.

Activity

  1. added
    area: schemaSchema builders, schema DSL, and JSON Schema export
    area: cliCLI dispatch, planning, root UX, or plugins
    area: completionShell completion generation and parsing
    on Aug 1, 2026
  2. coderabbitai commented on Aug 1, 2026

    @coderabbitai
    🔗 Related PRs

    #12 - add: prompt-flag compatibility validation [closed]
    #54 - fix(cli): consistent root --help reach and advertise built-in global flags [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]
    #60 - feat(schema): Standard Schema interop and DSL removal [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!

  3. added this to the v4 milestone on Aug 12, 2026
  4. modified the milestones: v4, v4.x 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

No one assigned

    Labels

    area: cliCLI dispatch, planning, root UX, or pluginsarea: completionShell completion generation and parsingarea: 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