Merge remote-tracking branch 'origin/v3-refactor/libs' into v3-refactor/libs

# Conflicts:
#	config/artifact.yml
#	config/pkg.lib.yml
#	spc.registry.json
This commit is contained in:
crazywhalecc
2026-02-03 19:14:53 +08:00
94 changed files with 2214 additions and 781 deletions

View File

@@ -164,7 +164,14 @@ class Artifact
// For selective mode, cannot reliably check extraction status
if ($mode === 'selective') {
return false;
// check files existence
foreach ($extract_config['files'] as $target_file) {
$target_file = FileSystem::replacePathVariable($target_file);
if (!file_exists($target_file)) {
return false;
}
}
return true;
}
// For standalone mode, check directory or file and hash
@@ -268,6 +275,19 @@ class Artifact
return FileSystem::convertPath(SOURCE_PATH . '/' . $path);
}
/**
* Get source build root directory.
* It's only worked when 'source-root' is defined in artifact config.
* Normally it's equal to source dir.
*/
public function getSourceRoot(): string
{
if (isset($this->config['metadata']['source-root'])) {
return $this->getSourceDir() . '/' . ltrim($this->config['metadata']['source-root'], '/');
}
return $this->getSourceDir();
}
/**
* Get binary extraction directory and mode.
*

View File

@@ -312,7 +312,7 @@ class ArtifactDownloader
FileSystem::createDir(DOWNLOAD_PATH);
}
logger()->info('Downloading' . implode(', ', array_map(fn ($x) => " '{$x->getName()}'", $this->artifacts)) . " with concurrency {$this->parallel} ...");
// Download artifacts parallely
// Download artifacts parallelly
if ($this->parallel > 1) {
$this->downloadWithConcurrency();
} else {
@@ -438,7 +438,7 @@ class ArtifactDownloader
break;
}
}
$vvv = ApplicationContext::isDebug() ? "\nIf the problem persists, consider using `-vvv` to enable verbose mode, and disable parallel downloading for more details." : '';
$vvv = !ApplicationContext::isDebug() ? "\nIf the problem persists, consider using `-vvv` to enable verbose mode, or disable parallel downloading for more details." : '';
throw new DownloaderException("Download artifact '{$artifact->getName()}' failed. Please check your internet connection and try again.{$vvv}");
}

View File

@@ -247,6 +247,7 @@ class ArtifactExtractor
$artifact->emitAfterBinaryExtract($target_path, $platform);
logger()->debug("Emitted after-binary-extract hooks for [{$name}]");
/* @phpstan-ignore-next-line */
if ($hash !== null && $cache_info['cache_type'] !== 'file') {
FileSystem::writeFile("{$target_path}/.spc-hash", $hash);
}

View File

@@ -20,6 +20,7 @@ class FileList implements DownloadTypeInterface
throw new DownloaderException("Failed to get {$name} file list from {$config['url']}");
}
$versions = [];
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

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class PatchBeforeBuild {}

View File

