From b90356bc1d2311a75e0c79304e03ea8630150ff4 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Sun, 8 Mar 2026 17:47:09 +0800 Subject: [PATCH] Enhancement for bin/spc dev:info command --- src/StaticPHP/Artifact/ArtifactCache.php | 11 + .../Command/Dev/PackageInfoCommand.php | 246 +++++++++++++++++- src/StaticPHP/Registry/PackageLoader.php | 158 +++++++++++ 3 files changed, 404 insertions(+), 11 deletions(-) diff --git a/src/StaticPHP/Artifact/ArtifactCache.php b/src/StaticPHP/Artifact/ArtifactCache.php index 7626831a..5a2c8bac 100644 --- a/src/StaticPHP/Artifact/ArtifactCache.php +++ b/src/StaticPHP/Artifact/ArtifactCache.php @@ -203,6 +203,17 @@ class ArtifactCache return $this->cache[$artifact_name]['binary'][$platform] ?? null; } + /** + * Get all binary cache entries for an artifact, keyed by platform string. + * + * @param string $artifact_name Artifact name + * @return array Map of platform → cache info (may be empty) + */ + public function getAllBinaryInfo(string $artifact_name): array + { + return $this->cache[$artifact_name]['binary'] ?? []; + } + /** * Get the full path to the cached file/directory. * diff --git a/src/StaticPHP/Command/Dev/PackageInfoCommand.php b/src/StaticPHP/Command/Dev/PackageInfoCommand.php index 7c869199..ba621c5e 100644 --- a/src/StaticPHP/Command/Dev/PackageInfoCommand.php +++ b/src/StaticPHP/Command/Dev/PackageInfoCommand.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace StaticPHP\Command\Dev; +use StaticPHP\Artifact\ArtifactCache; use StaticPHP\Command\BaseCommand; use StaticPHP\Config\ArtifactConfig; use StaticPHP\Config\PackageConfig; +use StaticPHP\DI\ApplicationContext; +use StaticPHP\Registry\PackageLoader; use StaticPHP\Registry\Registry; +use StaticPHP\Runtime\SystemTarget; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -34,18 +38,26 @@ class PackageInfoCommand extends BaseCommand } $pkgConfig = PackageConfig::get($packageName); - $artifactConfig = ArtifactConfig::get($packageName); + // Resolve the actual artifact name: + // - string field → named reference (e.g. php → php-src) + // - array field → inline artifact, key is package name + // - null → no artifact, or may match by package name + $artifactField = $pkgConfig['artifact'] ?? null; + $artifactName = is_string($artifactField) ? $artifactField : $packageName; + $artifactConfig = ArtifactConfig::get($artifactName); $pkgInfo = Registry::getPackageConfigInfo($packageName); - $artifactInfo = Registry::getArtifactConfigInfo($packageName); + $artifactInfo = Registry::getArtifactConfigInfo($artifactName); + $annotationInfo = PackageLoader::getPackageAnnotationInfo($packageName); + $cacheInfo = $this->resolveCacheInfo($artifactName, $artifactConfig); if ($this->getOption('json')) { - return $this->outputJson($packageName, $pkgConfig, $artifactConfig, $pkgInfo, $artifactInfo); + return $this->outputJson($packageName, $artifactName, $pkgConfig, $artifactConfig, $pkgInfo, $artifactInfo, $annotationInfo, $cacheInfo); } - return $this->outputTerminal($packageName, $pkgConfig, $artifactConfig, $pkgInfo, $artifactInfo); + return $this->outputTerminal($packageName, $pkgConfig, $artifactConfig, $pkgInfo, $artifactInfo, $annotationInfo, $cacheInfo); } - private function outputJson(string $name, array $pkgConfig, ?array $artifactConfig, ?array $pkgInfo, ?array $artifactInfo): int + private function outputJson(string $name, string $artifactName, array $pkgConfig, ?array $artifactConfig, ?array $pkgInfo, ?array $artifactInfo, ?array $annotationInfo, ?array $cacheInfo): int { $data = [ 'name' => $name, @@ -55,15 +67,24 @@ class PackageInfoCommand extends BaseCommand ]; if ($artifactConfig !== null) { + $data['artifact_name'] = $artifactName !== $name ? $artifactName : null; $data['artifact_config_file'] = $artifactInfo ? $this->toRelativePath($artifactInfo['config']) : null; $data['artifact'] = $this->splitArtifactConfig($artifactConfig); } + if ($annotationInfo !== null) { + $data['annotations'] = $annotationInfo; + } + + if ($cacheInfo !== null) { + $data['cache'] = $cacheInfo; + } + $this->output->writeln(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); return static::SUCCESS; } - private function outputTerminal(string $name, array $pkgConfig, ?array $artifactConfig, ?array $pkgInfo, ?array $artifactInfo): int + private function outputTerminal(string $name, array $pkgConfig, ?array $artifactConfig, ?array $pkgInfo, ?array $artifactInfo, ?array $annotationInfo, ?array $cacheInfo): int { $type = $pkgConfig['type'] ?? 'unknown'; $registry = $pkgInfo['registry'] ?? 'unknown'; @@ -86,12 +107,15 @@ class PackageInfoCommand extends BaseCommand // Artifact config if ($artifactConfig !== null) { $artifactFile = $artifactInfo ? $this->toRelativePath($artifactInfo['config']) : 'unknown'; - $this->output->writeln("── Artifact Config ── file: {$artifactFile}"); - - // Check if artifact config is inline (embedded in pkg config) or separate - $inlineArtifact = $pkgConfig['artifact'] ?? null; - if (is_array($inlineArtifact)) { + $artifactField = $pkgConfig['artifact'] ?? null; + if (is_string($artifactField)) { + // Named reference: show the artifact name it points to + $this->output->writeln("── Artifact Config ── artifact: {$artifactField} file: {$artifactFile}"); + } elseif (is_array($artifactField)) { + $this->output->writeln("── Artifact Config ── file: {$artifactFile}"); $this->output->writeln(' (inline in package config)'); + } else { + $this->output->writeln("── Artifact Config ── file: {$artifactFile}"); } $split = $this->splitArtifactConfig($artifactConfig); @@ -107,9 +131,122 @@ class PackageInfoCommand extends BaseCommand $this->output->writeln(''); } + // Annotation section + $this->outputAnnotationSection($name, $annotationInfo); + + // Cache status section + $this->outputCacheSection($cacheInfo); + return static::SUCCESS; } + private function outputAnnotationSection(string $packageName, ?array $annotationInfo): void + { + if ($annotationInfo === null) { + $this->output->writeln('── Annotations ── (no annotation class registered)'); + $this->output->writeln(''); + return; + } + + $shortClass = $this->classBaseName($annotationInfo['class']); + $this->output->writeln("── Annotations ── class: {$shortClass}"); + $this->output->writeln(" {$annotationInfo['class']}"); + + // Method-level hooks + $methods = $annotationInfo['methods']; + if (!empty($methods)) { + $this->output->writeln(''); + $this->output->writeln(' Method hooks:'); + foreach ($methods as $methodName => $attrs) { + $attrList = implode(' ', array_map(fn ($a) => $this->formatAttr($a), $attrs)); + $this->output->writeln(" {$methodName}() {$attrList}"); + } + } + + // Before-stage hooks targeting this package (inbound) + $beforeStages = $annotationInfo['before_stages']; + if (!empty($beforeStages)) { + $this->output->writeln(''); + $this->output->writeln(' Before-stage hooks (inbound):'); + foreach ($beforeStages as $stage => $hooks) { + foreach ($hooks as $hook) { + $source = $this->classBaseName($hook['class']) . '::' . $hook['method'] . '()'; + $cond = $hook['only_when'] !== null ? " (only_when: {$hook['only_when']})" : ''; + $this->output->writeln(" {$stage} ← {$source}{$cond}"); + } + } + } + + // After-stage hooks targeting this package (inbound) + $afterStages = $annotationInfo['after_stages']; + if (!empty($afterStages)) { + $this->output->writeln(''); + $this->output->writeln(' After-stage hooks (inbound):'); + foreach ($afterStages as $stage => $hooks) { + foreach ($hooks as $hook) { + $source = $this->classBaseName($hook['class']) . '::' . $hook['method'] . '()'; + $cond = $hook['only_when'] !== null ? " (only_when: {$hook['only_when']})" : ''; + $this->output->writeln(" {$stage} ← {$source}{$cond}"); + } + } + } + + // Outbound hooks: stages this package's class registers on other packages (exclude self-hooks) + $outboundBefore = $annotationInfo['outbound_before_stages'] ?? []; + $outboundAfter = $annotationInfo['outbound_after_stages'] ?? []; + // Filter out entries targeting the same package — those are already shown inbound + $outboundBefore = array_filter($outboundBefore, fn ($pkg) => $pkg !== $packageName, ARRAY_FILTER_USE_KEY); + $outboundAfter = array_filter($outboundAfter, fn ($pkg) => $pkg !== $packageName, ARRAY_FILTER_USE_KEY); + if (!empty($outboundBefore) || !empty($outboundAfter)) { + $this->output->writeln(''); + $this->output->writeln(' Hooks on other packages (outbound):'); + foreach ($outboundBefore as $targetPkg => $stages) { + foreach ($stages as $stage => $hooks) { + foreach ($hooks as $hook) { + $cond = $hook['only_when'] !== null ? " (only_when: {$hook['only_when']})" : ''; + $this->output->writeln(" #[BeforeStage] → {$targetPkg} {$stage} {$hook['method']}(){$cond}"); + } + } + } + foreach ($outboundAfter as $targetPkg => $stages) { + foreach ($stages as $stage => $hooks) { + foreach ($hooks as $hook) { + $cond = $hook['only_when'] !== null ? " (only_when: {$hook['only_when']})" : ''; + $this->output->writeln(" #[AfterStage] → {$targetPkg} {$stage} {$hook['method']}(){$cond}"); + } + } + } + } + + $this->output->writeln(''); + } + + /** + * Format a single attribute entry (from annotation_map) as a colored inline string. + * + * @param array{attr: string, args: array} $attr + */ + private function formatAttr(array $attr): string + { + $name = $attr['attr']; + $args = $attr['args']; + if (empty($args)) { + return "#[{$name}]"; + } + $argStr = implode(', ', array_map( + fn ($v) => is_string($v) ? "'{$v}'" : (string) $v, + array_values($args) + )); + return "#[{$name}({$argStr})]"; + } + + /** Return the trailing class name component without the namespace. */ + private function classBaseName(string $fqcn): string + { + $parts = explode('\\', $fqcn); + return end($parts); + } + /** * Split artifact config into logical sections for cleaner display. * @@ -190,4 +327,91 @@ class PackageInfoCommand extends BaseCommand } return $normalized; } + + /** + * Build cache status data for display/JSON. + * Returns null when there is no artifact config for this package. + */ + private function resolveCacheInfo(string $name, ?array $artifactConfig): ?array + { + if ($artifactConfig === null) { + return null; + } + $cache = ApplicationContext::get(ArtifactCache::class); + $currentPlatform = SystemTarget::getCurrentPlatformString(); + $hasSource = array_key_exists('source', $artifactConfig) || array_key_exists('source-mirror', $artifactConfig); + $hasBinary = array_key_exists('binary', $artifactConfig) || array_key_exists('binary-mirror', $artifactConfig); + return [ + 'current_platform' => $currentPlatform, + 'has_source' => $hasSource, + 'has_binary' => $hasBinary, + 'source' => $hasSource ? [ + 'downloaded' => $cache->isSourceDownloaded($name), + 'info' => $cache->getSourceInfo($name), + ] : null, + 'binary' => $hasBinary ? $cache->getAllBinaryInfo($name) : null, + ]; + } + + private function outputCacheSection(?array $cacheInfo): void + { + if ($cacheInfo === null) { + $this->output->writeln('── Cache Status ── (no artifact config)'); + $this->output->writeln(''); + return; + } + + $platform = $cacheInfo['current_platform']; + $this->output->writeln("── Cache Status ── current platform: {$platform}"); + + // Source + $this->output->writeln(''); + $this->output->writeln(' source:'); + if (!$cacheInfo['has_source']) { + $this->output->writeln(' ─ not applicable'); + } elseif ($cacheInfo['source']['downloaded'] && $cacheInfo['source']['info'] !== null) { + $this->output->writeln(' ✓ downloaded ' . $this->formatCacheEntry($cacheInfo['source']['info'])); + } else { + $this->output->writeln(' ✗ not downloaded'); + } + + // Binary + $this->output->writeln(''); + $this->output->writeln(' binary:'); + if (!$cacheInfo['has_binary']) { + $this->output->writeln(' ─ not applicable'); + } elseif (empty($cacheInfo['binary'])) { + $this->output->writeln(" ✗ {$platform} (current — not cached)"); + } else { + $allBinary = $cacheInfo['binary']; + foreach ($allBinary as $binPlatform => $binInfo) { + $isCurrent = $binPlatform === $platform; + $tag = $isCurrent ? ' (current)' : ''; + if ($binInfo !== null) { + $this->output->writeln(" ✓ {$binPlatform}{$tag} " . $this->formatCacheEntry($binInfo)); + } else { + $this->output->writeln(" ✗ {$binPlatform}{$tag}"); + } + } + // Show current platform if not already listed + if (!array_key_exists($platform, $allBinary)) { + $this->output->writeln(" ✗ {$platform} (current — not cached)"); + } + } + + $this->output->writeln(''); + } + + private function formatCacheEntry(array $info): string + { + $type = $info['cache_type'] ?? '?'; + $version = $info['version'] !== null ? " {$info['version']}" : ''; + $time = isset($info['time']) ? ' ' . date('Y-m-d H:i', (int) $info['time']) : ''; + $file = match ($type) { + 'archive', 'file' => isset($info['filename']) ? " {$info['filename']}" : '', + 'git', 'local' => isset($info['dirname']) ? " {$info['dirname']}" : '', + default => '', + }; + return "[{$type}]{$version}{$time}{$file}"; + } } diff --git a/src/StaticPHP/Registry/PackageLoader.php b/src/StaticPHP/Registry/PackageLoader.php index 573ad7da..421403c9 100644 --- a/src/StaticPHP/Registry/PackageLoader.php +++ b/src/StaticPHP/Registry/PackageLoader.php @@ -40,6 +40,42 @@ class PackageLoader /** @var array Track loaded classes to prevent duplicates */ private static array $loaded_classes = []; + /** + * Annotation metadata keyed by package name, capturing the defining class and its method-level attributes. + * + * @var array}>>}> + */ + private static array $annotation_map = []; + + /** + * Source metadata for #[BeforeStage] hooks, keyed by target package name → stage name. + * + * @var array>> + */ + private static array $before_stage_meta = []; + + /** + * Source metadata for #[AfterStage] hooks, keyed by target package name → stage name. + * + * @var array>> + */ + private static array $after_stage_meta = []; + + /** + * Reverse index of #[BeforeStage] hooks, keyed by registering class → target package → stage. + * Enables O(1) "outbound hook" lookup: what stages does a given class hook into on other packages? + * + * @var array>>> + */ + private static array $class_before_stage_meta = []; + + /** + * Reverse index of #[AfterStage] hooks, keyed by registering class → target package → stage. + * + * @var array>>> + */ + private static array $class_after_stage_meta = []; + public static function initPackageInstances(): void { if (self::$packages !== null) { @@ -213,8 +249,19 @@ class PackageLoader Validate::class => $pkg->setValidateCallback([$instance_class, $method->getName()]), default => null, }; + + // Capture annotation metadata for inspection (dev:info, future event-trace commands) + $meta_attr = self::annotationShortName($method_attribute->getName()); + if ($meta_attr !== null) { + self::$annotation_map[$pkg->getName()]['methods'][$method->getName()][] = [ + 'attr' => $meta_attr, + 'args' => self::annotationArgs($method_instance), + ]; + } } } + // Record which class defines this package (set once; IS_REPEATABLE may loop more than once) + self::$annotation_map[$pkg->getName()]['class'] ??= $class_name; // register package self::$packages[$pkg->getName()] = $pkg; } @@ -260,6 +307,63 @@ class PackageLoader return self::$after_stages; } + /** + * Get annotation metadata for a specific package. + * + * Returns null if no annotation class was loaded for this package (config-only package). + * The returned structure includes the defining class name, per-method attribute list, + * inbound BeforeStage/AfterStage hooks targeting this package, and outbound hooks that + * this package's class registers on other packages. + * + * @return null|array{ + * class: string, + * methods: array}>>, + * before_stages: array>, + * after_stages: array>, + * outbound_before_stages: array>>, + * outbound_after_stages: array>> + * } + */ + public static function getPackageAnnotationInfo(string $name): ?array + { + $class_info = self::$annotation_map[$name] ?? null; + if ($class_info === null) { + return null; + } + $class = $class_info['class']; + return [ + 'class' => $class, + 'methods' => $class_info['methods'], + 'before_stages' => self::$before_stage_meta[$name] ?? [], + 'after_stages' => self::$after_stage_meta[$name] ?? [], + 'outbound_before_stages' => self::$class_before_stage_meta[$class] ?? [], + 'outbound_after_stages' => self::$class_after_stage_meta[$class] ?? [], + ]; + } + + /** + * Get all annotation metadata keyed by package name. + * Useful for future event-trace commands or cross-package inspection. + * + * @return array + */ + public static function getAllAnnotations(): array + { + $result = []; + foreach (self::$annotation_map as $name => $info) { + $class = $info['class']; + $result[$name] = [ + 'class' => $class, + 'methods' => $info['methods'], + 'before_stages' => self::$before_stage_meta[$name] ?? [], + 'after_stages' => self::$after_stage_meta[$name] ?? [], + 'outbound_before_stages' => self::$class_before_stage_meta[$class] ?? [], + 'outbound_after_stages' => self::$class_after_stage_meta[$class] ?? [], + ]; + } + return $result; + } + public static function getBeforeStageCallbacks(string $package_name, string $stage): iterable { // match condition @@ -385,6 +489,16 @@ class PackageLoader } $package_name = $method_instance->package_name === '' ? $pkg->getName() : $method_instance->package_name; self::$before_stages[$package_name][$stage][] = [[$instance_class, $method->getName()], $method_instance->only_when_package_resolved]; + $registering_class = get_class($instance_class); + self::$before_stage_meta[$package_name][$stage][] = [ + 'class' => $registering_class, + 'method' => $method->getName(), + 'only_when' => $method_instance->only_when_package_resolved, + ]; + self::$class_before_stage_meta[$registering_class][$package_name][$stage][] = [ + 'method' => $method->getName(), + 'only_when' => $method_instance->only_when_package_resolved, + ]; } private static function addAfterStage(\ReflectionMethod $method, ?Package $pkg, mixed $instance_class, object $method_instance): void @@ -400,5 +514,49 @@ class PackageLoader } $package_name = $method_instance->package_name === '' ? $pkg->getName() : $method_instance->package_name; self::$after_stages[$package_name][$stage][] = [[$instance_class, $method->getName()], $method_instance->only_when_package_resolved]; + $registering_class = get_class($instance_class); + self::$after_stage_meta[$package_name][$stage][] = [ + 'class' => $registering_class, + 'method' => $method->getName(), + 'only_when' => $method_instance->only_when_package_resolved, + ]; + self::$class_after_stage_meta[$registering_class][$package_name][$stage][] = [ + 'method' => $method->getName(), + 'only_when' => $method_instance->only_when_package_resolved, + ]; + } + + /** + * Map a fully-qualified attribute class name to a short display name for metadata storage. + * Returns null for attributes that are not tracked in the annotation map. + */ + private static function annotationShortName(string $attr): ?string + { + return match ($attr) { + Stage::class => 'Stage', + BuildFor::class => 'BuildFor', + PatchBeforeBuild::class => 'PatchBeforeBuild', + CustomPhpConfigureArg::class => 'CustomPhpConfigureArg', + InitPackage::class => 'InitPackage', + ResolveBuild::class => 'ResolveBuild', + Info::class => 'Info', + Validate::class => 'Validate', + default => null, + }; + } + + /** + * Extract the meaningful constructor arguments from an attribute instance as a key-value array. + * + * @return array + */ + private static function annotationArgs(object $inst): array + { + return match (true) { + $inst instanceof Stage => array_filter(['function' => $inst->function], fn ($v) => $v !== null), + $inst instanceof BuildFor => ['os' => $inst->os], + $inst instanceof CustomPhpConfigureArg => array_filter(['os' => $inst->os], fn ($v) => $v !== ''), + default => [], + }; } }