mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
wip
This commit is contained in:
@ -283,6 +283,11 @@ final readonly class ChangedFiles
|
|||||||
'.phpunit.result.cache',
|
'.phpunit.result.cache',
|
||||||
'vendor/',
|
'vendor/',
|
||||||
'node_modules/',
|
'node_modules/',
|
||||||
|
// Laravel regenerates these from manifest state
|
||||||
|
// (package.json, service providers) at boot — they're
|
||||||
|
// fully derived, not authored. Treating them as
|
||||||
|
// "changes" just flaps the diff noisily.
|
||||||
|
'bootstrap/cache/',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($prefixes as $prefix) {
|
foreach ($prefixes as $prefix) {
|
||||||
|
|||||||
@ -85,6 +85,11 @@ final readonly class Fingerprint
|
|||||||
// Pest itself is edited.
|
// Pest itself is edited.
|
||||||
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||||
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||||
|
// `vite.config.*` reshapes the module graph
|
||||||
|
// `JsModuleGraph` records at the next `--tia` run; if
|
||||||
|
// the config drifts without a rebuild, the stored
|
||||||
|
// `$jsFileToComponents` map is silently stale.
|
||||||
|
'vite_config' => self::viteConfigHash($projectRoot),
|
||||||
],
|
],
|
||||||
'environmental' => [
|
'environmental' => [
|
||||||
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
||||||
@ -193,6 +198,28 @@ final readonly class Fingerprint
|
|||||||
return $normalised;
|
return $normalised;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined hash of every `vite.config.{ts,js,mjs,cjs,mts}` present
|
||||||
|
* at the project root. Most projects have exactly one; we accept
|
||||||
|
* any of the five recognised extensions without assuming which
|
||||||
|
* the user picked. Returns null when no config file exists —
|
||||||
|
* treated as "no Vite project" by the matcher, no drift.
|
||||||
|
*/
|
||||||
|
private static function viteConfigHash(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||||
|
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||||
|
|
||||||
|
if ($hash !== null) {
|
||||||
|
$parts[] = $name.':'.$hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||||
|
}
|
||||||
|
|
||||||
private static function hashIfExists(string $path): ?string
|
private static function hashIfExists(string $path): ?string
|
||||||
{
|
{
|
||||||
if (! is_file($path)) {
|
if (! is_file($path)) {
|
||||||
|
|||||||
@ -297,6 +297,131 @@ 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.
|
||||||
|
$newJsFiles = [];
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
if (isset($preciselyHandledPages[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($sharedFilesResolved[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($this->jsFileToComponents[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! str_starts_with($rel, 'resources/js/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newJsFiles[] = $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newJsFiles !== []) {
|
||||||
|
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||||
|
|
||||||
|
if ($freshMap !== null) {
|
||||||
|
foreach ($newJsFiles as $rel) {
|
||||||
|
$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.
|
||||||
|
$sharedFilesResolved[$rel] = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$touchedAny = false;
|
||||||
|
foreach ($pages as $pageComponent) {
|
||||||
|
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||||
|
$changedComponents[$pageComponent] = true;
|
||||||
|
$touchedAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($touchedAny) {
|
||||||
|
$sharedFilesResolved[$rel] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blade orphan detection. Mirror of the JS orphan check: a
|
||||||
|
// newly-added `.blade.php` that literally nothing references
|
||||||
|
// (no `@include('x.y')`, no `view('x.y')`, no `Route::view`)
|
||||||
|
// can't affect any test, so the broad `resources/views/**`
|
||||||
|
// watch broadcast is wasted work. We only do this for blades
|
||||||
|
// *outside* the auto-resolved directories where Laravel /
|
||||||
|
// Livewire / Flux map class/tag names to file paths without
|
||||||
|
// a literal reference — there the absence of a string match
|
||||||
|
// isn't evidence of orphanhood.
|
||||||
|
$newBlades = [];
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
if (isset($preciselyHandledPages[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($sharedFilesResolved[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! str_ends_with(strtolower($rel), '.blade.php')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($this->fileIds[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! $this->isBladeOrphanEligible($rel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_file($this->projectRoot.DIRECTORY_SEPARATOR.$rel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newBlades[] = $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newBlades !== []) {
|
||||||
|
$needles = [];
|
||||||
|
foreach ($newBlades as $rel) {
|
||||||
|
$name = $this->viewNameFromBladePath($rel);
|
||||||
|
if ($name !== null) {
|
||||||
|
$needles[$name] = $rel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$referenced = $this->findReferencedViewNames($needles, $newBlades);
|
||||||
|
|
||||||
|
foreach ($newBlades as $rel) {
|
||||||
|
$name = $this->viewNameFromBladePath($rel);
|
||||||
|
if ($name === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! isset($referenced[$name])) {
|
||||||
|
// No `@include`, `view(…)`, or `Route::view(…)`
|
||||||
|
// mentions this view name anywhere in the project.
|
||||||
|
// Dynamic includes (`@include($var)`) can't be
|
||||||
|
// proven against — we accept the tradeoff of
|
||||||
|
// under-invalidation in the narrow case where a
|
||||||
|
// view is loaded exclusively via runtime
|
||||||
|
// composition. The watch pattern still fires for
|
||||||
|
// blades in components/, livewire/, pages/ etc.
|
||||||
|
$sharedFilesResolved[$rel] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($changedComponents !== []) {
|
if ($changedComponents !== []) {
|
||||||
foreach ($this->testInertiaComponents as $testFile => $components) {
|
foreach ($this->testInertiaComponents as $testFile => $components) {
|
||||||
if (isset($affectedSet[$testFile])) {
|
if (isset($affectedSet[$testFile])) {
|
||||||
@ -806,6 +931,142 @@ final class Graph
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blades inside auto-resolving directories (Blade components,
|
||||||
|
* Livewire components, Volt pages, Flux UI, vendor packages) are
|
||||||
|
* referenced by *class name* or *tag name* at runtime rather than
|
||||||
|
* by literal path string. Absence of a string match therefore
|
||||||
|
* can't prove orphanhood — we skip them and let the watch pattern
|
||||||
|
* do its job.
|
||||||
|
*/
|
||||||
|
private function isBladeOrphanEligible(string $rel): bool
|
||||||
|
{
|
||||||
|
$prefix = 'resources/views/';
|
||||||
|
if (! str_starts_with($rel, $prefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tail = substr($rel, strlen($prefix));
|
||||||
|
|
||||||
|
foreach (['components/', 'livewire/', 'pages/', 'flux/', 'vendor/'] as $autoResolved) {
|
||||||
|
if (str_starts_with($tail, $autoResolved)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps `resources/views/admin/reports.blade.php` →
|
||||||
|
* `admin.reports` (Laravel's dot-notation view name). Returns
|
||||||
|
* null for anything that isn't a regular `.blade.php` under
|
||||||
|
* `resources/views/`.
|
||||||
|
*/
|
||||||
|
private function viewNameFromBladePath(string $rel): ?string
|
||||||
|
{
|
||||||
|
$prefix = 'resources/views/';
|
||||||
|
$suffix = '.blade.php';
|
||||||
|
|
||||||
|
if (! str_starts_with($rel, $prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (! str_ends_with(strtolower($rel), $suffix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tail = substr($rel, strlen($prefix));
|
||||||
|
$base = substr($tail, 0, strlen($tail) - strlen($suffix));
|
||||||
|
|
||||||
|
if ($base === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_replace('/', '.', $base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans every `.php` / `.blade.php` file under `app/`, `routes/`,
|
||||||
|
* `tests/`, and `resources/views/` for a literal occurrence of
|
||||||
|
* each needle (the dot-notation view name). Returns the set of
|
||||||
|
* needles that were found at least once.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $needles view name → blade path (target itself, skipped during scan)
|
||||||
|
* @param array<int, string> $skipPaths project-relative paths to skip (the new blades themselves)
|
||||||
|
* @return array<string, true>
|
||||||
|
*/
|
||||||
|
private function findReferencedViewNames(array $needles, array $skipPaths): array
|
||||||
|
{
|
||||||
|
if ($needles === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$skipSet = array_fill_keys($skipPaths, true);
|
||||||
|
$found = [];
|
||||||
|
|
||||||
|
$roots = [
|
||||||
|
$this->projectRoot.DIRECTORY_SEPARATOR.'app',
|
||||||
|
$this->projectRoot.DIRECTORY_SEPARATOR.'routes',
|
||||||
|
$this->projectRoot.DIRECTORY_SEPARATOR.'tests',
|
||||||
|
$this->projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'views',
|
||||||
|
];
|
||||||
|
|
||||||
|
$rootLen = strlen(rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR);
|
||||||
|
|
||||||
|
foreach ($roots as $root) {
|
||||||
|
if (! is_dir($root)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($found) === count($needles)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $fileInfo) {
|
||||||
|
if (count($found) === count($needles)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (! $fileInfo->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $fileInfo->getPathname();
|
||||||
|
$lower = strtolower((string) $path);
|
||||||
|
|
||||||
|
if (! str_ends_with($lower, '.php')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relPath = str_replace(DIRECTORY_SEPARATOR, '/', substr((string) $path, $rootLen));
|
||||||
|
|
||||||
|
if (isset($skipSet[$relPath])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = @file_get_contents($path);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($needles) as $needle) {
|
||||||
|
if (isset($found[$needle])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_contains($content, $needle)) {
|
||||||
|
$found[$needle] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops edges whose test file no longer exists on disk. Prevents the graph
|
* Drops edges whose test file no longer exists on disk. Prevents the graph
|
||||||
* from keeping stale entries for deleted / renamed tests that would later
|
* from keeping stale entries for deleted / renamed tests that would later
|
||||||
|
|||||||
@ -51,6 +51,24 @@ final class JsModuleGraph
|
|||||||
return JsImportParser::parse($projectRoot);
|
return JsImportParser::parse($projectRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict variant — only runs the Node helper, never falls back to
|
||||||
|
* the PHP parser. Returns null when Node isn't available or Vite
|
||||||
|
* won't load.
|
||||||
|
*
|
||||||
|
* Used at replay time when we need to *trust a negative result*
|
||||||
|
* (i.e., "no page imports this file, so it's orphan, safe to
|
||||||
|
* skip"). The PHP fallback is conservative on positives but can
|
||||||
|
* miss imports that rely on custom aliases or plugins — negative
|
||||||
|
* results from it cannot be trusted for orphan pruning.
|
||||||
|
*
|
||||||
|
* @return array<string, list<string>>|null
|
||||||
|
*/
|
||||||
|
public static function buildStrict(string $projectRoot): ?array
|
||||||
|
{
|
||||||
|
return self::tryNodeHelper($projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the project looks like a Vite + Node project we can
|
* True when the project looks like a Vite + Node project we can
|
||||||
* ask for a module graph. Gate for callers that want to skip the
|
* ask for a module graph. Gate for callers that want to skip the
|
||||||
|
|||||||
Reference in New Issue
Block a user