@@ -4,22 +4,32 @@ declare(strict_types=1);
namespace StaticPHP\Command;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Util\V2CompatLayer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
#[AsCommand('build:libs')]
#[AsCommand('build:libs', 'Build specified library packages')]
class BuildLibsCommand extends BaseCommand
{
public function configure()
public function configure(): void
{
$this->addArgument('libraries', InputArgument::REQUIRED, 'The library packages will be compiled, comma separated');
// Builder options
$this->getDefinition()->addOptions([
new InputOption('with-suggests', ['L', 'E'], null, 'Resolve and install suggested packages as well'),
new InputOption('with-packages', null, InputOption::VALUE_REQUIRED, 'add additional packages to install/build, comma separated', ''),
new InputOption('no-download', null, null, 'Skip downloading artifacts (use existing cached files)'),
...V2CompatLayer::getLegacyBuildOptions(),
]);
}
public function handle(): int
{
$libs = parse_comma_list($this->input->getArgument('libraries'));
$installer = new \StaticPHP\Package\PackageInstaller($this->input->getOptions());
$installer = new PackageInstaller($this->input->getOptions());
foreach ($libs as $lib) {
$installer->addBuildPackage($lib);
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command\Dev;
use StaticPHP\Command\BaseCommand;
use StaticPHP\Registry\Registry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Yaml\Yaml;
#[AsCommand('dev:lint-config', 'Lint configuration file format', ['dev:sort-config'])]
class LintConfigCommand extends BaseCommand
{
public function handle(): int
{
// get loaded configs
$loded_configs = Registry::getLoadedArtifactConfigs();
foreach ($loded_configs as $file) {
$this->sortConfigFile($file, 'artifact');
}
$loaded_pkg_configs = Registry::getLoadedPackageConfigs();
foreach ($loaded_pkg_configs as $file) {
$this->sortConfigFile($file, 'package');
}
return static::SUCCESS;
}
public function artifactSortKey(string $a, string $b): int
{
// sort by predefined order, other not matching keys go to the end alphabetically
$order = ['source', 'source-mirror', 'binary', 'binary-mirror', 'metadata'];
$pos_a = array_search($a, $order, true);
$pos_b = array_search($b, $order, true);
// Both in order list
if ($pos_a !== false && $pos_b !== false) {
return $pos_a <=> $pos_b;
}
// Only $a in order list
if ($pos_a !== false) {
return -1;
}
// Only $b in order list
if ($pos_b !== false) {
return 1;
}
// Neither in order list, sort alphabetically
return $a <=> $b;
}
public function packageSortKey(string $a, string $b): int
{
// sort by predefined order, other not matching keys go to the end alphabetically
$order = ['type', 'artifact', 'depends', 'suggests', 'frameworks'];
// Handle suffix patterns (e.g., 'depends@unix', 'static-libs@windows')
$base_a = preg_replace('/@(unix|windows|macos|linux|freebsd|bsd)$/', '', $a);
$base_b = preg_replace('/@(unix|windows|macos|linux|freebsd|bsd)$/', '', $b);
$pos_a = array_search($base_a, $order, true);
$pos_b = array_search($base_b, $order, true);
// Both in order list
if ($pos_a !== false && $pos_b !== false) {
if ($pos_a === $pos_b) {
// Same base field, sort by suffix
return $a <=> $b;
}
return $pos_a <=> $pos_b;
}
// Only $a in order list
if ($pos_a !== false) {
return -1;
}
// Only $b in order list
if ($pos_b !== false) {
return 1;
}
// Neither in order list, sort alphabetically
return $a <=> $b;
}
private function sortConfigFile(mixed $file, string $config_type): void
{
// read file content with different extensions
$content = file_get_contents($file);
if ($content === false) {
$this->output->writeln("Failed to read artifact config file: {$file}");
return;
}
$data = match (pathinfo($file, PATHINFO_EXTENSION)) {
'json' => json_decode($content, true),
'yml', 'yaml' => Yaml::parse($content), // skip yaml files for now
default => null,
};
if (!is_array($data)) {
$this->output->writeln("Invalid JSON format in artifact config file: {$file}");
return;
}
ksort($data);
foreach ($data as $artifact_name => &$config) {
uksort($config, $config_type === 'artifact' ? [$this, 'artifactSortKey'] : [$this, 'packageSortKey']);
}
unset($config);
$new_content = match (pathinfo($file, PATHINFO_EXTENSION)) {
'json' => json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n",
'yml', 'yaml' => Yaml::dump($data, 4, 2),
default => null,
};
file_put_contents($file, $new_content);
$this->output->writeln("Sorted artifact config file: {$file}");
}
}

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Command\Dev;
use StaticPHP\Command\BaseCommand;
use StaticPHP\Registry\Registry;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand('dev:sort-config', 'Sort artifact configuration files alphabetically')]
class SortConfigCommand extends BaseCommand
{
public function handle(): int
{
// get loaded configs
$loded_configs = Registry::getLoadedArtifactConfigs();
foreach ($loded_configs as $file) {
$this->sortConfigFile($file);
}
$loaded_pkg_configs = Registry::getLoadedPackageConfigs();
foreach ($loaded_pkg_configs as $file) {
$this->sortConfigFile($file);
}
return static::SUCCESS;
}
private function sortConfigFile(mixed $file): void
{
$content = file_get_contents($file);
if ($content === false) {
$this->output->writeln("Failed to read artifact config file: {$file}");
return;
}
$data = json_decode($content, true);
if (!is_array($data)) {
$this->output->writeln("Invalid JSON format in artifact config file: {$file}");
return;
}
ksort($data);
foreach ($data as $artifact_name => &$config) {
ksort($config);
}
unset($config);
$new_content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
file_put_contents($file, $new_content);
$this->output->writeln("Sorted artifact config file: {$file}");
}
}

View File

@@ -77,4 +77,19 @@ class ArtifactConfig
{
return self::$artifact_configs[$artifact_name] ?? null;
}
/**
* Register an inline artifact configuration.
* Used when artifact is defined inline within a package configuration.
*
* @param string $artifact_name Artifact name (usually same as package name)
* @param array $config Artifact configuration
* @param string $registry_name Registry name
* @param string $source_info Source info for debugging
*/
public static function registerInlineArtifact(string $artifact_name, array $config, string $registry_name, string $source_info = 'inline'): void
{
self::$artifact_configs[$artifact_name] = $config;
Registry::_bindArtifactConfigFile($artifact_name, $registry_name, $source_info);
}
}

View File

@@ -18,7 +18,7 @@ class ConfigValidator
'type' => ConfigType::STRING,
'depends' => ConfigType::LIST_ARRAY, // @
'suggests' => ConfigType::LIST_ARRAY, // @
'artifact' => ConfigType::STRING,
'artifact' => [self::class, 'validateArtifactField'], // STRING or OBJECT
'license' => [ConfigType::class, 'validateLicenseField'],
'lang' => ConfigType::STRING,
'frameworks' => ConfigType::LIST_ARRAY, // @
@@ -102,7 +102,14 @@ class ConfigValidator
if (!is_array($data)) {
throw new ValidationException("{$config_file_name} is broken");
}
// Define allowed artifact fields
$allowed_artifact_fields = ['source', 'source-mirror', 'binary', 'binary-mirror', 'metadata'];
foreach ($data as $name => $artifact) {
// First pass: validate unknown fields
self::validateNoInvalidFields('artifact', $name, $artifact, $allowed_artifact_fields);
foreach ($artifact as $k => $v) {
// check source field
if ($k === 'source' || $k === 'source-mirror') {
@@ -202,6 +209,11 @@ class ConfigValidator
throw new ValidationException("Package [{$name}] in {$config_file_name} of type '{$pkg['type']}' must have an 'artifact' field");
}
// validate and lint inline artifact object if present
if (isset($pkg['artifact']) && is_array($pkg['artifact'])) {
self::validateAndLintInlineArtifact($name, $data[$name]['artifact']);
}
// check if "php-extension" package has php-extension specific fields and validate inside
if ($pkg['type'] === 'php-extension') {
self::validatePhpExtensionFields($name, $pkg);
@@ -234,6 +246,19 @@ class ConfigValidator
}
}
/**
* Validate artifact field - can be string (reference) or object (inline).
*
* @param mixed $value Field value
*/
public static function validateArtifactField(mixed $value): bool
{
if (!is_string($value) && !is_assoc_array($value)) {
return false;
}
return true;
}
/**
* Validate an artifact download object field.
*
@@ -373,4 +398,19 @@ class ConfigValidator
}
}
}
/**
* Validate and lint inline artifact object structure.
*
* @param string $pkg_name Package name
* @param array $artifact Inline artifact configuration (passed by reference to apply linting)
*/
private static function validateAndLintInlineArtifact(string $pkg_name, array &$artifact): void
{
// Validate and lint as if it's a standalone artifact
$temp_data = [$pkg_name => $artifact];
self::validateAndLintArtifacts("inline artifact in package '{$pkg_name}'", $temp_data);
// Write back the linted artifact configuration
$artifact = $temp_data[$pkg_name];
}
}

View File

@@ -23,7 +23,7 @@ class PackageConfig
throw new WrongUsageException("Directory {$dir} does not exist, cannot load pkg.json config.");
}
$loaded = [];
$files = glob("{$dir}/pkg.*.json");
$files = glob("{$dir}/*");
if (is_array($files)) {
foreach ($files as $file) {
self::loadFromFile($file, $registry_name);
@@ -58,10 +58,39 @@ class PackageConfig
foreach ($data as $pkg_name => $config) {
self::$package_configs[$pkg_name] = $config;
Registry::_bindPackageConfigFile($pkg_name, $registry_name, $file);
// Register inline artifact if present
if (isset($config['artifact']) && is_array($config['artifact'])) {
ArtifactConfig::registerInlineArtifact(
$pkg_name,
$config['artifact'],
$registry_name,
"inline in {$file}"
);
}
}
return $file;
}
public static function loadFromArray(array $data, string $registry_name): void
{
ConfigValidator::validateAndLintPackages('array_input', $data);
foreach ($data as $pkg_name => $config) {
self::$package_configs[$pkg_name] = $config;
Registry::_bindPackageConfigFile($pkg_name, $registry_name, 'array_input');
// Register inline artifact if present
if (isset($config['artifact']) && is_array($config['artifact'])) {
ArtifactConfig::registerInlineArtifact(
$pkg_name,
$config['artifact'],
$registry_name,
'inline in array_input'
);
}
}
}
/**
* Check if a package configuration exists.
*/

View File

@@ -8,8 +8,8 @@ use StaticPHP\Command\BuildLibsCommand;
use StaticPHP\Command\BuildTargetCommand;
use StaticPHP\Command\Dev\EnvCommand;
use StaticPHP\Command\Dev\IsInstalledCommand;
use StaticPHP\Command\Dev\LintConfigCommand;
use StaticPHP\Command\Dev\ShellCommand;
use StaticPHP\Command\Dev\SortConfigCommand;
use StaticPHP\Command\DoctorCommand;
use StaticPHP\Command\DownloadCommand;
use StaticPHP\Command\DumpLicenseCommand;
@@ -62,7 +62,7 @@ class ConsoleApplication extends Application
new ShellCommand(),
new IsInstalledCommand(),
new EnvCommand(),
new SortConfigCommand(),
new LintConfigCommand(),
]);
// add additional commands from registries

