diff --git a/src/StaticPHP/Command/Dev/DumpStagesCommand.php b/src/StaticPHP/Command/Dev/DumpStagesCommand.php
new file mode 100644
index 00000000..4b20fe21
--- /dev/null
+++ b/src/StaticPHP/Command/Dev/DumpStagesCommand.php
@@ -0,0 +1,157 @@
+addArgument('packages', InputArgument::OPTIONAL, 'Comma-separated list of packages to dump, e.g. "openssl,zlib,curl". Dumps all packages if omitted.');
+ $this->addArgument('output', InputArgument::OPTIONAL, 'Output file path', ROOT_DIR . '/dump-stages.json');
+ $this->addOption('relative', 'r', InputOption::VALUE_NONE, 'Output file paths relative to ROOT_DIR');
+ }
+
+ public function handle(): int
+ {
+ $outputFile = $this->getArgument('output');
+ $useRelative = (bool) $this->getOption('relative');
+
+ $filterPackages = null;
+ if ($packagesArg = $this->getArgument('packages')) {
+ $filterPackages = array_flip(parse_comma_list($packagesArg));
+ }
+
+ $result = [];
+
+ foreach (PackageLoader::getPackages() as $name => $pkg) {
+ if ($filterPackages !== null && !isset($filterPackages[$name])) {
+ continue;
+ }
+ $entry = [
+ 'type' => $pkg->getType(),
+ 'stages' => [],
+ 'before_stages' => [],
+ 'after_stages' => [],
+ ];
+
+ // Resolve main stages
+ foreach ($pkg->getStages() as $stageName => $callable) {
+ $location = $this->resolveCallableLocation($callable);
+ if ($location !== null && $useRelative) {
+ $location['file'] = $this->toRelativePath($location['file']);
+ }
+ $entry['stages'][$stageName] = $location;
+ }
+
+ $result[$name] = $entry;
+ }
+
+ // Resolve before/after stage external callbacks
+ foreach (PackageLoader::getAllBeforeStages() as $pkgName => $stages) {
+ if ($filterPackages !== null && !isset($filterPackages[$pkgName])) {
+ continue;
+ }
+ foreach ($stages as $stageName => $callbacks) {
+ foreach ($callbacks as [$callable, $onlyWhen]) {
+ $location = $this->resolveCallableLocation($callable);
+ if ($location !== null && $useRelative) {
+ $location['file'] = $this->toRelativePath($location['file']);
+ }
+ $entry_data = $location ?? [];
+ if ($onlyWhen !== null) {
+ $entry_data['only_when_package_resolved'] = $onlyWhen;
+ }
+ $result[$pkgName]['before_stages'][$stageName][] = $entry_data;
+ }
+ }
+ }
+
+ foreach (PackageLoader::getAllAfterStages() as $pkgName => $stages) {
+ if ($filterPackages !== null && !isset($filterPackages[$pkgName])) {
+ continue;
+ }
+ foreach ($stages as $stageName => $callbacks) {
+ foreach ($callbacks as [$callable, $onlyWhen]) {
+ $location = $this->resolveCallableLocation($callable);
+ if ($location !== null && $useRelative) {
+ $location['file'] = $this->toRelativePath($location['file']);
+ }
+ $entry_data = $location ?? [];
+ if ($onlyWhen !== null) {
+ $entry_data['only_when_package_resolved'] = $onlyWhen;
+ }
+ $result[$pkgName]['after_stages'][$stageName][] = $entry_data;
+ }
+ }
+ }
+
+ $json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ file_put_contents($outputFile, $json . PHP_EOL);
+
+ $this->output->writeln('Dumped stages for ' . count($result) . " package(s) to: {$outputFile}");
+ return static::SUCCESS;
+ }
+
+ /**
+ * Resolve the file, start line, class and method name of a callable using reflection.
+ *
+ * @return null|array{file: string, line: false|int, class: string, method: string}
+ */
+ private function resolveCallableLocation(mixed $callable): ?array
+ {
+ try {
+ if (is_array($callable) && count($callable) === 2) {
+ $ref = new \ReflectionMethod($callable[0], $callable[1]);
+ return [
+ 'class' => $ref->getDeclaringClass()->getName(),
+ 'method' => $ref->getName(),
+ 'file' => (string) $ref->getFileName(),
+ 'line' => $ref->getStartLine(),
+ ];
+ }
+ if ($callable instanceof \Closure) {
+ $ref = new \ReflectionFunction($callable);
+ $scopeClass = $ref->getClosureScopeClass();
+ return [
+ 'class' => $scopeClass !== null ? $scopeClass->getName() : '{closure}',
+ 'method' => '{closure}',
+ 'file' => (string) $ref->getFileName(),
+ 'line' => $ref->getStartLine(),
+ ];
+ }
+ if (is_string($callable) && str_contains($callable, '::')) {
+ [$class, $method] = explode('::', $callable, 2);
+ $ref = new \ReflectionMethod($class, $method);
+ return [
+ 'class' => $ref->getDeclaringClass()->getName(),
+ 'method' => $ref->getName(),
+ 'file' => (string) $ref->getFileName(),
+ 'line' => $ref->getStartLine(),
+ ];
+ }
+ } catch (\ReflectionException) {
+ // ignore
+ }
+ return null;
+ }
+
+ private function toRelativePath(string $absolutePath): string
+ {
+ $root = rtrim(ROOT_DIR, '/') . '/';
+ if (str_starts_with($absolutePath, $root)) {
+ return substr($absolutePath, strlen($root));
+ }
+ return $absolutePath;
+ }
+}
diff --git a/src/StaticPHP/ConsoleApplication.php b/src/StaticPHP/ConsoleApplication.php
index 8608f761..4afa221c 100644
--- a/src/StaticPHP/ConsoleApplication.php
+++ b/src/StaticPHP/ConsoleApplication.php
@@ -6,6 +6,7 @@ namespace StaticPHP;
use StaticPHP\Command\BuildLibsCommand;
use StaticPHP\Command\BuildTargetCommand;
+use StaticPHP\Command\Dev\DumpStagesCommand;
use StaticPHP\Command\Dev\EnvCommand;
use StaticPHP\Command\Dev\IsInstalledCommand;
use StaticPHP\Command\Dev\LintConfigCommand;
@@ -67,6 +68,7 @@ class ConsoleApplication extends Application
new EnvCommand(),
new LintConfigCommand(),
new PackLibCommand(),
+ new DumpStagesCommand(),
]);
// add additional commands from registries
diff --git a/src/StaticPHP/Package/Package.php b/src/StaticPHP/Package/Package.php
index 64b9f2e4..fd59e98b 100644
--- a/src/StaticPHP/Package/Package.php
+++ b/src/StaticPHP/Package/Package.php
@@ -128,6 +128,16 @@ abstract class Package
$this->stages[$name] = $stage;
}
+ /**
+ * Get all defined stages for this package.
+ *
+ * @return array
+ */
+ public function getStages(): array
+ {
+ return $this->stages;
+ }
+
/**
* Check if the package has a specific stage defined.
*
diff --git a/src/StaticPHP/Registry/PackageLoader.php b/src/StaticPHP/Registry/PackageLoader.php
index ca195ff0..573ad7da 100644
--- a/src/StaticPHP/Registry/PackageLoader.php
+++ b/src/StaticPHP/Registry/PackageLoader.php
@@ -240,6 +240,26 @@ class PackageLoader
}
}
+ /**
+ * Get all registered before-stage callbacks (raw).
+ *
+ * @return array>>
+ */
+ public static function getAllBeforeStages(): array
+ {
+ return self::$before_stages;
+ }
+
+ /**
+ * Get all registered after-stage callbacks (raw).
+ *
+ * @return array>>
+ */
+ public static function getAllAfterStages(): array
+ {
+ return self::$after_stages;
+ }
+
public static function getBeforeStageCallbacks(string $package_name, string $stage): iterable
{
// match condition