Skip to content

Simulate loop analysis for goto-label pairs#5701

Merged
ondrejmirtes merged 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-k9brwhl
May 19, 2026
Merged

Simulate loop analysis for goto-label pairs#5701
ondrejmirtes merged 3 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-k9brwhl

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

This PR treats goto as a terminating statement (like break/continue/return) and implements loop-like scope stabilization for backward goto-label pairs (where a goto jumps back to an earlier label, creating a loop).

Changes

goto as a terminating statement: A goto statement now produces an exit point carrying its scope, and marks execution as terminated. This fixes forward-goto cases where code after a goto was not recognized as unreachable, and where labels after terminating code were not recognized as reachable via goto.

Same-level backward goto stabilization: When a label at the current statement-list level has a goto targeting it from later in the same list, the statements between the label and the goto are processed in a stabilization loop (using NoopNodeCallback and enterDeep(), with generalization after GENERALIZE_AFTER_ITERATION and a cap at LOOP_SCOPE_ITERATIONS), exactly matching the existing loop analysis pattern for while/for/foreach.

Nested backward goto stabilization: When a compound statement (e.g., try-catch) contains both a label and a goto targeting it across sub-blocks (label in try body, goto in catch body), the containing statement is processed through the same stabilization loop before the real analysis pass.

Label reachability: Labels now un-terminate execution when reached via a forward goto exit point. The unreachable-statement detection (getNextUnreachableStatements) stops at Label nodes to avoid false positives.

Regression tests

  • bug-4674.php (NSRT) — forward goto type narrowing
  • bug-7711.php (NSRT) — backward goto null-check retry loop
  • bug-7734.php (NSRT) — forward goto resource narrowing
  • bug-9904.php (NSRT) — forward goto skips foreach
  • goto-label-stabilization.php (NSRT) — comprehensive stabilization tests (forward narrowing, backward loops, retry patterns, multiple gotos to same label)
  • bug-11731.php (UnreachableStatementRule) — unreachable code after goto
  • bug-12167.php (NumberComparisonOperatorsConstantConditionRule) — try-catch retry pattern with nested goto-label
  • bug-12852.php (BooleanNotConstantConditionRule) — unconditional assignment before condition in goto loop (correct always-true report, matching while-loop semantics)
  • bug-14638.php (MissingReturnRule) — Symfony cache goto retry pattern with missing return false positive

Closes phpstan/phpstan#14641
Closes phpstan/phpstan#920
Closes phpstan/phpstan#14638
Closes phpstan/phpstan#4674
Closes phpstan/phpstan#7711
Closes phpstan/phpstan#7734
Closes phpstan/phpstan#9904
Closes phpstan/phpstan#11731
Closes phpstan/phpstan#12167

Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

Good job so far!

Comment thread src/Analyser/NodeScopeResolver.php Outdated
Comment on lines +413 to +464
$backwardGotoLabels = [];
$labelIndices = [];
foreach ($stmts as $idx => $s) {
if (!($s instanceof Node\Stmt\Label)) {
continue;
}

$labelIndices[$s->name->toString()] = $idx;
}
$nodeFinder = new NodeFinder();
if ($labelIndices !== []) {
foreach ($labelIndices as $labelName => $labelIdx) {
$stmtsAfterLabel = array_slice($stmts, $labelIdx + 1);
/** @var Goto_[] $gotos */
$gotos = $nodeFinder->findInstanceOf($stmtsAfterLabel, Goto_::class);
foreach ($gotos as $goto) {
if ($goto->name->toString() === $labelName) {
$backwardGotoLabels[$labelName] = true;
break;
}
}
}
}