View File

@@ -146,7 +146,8 @@ readonly class Doctor
foreach (DoctorLoader::getDoctorItems() as [$item, $optional]) {
/* @var CheckItem $item */
// optional check
if ($optional !== null && !call_user_func($optional)) {
/* @phpstan-ignore-next-line */
if (is_callable($optional) && !call_user_func($optional)) {
continue; // skip this when the optional check is false
}
// limit_os check

View File

@@ -27,25 +27,25 @@ class LinuxMuslCheck
public static function optionalCheck(): bool
{
$toolchain = ApplicationContext::get(ToolchainInterface::class);
return $toolchain instanceof MuslToolchain || $toolchain instanceof ZigToolchain && !LinuxUtil::isMuslDist();
return $toolchain instanceof MuslToolchain || $toolchain instanceof ZigToolchain && !LinuxUtil::isMuslDist() && !str_contains(getenv('SPC_TARGET') ?: '', 'gnu');
}
/** @noinspection PhpUnused */
#[CheckItem('if musl-wrapper is installed', limit_os: 'Linux', level: 800)]
public function checkMusl(): CheckResult
public function checkMusl(): ?CheckResult
{
$musl_wrapper_lib = sprintf('/lib/ld-musl-%s.so.1', php_uname('m'));
if (file_exists($musl_wrapper_lib) && (file_exists('/usr/local/musl/lib/libc.a') || getenv('SPC_TOOLCHAIN') === ZigToolchain::class)) {
return CheckResult::ok();
return null;
}
return CheckResult::fail('musl-wrapper is not installed on your system', 'fix-musl-wrapper');
}
#[CheckItem('if musl-cross-make is installed', limit_os: 'Linux', level: 799)]
public function checkMuslCrossMake(): CheckResult
public function checkMuslCrossMake(): ?CheckResult
{
if (getenv('SPC_TOOLCHAIN') === ZigToolchain::class && !LinuxUtil::isMuslDist()) {
return CheckResult::ok();
return null;
}
$arch = arch2gnu(php_uname('m'));
$cross_compile_lib = "/usr/local/musl/{$arch}-linux-musl/lib/libc.a";

View File

@@ -19,7 +19,6 @@ class LinuxToolCheck
'bzip2', 'cmake', 'gcc',
'g++', 'patch', 'binutils-gold',
'libtoolize', 'which',
'patchelf',
];
public const TOOLS_DEBIAN = [
@@ -28,7 +27,6 @@ class LinuxToolCheck
'tar', 'unzip', 'gzip', 'gcc', 'g++',
'bzip2', 'cmake', 'patch',
'xz', 'libtoolize', 'which',
'patchelf',
];
public const TOOLS_RHEL = [
@@ -36,8 +34,7 @@ class LinuxToolCheck
'git', 'autoconf', 'automake',
'tar', 'unzip', 'gzip', 'gcc', 'g++',
'bzip2', 'cmake', 'patch', 'which',
'xz', 'libtool', 'gettext-devel',
'patchelf', 'file',
'xz', 'libtool', 'gettext-devel', 'file',
];
public const TOOLS_ARCH = [

View File

@@ -175,8 +175,21 @@ abstract class Package
public function getArtifact(): ?Artifact
{
// find config
$artifact_name = PackageConfig::get($this->name, 'artifact');
return $artifact_name !== null ? ArtifactLoader::getArtifactInstance($artifact_name) : null;
$artifact_field = PackageConfig::get($this->name, 'artifact');
if ($artifact_field === null) {
return null;
}
if (is_string($artifact_field)) {
return ArtifactLoader::getArtifactInstance($artifact_field);
}
if (is_array($artifact_field)) {
return ArtifactLoader::getArtifactInstance($this->name);
}
return null;
}
/**
@@ -199,6 +212,19 @@ abstract class Package
throw new SPCInternalException("Source directory for package {$this->name} is not available because the source artifact is missing.");
}
/**
* Get source build root directory.
* It's only worked when 'source-root' is defined in artifact config.
* Normally it's equal to source dir.
*/
public function getSourceRoot(): string
{
if (($artifact = $this->getArtifact()) && $artifact->hasSource()) {
return $artifact->getSourceRoot();
}
throw new SPCInternalException("Source root for package {$this->name} is not available because the source artifact is missing.");
}
/**
* Check if the package has a binary available for current OS and architecture.
*/

View File

@@ -16,7 +16,7 @@ trait PackageCallbacksTrait
protected mixed $validate_callback = null;
protected mixed $patch_before_build_callback = null;
protected mixed $patch_before_build_callbacks = null;
public function setInfoCallback(callable $callback): void
{
@@ -48,9 +48,9 @@ trait PackageCallbacksTrait
$this->validate_callback = $callback;
}
public function setPatchBeforeBuildCallback(callable $callback): void
public function addPatchBeforeBuildCallback(callable $callback): void
{
$this->patch_before_build_callback = $callback;
$this->patch_before_build_callbacks[] = $callback;
}
public function patchBeforeBuild(): void
@@ -58,16 +58,18 @@ trait PackageCallbacksTrait
if (file_exists("{$this->getSourceDir()}/.spc-patched")) {
return;
}
if ($this->patch_before_build_callback === null) {
if ($this->patch_before_build_callbacks === null) {
return;
}
// Use CallbackInvoker with current package as context
$ret = ApplicationContext::invoke($this->patch_before_build_callback, [
Package::class => $this,
static::class => $this,
]);
if ($ret === true) {
FileSystem::writeFile("{$this->getSourceDir()}/.spc-patched", 'PATCHED!!!');
foreach ($this->patch_before_build_callbacks as $callback) {
$ret = ApplicationContext::invoke($callback, [
Package::class => $this,
static::class => $this,
]);
if ($ret === true) {
FileSystem::writeFile("{$this->getSourceDir()}/.spc-patched", 'PATCHED!!!');
}
}
}

View File

@@ -17,6 +17,7 @@ use StaticPHP\Util\DependencyResolver;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\GlobalEnvManager;
use StaticPHP\Util\InteractiveTerm;
use StaticPHP\Util\LicenseDumper;
use StaticPHP\Util\V2CompatLayer;
use ZM\Logger\ConsoleColor;
@@ -208,6 +209,11 @@ class PackageInstaller
}
}
}
$this->dumpLicenseFiles($this->packages);
if ($interactive) {
InteractiveTerm::success('Exported package licenses', true);
}
}
public function isBuildPackage(Package|string $package): bool
@@ -253,7 +259,7 @@ class PackageInstaller
if ($this->isBuildPackage($package)) {
return $package->isInstalled();
}
if ($package instanceof LibraryPackage && $package->getArtifact()->shouldUseBinary()) {
if ($package->getArtifact() !== null && $package->getArtifact()->shouldUseBinary()) {
$artifact = $package->getArtifact();
return $artifact->isBinaryExtracted();
}
@@ -460,6 +466,21 @@ class PackageInstaller
return null;
}
/**
* @param Package[] $packages
*/
private function dumpLicenseFiles(array $packages): void
{
$dumper = new LicenseDumper();
foreach ($packages as $package) {
$artifact = $package->getArtifact();
if ($artifact !== null) {
$dumper->addArtifacts([$artifact->getName()]);
}
}
$dumper->dump(BUILD_ROOT_PATH . '/license');
}
/**
* Validate that a package has required artifacts.
*/

