Compare commits

..

2 Commits

Author SHA1 Message Date
Jerry Ma
5a1fd1f388 Merge branch 'v3' into v3c/artifact-static-helpers 2026-05-29 17:32:02 +09:00
henderkes
91cf4f83b5 artifact: add path/binary/isInstalled static helpers
Give zig, rust, go_win and go_xcaddy a small consistent surface for
locating the install directory and a binary inside it:

- path(): install/extract root for the artifact
- binary($name = '<default>'): full path to a binary under that root,
  picking the artifact's natural layout (top-level for zig, bin/ for
  rust and the go toolchains)
- isInstalled(): is the default binary present on disk

Callers that previously concatenated PKG_ROOT_PATH . '/zig/zig' (and
the equivalents for the other artifacts) by hand can call the helpers
instead, and any later code that needs to ask "is this toolchain
available" can use isInstalled() without rebuilding the path.
2026-05-24 21:39:56 +07:00
58 changed files with 528 additions and 1364 deletions

View File

@@ -38,9 +38,6 @@ jobs:
- name: "windows-x64"
os: "ubuntu-latest"
filename: "spc-windows-x64.exe"
permissions:
id-token: write
attestations: write
steps:
- name: "Checkout"
uses: "actions/checkout@v5"
@@ -108,12 +105,6 @@ jobs:
fi
fi
- name: "Generate build provenance attestation"
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@v4
with:
subject-path: "${{ github.workspace }}/${{ matrix.operating-system.name == 'windows-x64' && 'spc.exe' || 'spc' }}"
- name: "Copy file"
run: |
if [ "${{ matrix.operating-system.name }}" != "windows-x64" ]; then

View File

@@ -10,6 +10,7 @@
"config",
"src",
"vendor/psr",
"vendor/laravel/prompts",
"vendor/symfony",
"vendor/php-di",
"vendor/zhamao"

View File

@@ -12,8 +12,10 @@
"php": ">=8.4",
"ext-mbstring": "*",
"ext-zlib": "*",
"laravel/prompts": "~0.1",
"php-di/php-di": "^7.1",
"symfony/console": "^5.4 || ^6 || ^7",
"symfony/process": "^7.2",
"symfony/yaml": "^7.2",
"zhamao/logger": "^1.1.4"
},

568
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
ext-fastchart:
type: php-extension
artifact:
source:
type: ghtar
repo: iliaal/fastchart
extract: php-src/ext/fastchart
prefer-stable: true
metadata:
license-files: [LICENSE]
depends:
- freetype
suggests:
- libpng
- libjpeg
- libwebp
php-extension:
os:
- Linux
- Darwin

View File

@@ -1,14 +0,0 @@
ext-fastjson:
type: php-extension
artifact:
source:
type: ghtar
repo: iliaal/fastjson
extract: php-src/ext/fastjson
prefer-stable: true
metadata:
license-files: [LICENSE]
php-extension:
os:
- Linux
- Darwin

View File

@@ -10,5 +10,3 @@ ext-gmssl:
license: PHP-3.01
depends:
- gmssl
php-extension:
arg-type: with-path

View File

@@ -1,6 +1,6 @@
parameters:
reportUnmatchedIgnoredErrors: false
level: 5
level: 4
phpVersion: 80400
paths:
- ./src/

View File

@@ -15,6 +15,23 @@ use StaticPHP\Util\GlobalEnvManager;
class go_win
{
/** GOROOT for the Windows Go toolchain. */
public static function path(): string
{
return PKG_ROOT_PATH . '/go-win';
}
/** Path to a binary inside go-win's bin/ (go.exe, gofmt.exe, …). */
public static function binary(string $name = 'go.exe'): string
{
return self::path() . '/bin/' . $name;
}
public static function isInstalled(): bool
{
return is_file(self::binary());
}
#[CustomBinary('go-win', [
'windows-x86_64',
])]

View File

@@ -17,6 +17,23 @@ use StaticPHP\Util\System\LinuxUtil;
class go_xcaddy
{
/** GOROOT for the bundled Go toolchain used to build xcaddy. */
public static function path(): string
{
return PKG_ROOT_PATH . '/go-xcaddy';
}
/** Path to a binary inside go-xcaddy's bin/ (xcaddy, go, …). */
public static function binary(string $name = 'xcaddy'): string
{
return self::path() . '/bin/' . $name;
}
public static function isInstalled(): bool
{
return is_file(self::binary());
}
#[CustomBinary('go-xcaddy', [
'linux-x86_64',
'linux-aarch64',

View File

@@ -16,6 +16,23 @@ use StaticPHP\Util\System\LinuxUtil;
class rust
{
/** Install prefix the rust tarball's install.sh writes into. */
public static function path(): string
{
return PKG_ROOT_PATH . '/rust';
}
/** Path to a binary inside the rust install dir (cargo, rustc, rustup, …). */
public static function binary(string $name = 'cargo'): string
{
return self::path() . '/bin/' . $name;
}
public static function isInstalled(): bool
{
return is_file(self::binary());
}
#[CustomBinary('rust', [
'linux-x86_64',
'linux-aarch64',

View File

@@ -15,6 +15,23 @@ use StaticPHP\Runtime\SystemTarget;
class zig
{
/** Directory zig extracts into. */
public static function path(): string
{
return PKG_ROOT_PATH . '/zig';
}
/** Path to a binary inside the zig install dir (zig, zig-cc, zig-c++, zig-ar, …). */
public static function binary(string $name = 'zig'): string
{
return self::path() . '/' . $name;
}
public static function isInstalled(): bool
{
return is_file(self::binary());
}
#[CustomBinary('zig', [
'linux-x86_64',
'linux-aarch64',

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace Package\Extension;
use Package\Target\php;
use StaticPHP\Attribute\Package\BeforeStage;
use StaticPHP\Attribute\Package\Extension;
use StaticPHP\Attribute\PatchDescription;
use StaticPHP\Package\PhpExtensionPackage;
use StaticPHP\Util\FileSystem;
#[Extension('gmssl')]
class gmssl extends PhpExtensionPackage
{
#[BeforeStage('php', [php::class, 'buildconfForUnix'], 'ext-gmssl')]
#[BeforeStage('php', [php::class, 'buildconfForWindows'], 'ext-gmssl')]
#[PatchDescription('Fix ext-gmssl v1.1.1 compatibility with GmSSL >= 3.1.0 where SM2_VERIFY_CTX was removed (unified into SM2_SIGN_CTX)')]
public function patchSm2VerifyCtx(): void
{
// See: https://github.com/crazywhalecc/static-php-cli/issues/1182
FileSystem::replaceFileStr(
"{$this->getSourceDir()}/gmssl.c",
'SM2_VERIFY_CTX',
'SM2_SIGN_CTX'
);
}
#[BeforeStage('php', [php::class, 'buildconfForWindows'], 'ext-gmssl')]
#[PatchDescription('Add CHECK_LIB to config.w32 for static Windows builds')]
public function patchBeforeBuildconfWin(): bool
{
$configW32 = "{$this->getSourceDir()}/config.w32";
if (str_contains(FileSystem::readFile($configW32), 'CHECK_LIB(')) {
return false;
}
FileSystem::replaceFileStr(
$configW32,
'AC_DEFINE(',
'CHECK_LIB("gmssl.lib", "gmssl", PHP_GMSSL);' . PHP_EOL . 'AC_DEFINE('
);
return true;
}
}

View File

@@ -22,7 +22,6 @@ class gettext_win
{
$ver = WindowsUtil::findVisualStudio();
$vs_ver_dir = match ($ver['major_version']) {
'18', // VS 2026 reuses the VS2022 (MSVC17) solution, which msbuild builds via forward compatibility.
'17' => '\MSVC17',
'16' => '\MSVC16',
default => throw new EnvironmentException("Current VS version {$ver['major_version']} is not supported yet!"),
@@ -45,9 +44,7 @@ class gettext_win
{
$vs_ver_dir = ApplicationContext::get('gettext_win_vs_ver_dir');
cmd()->cd("{$lib->getSourceDir()}{$vs_ver_dir}\\libintl_static")
// WholeProgramOptimization (/GL) emits LTCG objects that frankenphp's lld-link cannot
// read ("is not a native COFF file"); disable it so the .lib stays plain COFF.
->exec('msbuild libintl_static.vcxproj /t:Rebuild /p:Configuration=Release /p:Platform=x64 /p:WindowsTargetPlatformVersion=10.0 /p:WholeProgramOptimization=false');
->exec('msbuild libintl_static.vcxproj /t:Rebuild /p:Configuration=Release /p:Platform=x64 /p:WindowsTargetPlatformVersion=10.0');
FileSystem::createDir($lib->getLibDir());
FileSystem::createDir($lib->getIncludeDir());
// libintl_a.lib is the static library output; copy as libintl.lib for linker compatibility

View File

@@ -18,9 +18,7 @@ class gmssl
#[BuildFor('Darwin')]
public function build(LibraryPackage $lib): void
{
UnixCMakeExecutor::create($lib)
->addConfigureArgs('-DENABLE_SM2_PRIVATE_KEY_EXPORT=ON')
->build();
UnixCMakeExecutor::create($lib)->build();
}
#[BuildFor('Windows')]
@@ -35,7 +33,6 @@ class gmssl
'-G "NMake Makefiles"',
'-DWIN32=ON',
'-DBUILD_SHARED_LIBS=OFF',
'-DENABLE_SM2_PRIVATE_KEY_EXPORT=ON',
'-DCMAKE_BUILD_TYPE=Release',
'-DCMAKE_C_FLAGS_RELEASE="/MT /O2 /Ob2 /DNDEBUG"',
'-DCMAKE_CXX_FLAGS_RELEASE="/MT /O2 /Ob2 /DNDEBUG"',

View File

@@ -21,7 +21,6 @@ class libffi_win
{
$ver = WindowsUtil::findVisualStudio();
$vs_ver_dir = match ($ver['major_version']) {
'18', // VS 2026 reuses the vs17 solution, which msbuild builds via forward compatibility.
'17' => '\win32\vs17_x64',
'16' => '\win32\vs16_x64',
default => throw new EnvironmentException("Current VS version {$ver['major_version']} is not supported!"),
@@ -34,9 +33,7 @@ class libffi_win
{
$vs_ver_dir = ApplicationContext::get('libffi_win_vs_ver_dir');
cmd()->cd("{$lib->getSourceDir()}{$vs_ver_dir}")
// WholeProgramOptimization (/GL) emits LTCG objects that frankenphp's lld-link cannot
// read ("is not a native COFF file"); disable it so the .lib stays plain COFF.
->exec('msbuild libffi-msvc.sln /t:Rebuild /p:Configuration=Release /p:Platform=x64 /p:WholeProgramOptimization=false');
->exec('msbuild libffi-msvc.sln /t:Rebuild /p:Configuration=Release /p:Platform=x64');
FileSystem::createDir($lib->getLibDir());
FileSystem::createDir($lib->getIncludeDir());

View File

@@ -21,7 +21,6 @@ class libiconv_win
{
$ver = WindowsUtil::findVisualStudio();
$vs_ver_dir = match ($ver['major_version']) {
'18', // VS 2026 reuses the VS2022 (MSVC17) solution, which msbuild builds via forward compatibility.
'17' => '\MSVC17',
'16' => '\MSVC16',
default => throw new EnvironmentException("Current VS version {$ver['major_version']} is not supported yet!"),
@@ -34,9 +33,7 @@ class libiconv_win
{
$vs_ver_dir = ApplicationContext::get('vs_ver_dir');
cmd()->cd("{$lib->getSourceDir()}{$vs_ver_dir}")
// WholeProgramOptimization (/GL) emits LTCG objects that frankenphp's lld-link cannot
// read ("is not a native COFF file"); disable it so the .lib stays plain COFF.
->exec('msbuild libiconv.sln /t:Rebuild /p:Configuration=Release /p:Platform=x64 /p:WholeProgramOptimization=false');
->exec('msbuild libiconv.sln /t:Rebuild /p:Configuration=Release /p:Platform=x64');
FileSystem::createDir($lib->getLibDir());
FileSystem::createDir($lib->getIncludeDir());
FileSystem::copy("{$lib->getSourceDir()}{$vs_ver_dir}\\x64\\lib\\libiconv.lib", "{$lib->getLibDir()}\\libiconv.lib");

View File

@@ -42,16 +42,13 @@ class libsodium
{
$ver = WindowsUtil::findVisualStudio();
$vs_ver_dir = match ($ver['major_version']) {
'18', // VS 2026 reuses the vs2022 solution, which msbuild builds via forward compatibility.
'17' => '\vs2022',
'16' => '\vs2019',
default => throw new EnvironmentException("Current VS version {$ver['major_version']} is not supported yet!"),
};
cmd()->cd("{$lib->getSourceDir()}\\builds\\msvc{$vs_ver_dir}")
// WholeProgramOptimization (/GL) emits LTCG objects that frankenphp's lld-link cannot
// read ("is not a native COFF file"); disable it so the .lib stays plain COFF.
->exec('msbuild libsodium.sln /t:Rebuild /p:Configuration=StaticRelease /p:Platform=x64 /p:WholeProgramOptimization=false /p:PreprocessorDefinitions="SODIUM_STATIC=1"');
->exec('msbuild libsodium.sln /t:Rebuild /p:Configuration=StaticRelease /p:Platform=x64 /p:PreprocessorDefinitions="SODIUM_STATIC=1"');
FileSystem::createDir($lib->getLibDir());
FileSystem::createDir($lib->getIncludeDir());

View File

@@ -21,7 +21,6 @@ class mpir
{
$ver = WindowsUtil::findVisualStudio();
$vs_ver_dir = match ($ver['major_version']) {
'18', // VS 2026 reuses the build.vc17 solution, which msbuild builds via forward compatibility.
'17' => '\build.vc17',
'16' => '\build.vc16',
default => throw new EnvironmentException("Current VS version {$ver['major_version']} is not supported yet!"),

View File

@@ -20,8 +20,8 @@ class unixodbc extends LibraryPackage
{
$sysconf_selector = match ($os = SystemTarget::getTargetOS()) {
'Darwin' => match (SystemTarget::getTargetArch()) {
'x86_64' => is_dir('/usr/local/etc') ? '/usr/local/etc' : '/opt/local/etc',
'aarch64' => is_dir('/opt/homebrew/etc') ? '/opt/homebrew/etc' : '/opt/local/etc',
'x86_64' => '/usr/local/etc',
'aarch64' => '/opt/homebrew/etc',
default => throw new WrongUsageException('Unsupported architecture: ' . GNU_ARCH),
},
'Linux' => '/etc',

View File

@@ -512,7 +512,6 @@ HEADER;
$vc_matches = ['unknown', 'unknown'];
} else {
$vc_matches = match ($vc['major_version']) {
'18', // VS 2026 shares the VS2022 (v143) runtime conventions, so it reports as VS17.
'17' => ['VS17', 'Visual C++ 2022'],
'16' => ['VS16', 'Visual C++ 2019'],
default => ['unknown', 'unknown'],

View File

@@ -45,11 +45,7 @@ class FileList implements DownloadTypeInterface, CheckUpdateInterface
throw new DownloaderException("Failed to get {$name} file list from {$config['url']}");
}
$versions = [];
$cnt = count($matches['version']);
if ($cnt === 0) {
throw new DownloaderException("Failed to get {$name} file list from {$config['url']}: no version parsed");
}
logger()->debug("Matched {$cnt} versions for {$name}");
logger()->debug('Matched ' . count($matches['version']) . " versions for {$name}");
foreach ($matches['version'] as $i => $version) {
$lower = strtolower($version);
foreach (['alpha', 'beta', 'rc', 'pre', 'nightly', 'snapshot', 'dev'] as $beta) {

View File

@@ -54,10 +54,6 @@ abstract class BaseCommand extends Command
}
set_error_handler(static function ($error_no, $error_msg, $error_file, $error_line) {
// Respect the @ suppression operator (error_reporting() returns 0 when @ is used)
if (error_reporting() === 0) {
return true;
}
$tips = [
E_WARNING => ['PHP Warning: ', 'warning'],
E_NOTICE => ['PHP Notice: ', 'notice'],

View File

@@ -110,7 +110,7 @@ class CraftCommand extends BaseCommand
* shared-extensions: array<string>,
* packages: array<string>,
* sapi: array<string>,
* verbosity: 128|16|256|32|64|8,
* verbosity: int,
* debug: bool,
* clean-build: bool,
* build-options: array<string, mixed>,
@@ -171,16 +171,11 @@ class CraftCommand extends BaseCommand
}
// verbosity
$verbosity_level = $craft['verbosity'] ?? OutputInterface::VERBOSITY_NORMAL;
$debug = $craft['debug'] ?? false;
$verbosity_level = $debug
? OutputInterface::VERBOSITY_DEBUG
: match ((int) ($craft['verbosity'] ?? 0)) {
OutputInterface::VERBOSITY_QUIET => OutputInterface::VERBOSITY_QUIET,
OutputInterface::VERBOSITY_VERBOSE => OutputInterface::VERBOSITY_VERBOSE,
OutputInterface::VERBOSITY_VERY_VERBOSE => OutputInterface::VERBOSITY_VERY_VERBOSE,
OutputInterface::VERBOSITY_DEBUG => OutputInterface::VERBOSITY_DEBUG,
default => OutputInterface::VERBOSITY_NORMAL,
};
if ($debug) {
$verbosity_level = OutputInterface::VERBOSITY_DEBUG;
}
$craft['verbosity'] = $verbosity_level;
// clean-build (if true, reset before all builds)

View File

@@ -16,7 +16,7 @@ class GenExtTestMatrixCommand extends BaseCommand
private const array OS_RUNNERS = [
'linux' => ['arch' => 'x86_64', 'runner' => 'ubuntu-latest', 'os_key' => 'Linux'],
'windows' => ['arch' => 'x86_64', 'runner' => 'windows-2025', 'os_key' => 'Windows'],
'windows' => ['arch' => 'x86_64', 'runner' => 'windows-latest', 'os_key' => 'Windows'],
'macos' => ['arch' => 'aarch64', 'runner' => 'macos-15', 'os_key' => 'Darwin'],
];
@@ -60,8 +60,6 @@ class GenExtTestMatrixCommand extends BaseCommand
'glfw',
'imagick',
'intl',
'mongodb',
'gmssl',
];
/**

View File

@@ -154,7 +154,7 @@ class TestBotCommand extends BaseCommand
'targets' => array_values($targets),
'gen_matrix_args' => $gen_matrix_args,
'gen_matrix_args_tier2' => $gen_matrix_args_tier2,
'php_versions' => $php_versions,
'php_versions' => array_values($php_versions),
'tier2' => $tier2,
'comment_body' => $comment_body,
];

View File

@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Exception\SPCInternalException;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use function Laravel\Prompts\confirm;
#[AsCommand('reset')]
class ResetCommand extends BaseCommand
@@ -47,11 +46,7 @@ class ResetCommand extends BaseCommand
// Confirm with user unless --yes is specified
if (!$this->input->getOption('yes')) {
$helper = $this->getHelper('question');
if (!$helper instanceof QuestionHelper) {
throw new SPCInternalException('Question helper not provided');
}
if (!$helper->ask($this->input, $this->output, new ConfirmationQuestion('Are you sure you want to continue? [y/N] ', false))) {
if (!confirm('Are you sure you want to continue?', false)) {
InteractiveTerm::error(message: 'Reset operation cancelled.');
return static::SUCCESS;
}

View File

@@ -38,7 +38,7 @@ class ArtifactConfig
*/
public static function loadFromFile(string $file, string $registry_name): string
{
$content = @file_get_contents($file);
$content = file_get_contents($file);
if ($content === false) {
throw new WrongUsageException("Failed to read artifact config file: {$file}");
}

View File

@@ -46,7 +46,7 @@ class PackageConfig
*/
public static function loadFromFile(string $file, string $registry_name): string
{
$content = @file_get_contents($file);
$content = file_get_contents($file);
if ($content === false) {
throw new WrongUsageException("Failed to read package config file: {$file}");
}

View File

@@ -79,11 +79,11 @@ class ApplicationContext
/**
* Get a service from the container.
*
* @template T of object
* @template T
*
* @param class-string<T>|string $id Service identifier
* @param class-string<T> $id Service identifier
*
* @return ($id is class-string<T> ? T : mixed)
* @return null|T
*/
public static function get(string $id): mixed
{

View File

@@ -11,14 +11,11 @@ use StaticPHP\Registry\DoctorLoader;
use StaticPHP\Runtime\Shell\Shell;
use StaticPHP\Runtime\SystemTarget;
use StaticPHP\Util\InteractiveTerm;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use ZM\Logger\ConsoleColor;
use function Laravel\Prompts\confirm;
readonly class Doctor
{
public function __construct(private ?OutputInterface $output = null, private int $auto_fix = FIX_POLICY_PROMPT, public readonly bool $interactive = true)
@@ -128,14 +125,9 @@ readonly class Doctor
return false;
}
// prompt for fix
if ($this->auto_fix === FIX_POLICY_PROMPT) {
$helper = new QuestionHelper();
$input = ApplicationContext::has(InputInterface::class) ? ApplicationContext::get(InputInterface::class) : new ArrayInput([]);
$output = ApplicationContext::has(OutputInterface::class) ? ApplicationContext::get(OutputInterface::class) : $this->output ?? new ConsoleOutput();
if (!$helper->ask($input, $output, new ConfirmationQuestion('Do you want to try to fix this issue now? [Y/n] ', true))) {
$this->output?->writeln('<comment>You canceled fix.</comment>');
return false;
}
if ($this->auto_fix === FIX_POLICY_PROMPT && !confirm('Do you want to try to fix this issue now?')) {
$this->output?->writeln('<comment>You canceled fix.</comment>');
return false;
}
// perform fix
InteractiveTerm::indicateProgress("Fixing {$result->getFixItem()} ... ");

View File

@@ -33,20 +33,15 @@ class MacOSToolCheck
'glibtoolize',
];
#[CheckItem('if homebrew or macports has installed', limit_os: 'Darwin', level: 998)]
public function checkBrewOrPorts(): ?CheckResult
#[CheckItem('if homebrew has installed', limit_os: 'Darwin', level: 998)]
public function checkBrew(): ?CheckResult
{
$brewPath = MacOSUtil::findCommand('brew');
$portPath = MacOSUtil::findCommand('port');
if ($brewPath && $brewPath !== '/opt/homebrew/bin/brew' && getenv('GNU_ARCH') === 'aarch64') {
return CheckResult::fail('Current homebrew (/usr/local/bin/homebrew) is not installed for M1 Mac, please re-install homebrew in /opt/homebrew/ !');
}
if ($brewPath === null && $portPath === null) {
if (($path = MacOSUtil::findCommand('brew')) === null) {
return CheckResult::fail('Homebrew is not installed', 'brew');
}
if ($path !== '/opt/homebrew/bin/brew' && getenv('GNU_ARCH') === 'aarch64') {
return CheckResult::fail('Current homebrew (/usr/local/bin/homebrew) is not installed for M1 Mac, please re-install homebrew in /opt/homebrew/ !');
}
return CheckResult::ok();
}
@@ -65,8 +60,8 @@ class MacOSToolCheck
return CheckResult::ok();
}
#[CheckItem('if homebrew or macports llvm are installed', limit_os: 'Darwin')]
public function checkBrewOrPortsLLVM(): ?CheckResult
#[CheckItem('if homebrew llvm are installed', limit_os: 'Darwin')]
public function checkBrewLLVM(): ?CheckResult
{
if (getenv('SPC_USE_LLVM') === 'brew') {
$homebrew_prefix = getenv('HOMEBREW_PREFIX') ?: (SystemTarget::getTargetArch() === 'aarch64' ? '/opt/homebrew' : '/usr/local/homebrew');
@@ -76,16 +71,6 @@ class MacOSToolCheck
}
return CheckResult::ok($path);
}
if (getenv('SPC_USE_LLVM') === 'port') {
$macportsPrefix = '/opt/local';
if (($path = MacOSUtil::findCommand('clang', ["{$macportsPrefix}/bin"])) === null) {
return CheckResult::fail('MacPorts llvm is not installed', 'build-tools', ['missing' => ['llvm']]);
}
return CheckResult::ok($path);
}
return null;
}
@@ -106,7 +91,7 @@ class MacOSToolCheck
if ($command_path !== []) {
return CheckResult::fail("Current {$bison} version is too old: " . $matches[0]);
}
return $this->checkBisonVersion(['/opt/homebrew/opt/bison/bin', '/usr/local/opt/bison/bin', '/opt/local/bin']);
return $this->checkBisonVersion(['/opt/homebrew/opt/bison/bin', '/usr/local/opt/bison/bin']);
}
return CheckResult::ok($matches[0]);
}
@@ -123,9 +108,6 @@ class MacOSToolCheck
#[FixItem('build-tools')]
public function fixBuildTools(array $missing): bool
{
$brewPath = MacOSUtil::findCommand('brew');
$portPath = MacOSUtil::findCommand('port');
$replacement = [
'glibtoolize' => 'libtool',
];
@@ -133,18 +115,7 @@ class MacOSToolCheck
if (isset($replacement[$cmd])) {
$cmd = $replacement[$cmd];
}
if ($brewPath !== null) {
shell()->exec('brew install --formula ' . escapeshellarg($cmd));
continue;
}
if ($portPath !== null) {
shell()->exec('port install ' . escapeshellarg($cmd));
continue;
}
return false;
shell()->exec('brew install --formula ' . escapeshellarg($cmd));
}
return true;
}

View File

@@ -75,9 +75,6 @@ class PackageInstaller
}
// special check for php target packages
if (in_array($package->getName(), ['php', 'php-cli', 'php-fpm', 'php-micro', 'php-cgi', 'php-embed', 'frankenphp'], true)) {
if (!$package instanceof TargetPackage) {
throw new WrongUsageException("Package '{$package->getName()}' is expected to be a TargetPackage.");
}
$this->handlePhpTargetPackage($package);
return $this;
}

View File

@@ -370,10 +370,7 @@ class PackageLoader
// match condition
$installer = ApplicationContext::get(PackageInstaller::class);
$stages = self::$before_stages[$package_name][$stage] ?? [];
foreach ($stages as $entry) {
$callback = $entry[0];
$only_when_package_resolved = $entry[1] ?? null;
$conditionals = $entry[2] ?? [];
foreach ($stages as [$callback, $only_when_package_resolved, $conditionals]) {
if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) {
continue;
}
@@ -392,10 +389,7 @@ class PackageLoader
$installer = ApplicationContext::get(PackageInstaller::class);
$stages = self::$after_stages[$package_name][$stage] ?? [];
$result = [];
foreach ($stages as $entry) {
$callback = $entry[0];
$only_when_package_resolved = $entry[1] ?? null;
$conditionals = $entry[2] ?? [];
foreach ($stages as [$callback, $only_when_package_resolved, $conditionals]) {
if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) {
continue;
}
@@ -439,9 +433,7 @@ class PackageLoader
}
$pkg = self::getPackage($package_name);
foreach ($stages as $stage_name => $before_events) {
foreach ($before_events as $entry) {
$event_callable = $entry[0];
$only_when_package_resolved = $entry[1] ?? null;
foreach ($before_events as [$event_callable, $only_when_package_resolved, $conditionals]) {
// check only_when_package_resolved package exists
if ($only_when_package_resolved !== null && !self::hasPackage($only_when_package_resolved)) {
throw new RegistryException("{$event_name} event in package [{$package_name}] for stage [{$stage_name}] has unknown only_when_package_resolved package [{$only_when_package_resolved}].");

View File

@@ -117,7 +117,7 @@ class UnixAutoconfExecutor extends Executor
/**
* Add configure args.
*/
public function addConfigureArgs(string ...$args): static
public function addConfigureArgs(...$args): static
{
$this->configure_args = [...$this->configure_args, ...$args];
return $this;
@@ -126,7 +126,7 @@ class UnixAutoconfExecutor extends Executor
/**
* Remove some configure args, to bypass the configure option checking for some libs.
*/
public function removeConfigureArgs(string ...$args): static
public function removeConfigureArgs(...$args): static
{
$this->configure_args = array_diff($this->configure_args, $args);
return $this;

View File

@@ -135,7 +135,7 @@ class UnixCMakeExecutor extends Executor
/**
* Add configure args.
*/
public function addConfigureArgs(string ...$args): static
public function addConfigureArgs(...$args): static
{
$this->configure_args = [...$this->configure_args, ...$args];
return $this;
@@ -144,7 +144,7 @@ class UnixCMakeExecutor extends Executor
/**
* Remove some configure args, to bypass the configure option checking for some libs.
*/
public function removeConfigureArgs(string ...$args): static
public function removeConfigureArgs(...$args): static
{
$this->ignore_args = [...$this->ignore_args, ...$args];
return $this;
@@ -302,12 +302,9 @@ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_C_STANDARD_INCLUDE_DIRECTORIES "{$include}")
set(CMAKE_CXX_STANDARD_INCLUDE_DIRECTORIES "{$include}")
CMAKE;
// pin AR/RANLIB so cmake uses zig-ar/zig-ranlib instead of system /usr/bin/ranlib (zig archives need it)
// Whoops, linux may need CMAKE_AR sometimes
if (PHP_OS_FAMILY === 'Linux') {
$ar = getenv('SPC_DEFAULT_AR') ?: getenv('AR') ?: 'ar';
$ranlib = getenv('SPC_DEFAULT_RANLIB') ?: (getenv('RANLIB') ?: 'ranlib');
$toolchain .= "\nSET(CMAKE_AR \"{$ar}\")";
$toolchain .= "\nSET(CMAKE_RANLIB \"{$ranlib}\")";
$toolchain .= "\nSET(CMAKE_AR \"ar\")";
}
FileSystem::writeFile(SOURCE_PATH . '/toolchain.cmake', $toolchain);
return $created = realpath(SOURCE_PATH . '/toolchain.cmake');

View File

@@ -99,7 +99,7 @@ class WindowsCMakeExecutor extends Executor
/**
* Add configure args.
*/
public function addConfigureArgs(string ...$args): static
public function addConfigureArgs(...$args): static
{
$this->configure_args = [...$this->configure_args, ...$args];
return $this;
@@ -108,7 +108,7 @@ class WindowsCMakeExecutor extends Executor
/**
* Remove some configure args, to bypass the configure option checking for some libs.
*/
public function removeConfigureArgs(string ...$args): static
public function removeConfigureArgs(...$args): static
{
$this->ignore_args = [...$this->ignore_args, ...$args];
return $this;
@@ -189,10 +189,6 @@ class WindowsCMakeExecutor extends Executor
{
return $this->custom_default_args ?? [
'-A x64',
// CMake 4.x hard-errors on projects requesting compatibility with CMake < 3.5
// (e.g. wineditline). This is the documented escape hatch; modern projects and
// older CMake releases ignore it.
'-DCMAKE_POLICY_VERSION_MINIMUM=3.5',
'-DCMAKE_BUILD_TYPE=Release',
'-DBUILD_SHARED_LIBS=OFF',
'-DBUILD_STATIC_LIBS=ON',

View File

@@ -15,7 +15,6 @@ class ClangBrewToolchain extends ClangNativeToolchain
GlobalEnvManager::putenv("SPC_DEFAULT_CC={$homebrew_prefix}/opt/llvm/bin/clang");
GlobalEnvManager::putenv("SPC_DEFAULT_CXX={$homebrew_prefix}/opt/llvm/bin/clang++");
GlobalEnvManager::putenv("SPC_DEFAULT_AR={$homebrew_prefix}/opt/llvm/bin/llvm-ar");
GlobalEnvManager::putenv("SPC_DEFAULT_RANLIB={$homebrew_prefix}/opt/llvm/bin/llvm-ranlib");
GlobalEnvManager::putenv('SPC_DEFAULT_LD=ld');
GlobalEnvManager::addPathIfNotExists("{$homebrew_prefix}/opt/llvm/bin");
}

View File

@@ -21,7 +21,6 @@ class ClangNativeToolchain implements UnixToolchainInterface
GlobalEnvManager::putenv('SPC_DEFAULT_CC=clang');
GlobalEnvManager::putenv('SPC_DEFAULT_CXX=clang++');
GlobalEnvManager::putenv('SPC_DEFAULT_AR=ar');
GlobalEnvManager::putenv('SPC_DEFAULT_RANLIB=ranlib');
GlobalEnvManager::putenv('SPC_DEFAULT_LD=ld');
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Toolchain;
use StaticPHP\Util\GlobalEnvManager;
class ClangPortsToolchain extends ClangNativeToolchain
{
public function initEnv(): void
{
$macports_prefix = getenv('MACPORTS_PREFIX') ?: '/opt/local';
GlobalEnvManager::putenv("SPC_DEFAULT_CC={$macports_prefix}/bin/clang");
GlobalEnvManager::putenv("SPC_DEFAULT_CXX={$macports_prefix}/bin/clang++");
GlobalEnvManager::putenv("SPC_DEFAULT_AR={$macports_prefix}/bin/llvm-ar");
GlobalEnvManager::putenv('SPC_DEFAULT_LD=ld');
GlobalEnvManager::addPathIfNotExists("{$macports_prefix}/bin");
}
}

View File

@@ -18,7 +18,6 @@ class GccNativeToolchain implements UnixToolchainInterface
GlobalEnvManager::putenv('SPC_DEFAULT_CC=gcc');
GlobalEnvManager::putenv('SPC_DEFAULT_CXX=g++');
GlobalEnvManager::putenv('SPC_DEFAULT_AR=ar');
GlobalEnvManager::putenv('SPC_DEFAULT_RANLIB=ranlib');
GlobalEnvManager::putenv('SPC_DEFAULT_LD=ld');
}

View File

@@ -41,7 +41,6 @@ class ToolchainManager
'Windows' => MSVCToolchain::class,
'Darwin' => match (getenv('SPC_USE_LLVM') ?: 'system') {
'brew' => ClangBrewToolchain::class,
'port' => ClangPortsToolchain::class,
default => ClangNativeToolchain::class,
},
default => throw new WrongUsageException('Unsupported OS family: ' . PHP_OS_FAMILY),

View File

@@ -16,7 +16,6 @@ class ZigToolchain implements UnixToolchainInterface
GlobalEnvManager::putenv('SPC_DEFAULT_CC=zig-cc');
GlobalEnvManager::putenv('SPC_DEFAULT_CXX=zig-c++');
GlobalEnvManager::putenv('SPC_DEFAULT_AR=zig-ar');
GlobalEnvManager::putenv('SPC_DEFAULT_RANLIB=zig-ranlib');
GlobalEnvManager::putenv('SPC_DEFAULT_LD=zig-ld.lld');
// Generate additional objects needed for zig toolchain

View File

@@ -134,10 +134,10 @@ class GlobalEnvManager
}
// test bison
if (PHP_OS_FAMILY === 'Darwin') {
if ($bison = MacOSUtil::findCommand('bison', ['/opt/homebrew/opt/bison/bin', '/usr/local/opt/bison/bin', '/opt/local/bin/bison'])) {
if ($bison = MacOSUtil::findCommand('bison', ['/opt/homebrew/opt/bison/bin', '/usr/local/opt/bison/bin'])) {
self::putenv("BISON={$bison}");
}
if ($yacc = MacOSUtil::findCommand('yacc', ['/opt/homebrew/opt/bison/bin', '/usr/local/opt/bison/bin', '/opt/local/bin/yacc'])) {
if ($yacc = MacOSUtil::findCommand('yacc', ['/opt/homebrew/opt/bison/bin', '/usr/local/opt/bison/bin'])) {
self::putenv("YACC={$yacc}");
}
}

View File

@@ -17,8 +17,8 @@ class InteractiveTerm
public static function notice(string $message, bool $indent = false): void
{
$no_ansi = (bool) ApplicationContext::get(InputInterface::class)->getOption('no-ansi');
$output = ApplicationContext::get(OutputInterface::class);
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
if ($output->isVerbose()) {
logger()->notice(strip_ansi_colors($message));
} else {
@@ -29,8 +29,8 @@ class InteractiveTerm
public static function success(string $message, bool $indent = false): void
{
$no_ansi = (bool) ApplicationContext::get(InputInterface::class)->getOption('no-ansi');
$output = ApplicationContext::get(OutputInterface::class);
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
if ($output->isVerbose()) {
logger()->info(strip_ansi_colors($message));
} else {
@@ -41,8 +41,8 @@ class InteractiveTerm
public static function plain(string $message, string $level = 'info'): void
{
$no_ansi = (bool) ApplicationContext::get(InputInterface::class)->getOption('no-ansi');
$output = ApplicationContext::get(OutputInterface::class);
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
if ($output->isVerbose()) {
match ($level) {
'debug' => logger()->debug(strip_ansi_colors($message)),
@@ -59,8 +59,8 @@ class InteractiveTerm
public static function info(string $message): void
{
$no_ansi = (bool) ApplicationContext::get(InputInterface::class)->getOption('no-ansi');
$output = ApplicationContext::get(OutputInterface::class);
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
if (!$output->isVerbose()) {
$output->writeln(($no_ansi ? 'strip_ansi_colors' : 'strval')(ConsoleColor::green('▶ ') . $message));
}
@@ -69,8 +69,8 @@ class InteractiveTerm
public static function error(string $message, bool $indent = true): void
{
$no_ansi = (bool) ApplicationContext::get(InputInterface::class)->getOption('no-ansi');
$output = ApplicationContext::get(OutputInterface::class);
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
if ($output->isVerbose()) {
logger()->error(strip_ansi_colors($message));
} else {
@@ -86,16 +86,16 @@ class InteractiveTerm
public static function setMessage(string $message): void
{
$no_ansi = (bool) ApplicationContext::get(InputInterface::class)->getOption('no-ansi');
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
self::$indicator?->setMessage(($no_ansi ? 'strip_ansi_colors' : 'strval')($message));
logger()->debug(strip_ansi_colors($message));
}
public static function finish(string $message, bool $status = true): void
{
$no_ansi = (bool) ApplicationContext::get(InputInterface::class)->getOption('no-ansi');
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$message = $no_ansi ? strip_ansi_colors($message) : $message;
$output = ApplicationContext::get(OutputInterface::class);
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
if ($output->isVerbose()) {
if ($status) {
logger()->info($message);
@@ -116,8 +116,8 @@ class InteractiveTerm
public static function indicateProgress(string $message): void
{
$no_ansi = (bool) ApplicationContext::get(InputInterface::class)->getOption('no-ansi');
$output = ApplicationContext::get(OutputInterface::class);
$no_ansi = ApplicationContext::get(InputInterface::class)?->getOption('no-ansi') ?? false;
$output = ApplicationContext::get(OutputInterface::class) ?? new ConsoleOutput();
if ($output->isVerbose()) {
logger()->info(strip_ansi_colors($message));
return;

View File

@@ -3,11 +3,22 @@
declare(strict_types=1);
// static-php-cli version string
use Laravel\Prompts\ConfirmPrompt;
use Laravel\Prompts\Prompt;
use Laravel\Prompts\TextPrompt;
use StaticPHP\ConsoleApplication;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\System\LinuxUtil;
use StaticPHP\Util\System\MacOSUtil;
use StaticPHP\Util\System\WindowsUtil;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
const SPC_VERSION = ConsoleApplication::VERSION;
// output path for everything, other paths are defined relative to this by default
@@ -64,3 +75,31 @@ putenv('CPU_COUNT=' . CPU_COUNT);
putenv('SPC_ARCH=' . php_uname('m'));
putenv('GNU_ARCH=' . GNU_ARCH);
putenv('MAC_ARCH=' . MAC_ARCH);
// initialize windows prompt fallback for laravel-prompts
Prompt::fallbackWhen(PHP_OS_FAMILY === 'Windows');
ConfirmPrompt::fallbackUsing(function (ConfirmPrompt $prompt) {
$helper = new QuestionHelper();
$case = $prompt->default ? ' [Y/n] ' : ' [y/N] ';
$question = new ConfirmationQuestion($prompt->label . $case, $prompt->default);
if (ApplicationContext::has(InputInterface::class) && ApplicationContext::has(OutputInterface::class)) {
$input = ApplicationContext::get(InputInterface::class);
$output = ApplicationContext::get(OutputInterface::class);
} else {
$input = new ArrayInput([]);
$output = new ConsoleOutput();
}
return $helper->ask($input, $output, $question);
});
TextPrompt::fallbackUsing(function (TextPrompt $prompt) {
$helper = new QuestionHelper();
$question = new Question($prompt->label . ' ', $prompt->default);
if (ApplicationContext::has(InputInterface::class) && ApplicationContext::has(OutputInterface::class)) {
$input = ApplicationContext::get(InputInterface::class);
$output = ApplicationContext::get(OutputInterface::class);
} else {
$input = new ArrayInput([]);
$output = new ConsoleOutput();
}
return $helper->ask($input, $output, $question);
});

View File

@@ -21,8 +21,6 @@ class GlobalsFunctionsTest extends TestCase
protected function tearDown(): void
{
$GLOBALS['spc_log_filters'] = null;
// Restore logger level to avoid polluting other tests with DEBUG noise
logger()->setLevel(LogLevel::ERROR);
}
public function testAddLogFilterDeduplicates(): void

View File

@@ -23,10 +23,12 @@ class ArtifactDownloaderTest extends TestCase
// Reset ArtifactConfig and ArtifactLoader static state
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$property->setValue(null, []);
$loaderReflection = new \ReflectionClass(ArtifactLoader::class);
$loaderProperty = $loaderReflection->getProperty('artifacts');
$loaderProperty->setAccessible(true);
$loaderProperty->setValue(null, null);
}
@@ -36,10 +38,12 @@ class ArtifactDownloaderTest extends TestCase
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$property->setValue(null, []);
$loaderReflection = new \ReflectionClass(ArtifactLoader::class);
$loaderProperty = $loaderReflection->getProperty('artifacts');
$loaderProperty->setAccessible(true);
$loaderProperty->setValue(null, null);
}
@@ -339,6 +343,7 @@ class ArtifactDownloaderTest extends TestCase
{
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$configs = $property->getValue(null) ?? [];
$configs[$name] = $config;
$property->setValue(null, $configs);

View File

@@ -31,10 +31,12 @@ class ArtifactExtractorTest extends TestCase
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$property->setValue(null, []);
$loaderReflection = new \ReflectionClass(ArtifactLoader::class);
$loaderProperty = $loaderReflection->getProperty('artifacts');
$loaderProperty->setAccessible(true);
$loaderProperty->setValue(null, null);
ApplicationContext::reset();
@@ -49,10 +51,12 @@ class ArtifactExtractorTest extends TestCase
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$property->setValue(null, []);
$loaderReflection = new \ReflectionClass(ArtifactLoader::class);
$loaderProperty = $loaderReflection->getProperty('artifacts');
$loaderProperty->setAccessible(true);
$loaderProperty->setValue(null, null);
ApplicationContext::reset();
@@ -153,6 +157,7 @@ class ArtifactExtractorTest extends TestCase
// Pre-populate the extracted map for 'my-pkg' via reflection
$reflection = new \ReflectionClass(ArtifactExtractor::class);
$extractedProperty = $reflection->getProperty('extracted');
$extractedProperty->setAccessible(true);
$extractedProperty->setValue($extractor, ['my-pkg' => true]);
$result = $extractor->extract($artifact, false);
@@ -176,6 +181,7 @@ class ArtifactExtractorTest extends TestCase
// Pre-populate the extracted map so we don't need actual downloads
$reflection = new \ReflectionClass(ArtifactExtractor::class);
$extractedProperty = $reflection->getProperty('extracted');
$extractedProperty->setAccessible(true);
$extractedProperty->setValue($extractor, ['my-pkg' => true]);
$result = $extractor->extract('my-pkg', false);
@@ -198,6 +204,7 @@ class ArtifactExtractorTest extends TestCase
{
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$configs = $property->getValue(null) ?? [];
$configs[$name] = $config;
$property->setValue(null, $configs);

View File

@@ -29,6 +29,7 @@ class ArtifactTest extends TestCase
// Reset ArtifactConfig static state
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$property->setValue(null, []);
// Reset DI container
@@ -44,6 +45,7 @@ class ArtifactTest extends TestCase
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$property->setValue(null, []);
ApplicationContext::reset();
@@ -713,6 +715,7 @@ class ArtifactTest extends TestCase
{
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$configs = $property->getValue(null) ?? [];
$configs[$name] = $config;
$property->setValue(null, $configs);

View File

@@ -1,291 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\StaticPHP\Command\Dev;
use PHPUnit\Framework\TestCase;
use Psr\Log\LogLevel;
use StaticPHP\Command\Dev\GenExtTestMatrixCommand;
use StaticPHP\Config\PackageConfig;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @internal
*/
class GenExtTestMatrixCommandTest extends TestCase
{
private Application $app;
protected function setUp(): void
{
parent::setUp();
// Reset PackageConfig static state
$ref = new \ReflectionClass(PackageConfig::class);
$prop = $ref->getProperty('package_configs');
$prop->setValue(null, []);
// Register fixture packages
PackageConfig::loadFromArray(self::buildFixture(), 'test');
// Set up Symfony Application with the command under test
$this->app = new Application();
$this->app->add(new GenExtTestMatrixCommand());
$this->app->setAutoExit(false);
}
protected function tearDown(): void
{
parent::tearDown();
// Reset PackageConfig static state
$ref = new \ReflectionClass(PackageConfig::class);
$prop = $ref->getProperty('package_configs');
$prop->setValue(null, []);
// Restore logger level (BaseCommand::execute() may have changed it)
logger()->setLevel(LogLevel::ERROR);
}
// ──────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────
/**
* swoole entry must contain all swoole-hook-* virtuals and nothing else.
*/
public function testSwooleBundlesHookVirtuals(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux']);
$swooleEntries = $this->findEntriesContaining($matrix, 'swoole');
$this->assertCount(1, $swooleEntries, 'Expected exactly one entry containing swoole');
$parts = explode(',', $swooleEntries[0]['extension']);
sort($parts);
$this->assertContains('swoole', $parts);
$this->assertContains('swoole-hook-mysql', $parts);
$this->assertContains('swoole-hook-pgsql', $parts);
}
/**
* curl must NOT appear in the same entry as swoole, even though swoole depends on it.
*/
public function testCurlIsNotPulledIntoSwooleEntry(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux']);
// The swoole entry must not contain 'curl'
$swooleEntries = $this->findEntriesContaining($matrix, 'swoole');
$this->assertCount(1, $swooleEntries);
$parts = explode(',', $swooleEntries[0]['extension']);
$this->assertNotContains('curl', $parts, 'curl must not appear inside the swoole matrix entry');
// curl must appear in a separate entry
$curlEntries = $this->findEntriesContaining($matrix, 'curl');
$this->assertNotEmpty($curlEntries, 'curl must have its own matrix entry');
}
/**
* swow must be fully isolated — its entry should only contain 'swow'.
*/
public function testSwowIsIsolated(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux']);
$swowEntries = $this->findEntriesContaining($matrix, 'swow');
$this->assertCount(1, $swowEntries, 'Expected exactly one entry containing swow');
$this->assertSame('swow', $swowEntries[0]['extension'], 'swow entry must contain only swow');
}
/**
* dom and xml must appear in the same matrix entry (DFS chain).
*/
public function testDomXmlChain(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux']);
$chainEntries = $this->findEntriesContaining($matrix, 'dom', 'xml');
$this->assertNotEmpty($chainEntries, 'dom and xml must appear in the same matrix entry');
}
/**
* --os=Windows must exclude ext-linux-only.
*/
public function testOsFilterExcludesLinuxOnlyFromWindows(): void
{
$matrix = $this->runMatrix(['--os' => 'Windows']);
$linuxOnlyEntries = $this->findEntriesContaining($matrix, 'linux-only');
$this->assertEmpty($linuxOnlyEntries, 'ext-linux-only must not appear in the Windows matrix');
}
/**
* --os=Linux must include ext-linux-only.
*/
public function testOsFilterIncludesLinuxOnly(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux']);
$linuxOnlyEntries = $this->findEntriesContaining($matrix, 'linux-only');
$this->assertNotEmpty($linuxOnlyEntries, 'ext-linux-only must appear in the Linux matrix');
}
/**
* All returned entries must reference the requested OS runner when --os is specified.
*/
public function testOsFilterRestrictsRunners(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux']);
foreach ($matrix as $entry) {
$this->assertSame('linux', $entry['os'], "Entry {$entry['extension']} must only target Linux");
}
}
/**
* --for-extensions=redis must return only entries that contain 'redis'.
*/
public function testForExtensionsFilter(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux', '--for-extensions' => 'redis']);
$this->assertNotEmpty($matrix, '--for-extensions=redis must yield at least one entry');
foreach ($matrix as $entry) {
$parts = explode(',', $entry['extension']);
$this->assertContains('redis', $parts, "Entry {$entry['extension']} does not contain redis");
}
}
/**
* --for-libs=libxml2 must return only entries whose extension(s) depend on libxml2.
*/
public function testForLibsFilter(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux', '--for-libs' => 'libxml2']);
$this->assertNotEmpty($matrix, '--for-libs=libxml2 must yield at least one entry');
foreach ($matrix as $entry) {
$parts = explode(',', $entry['extension']);
// xml depends on libxml2 directly; dom depends on xml (which depends on libxml2)
$match = count(array_intersect($parts, ['xml', 'dom'])) > 0;
$this->assertTrue($match, "Entry {$entry['extension']} should not appear in --for-libs=libxml2 results");
}
}
/**
* --tier2 must produce only Tier2 runners and no Windows entries.
*/
public function testTier2Flag(): void
{
$matrix = $this->runMatrix(['--tier2' => true]);
$this->assertNotEmpty($matrix);
foreach ($matrix as $entry) {
$this->assertNotSame('windows', $entry['os'], '--tier2 must not include Windows entries');
$this->assertContains(
$entry['runner'],
['ubuntu-24.04-arm', 'macos-15-intel'],
"Runner {$entry['runner']} is not a valid Tier2 runner"
);
}
}
/**
* Each entry must have the mandatory keys and correct types.
*/
public function testEntryShape(): void
{
$matrix = $this->runMatrix(['--os' => 'Linux']);
$this->assertNotEmpty($matrix);
foreach ($matrix as $entry) {
$this->assertArrayHasKey('runner', $entry);
$this->assertArrayHasKey('os', $entry);
$this->assertArrayHasKey('arch', $entry);
$this->assertArrayHasKey('extension', $entry);
$this->assertArrayHasKey('build-args', $entry);
$this->assertIsString($entry['extension']);
$this->assertIsString($entry['build-args']);
$this->assertStringContainsString($entry['extension'], $entry['build-args']);
}
}
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
/**
* Run the command with the given options and return the parsed JSON matrix.
*/
private function runMatrix(array $options = []): array
{
$tester = new CommandTester($this->app->find('dev:gen-ext-test-matrix'));
$tester->execute($options, ['decorated' => false]);
$output = $tester->getDisplay();
$matrix = json_decode($output, true);
$this->assertIsArray($matrix, "Command output is not valid JSON. Output:\n{$output}");
return $matrix;
}
/**
* Find matrix entries whose 'extension' field contains all of the given names.
*
* @return array[] matching entries
*/
private function findEntriesContaining(array $matrix, string ...$names): array
{
return array_values(array_filter($matrix, static function (array $entry) use ($names): bool {
$parts = explode(',', $entry['extension']);
foreach ($names as $name) {
if (!in_array($name, $parts, true)) {
return false;
}
}
return true;
}));
}
/**
* Minimal valid php-extension fixture.
*
* Layout:
* - ext-swow standalone isolated, no ext deps
* - ext-swoole standalone isolated, depends on ext-curl
* - ext-swoole-hook-* virtual (arg-type: none) — must be bundled with swoole
* - ext-curl simple orphan, depended on by swoole but must NOT be pulled into swoole entry
* - ext-redis simple orphan
* - ext-xml depends on lib 'libxml2'
* - ext-dom depends on ext-xml (DFS chain)
* - ext-linux-only restricted to Linux via os: [Linux]
*/
private static function buildFixture(): array
{
// php-extension must be a non-empty assoc array ([] fails is_assoc_array() check).
$ext = static fn (array $phpExt = ['arg-type' => 'standard'], array $topLevel = []): array => array_merge(['type' => 'php-extension', 'php-extension' => $phpExt], $topLevel);
return [
// Isolated standalones
'ext-swow' => $ext(),
'ext-swoole' => $ext(['arg-type' => 'standard'], ['depends' => ['ext-curl']]),
// Swoole hook virtuals (arg-type: none → virtual)
'ext-swoole-hook-mysql' => $ext(['arg-type' => 'none']),
'ext-swoole-hook-pgsql' => $ext(['arg-type' => 'none']),
// Simple orphans
'ext-curl' => $ext(),
'ext-redis' => $ext(),
// DFS chain: dom depends on xml; xml depends on lib 'libxml2'
'ext-xml' => $ext(['arg-type' => 'standard'], ['depends' => ['libxml2']]),
'ext-dom' => $ext(['arg-type' => 'standard'], ['depends' => ['ext-xml']]),
// OS-restricted to Linux only
'ext-linux-only' => $ext(['os' => ['Linux']]),
];
}
}

View File

@@ -25,6 +25,7 @@ class ArtifactConfigTest extends TestCase
// Reset static state
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$property->setValue([]);
}
@@ -40,6 +41,7 @@ class ArtifactConfigTest extends TestCase
// Reset static state
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$property->setValue([]);
}

View File

@@ -26,6 +26,7 @@ class PackageConfigTest extends TestCase
// Reset static state
$reflection = new \ReflectionClass(PackageConfig::class);
$property = $reflection->getProperty('package_configs');
$property->setAccessible(true);
$property->setValue([]);
}
@@ -40,6 +41,7 @@ class PackageConfigTest extends TestCase
// Reset static state
$reflection = new \ReflectionClass(PackageConfig::class);
$property = $reflection->getProperty('package_configs');
$property->setAccessible(true);
$property->setValue([]);
}

View File

@@ -32,10 +32,12 @@ class ArtifactLoaderTest extends TestCase
// Reset ArtifactLoader and ArtifactConfig state
$reflection = new \ReflectionClass(ArtifactLoader::class);
$property = $reflection->getProperty('artifacts');
$property->setAccessible(true);
$property->setValue(null, null);
$configReflection = new \ReflectionClass(ArtifactConfig::class);
$configProperty = $configReflection->getProperty('artifact_configs');
$configProperty->setAccessible(true);
$configProperty->setValue(null, []);
}
@@ -50,10 +52,12 @@ class ArtifactLoaderTest extends TestCase
// Reset ArtifactLoader and ArtifactConfig state
$reflection = new \ReflectionClass(ArtifactLoader::class);
$property = $reflection->getProperty('artifacts');
$property->setAccessible(true);
$property->setValue(null, null);
$configReflection = new \ReflectionClass(ArtifactConfig::class);
$configProperty = $configReflection->getProperty('artifact_configs');
$configProperty->setAccessible(true);
$configProperty->setValue(null, []);
}
@@ -425,6 +429,7 @@ class TestArtifact1 {
{
$reflection = new \ReflectionClass(ArtifactConfig::class);
$property = $reflection->getProperty('artifact_configs');
$property->setAccessible(true);
$configs = $property->getValue();
$configs[$name] = [
'type' => 'source',

View File

@@ -26,9 +26,11 @@ class DoctorLoaderTest extends TestCase
// Reset DoctorLoader state
$reflection = new \ReflectionClass(DoctorLoader::class);
$property = $reflection->getProperty('doctor_items');
$property->setAccessible(true);
$property->setValue(null, []);
$property = $reflection->getProperty('fix_items');
$property->setAccessible(true);
$property->setValue(null, []);
}
@@ -43,9 +45,11 @@ class DoctorLoaderTest extends TestCase
// Reset DoctorLoader state
$reflection = new \ReflectionClass(DoctorLoader::class);
$property = $reflection->getProperty('doctor_items');
$property->setAccessible(true);
$property->setValue(null, []);
$property = $reflection->getProperty('fix_items');
$property->setAccessible(true);
$property->setValue(null, []);
}

View File

@@ -33,20 +33,25 @@ class PackageLoaderTest extends TestCase
$reflection = new \ReflectionClass(PackageLoader::class);
$property = $reflection->getProperty('packages');
$property->setAccessible(true);
$property->setValue(null, null);
$property = $reflection->getProperty('before_stages');
$property->setAccessible(true);
$property->setValue(null, []);
$property = $reflection->getProperty('after_stages');
$property->setAccessible(true);
$property->setValue(null, []);
$property = $reflection->getProperty('loaded_classes');
$property->setAccessible(true);
$property->setValue(null, []);
// Reset PackageConfig state
$configReflection = new \ReflectionClass(PackageConfig::class);
$configProperty = $configReflection->getProperty('package_configs');
$configProperty->setAccessible(true);
$configProperty->setValue(null, []);
}
@@ -62,20 +67,25 @@ class PackageLoaderTest extends TestCase
$reflection = new \ReflectionClass(PackageLoader::class);
$property = $reflection->getProperty('packages');
$property->setAccessible(true);
$property->setValue(null, null);
$property = $reflection->getProperty('before_stages');
$property->setAccessible(true);
$property->setValue(null, []);
$property = $reflection->getProperty('after_stages');
$property->setAccessible(true);
$property->setValue(null, []);
$property = $reflection->getProperty('loaded_classes');
$property->setAccessible(true);
$property->setValue(null, []);
// Reset PackageConfig state
$configReflection = new \ReflectionClass(PackageConfig::class);
$configProperty = $configReflection->getProperty('package_configs');
$configProperty->setAccessible(true);
$configProperty->setValue(null, []);
}
@@ -349,6 +359,7 @@ class PackageLoaderTest extends TestCase
// Manually add a before_stage for non-existent package
$reflection = new \ReflectionClass(PackageLoader::class);
$property = $reflection->getProperty('before_stages');
$property->setAccessible(true);
$property->setValue(null, [
'non-existent-package' => [
'stage-name' => [[fn () => null, null]],
@@ -373,6 +384,7 @@ class PackageLoaderTest extends TestCase
// Manually add a before_stage for non-existent stage
$reflection = new \ReflectionClass(PackageLoader::class);
$property = $reflection->getProperty('before_stages');
$property->setAccessible(true);
$property->setValue(null, [
'test-lib' => [
'non-existent-stage' => [[fn () => null, null]],
@@ -396,6 +408,7 @@ class PackageLoaderTest extends TestCase
// Manually add a before_stage with unknown only_when_package_resolved
$reflection = new \ReflectionClass(PackageLoader::class);
$property = $reflection->getProperty('before_stages');
$property->setAccessible(true);
$property->setValue(null, [
'test-lib' => [
'test-stage' => [[fn () => null, 'non-existent-package']],
@@ -422,6 +435,7 @@ class PackageLoaderTest extends TestCase
// This should NOT throw an exception because the package has no build function for current OS
$reflection = new \ReflectionClass(PackageLoader::class);
$property = $reflection->getProperty('before_stages');
$property->setAccessible(true);
$property->setValue(null, [
'test-lib' => [
'build' => [[fn () => null, null]],
@@ -444,6 +458,7 @@ class PackageLoaderTest extends TestCase
$reflection = new \ReflectionClass(PackageLoader::class);
$property = $reflection->getProperty('before_stages');
$property->setAccessible(true);
$property->setValue(null, [
'test-package' => [
'test-stage' => [
@@ -467,6 +482,7 @@ class PackageLoaderTest extends TestCase
$reflection = new \ReflectionClass(PackageLoader::class);
$property = $reflection->getProperty('after_stages');
$property->setAccessible(true);
$property->setValue(null, [
'test-package' => [
'test-stage' => [
@@ -554,6 +570,7 @@ class TestPackage1 {
{
$reflection = new \ReflectionClass(PackageConfig::class);
$property = $reflection->getProperty('package_configs');
$property->setAccessible(true);
$configs = $property->getValue();
$configs[$name] = [
'type' => $type,

View File

@@ -1,531 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\StaticPHP\Util;
use PHPUnit\Framework\TestCase;
use StaticPHP\Config\PackageConfig;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Util\DependencyResolver;
/**
* Tests for the DependencyResolver — the topological sort engine that
* determines the order in which packages (libraries, extensions, targets)
* must be built.
*
* @internal
*/
class DependencyResolverTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->resetPackageConfig();
}
protected function tearDown(): void
{
parent::tearDown();
$this->resetPackageConfig();
}
// ──────────────────────────────────────────────
// Basic resolution
// ──────────────────────────────────────────────
public function testResolveSinglePackageNoDependencies(): void
{
$this->loadConfig([
'zlib' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve(['zlib']);
$this->assertSame(['zlib'], $result);
}
public function testResolveLinearChain(): void
{
// a -> b -> c (a depends on b, b depends on c)
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b']],
'b' => ['type' => 'library', 'depends' => ['c']],
'c' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve(['a']);
// c must be first, then b, then a
$this->assertSame(['c', 'b', 'a'], $result);
}
public function testResolveMultipleIndependentChains(): void
{
// a -> b, x -> y (two independent dependency chains)
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b']],
'b' => ['type' => 'library'],
'x' => ['type' => 'library', 'depends' => ['y']],
'y' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve(['a', 'x']);
// Dependencies must come before their dependants
$posB = array_search('b', $result, true);
$posA = array_search('a', $result, true);
$posY = array_search('y', $result, true);
$posX = array_search('x', $result, true);
$this->assertIsInt($posB);
$this->assertIsInt($posA);
$this->assertIsInt($posY);
$this->assertIsInt($posX);
$this->assertLessThan($posA, $posB, 'b should come before a');
$this->assertLessThan($posX, $posY, 'y should come before x');
}
public function testResolveSharedDependency(): void
{
// a -> c, b -> c (c is shared)
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['c']],
'b' => ['type' => 'library', 'depends' => ['c']],
'c' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve(['a', 'b']);
// c must appear exactly once and before both a and b
$cCount = count(array_keys($result, 'c', true));
$this->assertSame(1, $cCount, 'Shared dependency c should appear exactly once');
$posC = array_search('c', $result, true);
$posA = array_search('a', $result, true);
$posB = array_search('b', $result, true);
$this->assertLessThan($posA, $posC, 'c should come before a');
$this->assertLessThan($posB, $posC, 'c should come before b');
}
public function testResolveDiamondDependency(): void
{
// a
// / \
// b c
// \ /
// d
$this->loadConfig([
'a' => ['type' => 'target', 'depends' => ['b', 'c']],
'b' => ['type' => 'library', 'depends' => ['d']],
'c' => ['type' => 'library', 'depends' => ['d']],
'd' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve(['a']);
// d must appear exactly once and before everything
$dCount = count(array_keys($result, 'd', true));
$this->assertSame(1, $dCount);
$posD = array_search('d', $result, true);
$posB = array_search('b', $result, true);
$posC = array_search('c', $result, true);
$posA = array_search('a', $result, true);
$this->assertLessThan($posB, $posD, 'd should come before b');
$this->assertLessThan($posC, $posD, 'd should come before c');
$this->assertLessThan($posA, $posB, 'b should come before a');
$this->assertLessThan($posA, $posC, 'c should come before a');
}
// ──────────────────────────────────────────────
// Suggests (optional dependencies)
// ──────────────────────────────────────────────
public function testResolveSuggestsAreExcludedByDefault(): void
{
// a depends on b, suggests c
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b'], 'suggests' => ['c']],
'b' => ['type' => 'library'],
'c' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve(['a']);
// c should NOT be in the resolved list (it's only suggested, not depended)
$this->assertNotContains('c', $result);
$this->assertSame(['b', 'a'], $result);
}
public function testResolveSuggestsIncludedWhenFlagSet(): void
{
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b'], 'suggests' => ['c']],
'b' => ['type' => 'library'],
'c' => ['type' => 'library', 'depends' => ['b']],
]);
$result = DependencyResolver::resolve(['a'], include_suggests: true);
// c IS a suggest of a and should be included when flag is set
$this->assertContains('c', $result);
$posB = array_search('b', $result, true);
$posC = array_search('c', $result, true);
$posA = array_search('a', $result, true);
$this->assertLessThan($posA, $posB, 'b should come before a');
$this->assertLessThan($posA, $posC, 'c should come before a');
}
// ──────────────────────────────────────────────
// Virtual-target promotion
// ──────────────────────────────────────────────
public function testResolveVirtualTargetPromotesDepsToParent(): void
{
// php-cli (virtual-target) depends on [php, ext-ctype]
// When php-cli is in the input, ext-ctype should be promoted to php's deps
$this->loadConfig([
'php-cli' => ['type' => 'virtual-target', 'depends' => ['php', 'ext-ctype']],
'php' => ['type' => 'target', 'depends' => ['libxml2']],
'ext-ctype' => ['type' => 'php-extension', 'depends' => []],
'libxml2' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve(['php-cli']);
$posPhp = array_search('php', $result, true);
$posCtype = array_search('ext-ctype', $result, true);
$posLibxml2 = array_search('libxml2', $result, true);
$this->assertIsInt($posPhp);
$this->assertIsInt($posCtype);
$this->assertIsInt($posLibxml2);
// ext-ctype was promoted to php's deps, so it must come before php
$this->assertLessThan($posPhp, $posCtype, 'ext-ctype should come before php (promoted dep)');
// libxml2 is a native dep of php, so it must also come before php
$this->assertLessThan($posPhp, $posLibxml2, 'libxml2 should come before php');
}
public function testResolveVirtualTargetNotInInputDoesNotPromote(): void
{
// php-cli is a virtual-target but NOT in the input request,
// so its deps should NOT be injected into php
$this->loadConfig([
'php-cli' => ['type' => 'virtual-target', 'depends' => ['php', 'ext-ctype']],
'php' => ['type' => 'target', 'depends' => ['libxml2']],
'ext-ctype' => ['type' => 'php-extension'],
'libxml2' => ['type' => 'library'],
]);
// Only php is requested, not php-cli
$result = DependencyResolver::resolve(['php']);
// ext-ctype should NOT be in the result since php-cli was not requested
$this->assertNotContains('ext-ctype', $result);
$this->assertSame(['libxml2', 'php'], $result);
}
// ──────────────────────────────────────────────
// Dependency overrides
// ──────────────────────────────────────────────
public function testResolveDependencyOverridesAddDeps(): void
{
$this->loadConfig([
'a' => ['type' => 'library'],
'b' => ['type' => 'library'],
'c' => ['type' => 'library'],
]);
// Override: a now depends on b and c
$result = DependencyResolver::resolve(['a'], dependency_overrides: [
'a' => ['b', 'c'],
]);
$posA = array_search('a', $result, true);
$posB = array_search('b', $result, true);
$posC = array_search('c', $result, true);
$this->assertLessThan($posA, $posB, 'b should come before a (override)');
$this->assertLessThan($posA, $posC, 'c should come before a (override)');
}
public function testResolveDependencyOverridesMergeWithExisting(): void
{
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b']],
'b' => ['type' => 'library'],
'c' => ['type' => 'library'],
]);
// a natively depends on b, override adds c
$result = DependencyResolver::resolve(['a'], dependency_overrides: [
'a' => ['c'],
]);
$this->assertContains('b', $result);
$this->assertContains('c', $result);
$posA = array_search('a', $result, true);
$posB = array_search('b', $result, true);
$posC = array_search('c', $result, true);
$this->assertLessThan($posA, $posB, 'b should come before a');
$this->assertLessThan($posA, $posC, 'c should come before a');
}
// ──────────────────────────────────────────────
// Error handling
// ──────────────────────────────────────────────
public function testResolveUnknownPackageThrowsException(): void
{
$this->loadConfig([
'zlib' => ['type' => 'library'],
]);
$this->expectException(WrongUsageException::class);
$this->expectExceptionMessage('does not exist in config');
DependencyResolver::resolve(['nonexistent']);
}
public function testResolveUnregisteredDependencyThrowsException(): void
{
// a depends on b, but b is not in the config
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b']],
]);
$this->expectException(WrongUsageException::class);
$this->expectExceptionMessage('not exist');
DependencyResolver::resolve(['a']);
}
// ──────────────────────────────────────────────
// Reverse dependency map ($why parameter)
// ──────────────────────────────────────────────
public function testReverseDependencyMap(): void
{
// a -> b -> c
$this->loadConfig([
'a' => ['type' => 'target', 'depends' => ['b']],
'b' => ['type' => 'library', 'depends' => ['c']],
'c' => ['type' => 'library'],
]);
$why = [];
DependencyResolver::resolve(['a'], why: $why);
$this->assertArrayHasKey('c', $why, 'c is depended upon');
$this->assertContains('b', $why['c'], 'b depends on c');
$this->assertArrayHasKey('b', $why, 'b is depended upon');
$this->assertContains('a', $why['b'], 'a depends on b');
}
public function testReverseDependencyMapOnlyIncludesResolvedPackages(): void
{
// a -> b -> c, but only requesting a
// d is not in the resolved set
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b']],
'b' => ['type' => 'library', 'depends' => ['c']],
'c' => ['type' => 'library'],
'd' => ['type' => 'library', 'depends' => ['c']], // not in input
]);
$why = [];
DependencyResolver::resolve(['a'], why: $why);
// d should NOT appear in the reverse map since it's not in the resolved set
$this->assertArrayNotHasKey('d', $why);
}
// ──────────────────────────────────────────────
// getSubDependencies
// ──────────────────────────────────────────────
public function testGetSubDependenciesLinearChain(): void
{
// a -> b -> c -> d
$this->loadConfig([
'a' => ['type' => 'target', 'depends' => ['b']],
'b' => ['type' => 'library', 'depends' => ['c']],
'c' => ['type' => 'library', 'depends' => ['d']],
'd' => ['type' => 'library'],
]);
$subDeps = DependencyResolver::getSubDependencies('a', ['a', 'b', 'c', 'd']);
// Should return [d, c, b] in dependency order (a not included)
$this->assertNotContains('a', $subDeps);
$this->assertSame(['d', 'c', 'b'], $subDeps);
}
public function testGetSubDependenciesPackageNotInResolvedSet(): void
{
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b']],
'b' => ['type' => 'library'],
]);
$subDeps = DependencyResolver::getSubDependencies('nonexistent', ['a', 'b']);
$this->assertSame([], $subDeps);
}
public function testGetSubDependenciesWithSuggests(): void
{
$this->loadConfig([
'a' => ['type' => 'target', 'depends' => ['b'], 'suggests' => ['c']],
'b' => ['type' => 'library'],
'c' => ['type' => 'library'],
]);
// Without include_suggests: only b is a sub-dep
$without = DependencyResolver::getSubDependencies('a', ['a', 'b', 'c'], include_suggests: false);
$this->assertSame(['b'], $without);
// With include_suggests: both b and c are sub-deps
$with = DependencyResolver::getSubDependencies('a', ['a', 'b', 'c'], include_suggests: true);
$this->assertContains('b', $with);
$this->assertContains('c', $with);
}
public function testGetSubDependenciesOnlyIncludesResolvedDeps(): void
{
// a depends on b and c, but c is not in the resolved set
$this->loadConfig([
'a' => ['type' => 'target', 'depends' => ['b', 'c']],
'b' => ['type' => 'library'],
'c' => ['type' => 'library'],
]);
// c is NOT in the resolved set
$subDeps = DependencyResolver::getSubDependencies('a', ['a', 'b']);
$this->assertContains('b', $subDeps);
$this->assertNotContains('c', $subDeps, 'c is not in the resolved set, should be excluded');
}
// ──────────────────────────────────────────────
// Edge cases & defensive
// ──────────────────────────────────────────────
public function testResolveEmptyInput(): void
{
$this->loadConfig([
'zlib' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve([]);
$this->assertSame([], $result);
}
public function testResolveWithStringAndPackageInstanceMixed(): void
{
$this->loadConfig([
'a' => ['type' => 'library'],
'b' => ['type' => 'library'],
]);
// Pass one as string, one as a mock Package
$mockPackage = $this->createMockPackage('a');
$result = DependencyResolver::resolve([$mockPackage, 'b']);
$this->assertContains('a', $result);
$this->assertContains('b', $result);
}
public function testResolveDuplicateInputPackages(): void
{
// Requesting the same package twice should not duplicate it in output
$this->loadConfig([
'zlib' => ['type' => 'library'],
]);
$result = DependencyResolver::resolve(['zlib', 'zlib']);
$this->assertSame(['zlib'], $result);
}
/**
* Documents the current behavior for circular dependencies.
* The algorithm does not detect cycles; it silently resolves them
* using the visited-set to break infinite recursion. This test
* locks in the current behavior so any intentional change is caught.
*/
public function testCircularDependencyDoesNotLoopInfinitely(): void
{
// a -> b -> a (circular)
$this->loadConfig([
'a' => ['type' => 'library', 'depends' => ['b']],
'b' => ['type' => 'library', 'depends' => ['a']],
]);
// Must not hang — should complete and return both packages
$result = DependencyResolver::resolve(['a']);
$this->assertCount(2, $result);
$this->assertContains('a', $result);
$this->assertContains('b', $result);
}
// ──────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────
/**
* Load package configurations directly into PackageConfig.
* Uses reflection to inject fixture data without needing YAML files on disk.
*
* @param array<string, array{type: string, depends?: string[], suggests?: string[]}> $configs
*/
private function loadConfig(array $configs): void
{
$reflection = new \ReflectionClass(PackageConfig::class);
$property = $reflection->getProperty('package_configs');
$existing = $property->getValue();
if (!is_array($existing)) {
$existing = [];
}
foreach ($configs as $name => $config) {
$existing[$name] = $config;
}
$property->setValue(null, $existing);
}
/**
* Reset PackageConfig to empty state.
*/
private function resetPackageConfig(): void
{
$reflection = new \ReflectionClass(PackageConfig::class);
$property = $reflection->getProperty('package_configs');
$property->setValue(null, []);
}
/**
* Create a minimal mock Package object that returns a given name.
*/
private function createMockPackage(string $name): object
{
return new class($name) {
public function __construct(private string $name) {}
public function getName(): string
{
return $this->name;
}
};
}
}

View File

@@ -2,10 +2,8 @@
declare(strict_types=1);
use Psr\Log\LogLevel;
use StaticPHP\Registry\Registry;
require_once __DIR__ . '/../src/bootstrap.php';
\StaticPHP\Registry\Registry::resolve();
logger()->setLevel(LogLevel::ERROR);
Registry::resolve();