Skip to content

Survive specs whose operation and parameter names collide - #31

Merged
lucianfialho merged 1 commit into
mainfrom
feat/real-world-spec-compat
Aug 3, 2026
Merged

lucianfialho merged 1 commit into
mainfrom
feat/real-world-spec-compat

Conversation

@lucianfialho

Copy link
Copy Markdown
Owner

Reworked from #25 by @kevin-krause. Credit is his; the rebase and the extra tests are mine.

The bug

A spec with two operationIds that simplify to the same command name did not degrade — it killed the CLI. Commander rejects the duplicate while the command tree is being built, before any command runs:

$ spec2cli --spec api.json items --help
Error: cannot add command 'search' as already have command 'search'

searchItems and searchItem under one tag is enough. Every invocation fails, --help included, so there is no way to even discover what went wrong. Closes #23.

Colliding names are now disambiguated by HTTP method (search-get, search-post), then by a numeric suffix for whatever still overlaps.

Two more shapes real specs use

Parameters sharing a name across locations produced two flags of the same name, which Commander rejects the same way. The existing guard only caught body fields clashing with parameters, so id in the path plus id in the query still got through. Collisions are now tracked by name as well as by location, first declaration winning.

Parameter names are not constrained to what a flag can be called. filter[name] became an unusable option. It is now offered as --filter-name, while the request still goes out under the spec's own name — verified on the wire.

Also

JSON Pointer escapes are decoded when resolving refs. ~1 is the only way to write / inside a pointer segment (RFC 6901), so any ref into paths — #/paths/~1pets/parameters/0 — silently missed and fell back to an unresolved $ref. Ref resolution also refuses to walk into a non-object rather than yielding a partial.

What I left out of #25, and why

That PR also extracted URL and request building out of http.ts and dry-run.ts — the same de-duplication #29 does, three months earlier. Keeping both would conflict for no gain, so this branch carries only the parts that are not otherwise covered:

On the delay

The PR sat from 21 May. The author's own comment saying it needed cleanup was posted the same day and was already stale — the branch is a single clean commit with no stray files. Meanwhile part of it was reimplemented on main independently and worse. That is on the review side, not his.

Testing

137 tests, up from 123. The new tests were checked against main first: 5 fail there (the command collision crash, three-way collision, flag sanitization, JSON Pointer escaping, and cross-location parameter collision). The other 4 pass on main and are kept as guards — they document behaviour rather than fix it, and the commit message says so.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QpcUFZcRHNurdJ2diVg37T

A spec with two operationIds that simplify to the same command name did not
degrade — it killed the CLI. Commander rejects a duplicate while the command
tree is being built, before any command runs, so `searchItems` and `searchItem`
under one tag made every invocation fail, `--help` included:

  $ spec2cli --spec api.json items --help
  Error: cannot add command 'search' as already have command 'search'

Colliding command names are now disambiguated by HTTP method, then by a numeric
suffix for whatever still overlaps. Closes #23.

Two more shapes that real specs use and this one mishandled:

Parameters sharing a name across locations produced two flags of the same name,
which Commander rejects the same way. The existing guard only caught body fields
clashing with parameters, so `id` in the path plus `id` in the query still got
through. Collisions are now tracked by name as well as by location, first
declaration winning.

Parameter names are not constrained to what a flag can be called. `filter[name]`
became an unusable option; it is now offered as `--filter-name` while the
request still goes out under the spec's own name.

Also decodes JSON Pointer escapes when resolving refs. `~1` is the only way to
write `/` inside a pointer segment (RFC 6901), so any ref into `paths` — such as
`#/paths/~1pets/parameters/0` — silently missed and fell back to an unresolved
`$ref`. Ref resolution now also refuses to walk into a non-object rather than
yielding a partial.

Reworked from #25 by @kevin-krause, rebased onto main after the original went
stale. The refactor half of that PR is left out: main has since grown its own
parameter-ref resolution, and the request-building extraction is handled
separately. Its remaining URL fragment fix is ported alongside that work.

Co-authored-by: Kevin Krause <kevin-krause@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpcUFZcRHNurdJ2diVg37T
@lucianfialho
lucianfialho merged commit c40f56b into main Aug 3, 2026
1 check passed
@lucianfialho
lucianfialho deleted the feat/real-world-spec-compat branch August 3, 2026 00:09
lucianfialho added a commit that referenced this pull request Aug 3, 2026
Brings in the name-collision work from #31 and the conditional spec cache from
#30. Three files needed a decision:

`dynamic-commands.ts` auto-merged into something that compiled but was wrong.
#31 made Commander store options under a sanitized flag name; this branch reads
required params back to decide whether any are missing. Merged naively those
cross: a required `filter[name]` is stored as `filterName`, so the missing-input
check looked it up under the spec's name and reported it absent no matter what
the caller passed. Both readers now go through optionValueForParam.

`flags.ts` — both sides added a boolean flag (`--reveal` here, `--refresh` on
main); kept both, over the extracted VALUE_FLAGS set this branch introduced.

`README.md` — both sides documented different sections; kept both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpcUFZcRHNurdJ2diVg37T
lucianfialho added a commit that referenced this pull request Aug 3, 2026
`privacy scan` reports the fields the filter would touch, grouped by schema, and
which operations return them:

  $ spec2cli privacy scan ./api.yaml
  4 fields would be redacted:
    Customer
      email, full_name, phone, cpf
  Returned by:
    GET /customers

Turning the filter on without being able to see its reach was a bad trade in
both directions: too little and personal data goes out anyway, too much and a
field the caller needed comes back redacted with no hint why. Detection is by
name and format, so an unusually named field is missed — which is the reason to
be able to look. Part of #22; the runtime filtering half already shipped.

Array parameters given as JSON were sent as raw strings. The extractor names an
array after its items — `string[]`, not `array` — so the check for structured
types never matched and `["a","b"]` reached the server quoted.

The dead commander-builder module is gone. It had not been on the runtime path
for some time, duplicated command building, and had already drifted: #31 changed
it while the live path moved on. That is the same shape of divergence that let
--dry-run print credentials in the clear.

Its JSON-parameter tests are replaced against the live path, and doing so is what
surfaced the array bug: the old ones hand-built their Operation objects with
`type: "array"`, a value the extractor never produces, so they passed while the
behaviour they claimed to cover was broken.


Claude-Session: https://claude.ai/code/session_01QpcUFZcRHNurdJ2diVg37T

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

feat: auto-clean command names when operationId uses route paths (e.g. HubSpot)

1 participant