View File

@@ -12,6 +12,7 @@ use StaticPHP\Attribute\Package\Extension;
use StaticPHP\Attribute\Package\Info;
use StaticPHP\Attribute\Package\InitPackage;
use StaticPHP\Attribute\Package\Library;
use StaticPHP\Attribute\Package\PatchBeforeBuild;
use StaticPHP\Attribute\Package\ResolveBuild;
use StaticPHP\Attribute\Package\Stage;
use StaticPHP\Attribute\Package\Target;
@@ -168,7 +169,7 @@ class PackageLoader
}
}
$pkg = self::$packages[$attribute_instance->name];
$pkg = self::$packages[$attribute_instance->name] ?? null;
// Use the package instance if it's a Package subclass, otherwise create a new instance
$instance_class = is_a($class_name, Package::class, true) ? $pkg : $refClass->newInstance();
@@ -183,7 +184,7 @@ class PackageLoader
if (!in_array($package_type, $pkg_type_attr, true)) {
throw new RegistryException("Package [{$attribute_instance->name}] type mismatch: config type is [{$package_type}], but attribute type is [" . implode('|', $pkg_type_attr) . '].');
}
if ($pkg !== null && !PackageConfig::isPackageExists($pkg->getName())) {
if ($pkg instanceof Package && !PackageConfig::isPackageExists($pkg->getName())) {
throw new RegistryException("Package [{$pkg->getName()}] config not found for class {$class}");
}
@@ -196,6 +197,8 @@ class PackageLoader
match ($method_attribute->getName()) {
// #[BuildFor(PHP_OS_FAMILY)]
BuildFor::class => self::addBuildFunction($pkg, $method_instance, [$instance_class, $method->getName()]),
// #[BeforeBuild]
PatchBeforeBuild::class => self::addPatchBeforeBuildFunction($pkg, [$instance_class, $method->getName()]),
// #[CustomPhpConfigureArg(PHP_OS_FAMILY)]
CustomPhpConfigureArg::class => self::bindCustomPhpConfigureArg($pkg, $method_attribute->newInstance(), [$instance_class, $method->getName()]),
// #[Stage('stage_name')]
@@ -332,6 +335,11 @@ class PackageLoader
$pkg->addBuildFunction($attr->os, $fn);
}
private static function addPatchBeforeBuildFunction(Package $pkg, callable $fn): void
{
$pkg->addPatchBeforeBuildCallback($fn);
}
private static function addStage(\ReflectionMethod $method, Package $pkg, object $instance_class, object $method_instance): void
{
$name = $method_instance->function;
@@ -347,7 +355,7 @@ class PackageLoader
$stage = $method_instance->stage;
$stage = match (true) {
is_string($stage) => $stage,
is_array($stage) && count($stage) === 2 => $stage[1],
count($stage) === 2 => $stage[1],
default => throw new RegistryException('Invalid stage definition in BeforeStage attribute.'),
};
if ($method_instance->package_name === '' && $pkg === null) {

View File

@@ -13,6 +13,8 @@ use Symfony\Component\Yaml\Yaml;
class Registry
{
private static ?string $current_registry_name = null;
/** @var string[] List of loaded registries */
private static array $loaded_registries = [];
@@ -35,7 +37,7 @@ class Registry
public static function getRegistryConfig(?string $registry_name = null): array
{
if ($registry_name === null && spc_mode(SPC_MODE_SOURCE)) {
return self::$registry_configs['internal'];
return self::$registry_configs['core'];
}
if ($registry_name !== null && isset(self::$registry_configs[$registry_name])) {
return self::$registry_configs[$registry_name];
@@ -83,6 +85,8 @@ class Registry
logger()->debug("Loading registry '{$registry_name}' from file: {$registry_file}");
self::$current_registry_name = $registry_name;
// Load composer autoload if specified (for external registries with their own dependencies)
if (isset($data['autoload']) && is_string($data['autoload'])) {
$autoload_path = FileSystem::fullpath($data['autoload'], dirname($registry_file));
@@ -94,24 +98,6 @@ class Registry
}
}
// load doctor items from PSR-4 directories
if (isset($data['doctor']['psr-4']) && is_assoc_array($data['doctor']['psr-4'])) {
foreach ($data['doctor']['psr-4'] as $namespace => $path) {
$path = FileSystem::fullpath($path, dirname($registry_file));
DoctorLoader::loadFromPsr4Dir($path, $namespace, $auto_require);
}
}
// load doctor items from specific classes
// Supports both array format ["ClassName"] and map format {"ClassName": "path/to/file.php"}
if (isset($data['doctor']['classes']) && is_array($data['doctor']['classes'])) {
foreach ($data['doctor']['classes'] as $key => $value) {
[$class, $file] = self::parseClassEntry($key, $value);
self::requireClassFile($class, $file, dirname($registry_file), $auto_require);
DoctorLoader::loadFromClass($class);
}
}
// load package configs
if (isset($data['package']['config']) && is_array($data['package']['config'])) {
foreach ($data['package']['config'] as $path) {
@@ -136,6 +122,24 @@ class Registry
}
}
// load doctor items from PSR-4 directories
if (isset($data['doctor']['psr-4']) && is_assoc_array($data['doctor']['psr-4'])) {
foreach ($data['doctor']['psr-4'] as $namespace => $path) {
$path = FileSystem::fullpath($path, dirname($registry_file));
DoctorLoader::loadFromPsr4Dir($path, $namespace, $auto_require);
}
}
// load doctor items from specific classes
// Supports both array format ["ClassName"] and map format {"ClassName": "path/to/file.php"}
if (isset($data['doctor']['classes']) && is_array($data['doctor']['classes'])) {
foreach ($data['doctor']['classes'] as $key => $value) {
[$class, $file] = self::parseClassEntry($key, $value);
self::requireClassFile($class, $file, dirname($registry_file), $auto_require);
DoctorLoader::loadFromClass($class);
}
}
// load packages from PSR-4 directories
if (isset($data['package']['psr-4']) && is_assoc_array($data['package']['psr-4'])) {
foreach ($data['package']['psr-4'] as $namespace => $path) {
@@ -193,6 +197,7 @@ class Registry
}
ConsoleApplication::_addAdditionalCommands($instances);
}
self::$current_registry_name = null;
}
/**
@@ -300,6 +305,11 @@ class Registry
return self::$loaded_artifact_configs;
}
public static function getCurrentRegistryName(): ?string
{
return self::$current_registry_name;
}
/**
* Parse a class entry from the classes array.
* Supports two formats:

View File

@@ -169,7 +169,7 @@ class UnixAutoconfExecutor extends Executor
*/
private function initShell(): void
{
$this->shell = shell()->cd($this->package->getSourceDir())->initializeEnv($this->package)->appendEnv([
$this->shell = shell()->cd($this->package->getSourceRoot())->initializeEnv($this->package)->appendEnv([
'CFLAGS' => "-I{$this->package->getIncludeDir()}",
'CXXFLAGS' => "-I{$this->package->getIncludeDir()}",
'LDFLAGS' => "-L{$this->package->getLibDir()}",
@@ -185,12 +185,12 @@ class UnixAutoconfExecutor extends Executor
$callable();
return $this;
} catch (SPCException $e) {
if (file_exists("{$this->package->getSourceDir()}/config.log")) {
logger()->debug("Config log file found: {$this->package->getSourceDir()}/config.log");
if (file_exists("{$this->package->getSourceRoot()}/config.log")) {
logger()->debug("Config log file found: {$this->package->getSourceRoot()}/config.log");
$log_file = "lib.{$this->package->getName()}.console.log";
logger()->debug('Saved config log file to: ' . SPC_LOGS_DIR . "/{$log_file}");
$e->addExtraLogFile("{$this->package->getName()} library config.log", $log_file);
copy("{$this->package->getSourceDir()}/config.log", SPC_LOGS_DIR . "/{$log_file}");
copy("{$this->package->getSourceRoot()}/config.log", SPC_LOGS_DIR . "/{$log_file}");
}
throw $e;
}

View File

@@ -233,7 +233,7 @@ class UnixCMakeExecutor extends Executor
private function initBuildDir(): void
{
if ($this->build_dir === null) {
$this->build_dir = "{$this->package->getSourceDir()}/build";
$this->build_dir = "{$this->package->getSourceRoot()}/build";
}
}
@@ -295,7 +295,7 @@ CMAKE;
*/
private function initShell(): void
{
$this->shell = shell()->cd($this->package->getSourceDir())->initializeEnv($this->package)->appendEnv([
$this->shell = shell()->cd($this->package->getSourceRoot())->initializeEnv($this->package)->appendEnv([
'CFLAGS' => "-I{$this->package->getIncludeDir()}",
'CXXFLAGS' => "-I{$this->package->getIncludeDir()}",
'LDFLAGS' => "-L{$this->package->getLibDir()}",

View File

@@ -18,10 +18,10 @@ class ClangNativeToolchain implements UnixToolchainInterface
{
public function initEnv(): void
{
GlobalEnvManager::putenv('SPC_LINUX_DEFAULT_CC=clang');
GlobalEnvManager::putenv('SPC_LINUX_DEFAULT_CXX=clang++');
GlobalEnvManager::putenv('SPC_LINUX_DEFAULT_AR=ar');
GlobalEnvManager::putenv('SPC_LINUX_DEFAULT_LD=ld');
GlobalEnvManager::putenv('SPC_DEFAULT_CC=clang');
GlobalEnvManager::putenv('SPC_DEFAULT_CXX=clang++');
GlobalEnvManager::putenv('SPC_DEFAULT_AR=ar');
GlobalEnvManager::putenv('SPC_DEFAULT_LD=ld');
}
public function afterInit(): void
@@ -52,6 +52,6 @@ class ClangNativeToolchain implements UnixToolchainInterface
public function isStatic(): bool
{
return PHP_OS_FAMILY === 'Linux' && LinuxUtil::isMuslDist();
return PHP_OS_FAMILY === 'Linux' && LinuxUtil::isMuslDist() && !getenv('SPC_MUSL_DYNAMIC');
}
}

View File

@@ -15,10 +15,10 @@ class GccNativeToolchain implements UnixToolchainInterface
{
public function initEnv(): void
{
GlobalEnvManager::putenv('SPC_LINUX_DEFAULT_CC=gcc');
GlobalEnvManager::putenv('SPC_LINUX_DEFAULT_CXX=g++');
GlobalEnvManager::putenv('SPC_LINUX_DEFAULT_AR=ar');
GlobalEnvManager::putenv('SPC_LINUX_DEFAULT_LD=ld');
GlobalEnvManager::putenv('SPC_DEFAULT_CC=gcc');
GlobalEnvManager::putenv('SPC_DEFAULT_CXX=g++');
GlobalEnvManager::putenv('SPC_DEFAULT_AR=ar');
GlobalEnvManager::putenv('SPC_DEFAULT_LD=ld');
}
public function afterInit(): void
@@ -49,6 +49,6 @@ class GccNativeToolchain implements UnixToolchainInterface
public function isStatic(): bool
{
return PHP_OS_FAMILY === 'Linux' && LinuxUtil::isMuslDist();
return PHP_OS_FAMILY === 'Linux' && LinuxUtil::isMuslDist() && !getenv('SPC_MUSL_DYNAMIC');
}
}

View File

@@ -14,10 +14,10 @@ class MuslToolchain implements UnixToolchainInterface
{
$arch = getenv('GNU_ARCH');
// Set environment variables for musl toolchain
GlobalEnvManager::putenv("SPC_LINUX_DEFAULT_CC={$arch}-linux-musl-gcc");
GlobalEnvManager::putenv("SPC_LINUX_DEFAULT_CXX={$arch}-linux-musl-g++");
GlobalEnvManager::putenv("SPC_LINUX_DEFAULT_AR={$arch}-linux-musl-ar");
GlobalEnvManager::putenv("SPC_LINUX_DEFAULT_LD={$arch}-linux-musl-ld");
GlobalEnvManager::putenv("SPC_DEFAULT_CC={$arch}-linux-musl-gcc");
GlobalEnvManager::putenv("SPC_DEFAULT_CXX={$arch}-linux-musl-g++");
GlobalEnvManager::putenv("SPC_DEFAULT_AR={$arch}-linux-musl-ar");
GlobalEnvManager::putenv("SPC_DEFAULT_LD={$arch}-linux-musl-ld");
GlobalEnvManager::addPathIfNotExists('/usr/local/musl/bin');
GlobalEnvManager::addPathIfNotExists("/usr/local/musl/{$arch}-linux-musl/bin");
@@ -40,7 +40,7 @@ class MuslToolchain implements UnixToolchainInterface
public function getCompilerInfo(): ?string
{
$compiler = getenv('CC') ?: getenv('SPC_LINUX_DEFAULT_CC');
$compiler = getenv('CC') ?: getenv('SPC_DEFAULT_CC');
$version = shell(false)->execWithResult("{$compiler} --version", false);
$head = pathinfo($compiler, PATHINFO_BASENAME);
if ($version[0] === 0 && preg_match('/linux-musl-cc.*(\d+.\d+.\d+)/', $version[1][0], $match)) {

View File

@@ -55,6 +55,58 @@ class DependencyResolver
return $resolved;
}
/**
* Get all dependencies of a specific package within a resolved package set.
* This is useful when you need to get build flags for a specific library and its deps.
*
* The method will only include dependencies that exist in the resolved set,
* which properly handles optional dependencies (suggests) - only those that
* were actually resolved will be included.
*
* @param string $package_name The package to get dependencies for
* @param string[] $resolved_packages The resolved package list (from resolve())
* @param bool $include_suggests Whether to include suggests that are in resolved set
* @return string[] Dependencies of the package (NOT including itself), ordered for building
*/
public static function getSubDependencies(string $package_name, array $resolved_packages, bool $include_suggests = false): array
{
// Create a lookup set for O(1) membership check
$resolved_set = array_flip($resolved_packages);
// Verify the target package is in the resolved set
if (!isset($resolved_set[$package_name])) {
return [];
}
// Build dependency map from config
$dep_map = [];
foreach ($resolved_packages as $pkg) {
$dep_map[$pkg] = [
'depends' => PackageConfig::get($pkg, 'depends', []),
'suggests' => PackageConfig::get($pkg, 'suggests', []),
];
}
// Collect all sub-dependencies recursively (excluding the package itself)
$visited = [];
$sorted = [];
// Get dependencies to process for the target package
$deps = $dep_map[$package_name]['depends'] ?? [];
if ($include_suggests) {
$deps = array_merge($deps, $dep_map[$package_name]['suggests'] ?? []);
}
// Only visit dependencies that are in the resolved set
foreach ($deps as $dep) {
if (isset($resolved_set[$dep])) {
self::visitSubDeps($dep, $dep_map, $resolved_set, $include_suggests, $visited, $sorted);
}
}
return $sorted;
}
/**
* Build a reverse dependency map for the resolved packages.
* For each package that is depended upon, list which packages depend on it.
@@ -89,6 +141,39 @@ class DependencyResolver
return $why;
}
/**
* Recursive helper for getSubDependencies.
* Visits dependencies in topological order (dependencies first).
*/
private static function visitSubDeps(
string $pkg_name,
array $dep_map,
array $resolved_set,
bool $include_suggests,
array &$visited,
array &$sorted
): void {
if (isset($visited[$pkg_name])) {
return;
}
$visited[$pkg_name] = true;
// Get dependencies to process
$deps = $dep_map[$pkg_name]['depends'] ?? [];
if ($include_suggests) {
$deps = array_merge($deps, $dep_map[$pkg_name]['suggests'] ?? []);
}
// Only visit dependencies that are in the resolved set
foreach ($deps as $dep) {
if (isset($resolved_set[$dep])) {
self::visitSubDeps($dep, $dep_map, $resolved_set, $include_suggests, $visited, $sorted);
}
}
$sorted[] = $pkg_name;
}
/**
* Visitor pattern implementation for dependency resolution.
*

View File

@@ -75,7 +75,7 @@ class LicenseDumper
*
* @param Artifact $artifact Artifact instance
* @param string $target_dir Target directory
* @param array<string, array> &$license_summary Summary data to populate
* @param array &$license_summary Summary data to populate
* @return bool True if dumped
* @throws SPCInternalException
*/
@@ -163,18 +163,26 @@ class LicenseDumper
{
$artifact_name = $artifact->getName();
// Try source directory first (if extracted)
if ($artifact->isSourceExtracted()) {
$source_dir = $artifact->getSourceDir();
$full_path = "{$source_dir}/{$license_file_path}";
// replace
if (str_starts_with($license_file_path, '@/')) {
$license_file_path = str_replace('@/', ROOT_DIR . '/src/globals/licenses/', $license_file_path);
}
$source_dir = $artifact->getSourceDir();
if (FileSystem::isRelativePath($license_file_path)) {
$full_path = "{$source_dir}/{$license_file_path}";
} else {
$full_path = $license_file_path;
}
// Try source directory first (if extracted)
if ($artifact->isSourceExtracted() || file_exists($full_path)) {
logger()->debug("Checking license file: {$full_path}");
if (file_exists($full_path)) {
logger()->info("Reading license from source: {$full_path}");
return file_get_contents($full_path);
}
} else {
logger()->debug("Artifact source not extracted: {$artifact_name}");
logger()->warning("Artifact source not extracted: {$artifact_name}");
}
// Fallback: try SOURCE_PATH directly

View File

@@ -28,7 +28,12 @@ class PkgConfigUtil
];
$found = null;
foreach ($find_list as $file) {
if (file_exists($file) && is_executable($file)) {
$exists = file_exists($file);
$executable = is_executable($file);
if (!$exists) {
continue;
}
if (!$executable && chmod($file, 0755) || $executable) {
$found = $file;
break;
}

View File

@@ -149,6 +149,117 @@ class SPCConfigUtil
return $ret;
}
/**
* Get build configuration for a package and its sub-dependencies within a resolved set.
*
* This is useful when you need to statically link something against a specific
* library and all its transitive dependencies. It properly handles optional
* dependencies by only including those that were actually resolved.
*
* @param string $package_name The package to get config for
* @param string[] $resolved_packages The full resolved package list
* @param bool $include_suggests Whether to include resolved suggests
* @return array{
* cflags: string,
* ldflags: string,
* libs: string
* }
*/
public function getPackageDepsConfig(string $package_name, array $resolved_packages, bool $include_suggests = false): array
{
// Get sub-dependencies within the resolved set
$sub_deps = DependencyResolver::getSubDependencies($package_name, $resolved_packages, $include_suggests);
if (empty($sub_deps)) {
return [
'cflags' => '',
'ldflags' => '',
'libs' => '',
];
}
// Use libs_only_deps mode and no_php for library linking
$save_no_php = $this->no_php;
$save_libs_only_deps = $this->libs_only_deps;
$this->no_php = true;
$this->libs_only_deps = true;
$ret = $this->configWithResolvedPackages($sub_deps);
$this->no_php = $save_no_php;
$this->libs_only_deps = $save_libs_only_deps;
return $ret;
}
/**
* Get configuration using already-resolved packages (skip dependency resolution).
*
* @param string[] $resolved_packages Already resolved package names in build order
* @return array{
* cflags: string,
* ldflags: string,
* libs: string
* }
*/
public function configWithResolvedPackages(array $resolved_packages): array
{
$ldflags = $this->getLdflagsString();
$cflags = $this->getIncludesString($resolved_packages);
$libs = $this->getLibsString($resolved_packages, !$this->absolute_libs);
// additional OS-specific libraries (e.g. macOS -lresolv)
if ($extra_libs = SystemTarget::getRuntimeLibs()) {
$libs .= " {$extra_libs}";
}
$extra_env = getenv('SPC_EXTRA_LIBS');
if (is_string($extra_env) && !empty($extra_env)) {
$libs .= " {$extra_env}";
}
// package frameworks
if (SystemTarget::getTargetOS() === 'Darwin') {
$libs .= " {$this->getFrameworksString($resolved_packages)}";
}
// C++
if ($this->hasCpp($resolved_packages)) {
$libcpp = SystemTarget::getTargetOS() === 'Darwin' ? '-lc++' : '-lstdc++';
$libs = str_replace($libcpp, '', $libs) . " {$libcpp}";
}
if ($this->libs_only_deps) {
// mimalloc must come first
if (in_array('mimalloc', $resolved_packages) && file_exists(BUILD_LIB_PATH . '/libmimalloc.a')) {
$libs = BUILD_LIB_PATH . '/libmimalloc.a ' . str_replace([BUILD_LIB_PATH . '/libmimalloc.a', '-lmimalloc'], ['', ''], $libs);
}
return [
'cflags' => clean_spaces(getenv('CFLAGS') . ' ' . $cflags),
'ldflags' => clean_spaces(getenv('LDFLAGS') . ' ' . $ldflags),
'libs' => clean_spaces(getenv('LIBS') . ' ' . $libs),
];
}
// embed
if (!$this->no_php) {
$libs = "-lphp {$libs} -lc";
}
$allLibs = getenv('LIBS') . ' ' . $libs;
// mimalloc must come first
if (in_array('mimalloc', $resolved_packages) && file_exists(BUILD_LIB_PATH . '/libmimalloc.a')) {
$allLibs = BUILD_LIB_PATH . '/libmimalloc.a ' . str_replace([BUILD_LIB_PATH . '/libmimalloc.a', '-lmimalloc'], ['', ''], $allLibs);
}
return [
'cflags' => clean_spaces(getenv('CFLAGS') . ' ' . $cflags),
'ldflags' => clean_spaces(getenv('LDFLAGS') . ' ' . $ldflags),
'libs' => clean_spaces($allLibs),
];
}
private function hasCpp(array $packages): bool
{
foreach ($packages as $package) {

View File

@@ -44,7 +44,7 @@ abstract class UnixUtil
continue;
}
$name = preg_replace('/@.*$/', '', $name);
if ($name !== '' && $name !== false) {
if (!empty($name)) {
$defined[] = $name;
}
}