TIA: a run that overlaps a commit records a passing result for code it never executed
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 11.7k
- Forks
- 538
- Avg merge
- 4d 11h
- Merged PRs (30d)
- 8
Description
Summary
If a commit lands while a --tia run is running, the run records that commit as its baseline. But the results came from the code as it was before that commit. Later --tia runs see no changes since the recorded commit, so they replay the old results. pest --tia then reports green for code that fails.
The green does not go away. Every later --tia run replays it. It clears only when something actually runs the test, which means a run without --tia.
This does not need a long run. A replay with a non-empty affected set turns the recorder on and takes the same end-of-run path. In the script below, the run that causes the problem takes about one second.
Only one process needs to run pest. The other process just has to git commit. A developer committing in a second terminal will do it. So will an IDE that commits or rebases, or a CI job that lands a commit or checks out during a run.
Environment
pest v5.1.0 (latest release, 2026-08-10), PHPUnit 13.3.0, PHP 8.4.23, Xdebug 3.4.0alpha2-dev, macOS.
Line numbers below come from the installed v5.1.0 source. The script was run four times from scratch in clean temp directories and reproduced every time. macOS only. I have not tried it on Linux or in CI, so I cannot say how it behaves there.
Reproduction
Self-contained. Runs in a temp directory, about 40 seconds, most of it composer. Needs PHP 8.4+, composer, git and a coverage driver. It uses a file handshake instead of a sleep, so the second commit always lands mid-run.
repro.sh
#!/usr/bin/env bash
#
# Minimal reproduction: Pest 5 TIA records a green result for code it never executed.
#
# Scenario: a second process commits while a `--tia` run is in flight. The run stamps
# HEAD read at the END of the run as its recorded baseline, so the other process's commit
# becomes the label on results produced from pre-commit code. Every later `--tia` run then
# sees "nothing changed since the recorded commit" and replays the stale green.
#
# The run that does this takes about ONE SECOND -- it is a replay whose affected set is
# non-empty, which activates the recorder and takes the same end-of-run stamping path.
#
# Timing is a file handshake, not a sleep, so the commit lands strictly mid-run every time.
#
# Requires: PHP 8.4+, composer, git, and a coverage driver (xdebug or pcov).
# Runs entirely in a temp directory; touches nothing else. ~40s, most of it composer.
set -euo pipefail
DIR=$(mktemp -d)
echo "working in $DIR"
cd "$DIR"
mkdir -p src tests
cat > composer.json <<'JSON'
{
"name": "repro/tia-midrun-commit",
"require-dev": { "pestphp/pest": "^5.1" },
"autoload": { "psr-4": { "Repro\\": "src/" } },
"config": { "allow-plugins": { "pestphp/pest-plugin": true } }
}
JSON
cat > phpunit.xml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Tests"><directory suffix="Test.php">tests</directory></testsuite>
</testsuites>
<source><include><directory suffix=".php">src</directory></include></source>
</phpunit>
XML
cat > src/Calculator.php <<'PHP'
<?php
declare(strict_types=1);
namespace Repro;
final class Calculator
{
public function add(int $a, int $b): int
{
return $a + $b;
}
}
PHP
cat > tests/AddTest.php <<'PHP'
<?php
declare(strict_types=1);
use Repro\Calculator;
it('adds two numbers', function (): void {
expect((new Calculator())->add(2, 2))->toBe(4);
});
PHP
# Blocks mid-run so a second process can commit at a deterministic point.
cat > tests/HandshakeTest.php <<'PHP'
<?php
declare(strict_types=1);
use Repro\Calculator;
it('holds the run open', function (): void {
expect((new Calculator())->add(1, 1))->toBeInt(); // depend on the same source file
$sig = getenv('REPRO_SIG');
if (is_string($sig) && $sig !== '') {
touch($sig.'/reached');
$deadline = microtime(true) + 20.0;
while (! file_exists($sig.'/committed') && microtime(true) < $deadline) {
usleep(50_000);
}
}
});
PHP
printf 'vendor/\n' > .gitignore
composer install --quiet --no-interaction
git init -q -b main
git config user.email repro@example.test
git config user.name "TIA Repro"
git config init.defaultBranch main
git remote add origin https://github.com/example/tia-repro.git # TIA requires a remote
git add -A && git commit -qm "baseline"
SIG="$DIR/sig"; mkdir -p "$SIG"
echo
echo "=== 1. record the TIA baseline ==============================================="
XDEBUG_MODE=coverage vendor/bin/pest --tia >/dev/null 2>&1 || true
echo " recorded at $(git rev-parse --short HEAD)"
echo
echo "=== 2. a real source change, committed -- makes both tests 'affected' ========"
sed -i.bak 's/return \$a + \$b;/return \$a + \$b + 0;/' src/Calculator.php && rm -f src/Calculator.php.bak
git add -A && git commit -qm "a change that makes the tests affected"
echo " HEAD is now $(git rev-parse --short HEAD)"
echo
echo "=== 3. run --tia; a SECOND PROCESS COMMITS while it is in flight ============="
(
deadline=$(( $(date +%s) + 25 ))
while [[ ! -f "$SIG/reached" && $(date +%s) -lt $deadline ]]; do sleep 0.05; done
if [[ ! -f "$SIG/reached" ]]; then echo " !! handshake failed, repro inconclusive"; touch "$SIG/committed"; exit 1; fi
cd "$DIR"
cat > src/Calculator.php <<'PHP'
<?php
declare(strict_types=1);
namespace Repro;
final class Calculator
{
public function add(int $a, int $b): int
{
return $a + $b + 1000; // the other process's change -- BREAKS AddTest
}
}
PHP
git add -A && git commit -qm "second process: breaks add() mid-run"
echo " second process committed $(git rev-parse --short HEAD) mid-run"
touch "$SIG/committed"
) &
WATCHER=$!
START=$(date +%s)
REPRO_SIG="$SIG" XDEBUG_MODE=coverage vendor/bin/pest --tia 2>&1 | tail -3
echo " ^ that run took $(( $(date +%s) - START ))s"
wait $WATCHER || true
echo
echo "=== 4. what TIA recorded ====================================================="
KEY=$(php -r 'require "vendor/autoload.php"; echo basename(Pest\Plugins\Tia\Storage::tempDir(getcwd()));')
php -r '
$g = json_decode(file_get_contents(getenv("HOME")."/.pest/tia/'"$KEY"'/graph.json"), true);
echo " graph recorded_at_sha = ".substr($g["baselines"]["main"]["sha"], 0, 7)."\n";'
echo " actual HEAD = $(git rev-parse --short HEAD)"
echo " ^ if these match, TIA has labelled its results with a commit it never executed"
echo
echo "=== 4b. the contract break, measured from TIA's OWN recorded edges ==========="
php -r '
$g = json_decode(file_get_contents(getenv("HOME")."/.pest/tia/'"$KEY"'/graph.json"), true);
$idx = array_search("src/Calculator.php", $g["files"], true);
$dependents = [];
foreach ($g["edges"] as $testFile => $fileIdx) {
if (in_array($idx, $fileIdx, true)) { $dependents[] = $testFile; }
}
echo " the mid-run commit modified : src/Calculator.php\n";
echo " tests TIA itself recorded as depending on it: ".count($dependents)."\n";
foreach ($dependents as $d) { echo " - $d\n"; }
'
RECORDED_SHA=$(php -r '
$g = json_decode(file_get_contents(getenv("HOME")."/.pest/tia/'"$KEY"'/graph.json"), true);
echo $g["baselines"]["main"]["sha"];')
CHANGED_SINCE=$(git diff --name-only "$RECORDED_SHA" | wc -l | tr -d ' ')
echo " changed files git reports since that recorded baseline: $CHANGED_SINCE"
echo " ^ TIA had itself recorded those tests as covering the changed file, yet computes"
echo " an empty changed-set from its own baseline. Step 5 shows the consequence:"
echo " every test is replayed and none is re-run."
echo
echo "=== 5. the bug ==============================================================="
strip() { sed $'s/\033\\[[0-9;]*m//g'; }
echo -n " pest --tia : "; XDEBUG_MODE=coverage vendor/bin/pest --tia 2>&1 | strip | grep -E "Tests:" | sed 's/^ *//'
echo -n " pest : "; vendor/bin/pest 2>&1 | strip | grep -E "Tests:" | sed 's/^ *//'
echo
echo " --tia reports green; the same suite without --tia fails."
echo " The green persists across repeated --tia runs; only a non-TIA run clears it."
echo
echo " (clean up: rm -rf $DIR and rm -rf ~/.pest/tia/$KEY)"
Output:
=== 1. record the TIA baseline ===============================================
recorded at d11ab63
=== 2. a real source change, committed -- makes both tests 'affected' ========
HEAD is now 3a3f6dc
=== 3. run --tia; a SECOND PROCESS COMMITS while it is in flight =============
second process committed 37ee0a9 mid-run
Tests: 2 passed (2 assertions, 2 affected)
^ that run took 1s
=== 4. what TIA recorded =====================================================
graph recorded_at_sha = 37ee0a9
actual HEAD = 37ee0a9
=== 5. the bug ===============================================================
pest --tia : Tests: 2 passed (2 assertions, 2 replayed)
pest : Tests: 1 failed, 1 passed (2 assertions)
--tia passes. The same suite without --tia fails.
What TIA's own graph says
This is the part that matters. The problem is not a wrong value on a graph entry. It is that TIA skips tests it has itself recorded as covering the changed file, and says nothing about it.
Step 4b of the script reads the graph that the run just wrote:
=== 4b. the contract break, measured from TIA's OWN recorded edges ===========
the mid-run commit modified : src/Calculator.php
tests TIA itself recorded as depending on it: 2
- tests/AddTest.php
- tests/HandshakeTest.php
changed files git reports since that recorded baseline: 0
So TIA knows both tests depend on src/Calculator.php. The mid-run commit changed that file. But because the baseline is the mid-run commit itself, TIA computes an empty changed-set and re-runs nothing. One of those two tests is the one that catches the break. Step 5 shows the result: everything is replayed, the suite reports green, and the output gives no sign that the selection is unsound.
Why it happens
Tia.php:623 reads HEAD at the end of the run. Tia.php:638 stores it as the baseline:
$currentSha = $changedFiles->currentSha(); // :623, read at END of run
...
$graph->setRecordedAtSha($this->branch, $currentSha); // :638 (and again at :726)
setLastRunTree() then snapshots since($currentSha), which is empty. The next run finds no changed files and replays everything.
There is already a guard for this shape of problem, but it cannot see the commit. structuralFingerprintShifted() (Tia.php:234-239) compares start-of-run state against end-of-run state, and on a difference Pest discards the recorded edges and prints a warning (:627-634, and again at :713-722). The machinery is right. The input set is incomplete.
Fingerprint::compute() (Fingerprint.php:33-41) hashes composer.lock, phpunit.xml, phpunit.xml.dist, the Vite config, the package lock and the JS config. It does not hash HEAD, and it does not hash app source. An ordinary code commit changes none of those, so the guard never fires.
The replay path has no guard at all. bumpRecordedSha() (Tia.php:1636-1657, called from :687-688) re-stamps the SHA unconditionally.
Suggested fix
The comparison already exists. What is missing is one input. $this->startFingerprint is captured at :850. No SHA is captured at the start of a run anywhere.
- Capture
$changedFiles->currentSha()at start, next to$this->startFingerprint. - Recording path: add a start-vs-end HEAD mismatch to the existing discard-and-warn condition.
- Replay path: make
bumpRecordedSha()refuse to stamp when HEAD moved during the run.
Recording the start SHA instead would not be correct. If the tree moved during the run, the results describe a mix of two commits, and no single SHA labels them honestly. Discarding the recording is the safe answer, and it is what the existing guard already does for config drift.
I am happy to test a patch against a real suite.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in Tia.php around the recording and replay paths at the cited lines, including structuralFingerprintShifted() and bumpRecordedSha(), then inspect Fingerprint.php and the existing TIA tests. Reproduce the mid-run commit scenario with the supplied script. Done means a commit during a --tia run cannot produce replayed green results for code it did not execute, while the existing warning and discard behavior remains intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, php
- Domain
- testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100