#9

fix(naming): guarantee unique command names for colliding operations- #5

Openpcomanswants to merge intofrom

This change fixes vercel-labs/specli#7. specli exec crashes with the error cannot add command 'list-priorities' when an OpenAPI specification (spec) defines more than one collection GET operation under a single tag. The Atlassian Jira Cloud REST API v3 spec triggers this crash, which makes it unusable with specli.

After this change, you can run specli exec against specs that define multiple collection GET operations under one tag.

What causes the crash

planOperations renames operations when their resource:action pair repeats, but it doesn't check whether the renamed actions are themselves unique. In the reproduction case from the issue, two operationId values reduce to the same disambiguator:

  • getPriorities loses its get- prefix, becomes priorities, and produces list-priorities.
  • searchPriorities loses its search- prefix, becomes priorities, and produces list-priorities.

Commander throws an error when it registers the second command. The code that resolves collisions creates a collision of its own.

How the fix works

Disambiguation now generates a list of candidate names and selects the first name that no other command claims:

  • disambiguationCandidates replaces deriveDisambiguatedAction. It returns the candidate names, which are the name derived from the operationId followed by a path segment, instead of selecting one name without checking it.
  • pickUnclaimedAction checks each candidate against the set of claimed resource:action keys. When every candidate is taken, it appends a numeric suffix.
  • planOperations adds the already-unique names to the claimed set before disambiguation begins, so a renamed command can't take a name that another command already uses. It adds each new name to the set as it assigns it.

Uniqueness is now a property of the algorithm rather than an assumption. The numeric suffix replaces the per-collision-group counter.

The change affects one file and about 40 lines. The path-segment loop changes only from return to push and break, because the function now collects candidates instead of returning them one at a time.

Validation against the Jira Cloud v3 spec

The live spec from developer.atlassian.com contains 617 operations across 2.4 MB. Before this change, specli exec crashes in addGeneratedCommands. After this change, the spec loads and every command registers.

CheckResult
Operations in the spec617
Commands in the model617, so the fix drops no operations
Distinct operation keys617, so no command shadows another
Duplicate resource action names0, down from four names across eight operations

Of the 414 operations that require disambiguation, 412 receive a name derived from the operationId or the path, and two fall back to a numeric suffix.

Naming changes on this spec

Four names collided before this change. The fix renames those four operations and no others. In each pair, the first operation keeps its name and the second operation receives a new name.

OperationoperationIdName beforeName after
GET /prioritygetPrioritiesissue-priorities list-prioritiesUnchanged
GET /priority/searchsearchPrioritiesissue-priorities list-priorities, a duplicateissue-priorities list-search
GET /resolutiongetResolutionsissue-resolutions list-resolutionsUnchanged
GET /resolution/searchsearchResolutionsissue-resolutions list-resolutions, a duplicateissue-resolutions list-search
GET /issue/{issueIdOrKey}/workloggetIssueWorklogissue-worklogs get-worklogUnchanged
GET /issue/{issueIdOrKey}/worklog/{id}getWorklogissue-worklogs get-worklog, a duplicateissue-worklogs get-1
GET /project/{projectIdOrKey}/rolegetProjectRolesproject-roles get-roleUnchanged
GET /project/{projectIdOrKey}/role/{id}getProjectRoleproject-roles get-role, a duplicateproject-roles get-1

The two /search operations receive descriptive names. The other two operations receive get-1, because their paths follow the /{parent}/{child}/{id} pattern, in which every segment that isn't a parameter already matches the resource name, and their operationId values contain no additional information. These two names are unique and correct, but they don't describe the operation. For a possible improvement, see Follow-up work.

Specs without collisions

The same comparison against other published specs produces no naming changes:

SpecOperationsNames changed
Box2960
Swagger Petstore v3190

The Jira comparison also shows no renumbering. Wherever the previous counter produced a valid name, the numeric suffix produces the same name.

Two other specs remain unevaluated. The DigitalOcean spec spans multiple files with relative $ref values, so it doesn't resolve on its own. The Stripe spec, at 8 MB, didn't finish dereferencing within nine minutes in the test environment.

Behavior

The eight rows in the preceding table describe every naming change. The existing test expectations, which are get-1, create-v13, get-events, and get-profile, remain unchanged.

The reproduction case from the issue now produces two working commands:

$ specli exec ./repro.json issue-priorities list-priorities --curl
curl -sS -X GET 'https://example.com/priority'

$ specli exec ./repro.json issue-priorities list-search --curl
curl -sS -X GET 'https://example.com/priority/search'

Test results

naming.test.ts gains two regression tests: one for two operations whose operationId values reduce to the same disambiguator, and one for a renamed command that collides with an unrelated command.

  • bun test: 135 tests pass, and none fail.
  • bun run lint: no errors.
  • bun run typecheck: no errors.

Follow-up work

The following items fall outside the scope of this change:

  1. Version segments produce unhelpful names. The path fallback selects the last segment that doesn't match the resource, so a path such as /rest/api/3/dashboard/{id} produces a name such as dashboards get-3. This behavior predates this change. Skipping segments that contain only a version number would improve about 15 names in the Jira spec.
  2. The typecheck script writes build output. The script runs tsgo without --noEmit, so it creates dist/. As a result, biome ci checks 224 files instead of 82, and bun test runs each test twice: once from src and once from the compiled copy.
  3. Seven tests depend on network access. These tests call petstore3.swagger.io and httpbin.org, so they time out in environments that restrict outbound requests.