Skip to content

fix(tia): run the full suite when code the project loads from outside its root changes - #1915

Open
jfatr wants to merge 5 commits into
pestphp:5.xfrom
jfatr:fix/tia-external-sources
Open

jfatr wants to merge 5 commits into
pestphp:5.xfrom
jfatr:fix/tia-external-sources

Conversation

@jfatr

@jfatr jfatr commented Sep 15, 2026

Copy link
Copy Markdown

Builds on #1914. The first commit here is that pull request; review the second commit, fix(tia): run the full suite when code the project loads from outside its root changes.

What happens

A nested project can load PHP from outside its own root: a Composer path repository, a psr-4 mapping onto ../packages/shared/src, a PHPUnit bootstrap, <source> directory or <testsuite>, an --include-path, or a symlink that leaves the project. The dependency graph reaches no further than the project root, so a change to any of those files selects no test at all, and the suite replays from cache with results that the changed code no longer supports.

The fix

src/Plugins/Tia/ExternalSources.php reads the composer manifest of the project and the PHPUnit configuration that the run selects, and resolves the roots the project loads from outside itself.

  • The configuration is the one PHPUnit opens: -c in its split, joined and attached forms, a directory as its value, --no-configuration, and the implicit file of the working directory in the order phpunit.xml, phpunit.dist.xml, phpunit.xml.dist. --bootstrap and --include-path on the command line count as declarations too, and so does the bootstrap attribute of each test suite.
  • Each declaration resolves against the file that declares it, follows its symlinks when the target exists, keeps the segments before the first wildcard of a path repository or a source directory, and keeps a target that a commit deleted. A root matches the directory itself, so a submodule that git names by its gitlink alone matches.
  • A committed change under one of those roots stops the replay and records a fresh graph when a coverage driver is available, or runs the full suite and keeps the recorded commit where it is when none is.
  • An uncommitted change there runs the full suite and writes nothing, on every path that writes: a replay, a first run, --fresh, a rebuild after drift, and the result-only write of a --filter run. Git stops reporting such an edit the moment it is undone, so no snapshot of the run could stand behind a cached result.
  • A declaration that leaves the repository, or that sits under a path the repository ignores, is reported and runs the full suite, because no run can observe a change there.
  • The structural fingerprint carries the external roots and the identity of the selected configuration, so a graph recorded under one configuration does not replay under another, and a project that holds both phpunit.xml and phpunit.xml.dist tells -c phpunit.xml.dist apart from the default. A project that declares nothing outside its root and runs the file PHPUnit selects by default keeps the fingerprint it had.

This is the narrow form on purpose. Every change outside the project would make Tia worthless in a monorepo whose frontend moves daily; a project that declares nothing outside its root keeps the selection it had.

Tests

tests/Unit/Plugins/Tia/ExternalSources.php covers each declaration source, the configuration selection and the fingerprint. tests/Features/Tia/Monorepo.php runs a nested project against a changed sibling package, an autoload file, a bootstrap, a deleted package, an external test suite, a wildcard path repository, a symlink, a submodule, a configuration argument, a relative -c from the repository root, an uncommitted external edit on each write path, and a project that loads from outside its repository.

  • pest --group=tia: 248 passed.
  • rector, pint, phpstan and type coverage at 100 % pass.

🤖 Generated with Claude Code

jfatr and others added 2 commits September 15, 2026 14:48
Tia refused to run whenever the Pest project was not the git repository
root, because git prints and addresses paths relative to the repository
root while the dependency graph is keyed on project-relative paths.

Translate at every git boundary instead of guarding against the mismatch.
`Git::pathPrefix()` resolves the directory of the project inside the
repository, and `ChangedFiles` strips that prefix off the paths git prints,
drops the paths of sibling packages, and prepends it to `git show`. Both
diffs read `-z` records, because `core.quotePath` C-quotes a path outside
ASCII, and `--no-relative` holds the frame when a user sets `diff.relative`.

Three places read `<projectRoot>/.git` directly, which is absent in a
nested project and in a worktree, and now ask git: `BaseRemote`, `Storage`
and `Fingerprint::isTrackedByGit()`. `Storage` folds the prefix into the
state key so two projects in one repository do not share a graph, and the
fingerprint carries the prefix so a baseline published by a sibling project
is rejected. A project at the repository root keeps the key and the
fingerprint it had.

`git check-ignore` runs without `--no-index`, so a tracked file that an
ignore rule matches counts as part of the project on both sides of the
boundary.

Changes outside the project are dropped and reported by count in the run
summary, so a nested project does not look idle when the repository around
it moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… its root changes

A nested project can load PHP from outside its own root: a Composer path
repository, a psr-4 mapping onto `../packages/shared/src`, a PHPUnit
bootstrap, source directory or test suite, an include path, or a symlink
that leaves the project. The dependency graph reaches no further than the
project root, so a change to those files selected no test at all and the
suite replayed from cache.

`ExternalSources` reads the composer manifest of the project and the PHPUnit
configuration that the run selects, the way PHPUnit selects it: `-c` in its
split, joined and attached forms, a directory as its value,
`--no-configuration`, `--bootstrap` and `--include-path`, and the implicit
file of the working directory. Each declaration resolves against the file
that declares it, follows its symlinks when the target exists, keeps the
segments before a wildcard, and keeps a target that a commit deleted. A
root answers to the directory itself, so a moved submodule matches.

A committed change under one of those roots stops the replay and records a
fresh graph when a coverage driver is available. An uncommitted change
there runs the full suite and writes nothing, on every path that writes:
git stops reporting such an edit the moment it is undone, and no snapshot
of the run covers it. A declaration that leaves the repository, or sits
under a path the repository ignores, is reported and runs the full suite,
because no run can observe it.

The structural fingerprint carries the external roots and the identity of
the selected configuration, so a graph recorded under one configuration
does not replay under another. A project that declares nothing outside its
root and runs the file PHPUnit selects by default keeps the fingerprint it
had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jfatr and others added 3 commits September 18, 2026 14:03
Replace the phpunit_xml and phpunit_xml_dist entries with one configuration
entry, and drop the exception that left the file of the project root to the
tracked hash. A phpunit.xml that git ignores now invalidates the graph when
its content changes. SCHEMA_VERSION moves to 19, so existing graphs rebuild
once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	src/Plugins/Tia/Baselines/BaseRemote.php
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