feat!: derive pro command names from the spec, and nest independently-writable sub-paths - #377
Conversation
A resource's name came from the filename its paths were split into — upstream's
jss module names, which appear in no spec. Four things followed from that
filename with nothing stating them: the command name, the endpoint-version
family (keyed on a `-vN` suffix), whether a `-preview` tag reached a command,
and whether the splitter could delete the file.
The ingest was idempotent but not reproducible: wiping specs/*.yaml and
re-ingesting the same live monolith gave 127 files instead of 165, 132 resources
instead of 169, 73 operations dropped, 98 command groups renamed and 18
`-preview` tags published as commands — at exit 0.
The tag decides what belongs together, the path decides where the boundary
falls, and the name comes from the path. Neither signal works alone, and both
failure modes were measured on the live 11.31.1 monolith (550 paths, 820 ops):
paths alone split a resource upstream considers whole (19 splits — computer
inventory is three roots and one resource), while tags alone are too coarse to
be a command (the computer-inventory tag carries 56 operations, and
computer-groups carries two complete CRUD sets whose {id} paths both want
get/update/delete, which disambiguateSameTerminalOps cannot separate). Tags are
sound rather than convenient here: no path in the document carries two tags.
Naming from the path keeps the biggest resources on the names they ship under —
computers-inventory absorbs computer-smart-groups, erase-device-computers and
remove-computer-mdm-profiles without being renamed, so the `pro computers` alias
is untouched.
Version consolidation stops reading a filename suffix. Every version of a path
lands in one resource and deduplicateVersionedOps picks the highest per path
shape, so the mis-keying that cost this CLI its v4 computer-inventory endpoints
cannot be expressed.
specs/ becomes one normalised document plus the App Installer subtree no
monolith carries. Split's routing is gone — the layout scan, the tag-derived
filename fallback, shared/exclusive component partitioning, _MonolithLibrary and
PreservedSpecs. The $ref closure moves into the parser, where it belongs: it
exists so detectNameField and detectIDField see one resource's fields, not so a
file can be self-contained.
TestParseMonolith_LosesNoEndpoint is the guard, against a committed snapshot of
every endpoint the 165-file layout reached. A snapshot because the parse it
describes no longer exists — a redesign that removes its own baseline has to
carry it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
165 per-resource files become JamfProAPI.yaml (1.58 MB) plus AppInstallers.yaml, which no consolidated /api/schema/ document carries — App Installers sits under hiddenapi/ in jamf/jss, so the gateway's published Pro API spec is the only thing that describes it. Nothing is lost. Verified against the live 11.31.1 monolith: the only paths the committed tree held that the document does not are the 17 App Installer ones, which is exactly what PreservedSpecs existed to protect and what the manifest's `source` field now covers. Written as sorted, deterministic YAML rather than as the server sent it. That is the one thing the 165-file layout was genuinely good for: a 2 MB document on one line makes every ingest an unreviewable diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mes key on
Mechanical consequence of the parser change: 87 renames, 24 merges and 13
splits, so every table keyed on a resource name moves with them.
groups.go keeps its 62 comment lines — the reasoning about why a resource sits
in a group is the content — so this is a key rename, not a regenerated map, with
each split-off resource inserted beside the sibling it came from. Two merges
spanned two groups and needed a decision: `enrollment` takes groupEnrollment,
and `user` takes groupAdminAccounts because /v1/user/change-password and
/v1/user/preferences are the *calling* account's own, which is exactly the line
groupUsers' own comment draws.
pro.go's suppressions used to name six standalone resources — what a per-file
layout produced, where /v1/computer-inventory/{id}/erase sat in its own file and
became `pro erase-device-computers`. Grouping files each action under the
collection it acts on, so two are now whole-resource removals and three are
single subcommands. `computers` is no longer suppressed: that path is dropped at
ingest, and the name belongs to POST /v1/computers/{id}/recalculate-smart-groups,
which has no handwritten counterpart.
Four aliases collided with a derived name. `computers` and `smart-computer-groups`
are resolved by merging the derived group into the resource the alias names —
the collision was pointing at a real one, since /v1/mobile-devices/{id}/
recalculate-smart-groups and /v1/users/{id}/recalculate-smart-groups already land
inside their own resources and only the computer one was stranded. `ddm` is
renamed to ddm-clients, because `pro ddm` is an established alias for the platform
ddm-reports command that a real subcommand would shadow. `jamf-connect`'s alias
and target became the same name, so the entry is dropped.
Not done, and tracked in docs/spec-ingest-handover.md: the deprecation aliases
for the old names, and 10 failing tests — 6 of them because gateway.successors
emptied when static-computer-groups folded into the v3-served command, and
nothing among the 67 refused commands has a gateway-served replacement to take
its place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…working
The tag is the section heading the API reference publishes, so a command named
after it is one a reader can look up. The path is a worse authority on the noun
than it appears: upstream serves the same resource at
/v1/computer-inventory/{id}/erase and /v4/computers-inventory, so singular
versus plural is an accident of which path you land on, where the tag is a
deliberate choice. `pro computer-inventory` now matches the reference.
Measured on the live 11.31.1 monolith against the 163 resources before:
resources unchanged renamed merges splits
tag names 134 53 44 22 13
path names 136 60 40 24 13
Seven fewer names survive and four more move, which the aliases absorb. What is
not a trade is the override table: naming from paths needed eight entries and
seven of them were names I invented — a `pki-` prefix the reference does not
use, `ldap-lookups`, `ddm-clients`, `certificate-authorities`,
`team-viewer-remote-administrations`. Each was a guess at what a resource should
be called, and the tag already said. One entry survives: `policies-preview`
strips to `policies`, and there is no modern policy API at all — no path in the
document contains /policies — so `pro policies` would hold two settings fields
next to the real Classic surface. It stays `policy-properties`.
A tag covering several groups cannot name them all, and 14 do. The primary
group — the root that prefixes its siblings, else the one with most paths —
takes the bare tag and each sibling appends the segments that distinguish it,
which reproduces the names those siblings already have
(computer-groups-smart-groups, enrollment-languages).
Every former name is a cobra alias on its replacement, warning on use with the
name to move to and the date it stops working. Three whose endpoints are no
longer ingested refuse with an explanation instead, because "usage, exit 2"
tells a caller they typed something wrong when the endpoint is gone. The
warning is not silenced by --quiet or --no-hints, on the same reasoning as
JAMF_CLI_ALLOW_UNPUBLISHED's: a workflow depending on something transitional
should say so on every run.
The aliases expire on 2027-03-09 and TestDeprecatedNamesHaveNotExpired fails the
build once it passes, naming every entry to delete. A comment saying "remove
after March" is how dead code lives for years.
Reading a parent's alias is not possible through cobra's API — it records the
matched name on every command it traverses but flips `called` only on the
executed leaf — so the warning reads the resource token out of argv, which is
exact for every ordinary invocation.
Three guards, all of which caught real bugs here rather than being written after
the fact: TestEveryFormerResourceNameStillResolves (163 former names: 71 live,
102 aliased, 3 withdrawn — nothing orphaned), TestDeprecatedNamesPointAtCommands
ThatShip (caught pro.go still removing `computer-inventory` from when that name
meant the stray erase/remove-mdm pair, which would have deleted `pro comp
list`), and TestProWiringNamesCommandsThatShip (addSubcommand and friends no-op
silently when a parent is renamed, which had happened four times).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tag naming and the alias layer landed since it was written, the override table shrank from 28 entries to 21, and the cobra note about CalledAs was wrong — it reads empty for anything but the executed leaf, which is why the deprecation warning reads argv instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both named pre-rename forms — `pro team-viewer-remote-administrations` and `pro computers-inventory redeploy-framework`. A withdrawal message that confidently names a command which does not exist is worse than the bare "unknown command" it replaced. Prose is what a rename does not update, so TestWithdrawnNameMessagesNameCommandsThatShip resolves every command name quoted in those messages against the assembled tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The failure split was backwards (7 successors, 6 stale, not 6 and 7), the alias count was written before the last override change, and two guards added after the doc was written were missing from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six tests referenced a resource name that moved or a count that shifted when `pro` command names started coming from the spec. `pro computers-inventory` is `pro computer-inventory` in TestApplyAliases, the three TestCollectCommands cases, and bodyFileUploadLeaves — which also keyed `pro enrollment-customizations-images upload`, now singular. unmatchedExampleLeaves goes 21 → 20. The one that dropped is `pro computer-groups get`, which sat in the second of two identical computer-groups subtrees because the generated registry called NewComputerGroupsCmd twice — `pro --help` printed the row twice and Find resolved every path beneath it to the first copy. Resource identity comes from the spec's paths and tags now rather than from two filenames naming one resource, so there is one subtree and the example resolves to its own leaf. The comment records that rather than the duplicate it described. TestChainSkip_RootOnlyNamesDoNotSkipNestedCommands was passing, but only because `pro mdm-commands commands` resolves through a deprecation alias. It names the live `pro mdm commands` now, so it asserts what it claims to and survives the alias expiry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`successors` held one entry, `pro static-computer-groups`, redirecting the withdrawn v2 computer-groups command at its v3 sibling — two commands only because a per-file spec layout named each version's file separately. Resource identity comes from the spec's paths and tags now, so both versions land in one resource, deduplicateVersionedOps keeps the v3 the gateway publishes, and there is no v2 command left to refuse or redirect. Wire-checked on an EU environment credential: `pro computer-groups-static-groups` list, create, `get --name` and delete all answer. The rename removed the need for the entry rather than invalidating it. All 59 refused commands the binary ships were re-checked for a replacement and none has one, so the table is empty on purpose. Seven tests asserted a non-empty one. internal/gateway takes this repo's test-local-entry idiom (cf. generator/gateway's probedUnserved tests), keyed on the deliberately synthetic `pro example-withdrawn` so the fixture cannot be mistaken for a live entry. TestEverySuccessorEntryRenders installs it too: it used to t.Skip on an empty table, and its SuccessorTable completeness check read as agreement at 0 == 0. internal/commands does not skip. A skip there re-opens the defect those tests were written for — gatewaySuccessor was computed, stored and never marshaled for a release with every test green. Two layers instead: Delegation, which holds at any table size. The refusal test asserts checkAPIMatch's message is byte-identical to gateway.Refusal, so a hand-rolled message cannot pass by containing the same phrases. The catalog test recomputes gateway.Successor(binary + " " + command) per refused entry and demands agreement, the binary-name half being exactly what broke before. The JSON test drives commandEntriesToMaps with two hand-built rows, that function being pure, pinning both directions of the positive-only key with no live value. An installed fixture for the four renderers whose successor path is otherwise dead code — deleting it changes nothing observable for any input the shipped tree produces. gateway.InstallSuccessorForTest takes *testing.T as the narrowest interface satisfying it rather than importing testing into a production package, which is also what keeps it out of production use: nothing outside a test has a value to pass. It refuses to shadow a live entry. TestEveryCommandEntryFieldReachesTheCatalog is the one straight improvement. It swept the shipped tree, so it reported gatewaySuccessor unprojected when the table emptied — failing on absent live data rather than on a defect. It runs over one reflectively-populated commandEntry now, which answers the objection its own comment raised against a fixture (a hand-written literal is a list to keep in step with the struct) and covers every field rather than the ones the tree happens to carry. An unhandled field kind fails rather than being skipped. Mutation-checked: dropping the map copy, returning "" from gatewaySuccessorOf, handing it the binary-less prefix, dropping successorHelp from either help renderer, composing the refusal by hand, and removing the successor block from gateway.Refusal each fail at least one test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ehind
Twelve tables in generator/parser are consulted as `table[r.Name]`, so a key
that names no resource is not an error — it is a lookup that misses and the
generator falls back to what it would have done with no override. Nothing
reports it: `make generate` exits 0, every command still ships, and the only
symptom is at the wire. Spec-derived naming renamed the resources underneath
thirteen keys across eight tables, and six user-visible defects followed.
Reported: `pro packages delete --name "…"` answered
`400 INVALID_FIELD Cannot filter by field [name]`, the endpoint naming
packageName among the fields it does accept.
The rest, found by diffing every resolveNameToID call site against main:
- computer-inventory lost `general.name`, `--serial`, `--udid`, its table
columns, its default --section set and its detail-path pin. The detector
answered `displayName`, off a nested configuration-profile schema.
- app-installers-titles lost `titleName`. That override exists because the
published spec marks the field readOnly, and the titles collection declares
no filter parameter at all, so the field name is the whole match — all 363
titles reported "no resource found".
- inventory-preload-records lost `serialNumber`.
- volume-purchasing-locations and device-enrollments lost `--token-file`, so
the only route for a VPP service token or a DEP .p7m was to paste it into a
JSON body — a credential this CLI exists to keep out of argv.
TestEveryResourceKeyedOverrideNamesALiveResource is the guard that was missing;
a rename is exactly when a name-keyed table needs re-keying and exactly when
nobody thinks to. TestResourceKeyedOverrideListIsComplete keeps its hand list
honest, since adding a table would otherwise be enough to opt out.
Two resources needed more than a re-key. /v1/packages and
/v2/jamf-remote-assist/session were reading their name field off the shared
`ExportField{fieldName}` body that every `/export` action references: one
components map means the per-resource $ref closure now reaches it, where 165
per-file specs did not. detectNameField gets a narrower closure —
representationSchemas, which skips `x-action` payloads — because an action's
body is a command and its fields are arguments, so letting them compete to be
the resource's name field is a category error. resourceNameFieldOverrides'
own comment already recorded the same defect for the mdm command log picking up
`userName` from DeleteUserCommand.
A rule reading only the schema map cannot separate these: `ExportField` and
`AccountUser` are both a schema named after their field's prefix, and
/v1/accounts' `username` is correct. One keyed on the resource name gets
/v1/accounts wrong, which is why it is not that either — pinned in
TestNameFieldIgnoresActionPayloads so it is not reached for again.
detectIDField keeps the full closure: it matches against the get operation's
path parameter, which an action body legitimately carries.
Every resolveNameToID call site in the generated tree now agrees with main.
Wire-verified on an EU environment credential: `pro packages get --name
Homebrew.pkg` returns id 108 and `delete --name` resolves the right id.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three passes in generator/parser encoded "the resource is one path root", which
was true while a resource's identity came from the filename its paths were split
into and is not true now that a tag can merge several roots into one resource.
Each shipped a differently-wrong command name, and `make generate` exited 0 for
all of them.
resolveNoParamConflicts renames a colliding no-param GET to its terminal path
segment, exempting only a path with a /{param} child. /v3/sso, /v2/sso/cert and
/v1/sso/failover used to be three resources with a clean get/update each; merged
into one, the rename fired on both sides and the primary endpoint lost its verb —
`pro sso-settings sso` beside `cert`, and `pro enrollment enrollment` beside
`language-codes`. 15 resources, 19 operations. The resource's own root is
canonical too now, marked from the group's declared root.
Deriving that root from the collision group instead got three cases wrong, and
each is pinned: `pro ldap list` returned LDAP *groups*, /v1/ldap/groups being the
shallowest colliding path with no GET /v1/ldap above it — a plain verb pointing
at one of three peers with nothing in the name saying which, which is worse than
the stutter it replaced. `pro certificate-authority list` returned the one active
CA for the same reason. And a group-derived root is group-dependent, so
`PUT /v3/sso` lost `update` the moment the singleton rename took its GET out of
the `list` group. The root is a fact about the resource, not about which
operations happen to collide.
reclassifyMisannotatedCreates required a collection root to be a single segment
after the version, so POST /v1/log-flushing/task and POST /v1/jcds/files kept
`task` and `files` while their siblings had a clean get/delete beneath /{id}. The
/{param}-child test beside it was always the real question. Same for
monolith.isActionPath, which stamped x-action on /v1/app-installers/titles and
/deployments — one segment below the subtree prefix, each with its own /{id} —
so their collection GETs were named `titles` and `deployments` rather than
`list`.
renameSingletonRootGet gave up entirely if *any* operation on the resource
carried a path parameter, which a merged resource always has: /v1/jamf-protect is
GET+PUT+DELETE on one path and the same resource carries
/v1/jamf-protect/deployments/{id}/tasks, so the singleton's read shipped as
`list`. Judged per path now, and restricted to the resource root — without that
restriction any GET+PUT sub-path took `get`, which made
/v2/local-admin-password/settings the resource's `get` and knocked
`pending-rotations` into `list`.
Checked against main endpoint by endpoint: 15 core verbs gained, 14 given up by a
sub-path to its resource root, none lost outright. Wire-verified on an EU
environment credential: `pro sso-settings get`, `sso-settings cert`,
`enrollment get`, `jamf-protect get`, `certificate-authority active`,
`ldap groups`, `app-installers get`, `app-installers-titles list`.
One rough edge left, recorded rather than fixed: a verb colliding with nothing
keeps the plain name even on a sub-path, so `pro sso-settings delete` is
DELETE /v2/sso/cert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…names
CHANGELOG.md had no entry for this branch at all — 163 resources to 135, 40
renamed, 24 merged, 13 split, 102 aliases expiring 2027-03-09 — which is exactly
what that file says it exists for ("which changes are breaking, what the
migration is, and why"). It also cited `pro computers-inventory upload` in its
own Unreleased section, a name this branch deprecated.
The sharp edge gets a table. An alias maps one resource name to one replacement,
so where a resource split, or where an operation's name derives from a path
segment that now sits under a different resource, the alias resolves and the
subcommand then does not exist. 36 invocations are in that position with no
migration route but the table; the endpoint is unchanged in every one, so only
the command name moved. Seven endpoints are no longer ingested, each with its
reason.
README.md, docs/GLOSSARY.md and skills/jamf-cli/SKILL.md taught names that now
work only through a warning-emitting alias: `pro jcds upload/download/sync/files`
(and `pro jcds upload` never existed at all — that is `pro packages upload`),
`static-computer-groups`, `smart-computer-groups`. Every replacement was checked
against the built binary.
CLAUDE.md had two claims the rename made false. The gateway-coverage section said
the surviving withdrawal refusals leave "only one … a command anyone can run:
`pro static-computer-groups`" — both versions are one resource now, the v3 wins,
and that name does not ship — and the refused count is 59, not 67. The
"Where to Make Changes" table gains the three mechanisms this work added:
the resource-name-keyed override guard, representationSchemas, and the no-param
root rule.
Left for the merge pass, as the handover schedules: CLAUDE.md still describes
DeduplicateVersioned, which is out of the pipeline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pro sso-settings delete` is DELETE /v2/sso/cert, and `download` and `parse` belong to the certificate as well — a tag that merges several path roots flattens an independently-writable sub-path into its parent, and a verb that collides with nothing keeps the plain name because no pass reaches it. The brief records the rule that identifies a sub-resource (PUT/PATCH/DELETE on the sub-path itself; a POST-only sub-path is an append or a command submission), measured over the committed specs: 9 sub-resources, and the 22 sub-paths that must not split, with no override table. It also records what makes this bigger than it reads: no generated Pro resource nests today, and `applyDeprecatedNames` redirects by appending a cobra alias to a direct child of `pro`, which cannot express `sso-settings-cert` -> `pro sso-settings cert`. Three options for that, in preference order, plus the six consumers keyed on (resource, op) that a nested one would silently miss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tag can cover several path roots and the grouping merges them, which is right
for the resource's identity and wrong for its verbs. An independently-writable
sub-path flattened into its parent produces a plain CRUD verb that silently
belongs to the sub-path, and because a lone verb collides with nothing, no
existing pass reached it: resolveNoParamConflicts renames a verb that *collides*.
Three of these shipped. `pro sso-settings delete` sent DELETE /v2/sso/cert and
deleted the SSO certificate rather than the configuration its name names, with
`download` and `parse` belonging to the certificate too.
`pro managed-software-updates-plans update` sent PUT on the plans feature toggle,
on a resource whose `list`, `get` and `create` are real plan CRUD — and
`pro managed-software-updates-plans apply` was composed out of it, so given the
name of an existing plan it resolved the name to an id and then PUT the plan
document at the feature toggle. `pro csa delete` deleted the CSA token.
The rule is read off the methods the spec declares: a sub-path carrying PUT,
PATCH or DELETE **on the sub-path itself** owns a separately-writable object and
becomes a Resource of its own; a POST-only sub-path is an append or a command
submission and stays flat. No override table. Over the committed document that
admits nine sub-paths and refuses every other one, all 18 GET+POST /history
pairs included — by the rule, not by a `history` special case, which is the right
outcome: if Jamf ever ships DELETE /x/history, splitting it is defensible.
One further condition, and it is load-bearing rather than taste: a sub-path
holding *every* operation in its group does not split. /v1/app-request/settings
and /v1/service-discovery-enrollment/well-known-settings are each the entire
resource, so there is no sibling verb for the plain one to be confused with and
`pro app-request settings get` would be a token of stutter resolving nothing.
A sub-resource is a *Resource and runs the same pass pipeline against its own
root, which is the point — that is what makes GET /v2/sso/cert come out as `get`
rather than `list`, and GET /v2/sso/cert/download as `download`. A hand-rolled
naming rule for sub-resources would have been a fourth place encoding "the
resource is one path root", the assumption this branch has already been bitten by
three times.
Three passes had to learn the same lesson again, each found by a rename it would
otherwise have shipped at exit 0:
- renameRootActionVerbs. inferOperationName reads a literal terminal segment as
an action's name, which is wrong when the segment *is* the resource:
GET/PUT /v1/app-installers/global-settings arrived as `global-settings` and
`update-global-settings`, and POST /v2/sso/cert as `cert`. Confined to a root
the API declares separately writable, which is the same test subResourceRoots
applies — and which is what keeps POST /v1/slasa and
POST /v2/patch-management-accept-disclaimer, the two endpoints
inferOperationName's own comment names as true actions, untouched. A 201 test
would have excluded both and also excluded POST /v2/sso/cert, which answers
200.
- renameLoneNonCanonicalList. Taking a sub-path out from under a colliding
sibling leaves the survivor alone, so nothing collides and a plain `list`
stays on an endpoint that is not the resource's collection: `pro csa list`
would have returned the tenant id. Scoped to a resource the split touched,
deliberately — six unrelated resources have the same shape (`pro m2m list`
returns a tenant id, `pro jamf-protect list` the Protect plans) and renaming
those is seven breaking renames this change is not. It also refuses a
terminal segment a sibling already answers to, so it cannot repurpose
`pro mdm commands` from issuing an MDM command to reading the command log.
- renameSingletonRootGet now accepts a same-path DELETE alongside a PUT, so
GET+DELETE /v1/csa/token reads as one object rather than a collection. Also
scoped to the split, for the same reason: /v1/cloud-distribution-point is the
same shape and has shipped as `list` for as long as the command has existed.
Resource gains Root, carried rather than re-derived. Every attempt to infer it
has been wrong in a different way — "the shallowest no-param path" answers
/inventory-preload/csv for a resource whose declared root is dropped and
/mdm/commands for `pro mdm`, and it produced a wrong answer in this change's own
guard before the field existed.
Zero endpoints gained or lost, verified against the committed baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The template emits one file per sub-resource through the **unchanged**
resourceTemplate, and the parent's constructor adds it. Reusing the template
rather than defining a nested-command block is what keeps a nested command's
flags, --scaffold, pagination, confirmation and gateway annotations identical to
the flat one's — deriving those a second time for a nesting level would be a
second implementation to keep in step.
Three identifiers are derived from the qualified name rather than the bare one,
each for a distinct reason: GoName because three sub-resources are called
`settings` and their constructors have to differ, FileBase because one file per
resource is what the stale-file prune assumes, and CmdPath because an example
naming only the last token documents a command that does not exist.
The example builders take CmdPath — except the two that build a *filename* out of
the resource's name, which is a distinction the first cut missed: CmdPath carries
a space, so `--from-file app-installers global-settings.json` read as a flag
value plus a stray positional and was refused by the leaf's own validator.
TestNoExampleDocumentsAnUndeclaredPositional caught all seven.
Every consumer keyed on the *endpoint* now reads AllOperations, and every
consumer keyed on the *resource name* keys on QualifiedName:
- modernGatewayOps. The one that matters most, and the failure mode is silent:
an unstamped operation carries no jamf:gateway annotation, checkAPIMatch
refuses only on that, so a nested command on a withdrawn endpoint would go
out to the bare 403 the pre-flight refusal exists to replace.
- The nine resource-name-keyed Apply* passes. No entry names one of the nine
affected parents today, so nothing needed re-keying — but a lookup that
misses is silent and falls back to a default, which is the failure that cost
this branch six user-visible defects.
- The smoke and backup registries. Backup drops seven list-only entries whose
`list` op was renamed away from `list`; every one pointed at a feature
toggle, a tenant id or a scheduler summary rather than a collection, and none
is in pro_resources.go's curated allowlist.
ApplyNameOverrides carries a renamed parent's children with it. No entry reaches
a resource with sub-resources today; it is there because a stale Parent makes
QualifiedName answer for a resource nothing ships, and nothing would report it.
Also fixes a pre-existing one-word defect in the apply recovery hint, which told
the caller to run `jamf-cli device-enrollments update` with no `pro`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine new commands, each a sub-path the API makes independently writable:
pro activation-code organization-name
pro app-installers global-settings
pro csa token
pro enrollment adue-session-token-settings
pro local-admin-password settings
pro managed-software-updates-plans feature-toggle
pro self-service settings
pro self-service-plus settings
pro sso-settings cert
Their verbs are plain again — `pro sso-settings cert get|create|update|delete`
plus `download` and `parse` — and the misdirected verbs are gone from the
parents. `pro sso-settings download` sends GET /v3/sso/metadata/download again,
which it did before the rename and stopped doing when the certificate's own
`download` won the collision.
Two things disappear rather than move.
`pro managed-software-updates-plans apply` is not regenerated, and it was
destructive rather than merely absent: /v1/managed-software-updates/plans
publishes a collection POST and a {id} GET and no update at all, so the only PUT
the resource carried was the feature toggle's, and `apply` composed it. Given the
name of an existing plan it resolved the name to an id and then PUT the plan
document at /v1/managed-software-updates/plans/feature-toggle. The diff of every
resolveNameToID call site between this tree and main shows that removal as the
only name-resolution change in the whole change.
`pro sso-settings delete` is gone rather than deleting the certificate.
Wire-verified on an EU environment credential: all nine nested reads answer,
`pro self-service-plus settings update` round-trips true then false, and
`-n pro sso-settings cert delete --yes` previews rather than executing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cobra alias is a name on one command, so it can only ever resolve to a direct
child of `pro`. Four retired resource names cover endpoints that are now a
nested sub-resource, and pointing them at the parent instead is not merely
imprecise:
- `pro sso-settings-cert get` would read the SSO configuration rather than its
certificate, and the same for the other two whose parent kept a `get`.
- `pro self-service-settings get` resolved **correctly** before the nesting and
would answer `unknown command "get"` after it. That is a working alias
regressing, which is what forces a real redirect rather than a table note.
So nestedAliases registers each old name as a hidden second instance of the
nested subtree, built by calling the generated constructor again. A second
instance rather than the same command under two parents, because AddCommand
reparents: registering one *cobra.Command twice leaves CommandPath, --help and
usage describing whichever registration came last. From the constructor rather
than a hand-written mirror, so the redirect cannot drift from what it redirects
to — a mirror is a list to keep in step, and the whole reason these names exist
is that nobody updates one.
All four are exact: each was one spec file in the 165-file layout, every one of
its operations moved into the sub-resource, and none stayed behind on the parent.
That is not a coincidence — it is the same partition arriving from the other
direction, and it is the strongest evidence the write-method rule picks the right
boundary.
The GroupID comes from proGroupMap, not from the parent command's GroupID:
applyDeprecatedNames runs before applyProGroups, so every parent's GroupID is
still "" at that point and copying it left all four stubs in "Additional
Commands" — the one listing a hidden compatibility stub must stay out of.
Governed by the same deprecatedNamesRemovedAfter date, and swept by the same
expiry guard: one change created both tables, so one date retires both.
Wire-verified on an EU environment credential — each of the four warns, names the
two-token replacement, and returns its own endpoint's body.
unmatchedExampleLeaves 20 -> 33. The thirteen are the leaves under these four
stubs, whose Examples correctly name the live path — a stub whose --help taught
its own dead name would be the defect. It moves here rather than with the guards,
because this commit is what makes it true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three guards, each for a failure that reports nothing on its own.
**TestSubResourcePartitionIsPinned** — the reproducibility guard. Which sub-paths
are independently writable decides how deep a command sits, so a spec drop that
adds a DELETE to a sub-path moves commands and `make generate` exits 0. Both
halves are asserted: the nine admitted, each with the endpoint that qualified it,
and the composition of what is refused — 18 GET+POST /history pairs plus five
named individually, because a rule is only pinned by what it refuses. The
/history count is asserted separately so a `history` special case creeping in
would have to move it.
TestSubResourceRoots covers the rule directly, including the two branches the
live document does not exercise (nested candidates, and a candidate the root sits
inside). TestSubResourceIdentityIsSelfConsistent pins Parent, GoName and
FileBase, each of which has a distinct failure mode and none of which is visible
from a command name.
**TestNoPlainVerbLeavesItsResource** — the test that would have caught
`pro sso-settings delete`. A plain CRUD verb must address its resource's own
root or a path beneath it; anything else is a verb whose noun is wrong. Written
against the whole shipped surface rather than the nine resources the split
touched, with an allowlist of four pre-existing cases that the split does not
address — each a tag merging two sibling path roots, which is a different shape
(a second collection root, or a path under {id}) needing a rule about
parameterised sub-paths and a judgement about which root is the resource. A stale
allowlist entry fails too.
**TestEveryFormerInvocationKeepsItsShape** — the sweep that found the six
override defects on this branch, committed with its baseline the way
endpoints-before-path-grouping.tsv already is.
Resolution is not the property; shape is, and two failure modes hide behind a
resolution check. cobra's Find falls back to the deepest command it matched, so
`pro sso-settings delete` "resolves" to `pro sso-settings` with `delete` left
over. And a leaf can become a **group**: `pro csas token` returned the CSA token
and now resolves to the `pro csa token` parent, which prints help and exits 0, so
a script piping it into jq gets usage text and no error. Three invocations did
that, and a resolution-only sweep called all three fine. `group` is decided by
HasSubCommands rather than Runnable, because guardUnknownSubcommands makes every
parent runnable so a typo earns a refusal — Runnable() is true for all 235 groups.
Do not probe by running a command with a bogus flag: cobra reports the flag error
before the unknown subcommand, which reported 0 broken when 61 were.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CHANGELOG.md gains three subsections and seven migration rows:
- The nesting itself: what it cost (three plain verbs pointing at a different
object), the derived rule, the nine sub-resources, the four redirects, and
the two silent meaning changes it repairs — `pro sso-settings download` sent
the SAML metadata before the rename and the SSO certificate after it, which
no test checking that a command *resolves* can see.
- The three paths that became command groups. The sharpest edge of the whole
rename: still resolves, still exits 0, prints usage where it returned data.
Not made to fail, because a parent printing help on exit 0 is this CLI's
convention everywhere; what changed is that these three used to be leaves.
- The three `apply` commands that are gone, and why none of them worked —
including the plans one, which given an existing plan's name replaced the
tenant's managed-software-update feature toggle with the plan document.
The migration table's count moves 36 -> 43 and its prose stops implying the
table is the complete set; the subsections after it cover the rest.
CLAUDE.md gains four rows and one paragraph, and one existing row is corrected:
it named `noParamRoots`, which no longer exists — the mechanism is
`isResourceRootPath` read against the carried `Resource.Root`, and the reason it
is carried rather than inferred is the point of the row.
docs/nested-subresources-handover.md is deleted, its work being done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ves open
The design brief left three decisions to whoever implemented it. Written down
where the rule is, because each is a place the rule could reasonably have gone
the other way and none of them is recoverable from the code.
- A sub-path with a deeper operation but no write on itself does not split, so
/v1/sso/failover stays flat and `generate` is another lone verb. Widening to
"any sub-path with a deeper operation" would nest eighteen /history appends,
the certificate authority's /der and /pem, and /mdm/commands, for no
correctness gain — the rule's justification is ownership, and having a
deeper operation is a fact about URL shape.
- A GET+POST sub-resource will not split and none exists; the partition guard
is what makes its arrival a decision rather than a silence.
- `csa` keeping only `tenant-id` beside the split-out `token` reads fine, and
better than `pro csa delete` deleting a token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline stopped calling it when a resource's identity moved from a filename to the spec's paths, and four CLAUDE.md paragraphs went on describing it as the mechanism — including the "Where to Make Changes" row a reader consults *first* when a resource ships the wrong API version. The earlier handover recorded this as a loose end for the CLAUDE.md pass and the pass missed it. What replaces it is stronger than a correction, so the paragraphs now say it: every version of an endpoint lands in one resource by construction and `deduplicateVersionedOps` picks the highest per version-stripped path shape, so there is no cross-resource pass and no filename suffix for one to key on. The class is inexpressible rather than guarded. Also records what is left over rather than quietly leaving it: the function is still in parser.go with its 17 test references and has **no non-test caller**, so `TestDeduplicateVersioned_*` and the inventory-preload reasoning in `resourceGetDetailPathOverrides` describe a mechanism that no longer runs. Deleting it is a decision, not a tidy-up, so it is named as unfinished business. Two pointers corrected with it: `gatewayOps` lives in generator/gateway/overrides_test.go, not gateway_coverage_test.go, and it derives its ops from the same parser.LoadDocuments entry point main.go uses rather than replaying a consolidation pass that no longer exists. docs/spec-ingest-handover.md is deleted, as its own header instructed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DeduplicateVersioned` picked between *resources* named after spec filenames,
keyed on the `-v-{N}s` suffix the parser derived from `ComputersInventoryV3.yaml`.
There are no such filenames any more: a resource's identity comes from the
spec's paths and tags, every version of an endpoint lands in one resource by
construction, and the surviving lowercase `deduplicateVersionedOps` picks the
highest per version-stripped path shape *inside* it. The pipeline stopped
calling the exported one when that landed, and it has had no non-test caller
since.
Kept dormant it was worse than absent: an exported entry point with four
passing tests, describing a rule about filename suffixes to anyone reading the
generator for how version consolidation works — and the two tests that exist to
pin the computer-inventory and inventory-preload shapes were pinning a function
that no longer runs, which is a guard that cannot fail on a real defect.
Goes with it: `resourceAPIVersion` and the `versionedName` regexp (its only
users), `TestDeduplicateVersioned` and its three siblings, and the `makeResource`
helper they alone used. `apiVersionRank` and `pluralize` stay — `compareAPIVersions`
and `ParseLoadedSpec` use them, and `ParseLoadedSpec` is live through the
platform parser.
Six comments across five files named it as the reason for something and now say
"cross-resource version pass" instead, so none of them points at a symbol that
does not exist. `deduplicateVersionedOps` and `TestDeduplicateVersionedOps*` are
untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ktn-jamf
left a comment
There was a problem hiding this comment.
Summary
This change replaces filename-derived pro command names with names derived from the spec's own OpenAPI tags and URL path roots. The reasoning is unusually good. The three core files each state the rule, the two measured failure modes, and the option that was rejected. The sub-resource split is derived from declared methods with no override table, and it repairs three real defects (pro sso-settings delete deleted the SSO certificate, pro csa delete deleted the CSA token, pro managed-software-updates-plans update wrote the feature toggle). The guards are written against defects the work actually hit, and I confirmed that each one runs against real data and is not vacuous: 704 endpoints, 1355 invocations, 9 admitted sub-paths.
One defect must be fixed before merge. The rule that decides which tag names a resource is not the rule the PR describes. Where one path root carries two tags, the name comes from whichever path sorts last. Three resources ship a wrong name as a result, and one of them is a write surface.
Findings
❌ CRITICAL (1) (correctness) — a path root shared by two tags is named by sort order, and pro smart-user-groups is the user CRUD
generator/parser/taggroup.go:106-122
rootOf (line 89) is computed per path from the whole document. It never reads the tag. resolveRootsWithinTag folds roots only inside one tag, so it cannot stop two tags from computing the same root string. The assembly loop then groups on root alone, and g.Tag = tagOf[p] (line 118) is overwritten on every iteration. Paths arrive sorted, so the surviving tag is the tag of the alphabetically last path. nameFromTags names the resource after that tag. Nothing detects the merge and nothing reports it.
Two statements in the code assert the opposite:
generator/parser/pathgroup.go:44— "Tag is the OpenAPI tag every path in the group carries".generator/parser/taggroup.go:33-35— "a path root never merges two tags".
I simulated the pass over the committed specs/JamfProAPI.yaml, with droppedTags and droppedPaths applied. Seven groups hold more than one tag. Three take a wrong name, and all three ship. Verified against the generated source and against the built binary:
| root | tag that wins | correct tag | shipped name |
|---|---|---|---|
users |
smart-user-groups |
users |
pro smart-user-groups |
patch-policies |
patch-policy-logs |
patch-policies |
pro patch-policy-logs |
user |
jamf-pro-user-account-settings |
— | pro jamf-pro-user-account-settings |
bin/jamf-cli pro smart-user-groups --help reports:
Available Commands:
apply Create or replace a smart-user-group by name
create Create a new user in inventory
delete Deletes a user from the inventory by ID
internal/commands/pro/generated/smart_user_groups.go confirms the endpoints. list, get, create, update and delete all send /v1/users, apply resolves on username at /v1/users (line 615), and its replace branch deletes at /v1/users/{id} (line 584). The name and the help text describe a smart user group. The command reads, writes and deletes a user. pro patch-policy-logs list has the same shape: its own Short is "Retrieve Patch Policies" and its Example says "List all patch-policy-logs".
internal/commands/deprecated_names.go:167 records "users": {Now: "smart-user-groups"} beside "user-smart-groups": {Now: "smart-user-groups"} at line 166. Two unrelated former resources now point at one name, which is the merge made visible in the alias table.
Failure scenario. An operator reads pro smart-user-groups apply --help, which states that the input's username field decides whether the resource is replaced, and pipes a document of group definitions into it. The command resolves each name against the user collection and, on a match, deletes and recreates a user record. No warning names the object it acts on, because every string the command prints says "smart-user-group".
Nothing is broken today, because deprecatedNames keeps pro users and pro patch-policies working with a warning. The cost is different. This PR exists to remove names that came from an accident, and it replaces one accident (a filename) with another (sort order). After 2027-03-09 the wrong name is the only name, and correcting it then is a second breaking rename.
Suggested fix. Name a merged group from the tag of its own root collection path, and fall back to the most frequent tag. I checked this against the document: the five merged groups that have a root collection get the correct tag from it, and the two without one (user, self-service) already get the correct answer from frequency.
- g.Tag = tagOf[p]
+ // A root can carry paths from two tags. The tag of the group's own
+ // root collection names it; last-write-wins names it by sort order.
+ g.Tag = groupTag(root, g.Paths, tagOf)Add a guard so the next spec drop cannot repeat this silently: report every root that merges two tags as a note from make generate, or fail with the root and the tags named. A test that asserts each resource's name against the tag of its root collection is the direct form.
Fixed when. pro users list and pro patch-policies list are the canonical names again, no resource name contradicts the Short text of its own verbs, and a root that merges two tags is reported rather than resolved by order.
⚠️ IMPORTANT (2) (correctness) — "no path carries two different tags" is the design's premise and is enforced nowhere
generator/parser/monolithparse.go:56-98, generator/parser/taggroup.go:33-35
tagsByPath accumulates op.Tags across every method on a path, and firstTagOf takes tags[0] in sorted-method order. If two methods on one path declare different tags, one tag wins and nothing reports it.
I checked the committed document: zero paths carry disagreeing tags, so the premise holds today. That is why this is IMPORTANT and not CRITICAL. The premise is load-bearing for the whole design, it is stated as a fact about the document, and the document is refreshed from upstream on every ingest.
Failure scenario. A spec drop tags GET /v3/foo as foo and PATCH /v3/foo as foo-management. firstTagOf picks one. TestParseMonolith_LosesNoEndpoint compares endpoint counts and passes. The command tree reorganises, and the diff reads as a plausible rename.
Suggested fix. Fail ParseMonolith when a path carries more than one base tag, naming the path and the tags.
Fixed when. A path with disagreeing tags fails make generate with both tags named.
⚠️ IMPORTANT (3) (documentation) — docs/guides/platform-api-ga.md was not updated, and it now contradicts the binary
CLAUDE.md's "Read first" section states that this guide "quotes verbatim CLI output" and must be updated whenever the refused-command list moves. This PR moves it. CLAUDE.md's own line 534 records the refused count dropping from 67 to 59 "because spec-derived resource naming folded the withdrawn versions", and the guide was not touched.
Stale rows and transcripts include pro static-computer-groups (lines 310, 358, 371, 377, 407-408), pro computers-inventory (305, 329, 362, 588-590), pro authentications (309), pro api-roles-privileges (311), pro systems (313), pro database-connections (314), pro mac-os-managed-software-updates (316) and pro oauth-token-sessions (318). Line 408 quotes a refusal message for a command that no longer exists.
CLAUDE.md itself was updated thoroughly, and its remaining computers-inventory mentions are correct past-tense narrative. The gap is this one file.
Failure scenario. A user follows the guide's own instruction at line 348 and greps commands -o json for pro mac-os-managed-software-updates. The grep returns nothing, because the command is now pro macos-managed-software-updates.
Suggested fix. Re-run the guide's transcripts against the built binary and correct every name and every count.
Fixed when. Every pro <resource> name quoted in the guide matches a name bin/jamf-cli commands -o json reports.
⚠️ IMPORTANT (4) (reliability) — the three leaf-to-group conversions get no runtime signal
internal/commands/deprecated_names.go
Every other breaking class in this PR gets a runtime answer. A renamed resource warns and resolves. A withdrawn one refuses and explains. The three former leaves that became groups get nothing. pro csa token -o json prints usage text and exits 0, even when -o json or --field asks for structured data. A typo exits 2, so this state is less legible than a typo.
The PR calls this "the sharpest edge" and answers it with a CHANGELOG row. The CHANGELOG is not read at runtime.
Failure scenario. A job that ran pro csas token -o json | jq -r .value now pipes cobra's help text into jq. The failure appears in jq, with nothing pointing back at the CLI.
Suggested fix. Where a structured-output flag reaches one of these three groups, refuse with an explanation that names the new leaf, in the same shape withdrawnNames uses.
Fixed when. pro csa token -o json exits non-zero and names pro csa token get.
⚠️ IMPORTANT (5) (usability) — the 43 moved invocations get cobra's bare "unknown command"
internal/commands/deprecated_names.go, CHANGELOG.md lines 50-115
The resource alias resolves the first token. Cobra then reports unknown command "triggers" for "pro scheduler" and exits 2, which is indistinguishable from a typo. This is the hardest class to self-diagnose, because the resource name is right and only the verb moved. The 55-row table exists for exactly this case and nothing at runtime points at it.
Suggested fix. Add a resource-plus-verb table so applyDeprecatedNames can intercept these 43 and warn with the new invocation named.
Fixed when. None of the 43 documented invocations produce cobra's generic error.
⚠️ IMPORTANT (6) (test-coverage) — the deprecation warning is the whole migration mechanism and no test asserts it
internal/commands/deprecated_names.go:401, wired at internal/commands/root.go:759
warnIfDeprecatedName prints the warning that tells a caller of one of the 98 retired names to migrate. No test calls it and no test captures stderr from a command run. TestProductTokenAndResourceToken covers only the two pure helpers it is built from.
Replacing the function body with a no-op leaves the whole internal/commands suite green. Every existing guard checks that the alias resolves and runs, and none checks that the warning is emitted.
Failure scenario. A later reorganisation of PersistentPreRunE drops the call site, or the lookup regresses. Scripts on the 98 deprecated names keep working with no warning until 2027-03-09, then break together with no notice. That is the exact outcome the alias mechanism exists to prevent.
Suggested fix. Assert the exact warning text on stderr for one deprecatedNames entry and one nestedAliases entry, and assert that a live name emits none.
Fixed when. A test fails when warnIfDeprecatedName is replaced with a no-op.
💡 NICE-TO-HAVE (7) (dead code) — GroupPathsByCollection ships with six tests and no caller
generator/parser/pathgroup.go:73-113
Nothing in the pipeline calls it. Its only callers are six tests in pathgroup_test.go. Those tests pin the path-only rule, which this PR replaced with the tag-plus-path hybrid, so they read as coverage of the shipped rule and are not. The PR's own reasoning for deleting DeduplicateVersioned applies here: "kept dormant they would have been worse than absent".
Fixed when. The function is deleted, or its tests target GroupPathsByTagAndCollection.
💡 NICE-TO-HAVE (8) (reliability) — the alias expiry has no advance signal
internal/commands/deprecated_names.go:34-39, deprecated_names_test.go:50-58
A test that fails the build on a date is the right forcing function, and it matches this repo's conventions. There is no schedule: trigger in .github/workflows/, so the test first fires on whatever PR runs CI on or after 2027-03-09. The work it forces is a real PR, not a one-line change.
Suggested fix. Warn 30 to 60 days ahead, or file a tracked issue now.
💡 NICE-TO-HAVE (9) (usability) — a split sub-resource lost its short alias
internal/commands/aliases.go
pro jcds files became pro jamf-cloud-distribution-service-files, 37 characters, with no alias, while the parent keeps jcds.
"jamf-cloud-distribution-service": {"jcds"},
+ "jamf-cloud-distribution-service-files": {"jcds-files"},✅ POSITIVE — the sub-resource rule, and the discipline around it
generator/parser/subresource.go is the best-argued file in the change. It names the rule, names the three questions the rule leaves open, and records the answer taken for each with the reason. The rule is read off declared methods, so a new path needs no table entry. It admits 9 sub-paths and refuses 131, and all 18 GET-plus-POST /history pairs are refused by the rule rather than by a special case.
The sub-resource consumers are also right, and each carries the reason. modernGatewayOps (generator/main.go:859) uses AllOperations() with the correct rationale: an unstamped operation carries no jamf:gateway annotation, so checkAPIMatch could not refuse a nested command on a withdrawn endpoint. The smoke and backup registries use FlattenResources plus QualifiedName(). I checked every consumer and found no gap.
Review coverage and scope
Risk: high. 366 files, breaking, generator plus whole pro command surface, one pass deleted.
- Design and architecture — read the three core passes in full
- Correctness — simulated
GroupPathsByTagAndCollectionover the committed document; verified findings against generated source and the built binary - Scope symmetry and data-flow completeness — checked every
Operations/AllOperations/QualifiedNameconsumer - Security — no auth surface changed; the gateway-annotation path was checked because a missed operation cannot be refused
- Reliability — alias, redirect and refusal paths
- Test coverage and fidelity — ran
./generator/...,./internal/commands/...,./internal/gateway/...: all pass. Ran the four named guards and confirmed each processes real data (704 endpoints, 1355 invocations, 9 sub-paths). The test-quality lane independently re-derived both committed baselines by buildingorigin/mainand running the old pipeline against the old 165-file layout:endpoints-before-path-grouping.tsvmatches exactly, and all 1355 rows ofpro-invocations-before-nesting.tsvmatchmain's real cobra tree. The baselines are genuine pre-images, not self-fulfilling. Four of five mutations were killed; the survivor is finding 6 - Documentation currency — CLAUDE.md, README.md, docs/GLOSSARY.md, docs/guides/platform-api-ga.md, CHANGELOG.md
- Code quality — dead code found
- [na] Performance — no hot path changed
- [na] Frontend —
docs/site/is generated from the binary
Specialist lanes dispatched: silent-failure-hunter, devil-advocate, usability-reviewer, test-quality-reviewer. All four returned. Findings 2, 4, 5, 6, 8 and 9 come from them and were each re-checked against the code before inclusion. Finding 1 is my own and is confirmed three ways.
Prior reviews: none.
Confidence: finding 1 — 5/5, verified against the built binary. Findings 2, 3, 6, 7 — 5/5, verified directly. Findings 4, 5 — 4/5. Findings 8, 9 — 4/5.
Rating: 3/5
Excellent engineering with one must-fix. The design, the reasoning and the guards are all above the bar for a change this size. Finding 1 blocks merge, because a breaking rename that ships a wrong name buys a second breaking rename later, and one of the wrong names is a destructive write surface.
Warning
Fix finding 1 before merge. Findings 2 to 6 are worth clearing in the same pass, because each is cheap now and expensive after the aliases expire.
Reviewed with the pr-review skill (Claude Opus 5). Findings 1, 2, 3 and 7 were verified directly against the built binary and the committed spec document; the rest come from specialist lanes and were re-checked against the code before inclusion.
…not by sort order Addresses the nine findings on PR #377. CRITICAL (1) — a path root that carries two tags was named by whichever of its paths sorted last. `g.Tag = tagOf[p]` was assigned inside the assembly loop, so the last write won, and `nameFromTags` named the resource after it. Eight roots in the 11.31.1 monolith hold two tags — which is the correct grouping, a `recalculate` action on `/v1/users/{id}` belonging with the user CRUD it acts on — and three took a wrong name. Two shipped a name that contradicted every string the commands under it printed: - `/v1/users` CRUD shipped as `pro smart-user-groups`, named after two recalculate actions, with an `apply` that resolved a name against the user collection and then deleted and recreated a **user record**. - `/v2/patch-policies` shipped as `pro patch-policy-logs`, whose own `list` is "Retrieve Patch Policies". `groupTag` names a merged group from the tag of its own root collection, falling back to the most frequent tag where no path answers as the collection. Computed after `mergeRoots`, which moves paths between groups. `pro users` and `pro patch-policies` are the canonical names again; `pro user-smart-groups` and `pro patch-policy-logs` are aliases, and every verb from each survives under its new parent unchanged. Wire-verified on an EU environment credential: `GET /pro/v1/users` and `GET /pro/v2/patch-policies` both 200. IMPORTANT (2) — "no path carries two different tags" is the premise the whole grouping rests on and was enforced nowhere: `firstTagOf` took `tags[0]` in sorted-method order and said nothing. `soleBaseTagOf` refuses it, naming the path and both tags. Zero live paths disagree, so it refuses nothing today. The same shared-key defect had a second instance. Untagged paths all shared the empty tag as their bounding key, so `resolveRootsWithinBound`'s no-survivor fallback would fold every unrelated untagged root into the largest — `/v1/health-check` and `/v1/health-status` as one resource. `boundKey` bounds an untagged path by its own root. IMPORTANT (3) — docs/guides/platform-api-ga.md was quoting names the binary no longer ships. The refusal table is recounted from `commands -o json` (59, not 67, with the eight accounted for: six were the withdrawn v2 static computer groups, which stop existing when every version of an endpoint lands in one resource, and two were `pro policy-properties`, whose unversioned legacy twin is no longer ingested while the versioned path it shared a tag with is published). The `static-computer-groups` successor walkthrough described a case that no longer exists and is rewritten around an empty successor table. Every refusal re-verified on the wire. IMPORTANT (4) — the three former leaves that became command groups printed help at exit 0, so `pro csas token -o json | jq -r .value` fed jq a usage message. `guardFormerLeafGroups` refuses a data request naming the leaf; a bare invocation and a typo beneath it are unchanged. Wired after `guardUnknownSubcommands`, whose RunE it wraps. IMPORTANT (5) — the 43 invocations whose operation name also moved got cobra's bare `unknown command`. `movedInvocations` registers a refusal stub for each, keyed on the path after alias resolution so both spellings reach it, plus the nested-alias spellings. Exit 2, as every other command that does not exist: what is missing is the pointer, not a classification. IMPORTANT (6) — no test asserted `warnIfDeprecatedName`, and replacing its body with a no-op left the package green. Two tests now capture stderr: one pinning the exact wording for a `deprecatedNames` and a `nestedAliases` entry and that a live name says nothing, one sweeping all 100 retired names. NICE (7) — `GroupPathsByCollection` had no caller and six tests pinning the retired path-only rule. Deleted; the six now run against the shipped rule with untagged paths, which is the path half of the hybrid. NICE (8) — the expiry guard fired once, on whatever PR ran CI on the day. A scheduled workflow fails 60 days ahead instead, listing what has to go. It never blocks a pull request. NICE (9) — `pro jcds-files` is a curated alias for the 37-character split half, and `pro jcds` is promoted from an expiring deprecation alias to a curated one. `applyAliases` no longer appends an alias a command already answers to, with a tree-wide guard, since two tables naming one alias printed it twice in --help. Also: the invocation sweep gains a `refused` shape, deliberately not folded into `gone` — a row moving back is the pointer being lost — and the positional guard exempts the stubs against a count derived from the table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@ktn-jamf — all nine findings addressed in 1ee814f. Ready for re-review. The CRITICAL was real and your diagnosis was exact, including the suggested fix and the check that the five merged groups with a root collection get the correct tag from it. Two things came out of chasing it that were not in the report. ❌ CRITICAL (1) — a merged root named by sort order
Reproduced your simulation before changing anything — 8 merged groups over the committed document (you counted 7; the eighth is
Wire-verified on an EU environment credential: Two guards, and I mutated The finding had a sibling in the same shape. Untagged paths all shared the empty string as their bounding key, so
|
ktn-jamf
left a comment
There was a problem hiding this comment.
Re-review of 1ee814f (round 2)
All nine round-1 findings are fixed, and I verified each one independently rather than on the strength of the reply. The CRITICAL fix is exactly right: I re-ran my own simulation of the naming pass over specs/JamfProAPI.yaml against the new groupTag, and all seven merged roots now take the tag of their own root collection. pro users apply reads "Create or replace a user by name" and pro patch-policies list reads "Retrieve Patch Policies". The two self-reported extras — boundKey for untagged paths, and the mostFrequentTag tie-break — are both real improvements I had not asked for.
One new CRITICAL, and it is not from the fix commit. It is in the PR as a whole and I missed it in round 1. Seven write operations that main ships are dropped by this PR at generate time, with a warning and nothing else. Three of them have no replacement command, so the capability is gone. TestParseMonolith_LosesNoEndpoint passes throughout, because it measures parsing and the loss happens after it.
Warning
Round-1 findings are all clear. Do not merge on that alone — finding (1) below is a capability regression against main, verified command by command on both binaries.
Rating: 3/5
Prior findings status (9)
| # | Location | State | Notes |
|---|---|---|---|
| (1) | generator/parser/taggroup.go:118 |
✅ Fixed | groupTag names a merged root from its own root collection, most-frequent tag as fallback, tie broken lexicographically. My independent simulation gives the correct tag for all 7 merged roots. pro users and pro patch-policies restored, and every verb's Short now matches its endpoint |
| (2) | generator/parser/monolithparse.go:56-98 |
✅ Fixed | soleBaseTagOf refuses a path carrying two base tags, naming both. firstTagOf is gone from the tree. Non-vacuous: my suite run caught the lane's own mutation of it failing TestSoleBaseTagOfRefusesAPathCarryingTwoTags |
| (3) | docs/guides/platform-api-ga.md |
✅ Fixed | Seven of eight stale names gone; the three remaining static-computer-groups mentions are correct past-tense narrative. The refused count now reads 59, and commands -o json on the built binary returns exactly 59 |
| (4) | leaf-to-group conversions | ✅ Fixed | pro csa token -o json exits 2 with a JSON error naming pro csa token get; a bare invocation still prints help at 0. Changed() on the root persistent flags is read rather than the value, so a profile default-output does not over-trigger |
| (5) | the 43 moved invocations | ✅ Fixed | movedInvocations, 42 entries covering 43 invocations (one entry names the two that collapse onto pro enrollment list). pro schedulers triggers now names pro scheduler-jobs triggers |
| (6) | internal/commands/deprecated_names.go:401 |
✅ Fixed | TestWarnIfDeprecatedNameNamesTheReplacement and TestEveryDeprecatedNameWarns exist in moved_invocations_test.go and assert the exact stderr text for a deprecatedNames entry, a nestedAliases entry, the expiry date, and silence for a live name. A no-op body cannot pass them |
| (7) | generator/parser/pathgroup.go |
✅ Fixed | GroupPathsByCollection deleted; the six tests now exercise GroupPathsByTagAndCollection with untagged paths, which is the shipped rule |
| (8) | alias expiry signal | ✅ Fixed | .github/workflows/expiry-notice.yaml, Mondays 08:00 UTC, armed by JAMF_CLI_EXPIRY_NOTICE and separate from CI so it cannot block a pull request |
| (9) | internal/commands/aliases.go |
✅ Fixed | pro jcds-files resolves. appendNewAliases deduped the double jcds registration, and pro jamf-cloud-distribution-service --help lists the alias once |
Active findings
❌ CRITICAL (1) (correctness) — seven write operations main ships are dropped at generate time, three with no replacement
generator/parser/parser.go (the duplicate-operation-name drop), surfaced at generator/parser/monolithparse_test.go
make generate prints, and exits 0:
Warning: duplicate operation name "scope-by-id" — dropping POST /v2/computer-prestages/{id}/scope
Warning: duplicate operation name "scope-by-id" — dropping PUT /v2/computer-prestages/{id}/scope
Warning: duplicate operation name "create" — dropping POST /v2/enrollment-customizations
Warning: duplicate operation name "delete" — dropping DELETE /v2/enrollment-customizations/{id}
Warning: duplicate operation name "update" — dropping PUT /v2/enrollment-customizations/{id}
Warning: duplicate operation name "scope-by-id" — dropping POST /v2/mobile-device-prestages/{id}/scope
Warning: duplicate operation name "scope-by-id" — dropping PUT /v2/mobile-device-prestages/{id}/scope
I built both revisions and diffed the dropped-operation lists: origin/main drops 32, this PR drops 34, and the seven above are new in this PR while five others main dropped are now kept. So this is not the pre-existing 32 under a new spelling.
All seven are recorded in the PR's own baseline fixture as endpoints the CLI shipped before, generator/parser/testdata/endpoints-before-path-grouping.tsv (first four columns of each row):
computer-prestage-scopes create-scope POST /v2/computer-prestages/{id}/scope
computer-prestage-scopes update-scope PUT /v2/computer-prestages/{id}/scope
enrollment-customizations create POST /v2/enrollment-customizations
enrollment-customizations delete DELETE /v2/enrollment-customizations/{id}
enrollment-customizations update PUT /v2/enrollment-customizations/{id}
mobile-device-prestage-scopes create-scope POST /v2/mobile-device-prestages/{id}/scope
mobile-device-prestage-scopes update-scope PUT /v2/mobile-device-prestages/{id}/scope
Command by command, on the two built binaries:
| capability | origin/main |
this PR |
|---|---|---|
| create an enrollment customization | pro enrollment-customizations create — "Create an Enrollment Customization" |
gone. pro enrollment-customization create is "Create an LDAP Panel", POST /v1/enrollment-customization/{id}/ldap |
| update one | update — "Update an Enrollment Customization" |
gone. update is "Update a single LDAP Panel" |
| delete one | delete — "Delete an Enrollment Customization with the supplied id" |
gone. delete is "Delete a single Panel", DELETE /v1/enrollment-customization/{id}/all/{panel-id} |
| add devices to a computer-prestage scope | pro computer-prestage-scopes create-scope |
gone. Only scope, scope-by-id (both GET) and delete-multiple remain |
| replace a computer-prestage scope | update-scope |
gone |
| the same two for mobile-device prestages | pro mobile-device-prestage-scopes create-scope / update-scope |
gone |
I grepped the whole of internal/commands for a replacement. /v2/enrollment-customizations appears only as a list and name-resolution path, never a POST; both prestage /scope paths appear only as GET, plus a read-only ScopePath in pro_resources.go for backup. There is no other command.
The apply built on top of the displaced verbs is worse than the loss. internal/commands/pro/generated/enrollment_customization.go:
id, err := resolveNameToIDForApply(reqCtx, ctx.Client, "/v2/enrollment-customizations", "displayName", "id", name, noInput)
updatePath := strings.Replace("/v1/enrollment-customization/{id}/ldap/{panel-id}", "{panel-id}", url.PathEscape(id), 1)
resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data))pro enrollment-customization apply resolves a customization name to a customization id, substitutes it into {panel-id}, and PUTs the document at an LDAP panel path — with {id} never substituted, so the request URL carries a literal {id}. That is the pro managed-software-updates-plans apply defect this PR's own description says it removed, reproduced on a different resource: list the collection, resolve a name to an id, then write the document at something else.
Why the guards do not see it, and this is the part worth reading. TestNoPlainVerbLeavesItsResource did fire on the three enrollment-customization verbs. They sit in its allowlist, each with a reason:
// The enrollment-customization panel families: /v1/enrollment-customization
// (singular) under a group rooted at /v1/enrollment-customizations
"POST /v1/enrollment-customization/{id}/ldap": "a panel under {id}; the singular and plural roots are one tag",
"PUT /v1/enrollment-customization/{id}/ldap/{panel-id}": "a panel under {id}; the singular and plural roots are one tag",
"DELETE /v1/enrollment-customization/{id}/all/{panel-id}": "a panel under {id}; the singular and plural roots are one tag",The allowlist's own preamble frames the class as a naming question deferred to a later change: "Splitting them would need a rule about parameterised sub-paths and a judgement about which root is the resource, and neither is this change." That framing is reasonable for a name. It is not reasonable for what actually happens, and the gap between the two is the finding: deciding which of two candidates takes the name create does not delete the loser — but here the loser is deleted, by the duplicate-name drop, and the allowlist entry says nothing about that because nothing told its author. The reason reads as "the panel keeps the name"; the effect is "the customization CRUD stops existing".
The other two guards are silent for their own reasons:
- The four prestage scope writes are not in the allowlist and were never considered, because the operation that survives on
/scopeisscope-by-id, a GET, andscope-by-idis not a plain verb. Nothing in that test looked at them. TestParseMonolith_LosesNoEndpointcompares the parse result against the fixture. The duplicate-name drop runs later, inGenerate. So "704 → 704, zero gained or lost" is true of parsing and not of the shipped surface, which is how the claim reads and how I read it in round 1.
The sub-resource split cannot rescue the panel case either, and correctly does not try: ldap carries only GET and POST on itself, with the writes on ldap/{panel-id}, so the PUT/PATCH/DELETE rule declines to split. The rule is right. What is missing is anything that notices the displaced operations were dropped rather than renamed.
Failure scenario. A Jamf admin's provisioning script runs pro enrollment-customizations create --from-file customization.json to stand up an enrollment customization, then pro computer-prestage-scopes update-scope <id> --from-file serials.json to scope a prestage. After this PR, the first name resolves through the deprecation alias to a command that creates an LDAP panel on a customization that does not exist yet, and the second no longer exists. Neither loss is in CHANGELOG.md, because nothing reported it.
Suggested fix. Two parts, and the first is the one that matters.
- Make the duplicate-name drop a failure, not a warning, or at minimum resolve it in favour of the operation that addresses the resource's own root. A resource's own
create,updateanddeletemust outrank a sub-path's.resolveNoParamConflictsalready renames a colliding verb rather than dropping one, and that is the behaviour wanted here: the panel operations should be renamed (create-ldap-panel, or a nestedpro enrollment-customization ldap create) and the customization CRUD kept. - Extend
TestParseMonolith_LosesNoEndpointto run afterGenerate's drops, so the fixture measures the shipped surface. That is the guard the PR describes; it is one pipeline stage short of being it.
Also make an allowlist entry state what happens to the loser. An entry in plainVerbsOutsideTheirRoot currently records why a verb may keep a foreign noun; it should not be satisfiable while the operation it displaced is being dropped. Asserting that every allowlisted verb's displaced sibling still ships would have failed here.
Fixed when. pro enrollment-customization create|update|delete address the customization again, the four prestage scope writes ship, make generate reports no dropped operation that the baseline fixture records as previously shipped, and a test fails if one is dropped.
Review coverage and scope
Round 2. Incremental diff 56bc41c..1ee814f, one commit, 25 files. Clean fast-forward, no force-push.
- Prior findings — all nine verified independently, not from the reply. Simulation of the new naming rule over the committed document; both binaries built and compared; counts reconciled against the code (96
deprecatedNames+ 4nestedAliases= the corrected "100 resolve"; 42 entries covering 43 invocations; 43refusedrows in the sweep baseline) - Baseline integrity —
pro-invocations-before-nesting.tsvis 1355 rows and its old-shape column is byte-identical to56bc41c. Only the expected-new column moved, so the fixture was not weakened to fit.endpoints-before-path-grouping.tsvis untouched - Correctness of the new code —
groupTag,boundKey,soleBaseTagOf,movedInvocations,formerLeafGroups,appendNewAliases - Silent failure — dispatched. No findings: every "skip silently" branch in the new tables is paired with a named test that fails on a stale entry, and the
Changed("output")claim was traced to the assignment site atroot.go:784, confirming a profiledefault-outputcannot over-trigger the refusal - Regression against
main— this is what found finding (1). Dropped-operation lists diffed acrossmain,56bc41cand1ee814f; every difference traced to a command on both binaries - Gates —
go test ./...green on a clean tree,make verify-generated,make verify-gateway-coverageandmake verify-siteall pass - Documentation currency — CLAUDE.md gained a "Where to Make Changes" row per new mechanism, each with its reason; CHANGELOG counts corrected 40→38 and 102→100
- [na] Performance, frontend — unchanged
- [?] Test-quality mutation lane — dispatched for round 2 and had not returned when this was written. Its round-1 finding is fixed and I verified the replacement guards by inspection: both tests capture stderr and assert the literal message, so the no-op mutation that survived round 1 cannot pass them. I also observed the lane's own in-flight mutation of
soleBaseTagOffailing its guard, which confirms finding (2)'s test is not vacuous
Findings not carried forward: none. Findings 1 to 9 are all ✅ Fixed.
Confidence: finding (1) — 5/5. Verified on two built binaries, with the dropped-operation lists diffed across three revisions, the PR's own baseline fixture quoted, and no replacement command found anywhere in internal/commands.
What is done well
The response is the best fix round I have reviewed on this repo. Three things stand out.
The fix went to the mechanism rather than the symptom. groupTag is the rule I suggested, and the tie-break is deterministic by name with the reason stated: "an unstable answer would move a resource's name between two runs of make generate with no change to the document."
Both self-reported extras are real. boundKey is the same class as the CRITICAL — a shared key merging things nothing declared to be together — found by asking where else the pattern occurred rather than by fixing what was reported. Without it /v1/health-check and /v1/health-status would have come out as one resource.
The guards were mutated before being claimed sound, and each carries a non-vacuity t.Fatal. TestMergedTagGroupsAreNamedByTheirRootCollection fails if no merged group is left to exercise, so a future spec drop cannot make it pass by making it irrelevant. The invocation sweep gained a fourth shape, refused, deliberately not folded into gone — because a row moving back would be the migration pointer being lost, which a resolution-only check reads as identical.
Finding (1) is not a criticism of any of that. It is a hole in a guard whose name reads as covering it, in a pass that predates this round.
Re-reviewed with the pr-review skill (Claude Opus 5). Round-1 findings were re-verified against the built binary and a re-run of the naming simulation; finding (1) was found by diffing the generated command surface against origin/main.
A name held by two operations was resolved by keeping one and discarding the
other, with a warning on stderr and exit 0. That is the wrong resolution
whichever operation loses, and on this branch the loser was twice the
resource's own root.
`pro enrollment-customization create`, `update` and `delete` addressed an LDAP
panel: the tag covers a singular root carrying four panel families under {id}
and the plural /v2/enrollment-customizations carrying the customization's own
CRUD, nine operations wanted three names, and the panels' won. `apply` was
built on top of that — it resolved a customization name to a customization id,
substituted it into {panel-id} and PUT the document at an LDAP panel path with
{id} never substituted, which is the managed-software-updates-plans apply
defect on a different resource. And POST/PUT on both prestage /scope endpoints
were gone, GET/POST/PUT there all reducing to `scope-by-id` once the
collection-level GET took `scope`.
Two passes. qualifyDuplicateVerbsOutsideTheRoot gives a plain verb to the
operation that addresses the resource's own root and qualifies the loser by the
segment that owns it (`ldap-create`, `dashboard-delete`); it touches writes
only, because a colliding GET is what resourceGetDetailPathOverrides depends on
being fused. And disambiguateSameTerminalOps falls back to the method prefix
when the path-derived name is taken by a sibling on the same path, which is
what buildDisambiguatedName cannot express.
19 write operations that were being deleted now ship, 12 of them never shipped
on main either. The surviving 16 drops are pinned in
testdata/dropped-operations.tsv, by a test that runs dedupeOperations —
TestParseMonolith_LosesNoEndpoint measures the parse and the drop happens after
it, which is why "704 → 704" was true and the loss was real. A dropped
operation that addresses its resource's own root fails outright.
`pro enrollment-customization-panels create|update|delete` now resolve to a
command that addresses the customization rather than the panel, so
guardDeprecatedNameVerbMoves refuses those three and names the panel command —
the same answer formerLeafGroups gives to the other shape that resolves, exits
0 and does something else. It fronts the leaf's Args as well as its RunE, or
the refusal is unreachable from the two positionals the old command took.
Wire-verified on nmartin.jamfcloud.com: the customization's create (201),
update (200), apply (list + PUT to /v2/enrollment-customizations/{id}) and
delete (204); text-create (201), text-update (200), ldap-create (201),
all-delete (204) and text-delete (204), all four previously dropped;
packages manifest-delete (204). create-scope and update-scope reach
POST/PUT /v2/computer-prestages/1/scope and earn Jamf's own field-attributed
DEVICE_DOES_NOT_EXIST_ON_TOKEN, that instance having no ADE devices.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Finding (1) is right and it is fixed. Two root causes, not one, and the fix restores nineteen write operations rather than the seven you named — twelve of them Pushed as The two causes
The prestage scopes. The fix
It touches writes only, and that is load-bearing: a colliding GET is left to
Zero new drops against Your suggestion 2, and why the guard is a pinned list instead
So That invariant found an eighth case immediately — The allowlistThe three One thing you did not ask about and I had to addFixing the precedence moves the breakage rather than removing it:
The sweep classifies those three as Wire verificationEU-region Jamf Pro instance, one session, full
The two Also verified:
|
|
@ktn-jamf re-review please — finding (1) is fixed in Three things worth aiming at, since each is a judgement rather than a mechanical fix:
Twelve of the nineteen restored operations never shipped on |
ktn-jamf
left a comment
There was a problem hiding this comment.
Re-review of 666ba61 (round 3)
Round 2's CRITICAL is fixed, and the fix is better than what I asked for. I verified the central claim independently: origin/main drops 32 operations, this commit drops 15, and the set difference contains no new drops in either direction beyond restorations. All seven operations I named ship, create/update/delete address the customization again, both prestage scope writes are back under main's own names, and the malformed apply now resolves and writes at /v2/enrollment-customizations/{id} with {id} correctly substituted.
The three points you invited attack on are the right three. Two of them hold. On the third — a write the GET carve-out's reasoning should also exempt — I did not find one, but I found something adjacent and worse, and it has been in the branch since 56bc41c. I missed it in rounds 1 and 2.
pro.go wires two destructive commands by name, the rename staled both names, and neither helper reports it. The generated erase and remove-mdm-profile now ship alongside the hand-written pair they were meant to replace, under v-4-computers-inventory-erase and v-4-computers-inventory-remove-mdm-profile, without the --confirm-destructive gate the hand-written pair carry for bulk.
Warning
Prior finding is clear. Finding (1) is a second, weaker-gated path to a fleet-wide Mac wipe, reachable with --group … --yes, and its confirmation prompt names the wrong action. Finding (2) is smaller but touches this round's own work: a flag after pro silences both the new refusal and the deprecation warning.
Rating: 3/5
Prior findings status
| # | Location | State | Notes |
|---|---|---|---|
| (1) | duplicate-operation-name drop | ✅ Fixed | Verified independently: main drops 32, this commit 15, zero new. All seven named operations ship; panel writes qualified to ldap-create / sso-update / all-delete; create-scope / update-scope restored on both prestage resources; apply corrected. The new deprecatedNameVerbMoves refusal names the exact replacement per verb and the two-positional old form reaches it |
Active findings
❌ CRITICAL (1) (correctness, safety) — the rename staled pro.go's destructive-command wiring, and both helpers fail silently
internal/commands/pro.go:107-108, against removeSubcommand / replaceSubcommand at internal/commands/pro.go:177-203
replaceSubcommand(cmd, []string{"computer-inventory"}, "erase", newComputerEraseCmd(cliCtx))
removeSubcommand(cmd, []string{"computer-inventory"}, "remove-mdm-profile")Both key on a child name. After this PR the generated operations are named v-4-computers-inventory-erase and v-4-computers-inventory-remove-mdm-profile, so neither key matches, and neither helper says so:
func removeSubcommand(root *cobra.Command, parentPath []string, childName string) {
parent, _, err := root.Find(parentPath)
if err != nil { return }
for _, child := range parent.Commands() {
if child.Name() == childName { parent.RemoveCommand(child); return }
}
} // no match → returns silently
func replaceSubcommand(...) {
...
for _, child := range parent.Commands() {
if child.Name() == childName { parent.RemoveCommand(child); break }
}
parent.AddCommand(replacement) // adds even when nothing was removed
}So the removal is a no-op and the replacement becomes an addition. pro computer-inventory --help on the built binary:
erase Erase a computer
remove-mdm Remove the MDM profile from a computer
v-4-computers-inventory-erase Erase a computer
v-4-computers-inventory-remove-mdm-profile Remove a computer's MDM profile
CLAUDE.md states the intent this defeats: "pro.go now replaceSubcommands erase and drops the generated remove-mdm-profile in favour of the hand-written remove-mdm — the hand-written pair target by serial, name or group, confirm the action, honour --dry-run and carry the Find My PIN body, where the generated pair take an <id>. A duplicate subcommand name is not a choice cobra can make."
The two paths are not equally gated. --confirm-destructive exists only in the hand-written files (pro_device_actions.go:276, pro_bulk.go, pro_bulk_commands.go), declared "required for bulk destructive operations". The generated template has no such flag. Its bulk path stops at one --yes-skippable prompt, so
pro comp v-4-computers-inventory-erase --group "All Managed Clients" --yes
wipes every Mac in the group behind a single flag, where pro comp erase --group … --yes still demands --confirm-destructive. The generated command is annotated jamf:destructive (computer_inventory.go:605), so this is not a missing annotation — it is a second gate the hand-written path has and the generated one does not.
The prompt that stands between the operator and the wipe names the wrong action. internal/commands/pro/generated/computer_inventory.go, inside the v-4-computers-inventory-erase command:
2821: "⚠️ This will delete %d computer-inventory from group %q. Type 'yes' to confirm: "
2918: "⚠️ This will v-4-computers-inventory-erase computer-inventory %q (id: %s). Type 'yes' to confirm: "The fleet-wide path says it will delete N "computer-inventory", which reads as removing inventory records rather than wiping devices. The single-device path interpolates the version-leaking operation name into the sentence, so the operator is asked to confirm "This will v-4-computers-inventory-erase computer-inventory". Line 2749's dry-run text has the same wording.
Why no test sees it. TestProWiringNamesCommandsThatShip (internal/commands/deprecated_names_test.go:288) greps pro.go for exactly this class and checks only the parent:
parents := regexp.MustCompile(`(?:add|remove|replace)Subcommand\(cmd, \[\]string\{"([a-z0-9-]+)"\}`)
...
if byName[m[1]] == nil && byAlias[m[1]] == nil {
t.Errorf("pro.go wires onto parent %q, which `pro` does not ship — the wiring is silently discarded", m[1])
}The capture group stops at the parent path. computer-inventory ships, so the check passes while both child keys are dead. The test's own comment already reasons about a case the assembled tree cannot judge — a whole-resource removal — and this is the sibling case: for replaceSubcommand the child exists after wiring, because the replacement was added, so the assembled tree cannot tell a successful replace from a stale key either. That is answerable here, unlike the case the comment defers: internal/commands imports internal/commands/pro/generated, so the pre-wiring parent can be built directly and its children inspected.
This is the same class the PR added TestEveryResourceKeyedOverrideNamesALiveResource for on the generator side — a name-keyed table whose key stops matching, silently. pro.go's wiring is a name-keyed table and got the parent half of that guard only.
Failure scenario. An admin knows pro comp erase requires --confirm-destructive for a group and has a CI policy that greps for that flag. Shell completion now offers v-4-computers-inventory-erase beside erase; either the admin picks it, or a script generated from commands -o json does. --group "All Managed Clients" --yes then wipes the fleet with the policy flag never involved, after a prompt that said it would "delete computer-inventory".
Suggested fix.
- Make the two wiring calls match the current names, and stop them failing silently:
removeSubcommandandreplaceSubcommandshould report or fail when the named child is absent. At.Fatal-backed test is enough — these run once at startup, so a hard failure is not a runtime risk. - Extend
TestProWiringNamesCommandsThatShipto capture the child name as well and assert it exists under that parent in the generated tree before wiring, built fromgenerated.NewComputerInventoryCmd(cliCtx)rather than from the assembled root. That distinguishes a successful replace from a stale key, which the assembled tree cannot. - Prefer keying the wiring on something the rename cannot move. The endpoint is stable where the name is not:
POST /v1/computer-inventory/{id}/erasedid not change spelling in this PR. A path-keyed removal would have survived it, and would survive the next rename. - Fix the bulk prompt and dry-run wording in
resourceTemplateso anx-actionnames its own action rather than "delete", and does not interpolate the raw operation name into an English sentence.
Fixed when. pro computer-inventory --help lists erase and remove-mdm and neither v-4- twin; a stale child key in pro.go fails a test rather than being discarded; and no confirmation prompt for an x-action says "delete" or contains a generated operation name.
⚠️ IMPORTANT (2) (correctness) — a global flag after pro defeats the new refusal and silences the deprecation warning for all 100 aliases
internal/commands/moved_invocations.go:166-208 (resourceTokenAfter), used by guardDeprecatedNameVerbMoves and by warnIfDeprecatedName (internal/commands/deprecated_names.go:438-448)
resourceTokenAfter scans raw os.Args for the first token after the product name that does not start with -. It does not know that a flag takes a value, so any flag placed between pro and the resource makes it return the flag's value as the resource token. Cobra does not require global flags before the subcommand, and -p is the documented way to select a profile, so pro -p <profile> <resource> … is an ordinary shape.
Verified on the built binary. The refusal:
$ jamf-cli pro enrollment-customization-panels update 1 2
message: `pro enrollment-customization-panels update` no longer names this operation …
hint: run `jamf-cli pro enrollment-customization ldap-update`
$ jamf-cli pro --profile ci-svc enrollment-customization-panels update 1 2
message: accepts at most 1 arg(s), received 2 # same for -p ci-svc
typed != called, so the Args relaxation is skipped and the strict validator guardStrayPositionals installed runs instead. The two positionals the old command took are rejected before RunE, so the refusal never executes and the operator gets exactly the bare cobra arity error this guard exists to replace.
The warning has the same bug, and it costs more because it reaches all 100 retired names:
$ jamf-cli pro enrollment-customizations create --scaffold
warning: `enrollment-customizations` is a deprecated name … Use `pro enrollment-customization`.
$ jamf-cli -p ci-svc pro enrollment-customizations create --scaffold
warning: `enrollment-customizations` is a deprecated name … Use `pro enrollment-customization`.
$ jamf-cli pro -p ci-svc enrollment-customizations create --scaffold
(no warning)
So the migration signal — the mechanism the whole 2027-03-09 deprecation window rests on, and the one round 1 asked to be made testable — is silently absent for a caller who puts the flag after the product. A CI job in that shape gets no warning for the entire window and then breaks on the expiry date, which is the outcome the aliases exist to prevent.
Suggested fix. Do not re-derive the invocation from os.Args. Cobra has already parsed past every flag by the time either hook runs, so take the resource token from the resolved command's own lineage (cmd.Parent().Name(), walking up to the product) or from cmd.Flags().Args(). Re-deriving flag arity from spelling cannot be made correct — a value that does not start with - is indistinguishable from a positional at that level.
Fixed when. A global flag with a non-dash value, placed anywhere between the product token and the resource token, changes neither which invocation guardDeprecatedNameVerbMoves believes was typed nor whether warnIfDeprecatedName fires. Worth a test row per placement, since the plain form already passes.
💡 NICE-TO-HAVE (3) — a third version-leaking name, and a plain verb whose noun is now ambiguous
internal/commands/pro/generated/mobile_device_prestages.go
pro mobile-device-prestages v-2-scope-delete-multiple is the third and last v-N- name in the binary (main has none). The collision behind it is genuine — /v3/mobile-device-prestages/{id}/attachments/delete-multiple and /v2/mobile-device-prestages/{id}/scope/delete-multiple are different operations — so a rename is required. Only the name chosen is the problem, and this round introduced a better convention for exactly this shape: under qualifyDuplicateVerbsOutsideTheRoot's rule the pair would read attachments-delete-multiple and scope-delete-multiple.
Two reasons it is worth taking with that convention rather than leaving:
- A version segment in a command name is the thing this PR exists to remove. CLAUDE.md's own history is that a name carrying version information is how the CLI came to send
/v3while/v4was served. - The winner is the attachments operation, so
pro mobile-device-prestages delete-multipledeletes attachments while its computer-prestage sibling'sdelete-multipleremoves scope.delete-multipleis not inplainVerbs, soTestNoPlainVerbLeavesItsResourcedoes not look at it. Two sibling resources answering the same verb with different nouns is the round-2 finding in miniature.
Not blocking: both operations ship and both send the right path.
Review coverage and scope
Round 3. Incremental diff 1ee814f..666ba61, one commit, 20 files, +3305/−446.
- Prior finding — verified independently rather than from the reply. Built
origin/mainand this head, ran the generator on both, diffed the dropped-operation lists (32 → 15, zero new), and compared the command surface verb by verb on both binaries.apply's corrected paths read off the generated source - The new refusal — exercised on the binary:
pro enrollment-customization-panels create|update 1 2|delete 1 2each refuse at exit 2 namingldap-create/ldap-update/all-delete; the two-positional old form reaches the refusal rather than being clamped byArgs;pro enrollment-customization create junkstill gets the stray-positional refusal; the old plural alias still scaffolds the customization body - Correctness and safety of the wiring — this is what found finding (1).
pro.go's helpers read in full, both keys traced against the generated names, both confirmation paths compared flag by flag - Naming consistency — swept the whole binary for version-leaking command names; three exist, all new in this PR
- Silent failure — dispatched on the new pass and the new guard, including your three invited targets
- Test quality — mutation-verified, six items, all confirmed and none vacuous. A no-op
qualifyDuplicateVerbsOutsideTheRootfails its own test on six assertions and makesTestTheDroppedOperationSetIsPinnedlist exactly the 15 root-losing operations. Removing the GET carve-out failsTestQualifyDuplicateVerbsLeavesGETsToDedupe, renamingGET /v4/computers-inventory-detail/{id}and splitting the fusion. Both halves of the pinned drop-set guard fire independently — the second clause does not swallow the first. Removing thedisambiguateSameTerminalOpsmethod fallback reproduces the four-operation prestage regression. Swapping the guard ahead ofguardStrayPositionalsfailsTestEveryLeafRefusesAnUndocumentedPositionalwith the exact message you predicted.readPinnedDropsfatals on an empty fixture -
create-scope/update-scopeagainstmain— compared byte for byte: identicalUse,Short,Long, annotations, path, method and version-lock body handling, plus a--namelookupmain's separated resource did not have.mainshipped them under a separatecomputer-prestage-scopesresource, which the sub-resource merge folded in at1ee814f; this commit restores both - Baseline fixture — the
wascolumn is byte-identical from its introduction through1ee814fto this head; only the expected-new column moves. Four rows gogone → leafas claimed, and three more goleaf → refused, which the newdeprecatedNameVerbMovesentries account for.TestEveryFormerInvocationKeepsItsShapepasses on 1355 invocations and asserts all three transition kinds non-empty - Documentation currency — CLAUDE.md gained a row for the new pass; CHANGELOG updated
- [na] Performance, frontend — unchanged
- The GET carve-out you flagged — no write needs the same exemption, and the reason is structural rather than a survey:
resourceCreateOpOverridesandresourceUpdateTokenOpOverridesmatch on(Path, Method)rather than onName(generator/parser/parser.go:213-263), so renaming a write earlier in the pipeline cannot break either.resourceGetDetailPathOverridesis the only fusing override and it is GET-keyed, which is exactly what the carve-out protects -
qualifyDuplicateVerbsOutsideTheRoot's candidate-exhaustion fallback — if the qualified name were also taken, the loser's name goes unresolved, but it is a non-root write, soTestTheDroppedOperationSetIsPinnedsurfaces it as a set diff rather than letting it vanish
I did not re-review the round-1 and round-2 findings; all twelve were verified fixed in their rounds and nothing in this diff touches them.
Confidence: finding (1) — 5/5. Verified on two built binaries, with the wiring helpers, both keys, the flag sets, the prompt strings and the half-guard all read directly. Findings (2) and (3) — 5/5, both reproduced on the binary.
What is done well
The fix went past what I reported in the direction that matters. I named seven operations; the two root causes turned out to account for nineteen, twelve of which main also dropped, and the fix takes all of them rather than the seven with a reviewer's name on them.
The correction to my suggestion 2 is right and I was wrong. I proposed failing on "any dropped operation the baseline fixture records as previously shipped". That fixture records the parse, so it holds all 34 pre-existing drops including benign ones and one that is deliberate — the suggestion would have demanded 34 fixes to satisfy a rule about 7. Pinning the drop set after dedupeOperations and asserting the narrower invariant is the better shape, and the invariant found an eighth case immediately.
Two judgements I would have got wrong. Restricting the new pass to writes, so resourceGetDetailPathOverrides keeps fusing pro computer-inventory get — with TestQualifyDuplicateVerbsLeavesGETsToDedupe pinning the version that failed. And scoping the invariant to "root loses to a sub-path" rather than to every drop, which is what keeps app-request-form-input-fields an honest recorded judgement instead of a forced fix.
Finding (1) is not a criticism of any of that. It is an older name-keyed table that the rename staled two rounds ago, with a guard that checks the parent and not the child.
Re-reviewed with the pr-review skill (Claude Opus 5). The prior finding was re-verified by generating on origin/main and this head and diffing the dropped-operation sets; finding (1) was found by sweeping the shipped command surface for names the rename moved.
`pro.go` suppresses a generated `erase` and `remove-mdm-profile` by name, in
favour of the hand-written pair that targets by serial, name or group and
requires `--confirm-destructive` for a bulk destructive operation. It was
suppressing the wrong two operations.
`POST /v1/computer-inventory/{id}/erase` and `/v4/computers-inventory/{id}/erase`
are the same endpoint at two path *shapes* — v4 renamed the collection segment —
so `deduplicateVersionedOps`, which keys on the shape, saw two unrelated
endpoints and kept both. The deprecated v1 pair took the plain names, the
suppression matched them, and the served v4 pair shipped beside `pro comp erase`
and `pro comp remove-mdm` as `v-4-computers-inventory-erase` and
`v-4-computers-inventory-remove-mdm-profile` — reachable with `--group … --yes`,
one flag short of the gate the hand-written pair require. Those two paths are
also the only ones in the document whose collection segment is singular, so they
alone were deciding the resource's own name.
The v1 pair is dropped at ingest, the same way inventory-preload v1 already is
and for the same reason. `main` shipped neither, so no capability moves.
Three more defects the same wiring hid:
- All three wiring helpers answered a stale key by doing nothing, and for a
suppression that does not remove a command, it ships one: `removeSubcommand`
leaves the generated command in place and `replaceSubcommand` adds its
replacement beside it. Every miss is recorded and a test fails on a non-empty
record. It found two more dead keys the rename had left, both removed.
- The destructive bulk block hardcoded `DELETE`, so
`pro mobile-device-groups erase --from-file ids.txt` sent
`DELETE /v2/mobile-device-groups/{id}/erase` to a POST-only endpoint and
reported "Deleted" for each entry. It sends the operation's own method now.
- The confirmation prompts described every destructive operation as a delete
("This will delete N computer-inventory" for a fleet wipe) or interpolated the
raw operation name into the sentence. A `DELETE` still reads "This will
delete"; anything else quotes its own action.
Also in this commit:
- No command name carries an API version. `v-2-scope-delete-multiple` was the
third; where two sub-path operations collide and neither addresses the
resource's root, each is now qualified by the thing it acts on
(`attachments-delete-multiple`, `scope-delete-multiple`), and both old
invocations refuse naming both replacements.
- A global flag between the product token and the resource token no longer
silences the deprecation warning or the moved-verb refusal. The token was read
from argv by treating anything not starting with `-` as the resource, so
`pro -p ci-svc icons get` read the profile name — an ordinary invocation, for
which the migration signal was absent for all 100 retired names. Flag arity is
asked of the command's own flag set, by cobra's rules.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@ktn-jamf — round 3 addressed in (1) CRITICAL — the destructive twinsYour diagnosis of the symptom was exactly right and the cause is one step further back: both keys matched, they just matched the wrong operations.
The deprecated pair won the plain names (same segment count, same method, document order decided it), so:
Fixed at the root: the v1 pair is dropped at ingest ( No Your four suggestions1. Names match, and a stale key is no longer silent. All three helpers record every miss at the moment of the failed lookup and 2. Child names are asserted, and against the pre-wiring tree in the sense that matters. Rather than rebuilding the generated parent, the miss is recorded before any replacement is added — so the record distinguishes a successful replace from a stale key, which is the thing you correctly said the assembled tree cannot. It also covers the whole-resource removals the source grep skips ( 3. Keying on the endpoint — I did not take this, and the reason is the noun. The two twins are 4. The wording is fixed, and fixing it exposed a correctness bug in the same block. The destructive bulk block is shared by a plain resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil) // on /v2/mobile-device-groups/{id}/eraseSo For the prose: a (2) IMPORTANT — the flag placementFixed, and you were right that arity cannot be re-derived from spelling: it is now asked of the command's own flag set, by cobra's One trap worth recording, because it reintroduced the same bug one layer down: Verified on the built binary, all placements:
(3) NICE-TO-HAVE — taken, with your convention
Two details that took a second pass, both from qualifying by the wrong segment:
The resulting surface diff is exactly four lines, and nothing else in 1757 commands moved.
Mutation checksSix, each reverted after:
Wire verificationEU tenant-scoped platform credentials and the
DocumentationCHANGELOG gained three sections (the version-free names with a before/after table, the v4 device actions and the v1 drop, and two Two CLAUDE.md staleness fixes fell out of this, both pre-existing on the branch rather than introduced by this round: the Ready for re-review. |
ktn-jamf
left a comment
There was a problem hiding this comment.
Re-review of d336a00 (round 4)
All three round-3 findings are fixed, and the CRITICAL's root cause was a layer below where I put it. I had the symptom right and the diagnosis wrong: both pro.go keys matched, and they matched the deprecated operations. POST /v1/computer-inventory/{id}/erase and POST /v4/computers-inventory/{id}/erase are one endpoint at two path shapes, deduplicateVersionedOps keys on the version-stripped shape and so saw two unrelated endpoints, and the deprecated pair won the plain names on document order. Dropping the v1 pair at ingest is the right fix and is the inventory-preload entry already sitting beside it with the noun changed.
Verified independently, all on the built binary:
pro computer-inventorylistseraseandremove-mdmand nov-4-twin;pro comp erase --helpstill carries--confirm-destructive.- No version-bearing command name survives anywhere in the tree, so the NICE-TO-HAVE went with it.
- The hand-written erase still sends
/v4/computers-inventory/{id}/erase, byte-identical toorigin/mainat the same line, so dropping the v1 pair costs no capability. origin/maindrops 32 operations, this head 15, with zero new drops in either direction.- The moved-verb refusal and the deprecation warning both fire for every flag form I could construct: space-separated value,
=form, short form, boolean, and combined shorts.
One thing to record about my round-3 evidence. My first re-test of the flag placement reported five failures, and all five were my own artefact — I used a nonexistent -p ci-svc, which fails in PersistentPreRunE before the leaf guard runs. The warning fired in that output, which is what showed the token had been found correctly. Re-run with a harmless value-taking flag, every placement passes.
No new findings of my own this round. Two things below are an answer to the question you asked and a note on what the fix turned up, not findings.
Tip
Merge-ready. Twelve findings across four rounds are all fixed and independently verified, every gate is green, and this round found nothing new.
Rating: 5/5
Prior findings status
| # | Location | State | Notes |
|---|---|---|---|
| (1) | internal/commands/pro.go:107-108 |
✅ Fixed | Root cause corrected one layer below my diagnosis: the deprecated v1 pair is dropped at ingest, so both keys now match the served v4 operations. No v-4- twin ships, --confirm-destructive is intact on the hand-written pair, and the hand-written path is unchanged from main. A stale key is no longer silent — all three helpers record a miss and TestProWiringResolvesEveryNameItUses fails on a non-empty record, which found two more dead keys immediately |
| (2) | internal/commands/moved_invocations.go:166-208 |
✅ Fixed | Flag arity is asked of the command's own flag set by cobra's stripFlags rules. Verified across every placement and form; --quiet correctly still does not silence the warning |
| (3) | mobile_device_prestages.go |
✅ Fixed | scope-delete-multiple and attachments-delete-multiple, both in the migration table. The ambiguous plain delete-multiple is gone, and the two prestage resources no longer answer the same verb with different nouns |
The question you asked
Keep the version-segment skip in buildDisambiguatedName. You are right that no live case reaches it and right to flag that rather than leave it unsaid, and the repo has a precedent that looks like it points the other way — DeduplicateVersioned was deleted rather than left dormant, on the grounds that "kept dormant they would have been worse than absent: an exported entry point with passing tests, describing a rule about filenames in a generator that no longer reads one."
The distinction is what the dormant code is about. DeduplicateVersioned described a rule about filenames, and filenames had stopped existing — its subject matter was gone, so no future input could reach it. The version skip describes a rule about version segments in paths, and those are everywhere in the document. It is dormant because a different mechanism (two droppedPaths entries) currently removes the only input that reached it, not because its subject is gone. A spec drop that reintroduces a versioned twin — which is exactly what an upstream deprecation cycle does — puts a live case back.
So the two mechanisms are not redundant. The skip makes a version-free name correct by construction; TestNoCommandNameCarriesAnAPIVersion catches one that slips through. Deleting the skip leaves the property resting on a test that reports the problem after the fact rather than on a rule that prevents it. It is also directly tested, so it is not an untested branch — which was the real objection to a dormant mechanism.
One thing I chased and am not raising
A lane flagged that firstPositional mis-locates the resource token for a clustered short-flag group whose last flag takes a value — pro -qp ci-svc icons get reads ci-svc as the resource, since the switch special-cases a single-character shorthand only. As a unit fact that is correct, and it looked like round 3's finding returning through a different door.
It is not a defect, and the reason is the design rule you stated. Cobra does not resolve that form either. On a command with no deprecation involved at all:
$ jamf-cli pro -qp ci-svc categories create --scaffold
unknown flag: --scaffold # exit 2 — never reached `categories create`
$ jamf-cli pro -q -p ci-svc categories create --scaffold
{ … } # exit 0
origin/main behaves identically, so this is cobra's own stripFlags carrying the same single-character limitation, and the invocation is broken before any guard is consulted. The guard is therefore agreeing with cobra, which is exactly what you said it must do — "the guard has to agree with cobra about where the resource token is rather than about what a valid command line looks like". Fixing the guard alone would make it disagree: it would report the resource as categories while cobra had resolved to ci-svc.
Worth one cheap thing, if you agree: a test row pinning that shape as agreeing with cobra rather than as correct, so a future reader who spots the same asymmetry does not "fix" it into a divergence. The existing -nq row passes because both flags are boolean, which is what made this look open.
Two smaller lane observations I am also not raising. staleProWiring is read only by its test, with no runtime signal — but a build-time wiring invariant enforced by a test is this repo's convention throughout, and a stderr warning on every invocation would be noise for a defect only a build can have. And a droppedPaths entry cannot detect its path being re-purposed as a live operation, only disappearing — a pre-existing property of that table rather than of these two entries, and the patch-family round-trip in CLAUDE.md shows the reversal case is watched by hand.
Two notes, not findings
The actionMethod bug is the more valuable half of this round. pro mobile-device-groups erase --from-file ids.txt sent DELETE to a POST-only endpoint and reported Deleted for each entry, present in v1.28.0 and earlier. I asked only for the prose to be fixed; the hardcoded method in the same block is a real shipped defect and a worse one. Verified: the generated bulk and single paths now send POST, and the prose reads This will run "erase" on 12 mobile-device-groups. Both halves carry a Fixed CHANGELOG entry with the "present in v1.28.0 and earlier" note, which is the right place for a pre-existing bug a refactor uncovers.
Declining my suggestion 3 was correct. I proposed keying the wiring on the endpoint rather than the name, on the reasoning that the endpoint is stable where the name is not. The two twins are …/computer-inventory/{id}/erase and …/computers-inventory/{id}/erase, so an endpoint key would have needed both spellings listed and the same edit would have been required — it is stable against a name rename and not against this. Pinning the property instead (TestTheDestructiveComputerActionsAreOneOperationEachOnTheServedVersion, one erase and one remove-mdm-profile, each on the v4 path) is the stronger answer, and the mutation table shows it is the only guard that fails when the two droppedPaths entries are reverted.
Review coverage and scope
Round 4. Incremental diff 666ba61..d336a00, one commit, 40 files.
- Prior findings — all three verified independently on the built binary rather than from the reply, with
origin/mainbuilt alongside for comparison - Capability — dropped-operation lists diffed
mainagainst this head (32 → 15, zero new); the hand-written erase path compared line for line againstmain - The flag-placement fix — eight invocation shapes, after discarding my own faulty first test
- The
actionMethodfix — template and generated output both read; bulk, group and single branches all send the operation's own method - Documentation — both staleness fixes checked against the binary: CLAUDE.md now says 28 unpublished Pro operations, the refused count is 59 and the binary agrees, and
pro policy-propertiesshipsget/updateon the versioned path with no refusal, which is what the corrected paragraph says - Silent failure — dispatched. Three observations, all resolved above as non-defects after I reproduced each against
origin/main; it independently confirmed theactionMethodfix covers all three destructive branches and that the two bulk blocks were the only hardcodedDELETEs in the template - Test quality — your mutation table reproduced independently, all six rows. The load-bearing one holds: reverting the two
droppedPathsentries failsTestTheDestructiveComputerActionsAreOneOperationEachOnTheServedVersionand nothing else, withTestNoCommandNameCarriesAnAPIVersionandTestProWiringResolvesEveryNameItUsesboth still passing — which is the argument for that pin existing. Disabling the root-less qualification atparser.go:1690failsTestEveryFormerInvocationKeepsItsShapewith exactly the 2 rows claimed, and changes exactly one generated file.TestStaleProWiringIsRecorded's third assertion — that a resolving key is not recorded — is real and fires, so the passing case is not a broken accumulator.TestNoCommandNameCarriesAnAPIVersionwalks every product, not just Pro, with achecked < 1500floor - Claim accuracy — one number in your reply is off and nothing depends on it: that walk examines 1822 names, not 1757. The test asserts only the floor
- [na] Performance, frontend — unchanged
Rounds 1 to 3 findings are not re-reviewed; all twelve were verified fixed in their rounds and nothing in this diff touches them.
What is done well, across four rounds
Each round fixed the mechanism rather than the reported instance, and three times the fix reached further than the report. Round 2 restored nineteen operations where I named seven. Round 3 corrected my proposed guard to a better shape and the correction found an eighth case. Round 4 found the root cause a layer below my diagnosis and fixed a shipped DELETE-on-POST bug that my complaint about prose happened to sit next to.
Three habits are worth naming because they are why this converged. Every guard was mutated before being claimed sound, and the mutation table states which guard fails for which mutation — including the one row that reads as a weakness and is the argument for the pin existing. Every fix that could not be exercised on the wire says so and says why, rather than implying coverage. And each round reported a defect found by the fix that nobody had asked about: boundKey, the eighth collision case, the hardcoded DELETE.
The one thing I would carry forward is that my own round-1 and round-2 reviews missed two defects that a comparison against origin/main's generated surface would have surfaced immediately. Diffing the shipped command surface against the base branch, not only the diff, is what found both.
Re-reviewed with the pr-review skill (Claude Opus 5). Prior findings were re-verified on the built binary with origin/main built alongside; the dropped-operation sets were regenerated on both.
procommand names come from the spec — the API's own OpenAPI tags and URL paths — instead of from the names of the 165 files the specs were split into.specs/becomes one normalised document.This is breaking. CHANGELOG.md is the migration guide and carries every moved invocation; this description is the reviewer's map.
Why
A resource's name came from the filename its paths were split into — upstream's jss module names, which appear in no published spec and in no API reference. Four things followed from that filename with nothing stating them: the command name, the endpoint-version family, whether a
-previewtag reached a command at all, and whether the ingest could delete the file.Two consequences:
specs/and re-ingesting the same live monolith gave 127 files instead of 165, 132 resources instead of 169, 73 operations dropped, 98 command groups renamed and 18-previewtags published as commands — at exit 0.-vNfilename suffix, which is how this CLI once sent/v3/computers-inventorywhile/v4was the served version, with the two v4-only operations never generated at all.Both are now facts about the paths. The second class is inexpressible rather than guarded, which is why this PR deletes the pass that used to guard it.
The rule
The tag decides what belongs together, the path decides where the boundary falls, and the name comes from the tag. Measured on the live 11.31.1 monolith (550 paths, 820 ops) against 163 resources before:
Neither signal works alone and both failure modes were measured — paths split a resource upstream considers whole (
computers-inventoryis three path roots), tags are too coarse to be a command (computer-groupscarries two complete CRUD sets that would both wantget). Tags are sound rather than convenient here: no path in the document carries two different tags.The decisive argument for naming from the tag rather than the path was the override table: path naming needed eight entries and seven were names invented in this repo (a
pki-prefix the reference does not use,ldap-lookups,ddm-clients,certificate-authorities). One override survives.Independently-writable sub-paths nest
A tag can cover several path roots, and merging them is right for the resource's identity and wrong for its verbs. A flattened sub-path produces a plain CRUD verb that silently belongs to something else, and because a lone verb collides with nothing, no naming pass reached it:
pro sso-settings deletesentDELETE /v2/sso/cert— it deleted the SSO certificate, not the configuration its name names.pro managed-software-updates-plans updatesentPUT …/plans/feature-toggleon a resource whoselist,getandcreateare real plan CRUD.pro csa deletedeleted the CSA token.The rule is read off the methods the spec declares: a sub-path carrying
PUT,PATCHorDELETEon itself owns a separately-writable object and becomes a command of its own. No override table. That admits nine sub-paths and refuses every other one, all 18 GET+POST/historypairs included — by the rule, not by ahistoryspecial case.One further condition: a sub-path holding every operation in its group does not split, there being no sibling verb for the plain one to be confused with (
pro app-request getstays as it is).Reviewing this
Start here. These three files are the change; the rest is consequence.
generator/parser/taggroup.goGroupPathsByTagAndCollectiongenerator/parser/pathgroup.gogenerator/parser/subresource.goThen the guards. Each failed on a real defect during the work rather than being written afterwards to describe one:
TestParseMonolith_LosesNoEndpoint— the guard for the whole change, against a committed snapshot of every endpoint reachable under the 165-file layout. 704 → 704, zero gained or lost.TestEveryFormerInvocationKeepsItsShape— every one ofmain's 1355proinvocations resolved through the new tree. Resolution is not the property; shape is. cobra'sFindfalls back to the deepest command it matched, and a leaf can become a help-printing group at exit 0 — three did, and a resolution-only sweep called all three fine.TestSubResourcePartitionIsPinned— the nine admitted and the composition of what is refused, so a spec drop that adds aDELETEto a sub-path fails visibly instead of silently moving commands.TestNoPlainVerbLeavesItsResource— the test that would have caughtpro sso-settings delete, written against the whole shipped surface with four pre-existing cases allowlisted and each carrying its reason.TestEveryResourceKeyedOverrideNamesALiveResource— the guard whose absence cost six user-visible defects at once when the rename moved resources out from under thirteen override keys.Two traps worth knowing before reading the passes. Three separate passes encoded "the resource is one path root" — true while identity came from a filename, false once a tag merges several — and each shipped a differently-wrong command name at exit 0.
Resource.Rootis now carried rather than inferred, because every attempt to infer it was wrong in a different way. And the sub-resource split added a fourth level to that same model, which is where three more passes needed re-reading.Compatibility
--quietor--no-hints, andTestDeprecatedNamesHaveNotExpiredfails the build on the date so the aliases cannot rot silently.applycommands are gone and none of them worked.pro managed-software-updates-plans applywas destructive rather than absent:/planspublishes no update at all, so the onlyPUTthe resource carried was the feature toggle's andapplycomposed it — given an existing plan's name it resolved the name to an id and thenPUTthe plan document at/plans/feature-toggle.Verification
make lint0 issues; full suite,verify-generated,verify-gateway-coverage,verify-classic-schemasandverify-siteall pass. Every commit builds and tests green individually.specs/and zero to generated code — the reproducibility property the change exists to establish.resolveNameToIDcall sites were diffed againstmain: one change, the removed plansapply.pro self-service-plus settings updateround-trips through a body and through--set; all four two-token redirects warn and return their own endpoint's body;-n pro sso-settings cert delete --yespreviews rather than executing;--scaffoldworks with no credentials.Two silent meaning changes repaired
Neither is visible to a test that only checks whether a command resolves, and both were introduced by the rename itself:
pro sso-settings downloadsentGET /v3/sso/metadata/downloadbefore the rename andGET /v2/sso/cert/downloadafter it, because the certificate'sdownloadwon the collision. It sends the SAML metadata again; the certificate is atpro sso-settings cert download.pro sso-settings deleteno longer exists rather than deleting the certificate.Deliberately not in this PR
listdefects, each alistpointing at a single object rather than a collection (pro m2m listreturns a tenant id,pro jamf-protect listthe Protect plans, and five more). Both new naming passes are scoped to resources the sub-resource split touches, so these keep the names they ship under — fixing them is seven breaking renames of unrelated commands, and it is not this change..wiki.gitrepo and carries command names. Nothing in this repository references a dead name; the wiki needs a follow-up pass after merge.🤖 Generated with Claude Code