Files
pest/src/Plugins/Tia/SourceScope.php
nuno maduro 6b9c768172 wip
2026-05-01 14:39:23 +01:00

274 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Scopes coverage collection to project source — the directories
* declared in `phpunit.xml`'s `<source>` 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
* `<source>` 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<string> $includes Absolute, normalised directory paths.
* @param list<string> $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 `<include>`
* 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<string>
*/
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<string>
*/
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<string>
*/
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<string>
*/
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);
}
}