[v3] implement tool package support with validation and config (#1196)

This commit is contained in:
Jerry Ma
2026-06-27 20:17:53 +08:00
committed by GitHub
9 changed files with 326 additions and 5 deletions

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Attribute\Package;
/**
* Indicates that the annotated class defines a tool package.
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
readonly class Tool
{
public function __construct(public string $name) {}
}

View File

@@ -19,6 +19,7 @@ enum ConfigType
'php-extension',
'target',
'virtual-target',
'tool',
];
public static function validateLicenseField(mixed $value): bool

View File

@@ -44,6 +44,13 @@ class ConfigValidator
'path' => ConfigType::LIST_ARRAY, // @
'env' => ConfigType::ASSOC_ARRAY, // @
'append-env' => ConfigType::ASSOC_ARRAY, // @
// tool type fields (nested under 'tool' key)
'tool' => ConfigType::ASSOC_ARRAY,
'provides' => ConfigType::LIST_ARRAY,
'binary-subdir' => ConfigType::STRING,
'install-root' => ConfigType::STRING,
'min-version' => ConfigType::STRING,
];
public const array PACKAGE_FIELDS = [
@@ -67,6 +74,9 @@ class ConfigValidator
'path' => false, // @
'env' => false, // @
'append-env' => false, // @
// tool fields (nested object)
'tool' => false,
];
public const array SUFFIX_ALLOWED_FIELDS = [
@@ -78,6 +88,7 @@ class ConfigValidator
'path',
'env',
'append-env',
'tools',
];
public const array PHP_EXTENSION_FIELDS = [
@@ -92,6 +103,13 @@ class ConfigValidator
'os' => false,
];
public const array TOOL_FIELDS = [
'provides' => true,
'binary-subdir' => false,
'install-root' => false,
'min-version' => false,
];
public const array ARTIFACT_TYPE_FIELDS = [ // [required_fields, optional_fields]
'filelist' => [['url', 'regex'], ['extract']],
'git' => [['url'], ['extract', 'submodules', 'rev', 'regex']],
@@ -220,8 +238,8 @@ class ConfigValidator
$fields = self::SUFFIX_ALLOWED_FIELDS;
self::validateSuffixAllowedFields($name, $pkg, $fields, $suffixes);
// check if "library|target" package has artifact field for target and library types
if (in_array($pkg['type'], ['target', 'library']) && !isset($pkg['artifact'])) {
// check if "library|target|tool" package has artifact field
if (in_array($pkg['type'], ['target', 'library', 'tool']) && !isset($pkg['artifact'])) {
throw new ValidationException("Package [{$name}] in {$config_file_name} of type '{$pkg['type']}' must have an 'artifact' field");
}
@@ -235,6 +253,11 @@ class ConfigValidator
self::validatePhpExtensionFields($name, $pkg);
}
// check if "tool" package has tool specific fields and validate inside
if ($pkg['type'] === 'tool') {
self::validateToolFields($name, $pkg);
}
// check for unknown fields
self::validateNoInvalidFields('package', $name, $pkg, array_keys(self::PACKAGE_FIELD_TYPES));
}
@@ -397,6 +420,29 @@ class ConfigValidator
self::validateNoInvalidFields('php-extension', $name, $pkg['php-extension'], array_keys(self::PHP_EXTENSION_FIELDS));
}
/**
* Validate tool specific fields for tool package type.
*/
private static function validateToolFields(int|string $name, mixed $pkg): void
{
if (!isset($pkg['tool'])) {
throw new ValidationException("Package {$name} of type 'tool' must have a 'tool' field");
}
if (!is_assoc_array($pkg['tool'])) {
throw new ValidationException("Package {$name} [tool] must be an object");
}
foreach (self::TOOL_FIELDS as $field => $required) {
if ($required && !isset($pkg['tool'][$field])) {
throw new ValidationException("Package {$name} [tool] must have required field [{$field}]");
}
if (isset($pkg['tool'][$field])) {
self::validatePackageFieldType($field, $pkg['tool'][$field], $name);
}
}
// check for unknown fields in tool
self::validateNoInvalidFields('tool', $name, $pkg['tool'], array_keys(self::TOOL_FIELDS));
}
private static function validateNoInvalidFields(string $config_type, int|string $item_name, mixed $item_content, array $allowed_fields): void
{
foreach ($item_content as $k => $v) {

View File

@@ -16,7 +16,7 @@ class PackageConfig
/**
* Load package configurations from a specified directory.
* It will look for files matching the pattern 'pkg.*.json' and 'pkg.json'.
* Only processes .json, .yml, and .yaml files (skips .gitkeep etc.).
*/
public static function loadFromDir(string $dir, string $registry_name): array
{
@@ -28,6 +28,10 @@ class PackageConfig
$files = FileSystem::scanDirFiles($dir, false);
if (is_array($files)) {
foreach ($files as $file) {
$ext = pathinfo($file, PATHINFO_EXTENSION);
if (!in_array($ext, ['json', 'yml', 'yaml'], true)) {
continue;
}
self::loadFromFile($file, $registry_name);
$loaded[] = $file;
}
@@ -46,7 +50,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

@@ -120,6 +120,20 @@ abstract class Package
return false;
}
/**
* Get the target directory where this package's artifacts should be placed.
*
* Libraries install to BUILD_ROOT_PATH (static-libs, headers, pkg-configs).
* Tools install to PKG_ROOT_PATH (executables).
* Extensions install to php-src/ext/ (shared objects).
*
* Override in subclasses to change the default.
*/
public function getInstallTarget(): string
{
return BUILD_ROOT_PATH;
}
/**
* Add a stage to the package.
*/

View File

@@ -11,6 +11,7 @@ use StaticPHP\Artifact\ArtifactExtractor;
use StaticPHP\Artifact\DownloaderOptions;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
use StaticPHP\Exception\EnvironmentException;
use StaticPHP\Exception\WrongUsageException;
use StaticPHP\Registry\PackageLoader;
use StaticPHP\Runtime\SystemTarget;
@@ -167,6 +168,9 @@ class PackageInstaller
// Early validation: check if packages can be built or installed before downloading
$this->validatePackagesBeforeBuild();
// Check that all required tools are installed before proceeding
$this->ensureRequiredTools();
// check download
if ($this->download) {
$downloaderOptions = DownloaderOptions::extractFromConsoleOptions($this->options, 'dl');
@@ -574,6 +578,66 @@ class PackageInstaller
return null;
}
/**
* Collect all tool packages required by the currently resolved packages.
*
* Reads the 'tools' field from each resolved package's YAML config.
* The field supports platform suffixes (tools@windows, tools@linux, etc.)
* resolved automatically by PackageConfig::get().
*
* Tools are NOT part of the library dependency graph — they are
* build-time prerequisites that must be installed before any library
* build begins.
*
* @return string[] Unique tool package names required for this build
*/
public function collectRequiredTools(): array
{
$tools = [];
foreach ($this->packages as $package) {
$deps = PackageConfig::get($package->getName(), 'tools', []);
foreach ((array) $deps as $tool_name) {
$tools[$tool_name] = true;
}
}
return array_keys($tools);
}
/**
* Check that all required tools are installed.
*
* Iterates through tools collected by collectRequiredTools(),
* resolves each to a ToolPackage instance, and checks isInstalled().
*
* @return array{missing: array<string>, installed: array<string>}
*/
public function checkRequiredTools(): array
{
$missing = [];
$installed = [];
foreach ($this->collectRequiredTools() as $tool_name) {
try {
$tool = PackageLoader::getPackage($tool_name);
} catch (WrongUsageException) {
$missing[] = $tool_name;
logger()->warning("Required tool '{$tool_name}' is not registered as a package.");
continue;
}
if (!$tool instanceof ToolPackage) {
logger()->warning("Package '{$tool_name}' is declared as a tool dependency but is not a ToolPackage (type: {$tool->getType()}).");
continue;
}
if ($tool->isInstalled()) {
$installed[] = $tool_name;
} else {
$missing[] = $tool_name;
}
}
return ['missing' => $missing, 'installed' => $installed];
}
/**
* @param Package[] $packages
*/
@@ -636,6 +700,27 @@ class PackageInstaller
}
}
/**
* Ensure all required tools are installed, throwing if any are missing.
*
* Called early in the build pipeline (before download/extract).
* When tools are missing, lists them with install hints.
*/
private function ensureRequiredTools(): void
{
$status = $this->checkRequiredTools();
if (empty($status['missing'])) {
if (!empty($status['installed'])) {
logger()->info('Required tools: ' . implode(', ', $status['installed']) . ' — all installed.');
}
return;
}
$msg = 'Missing required build tools: ' . implode(', ', $status['missing']) . "\n";
$msg .= "Run 'bin/spc doctor' to check your environment, or install the missing tools manually.";
throw new EnvironmentException($msg);
}
private function injectPackageEnvs(Package $package): void
{
$name = $package->getName();

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace StaticPHP\Package;
use StaticPHP\Config\PackageConfig;
use StaticPHP\Util\FileSystem;
use StaticPHP\Util\GlobalPathTrait;
/**
* Represents a build-time tool package.
*
* Tool packages are NOT link-time dependencies. They provide executables
* that are needed during the build process (compilers, code generators,
* assemblers, etc.) and are installed into PKG_ROOT_PATH.
*
* Tool packages do NOT produce static-libs, headers, or pkg-config files.
* They are resolved and installed independently from the library dependency graph.
*
* YAML config schema (config/pkg/tool/<name>.yml):
*
* nasm:
* type: tool
* tool:
* provides: [nasm.exe, ndisasm.exe] # executables this tool installs
* binary-subdir: '' # subdirectory under install root (default: '')
* min-version: '2.16' # minimum required version (optional)
* artifact:
* binary:
* windows-x86_64:
* type: url
* url: 'https://...'
* extract:
* nasm.exe: '{php_sdk_path}/bin/nasm.exe'
*/
class ToolPackage extends Package
{
use GlobalPathTrait;
/**
* Get the install root directory for this tool.
*
* Defaults to PKG_ROOT_PATH. Override via 'tool.install-root' in YAML
* or via the TOOL_INSTALL_ROOT_{NAME} environment variable.
*/
public function getInstallRoot(): string
{
$env_var = 'TOOL_INSTALL_ROOT_' . strtoupper(str_replace('-', '_', $this->name));
if ($root = getenv($env_var)) {
return $root;
}
$config_root = $this->getToolConfig()['install-root'] ?? null;
if ($config_root !== null) {
return FileSystem::replacePathVariable((string) $config_root);
}
return PKG_ROOT_PATH;
}
/**
* Get the directory where this tool's binaries reside.
*
* This is {install-root}/{binary-subdir}. If binary-subdir is not
* configured, returns the install root directly.
*/
public function getBinaryDir(): string
{
$subdir = $this->getToolConfig()['binary-subdir'] ?? '';
if ($subdir === '') {
return $this->getInstallRoot();
}
return $this->getInstallRoot() . DIRECTORY_SEPARATOR . $subdir;
}
/**
* Get the list of executables this tool provides.
*
* Reads from YAML 'tool.provides' field. Each entry is a bare filename
* (e.g. 'nasm.exe'), resolved relative to getBinaryDir().
*
* @return string[] Bare executable names (not full paths)
*/
public function getProvides(): array
{
return $this->getToolConfig()['provides'] ?? [];
}
/**
* Get the full path to a specific binary provided by this tool.
*
* @param string $name Bare executable name (must be listed in tool.provides).
* If empty, defaults to the first entry in provides.
* @return string Full absolute path to the binary
*/
public function getBinary(string $name = ''): string
{
$provides = $this->getProvides();
if ($name === '') {
$name = $provides[0] ?? throw new \RuntimeException("Tool '{$this->name}' has no 'tool.provides' configured.");
}
if (!in_array($name, $provides, true)) {
throw new \RuntimeException("Binary '{$name}' is not listed in tool.provides for '{$this->name}'. Available: " . implode(', ', $provides));
}
return $this->getBinaryDir() . DIRECTORY_SEPARATOR . $name;
}
/**
* Check whether this tool is installed (all provided binaries exist on disk).
*/
public function isInstalled(): bool
{
return array_all($this->getProvides(), fn ($binary) => file_exists($this->getBinary($binary)));
}
/**
* Get the minimum required version for this tool, if specified.
*
* Returns null if no version constraint is configured.
*/
public function getMinVersion(): ?string
{
$version = $this->getToolConfig()['min-version'] ?? null;
return $version !== null ? (string) $version : null;
}
/**
* Tools install to PKG_ROOT_PATH (or the configured install-root),
* not BUILD_ROOT_PATH.
*/
public function getInstallTarget(): string
{
return $this->getBinaryDir();
}
/**
* Get the 'tool' sub-config for this package.
*
* Returns the nested array under the 'tool' key in the package YAML,
* or an empty array if not configured.
*
* @return array<string, mixed>
*/
private function getToolConfig(): array
{
$config = PackageConfig::get($this->name);
if (!is_array($config) || !isset($config['tool']) || !is_array($config['tool'])) {
return [];
}
return $config['tool'];
}
}

View File

@@ -17,6 +17,7 @@ use StaticPHP\Attribute\Package\PatchBeforeBuild;
use StaticPHP\Attribute\Package\ResolveBuild;
use StaticPHP\Attribute\Package\Stage;
use StaticPHP\Attribute\Package\Target;
use StaticPHP\Attribute\Package\Tool;
use StaticPHP\Attribute\Package\Validate;
use StaticPHP\Config\PackageConfig;
use StaticPHP\DI\ApplicationContext;
@@ -27,6 +28,7 @@ use StaticPHP\Package\Package;
use StaticPHP\Package\PackageInstaller;
use StaticPHP\Package\PhpExtensionPackage;
use StaticPHP\Package\TargetPackage;
use StaticPHP\Package\ToolPackage;
use StaticPHP\Util\FileSystem;
class PackageLoader
@@ -88,6 +90,7 @@ class PackageLoader
'target', 'virtual-target' => new TargetPackage($name, $item['type']),
'library' => new LibraryPackage($name, $item['type']),
'php-extension' => new PhpExtensionPackage($name, $item['type']),
'tool' => new ToolPackage($name, $item['type']),
default => null,
};
if ($pkg !== null) {
@@ -190,7 +193,8 @@ class PackageLoader
$attribute_instance = $attribute->newInstance();
if ($attribute_instance instanceof Target === false &&
$attribute_instance instanceof Library === false &&
$attribute_instance instanceof Extension === false) {
$attribute_instance instanceof Extension === false &&
$attribute_instance instanceof Tool === false) {
// not a package attribute
continue;
}
@@ -216,6 +220,7 @@ class PackageLoader
Target::class => ['target', 'virtual-target'],
Library::class => ['library'],
Extension::class => ['php-extension'],
Tool::class => ['tool'],
default => null,
};
if (!in_array($package_type, $pkg_type_attr, true)) {

View File

@@ -27,6 +27,7 @@ class ConfigTypeTest extends TestCase
'php-extension',
'target',
'virtual-target',
'tool',
];
$this->assertEquals($expectedTypes, ConfigType::PACKAGE_TYPES);