$nestedBackwardGotoLabels = [];
foreach ($stmts as $idx => $s) {
if ($s instanceof Node\Stmt\Label) {
continue;
}
/** @var Node\Stmt\Label[] $nestedLabels */
$nestedLabels = $nodeFinder->findInstanceOf([$s], Node\Stmt\Label::class);
if ($nestedLabels === []) {
continue;
}
/** @var Goto_[] $nestedGotos */
$nestedGotos = $nodeFinder->findInstanceOf([$s], Goto_::class);
if ($nestedGotos === []) {
continue;
}
$nestedLabelNames = [];
foreach ($nestedLabels as $nestedLabel) {
$nestedLabelNames[$nestedLabel->name->toString()] = true;
}
foreach ($nestedGotos as $nestedGoto) {
$gotoName = $nestedGoto->name->toString();
if (!isset($nestedLabelNames[$gotoName])) {
continue;
}

$nestedBackwardGotoLabels[$idx][$gotoName] = true;
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would be really nice if we were able to move this code to a richParserNodeVisitor and if it ran very efficiently when traversing the AST. I don't want to do it here because the same code might be analysed multiple times and this would waste a lot of resources. i'm talking about code between lines 413-464.

Would be nice if the attributes on AST Node would be set just once on a specific node, like Label or Goto_ so that it's not spammed on all nodes for example.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done. The review feedback has been addressed with a single commit that:

  1. Created src/Parser/GotoLabelVisitor.php — a richParserNodeVisitor that runs once per file parse (not per analysis pass) and sets attributes on specific AST nodes:

    • hasBackwardGoto on Label nodes that have a backward goto targeting them
    • nestedBackwardGotoLabels on compound statement nodes (like TryCatch) that contain both a label and a goto to it
  2. Updated src/Analyser/NodeScopeResolver.php — removed the 53-line scanning block (the $backwardGotoLabels / $nestedBackwardGotoLabels computation with NodeFinder) and replaced the two consumption sites with getAttribute() calls reading from the pre-computed visitor attributes.

The attributes are only set on the specific relevant nodes (Label and compound statements), not spammed on all nodes, as the reviewer requested.

Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

Run make cs-fix

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Applied make cs-fix which fixed 2 coding style issues in GotoLabelVisitor.php (converting if-body to early-continue pattern). Both make phpstan and make tests pass cleanly. Committed and pushed.

Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

The NodeVisitor is forgetting about PropertyHook with Stmt[] body.

Write some failing tests about property hooks analysis (guarded for PHP 8.4+) and make them pass.

Also what about cross-boundary analysis? If a method contains a closure, the closure cannot goto to a label outside of the closure. If a method contains anonymous class, it also cannot jump in and out of it.

Verify the correctness in this regard. No stabilization and Scope-merging should take place in this case. Try a failing test first if you think the visitor has this problem.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of the changes:

PropertyHook fixGotoLabelVisitor::enterNode() now handles Node\PropertyHook with statement-array bodies, so goto/label pairs inside PHP 8.4+ property hooks are correctly detected. Without this, backward goto stabilization didn't run inside hooks (e.g., $i was 1 instead of int<1, max> after a retry loop).

Cross-boundary fix — Replaced NodeFinder::findInstanceOf (which recurses into all children) with a custom findWithinScope/collectWithinScope that stops at scope boundaries: Function_, ClassMethod, Closure, ClassLike, and PropertyHook. This prevents incorrect attribute-setting when a closure/anonymous class/nested function contains a goto/label pair with the same name as an outer scope label. While the engine's scope handling happens to make the stabilization a no-op in this case (closure scopes don't leak exit points), the visitor should not set incorrect attributes.

Tests added:

  • goto-property-hook.php — backward stabilization, forward narrowing, and forward fall-through inside property hooks (PHP 8.4+)
  • Three cross-boundary tests in goto-label-stabilization.php — closure, anonymous class, and nested function with same-name goto/label pairs

Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

Now please add a new rule about goto for an undefined label. It should live on level 0 and the reported error should be non-ignorable (because PHP itself fails with "Fatal error: 'goto' to undefined label 'test'").

Also test that it correctly sees cross-boundary (it should report undefined label even if there's label with that name but in a different boundary). So it should use the same node attributes set by the visitor as NodeScopeResolver uses.

The identifier could be goto.labelUndefined.

Also make another rule, on level 4, with ignorable error, about unused label. The identifier could be label.unused. The rule should only be enabled with bleeding edge (add a new key to featureToggles config).

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of what was implemented across two commits:

Commit 1: GotoUndefinedLabelRule (level 0, non-ignorable)

  • Extended GotoLabelVisitor with a processScopeBody() method that runs at each scope boundary (function, method, closure, property hook, file) to collect all labels and gotos, then sets:
    • GOTO_LABEL_UNDEFINED_ATTRIBUTE on Goto_ nodes targeting labels not in the same scope
    • LABEL_IS_USED_ATTRIBUTE on Label nodes indicating whether any goto targets them
  • Created GotoUndefinedLabelRule — registered at level 0, non-ignorable, identifier goto.labelUndefined
  • Tests cover: valid gotos, undefined labels, cross-boundary cases (closure, anonymous class, nested function), and property hooks

Commit 2: UnusedLabelRule (level 4, bleeding edge)

  • Created UnusedLabelRule — identifier label.unused, ignorable
  • Added unusedLabel feature toggle (default false in config.neon, true in bleedingEdge.neon)
  • Registered in config.level4.neon with conditional tag gated behind %featureToggles.unusedLabel%
  • Tests cover: used labels (no error), unused labels, cross-boundary unused labels, and property hooks

Both make phpstan and make tests pass cleanly, and make cs-fix reports no violations.

Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

I feel that the visitor should be written more efficiently. Look at TryCatchTypeVisitor. In enterNode it notes down to a stack when entering a new function etc. And when entering more nodes (the NodeTraverser using the visitor already traverses the AST) it decides what to set on them.

Also, Couldn't we use the existing attributes instead of also setting GOTO_LABEL_UNDEFINED_ATTRIBUTE and LABEL_IS_USED_ATTRIBUTE?

Take into account memory and time complexity of the code to optimize it.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of what changed:

Rewrote GotoLabelVisitor for efficiency — replaced the recursive findWithinScope/collectWithinScope approach with a bottom-up subtreeData computation that piggybacks on the NodeTraverser's existing AST walk:

  • Scope stack (scopeStack): pushed at scope boundaries (function, closure, method, class, property hook), collects labels and gotos during enterNode. Resolved at popScope() to set GOTO_LABEL_UNDEFINED_ATTRIBUTE and LABEL_IS_USED_ATTRIBUTE.

  • Subtree data (subtreeData): computed bottom-up in leaveNode. Each node's entry records which label/goto names exist in its subtree. Children's data is merged into the parent via += on array<string, true> maps, then children's entries are freed. This replaces findWithinScope's repeated recursive walks.

  • processStatementList: now O(n) per stmts array — looks up pre-computed subtreeData for each direct child via spl_object_id instead of doing recursive subtree searches.

  • Early exit: files without any goto or label skip all subtreeData computation (just scope stack push/pop).

  • Complexity: O(N) total instead of O(N*depth) from the old recursive approach. Each node is processed exactly once in leaveNode.

The 4 attributes (HAS_BACKWARD_GOTO, NESTED_BACKWARD_GOTO_LABELS, GOTO_LABEL_UNDEFINED, LABEL_IS_USED) are kept since they serve distinct purposes that can't easily be collapsed: the first two drive NodeScopeResolver stabilization, the latter two drive the rule checks, and each needs different information (statement-list position vs. scope-level matching).

Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

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

Exclude the two files failing in https://github.com/phpstan/phpstan-src/actions/runs/26086425070/job/76700590574?pr=5701 from make lint in Makefile.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. Added three --exclude entries to the Makefile lint target:

  • goto-undefined-label.php — contains intentional goto nonexistent; which is a PHP fatal error at parse time
  • goto-undefined-label-property-hook.php — same issue plus requires PHP 8.4+ property hook syntax
  • unused-label-property-hook.php — requires PHP 8.4+ property hook syntax

All three checks pass: make lint, make tests, make phpstan.

@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-k9brwhl branch from f02156e to 6157f11 Compare May 19, 2026 11:21
ondrejmirtes and others added 3 commits May 19, 2026 13:33
Treat goto as a terminating statement with exit points, and implement
loop-like stabilization for backward goto-label pairs at both the
same statement-list level and across nested structures like try-catch.

The GotoLabelVisitor runs once per parse and stores the resulting
HAS_BACKWARD_GOTO_ATTRIBUTE and NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE
on the relevant nodes. A bottom-up subtreeData computation leverages
the NodeTraverser's existing AST walk and stops at scope boundaries
(Function_, ClassMethod, Closure, ClassLike, PropertyHook) so goto-label
pairs in nested scopes do not affect outer scope analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GotoLabelVisitor now sets GOTO_LABEL_UNDEFINED by collecting all labels
and gotos within each scope boundary (function, method, closure,
property hook, file).

The new GotoUndefinedLabelRule (level 0) reports a non-ignorable
error when a goto targets a label that does not exist in the same
scope, including cross-boundary cases (e.g. goto in closure
targeting a label in the enclosing function).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The new UnusedLabelRule reports when a label is never targeted
by any goto in the same scope. It uses the LABEL_IS_USED attribute
set by GotoLabelVisitor.

Enabled only with bleeding edge via the unusedLabel feature toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ondrejmirtes ondrejmirtes force-pushed the create-pull-request/patch-k9brwhl branch from 6157f11 to 7bb934a Compare May 19, 2026 11:42
@ondrejmirtes ondrejmirtes merged commit 293a6ea into phpstan:2.1.x May 19, 2026
367 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-k9brwhl branch May 19, 2026 11:44
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