chore(legacy): raise PHPStan to level 10 - #174
Conversation
PHP converts integer-like array keys (e.g. profile sizes "1", "2") to ints, so Symfony's ChoiceQuestion treats the choices as a list and returns the description instead of the key. In interactive resources:set, when the service minimum hid all fractional sizes, the chosen profile_size became e.g. "CPU 2, memory 768 MB (shared)" and was sent to the API. The "(default)" marker was also never shown for these sizes. QuestionHelper::chooseAssoc() now maps the answer back to its key. Covered by TestResourcesSet_InteractiveIntegerProfileSizes in integration-tests/resources_set_values_test.go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The --disk help documents 'default', 'min' and 'minimum' values, but the integer check ran first, so `resources:set --disk app:default` always failed with "Invalid disk size default: it must be an integer in MB". Check the keywords before validating the integer. Covered by TestResourcesSet_DiskKeywords in integration-tests/resources_set_values_test.go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pressing Enter without typing at the auth:api-token-login or auth:verify-phone-number prompts passed null to the validator, which crashed with a TypeError (a typed string parameter, and PhoneNumberUtil::parse() respectively). Symfony only retries on exceptions, so the command aborted instead of asking again. Covered by TestEmptyInteractiveInput in integration-tests/empty_input_test.go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
remove($dir, true) was meant to retry after adding write permissions, but the retry never worked: Symfony renames a directory before deleting its contents and does not rename it back when an unlink fails, so the original path was gone. Recursing into subdirectories would also have thrown a TypeError, as FilesystemIterator yields SplFileInfo objects and the file uses strict types. Add the permissions before removing instead. This affects local:build when the previous build contains read-only files. Covered by FilesystemServiceTest::testRemoveDirWithChmod. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Level 10 reports mixed values reaching typed parameters, such as unchecked getOption() results and interactive validator input. It flags the null-validator crashes fixed in the previous commits. The baseline grows from 1178 to 1796 errors (+618). The new entries were reviewed and not pursued: most are mixed values read from API responses, json_decode() and getProperties() arrays that the API always populates, and generic Symfony returns. The rest are narrow edge cases left for follow-up: local:web's router with `passthru: true`, and session:switch with all-numeric session IDs. New code must pass level 10; CI runs it via `make lint-phpstan`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With chmod enabled, a generator was exhausted by unprotect() before Symfony's remove() could traverse it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 2 minor points
🔍 Full review · 10 files reviewed
🔵 Minor points
legacy/src/Service/Filesystem.php:65—remove()now callsunprotect($files, true)outside the try block on every$chmodcall, not only after a failed delete.unprotect()buildsnew \FilesystemIterator($file, ...)for each directory, which throwsUnexpectedValueException("failed to open dir") when the directory cannot be opened — e.g. a directory the user owns with mode 0300 (write+exec, not readable: the chmod guard!is_executable($file) || !is_writable($file)is false for it, so it is never relaxed), or a directory removed by another process betweenis_dir()and the iteration. That exception is not anIOException, so it escapesremove()instead of being turned into a warning and afalsereturn, aborting commands such aslocal:buildwith an unhandled exception where the old code returned false.legacy/src/Service/QuestionHelper.php:170—chooseAssoc()maps the answer back to a key with(string) array_search($choice, $items, true)when all keys are integer-like. Two failure modes follow from casting the result: if two entries share the same description string (Symfony only rejects ambiguity when the user types the text, not when they type the index) the first matching key is returned, so the wrong item is chosen; and if the value is not found at allarray_searchreturnsfalse, which(string)turns into""— the method then reports an empty key as a valid choice instead of failing. The siblingchoose()guards the same lookup withif ($choiceKey === false) throw new \RuntimeException(...); this path does not. The condition also re-implements Symfony's privateChoiceQuestion::isAssoc()heuristic, so a change there silently reintroduces the description-instead-of-key bug.
Verification
- validateDiskSize() now handles 'default'/'min'/'minimum' before the integer check, so those keywords no longer hit the 'must be an integer in MB' error.
- Both new validator closures accept ?string and reject empty input with an exception the Symfony question loop catches and re-prompts on, rather than a TypeError.
- remove() materialises a non-string iterable with iterator_to_array() before unprotect(), so a generator of paths is no longer consumed before fs->remove() sees it.
- unprotect() now iterates with FilesystemIterator::CURRENT_AS_PATHNAME, so is_link()/is_dir() get strings as strict_types requires instead of SplFileInfo.
- chooseAssoc()'s new key lookup only triggers when every key is integer-like, leaving string-keyed callers such as EnvironmentDeployCommand unchanged.
Each behaviour fix has a test in the diff: integration-tests/resources_set_values_test.go covers --disk app:default|min and the interactive integer-keyed profile-size choice; integration-tests/empty_input_test.go covers empty input at the two auth prompts; FilesystemServiceTest::testRemoveDirWithChmod covers remove($dir, true) for a string and a generator. CI runs these via the integration-test and legacy-php jobs in .github/workflows/ci.yml, which also runs make lint-phpstan at the new level 10; no PHP unit test covers QuestionHelper::chooseAssoc directly.
Review details
- Commit: 8a735f9
- Model: claude-opus-5
Review 1 of 10 for this pull request · View the full run
chooseAssoc(): accept a typed key directly, so items with duplicate descriptions resolve to the key the user chose, and throw when the answer matches no item instead of returning an empty key. unprotect(): also relax directories that are not readable (e.g. mode 0300), and return false instead of throwing when a directory cannot be opened. Addresses review feedback on the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 2 minor points
🔁 Incremental · 4 files reviewed
🔵 Minor points
legacy/src/Service/Filesystem.php:106— The newcatch (\UnexpectedValueException)stops the iterator-construction failure escapingunprotect(), but it only converts it intofalse, whichremove()discards (line 65 ignores the return value) before calling$this->fs->remove($files). Symfony'sFilesystem::remove()itself buildsnew \FilesystemIterator($file, ...)for every directory it descends into, and that throws the sameUnexpectedValueException("failed to open dir"). So for a directory whose mode cannot be relaxed — e.g. a root-owned sub-directory with mode 0300 inside a tree being cleaned bylocal:build, where@chmodfails because the user is not the owner —Filesystem::remove($dir, true)still terminates with an uncaughtUnexpectedValueException(not anIOException) instead of returning false with a warning.legacy/src/Service/QuestionHelper.php:166— The new validator short-circuits onis_string($answer) && array_key_exists($answer, $items), which changes resolution for genuinely associative item sets where one item's value is also another item's key. Given['a' => 'b', 'b' => 'c'], typingbpreviously went through ChoiceQuestion's assoc validator, which resolves a typed value to its key and returned'a'; now it returns'b'. Neither of the two current call sites (EnvironmentDeployCommand, ResourcesSetCommand) can collide this way, so nothing breaks today, but a future item list whose labels overlap its keys will silently select the wrong entry.
Verification
- Adding
!is_readable($file)makes the 0300 directory in the extended testRemoveDirWithChmod case get chmod'd to 0700 before the FilesystemIterator is built. chooseAssoc()now returns a key on every path: scalar answers matching a key are cast and returned, others are reverse-looked-up, and an unresolvable answer throws RuntimeException instead of a TypeError.- The pre-validator handles the all-integer-key case (
array_key_exists('2', [1=>…,2=>…])is true), so typing2yields"2"rather than the description text; integration test TestResourcesSet_InteractiveIntegerProfileSizes asserts exactly that. - Non-interactive mode still works: Symfony's getDefaultAnswer runs the new validator over the default, which returns the default key unchanged for 'stopstart' in EnvironmentDeployCommand.
- The untyped
fn($answer)closures introduce no unbaselined PHPStan error — noAnonymous function has parameter ... with no type specifiedentries exist for the many similar arrow functions already in src/.
FilesystemServiceTest::testRemoveDirWithChmod gains the 0300 directory case (run by the Run PHPUnit tests step in .github/workflows/ci.yml), and the PR's integration-tests/resources_set_values_test.go covers the interactive integer-profile-size path through chooseAssoc; the baseline/level change is exercised by the Lint PHP (phpstan) step. No unit test covers chooseAssoc directly, and nothing covers the new UnexpectedValueException catch (it needs a directory whose chmod fails).
Review 2 of 10 for this pull request · View the full run
Symfony's remove() throws UnexpectedValueException, not IOException, when it cannot open a directory (e.g. one the user cannot chmod). Catch it so remove() returns false with a warning. In chooseAssoc(), let a typed value take precedence over a key, as in Symfony's ChoiceQuestion. Addresses review feedback on the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 2 files reviewed
🔵 Minor point
legacy/src/Service/QuestionHelper.php:167— The new&& !in_array($answer, $items, true)guard is meant to give a typed value precedence over an identical key, but for integer-like keys — the very case the surrounding code exists for — the later post-processing undoes it. With$items = ['1' => '3', '2' => '1']and the user typing1: the guard sees1is also a value, so Symfony's default validator runs; because PHP made the keys ints the choices are not associative, so Symfony returns the choice text'1'; then line 175 (if (is_scalar($choice) && array_key_exists((string) $choice, $items)) return (string) $choice;) maps that text back to the key'1'and returns it, i.e. the key interpretation wins anyway, and the option actually labelled1(key'2') is never selectable. For string keys the guard does change behaviour (['a'=>'b','b'=>'c']+ typedbnow yieldsa), so the two key types now resolve the same ambiguity in opposite directions.
Verification
\UnexpectedValueExceptionextends\RuntimeExceptionand Symfony'sIOExceptiondoes too, so the widened catch inremove()covers both the old and the new case.- The removed
use IOExceptionimport is unused elsewhere in Filesystem.php, so dropping it breaks nothing. remove()still returns false and raises an E_USER_WARNING when Symfony's recursiveFilesystemIteratorcannot open a sub-directory, instead of letting the exception escape.- The two
chooseAssoc()call sites (EnvironmentDeployCommand, ResourcesSetCommand) pass items whose keys and values do not overlap, so the validator change does not alter them today.
These two commits add no tests: the widened catch in Filesystem::remove() and the new !in_array() validator branch are uncovered (existing FilesystemServiceTest::testRemoveDirWithChmod only exercises the success path). CI's test job runs make lint-phpstan and ./scripts/test/unit.sh over legacy/.
Review 3 of 10 for this pull request · View the full run
Replace the wrapped Symfony validator and post-processing with a validator that always returns a key: a typed key first, then a unique matching value. This resolves the same way for string and integer-like keys, and also applies to the non-interactive default. Add QuestionHelperTest to cover both key types, and widen the $items type to array<array-key, string>. Addresses review feedback on the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 3 files reviewed
🔵 Minor point
legacy/src/Service/QuestionHelper.php:167— The new validator coerces a non-scalar answer (which is what Symfony passes when the user presses Enter and the question has no default:null) to the empty string before matching. If$itemsever contains a key''— or a value''— then a bare Enter with no default silently resolves to that entry instead of re-prompting, becausearray_key_exists('', $items)/array_keys($items, '', true)matches. Today's two call sites (ResourcesSetCommand,EnvironmentDeployCommand) never build such an item, so this is latent rather than live, but the helper is generic and the empty-input path is exactly what this change set out to make safe.
Verification
- The validator returns a key for both spellings: typing the key and typing the value each resolve to the key, matching the new tests for string and integer-like keys.
- Non-interactive
chooseAssocstill works: Symfony'sgetDefaultAnswer()runs the validator on the default, so'2'comes back as the key, astestChooseAssocNonInteractiveasserts. InvalidArgumentExceptionfrom the validator is caught by Symfony'svalidateAttempts, so a blank or unmatched answer re-prompts (max 5) instead of crashing.- Both current callers (
ResourcesSetCommand:272,EnvironmentDeployCommand:110) are inside interactive guards, so the newLogicExceptionon a null choice is unreachable from them. - Removing the
chooseAssoc() expects array<string, string>baseline entry is matched by the widenedarray<array-key, string>docblock on the method.
The diff adds legacy/tests/Service/QuestionHelperTest.php, covering key/value/default answers for both string and integer-like keys plus the non-interactive path; the legacy-php job in .github/workflows/ci.yml runs make lint-phpstan and PHPUnit over legacy/.
Review 4 of 10 for this pull request · View the full run
Pressing Enter with no default could select an item keyed or valued ''. Addresses review feedback on the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 2 files reviewed
🔵 Minor point
legacy/src/Service/QuestionHelper.php:168— The validator now rejects every empty answer before the key lookup, so an items array containing an empty-string key can never be chosen:chooseAssoc(['' => 'None', 'a' => 'Apple'])loops for 5 attempts on Enter and then throwsInvalidArgumentException('A choice is required')instead of returning'', and in non-interactive modechooseAssoc($items, $text, '')throws the same fromQuestion::getDefaultAnswer()rather than returning the default. The two current callers (EnvironmentDeployCommand.php:110, ResourcesSetCommand.php:272) use non-empty keys, so nothing fails today, but the new testChooseAssocEmptyAnswer case pins this as the behaviour for any future caller with an empty key. Moving the emptiness check afterarray_key_exists($answer, $items)would keep rejecting blank input while leaving a genuine '' key selectable.
Verification
- The new empty-answer guard sits before the key/value lookups, so the existing key-, value- and default-resolution cases in chooseAssocProvider are unaffected.
- Both chooseAssoc call sites (EnvironmentDeployCommand.php:110, ResourcesSetCommand.php:272) pass only non-empty keys, so the new throw cannot fire for them.
- I ran the new test file against an installed Symfony 7 console: validateAttempts rethrows the stored InvalidArgumentException when the memory stream hits EOF, so testChooseAssocEmptyAnswer does assert the intended exception and not MissingInputException.
- testChooseAssocNonInteractive still passes because Question::getDefaultAnswer routes the non-null default '2' through the new validator, which returns the key unchanged.
The diff adds legacy/tests/Service/QuestionHelperTest::testChooseAssocEmptyAnswer, covering the new branch; it runs in the ci.yml "Run PHPUnit tests" step (./scripts/test/unit.sh), and I executed that single test file locally against a fresh composer install — all 9 cases pass.
Review 5 of 10 for this pull request · View the full run
Reject only a null answer (Enter with no default), so an item keyed '' can still be chosen as the explicit default. Addresses review feedback on the PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · ⚪ 1 nitpick
🔁 Incremental · 2 files reviewed
⚪ Nitpick
legacy/src/Service/QuestionHelper.php:171— Moving the emptiness check to a strict$answer === nulltest means a whitespace-only or empty typed answer (which trims to'') no longer produces "A choice is required" for item sets without an''key; it now falls through tosprintf('Value "%s" is invalid', ''), printingValue "" is invalid. The retry still happens, only the message is less helpful.
Verification
- Reproduced the new path with real symfony/console 7: with no default Symfony hands the validator
null, so theA choice is requiredbranch is still reached andtestChooseAssocEmptyAnswerstill throws. - The added provider case is genuine: with default
''Symfony returns''fromdoAsk, ChoiceQuestion's normalizer leaves it, andarray_key_exists('', $items)makeschooseAssocreturn''. - Both existing call sites (
EnvironmentDeployCommand,ResourcesSetCommand) pass non-empty keys, so admitting''as a valid key changes nothing for them. chooseAssocnever enables multiselect, so theis_scalar(...) ? ... : ''fallback cannot silently select an''key from an array answer.
The change is covered by the new empty key as default data-provider row plus the pre-existing testChooseAssocEmptyAnswer/testChooseAssocNonInteractive cases in legacy/tests/Service/QuestionHelperTest.php, run by the legacy-php CI job (./scripts/test/unit.sh, which also runs php-cs-fixer and make lint-phpstan).
Review 6 of 10 for this pull request · View the full run
Raises PHPStan in
legacy/from level 9 to level 10. Level 10 checksmixedvalues reaching typed parameters, which is where unchecked input and interactive validator bugs show up.The 644 new errors were reviewed. These real bugs are fixed, each with a test that failed before the fix:
--disk app:defaultand--disk app:minalways failed with "must be an integer in MB".1,2), the chosenprofile_sizewas sent as the description text ("CPU 2, memory 768 MB (shared)"). Fixed inQuestionHelper::chooseAssoc().local:build) never worked, because Symfony had already renamed the directory. Permissions are now fixed before removal.The remaining errors are baselined (1178 → 1786 entries). Most are
mixedvalues from API responses and decoded JSON. Two edge cases are left for follow-up: thelocal:webrouter withpassthru: true, andsession:switchwith all-numeric session IDs.🤖 Generated with Claude Code