This commit is contained in:
nuno maduro
2026-04-27 13:16:05 +01:00
parent f45cbf43c5
commit 81bfdbf8fe
5 changed files with 82 additions and 21 deletions

View File

@ -367,6 +367,15 @@ YAML;
return $m[1]; return $m[1];
} }
// SSH URL form: ssh://[user@]github.com[:port]/org/repo(.git).
// Some teams configure this explicitly to pin the SSH port; the
// colon-separated form above doesn't match. Mirrors the parser
// in `Storage::originIdentity` so the same remote produces the
// same project key for both storage and remote-fetch.
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return $m[1];
}
return null; return null;
} }

View File

@ -6,6 +6,7 @@ namespace Pest\Plugins\Tia;
use Pest\Support\Container; use Pest\Support\Container;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface;
/** /**
* File-level Test Impact Analysis graph. * File-level Test Impact Analysis graph.
@ -319,7 +320,21 @@ final class Graph
if ($newJsFiles !== []) { if ($newJsFiles !== []) {
$freshMap = JsModuleGraph::buildStrict($this->projectRoot); $freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap !== null) { 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.
$output = Container::getInstance()->get(OutputInterface::class);
if ($output instanceof OutputInterface) {
$output->writeln(sprintf(
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles),
));
}
} else {
foreach ($newJsFiles as $rel) { foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? []; $pages = $freshMap[$rel] ?? [];

View File

@ -100,21 +100,19 @@ final class Recorder
if (function_exists('pcov\\start')) { if (function_exists('pcov\\start')) {
$this->driver = 'pcov'; $this->driver = 'pcov';
$this->driverAvailable = true; $this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage')) { } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
// Xdebug is loaded. Probe whether coverage mode is active by // Xdebug 3+ exposes the active mode set via `xdebug_info`,
// attempting a start — it emits E_WARNING when the mode is off. // so we can ask directly instead of probing with a
// We capture the warning via a temporary error handler. // start/stop pair. The probe approach used to emit
$probeOk = true; // E_WARNING when coverage mode was off; with monitoring
set_error_handler(static function () use (&$probeOk): bool { // agents (Sentry, Bugsnag) hooked into the error
$probeOk = false; // handler stack that warning could be reported as a
// real error. `xdebug_info('mode')` is silent and
// returns the active modes as a list, so a presence
// check is enough.
$modes = \xdebug_info('mode');
return true; if (is_array($modes) && in_array('coverage', $modes, true)) {
});
\xdebug_start_code_coverage();
restore_error_handler();
if ($probeOk) {
\xdebug_stop_code_coverage(false);
$this->driver = 'xdebug'; $this->driver = 'xdebug';
$this->driverAvailable = true; $this->driverAvailable = true;
} }

View File

@ -105,21 +105,27 @@ final class TableExtractor
} }
/** /**
* @return list<string> Table names referenced by `Schema::` calls * @return list<string> Table names referenced by `Schema::` calls,
* OR raw DDL statements in the given migration * raw DDL, or DML inside the given migration
* file contents. Empty when nothing matches — * file contents. Empty when nothing matches —
* callers treat that as "fall back to the * callers treat that as "fall back to the
* broad watch pattern". * broad watch pattern".
* *
* Two passes: * Three passes:
* 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename` * 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename`
* captures the conventional Laravel migration shape. * captures the conventional Laravel migration shape.
* 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP / * 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP /
* TRUNCATE / RENAME TABLE <name>` patterns inside string * TRUNCATE / RENAME TABLE <name>` patterns inside string
* literals (i.e. `DB::statement('CREATE TABLE …')`, * literals (i.e. `DB::statement('CREATE TABLE …')`,
* `DB::unprepared('ALTER TABLE …')`). False positives possible * `DB::unprepared('ALTER TABLE …')`).
* if the same syntax appears in a comment or unrelated string, * 3. DML inside migration bodies — `INSERT INTO`, `UPDATE … SET`,
* but over-attribution is correctness-safe. * `DELETE FROM`, and Laravel's fluent `DB::table('foo')`.
* Catches the seeded-lookup-table case where a migration
* populates rows that tests later read.
*
* False positives possible when the same syntax appears in a
* comment or unrelated string, but over-attribution is
* correctness-safe.
*/ */
public static function fromMigrationSource(string $php): array public static function fromMigrationSource(string $php): array
{ {
@ -157,6 +163,33 @@ final class TableExtractor
} }
} }
// Pass 3: DML inside migration bodies. Migrations that seed
// lookup tables via `DB::statement('INSERT INTO roles …')`,
// `DB::table('statuses')->insert(…)`, `UPDATE foo SET …`, or
// `DELETE FROM bar` are common in Laravel. Without picking
// these up, an edit to the seed payload would route through
// only the schema'd tables and silently skip every test that
// reads from the populated table. Fluent-builder calls
// (`DB::table('x')`) and raw SQL strings are both covered.
$dmlPatterns = [
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
];
foreach ($dmlPatterns as $pattern) {
if (preg_match_all($pattern, $php, $matches) === false) {
continue;
}
foreach ($matches[1] as $name) {
$lower = strtolower($name);
if (! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
$out = array_keys($tables); $out = array_keys($tables);
sort($out); sort($out);

View File

@ -43,12 +43,18 @@ final readonly class Inertia implements WatchDefault
'resources/js/Pages/**/*.tsx' => [$browserDir], 'resources/js/Pages/**/*.tsx' => [$browserDir],
'resources/js/Pages/**/*.jsx' => [$browserDir], 'resources/js/Pages/**/*.jsx' => [$browserDir],
'resources/js/Pages/**/*.svelte' => [$browserDir], 'resources/js/Pages/**/*.svelte' => [$browserDir],
'resources/js/Pages/**/*.ts' => [$browserDir],
'resources/js/Pages/**/*.js' => [$browserDir],
// Shared layouts / components consumed by pages. // Shared layouts / components consumed by pages.
'resources/js/Layouts/**/*.vue' => [$browserDir], 'resources/js/Layouts/**/*.vue' => [$browserDir],
'resources/js/Layouts/**/*.tsx' => [$browserDir], 'resources/js/Layouts/**/*.tsx' => [$browserDir],
'resources/js/Layouts/**/*.ts' => [$browserDir],
'resources/js/Layouts/**/*.js' => [$browserDir],
'resources/js/Components/**/*.vue' => [$browserDir], 'resources/js/Components/**/*.vue' => [$browserDir],
'resources/js/Components/**/*.tsx' => [$browserDir], 'resources/js/Components/**/*.tsx' => [$browserDir],
'resources/js/Components/**/*.ts' => [$browserDir],
'resources/js/Components/**/*.js' => [$browserDir],
// SSR entry point. // SSR entry point.
'resources/js/ssr.js' => [$browserDir], 'resources/js/ssr.js' => [$browserDir],