Skip to content

Identify dead code using static analysis#1231

Open
sebastianbergmann wants to merge 3 commits into
mainfrom
static-dead-code-analysis
Open

Identify dead code using static analysis#1231
sebastianbergmann wants to merge 3 commits into
mainfrom
static-dead-code-analysis

Conversation

@sebastianbergmann

@sebastianbergmann sebastianbergmann commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Xdebug populates a third line status, not executable (-2), for source lines that the PHP compiler/optimiser has identified as dead code.

PCOV does not do this: it only distinguishes executed from not executed, so suites running under PCOV report unreachable lines as ordinary coverage gaps and surface false negatives in the report.

This is an attempt to close that gap: it adds an AST-based dead-code analysis pass to the static analyser so dead lines can be surfaced as LINE_NOT_EXECUTABLE regardless of which collector produced the raw coverage data, without depending on bytecode-level introspection that PCOV does not expose.

What "dead" means here

The new analysis is structural and intra-procedural. It works entirely from the PHP-Parser AST, so it can only report what is locally derivable from the source:

  • Statements following an unconditional control-flow transfer within the same stmts block: return, throw, exit / die, break, continue
  • Bodies of branches with literal-constant conditions:
    • if (false) { ... } / elseif (false) { ... }
    • the elseif and else tails after if (true)
    • while (false) { ... } and for (...; false; ...)
    • the unreachable arm of a ternary with a literal-constant condition (multi-line only, to avoid ambiguity when both arms share a source line)

Whole-program reasoning (never-called functions, opcode-folded branches under user-defined constants, optimiser decisions) is explicitly out of scope.

The AST analyser will always be a strict subset of what bytecode-level dead-code detection can see, but it covers the structural cases that drive most user-visible "phantom uncovered line" complaints under PCOV.

Architecture

The analysis is layered the same way the existing line classification is, visitor → source analyser → file analyser → consumer, so it composes cleanly with the existing pipeline and the static-analysis cache.

DeadCodeFindingVisitor (new) ─┐
                              ├─► ParsingSourceAnalyser ─► AnalysisResult.deadLines()
ExecutableLinesFindingVisitor ┘                 │
                                                ▼
                                  FilterProcessor.applyExecutableLinesFilter
                                                │
                                                ▼
                                  RawCodeCoverageData (lines flipped to -2)

DeadCodeFindingVisitor

A NodeVisitorAbstract that walks every parsed file once and accumulates a set array<positive-int, true> of line numbers that the patterns above mark as unreachable.

It marks line ranges rather than line-by-line: e.g. for if (true) { ... } else { ... } it marks the entire range of the else node, including wrapper-boundary lines like } else { and the closing }. Those boundary lines are not classified as executable in the first place, so the downstream intersection drops them; this keeps the visitor simple and avoids depending on the executable-lines classifier from inside the visitor.

ParsingSourceAnalyser

Optionally constructs the new visitor (controlled by a constructor flag) and intersects its raw output with ExecutableLinesFindingVisitor::executableLinesGroupedByBranch() before publishing the dead-line set on the AnalysisResult. This gives the invariant deadLines() ⊆ executableLines() by construction and the consumer never has to defend against a "dead but not classified executable" line.

When the flag is off (the default) the visitor is not constructed, the traversal runs exactly as before, and AnalysisResult::deadLines() returns []. The traversal cost when the flag is on is one extra visitor on the same node walk, no second parse or second pass over the AST.

AnalysisResult

Gains a deadLines(): array<positive-int, true> accessor alongside the existing executableLines() / branchOperatorLines() / ignoredLines(). The constructor parameter is required (no defaulting) so it cannot be silently omitted when the result is constructed by hand in tests.

CachingSourceAnalyser

The dead-code-detection flag participates in the cache key. Flipping the flag invalidates cached entries automatically, so a project that toggles the option on does not need to nuke its analysis cache.

FilterProcessor and RawCodeCoverageData

Two integration points so dead lines surface in both covered and uncovered files:

  • FilterProcessor::applyExecutableLinesFilter() calls a new RawCodeCoverageData::markLinesAsNotExecutable() after the existing executable-line normalization. This demotes dead lines from LINE_NOT_EXECUTED (or whatever propagation left behind) to LINE_NOT_EXECUTABLE.
  • RawCodeCoverageData::fromUncoveredFile() seeds dead lines as LINE_NOT_EXECUTABLE directly, so files that no test ever touched still report dead code correctly.

The ordering inside applyExecutableLinesFilter() matters: dead-line marking runs after markExecutableLineByBranch() so the override beats any branch-propagation that may have crossed a dead line.

Configuration surface

A single boolean toggle on CodeCoverage:

$coverage->enableStaticDeadCodeDetection();   // opt in
$coverage->disableStaticDeadCodeDetection();  // back to default
$coverage->staticallyDetectsDeadCode();       // current state

Off by default to preserve existing behaviour and existing reports across upgrades. The flag is threaded through Registry::analyser() into the source analyser; no other public API moves.

@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.09%. Comparing base (10d7da3) to head (7fb16f7).

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #1231      +/-   ##
============================================
+ Coverage     98.04%   98.09%   +0.05%     
- Complexity     1618     1696      +78     
============================================
  Files           114      115       +1     
  Lines          5419     5563     +144     
============================================
+ Hits           5313     5457     +144     
  Misses          106      106              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

API Surface Changes

If any of the additions below are not intended as public API, mark them with @internal in the docblock.

New API Surface

Methods

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant