rectorphp/rector · error · ShouldNotHappenException
Array of nodes cannot be empty. Ensure "%s->refactor()" retu
Error message
Array of nodes cannot be empty. Ensure "%s->refactor()" returns non-empty array for Nodes.
A) Direct return null for no change:
return null;
B) Remove the Node:
return \PhpParser\NodeVisitor::REMOVE_NODE; What it means
AbstractRector::refactorNode() interprets refactor()'s return: null means no change, an int is a visitor state (REMOVE_NODE), a Node replaces, and a non-empty array of nodes splices in. An empty array is neither 'no change' nor a valid splice, so it throws ShouldNotHappenException with EMPTY_NODE_ARRAY_MESSAGE, naming your rule class and spelling out the two legal alternatives (return null, or return REMOVE_NODE).
Source
Thrown at src/Rector/AbstractRector.php:136
// whether it would actually have changed anything. Only a skip that prevents a real change
// counts as used; the original node is left untouched, so the file stays skipped either way.
$skipMatch = $this->skipper->matchSkip($this, $filePath);
if ($skipMatch instanceof SkipMatch) {
if ($this->refactor($this->cloneNode($node)) !== null) {
$this->skipper->markSkipUsed($skipMatch);
}
return null;
}
// ensure origNode pulled before refactor to avoid changed during refactor, ref https://3v4l.org/YMEGN
$originalNode = $node->getAttribute(AttributeKey::ORIGINAL_NODE) ?? $node;
$refactoredNodeOrState = $this->refactor($node);
// nothing to change → continue
if ($refactoredNodeOrState === null) {
return null;
}
if ($refactoredNodeOrState === []) {
$errorMessage = sprintf(self::EMPTY_NODE_ARRAY_MESSAGE, static::class);
throw new ShouldNotHappenException($errorMessage);
}
$isState = is_int($refactoredNodeOrState);
if ($isState) {
$this->createdByRuleDecorator->decorate($node, $originalNode, static::class);
// only remove node is supported
if ($refactoredNodeOrState !== NodeVisitor::REMOVE_NODE) {
// @todo warn about unsupported state in the future
return null;
}
// notify this rule changed code
$rectorWithLineChange = new RectorWithLineChange(static::class, $originalNode->getStartLine());
$this->file->addRectorClassWithLine($rectorWithLineChange);
return $refactoredNodeOrState;
}
return $this->postRefactorProcess($originalNode, $node, $refactoredNodeOrState, $filePath);
}
/**
* @return mixed[]|int|\PhpParser\Node|nullView on GitHub (pinned to 408fcb0ff1)
Solutions
- Return null on every nothing-changed path
- Return NodeVisitor::REMOVE_NODE (the int constant) when the intent was to delete the node
- Guard before returning: if ($statements === []) { return null; } return $statements;
Example fix
// before
$statements = [];
foreach ($args as $arg) {
// ...
}
return $statements; // [] when loop body never appends
// after
if ($statements === []) {
return null;
}
return $statements; Defensive patterns
Strategy: validation
Validate before calling
// normalize refactor() results through one exit point
public function refactor(Node $node): null|Node|array|int
{
$statements = $this->buildStatements($node);
return $statements === [] ? null : $statements;
} Type guard
/** @param mixed $return */
function isLegalRefactorReturn($return): bool
{
if ($return === null || $return instanceof \PhpParser\Node || is_int($return)) {
return true;
}
if (! is_array($return)) {
return false;
}
return $return !== []; // non-empty node arrays only
} Prevention
- Adopt the rule: empty means null — never return [] from refactor()
- Return NodeVisitor::REMOVE_NODE when deletion is the intent
- Run the custom rule over an empty-ish fixture in CI to exercise the nothing-matched path
When it happens
Trigger: A custom rule's refactor() builds $statements = [] and returns it when a loop/condition matched nothing but an early guard was missed — e.g. return $statements; after a foreach that added zero nodes.
Common situations: First draft of a rule where the collection path exists but is never populated; refactoring an if/else so a fall-through path returns [] instead of null; copying a multi-node rule template and deleting the append lines.
Related errors
- File object is missing. Make sure you call $this->currentFil
- "%s" rule is deprecated, as turning a docblock type into a r
- "%s" rule is deprecated, as risky. The "??" and "?:" operato
- "%s" is deprecated as depends on context and personal prefer
- "%s" rule is deprecated, as it is a personal preference that
AI-assisted analysis of rectorphp/rector@408fcb0ff1 (2026-08-21).
Data as JSON: /api/errors/b1bab82e9dcf4b96.
Report an issue: GitHub.