Skip to content

[skip] mark class/path skip used only when rule would change the file#8076

Merged
TomasVotruba merged 1 commit into
mainfrom
tv-skip-11
Jun 22, 2026
Merged

[skip] mark class/path skip used only when rule would change the file#8076
TomasVotruba merged 1 commit into
mainfrom
tv-skip-11

Conversation

@TomasVotruba

Copy link
Copy Markdown
Member

Problem

SkipSkipper::doesMatchSkip() called markUsed() the moment a rule's node matched a skip — even when the rule would never change the file. So a skip configured for a rule that is inert on that file still got reported as used, hiding genuinely removable skip config from the unused-skip report.

A skip is only truly "used" when it actually prevents a change.

Fix

Mark a class/path skip used only when the rule would actually change the file. Detection runs the rule on a deep clone, so the real node is never mutated → no change leaks into a skipped file.

AbstractRector::enterNode():

$filePath = $this->file->getFilePath();

// node already changed by this rule in a previous pass → hard skip
if ($this->skipper->shouldSkipCurrentNode(static::class, $node)) {
    return null;
}

// class/path skip is configured for this rule and file: run the rule on a deep clone to learn
// 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;
}

Deep clone helper (attributes preserved, real node untouched):

private function cloneNode(Node $node): Node
{
    $nodeTraverser = new NodeTraverser(new CloningVisitor());
    return $nodeTraverser->traverse([$node])[0];
}

Matching is now side-effect free and returns a small value object instead of marking eagerly:

// SkipSkipper::match()
if (! is_array($skippedFiles)) {
    return new SkipMatch($skippedClass, null);          // skip everywhere
}

$matchedPath = $this->fileInfoMatcher->matchPattern($filePath, $skippedFiles);
if ($matchedPath !== null) {
    return new SkipMatch($skippedClass, $matchedPath);  // rule-scoped path
}

Skipper owns marking; shouldSkipElementAndFilePath() keeps its eager behavior for the PostFileProcessor path, while matchSkip() / markSkipUsed() let the rule path defer marking until the change is confirmed:

public function matchSkip(string | object $element, string $filePath): ?SkipMatch
{
    if (! $this->classSkipVoter->match($element)) {
        return null;
    }

    return $this->classSkipVoter->matchSkip($element, $filePath);
}

public function markSkipUsed(SkipMatch $skipMatch): void
{
    $this->usedSkipCollector->markUsed($skipMatch->getSkippedClass(), $skipMatch->getMatchedPath());
}

Test

SkipUsedTrackingTest runs RenameClassRector with two skipped files — one that uses OldClass (rule would rename → change) and one that does not:

$usedPaths = $usedSkipCollector->provide()[RenameClassRector::class] ?? [];

// the skip that actually prevented a rename is marked used
$this->assertContains('*skip_used_renames_old_class*', $usedPaths);

// the skip on a file the rule would not have touched is never marked used
$this->assertNotContains('*skip_unused_no_old_class*', $usedPaths);

Tradeoffs

  • refactor() runs on a clone for skipped nodes → small perf cost, on skipped files only.
  • refactor() may still touch internal collectors during the probe — smaller risk than leaking output, not zero.

Previously a skip was marked used the moment a rule's node matched it,
even if refactor() would return null. Now the skipped rule is probed on a
deep clone and the skip is marked used only when it would actually change
the file; the real node is left untouched.
@TomasVotruba TomasVotruba merged commit dd21759 into main Jun 22, 2026
67 checks passed
@TomasVotruba TomasVotruba deleted the tv-skip-11 branch June 22, 2026 09:04
// 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) {

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.

The refactor() now called twice, I think this can be improved, I will look into it.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There is return null; in if ($skipMatch instanceof SkipMatch) {, so its called only once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants