diff --git a/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php b/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php new file mode 100644 index 00000000..2587df7d --- /dev/null +++ b/src/StaticPHP/Command/Dev/GenExtTestMatrixCommand.php @@ -0,0 +1,184 @@ + ['arch' => 'x86_64', 'runner' => 'ubuntu-latest', 'os_key' => 'Linux'], + 'windows' => ['arch' => 'x86_64', 'runner' => 'windows-2025', 'os_key' => 'Windows'], + ]; + + protected bool $no_motd = true; + + public function handle(): int + { + if (!spc_mode(SPC_MODE_SOURCE)) { + $this->output->writeln('This command is only available in source mode.'); + return static::USER_ERROR; + } + + $all = PackageConfig::getAll(); + + // Separate into regular and virtual extensions (build-static:false excluded globally) + $all_regular = []; + $all_virtual = []; + foreach ($all as $pkg_name => $config) { + if (($config['type'] ?? '') !== 'php-extension') { + continue; + } + if (($config['php-extension']['build-static'] ?? null) === false) { + continue; + } + if (($config['php-extension']['arg-type'] ?? '') === 'none') { + $all_virtual[$pkg_name] = $config; + } else { + $all_regular[$pkg_name] = $config; + } + } + + $entries = []; + + foreach (self::OS_RUNNERS as $os => $os_info) { + $os_key = $os_info['os_key']; + + // Filter by OS support + $os_regular = array_filter($all_regular, fn ($c) => $this->supportsOS($c, $os_key)); + $os_virtual = array_filter($all_virtual, fn ($c) => $this->supportsOS($c, $os_key)); + + // Pool: all ext-* names available on this OS (regular + virtual) + $pool_set = array_fill_keys( + array_merge(array_keys($os_regular), array_keys($os_virtual)), + true + ); + + // Compute ext_deps for every pool member: union of depends + suggests, limited to pool + $ext_deps = []; + foreach (array_merge($os_regular, $os_virtual) as $pkg_name => $config) { + $raw = array_merge( + $this->resolvePlatformList($config, 'depends', $os), + $this->resolvePlatformList($config, 'suggests', $os), + ); + $ext_deps[$pkg_name] = array_values(array_filter( + $raw, + fn ($d) => isset($pool_set[$d]) && $d !== $pkg_name + )); + } + + // Which regular exts are reachable as a dep/suggest from another regular ext? + $depended_on = []; + foreach ($os_regular as $pkg_name => $_) { + foreach ($ext_deps[$pkg_name] as $dep) { + $depended_on[$dep] = true; + } + } + + // Process order: roots (not depended on) first, then non-roots; each group alpha-sorted + $roots = []; + $non_roots = []; + foreach (array_keys($os_regular) as $pkg_name) { + if (isset($depended_on[$pkg_name])) { + $non_roots[] = $pkg_name; + } else { + $roots[] = $pkg_name; + } + } + sort($roots); + sort($non_roots); + + // DFS to collect dependency chains; true orphans (no ext-* relations) are batched + $covered = []; + $groups = []; + $orphans = []; + + foreach (array_merge($roots, $non_roots) as $ext) { + if (isset($covered[$ext])) { + continue; + } + $chain = $this->dfsCollect($ext, $ext_deps, $pool_set, $covered); + if (count($chain) === 1 && empty($ext_deps[$ext])) { + $orphans[] = $this->displayName($ext); + } else { + $groups[] = implode(',', array_map($this->displayName(...), $chain)); + } + } + + // All orphans become a single batched matrix entry + if (!empty($orphans)) { + sort($orphans); + $groups[] = implode(',', $orphans); + } + + sort($groups); + foreach ($groups as $group) { + $entries[] = [ + 'runner' => $os_info['runner'], + 'os' => $os, + 'arch' => $os_info['arch'], + 'extension' => $group, + 'build-args' => '"' . $group . '" ' . self::BUILD_TARGETS, + ]; + } + } + + $this->output->write(json_encode($entries, JSON_UNESCAPED_SLASHES)); + return static::SUCCESS; + } + + /** + * DFS-collect the dependency chain starting from $ext. + * Marks all visited nodes in $covered to prevent duplicates and handle cycles. + */ + private function dfsCollect(string $ext, array $ext_deps, array $pool_set, array &$covered): array + { + if (isset($covered[$ext])) { + return []; + } + $covered[$ext] = true; + $chain = [$ext]; + foreach ($ext_deps[$ext] ?? [] as $dep) { + if (!isset($covered[$dep]) && isset($pool_set[$dep])) { + $chain = array_merge($chain, $this->dfsCollect($dep, $ext_deps, $pool_set, $covered)); + } + } + return $chain; + } + + private function supportsOS(array $config, string $os_key): bool + { + $os_list = $config['php-extension']['os'] ?? null; + return $os_list === null || in_array($os_key, $os_list, true); + } + + private function displayName(string $pkg_name): string + { + return str_starts_with($pkg_name, 'ext-') ? substr($pkg_name, 4) : $pkg_name; + } + + /** + * Resolve the value of a platform-specific array field, applying the suffix fallback chain. + * + * Fallback rules (same as PackageConfig::get): + * linux : @linux → @unix → (base) + * macos : @macos → @unix → (base) + * windows : @windows → (base) + */ + private function resolvePlatformList(array $config, string $field, string $platform): array + { + return match ($platform) { + 'linux' => $config["{$field}@linux"] ?? $config["{$field}@unix"] ?? $config[$field] ?? [], + 'macos' => $config["{$field}@macos"] ?? $config["{$field}@unix"] ?? $config[$field] ?? [], + 'windows' => $config["{$field}@windows"] ?? $config[$field] ?? [], + default => $config[$field] ?? [], + }; + } +} diff --git a/src/StaticPHP/ConsoleApplication.php b/src/StaticPHP/ConsoleApplication.php index 06234f76..cc10554e 100644 --- a/src/StaticPHP/ConsoleApplication.php +++ b/src/StaticPHP/ConsoleApplication.php @@ -13,6 +13,7 @@ use StaticPHP\Command\Dev\DumpStagesCommand; use StaticPHP\Command\Dev\EnvCommand; use StaticPHP\Command\Dev\GenDepsDataCommand; use StaticPHP\Command\Dev\GenExtDocsCommand; +use StaticPHP\Command\Dev\GenExtTestMatrixCommand; use StaticPHP\Command\Dev\IsInstalledCommand; use StaticPHP\Command\Dev\LintConfigCommand; use StaticPHP\Command\Dev\PackageInfoCommand; @@ -85,6 +86,7 @@ class ConsoleApplication extends Application new PackageInfoCommand(), new GenExtDocsCommand(), new GenDepsDataCommand(), + new GenExtTestMatrixCommand(), ]); // add additional commands from registries