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|null

View on GitHub (pinned to 408fcb0ff1)

Solutions

  1. Return null on every nothing-changed path
  2. Return NodeVisitor::REMOVE_NODE (the int constant) when the intent was to delete the node
  3. 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

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


AI-assisted analysis of rectorphp/rector@408fcb0ff1 (2026-08-21). Data as JSON: /api/errors/b1bab82e9dcf4b96. Report an issue: GitHub.