Skip to content

fix(legacy): make hidden command aliases resolve again - #178

Merged
pjcdawkins merged 3 commits into
mainfrom
claude/hidden-aliases
Sep 24, 2026
Merged

pjcdawkins merged 3 commits into
mainfrom
claude/hidden-aliases

Conversation

@pjcdawkins

@pjcdawkins pjcdawkins commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Aliases set with setHiddenAliases(), such as snapshots, logs, user:role and environment:sql, failed with Command "..." is not defined.

Commands are lazy-loaded via the DI container. Symfony's AddConsoleCommandPass only maps names and aliases from the AsCommand attribute, and its setAliases() method call runs after the constructor, overwriting aliases set in configure(). Visible aliases were unaffected: they are all in AsCommand.

  • Hidden aliases are now declared with a #[HiddenAliases([...])] attribute. HiddenAliasesPass adds them as extra console.command tags before AddConsoleCommandPass runs, so they are in the lazy command map.
  • CommandBase::getHiddenAliases() reads the attribute; help, lists, list column widths and command name completion exclude them.
  • The JSON descriptor has a new hidden_aliases field, kept by the Go list model, so a command index built from list --format=json (fix: resolve abbreviations of native commands #176) can include them.
  • A PHPStan stub types Command::getAliases() as string[], removing several baseline entries.

Hidden aliases only work in full: HiddenAliasesCommandLoader leaves them out of getNames() (used for abbreviations and suggestions), and Application::addCommand() removes them from each command's aliases so they do not define namespaces such as snapshot. Application::get() resolves them by exact name. HiddenAliasesPass fails the build if a hidden alias clashes with another name.

Application::add() is replaced by an addCommand() override, which Symfony 7.4 uses for all registrations; the disabled_commands check now applies to default commands too.

Tests: a PHPUnit test (checks all declared hidden aliases resolve, and help/JSON output) and a Go integration test.

🤖 Generated with Claude Code

Commands are lazy-loaded via the DI container, and Symfony's
AddConsoleCommandPass only maps the name and aliases from the AsCommand
attribute. Its setAliases() method call also runs after the constructor,
so aliases added by setHiddenAliases() in configure() were overwritten.
As a result, aliases such as `snapshots`, `logs`, `user:role` and
`environment:sql` failed with "Command is not defined".

Hidden aliases are now declared with a #[HiddenAliases] attribute.
HiddenAliasesPass adds them as extra console.command tags before
AddConsoleCommandPass runs, so they are in the lazy command map.
CommandBase reads the attribute to keep them out of help, lists and
command name completion.

The JSON descriptor now has a `hidden_aliases` field, so a command
index built from `list --format=json` can include them.

A PHPStan stub types Command::getAliases() as string[], which removes
several baseline entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Reviewed — No blocking findings · 🔵 2 minor points

🔍 Full review · 29 files reviewed

🔵 Minor points

  • legacy/src/Application.php:157 — Registering hidden aliases in the DI command map also registers their namespaces with Symfony's Application, and namespaces that contain only hidden aliases now shadow the "command not defined" error. snapshot:*, int:act and i:act create the namespaces snapshot, int and i; Application::doRun() calls findDescribableNamespace(), find() throws (ambiguous), findNamespace() succeeds, and listNamespace() renders a listing that DescriptorUtils::describeNamespaces() empties again (it skips every non-canonical name). Verified by running the CLI on this checkout: cli snapshot, cli int and cli i each print only Available commands for the "&lt;x>" namespace: with no commands and exit 1, and cli help snapshot prints the same with exit 0. On the base branch these names are unknown, so the user gets Command "snapshot" is not defined. with suggestions.
  • legacy/src/Console/CustomJsonDescriptor.php:150 — The new hidden_aliases field survives only in the phar's own output. upsun list --format=json is served by the Go wrapper (commands/list.go), which unmarshals the legacy JSON into commands.Command (commands/list_models.go:215-224 — Name, Usage, Aliases, Description, Help, Examples, Definition, Hidden) and re-encodes it with JSONListFormatter; the struct has no field for hidden_aliases, so the key is dropped from the shipped CLI's JSON. Any index built from the released binary's list --format=json, rather than from the phar directly, will not see hidden aliases.
Verification
  • All 20 setHiddenAliases() call sites on the base map 1:1 onto #[HiddenAliases([...])] attributes with identical alias lists; no alias was lost.
  • No hidden alias collides with another command's name or visible alias, so no entry in AddConsoleCommandPass's lazy command map is silently overwritten.
  • HiddenAliasesPass is added before AddConsoleCommandPass at the same priority, so the extra console.command tags exist when the lazy map is built — running the CLI resolves snapshots, int:act and the abbreviation sql-dum.
  • Descriptors and Application::complete() operate on resolved CommandBase instances (DescriptorUtils unwraps LazyCommand), so list backup and _complete output show only visible aliases.
  • array_values() in getVisibleAliases() keeps the JSON aliases field a list even when the first alias is hidden.

The diff adds legacy/tests/Console/HiddenAliasesTest.php (every declared hidden alias resolves, plus help/JSON visibility) and integration-tests/hidden_aliases_test.go (four aliases via help --format json, plus list backup); these run in the legacy-php and integration-test GitHub Actions jobs, which also run phpstan and php-cs-fixer and report passing for this head. Nothing covers the namespace side effect reported above, and no test asserts on the Go list --format=json payload.

Review details
  • Commit: 736c3c7
  • Model: claude-opus-5

Review 1 of 10 for this pull request · View the full run

Registering hidden aliases in the lazy command map also made them
define namespaces (`snapshot`, `int`, `i`) and match abbreviations.
`upsun snapshot` printed an empty namespace listing instead of "Command
is not defined", and hidden aliases appeared in suggestions.

Hidden aliases now only work in full:
- HiddenAliasesPass lists them in a container parameter.
- HiddenAliasesCommandLoader leaves them out of getNames(), which
  Symfony uses for abbreviations and suggestions.
- Application::addCommand() removes them from each command's aliases,
  so they do not define namespaces, and Application::find() resolves
  them by exact name.

The Go list model now keeps the `hidden_aliases` field, so it is not
dropped from `list --format=json`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Reviewed — No blocking findings · 🔵 4 minor points

🔁 Incremental · 6 files reviewed

🔵 Minor points

  • legacy/src/Application.php:143 — Declaring addCommand() here kills the isCommandEnabled() filter in add() above it. Symfony 7.4's Application::init() picks its adder with a BC shim: (new \ReflectionMethod($this,'add'))->getDeclaringClass() !== (new \ReflectionMethod($this,'addCommand'))->getDeclaringClass() selects $this->add(...), otherwise $this->addCommand(...) (vendor Application.php:1351-1357). Before this commit only add() was overridden here, so the default commands (help, list, _complete, completion) went through the config check; now both methods are declared in this class, the classes compare equal, and getDefaultCommands() is registered via the new addCommand(), which does not consult Config::isCommandEnabled(). Combined with has()/all() already calling addCommand() directly for lazily loaded services (vendor line 642), add()'s check is now dead code for every registration path, and a name in application.disabled_commands / wrapped_disabled_commands is still registered and runnable.
  • legacy/src/Application.php:146 — Stripping the hidden aliases off the command object makes has() and get() disagree for a hidden alias: Application::has('snapshots') still returns true (the loader has it, and addCommand() returns the command), but the command is now registered only under backup:list, so Application::get('snapshots') reaches the !isset($this->commands[$name]) branch and throws CommandNotFoundException: The "snapshots" command cannot be found because it is registered under multiple names. Callers that guard with has() before get() (a normal Symfony idiom; the repo's own guards happen to use canonical names only) get a misleading exception instead of the command.
  • legacy/src/Console/HiddenAliasesCommandLoader.php:38 — getNames() subtracts the hidden-alias list from the loader's names by value, with nothing guarding against a hidden alias that collides with another command's real name or visible alias. No such collision exists today (I compared all 23 #[HiddenAliases] entries against every #[AsCommand] name and alias), but if one is ever added the real command silently disappears from all(), from list, from abbreviation matching and from completion, and Application::find() resolves the name to the hidden alias's command instead. HiddenAliasesPass could detect the clash at compile time and fail the build.
  • legacy/src/Application.php:86 — getParameter(HiddenAliasesPass::PARAMETER) is unguarded, and the container is loaded from config/cache/container.php whenever that file exists, with no staleness check (see container() below). A checkout with a container cached before this change — e.g. a developer switching to this branch without re-running composer install or make clean — now fails with an uncaught ParameterNotFoundException on every command, instead of the previous behaviour of quietly missing the aliases. hasParameter() with an empty-array fallback would degrade instead of crashing.
Verification
  • No hidden alias collides with any #[AsCommand] name or visible alias — I extracted all 23 hidden aliases and compared them against every command's name and aliases.
  • find()'s new branch resolves an exact hidden alias to the canonical name via the loader, and still honours --help because get() applies wantHelps.
  • Excluding hidden aliases from getNames() plus stripping them in addCommand() keeps them out of getNamespaces(), so findDescribableNamespace('snapshot') returns null.
  • The Go Command struct's new hidden_aliases field is re-encoded by JSONListFormatter, which marshals List directly, so the key survives the wrapper.
  • Both descriptors unwrap LazyCommand before reading aliases, so stripping aliases from the lazy wrapper does not change help or list output.

New PHPUnit cases (testHiddenAliasesDoNotDefineNamespaces, testHiddenAliasesAreNotAbbreviated) run in the legacy-php job and the extended Go TestHiddenAliases runs in the integration-test job; gh pr checks 178 shows both passing. Nothing covers the config-disabled-command path (isCommandEnabled) that the new addCommand() override now bypasses, nor a stale container cache.

Review details

Review 2 of 10 for this pull request · View the full run

Symfony 7.4 registers commands via addCommand(). Overriding both add()
and addCommand() made it skip add() for default commands too, so the
disabled_commands check never ran. The check now lives in
addCommand(), and add() is no longer overridden.

Hidden aliases are resolved in get() instead of find(), so has() and
get() agree for them.

HiddenAliasesPass fails the build when a hidden alias clashes with a
command name, an alias, or another hidden alias. The application falls
back to no hidden aliases when a stale container cache lacks the
parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@upsun-dispatch

Copy link
Copy Markdown

📋 PR Summary

This PR fixes hidden command aliases such as snapshots, logs, user:role and environment:sql, which failed with "Command is not defined" because lazy-loaded commands weren't registered under them. Each command now declares its hidden aliases with a #[HiddenAliases] attribute. A compiler pass registers those aliases in the lazy command map, and the pass now fails the build if a hidden alias clashes with another command's name or alias. Application resolves hidden aliases only when typed in full, through get(), so has() and get() agree. Help, list output and name completion leave hidden aliases out, but the JSON output includes them in a new hidden_aliases field.

Changes
Layer / File(s) Summary
Hidden alias mechanism
legacy/src/Console/HiddenAliases.php New attribute that declares a command's hidden aliases.
legacy/src/Console/HiddenAliasesPass.php Compiler pass that adds hidden aliases as console.command tags and stores them in a container parameter. It throws a LogicException if a hidden alias duplicates another hidden alias or an AsCommand name or alias.
legacy/src/Console/HiddenAliasesCommandLoader.php Wraps the command loader and leaves hidden aliases out of getNames(), so they aren't used for abbreviations or suggestions.
legacy/src/Application.php Reads the hidden-alias parameter, treating it as empty when it is missing. The disabled-commands check moves from add() into addCommand(), which also strips hidden aliases from each command. A get() override replaces the earlier find() override and resolves hidden aliases by exact name.
legacy/src/Command/CommandBase.php getHiddenAliases() now reads the attribute.
Command declarations
legacy/src/Command/Backup/BackupListCommand.php Moves its hidden aliases into the #[HiddenAliases] attribute. About 20 other command classes get the same change.
Output
legacy/src/Console/CustomJsonDescriptor.php Adds a hidden_aliases field to the JSON output.
legacy/src/Console/CustomTextDescriptor.php Leaves hidden aliases out of the text output.
commands/list_models.go Go list model keeps the hidden_aliases field.
Static analysis
legacy/phpstan-stubs/Command.stub Stub that types Command::getAliases() as string[].
legacy/phpstan.neon Registers the stub.
legacy/phpstan-baseline.neon Removes baseline entries the stub makes unnecessary.
Tests
legacy/tests/Console/HiddenAliasesTest.php Tests alias resolution, help and JSON output, that has() and get() agree, and that disabling a command also disables its hidden aliases.
legacy/tests/Console/HiddenAliasesPassTest.php Tests the stored parameter and clash detection in the compiler pass.
integration-tests/hidden_aliases_test.go Go integration test for hidden aliases.

@pjcdawkins
pjcdawkins merged commit 677bb72 into main Sep 24, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant