mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
wip
This commit is contained in:
@ -22,6 +22,7 @@ use Pest\Support\Container;
|
|||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -658,9 +659,50 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$stored = $graph->fingerprint();
|
$stored = $graph->fingerprint();
|
||||||
|
|
||||||
if (! Fingerprint::structuralMatches($stored, $current)) {
|
if (! Fingerprint::structuralMatches($stored, $current)) {
|
||||||
$this->output->writeln(
|
$drift = Fingerprint::structuralDrift($stored, $current);
|
||||||
' <fg=yellow>TIA</> graph structure outdated — rebuilding.',
|
|
||||||
);
|
$this->output->writeln(sprintf(
|
||||||
|
' <fg=yellow>TIA</> graph structure outdated (%s).',
|
||||||
|
$this->formatStructuralDrift($drift),
|
||||||
|
));
|
||||||
|
|
||||||
|
// For composer.lock specifically, surface the actual
|
||||||
|
// package-version deltas. Saves the user a `git diff
|
||||||
|
// composer.lock | grep -E "name|version"` round-trip when
|
||||||
|
// a routine `composer update` invalidates the graph.
|
||||||
|
if (in_array('composer_lock', $drift, true)) {
|
||||||
|
$branchSha = $graph->recordedAtSha($this->branch);
|
||||||
|
if ($branchSha !== null) {
|
||||||
|
$summary = $this->composerLockDelta(
|
||||||
|
TestSuite::getInstance()->rootPath,
|
||||||
|
$branchSha,
|
||||||
|
);
|
||||||
|
if ($summary !== '') {
|
||||||
|
$this->output->writeln(' <fg=gray>'.$summary.'</>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the remote baseline before paying for a local
|
||||||
|
// rebuild. CI runs the baseline workflow against every
|
||||||
|
// push to main, so the most common cause of structural
|
||||||
|
// drift (`composer update` landed on main, you pulled it,
|
||||||
|
// your branch hasn't diverged yet) is recoverable in
|
||||||
|
// ~5–30s of network instead of minutes of recording.
|
||||||
|
//
|
||||||
|
// Revalidation is the safety: even if the fetch succeeds,
|
||||||
|
// we only adopt the result when its stored fingerprint
|
||||||
|
// structurally matches the *current* one. A stale CI
|
||||||
|
// baseline (workflow hasn't run since the drift) gets
|
||||||
|
// dropped and we fall through to the local rebuild path.
|
||||||
|
$rebuilt = $this->tryRemoteBaselineForDrift($current);
|
||||||
|
|
||||||
|
if ($rebuilt instanceof Graph) {
|
||||||
|
return $this->reconcileFingerprint($rebuilt, $current);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->output->writeln(' <fg=yellow>TIA</> rebuilding graph from scratch.');
|
||||||
|
|
||||||
$this->state->delete(self::KEY_GRAPH);
|
$this->state->delete(self::KEY_GRAPH);
|
||||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
||||||
|
|
||||||
@ -1356,4 +1398,174 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
return $coverage->coverage === true;
|
return $coverage->coverage === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to short-circuit a structural-drift rebuild by fetching
|
||||||
|
* a fresh CI-recorded baseline. Returns the loaded `Graph` only if
|
||||||
|
* the fetched payload structurally matches the *current* fingerprint
|
||||||
|
* — i.e., CI has already recorded against the new shape and we can
|
||||||
|
* safely use those edges. Any other outcome (no GitHub remote, fetch
|
||||||
|
* cooldown, no successful CI run, fetched-graph-still-drifts) → null,
|
||||||
|
* caller falls back to local rebuild.
|
||||||
|
*
|
||||||
|
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
|
||||||
|
*/
|
||||||
|
private function tryRemoteBaselineForDrift(array $current): ?Graph
|
||||||
|
{
|
||||||
|
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||||
|
|
||||||
|
if (! $this->baselineSync->fetchIfAvailable($projectRoot, false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetched = $this->loadGraph($projectRoot);
|
||||||
|
|
||||||
|
if (! $fetched instanceof Graph) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
|
||||||
|
$this->output->writeln(
|
||||||
|
' <fg=yellow>TIA</> fetched baseline still drifts — discarding.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->output->writeln(
|
||||||
|
' <fg=green>TIA</> fetched baseline matches — skipping local rebuild.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return $fetched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps `Fingerprint::structuralDrift()` field names to a human
|
||||||
|
* label suitable for the `(reason)` part of the rebuild banner.
|
||||||
|
*
|
||||||
|
* @param list<string> $drift
|
||||||
|
*/
|
||||||
|
private function formatStructuralDrift(array $drift): string
|
||||||
|
{
|
||||||
|
static $labels = [
|
||||||
|
'composer_lock' => 'composer.lock',
|
||||||
|
'composer_json' => 'composer.json',
|
||||||
|
'phpunit_xml' => 'phpunit.xml',
|
||||||
|
'phpunit_xml_dist' => 'phpunit.xml.dist',
|
||||||
|
'vite_config' => 'vite.config',
|
||||||
|
'pest_factory' => 'Pest internals',
|
||||||
|
'pest_method_factory' => 'Pest internals',
|
||||||
|
];
|
||||||
|
|
||||||
|
$seen = [];
|
||||||
|
foreach ($drift as $key) {
|
||||||
|
$seen[$labels[$key] ?? $key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seen === []) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', array_keys($seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diffs `composer.lock` between the recorded SHA and the current
|
||||||
|
* working tree, returns a one-line summary like:
|
||||||
|
*
|
||||||
|
* "laravel/framework 12.30 → 12.31, + pestphp/pest 4.7"
|
||||||
|
*
|
||||||
|
* Empty string when git is unavailable, the sha doesn't have the
|
||||||
|
* file, the file can't be parsed, or there are no version
|
||||||
|
* deltas (a content-hash-only edit, vendor URL change, etc.).
|
||||||
|
*/
|
||||||
|
private function composerLockDelta(string $projectRoot, string $sha): string
|
||||||
|
{
|
||||||
|
$current = @file_get_contents($projectRoot.'/composer.lock');
|
||||||
|
if ($current === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot);
|
||||||
|
$process->setTimeout(5.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldVersions = $this->lockVersions($process->getOutput());
|
||||||
|
$newVersions = $this->lockVersions($current);
|
||||||
|
|
||||||
|
if ($oldVersions === [] && $newVersions === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
foreach ($newVersions as $name => $version) {
|
||||||
|
if (! isset($oldVersions[$name])) {
|
||||||
|
$changes[] = '+ '.$name.' '.$version;
|
||||||
|
} elseif ($oldVersions[$name] !== $version) {
|
||||||
|
$changes[] = $name.' '.$oldVersions[$name].' → '.$version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($oldVersions as $name => $version) {
|
||||||
|
if (! isset($newVersions[$name])) {
|
||||||
|
$changes[] = '− '.$name.' '.$version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($changes);
|
||||||
|
|
||||||
|
// Cap at a sensible number — a wholesale `composer update`
|
||||||
|
// could list 50+ packages and bury the prompt.
|
||||||
|
$maxShown = 8;
|
||||||
|
if (count($changes) > $maxShown) {
|
||||||
|
$extra = count($changes) - $maxShown;
|
||||||
|
$changes = array_slice($changes, 0, $maxShown);
|
||||||
|
$changes[] = sprintf('… +%d more', $extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> package name → version
|
||||||
|
*/
|
||||||
|
private function lockVersions(string $json): array
|
||||||
|
{
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach (['packages', 'packages-dev'] as $section) {
|
||||||
|
if (! isset($data[$section])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($data[$section])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($data[$section] as $package) {
|
||||||
|
if (! is_array($package)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = $package['name'] ?? null;
|
||||||
|
$version = $package['version'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($name) && is_string($version)) {
|
||||||
|
$out[$name] = $version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,7 +73,13 @@ final readonly class Fingerprint
|
|||||||
// watch pattern + `Recorder::linkAncestorFiles` reflection
|
// watch pattern + `Recorder::linkAncestorFiles` reflection
|
||||||
// walk, which gives precise per-test invalidation rather
|
// walk, which gives precise per-test invalidation rather
|
||||||
// than a wholesale rebuild that trashes the entire graph.
|
// than a wholesale rebuild that trashes the entire graph.
|
||||||
private const int SCHEMA_VERSION = 11;
|
// v12: PHP/JS structural inputs (pest_factory*, vite.config.*)
|
||||||
|
// now hash via `ContentHash::of()` so cosmetic comment +
|
||||||
|
// whitespace edits don't fire rebuilds. composer.json and
|
||||||
|
// composer.lock hash a behavioural subset — description,
|
||||||
|
// keywords, scripts, authors, install timestamps, dist
|
||||||
|
// URLs etc. no longer drift the structural fingerprint.
|
||||||
|
private const int SCHEMA_VERSION = 12;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
@ -86,28 +92,35 @@ final readonly class Fingerprint
|
|||||||
return [
|
return [
|
||||||
'structural' => [
|
'structural' => [
|
||||||
'schema' => self::SCHEMA_VERSION,
|
'schema' => self::SCHEMA_VERSION,
|
||||||
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
// `composer.lock` hashed against a *behavioural*
|
||||||
|
// subset (per-package version + reference + autoload +
|
||||||
|
// extra). Skips per-package install timestamps, dist
|
||||||
|
// URLs, support links, descriptions — none of which
|
||||||
|
// affect what code runs.
|
||||||
|
'composer_lock' => self::composerLockHash($projectRoot),
|
||||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||||
// Pest's generated classes bake the code-generation logic
|
// Pest's generated classes bake the code-generation logic
|
||||||
// in — if TestCaseFactory changes (new attribute, different
|
// in — if TestCaseFactory changes (new attribute, different
|
||||||
// method signature, etc.) every previously-recorded edge is
|
// method signature, etc.) every previously-recorded edge is
|
||||||
// stale. Hashing the factory sources makes path-repo /
|
// stale. Hashing via `ContentHash::of()` so cosmetic edits
|
||||||
// dev-main installs automatically rebuild their graphs when
|
// (comments, formatting) don't drift the fingerprint.
|
||||||
// Pest itself is edited.
|
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||||
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||||
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
|
||||||
// `vite.config.*` reshapes the module graph
|
// `vite.config.*` reshapes the module graph
|
||||||
// `JsModuleGraph` records at the next `--tia` run; if
|
// `JsModuleGraph` records at the next `--tia` run; if
|
||||||
// the config drifts without a rebuild, the stored
|
// the config drifts without a rebuild, the stored
|
||||||
// `$jsFileToComponents` map is silently stale.
|
// `$jsFileToComponents` map is silently stale.
|
||||||
|
// `viteConfigHash` itself uses `ContentHash::of()` so
|
||||||
|
// a comment-only edit to vite.config doesn't rebuild.
|
||||||
'vite_config' => self::viteConfigHash($projectRoot),
|
'vite_config' => self::viteConfigHash($projectRoot),
|
||||||
// `composer.json` carries `autoload-dev`, `extra.laravel`
|
// `composer.json` hashed against a behavioural subset:
|
||||||
// package discovery, etc. — any change reshapes which
|
// autoload(-dev), require(-dev), extra (Laravel
|
||||||
// classes Pest can resolve at boot. Hashing the whole
|
// package discovery), repositories, minimum-stability,
|
||||||
// file is over-conservative (cosmetic edits force
|
// and the platform / allow-plugins entries from
|
||||||
// rebuild) but cheap, and over-rebuild is always safe.
|
// `config`. Cosmetic fields (description, keywords,
|
||||||
'composer_json' => self::hashIfExists($projectRoot.'/composer.json'),
|
// scripts, authors, funding, support) are excluded.
|
||||||
|
'composer_json' => self::composerJsonHash($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
|
||||||
@ -137,6 +150,45 @@ final readonly class Fingerprint
|
|||||||
return $aStructural === $bStructural;
|
return $aStructural === $bStructural;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of structural field names that drifted between
|
||||||
|
* the stored and current fingerprints. Empty list = no drift.
|
||||||
|
* Caller uses this to tell the user *why* the graph rebuilt — a
|
||||||
|
* generic "graph outdated" message leaves people staring at
|
||||||
|
* unrelated diffs.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $stored
|
||||||
|
* @param array<string, mixed> $current
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function structuralDrift(array $stored, array $current): array
|
||||||
|
{
|
||||||
|
$a = self::structuralOnly($stored);
|
||||||
|
$b = self::structuralOnly($current);
|
||||||
|
|
||||||
|
$drifts = [];
|
||||||
|
|
||||||
|
foreach ($a as $key => $value) {
|
||||||
|
if ($key === 'schema') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (($b[$key] ?? null) !== $value) {
|
||||||
|
$drifts[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($b as $key => $value) {
|
||||||
|
if ($key === 'schema') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! array_key_exists($key, $a) && $value !== null) {
|
||||||
|
$drifts[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($drifts));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a list of field names that drifted between the stored and
|
* Returns a list of field names that drifted between the stored and
|
||||||
* current environmental fingerprints. Empty list = no drift. Caller
|
* current environmental fingerprints. Empty list = no drift. Caller
|
||||||
@ -227,7 +279,7 @@ final readonly class Fingerprint
|
|||||||
$parts = [];
|
$parts = [];
|
||||||
|
|
||||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
|
||||||
|
|
||||||
if ($hash !== null) {
|
if ($hash !== null) {
|
||||||
$parts[] = $name.':'.$hash;
|
$parts[] = $name.':'.$hash;
|
||||||
@ -237,6 +289,184 @@ final readonly class Fingerprint
|
|||||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavioural subset of `composer.json`. Keeps the keys that
|
||||||
|
* actually move test outcomes (autoload, require, extra,
|
||||||
|
* repositories, minimum-stability, platform / allow-plugins
|
||||||
|
* config) and drops cosmetic ones (description, keywords,
|
||||||
|
* scripts, authors, funding, homepage, support). Falls back to
|
||||||
|
* a raw hash on parse errors so any change still rebuilds.
|
||||||
|
*/
|
||||||
|
private static function composerJsonHash(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$path = $projectRoot.'/composer.json';
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
$hash = @hash_file('xxh128', $path);
|
||||||
|
|
||||||
|
return $hash === false ? null : $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
|
||||||
|
$relevantConfig = array_intersect_key($config, [
|
||||||
|
'platform' => true,
|
||||||
|
'allow-plugins' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$relevant = [
|
||||||
|
'autoload' => $data['autoload'] ?? null,
|
||||||
|
'autoload-dev' => $data['autoload-dev'] ?? null,
|
||||||
|
'require' => $data['require'] ?? null,
|
||||||
|
'require-dev' => $data['require-dev'] ?? null,
|
||||||
|
'extra' => $data['extra'] ?? null,
|
||||||
|
'repositories' => $data['repositories'] ?? null,
|
||||||
|
'minimum-stability' => $data['minimum-stability'] ?? null,
|
||||||
|
'prefer-stable' => $data['prefer-stable'] ?? null,
|
||||||
|
'config' => $relevantConfig === [] ? null : $relevantConfig,
|
||||||
|
];
|
||||||
|
|
||||||
|
self::sortRecursively($relevant);
|
||||||
|
|
||||||
|
$json = json_encode($relevant);
|
||||||
|
|
||||||
|
return $json === false ? null : hash('xxh128', $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavioural subset of `composer.lock`. For every package in
|
||||||
|
* `packages` and `packages-dev`, keeps version + dist/source
|
||||||
|
* reference (commit SHA — catches dev-branch updates that don't
|
||||||
|
* bump the version string) + autoload(-dev) + extra (Laravel
|
||||||
|
* package discovery). Drops install timestamps, dist URLs,
|
||||||
|
* support links, descriptions, etc. — none of which change what
|
||||||
|
* code runs.
|
||||||
|
*/
|
||||||
|
private static function composerLockHash(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$path = $projectRoot.'/composer.lock';
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
$hash = @hash_file('xxh128', $path);
|
||||||
|
|
||||||
|
return $hash === false ? null : $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relevant = [
|
||||||
|
'platform' => $data['platform'] ?? null,
|
||||||
|
'platform-dev' => $data['platform-dev'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (['packages', 'packages-dev'] as $section) {
|
||||||
|
if (! isset($data[$section])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($data[$section])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$packages = [];
|
||||||
|
|
||||||
|
foreach ($data[$section] as $package) {
|
||||||
|
if (! is_array($package)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $package['name'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$packages[$name] = [
|
||||||
|
'version' => $package['version'] ?? null,
|
||||||
|
'reference' => self::lockReference($package),
|
||||||
|
'autoload' => $package['autoload'] ?? null,
|
||||||
|
'autoload-dev' => $package['autoload-dev'] ?? null,
|
||||||
|
'extra' => $package['extra'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($packages);
|
||||||
|
$relevant[$section] = $packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::sortRecursively($relevant);
|
||||||
|
|
||||||
|
$json = json_encode($relevant);
|
||||||
|
|
||||||
|
return $json === false ? null : hash('xxh128', $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $package
|
||||||
|
*/
|
||||||
|
private static function lockReference(array $package): ?string
|
||||||
|
{
|
||||||
|
$dist = is_array($package['dist'] ?? null) ? $package['dist'] : [];
|
||||||
|
$source = is_array($package['source'] ?? null) ? $package['source'] : [];
|
||||||
|
|
||||||
|
$reference = $dist['reference'] ?? $source['reference'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reference) ? $reference : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sorts associative arrays by key so semantically
|
||||||
|
* equivalent JSON produces the same hash regardless of key
|
||||||
|
* ordering. Lists (numeric arrays) keep their order — they're
|
||||||
|
* meaningful in `repositories`, `autoload.files`, etc.
|
||||||
|
*/
|
||||||
|
private static function sortRecursively(mixed &$value): void
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAssoc = ! array_is_list($value);
|
||||||
|
|
||||||
|
if ($isAssoc) {
|
||||||
|
ksort($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($value as &$child) {
|
||||||
|
self::sortRecursively($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function contentHashOrNull(string $path): ?string
|
||||||
|
{
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = ContentHash::of($path);
|
||||||
|
|
||||||
|
return $hash === false ? null : $hash;
|
||||||
|
}
|
||||||
|
|
||||||
private static function hashIfExists(string $path): ?string
|
private static function hashIfExists(string $path): ?string
|
||||||
{
|
{
|
||||||
if (! is_file($path)) {
|
if (! is_file($path)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user