Skip to content

fix(laravel): index commands registered via withCommands() and their aliases - #323

Open
krist7599555 wants to merge 3 commits into
PHPantom-dev:mainfrom
krist7599555:fix/laravel-command-index-discovery
Open

fix(laravel): index commands registered via withCommands() and their aliases#323
krist7599555 wants to merge 3 commits into
PHPantom-dev:mainfrom
krist7599555:fix/laravel-command-index-discovery

Conversation

@krist7599555

@krist7599555 krist7599555 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Caution

This is vibe code fix

How this was discovered

Found in a Laravel 13 project whose Artisan commands live in app/Actions/Sync/, registered via withCommands() in bootstrap/app.php and declared with the Laravel 11 #[Signature] attribute.

The LSP flagged $this->call('sync:forma-projects') as "Unknown command: 'sync:forma-projects'", while at the same time:

  • php artisan list registered the command, and
  • phpantom_lsp analyze (via composer analyze) reported zero findings.

So the CLI and the LSP disagreed about the same command set. Digging into the source showed why:

  1. analyze also runs collect_fast_diagnostics, but the command-name check is gated on a non-empty index (if command_names.is_empty() { continue; }), and the headless analyze path never built the index — so the check was skipped entirely and "No errors" was not actually validation.
  2. The LSP does build the index, but discovery only saw *Command-named classes and files under Console/Commands/, and name recovery didn't understand the #[Signature] attribute — so the command was genuinely missing from the index and the warning was a false positive.

The analyze-side index gap has since been addressed upstream (6dc9a7a); the discovery and attribute gaps remained and are what this PR fixes.

Summary

The Artisan command index only treated classes whose short name ends in Command or files under Console/Commands/ as candidates, and recovered command names only from $signature, $name, and #[AsCommand]. Laravel 11+ registers commands differently:

  1. withCommands() in bootstrap/app.php loads commands from arbitrary directories (e.g. app/Actions/Sync) whose classes use the #[Signature('name {--opt}')] attribute — none of which the index recognizes.
  2. Commands can answer to aliases via #[Aliases([...])], the aliases: argument of #[Signature]/#[AsCommand], or Symfony's #[AsCommand(aliases: ...)] — none of which are indexed.

Both gaps produce the same false positive: $this->call('sync:forma-projects') (or Artisan::call('<alias>')) is reported as "Unknown command" by the LSP even though php artisan list registers it.

Fix

1. Complete candidate discovery (server.rsbuild_laravel_command_index + refresh_laravel_command_index)

Candidates are now: short name ends in Command or under Console/Commands/ or any non-vendor project class — scan_command_file's extends-Command/attribute checks decide. The byte pre-filter (signature/Signature/AsCommand/$name) keeps this cheap.

2. Recover the name from #[Signature] (commands.rs)

  • new signature_attribute_value() helper — the Laravel 11+ attribute form of $signature — used in command_from_class (after #[AsCommand], before the $signature property) and command_signature_at_offset (so $this->option('...') validates for attribute-declared commands).

3. Index command aliases (commands.rs)

  • CommandEntry gains aliases: Vec<String>; LaravelCommandIndex indexes each alias alongside the primary name (get()/all_names() resolve aliases).
  • command_aliases() recovers aliases from #[Aliases([...])] (which wins, mirroring Illuminate\Console\Command::configureFromAttributes) and the aliases: argument of #[Signature]/#[AsCommand] (named or positional).

Tests

Five unit tests in commands_tests.rs:

  • scans_signature_attribute_command_class / resolves_enclosing_signature_from_attribute#[Signature] name + option recovery.
  • indexes_standalone_aliases_attribute / indexes_signature_and_as_command_aliases / index_resolves_alias_names — alias recovery from all three attribute sources, plus index lookup via alias.

Verification

  • Full lib suite: 4968 passed, 0 failed (was 4965 before this change).
  • End-to-end on a Laravel app using withCommands([... app/Actions/Sync]):
    • real commands and real aliases → no warning;
    • $this->call('sync:does-not-exist') → correctly reported Unknown command;
    • composer analyze stays at zero findings, and now genuinely validates command names.

Notes for maintainers

  • The analyze (CLI) path already builds the index (added in 6dc9a7a1), so this change makes both modes agree — no CLI-side change needed here.
  • Attribute list checked against the current laravel/framework 13.x (v13.24.0): Aliases, Description, Help, Hidden, Signature, UsageDescription/Help/Hidden/Usage don't affect name resolution, so only the aliases family was addressed.

The Artisan command index only treated classes whose short name ends in
`Command` or files under `Console/Commands/` as candidates, so commands
registered from arbitrary directories (e.g. `withCommands()` in
bootstrap/app.php pointing at app/Actions/Sync) were never indexed. The
LSP then reported `$this->call('sync:forma-projects')` as "Unknown
command" even though `php artisan list` registers it.

Three gaps closed:

- Candidate discovery now also scans every non-vendor project class,
  letting scan_command_file's extends-Command / attribute checks decide.
- The byte pre-filter (build + incremental refresh paths) now recognizes
  the Laravel 11+ `#[Signature]` attribute, which previously slipped
  past the lowercase `signature` token check.
- command_from_class and command_signature_at_offset now read the
  `#[Signature('...')]` attribute as the command name / signature, so
  commands declared the modern way are indexed and their options validate.

The analyze (CLI) path already built the index (added in 6dc9a7a), so
this makes phpantom_lsp analyze and the LSP validate command names
against the same, complete command set.
…butes

Commands can answer to alternative names declared via #[Aliases([...])],
the aliases: argument of #[Signature] / #[AsCommand], or Symfony's
#[AsCommand(aliases: ...)]. phpantom ignored all of them, so calling a
valid alias (->call('cache:clean') for a cache:clear command) was
reported as "Unknown command" — the same false-positive class as the
withCommands() discovery gap.

- CommandEntry gains an aliases: Vec<String> field; LaravelCommandIndex
  indexes each alias alongside the primary name (get()/all_names()
  resolve aliases), so the unknown-command diagnostic accepts them.
- command_aliases() recovers aliases from #[Aliases([...])] (which wins,
  mirroring configureFromAttributes) and the aliases: named/positional
  argument of #[Signature] / #[AsCommand].
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 84.61538% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/virtual_members/laravel/commands.rs 84.61% 16 Missing ⚠️

📢 Thoughts on this report? Let us know!

@krist7599555
krist7599555 force-pushed the fix/laravel-command-index-discovery branch from e07dcd7 to b86ae52 Compare August 5, 2026 10:35
@krist7599555
krist7599555 force-pushed the fix/laravel-command-index-discovery branch from b86ae52 to 66af47b Compare August 5, 2026 10:46
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.

2 participants