Skip to content

Feature/infection diff mode qa#16

Merged
LTSCommerce merged 7 commits into
php8.4from
feature/infection-diff-mode-qa
Jul 7, 2026
Merged

Feature/infection diff mode qa#16
LTSCommerce merged 7 commits into
php8.4from
feature/infection-diff-mode-qa

Conversation

@LTSCommerce

Copy link
Copy Markdown
Contributor

No description provided.

joseph and others added 7 commits July 6, 2026 13:24
Add a generic, opt-in diff mode to the infection step. When the project sets
infectionDiffBase to a git ref (env var or qaConfig), the run is scoped to only
the source files changed against that base:

  - computes the changed added/modified source files with
    `git diff <base> --diff-filter=AM --name-only --relative` and passes them to
    Infection via --filter;
  - swaps the two monotonic MSI floor flags for a single --min-covered-msi=100 —
    the "no new escaped mutants in the changed code" bar (a fixed percentage
    floor is statistically meaningless on a handful of diff mutants);
  - drops --log-verbosity=all back to Infection's default console verbosity, so a
    report-rendering issue over an escaped-mutant diff cannot destroy the run's
    result (the configured file loggers stay authoritative).

We compute the file list ourselves rather than using Infection's own
--git-diff-base because that resolves git-diff paths relative to the process CWD
while `git diff` emits them relative to the repository ROOT; those agree only when
the project sits at the git root. For a project in a SUBDIRECTORY of the repo (a
monorepo package) --git-diff-base silently matches nothing — a false "no changed
files" pass. `git diff --relative` normalises paths to the CWD, so diff scoping is
correct whether the project is the repo root or a subdirectory.

This gives a fast per-change mutation check ("did the changed code's tests kill
its mutants?") without paying the whole-codebase cost on every iteration, while
the full whole-codebase run against the SSoT floors stays the CI-owned gate.

When infectionDiffBase is unset the invocation is byte-for-byte identical to the
previous full run — diff mode is purely additive. A git-diff failure (e.g. an
unknown base ref) fails the step with guidance rather than scoping to nothing; an
empty changed-file set skips cleanly (no new mutants to check).

Adds a Large test pinning the two-mode flag contract (full floors + verbose
report vs diff --filter + covered-MSI 100, verbose dropped) by extracting
runInfection and capturing the arguments with a stubbed runner.
… history

Two hardenings for the opt-in infectionDiffBase diff lane, driven by a
field-observed false green: a run invoked over an uncommitted working tree
(zero commits on the branch yet) silently under-reported — most changed
files were never mutated — and the resulting 0-escaped verdict was trusted,
only to be disproved when the same lane ran against the committed state.

1. Dirty-tree preflight refusal. When infectionDiffBase is set and
   'git status --porcelain' shows uncommitted changes under the source or
   tests directories, the step now FAILS FAST (before the expensive
   coverage run) with the offending paths and the remediation (commit
   first; a WIP commit is fine). Dirt outside src/tests (docs, tooling)
   cannot affect mutation results and does not block the lane.

2. Committed-history scoping. The changed-file set now comes from a
   three-dot 'git diff base...HEAD' (merge-base against HEAD) instead of a
   two-dot working-tree diff: it never reads the working tree, and a base
   ref that advanced since branching can no longer leak ITS changed files
   into the lane's judgement.

The computation is extracted into computeInfectionDiffFilter() and the
preflight into assertCleanTreeForInfectionDiffMode(); both are pinned by
new Large tests that drive the extracted functions against real temp git
repos (dirty tree refused loudly; clean tree passes; the filter contains
exactly the committed change). Full-lane behaviour is untouched.
…ith dirty-tree refusal and merge-base scoping
…empnam

The mutation diff-lane test drove a bash harness via \Safe\exec/\Safe\tempnam.
Under the current Safe stubs PHPStan types the exec() by-reference $output and
$exit code as nullable, mixed-valued out-params and \Safe\tempnam as a
non-false string, so the declared array{int, string} return no longer held.
Coalesce the exit code, build a list<string> of the output lines via the
existing is_string filter, and drop the now-redundant (string) tempnam cast so
the harness helpers are genuinely type-sound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A PHPStan upgrade widened Rule::processNode()'s scope parameter to the
CollectedDataEmitter&NodeCallbackInvoker&Scope intersection the analyser
actually passes. The rule's unit test built a Scope&NodeCallbackInvoker double,
so every processNode() call was a parameter type error under static analysis.
Build the double for all three interfaces and return the matching intersection
type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…an rules

Pre-existing drift the writable pipeline enforces but had not yet been applied:
Rector's Safe-function conversion (preg_match -> \Safe\preg_match) and
CS-Fixer's ordered_class_elements (public static method before private) and
global_namespace_import (\Attribute::class -> imported Attribute::class). No
behavioural change; this makes a writable run a no-op with a clean tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…able metadata literals)

PHP 8 attribute lines are never executed, so Infection's coverage-based
test selection can associate NO test with a mutant on them: every literal
inside a single-line #[...] attribute (OA\Property nullable/example
flags, ORM\Column length/nullable, Route paths, etc.) is a structurally
unkillable escape that pollutes MSI and forces per-project disclosure
ritual. Field evidence from the marketing-system consumer: 80/80 diff-lane
escapes across four consecutive plans were exactly this class, each
already netted by a deterministic non-PHPUnit gate (contract dump, schema
migration + reflection mapping tests); scoping them out re-scored the
same diff 348/348 killed / MSI 100% with behavioural mutation coverage
unchanged. Applied to the consumer template (configDefaults) and this
repo's own config. Multi-line attribute continuation lines are
deliberately NOT matched, so anything unusual still surfaces. Consumers
can override by redefining mutators in their own infection.json.
@LTSCommerce
LTSCommerce merged commit c01f113 into php8.4 Jul 7, 2026
3 of 4 checks passed
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.

1 participant