Skip to content

feat(cli): bind intent flags one level beneath an object preset - #83

Merged
2ynn merged 14 commits into
mainfrom
feat/cli/intent-nested-preset-binds
Oct 7, 2026
Merged

2ynn merged 14 commits into
mainfrom
feat/cli/intent-nested-preset-binds

Conversation

@2ynn

@2ynn 2ynn commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Why

An intent command flag could only bind a top-level body field. When a command presets an object — for example one member of a union-typed property, preset: {$.format: {kind: png}} — the fields of that object had no flag and could only be set through --body, which also requires repeating the preset's discriminator.

What changed

  • Decode (cli_commands.go): a flag's to: may name a field one level beneath an object preset ($.format.width). Deeper paths and array indexes stay decode errors.
  • Link (cli_commands_link.go): nestedBindScope resolves the field in the property's object, or in the union member the preset selects (reusing flattenUnion / presetUnionMergePoint), then infers type, enum suggestions and a preset default as for top-level flags. Rejected with a targeted error: a parent without an object preset, a union member the preset does not uniquely identify, the union's discriminator as the field, positional args, route-dispatch commands, operation-declared flags, a parent restricted to const/enum values, a readOnly field, another input binding the parent object itself. Decode also rejects a command where one input binds an object ($.format) and another binds a field beneath it ($.format.width), regardless of declaration order or whether the parent is a flag or a positional.
  • Render (intents.ts, intentcmd.go.stmpl): flags carry their body Path. Without a body the value is written into the preset object (flagutil.SetBodyPath); with --body/--body-param or a body piped on stdin it goes through PresetMerge.MergeNestedInput, then the preset fill (fix(cli): fill nested keys of a partial object from an intent's object preset #82) restores the discriminator.
  • Runtime (flags.go.stmpl, preset.go.stmpl): MergeInputIntoBody delegates to a path-based MergeInputIntoBodyPath (top-level behaviour and error text unchanged). MergeNestedInput refuses a caller object that names another union member (--width applies when image_format.kind is "png", but --body sets "svg"), a non-object parent, and a field the caller already set.
  • Fixture / docs: render --width ($.image_format.width) in the primary overlay; README bullet; changeset. Review SDK (zSDKs/sdk-cli) regenerated: internal/flagutil/{flags,preset}.go only.
$ cli render "x" --width 10 --dry-run
  "image_format": {"kind": "png", "width": 10}

Testing

  • go test ./internal/extensions/ — new TestCLICommands_NestedPresetBinds, TestCLICommands_NestedPresetBindErrors (10 cases, including parent/nested bind overlap in both declaration orders and with a positional parent), TestCLICommands_NestedBindRejectedOnRouteDispatch; parser path cases updated; operation-declared nested flag rejection in TestCLICommands_OperationDeclarationsAreStrict.
  • TARGET=review make test-cli — 609 tests, 2 skipped, 0 failures.
  • TARGET=primary make test-cli — 1687 tests, 0 failures. New TestIntentNestedFlagIntoPipedBody (a nested flag merged into a body piped on stdin gets the preset discriminator filled; a field the piped body already sets is a conflict naming stdin) and new cases in TestIntentPresetFromArgs and TestIntentPartialBodyKeepsPresets (written into the preset; merged into a partial body; conflict; other member; non-object parent). Run with agent-mode environment variables unset: with them set, agent mode disables interactive forms and the four TestConfigure* tests fail independently of this change.
  • make check-template-cli, gofmt, prettier, git diff --check.

Public-safety check

  • This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
  • Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (.claude/skills/public-repo-communication/SKILL.md).
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.

Summary by cubic

An intent command flag can now bind a field one level beneath an object preset (--width for $.format.width under preset: {$.format: {kind: png}}), and the preset fills the missing keys of a partial --body/--body-param object instead of replacing it whole and dropping the discriminator.

  • The linker resolves the field in the property's object or in the union member the preset selects, inferring type, enum suggestions, and preset default.
  • Without a body, the value writes into the preset object; with a body, it merges into the caller's object, refused when the caller object names another member, is a non-object, or already sets the field, from --body, --body-param, or stdin.
  • Rejects a parent without an object preset, a union member the preset does not uniquely identify, the union's discriminator (including for a union with a single object member), positional args, route-dispatch commands, operation-declared flags, a parent restricted to const/enum values, a readOnly field, and input binding both an object and a field beneath it; deeper paths stay decode errors.
  • Preset coverage checks own keys, so a key like toString or constructor no longer counts as preset-covered.

Written for commit 694004c. Summary will update on new commits.

View guided diff Turn on auto-fix

2ynn added 2 commits October 7, 2026 09:38
…t preset

An intent's preset was merged into a caller-supplied --body/--body-param per
top-level key only, so a partial object for a preset key (e.g. setting one
option of a union-typed property) replaced the preset object whole and
dropped the union discriminator the preset pinned.

Object presets now fill the missing keys of the caller's object at the merge
points the schema proves safe; caller keys always win. The linker walks each
object preset alongside its schema (named properties, map entries through
additionalProperties, and properties declared beside a union) and records a
merge point per JSON pointer: plain objects and map entries unconditionally,
and union members only when the preset identifies exactly one member through
the union's declared discriminator, or a const/single-enum key whose preset
value no other member accepts, with explicit and implicit mapping aliases.
At runtime a caller object is filled only at a merge point and, on a union,
only while its discriminator is absent or selects the preset's member.
Elsewhere the caller's object is kept whole, so union members are not mixed.
…nd composed maps

- Keep union members that can still accept an object (no type, or an open
  object) when flattening, so a remaining constrained member is not treated
  as the only candidate.
- In anyOf unions, a member that does not declare the candidate
  discriminator and is not closed also accepts the preset's value, so the
  const-inferred discriminator no longer identifies a member there. oneOf
  keeps the inference: an object matching a const member cannot also be
  valid for a catch-all member.
- Scope discriminator mappings to their propertyName and treat a value that
  layers map to different members as ambiguous, instead of letting the last
  layer win.
- Read additionalProperties schemas through allOf layers, so preset objects
  inside composed maps get merge points.
- Compare discriminator values with exact JSON numbers, so distinct large
  integers no longer collide through float64.
@2ynn
2ynn force-pushed the fix/cli/intent-preset-nested-object-dropped branch from a4bea69 to 598a809 Compare October 7, 2026 13:38
@2ynn
2ynn force-pushed the feat/cli/intent-nested-preset-binds branch 3 times, most recently from b7dd612 to 1f315c5 Compare October 7, 2026 14:22
2ynn added 2 commits October 7, 2026 10:51
… accept it

A merge point inferred from a const discriminator was emitted when another
member could still accept the caller's object, so preset fields from one
member could be filled into an object valid only for another.

- A member that does not declare the inferred key now counts as a possible
  match unless it is closed or places no constraint on an object at all (a
  catch-all, which no fill can invalidate); this replaces the oneOf/anyOf
  distinction, which also misread a oneOf composed through allOf.
- A property declared both beside a union and on one of its members is the
  conjunction of the two, so a member's broad redeclaration no longer hides
  an inherited nested union.
- Members without a type whose const or enum lists an object are kept as
  object members instead of being dropped.
An intent flag could only bind a top-level body field, so a field of an
object the command presets (one option of a union-typed property the preset
pins to a member) had no flag and was reachable only through --body.

A flag's `to:` may now name a field one level beneath an object preset
(`$.format.width` under `preset: {$.format: {kind: png}}`). The linker
resolves the field in the property's object, or in the union member the
preset selects, and infers its type, enum suggestions and any preset default.
It rejects a parent without an object preset, a union member the preset does
not uniquely identify, the union's discriminator, positional args, route
dispatch commands and operation-declared flags; deeper paths stay decode
errors.

Without a body the value is written into the preset object. With --body or
--body-param it is merged into the caller's object, created when absent, and
the preset then fills the discriminator. A caller object naming another
member, a non-object parent, and a field the caller already set are usage
errors.
@2ynn
2ynn force-pushed the feat/cli/intent-nested-preset-binds branch from 1f315c5 to 1ee3e9b Compare October 7, 2026 15:05
2ynn added 5 commits October 7, 2026 11:14
…to const/enum values

An object restricted to const or enum values must equal one of them, so
filling preset keys into a caller's object there can only produce a value
matching none: preset {kind: png} filled into a caller's {preset: vector}
under enum [{kind: png}, {preset: vector}] sent {preset: vector, kind: png}.
Such objects, untyped or object-typed, standalone or as a union member, or
under a union whose layer declares the restriction, no longer get a merge
point.

The union-through-allOf fixture now uses anyOf: a oneOf with a member that
accepts every object cannot validate any object the pinned member accepts.
# Conflicts:
#	templates/templates/cli/README.md
…onst/enum values

A field written by a flag into an object the schema restricts to const or
enum values makes the object match none of them, so the linker now rejects
such a bind instead of resolving the field.
…object

A member typed as a list that includes object (e.g. type: [object, "null"])
with no other constraint accepts every object just like type: object, but
was not recognized as a catch-all, so an otherwise identified preset member
lost its merge point.
@2ynn
2ynn marked this pull request as ready for review October 7, 2026 16:42
@2ynn
2ynn requested a review from a team as a code owner October 7, 2026 16:42
@2ynn
2ynn requested review from ThomasRooney and removed request for a team October 7, 2026 16:42

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 14 files

Shadow auto-approve: would not auto-approve because issues were found.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread templates/templates/cli/includes/intents.ts Outdated
Comment thread templates/templates/cli/auxiliary/internal/flagutil/preset.go.stmpl
Comment thread internal/extensions/cli_commands_link.go
… own properties

- A flag bound beneath an object preset to a readOnly field is now a
  decode error, as a preset targeting a readOnly property already is.
- Preset coverage checks used `in`, so a key named like an Object.prototype
  member (toString, constructor) counted as preset and a required input
  was treated as covered; they now check own properties.

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 4 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would require human review. Adds a user-facing CLI feature that relaxes the previous one-level bind limit (nested intent flags under object presets), a product/contract decision; the dismissed stdin-body preset bug is only deferred to a separate #89 not visible in this diff.

View guided diff | Turn on auto-fix | Re-trigger cubic

Base automatically changed from fix/cli/intent-preset-nested-object-dropped to main October 7, 2026 18:11

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 14 files

Shadow auto-approve: would not auto-approve because issues were found.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread internal/extensions/cli_commands_link.go
Comment thread internal/extensions/cli_commands_link.go
…on has one object member

A union with a single object member (the others non-object) skipped the
pinned-discriminator check, so a flag could overwrite the declared
discriminator the preset pins and send an object matching no member.

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would require human review. Adds a user-facing CLI feature: nested intent flags under object presets are now accepted and rendered, changing the manifest contract and body/stdin merge rules; the chosen scope and the dismissed {} union-arm edge case need human sign-off.

View guided diff | Turn on auto-fix | Re-trigger cubic

@2ynn
2ynn added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 9278dff Oct 7, 2026
658 of 664 checks passed
@2ynn
2ynn deleted the feat/cli/intent-nested-preset-binds branch October 7, 2026 19:52
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants