Skip to content

[6.x] Throw TypeParseTreeException for an incomplete conditional type - #11962

Merged
danog merged 4 commits into
vimeo:6.xfrom
alies-dev:alies-dev/typeparser-dangling-is-conditional
Sep 23, 2026
Merged

danog merged 4 commits into
vimeo:6.xfrom
alies-dev:alies-dev/typeparser-dangling-is-conditional

Conversation

@alies-dev

@alies-dev alies-dev commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Note

Stacked on #11960. The base here is 6.x because a fork branch cannot be used as a PR base, so the diff currently shows both commits. Only the second one — Throw TypeParseTreeException for an incomplete conditional type — belongs to this PR. I'll rebase once #11960 lands. Happy to fold it into #11960 instead if you prefer a single PR.

Problem

Follow-up to the gap I noted in #11960 (comment).

TypeParser::getTypeFromTree() handles ConditionalTree but has no branch for TemplateIsTree, the node ParseTreeCreator builds for an is token before a ? upgrades it into a ConditionalTree. An is expression that never gets its ternary therefore falls through to the invariant guard at the bottom:

if (!$parse_tree instanceof Value) {
    throw new InvalidArgumentException('Unrecognised parse tree type ' . $parse_tree::class);
}

None of the docblock parse sites catch InvalidArgumentException, so this killed the entire run:

/** @return ($key is null) */
public function c(?int $key) { return null; }
Uncaught InvalidArgumentException: Unrecognised parse tree type Psalm\Internal\Type\ParseTree\TemplateIsTree
  in src/Psalm/Internal/Type/TypeParser.php:402

This was not specific to @self-out — @return, @param-out and @psalm-if-this-is all died on the same input, even though all three already have a catch (TypeParseTreeException).

Change

Reject TemplateIsTree in getTypeFromTree() with a TypeParseTreeException, so every one of the eight docblock call sites degrades to InvalidDocblock as intended:

ERROR: InvalidDocblock - dangling.php:11:5 - Invalid conditional, expected ? after is in docblock for F::c

Handling it in getTypeFromTree() rather than in ParseTreeCreator::create() is deliberate: getTypeFromTree() already recurses, so nested occurrences such as list<($key is null)> are covered for free, whereas a tail check in create() would only see the root node and would need a full tree walk.

The InvalidArgumentException at the bottom of getTypeFromTree() is left alone. I probed the other ParseTree subclasses that reach it (Root, FieldEllipsis, CallableParamTree, MethodParamTree, KeyedArrayPropertyTree) with malformed docblocks — array{a: int, ...}, list<...>, callable(int...), callable(int):, array{a:}, Foo::bar(), int..., ... — and none of them are reachable from user input; they are either consumed by their parent node or rejected earlier with a TypeParseTreeException. TemplateIsTree was the only real hole, so the guard stays as a genuine internal invariant.

…if-this-is types

The self_out and if_this_is blocks in FunctionLikeDocblockScanner were the
only TypeParser::parseTokens() call sites in that file without a surrounding
catch (TypeParseTreeException), so an unparseable type there escaped as an
uncaught throwable during the scan phase, killing the whole run.

Fixes vimeo#11958
TypeParser::getTypeFromTree() fell through to the 'Unrecognised parse
tree type' InvalidArgumentException when a parenthesised `is` expression
was not completed into a ternary, e.g. `($key is null)`. None of the
docblock parse sites catch InvalidArgumentException, so such a type
aborted the whole run with an uncaught throwable - including through the
already-guarded @return and @param-out paths.

TemplateIsTree is now rejected with a TypeParseTreeException, so every
caller degrades to InvalidDocblock. Handling it in getTypeFromTree()
rather than ParseTreeCreator covers nested occurrences such as
`list<($key is null)>` through the existing recursion.
@alies-dev alies-dev changed the title Throw TypeParseTreeException for an incomplete conditional type [6.x] Throw TypeParseTreeException for an incomplete conditional type Sep 15, 2026
`(T is ? int : string)` crashed with an uncaught "Undefined array key
0" instead of a clean parse error. The `?` after a childless
TemplateIsTree promotes it into a ConditionalTree, so the existing
top-level TemplateIsTree guard never runs, and getTypeFromTree read
condition->children[0] unchecked.

Drops the baseline entry the guard makes redundant.

Refs vimeo#11958
Copilot AI lite review requested due to automatic review settings September 15, 2026 22:46
@alies-dev

Copy link
Copy Markdown
Contributor Author

Pushed a second commit, 94eca63, that closes a sibling hole Copilot spotted on #11964 (#11964 (review)). It is pre-existing on 6.x, not introduced by any PR in this stack:

/** @return ($key is ? int : string) */

crashes the scan with Undefined array key 0 in TypeParser::getTypeFromTree(). The ? promotes the childless TemplateIsTree into a ConditionalTree before the top-level guard from the first commit gets a chance to see it, and the ConditionalTree branch then reads condition->children[0] unchecked. The new commit adds the missing count check next to the existing children !== 2 one, so it degrades to InvalidDocblock like the other incomplete forms. Covered by a TypeParseTest case and an InvalidDocblock case in ConditionalReturnTypeTest, both confirmed to crash the run without the fix. The guard also makes one psalm-baseline.xml entry redundant, so that line is dropped.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Add regression coverage for nested incomplete conditional types to verify recursive handling.

Pull request overview

Fixes incomplete conditional-type parsing so invalid docblocks report InvalidDocblock instead of aborting analysis.

Changes:

  • Rejects dangling TemplateIsTree nodes with TypeParseTreeException.
  • Adds parser and docblock regression coverage.
  • Removes the obsolete baseline suppression.
File summaries
File Description
tests/TypeParseTest.php Tests malformed conditional types.
tests/ThisOutTest.php Tests incomplete this-out annotations.
tests/Template/ConditionalReturnTypeTest.php Tests invalid conditional return types.
tests/IfThisIsTest.php Tests invalid if-this-is annotations.
src/Psalm/Internal/Type/TypeParser.php Handles incomplete conditional parse trees.
src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php Converts parse failures into invalid-docblock reports.
psalm-baseline.xml Removes the resolved baseline entry.
Review details

Suppressed comments (1)

src/Psalm/Internal/Type/TypeParser.php:408

  • The new branch is specifically intended to make incomplete is expressions fail correctly when they are nested under another tree, but the added unit coverage only exercises a root-level TemplateIsTree. Please add a regression case such as list<(T is string)> (and ideally the affected docblock tags) so a future change to the recursive dispatch cannot reintroduce the uncaught InvalidArgumentException for nested occurrences.
        if ($parse_tree instanceof TemplateIsTree) {
            throw new TypeParseTreeException('Invalid conditional, expected ? after is');
        }
  • Files reviewed: 7/7 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@danog
danog merged commit 2b79f08 into vimeo:6.x Sep 23, 2026
59 of 60 checks passed
@alies-dev
alies-dev deleted the alies-dev/typeparser-dangling-is-conditional branch September 27, 2026 15:41
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.

3 participants