From e33ab57427ddb842b2bc915a417a9cec5f5c13ee Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Fri, 7 Aug 2026 09:25:32 +0800 Subject: [PATCH 1/7] Use --cflags to support system-level pkg-config paths --- src/StaticPHP/Util/PkgConfigUtil.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/StaticPHP/Util/PkgConfigUtil.php b/src/StaticPHP/Util/PkgConfigUtil.php index d5b03757f..7de8230fd 100644 --- a/src/StaticPHP/Util/PkgConfigUtil.php +++ b/src/StaticPHP/Util/PkgConfigUtil.php @@ -57,17 +57,22 @@ public static function getModuleVersion(string $pkg_config_str): string /** * Get CFLAGS from pkg-config * - * Returns --cflags-only-other output from pkg-config. + * Returns full --cflags output (not --cflags-only-other) because: + * 1. Libraries like ImageMagick and libxml2 install headers into include + * subdirectories whose -I paths are dropped by --cflags-only-other. + * 2. Extensions that can only be linked dynamically (glfw, webview, ...) + * intentionally resolve system .pc files via a user-set PKG_CONFIG_PATH; + * their -I paths are required. pkg-config itself already filters -I/usr/include, + * so only genuinely needed subdirectory includes appear here. * The reason we return the string is we cannot use array_unique() on cflags, * some cflags may contains spaces. * * @param string $pkg_config_str .pc file string, accepts multiple files - * @return string CFLAGS string, e.g. "-Wno-implicit-int-float-conversion ..." + * @return string CFLAGS string, e.g. "-I/path/to/include -Wno-implicit-int-float-conversion ..." */ public static function getCflags(string $pkg_config_str): string { - // get other things - $result = self::execWithResult("pkg-config --static --cflags-only-other {$pkg_config_str}"); + $result = self::execWithResult("pkg-config --static --cflags {$pkg_config_str}"); return trim($result); } From 5c37bca78b165200c9ef4e0f9e925f7992239416 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Fri, 7 Aug 2026 09:25:57 +0800 Subject: [PATCH 2/7] feat: prioritize stable releases by checking latest stable version from GitHub API --- .../Artifact/Downloader/Type/GitHubRelease.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php b/src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php index 20db71275..fd9b1f95a 100644 --- a/src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php +++ b/src/StaticPHP/Artifact/Downloader/Type/GitHubRelease.php @@ -57,6 +57,16 @@ public function getLatestGitHubRelease(string $name, string $repo, bool $prefer_ if (!is_array($data)) { throw new DownloaderException("Failed to get GitHub release API info for {$repo} from {$url}"); } + // GitHub's /releases list is ordered by publish date, so a newer-tagged prerelease + // (e.g. 2.2.x-alpha) can precede the latest stable (2.1.x-stable). /releases/latest + // returns the semantically latest stable release regardless of order; check it first. + if ($prefer_stable) { + $latest_url = str_replace('{repo}', $repo, self::API_URL) . '/latest'; + $latest = json_decode(default_shell()->executeCurl($latest_url, headers: $headers, retries: $retries) ?: '', true); + if (is_array($latest) && isset($latest['assets'])) { + array_unshift($data, $latest); + } + } foreach ($data as $release) { if ($prefer_stable && $release['prerelease'] === true) { continue; From 7d11cad5fd51ec65373201c6d7cc65b68573f90f Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Fri, 7 Aug 2026 09:26:11 +0800 Subject: [PATCH 3/7] fix: improve local source handling in ArtifactExtractor to prevent directory conflicts --- src/StaticPHP/Artifact/ArtifactExtractor.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/StaticPHP/Artifact/ArtifactExtractor.php b/src/StaticPHP/Artifact/ArtifactExtractor.php index 2237e0bff..70e8367d8 100644 --- a/src/StaticPHP/Artifact/ArtifactExtractor.php +++ b/src/StaticPHP/Artifact/ArtifactExtractor.php @@ -136,6 +136,12 @@ protected function extractSource(Artifact $artifact): int throw new WrongUsageException("Artifact source [{$name}] not downloaded, please download it first!"); } + // Local (--custom-local): source lives in place at $cache_info['dirname']. + if (($cache_info['cache_type'] ?? null) === 'local') { + $artifact->emitAfterSourceExtract($artifact->getSourceDir()); + return SPC_STATUS_ALREADY_EXTRACTED; + } + $source_file = $this->cache->getCacheFullPath($cache_info); $target_path = $artifact->getSourceDir(); @@ -174,8 +180,12 @@ protected function extractSource(Artifact $artifact): int return SPC_STATUS_ALREADY_EXTRACTED; } - // Remove old directory if hash mismatch - if (is_dir($target_path)) { + // Remove old directory if hash mismatch. + // Guard: a symlink at $target_path (left over from older local-source handling) must be + // unlinked directly — never recurse into the link target, that would wipe the user's tree. + if (is_link($target_path)) { + @unlink($target_path); + } elseif (is_dir($target_path)) { logger()->notice("Source [{$name}] hash mismatch, re-extracting..."); FileSystem::removeDir($target_path); } From c278403b2aaa656ce1d21eedbec910d7d4402386 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Fri, 7 Aug 2026 09:55:07 +0800 Subject: [PATCH 4/7] feat: implement cache matching interface for download types to validate cached entries based on request options --- src/StaticPHP/Artifact/ArtifactDownloader.php | 30 ++++++++++++++---- .../Downloader/Type/CacheMatchInterface.php | 31 +++++++++++++++++++ .../Artifact/Downloader/Type/Git.php | 15 ++++++++- .../Artifact/Downloader/Type/PhpRelease.php | 21 ++++++++++++- .../Artifact/Downloader/Type/Url.php | 9 +++++- src/StaticPHP/Artifact/DownloaderOptions.php | 5 +-- 6 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 src/StaticPHP/Artifact/Downloader/Type/CacheMatchInterface.php diff --git a/src/StaticPHP/Artifact/ArtifactDownloader.php b/src/StaticPHP/Artifact/ArtifactDownloader.php index bfe7a21f5..3dc0a628c 100644 --- a/src/StaticPHP/Artifact/ArtifactDownloader.php +++ b/src/StaticPHP/Artifact/ArtifactDownloader.php @@ -7,6 +7,7 @@ use Psr\Log\LogLevel; use StaticPHP\Artifact\Downloader\DownloadResult; use StaticPHP\Artifact\Downloader\Type\BitBucketTag; +use StaticPHP\Artifact\Downloader\Type\CacheMatchInterface; use StaticPHP\Artifact\Downloader\Type\CheckUpdateInterface; use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult; use StaticPHP\Artifact\Downloader\Type\DownloadTypeInterface; @@ -318,7 +319,10 @@ public function download(): void if (!is_dir(DOWNLOAD_PATH)) { FileSystem::createDir(DOWNLOAD_PATH); } - logger()->info('Downloading' . implode(', ', array_map(fn ($x) => " '{$x->getName()}'", $this->artifacts)) . " with concurrency {$this->parallel} ..."); + $pending = array_values(array_filter($this->artifacts, fn ($a) => $this->generateQueue($a) !== [])); + if ($pending !== []) { + logger()->info('Downloading' . implode(', ', array_map(fn ($x) => " '{$x->getName()}'", $pending)) . " with concurrency {$this->parallel} ..."); + } // Download artifacts parallelly if ($this->parallel > 1) { $this->downloadWithConcurrency(); @@ -573,8 +577,8 @@ private function downloadWithType(Artifact $artifact, int $current, int $total, $instance = null; $call = $this->downloaders[$item['config']['type']] ?? null; $type_display_name = match (true) { - $item['lock'] === 'source' && ($callback = $artifact->getCustomSourceCallback()) !== null => 'user defined source downloader', - $item['lock'] === 'binary' && ($callback = $artifact->getCustomBinaryCallback()) !== null => 'user defined binary downloader', + $item['lock'] === 'source' && $artifact->getCustomSourceCallback() !== null => $artifact->getCustomSourceCallbackOrigin() ?? 'source package downloader', + $item['lock'] === 'binary' && $artifact->getCustomBinaryCallback() !== null => $artifact->getCustomBinaryCallbackOrigin() ?? 'binary package downloader', default => SPC_DOWNLOAD_TYPE_DISPLAY_NAME[$item['config']['type']] ?? $item['config']['type'], }; $try_h = $try ? 'Try downloading' : 'Downloading'; @@ -753,6 +757,20 @@ private function generateQueue(Artifact $artifact): array $binary_downloaded = $artifact->isBinaryDownloaded(compare_hash: true); $source_downloaded = $artifact->isSourceDownloaded(compare_hash: true); + // Some download types fetch content depending on request options rather than config alone + // (e.g. php-release varies with --with-php): let them veto a stale cache entry. + // Custom source callbacks carry their own semantics, they bypass type-based checks. + if ($source_downloaded && $artifact->getCustomSourceCallback() === null) { + $source_config = $artifact->getDownloadConfig('source'); + $dl_cls = is_array($source_config) ? ($this->downloaders[$source_config['type']] ?? null) : null; + if ($dl_cls !== null && is_a($dl_cls, CacheMatchInterface::class, true)) { + $source_lock = ApplicationContext::get(ArtifactCache::class)->getSourceInfo($artifact->getName()) ?? []; + if (!(new $dl_cls())->cacheMatches($artifact->getName(), $source_config, $source_lock, $this)) { + $source_downloaded = false; + } + } + } + $item_source = ['display' => 'source', 'lock' => 'source', 'config' => $artifact->getDownloadConfig('source')]; $item_source_mirror = ['display' => 'source (mirror)', 'lock' => 'source', 'config' => $artifact->getDownloadConfig('source-mirror')]; @@ -847,21 +865,21 @@ private function applyCustomDownloads(): void if (isset($this->artifacts[$artifact_name])) { $this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $custom_url) { return (new Url())->download($artifact_name, ['url' => $custom_url], $downloader); - }); + }, 'custom url'); } } foreach ($this->custom_gits as $artifact_name => [$branch, $git_url]) { if (isset($this->artifacts[$artifact_name])) { $this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $branch, $git_url) { return (new Git())->download($artifact_name, ['rev' => $branch, 'url' => $git_url], $downloader); - }); + }, 'custom git'); } } foreach ($this->custom_locals as $artifact_name => $local_path) { if (isset($this->artifacts[$artifact_name])) { $this->artifacts[$artifact_name]->setCustomSourceCallback(function (ArtifactDownloader $downloader) use ($artifact_name, $local_path) { return (new LocalDir())->download($artifact_name, ['dirname' => $local_path], $downloader); - }); + }, 'custom local dir'); } } } diff --git a/src/StaticPHP/Artifact/Downloader/Type/CacheMatchInterface.php b/src/StaticPHP/Artifact/Downloader/Type/CacheMatchInterface.php new file mode 100644 index 000000000..fc75d1c26 --- /dev/null +++ b/src/StaticPHP/Artifact/Downloader/Type/CacheMatchInterface.php @@ -0,0 +1,31 @@ +getOption('with-php'); + // No explicit version request (option left at its null default): accept whatever + // is cached. The '8.5' default only applies when actually fetching, so a sticky + // cache is never invalidated by a version the user did not ask about. + if ($requested === null || $requested === '' || $requested === false) { + return true; + } + $cached_version = $lock_entry['version'] ?? null; + $cache_type = $lock_entry['cache_type'] ?? null; + if ($requested === 'git') { + return $cache_type === 'git'; + } + return $cached_version !== null + && $cache_type !== 'git' + && ($cached_version === $requested || str_starts_with($cached_version, $requested . '.')); + } + protected function fetchPhpReleaseInfo(string $name, array $config, ArtifactDownloader $downloader): array { $phpver = $downloader->getOption('with-php', '8.5'); diff --git a/src/StaticPHP/Artifact/Downloader/Type/Url.php b/src/StaticPHP/Artifact/Downloader/Type/Url.php index 02425fe5d..d082c4826 100644 --- a/src/StaticPHP/Artifact/Downloader/Type/Url.php +++ b/src/StaticPHP/Artifact/Downloader/Type/Url.php @@ -8,7 +8,7 @@ use StaticPHP\Artifact\Downloader\DownloadResult; /** url */ -class Url implements DownloadTypeInterface +class Url implements DownloadTypeInterface, CacheMatchInterface { public function download(string $name, array $config, ArtifactDownloader $downloader): DownloadResult { @@ -20,4 +20,11 @@ public function download(string $name, array $config, ArtifactDownloader $downlo default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry()); return DownloadResult::archive($filename, config: $config, extract: $config['extract'] ?? null, version: $version, downloader: static::class); } + + public function cacheMatches(string $name, array $config, array $lock_entry, ArtifactDownloader $downloader): bool + { + // A changed filename already invalidates via the file-exists check; a changed url + // with an unchanged filename (mirror switch, fixed-name tarball) does not. + return ($lock_entry['config']['url'] ?? null) === ($config['url'] ?? null); + } } diff --git a/src/StaticPHP/Artifact/DownloaderOptions.php b/src/StaticPHP/Artifact/DownloaderOptions.php index 47c960ef5..5b35bfef9 100644 --- a/src/StaticPHP/Artifact/DownloaderOptions.php +++ b/src/StaticPHP/Artifact/DownloaderOptions.php @@ -51,8 +51,9 @@ public static function getConsoleOptions(string $prefix = ''): array $shortI = $prefix ? null : 'i'; return [ - // php version option - new InputOption("{$p}with-php", null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.5)', '8.5'), + // php version option (null default: only enforced against the download cache when + // explicitly given; PhpRelease falls back to the latest 8.5.x when actually fetching) + new InputOption("{$p}with-php", null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format (default 8.5)'), // download preference options new InputOption("{$p}prefer-source", null, InputOption::VALUE_OPTIONAL, 'Prefer source downloads when both source and binary are available', false), From 9fe9942b14f660ca757fdd083b6039d059158075 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Fri, 7 Aug 2026 09:55:28 +0800 Subject: [PATCH 5/7] fix: adjust linker flags handling for Linux and Darwin in UnixCMakeExecutor --- .../Runtime/Executor/UnixCMakeExecutor.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/StaticPHP/Runtime/Executor/UnixCMakeExecutor.php b/src/StaticPHP/Runtime/Executor/UnixCMakeExecutor.php index 6d5e63437..25fef98d1 100644 --- a/src/StaticPHP/Runtime/Executor/UnixCMakeExecutor.php +++ b/src/StaticPHP/Runtime/Executor/UnixCMakeExecutor.php @@ -229,8 +229,15 @@ private function getDefaultCMakeArgs(): array "-DCMAKE_TOOLCHAIN_FILE={$this->makeCmakeToolchainFile()}", ]; - // EXE linker flags: base system libs + framework flags for target packages - $exeLinkerFlags = SystemTarget::getRuntimeLibs(); + // EXE linker flags: framework flags for target packages (Darwin). On Linux the + // runtime libs (-ldl & co.) are NOT passed here: CMake puts EXE_LINKER_FLAGS before + // the objects/archives, where -Wl,--as-needed (Debian gcc default, and our own + // LDFLAGS) discards them and breaks try_compile probes linking static archives that + // need them (curl's SSL_set_quic_tls_cbs check vs libcrypto.a needing dlopen on + // glibc < 2.34). They go into the toolchain file as CMAKE__STANDARD_LIBRARIES + // instead, which cmake appends AFTER the objects — correct order, as-needed intact, + // and the toolchain file is re-evaluated inside every try_compile. + $exeLinkerFlags = SystemTarget::getTargetOS() === 'Linux' ? '' : SystemTarget::getRuntimeLibs(); if ($this->package instanceof TargetPackage && SystemTarget::getTargetOS() === 'Darwin') { $resolvedNames = array_keys($this->installer->getResolvedPackages()); $resolvedNames[] = $this->package->getName(); @@ -309,6 +316,14 @@ private function makeCmakeToolchainFile(): string $ranlib = getenv('SPC_DEFAULT_RANLIB') ?: (getenv('RANLIB') ?: 'ranlib'); $toolchain .= "\nSET(CMAKE_AR \"{$ar}\")"; $toolchain .= "\nSET(CMAKE_RANLIB \"{$ranlib}\")"; + // Runtime libs as standard libraries: appended after objects/archives on every + // link line (incl. try_compile probes), so -Wl,--as-needed keeps the ones that + // are actually referenced. + $runtimeLibs = SystemTarget::getRuntimeLibs(); + if ($runtimeLibs !== '') { + $toolchain .= "\nset(CMAKE_C_STANDARD_LIBRARIES_INIT \"{$runtimeLibs}\")"; + $toolchain .= "\nset(CMAKE_CXX_STANDARD_LIBRARIES_INIT \"{$runtimeLibs}\")"; + } } FileSystem::writeFile(SOURCE_PATH . '/toolchain.cmake', $toolchain); return $created = realpath(SOURCE_PATH . '/toolchain.cmake'); From 66ebb5bedb5c2e02383a18170c20f342c8410f21 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Fri, 7 Aug 2026 09:55:50 +0800 Subject: [PATCH 6/7] feat: enhance custom source and binary callback handling with origin labels --- src/StaticPHP/Artifact/Artifact.php | 38 ++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/StaticPHP/Artifact/Artifact.php b/src/StaticPHP/Artifact/Artifact.php index d37d350b8..ac0c33115 100644 --- a/src/StaticPHP/Artifact/Artifact.php +++ b/src/StaticPHP/Artifact/Artifact.php @@ -27,12 +27,18 @@ class Artifact /** @var null|callable Bind custom source fetcher callback */ protected mixed $custom_source_callback = null; + /** @var null|string Display label describing where the custom source callback came from */ + protected ?string $custom_source_callback_origin = null; + /** @var null|callable Bind custom source check-update callback */ protected mixed $custom_source_check_update_callback = null; /** @var array Bind custom binary fetcher callbacks */ protected mixed $custom_binary_callbacks = []; + /** @var array Display label per platform describing where the custom binary callback came from */ + protected array $custom_binary_callback_origins = []; + /** @var array Bind custom binary check-update callbacks */ protected array $custom_binary_check_update_callbacks = []; @@ -285,15 +291,19 @@ public function getDownloadConfig(string $type): mixed * Get source extraction directory. * * Rules: - * 1. If extract is not specified: SOURCE_PATH/{artifact_name} - * 2. If extract is relative path: SOURCE_PATH/{value} - * 3. If extract is absolute path: {value} - * 4. If extract is array (dict): handled by extractor (selective extraction) + * 1. If cache_type is 'local': use the absolute dirname recorded at download time (no symlink/copy). + * 2. If extract is not specified: SOURCE_PATH/{artifact_name} + * 3. If extract is relative path: SOURCE_PATH/{value} + * 4. If extract is absolute path: {value} + * 5. If extract is array (dict): handled by extractor (selective extraction) */ public function getSourceDir(): string { // Prefer cache extract path, fall back to config $cache_info = ApplicationContext::get(ArtifactCache::class)->getSourceInfo($this->name); + if (($cache_info['cache_type'] ?? null) === 'local' && isset($cache_info['dirname'])) { + return FileSystem::convertPath($cache_info['dirname']); + } $extract = is_string($cache_info['extract'] ?? null) ? $cache_info['extract'] : ($this->config['source']['extract'] ?? null); @@ -406,10 +416,13 @@ public function getBinaryDir(): ?string /** * Set custom source fetcher callback. + * + * @param string $origin Short label shown in progress output (e.g. 'package downloader', 'custom url') */ - public function setCustomSourceCallback(callable $callback): void + public function setCustomSourceCallback(callable $callback, string $origin = 'package downloader'): void { $this->custom_source_callback = $callback; + $this->custom_source_callback_origin = $origin; } public function getCustomSourceCallback(): ?callable @@ -417,6 +430,11 @@ public function getCustomSourceCallback(): ?callable return $this->custom_source_callback ?? null; } + public function getCustomSourceCallbackOrigin(): ?string + { + return $this->custom_source_callback_origin; + } + /** * Set custom source check-update callback. */ @@ -451,11 +469,19 @@ public function emitCustomBinary(): void * * @param string $target_os Target OS platform string (e.g. linux-x86_64) * @param callable $callback Custom binary fetcher callback + * @param string $origin Short label shown in progress output (e.g. 'package downloader') */ - public function setCustomBinaryCallback(string $target_os, callable $callback): void + public function setCustomBinaryCallback(string $target_os, callable $callback, string $origin = 'package downloader'): void { ConfigValidator::validatePlatformString($target_os); $this->custom_binary_callbacks[$target_os] = $callback; + $this->custom_binary_callback_origins[$target_os] = $origin; + } + + public function getCustomBinaryCallbackOrigin(): ?string + { + $current_platform = SystemTarget::getCurrentPlatformString(); + return $this->custom_binary_callback_origins[$current_platform] ?? null; } /** From 933d7da4569d11291ffeeb72f64a101376899835 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Mon, 10 Aug 2026 10:11:37 +0800 Subject: [PATCH 7/7] perf: memoize generateQueue results within a single download run The pending-artifacts log filter computes each artifact's queue, and downloadWithType() computes it again, doubling the sha1_file/git rev-parse hash checks. Queues only depend on each artifact's own cache files, so memoize them per download() run. --- src/StaticPHP/Artifact/ArtifactDownloader.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/StaticPHP/Artifact/ArtifactDownloader.php b/src/StaticPHP/Artifact/ArtifactDownloader.php index 3dc0a628c..db5df2080 100644 --- a/src/StaticPHP/Artifact/ArtifactDownloader.php +++ b/src/StaticPHP/Artifact/ArtifactDownloader.php @@ -90,6 +90,9 @@ class ArtifactDownloader private array $_before_files; + /** @var array Memoized generateQueue() results, valid for one download() run (queues only depend on each artifact's own cache files) */ + private array $queue_memo = []; + /** * @param array{ * parallel?: int, @@ -319,6 +322,8 @@ public function download(): void if (!is_dir(DOWNLOAD_PATH)) { FileSystem::createDir(DOWNLOAD_PATH); } + // fresh memo for this run: queues reflect pre-download cache state + $this->queue_memo = []; $pending = array_values(array_filter($this->artifacts, fn ($a) => $this->generateQueue($a) !== [])); if ($pending !== []) { logger()->info('Downloading' . implode(', ', array_map(fn ($x) => " '{$x->getName()}'", $pending)) . " with concurrency {$this->parallel} ..."); @@ -752,6 +757,10 @@ private function downloadWithConcurrency(): void */ private function generateQueue(Artifact $artifact): array { + $memo_key = $artifact->getName(); + if (isset($this->queue_memo[$memo_key])) { + return $this->queue_memo[$memo_key]; + } /** @var array $queue */ $queue = []; $binary_downloaded = $artifact->isBinaryDownloaded(compare_hash: true); @@ -820,7 +829,7 @@ private function generateQueue(Artifact $artifact): array if (empty($queue)) { throw new ValidationException("Artifact '{$artifact->getName()}' does not provide any download source for current platform (" . SystemTarget::getCurrentPlatformString() . ').'); } - return $queue; + return $this->queue_memo[$memo_key] = $queue; } // check if already downloaded @@ -841,7 +850,7 @@ private function generateQueue(Artifact $artifact): array // if already downloaded, skip if ($has_usable_download) { - return []; + return $this->queue_memo[$memo_key] = []; } // validate: ensure at least one download source is available @@ -856,7 +865,7 @@ private function generateQueue(Artifact $artifact): array throw new ValidationException("Validation failed: Artifact '{$artifact->getName()}' does not provide any download source for current platform (" . SystemTarget::getCurrentPlatformString() . ').'); } - return $queue; + return $this->queue_memo[$memo_key] = $queue; } private function applyCustomDownloads(): void