From 6b9c7681721e3cc10a6d61909129a28c923c937e Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Fri, 1 May 2026 14:39:23 +0100 Subject: [PATCH] wip --- src/Plugins/Tia/Recorder.php | 40 +++-- src/Plugins/Tia/SourceScope.php | 273 ++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 14 deletions(-) create mode 100644 src/Plugins/Tia/SourceScope.php diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index 0a63410f..d406f9c4 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -61,6 +61,8 @@ final class Recorder private string $driver = 'none'; + private ?SourceScope $sourceScope = null; + public function activate(): void { $this->active = true; @@ -148,24 +150,28 @@ final class Recorder if ($this->driver === 'pcov') { \pcov\stop(); - /** @var array $data */ - $filesToCollectCoverageFor = \pcov\waiting(); + + // pcov\waiting() lists every file pcov has tracked but not + // yet collected for. Filter that list down to the project's + // source scope (phpunit.xml's `` plus other + // top-level project dirs, minus vendor / caches), then ask + // pcov to collect *only* for those — `pcov\inclusive` + // narrows the result set at the driver level instead of us + // post-filtering after a full collect. Anything pcov saw + // outside the scope is dropped before any line counts come + // back. + $scope = $this->sourceScope(); + $filesToCollectCoverageFor = []; + + foreach (\pcov\waiting() as $file) { + if (is_string($file) && $scope->contains($file)) { + $filesToCollectCoverageFor[] = $file; + } + } /** @var array $data */ $data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor); - // pcov returns every executable line in every file it - // tracked: positive values for executed lines, `-1` for - // executable-but-not-run. A file with no positives was - // loaded but nothing in it ran during this test's window - // — typically a declaration-only file (Mailables, Enums, - // DTOs) pulled in by some service-provider's static `use` - // at framework boot. Including those attributes every - // globally-bootstrapped class to whichever test triggered - // the boot, blowing up the affected set on edits to those - // files. We further narrow to phpunit.xml's `` - // scope so files outside the configured include set never - // become edges. $coveredFiles = self::filesWithExecutedLines($data); } else { /** @var array $data */ @@ -708,6 +714,11 @@ final class Recorder return $out; } + private function sourceScope(): SourceScope + { + return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath); + } + public function reset(): void { $this->currentTestFile = null; @@ -720,6 +731,7 @@ final class Recorder $this->fileToClassNames = []; $this->indexedClassNames = []; $this->classDependencyCache = []; + $this->sourceScope = null; $this->active = false; } } diff --git a/src/Plugins/Tia/SourceScope.php b/src/Plugins/Tia/SourceScope.php new file mode 100644 index 00000000..ccc071ba --- /dev/null +++ b/src/Plugins/Tia/SourceScope.php @@ -0,0 +1,273 @@ +` config plus any other + * top-level project directories that aren't on a hard-coded noise + * list (vendor, caches, IDE/git metadata). + * + * Used by `Recorder` as the per-test filter passed to + * `\pcov\collect(\pcov\inclusive, …)` — pcov tracks every file PHP + * loads, but we only ask for coverage on files inside the project + * source scope, so anything outside (vendor / caches / etc.) is + * dropped before any line counts come back. + * + * Falls back to "every top-level project dir minus the noise list" + * when no `phpunit.xml` / `phpunit.xml.dist` is present or it has no + * `` block — Pest projects without explicit phpunit config + * still get sensible scoping. + * + * @internal + */ +final class SourceScope +{ + /** + * Top-level directory names always treated as out-of-scope. These + * mirror what a Laravel app considers "not source": dependencies, + * editor metadata, framework artefacts, the TIA state itself. + */ + private const array TOP_LEVEL_NOISE = [ + 'vendor', + 'node_modules', + '.git', + '.idea', + '.vscode', + '.github', + '.pest', + '.phpunit.cache', + '.cache', + ]; + + /** + * Nested paths (relative to project root) that must be excluded + * even when their top-level parent is in scope. Laravel writes + * compiled views, route caches, and packaged manifests here on + * every framework boot — instrumenting them would burn cycles + * and create noisy edges. + */ + private const array NESTED_NOISE = [ + 'storage/framework', + 'storage/logs', + 'bootstrap/cache', + ]; + + /** + * @param list $includes Absolute, normalised directory paths. + * @param list $excludes Absolute, normalised directory paths. + */ + public function __construct( + private readonly string $projectRoot, + private readonly array $includes, + private readonly array $excludes, + ) {} + + public static function fromProjectRoot(string $projectRoot): self + { + $configPath = self::configPath($projectRoot); + + $phpunitIncludes = []; + $phpunitExcludes = []; + + if ($configPath !== null) { + $xml = @simplexml_load_file($configPath); + + if ($xml !== false) { + $configDir = dirname($configPath); + $phpunitIncludes = self::extractDirectories($xml, 'source/include/directory', $configDir); + $phpunitExcludes = self::extractDirectories($xml, 'source/exclude/directory', $configDir); + } + } + + $rootIncludes = self::topLevelProjectDirs($projectRoot); + + $includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes])); + $excludes = array_values(array_unique([ + ...$phpunitExcludes, + ...self::nestedNoiseDirs($projectRoot), + ])); + + if ($includes === []) { + $includes = [self::normalise($projectRoot)]; + } + + return new self($projectRoot, $includes, $excludes); + } + + /** + * True when the absolute file path is inside an `` + * directory and not under any exclude. Symlinks are resolved on + * the input so a `realpath()`'d coverage entry still matches a + * config that pointed at the unresolved tree. + */ + public function contains(string $absoluteFile): bool + { + $real = @realpath($absoluteFile); + $candidate = $real === false ? $absoluteFile : $real; + $candidate = self::normalise($candidate); + + foreach ($this->excludes as $excluded) { + if (self::startsWithDir($candidate, $excluded)) { + return false; + } + } + + foreach ($this->includes as $included) { + if (self::startsWithDir($candidate, $included)) { + return true; + } + } + + return false; + } + + /** + * Project-relative directories the resolver considers in scope. + * Useful for setting `pcov.directory` (a single common ancestor) + * or `\pcov\collect()`'s file filter. + * + * @return list + */ + public function includes(): array + { + return $this->includes; + } + + private static function configPath(string $projectRoot): ?string + { + foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) { + $candidate = $projectRoot.DIRECTORY_SEPARATOR.$name; + + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } + + /** + * @return list + */ + private static function extractDirectories(\SimpleXMLElement $xml, string $xpath, string $configDir): array + { + $nodes = $xml->xpath($xpath); + + if (! is_array($nodes)) { + return []; + } + + $out = []; + + foreach ($nodes as $node) { + $value = trim((string) $node); + + if ($value === '') { + continue; + } + + $absolute = self::resolveRelative($value, $configDir); + + if ($absolute === null) { + continue; + } + + $out[] = $absolute; + } + + return array_values(array_unique($out)); + } + + /** + * Every top-level directory under `$projectRoot` except those on + * the noise list. Hidden entries (dotdirs) are skipped unless + * they're explicitly project source — keeping `.git/`, `.idea/` + * etc. out without an explicit allowlist. + * + * @return list + */ + private static function topLevelProjectDirs(string $projectRoot): array + { + $entries = @scandir($projectRoot); + + if ($entries === false) { + return []; + } + + $out = []; + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + if (in_array($entry, self::TOP_LEVEL_NOISE, true)) { + continue; + } + + if ($entry !== '' && $entry[0] === '.') { + continue; + } + + $abs = $projectRoot.DIRECTORY_SEPARATOR.$entry; + + if (! is_dir($abs)) { + continue; + } + + $out[] = self::normalise(@realpath($abs) ?: $abs); + } + + return $out; + } + + /** + * @return list + */ + private static function nestedNoiseDirs(string $projectRoot): array + { + $out = []; + + foreach (self::NESTED_NOISE as $relative) { + $abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative); + $out[] = self::normalise(@realpath($abs) ?: $abs); + } + + return $out; + } + + private static function resolveRelative(string $path, string $configDir): ?string + { + $isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/' + || (strlen($path) >= 2 && $path[1] === ':')); + + $combined = $isAbsolute ? $path : $configDir.DIRECTORY_SEPARATOR.$path; + + $real = @realpath($combined); + + if ($real === false) { + // Directory may not exist yet (e.g. generated source) — keep + // the unresolved path so a future file under it still matches. + return self::normalise($combined); + } + + return self::normalise($real); + } + + private static function normalise(string $path): string + { + return rtrim($path, '/\\'); + } + + private static function startsWithDir(string $candidate, string $dir): bool + { + if ($candidate === $dir) { + return true; + } + + return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR); + } +}