This commit is contained in:
nuno maduro
2026-04-30 20:45:36 +01:00
parent f355b99bbf
commit 6a434be0f6
13 changed files with 495 additions and 1336 deletions

View File

@ -11,100 +11,35 @@ use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface;
/**
* File-level Test Impact Analysis graph.
*
* Persists the mapping `test_file → set<source_file>` so that subsequent runs
* can skip tests whose dependencies have not changed. Paths are stored relative
* to the project root and source files are deduplicated via an index so that
* the on-disk JSON stays compact for large suites.
* Dependency graph: test file → set<source file>. Skips unchanged tests on replay.
* Source files are indexed by numeric id to keep the on-disk JSON compact.
*
* @internal
*/
final class Graph
{
/**
* Relative path of each known source file, indexed by numeric id.
*
* @var array<int, string>
*/
/** @var array<int, string> */
private array $files = [];
/**
* Reverse lookup: source file → numeric id.
*
* @var array<string, int>
*/
/** @var array<string, int> */
private array $fileIds = [];
/**
* Edges: test file (relative) → list of source file ids.
*
* @var array<string, array<int, int>>
*/
/** @var array<string, array<int, int>> */
private array $edges = [];
/**
* Table edges: test file (relative) → list of lowercase SQL table
* names the test queried during record. Populated from the
* Recorder's `perTestTables()` snapshot; consumed at replay time
* to do surgical invalidation when a migration changes — the
* test only re-runs if its set intersects the tables the changed
* migration touches. Empty for tests that never hit the DB, which
* is exactly why those tests stay unaffected by migration edits.
*
* Unlike `$edges`, we store names rather than ids: the table
* universe is small (hundreds at most on a giant app), storing
* strings keeps the on-disk graph diff-readable, and the lookup
* cost is negligible compared to the per-file ids used above.
*
* @var array<string, array<int, string>>
*/
/** @var array<string, array<int, string>> */
private array $testTables = [];
/**
* Inertia page component edges: test file (relative) → list of
* component names the test server-side rendered (whatever was
* passed to `Inertia::render($component, …)`). Populated from
* `Recorder::perTestInertiaComponents()`; consumed at replay time
* so an edit to `resources/js/Pages/Users/Show.vue` only invalidates
* tests that rendered `Users/Show`. Same string-keyed shape as
* `$testTables` for the same diff-readable reasons.
*
* @var array<string, array<int, string>>
*/
/** @var array<string, array<int, string>> */
private array $testInertiaComponents = [];
/**
* Inverted JS dependency map: project-relative source path under
* `resources/js/**` → list of Inertia page components that
* transitively import it. Populated at record time by
* `JsModuleGraph::build()` (Vite module graph via Node helper,
* with a PHP fallback). Replay uses this to route a
* `Components/Button.vue` edit directly to the pages that depend
* on it, intersecting against `$testInertiaComponents` for
* surgical invalidation.
*
* @var array<string, array<int, string>>
*/
/** @var array<string, array<int, string>> */
private array $jsFileToComponents = [];
/**
* Environment fingerprint captured at record time.
*
* @var array<string, mixed>
*/
/** @var array<string, mixed> */
private array $fingerprint = [];
/**
* Per-branch baselines. Each branch independently tracks:
* - `sha` — last HEAD at which `--tia` ran on this branch
* - `tree` — content hashes of modified files at that point
* - `results` — per-test status + message + time
*
* Graph edges (test → source) stay shared across branches because
* structure doesn't change per branch. Only run-state is per-branch so
* a failing test on one branch doesn't poison another branch's replay.
*
* @var array<string, array{
* sha: ?string,
* tree: array<string, string>,
@ -113,20 +48,10 @@ final class Graph
*/
private array $baselines = [];
/**
* Canonicalised project root. Resolved through `realpath()` so paths
* captured by coverage drivers (always real filesystem targets) match
* regardless of whether the user's CWD is a symlink or has trailing
* separators.
*/
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
private readonly string $projectRoot;
/**
* Cached project-relative test files that contain at least one test in the
* `arch` group.
*
* @var array<string, true>|null
*/
/** @var array<string, true>|null */
private ?array $archTestFiles = null;
public function __construct(string $projectRoot)
@ -136,9 +61,6 @@ final class Graph
$this->projectRoot = $real !== false ? $real : $projectRoot;
}
/**
* Records that a test file depends on the given source file.
*/
public function link(string $testFile, string $sourceFile): void
{
$testRel = $this->relative($testFile);
@ -158,20 +80,11 @@ final class Graph
}
/**
* Returns the set of test files whose dependencies intersect $changedFiles.
*
* Two resolution paths:
* 1. **Coverage edges** — test depends on a PHP source file that changed.
* 2. **Watch patterns** — a non-PHP file (JS, CSS, config, …) matches a
* glob that maps to a test directory; every test under that directory
* is affected.
*
* @param array<int, string> $changedFiles Absolute or relative paths.
* @return array<int, string> Relative test file paths.
* @return array<int, string>
*/
public function affected(array $changedFiles): array
{
// Normalise all changed paths once.
$normalised = [];
foreach ($changedFiles as $file) {
@ -184,15 +97,9 @@ final class Graph
$affectedSet = [];
// Migration changes don't flow through the coverage-edge path —
// `RefreshDatabase` in every test's `setUp()` means every test
// has an edge to every migration, so step 1 would re-run the
// whole DB-touching suite on any migration edit. Route them
// separately: static-parse the migration source, union the
// referenced tables, and match tests whose recorded query
// footprint intersects that set. Missed files (rare: migrations
// with pure raw SQL or dynamic names) fall back to the watch
// pattern below.
// Migrations can't flow through coverage edges: `RefreshDatabase` gives every test an edge to
// every migration, so any migration change would re-run the whole DB suite. Route them via
// table-intersection instead; unparseable migrations fall through to the watch pattern.
$migrationPaths = [];
$nonMigrationPaths = [];
@ -237,16 +144,22 @@ final class Graph
}
}
// Inertia page-component routing. When a page under
// `resources/js/Pages/` changes, map it to the component name
// Inertia would use (the path relative to `Pages/`, extension
// stripped) and intersect with the captured component edges.
// Only invalidates tests that actually rendered the page.
// Pages with no captured edges (never rendered during record,
// brand-new on this branch) fall through to the watch-pattern
// fallback — safe over-run. Pages handled here are tracked in
// `$preciselyHandledPages` so the watch broadcast and JS-dep
// lookup don't re-route them.
// Inertia page routing: map changed page files to component names and intersect with recorded
// component edges. Pages with no captured edges fall through to the watch pattern.
$globalFrontendRuntimeFiles = [];
foreach ($nonMigrationPaths as $rel) {
if (! $this->isGlobalFrontendRuntimePath($rel)) {
continue;
}
foreach (array_keys($this->testInertiaComponents) as $testFile) {
$affectedSet[$testFile] = true;
}
$globalFrontendRuntimeFiles[$rel] = true;
}
$changedComponents = [];
$preciselyHandledPages = [];
@ -263,17 +176,13 @@ final class Graph
}
}
// Shared JS files (Components, Layouts, composables, etc.)
// aren't Inertia pages but pages depend on them transitively.
// `$jsFileToComponents` was computed at record time by walking
// Vite's module graph, so a change to
// `resources/js/Components/Button.vue` resolves directly to
// the set of page components that import it. Union those into
// `$changedComponents`. Files that aren't in the JS dep map
// fall through to the watch pattern below — same safety-net
// path the Inertia block above uses for unresolved pages.
// Shared JS files: resolve via the recorded Vite module graph to their dependent page components.
// Files absent from the map fall through to the watch pattern.
$sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) {
continue;
}
if (isset($preciselyHandledPages[$rel])) {
continue;
}
@ -295,23 +204,14 @@ final class Graph
}
}
// Orphan detection for NEW JS files. `$jsFileToComponents` is
// a record-time snapshot; files added since (a fresh Vue
// component, a new shared util, etc.) are absent from it.
// Today the broad watch pattern catches them — correct but
// pessimistic: a JS file that literally no page imports
// would still invalidate the entire browser dir.
//
// Fix: for each new JS file in the changed set, ask Vite
// (strict mode — no PHP fallback) which pages transitively
// import it. If none → orphan, suppress the broadcast. If
// some → precise union with their tests' components. The
// Node helper is the only resolver trustworthy enough to
// honour a *negative* answer (the PHP parser can silently
// miss custom aliases). When Node is unreachable we leave
// the files alone and let the watch pattern do its job.
// New JS files absent from the record-time map: ask Vite (strict, no PHP fallback) which pages
// import them. A negative answer suppresses the broad watch broadcast; Node is the only resolver
// trustworthy enough to honour a negative (PHP parser can miss custom aliases).
$newJsFiles = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) {
continue;
}
if (isset($preciselyHandledPages[$rel])) {
continue;
}
@ -331,12 +231,8 @@ final class Graph
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) {
// Vite resolver was unavailable (Node missing, cold-start
// timeout, vite.config refused to load). Falling back to
// the broad watch pattern is the correct call, but
// doing so silently can make a slow replay feel
// inexplicable — surface a single line so the user
// knows precision was downgraded for these files.
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user
// knows precision was downgraded rather than leaving the slower replay unexplained.
$output = Container::getInstance()->get(OutputInterface::class);
if ($output instanceof OutputInterface) {
$output->writeln(sprintf(
@ -349,9 +245,7 @@ final class Graph
$pages = $freshMap[$rel] ?? [];
if ($pages === []) {
// Vite itself says nothing imports this file.
// Safe to skip — mark handled so the watch
// pattern below doesn't re-broadcast it.
// Vite confirms no page imports this file — suppress the watch broadcast.
$sharedFilesResolved[$rel] = true;
continue;
@ -388,9 +282,8 @@ final class Graph
}
}
// 1. Coverage-edge lookup (PHP → PHP). Migrations are already
// handled above; skipping them here prevents their always-on
// coverage edges from invalidating the whole DB suite.
// Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents
// their always-on edges from re-running the whole DB suite.
$changedIds = [];
$unknownSourceDirs = [];
$sourcePhpChanged = false;
@ -410,28 +303,18 @@ final class Graph
$absolute = $this->projectRoot.'/'.$rel;
if (! is_file($absolute)) {
// Deleted source file unknown to the graph — can't affect
// any test because no edge ever pointed to it.
// Deleted source file unknown to the graph — no edge ever pointed to it.
continue;
}
// Source PHP file unknown to the graph — might be a new file
// that only exists on this branch (graph inherited from main).
// Only use the sibling heuristic for files that commonly
// participate in framework discovery / bootstrap. Ordinary new
// classes, enums, DTOs, services, etc. should not re-run sibling
// tests just because they live in the same directory.
if ($this->usesSiblingHeuristicForUnknownPhp($rel)) {
$unknownSourceDirs[dirname($rel)] = true;
}
}
}
// Architecture tests inspect source structure by namespace / path rather
// than by executing the inspected files. A new enum/class can therefore
// fail an Arch expectation without ever producing a coverage edge. Keep
// this fallback narrow: only tests in Pest's `arch` group run, not the
// suite.
// Arch tests inspect structure by namespace/path, never producing coverage edges for the files
// they examine — so a new class can fail an arch expectation without any edge to it.
if ($sourcePhpChanged) {
foreach (array_keys($this->edges) as $testFile) {
if ($this->isArchTestFile($testFile)) {
@ -454,11 +337,8 @@ final class Graph
}
}
// Unknown Blade files can still be routed precisely when another
// recorded Blade view statically references them (`@include`,
// `@extends`, `<x-alert />`, etc.). Walk the source-level Blade graph
// upward to rendered ancestors and invalidate tests that rendered those
// ancestors instead of broadcasting every Blade edit to the whole suite.
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
// ancestors and invalidate only tests that covered them.
$staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) {
@ -480,29 +360,13 @@ final class Graph
$staticallyHandledBlade[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) {
// Anonymous Blade components are leaf templates. If nothing in
// the project statically renders the component, treat it like an
// orphan rather than running the full suite.
// Anonymous component with no static usages — treat as orphan rather than broadcasting.
$staticallyHandledBlade[$rel] = true;
}
}
// 2. Watch-pattern lookup — fallback for files we don't have
// precise edges for. When a file is already in `$fileIds` step
// 1 resolved it surgically; broadcasting it again through the
// watch pattern would re-add every test the pattern maps to,
// defeating the point of recording the edge in the first place.
// Blade templates captured via Laravel's view composer are the
// motivating case — we want their specific tests, not every
// feature test. Migrations whose static parse yielded nothing
// (exotic syntax, raw SQL) are funneled back in here too so
// broad invalidation still kicks in for edge cases we can't
// parse.
// Exclude paths that were already routed precisely through
// either the Inertia page-component path or the shared-JS
// dependency path. Broadcasting them again via the watch
// pattern would re-add every test the pattern maps to,
// defeating the surgical match.
// Watch-pattern fallback: files with no precise edges. Already-resolved files are excluded
// to avoid re-broadcasting via the watch pattern and defeating the surgical match.
$unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) {
@ -516,8 +380,7 @@ final class Graph
}
if (! isset($this->fileIds[$rel])) {
if (! is_file($this->projectRoot.'/'.$rel)) {
// Deleted file unknown to the graph — no edge ever
// pointed to it, so it can't affect any test.
// Deleted file unknown to the graph — no edge ever pointed to it.
continue;
}
@ -535,22 +398,9 @@ final class Graph
$affectedSet[$testFile] = true;
}
// 3. Sibling heuristic for unknown source files.
//
// When a PHP source file is unknown to the graph (no test depends on
// it), it is either genuinely untested OR it was added on a branch
// whose graph was inherited from another branch (e.g. main). In the
// latter case the graph simply never saw the file.
//
// To avoid silent misses for framework-discovered files: find tests
// that already cover ANY file in the same directory. If
// `app/Listeners/SendWelcomeEmail.php` is unknown but neighbouring
// listeners are covered by a mail-flow test, run that test — it likely
// exercises the same discovery surface.
//
// This over-runs slightly (sibling may be unrelated) but never
// under-runs. And once the test executes, its coverage captures the
// new file → graph self-heals for next run.
// Sibling heuristic: unknown PHP source files may be new files whose graph was inherited from
// another branch. Run tests that cover neighbouring files in the same directory so framework-
// discovered files (Listeners, Events, Policies, etc.) aren't silently missed.
if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) {
@ -576,9 +426,6 @@ final class Graph
return array_keys($affectedSet);
}
/**
* Returns `true` if the given test file has any recorded dependencies.
*/
public function knowsTest(string $testFile): bool
{
$rel = $this->relative($testFile);
@ -586,9 +433,7 @@ final class Graph
return $rel !== null && isset($this->edges[$rel]);
}
/**
* @return array<int, string> All project-relative test files the graph knows.
*/
/** @return array<int, string> */
public function allTestFiles(): array
{
return array_keys($this->edges);
@ -610,12 +455,6 @@ final class Graph
return $this->fingerprint;
}
/**
* Returns the SHA the given branch last ran against, or falls back to
* `$fallbackBranch` (typically `main`) when this branch has no baseline
* yet. That way a freshly-created feature branch inherits main's
* baseline on its first run.
*/
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@ -640,12 +479,6 @@ final class Graph
];
}
/**
* Returns the cached assertion count for a test, or `null` if unknown.
* Callers use this to feed `addToAssertionCount()` at replay time so
* the "Tests: N passed (M assertions)" banner matches the recorded run
* instead of defaulting to 1 assertion per test.
*/
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@ -693,14 +526,7 @@ final class Graph
$this->baselines[$branch]['tree'] = $tree;
}
/**
* Wipes cached per-test results for the given branch. Edges and tree
* snapshot stay intact — the graph still describes the code correctly,
* only the "what happened last time" data is reset. Used on
* environmental fingerprint drift: the edges were recorded elsewhere
* (e.g. CI) so they're still valid, but the results aren't trustworthy
* on this machine until the tests re-run here.
*/
// Edges and tree snapshot stay intact; only the run-state is reset.
public function clearResults(string $branch): void
{
$this->ensureBaseline($branch);
@ -739,9 +565,6 @@ final class Graph
}
/**
* Replaces edges for the given test files. Used during a partial record
* run so that existing edges for other tests are preserved.
*
* @param array<string, array<int, string>> $testToFiles
*/
public function replaceEdges(array $testToFiles): void
@ -765,12 +588,6 @@ final class Graph
}
/**
* Replaces table edges for the given test files. Table names are
* lowercased + deduplicated; the input comes straight from the
* Recorder's `perTestTables()` snapshot. Tests absent from the
* input keep their existing table set (same partial-update policy
* as `replaceEdges`).
*
* @param array<string, array<int, string>> $testToTables
*/
public function replaceTestTables(array $testToTables): void
@ -800,11 +617,6 @@ final class Graph
}
/**
* Replaces Inertia component edges for the given test files. Names
* preserve case (they're identifiers like `Users/Show`, not
* user-supplied strings) but duplicates are collapsed. Same
* partial-update policy as `replaceTestTables`.
*
* @param array<string, array<int, string>> $testToComponents
*/
public function replaceTestInertiaComponents(array $testToComponents): void
@ -831,16 +643,8 @@ final class Graph
}
}
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
/**
* Replaces the whole JS dep map. Called at record time with the
* output of `JsModuleGraph::build()`. Empty input is treated as a
* resolver failure (Node missing, Vite refused to load, transient
* `npm install`) rather than a legitimate "no JS pages" signal —
* we keep the previous map. Stale entries for genuinely-deleted
* pages are harmless because deleted files never enter the
* changed set; over-broadcasting every JS edit through the watch
* pattern after a flaky Node run would be a real regression.
*
* @param array<string, array<int, string>> $fileToComponents
*/
public function replaceJsFileToComponents(array $fileToComponents): void
@ -877,23 +681,11 @@ final class Graph
$this->jsFileToComponents = $out;
}
/**
* Projects under Laravel conventionally keep migrations at
* `database/migrations/`. We recognise the directory as a prefix
* so nested subdirectories (a pattern some teams use for grouping
* — `database/migrations/tenant/`, `database/migrations/archived/`)
* are still routed through the table-intersection path.
*/
private function isMigrationPath(string $rel): bool
{
return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
}
/**
* Unknown PHP files have no historical edge yet. Keep sibling fan-out only
* for framework-discovered / boot-loaded conventions where adding a file can
* change behaviour without another source file changing too.
*/
private function usesSiblingHeuristicForUnknownPhp(string $rel): bool
{
static $prefixes = [
@ -905,6 +697,14 @@ final class Graph
'app/Console/Commands/',
'app/Mail/',
'app/Notifications/',
'app/Nova/Actions/',
'app/Nova/Dashboards/',
'app/Nova/Lenses/',
'app/Nova/Metrics/',
'app/Nova/Policies/',
'app/Nova/Resources/',
'app/Projectors/',
'app/Reactors/',
'database/factories/',
'database/seeders/',
];
@ -1204,15 +1004,7 @@ final class Graph
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
}
/**
* Reads `$rel` relative to the project root and extracts the
* tables it declares via `Schema::create/table/drop/rename`.
* Empty on missing/unreadable files or when the parser finds
* nothing — the caller escalates those cases to the watch
* pattern safety net.
*
* @return list<string>
*/
/** @return list<string> */
private function tablesForMigration(string $rel): array
{
$absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel;
@ -1230,21 +1022,7 @@ final class Graph
return TableExtractor::fromMigrationSource($content);
}
/**
* Maps a project-relative path to its Inertia component name if it
* lives under the project's pages directory with a recognised
* framework extension. Returns null otherwise so callers can
* cheaply ignore non-page files. Matches Inertia's resolver
* convention: strip the pages prefix, strip the extension, preserve
* the remaining slashes (`Users/Show.vue` → `Users/Show`).
*
* Both `resources/js/Pages/` (the classic Inertia-Vue convention)
* and `resources/js/pages/` (the Laravel React starter kit, and
* other lowercase-by-default setups) are accepted — paths from
* git are case-sensitive on Linux, so we must match the exact
* casing used by the project rather than picking one and forcing
* the other to fall through to the broad watch pattern.
*/
// Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
private function componentForInertiaPage(string $rel): ?string
{
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
@ -1273,13 +1051,27 @@ final class Graph
return null;
}
/**
* Whether any test's component set contains `$component`. Used to
* decide between precise edge matching and watch-pattern fallback
* for a changed Inertia page file.
*
* @param array<string, array<int, string>> $edges
*/
private function isGlobalFrontendRuntimePath(string $rel): bool
{
if (! str_starts_with($rel, 'resources/js/')) {
return false;
}
$tail = substr($rel, strlen('resources/js/'));
$dot = strrpos($tail, '.');
if ($dot === false) {
return false;
}
$name = substr($tail, 0, $dot);
$extension = substr($tail, $dot + 1);
return in_array($extension, ['js', 'jsx', 'ts', 'tsx', 'vue', 'svelte'], true)
&& in_array($name, ['App', 'app', 'bootstrap', 'echo', 'favicon'], true);
}
/** @param array<string, array<int, string>> $edges */
private function anyTestUses(array $edges, string $component): bool
{
foreach ($edges as $components) {
@ -1291,11 +1083,6 @@ final class Graph
return false;
}
/**
* Drops edges whose test file no longer exists on disk. Prevents the graph
* from keeping stale entries for deleted / renamed tests that would later
* be flagged as affected and confuse PHPUnit's discovery.
*/
public function pruneMissingTests(): void
{
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
@ -1319,12 +1106,6 @@ final class Graph
}
}
/**
* Rebuilds a graph from its JSON representation. Returns `null` when
* the payload is missing, unreadable, or schema-incompatible. Separated
* from transport (state backend, file, etc.) so tests can feed bytes
* directly without touching disk.
*/
public static function decode(string $json, string $projectRoot): ?self
{
$data = json_decode($json, true);
@ -1412,12 +1193,6 @@ final class Graph
return $graph;
}
/**
* Serialises the graph to its JSON on-disk form. Returns `null` if the
* payload can't be encoded (extremely rare — pathological UTF-8 only).
* Persistence is the caller's responsibility: write the returned bytes
* through whatever `State` implementation is in play.
*/
public function encode(): ?string
{
$payload = [
@ -1436,15 +1211,8 @@ final class Graph
return $json === false ? null : $json;
}
/**
* Normalises a path to be relative to the project root; returns `null` for
* paths we should ignore (outside the project, unknown, virtual, vendor).
*
* Accepts both absolute paths (from Xdebug/PCOV coverage) and
* project-relative paths (from `git diff`) — we normalise without relying
* on `realpath()` of relative paths because the current working directory
* is not guaranteed to be the project root.
*/
// Accepts both absolute paths (from coverage drivers) and project-relative paths (from git diff).
// Relative paths are NOT resolved via realpath() because CWD is not guaranteed to be the project root.
private function relative(string $path): ?string
{
if ($path === '' || $path === 'unknown') {
@ -1471,12 +1239,9 @@ final class Graph
return null;
}
// Always normalise to forward slashes. Windows' native separator
// would otherwise produce keys that never match paths reported
// by `git` (which always uses forward slashes).
// Always forward slashes — git always uses them; Windows backslashes would never match.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
} else {
// Normalise directory separators and strip any "./" prefix.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
while (str_starts_with($relative, './')) {
@ -1484,10 +1249,6 @@ final class Graph
}
}
// Vendor packages are pinned by composer.lock. Any upgrade bumps the
// fingerprint and invalidates the graph wholesale, so there is no
// reason to track individual vendor files — doing so inflates the
// graph by orders of magnitude on Laravel-style projects.
if (str_starts_with($relative, 'vendor/')) {
return null;
}