Created
December 4, 2025 13:13
-
-
Save adamziel/44ba68b4d3285655eccb9a6d4712b35f to your computer and use it in GitHub Desktop.
Composer fast resolve
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env php | |
| <?php | |
| /** | |
| * Composer Fast Resolve - Fast parallel dependency resolution | |
| * | |
| * Resolves all transitive dependencies from a composer.json and outputs | |
| * a new composer.json with all dependencies explicitly listed. | |
| * | |
| * Usage: | |
| * php composer-shrinkwrap.php composer.json > composer-shrinkwrap.json | |
| * php composer-shrinkwrap.php composer.json --no-dev | |
| * php composer-shrinkwrap.php composer.json -o output.json | |
| */ | |
| declare(strict_types=1); | |
| // Configuration | |
| const MAX_WORKERS = 20; | |
| const PACKAGIST_URL = 'https://repo.packagist.org/p2/'; | |
| const USER_AGENT = 'ComposerShrinkwrap/1.0'; | |
| /** | |
| * Main entry point | |
| */ | |
| function main(array $argv): int | |
| { | |
| $args = parseArgs($argv); | |
| if ($args['help'] || !$args['input']) { | |
| printUsage(); | |
| return $args['help'] ? 0 : 1; | |
| } | |
| if (!file_exists($args['input'])) { | |
| fwrite(STDERR, "Error: File not found: {$args['input']}\n"); | |
| return 1; | |
| } | |
| $rootComposer = json_decode(file_get_contents($args['input']), true); | |
| if (!$rootComposer) { | |
| fwrite(STDERR, "Error: Invalid JSON in {$args['input']}\n"); | |
| return 1; | |
| } | |
| fwrite(STDERR, "Resolving dependencies from: {$args['input']}\n"); | |
| fwrite(STDERR, "Include dev dependencies: " . ($args['include-dev'] ? 'yes' : 'no') . "\n"); | |
| $startTime = microtime(true); | |
| $result = resolveDependencies($rootComposer, $args['include-dev']); | |
| $elapsed = microtime(true) - $startTime; | |
| fwrite(STDERR, sprintf("\nResolved %d packages in %.2f seconds\n", count($result['packages']), $elapsed)); | |
| if (!empty($result['errors'])) { | |
| fwrite(STDERR, "\nWarnings:\n"); | |
| foreach ($result['errors'] as $error) { | |
| fwrite(STDERR, " - {$error}\n"); | |
| } | |
| } | |
| // Build output composer.json | |
| $output = buildShrinkwrapComposer($rootComposer, $result, $args['include-dev']); | |
| $json = json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; | |
| if ($args['output']) { | |
| file_put_contents($args['output'], $json); | |
| fwrite(STDERR, "\nWritten to: {$args['output']}\n"); | |
| } else { | |
| echo $json; | |
| } | |
| return 0; | |
| } | |
| /** | |
| * Parse command line arguments | |
| */ | |
| function parseArgs(array $argv): array | |
| { | |
| $args = [ | |
| 'input' => null, | |
| 'output' => null, | |
| 'include-dev' => true, | |
| 'help' => false, | |
| ]; | |
| for ($i = 1; $i < count($argv); $i++) { | |
| $arg = $argv[$i]; | |
| if ($arg === '--help' || $arg === '-h') { | |
| $args['help'] = true; | |
| } elseif ($arg === '--no-dev') { | |
| $args['include-dev'] = false; | |
| } elseif ($arg === '-o' || $arg === '--output') { | |
| $args['output'] = $argv[++$i] ?? null; | |
| } elseif (!str_starts_with($arg, '-')) { | |
| $args['input'] = $arg; | |
| } | |
| } | |
| return $args; | |
| } | |
| /** | |
| * Print usage information | |
| */ | |
| function printUsage(): void | |
| { | |
| fwrite(STDERR, <<<USAGE | |
| Composer Shrinkwrap - Fast parallel dependency resolution | |
| Usage: | |
| php composer-shrinkwrap.php <composer.json> [options] | |
| Options: | |
| -o, --output <file> Write output to file instead of stdout | |
| --no-dev Exclude dev dependencies | |
| -h, --help Show this help message | |
| Examples: | |
| php composer-shrinkwrap.php composer.json > shrinkwrap.json | |
| php composer-shrinkwrap.php composer.json --no-dev -o production.json | |
| USAGE); | |
| } | |
| /** | |
| * Resolve all dependencies using curl_multi or stream sockets | |
| */ | |
| function resolveDependencies(array $rootComposer, bool $includeDev): array | |
| { | |
| $require = $rootComposer['require'] ?? []; | |
| $requireDev = $includeDev ? ($rootComposer['require-dev'] ?? []) : []; | |
| // Filter platform packages and build initial constraint map | |
| // $allConstraints[package] = [['constraint' => '...', 'from' => '...'], ...] | |
| $allConstraints = []; | |
| $rootName = $rootComposer['name'] ?? 'root'; | |
| foreach ($require as $name => $constraint) { | |
| if (!isPlatformPackage($name)) { | |
| $allConstraints[$name][] = ['constraint' => $constraint, 'from' => $rootName]; | |
| } | |
| } | |
| foreach ($requireDev as $name => $constraint) { | |
| if (!isPlatformPackage($name)) { | |
| $allConstraints[$name][] = ['constraint' => $constraint, 'from' => $rootName . ' (dev)']; | |
| } | |
| } | |
| $pendingPackagist = array_keys($allConstraints); | |
| $pendingGit = []; | |
| $seen = array_flip($pendingPackagist); | |
| $packageMetadata = []; | |
| $resolved = []; | |
| $graph = []; | |
| $errors = []; | |
| $replaces = []; // Track packages replaced by others | |
| $total = count($pendingPackagist); | |
| $completed = 0; | |
| if (function_exists('curl_multi_init')) { | |
| resolveWithCurl( | |
| $pendingPackagist, $pendingGit, $seen, $allConstraints, | |
| $packageMetadata, $resolved, $graph, $errors, $total, $completed, | |
| $replaces | |
| ); | |
| } else { | |
| resolveWithStreams( | |
| $pendingPackagist, $pendingGit, $seen, $allConstraints, | |
| $packageMetadata, $resolved, $graph, $errors, $total, $completed, | |
| $replaces | |
| ); | |
| } | |
| fwrite(STDERR, "\r" . str_repeat(' ', 60) . "\r"); | |
| return [ | |
| 'packages' => $resolved, | |
| 'graph' => $graph, | |
| 'constraints' => $allConstraints, | |
| 'metadata' => $packageMetadata, | |
| 'errors' => $errors, | |
| 'replaces' => $replaces, | |
| ]; | |
| } | |
| /** | |
| * Check if a package name is a platform package (php, ext-*, lib-*) | |
| */ | |
| function isPlatformPackage(string $name): bool | |
| { | |
| return $name === 'php' | |
| || str_starts_with($name, 'ext-') | |
| || str_starts_with($name, 'lib-') | |
| || str_starts_with($name, 'composer-'); | |
| } | |
| /** | |
| * Resolve using curl_multi for parallel HTTP requests | |
| */ | |
| function resolveWithCurl( | |
| array &$pendingPackagist, | |
| array &$pendingGit, | |
| array &$seen, | |
| array &$constraints, | |
| array &$packageMetadata, | |
| array &$resolved, | |
| array &$graph, | |
| array &$errors, | |
| int &$total, | |
| int &$completed, | |
| array &$replaces = [] | |
| ): void { | |
| $multiHandle = curl_multi_init(); | |
| $handles = []; | |
| while (!empty($pendingPackagist) || !empty($pendingGit) || !empty($handles)) { | |
| // Add Packagist requests | |
| while (count($handles) < MAX_WORKERS && !empty($pendingPackagist)) { | |
| $packageName = array_shift($pendingPackagist); | |
| $url = PACKAGIST_URL . $packageName . '.json'; | |
| $ch = curl_init($url); | |
| curl_setopt_array($ch, [ | |
| CURLOPT_RETURNTRANSFER => true, | |
| CURLOPT_FOLLOWLOCATION => true, | |
| CURLOPT_TIMEOUT => 30, | |
| CURLOPT_CONNECTTIMEOUT => 10, | |
| CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: ' . USER_AGENT], | |
| ]); | |
| curl_multi_add_handle($multiHandle, $ch); | |
| $handles[(int)$ch] = ['handle' => $ch, 'package' => $packageName, 'type' => 'packagist']; | |
| } | |
| // Add Git requests | |
| while (count($handles) < MAX_WORKERS && !empty($pendingGit)) { | |
| $item = array_shift($pendingGit); | |
| $ch = curl_init($item['url']); | |
| curl_setopt_array($ch, [ | |
| CURLOPT_RETURNTRANSFER => true, | |
| CURLOPT_FOLLOWLOCATION => true, | |
| CURLOPT_TIMEOUT => 30, | |
| CURLOPT_CONNECTTIMEOUT => 10, | |
| CURLOPT_HTTPHEADER => ['User-Agent: ' . USER_AGENT], | |
| ]); | |
| curl_multi_add_handle($multiHandle, $ch); | |
| $handles[(int)$ch] = array_merge($item, ['handle' => $ch, 'type' => 'git']); | |
| } | |
| // Execute requests | |
| curl_multi_exec($multiHandle, $running); | |
| // Process completed requests | |
| while ($info = curl_multi_info_read($multiHandle)) { | |
| $ch = $info['handle']; | |
| $id = (int)$ch; | |
| if (!isset($handles[$id])) continue; | |
| $handleInfo = $handles[$id]; | |
| $packageName = $handleInfo['package']; | |
| $response = curl_multi_getcontent($ch); | |
| $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); | |
| curl_multi_remove_handle($multiHandle, $ch); | |
| curl_close($ch); | |
| unset($handles[$id]); | |
| if ($handleInfo['type'] === 'packagist') { | |
| handlePackagistResponse( | |
| $packageName, $response, $httpCode, | |
| $pendingGit, $constraints, $packageMetadata, $errors | |
| ); | |
| } else { | |
| $newDeps = handleGitResponse( | |
| $packageName, $response, $httpCode, $handleInfo, | |
| $pendingPackagist, $pendingGit, $seen, $constraints, | |
| $resolved, $graph, $errors, $replaces | |
| ); | |
| $total += $newDeps; | |
| $completed++; | |
| showProgress($completed, $total); | |
| } | |
| } | |
| // Wait for activity | |
| if (!empty($handles)) { | |
| curl_multi_select($multiHandle, 0.01); | |
| } | |
| } | |
| curl_multi_close($multiHandle); | |
| } | |
| /** | |
| * Resolve using stream sockets (fallback when curl unavailable) | |
| */ | |
| function resolveWithStreams( | |
| array &$pendingPackagist, | |
| array &$pendingGit, | |
| array &$seen, | |
| array &$constraints, | |
| array &$packageMetadata, | |
| array &$resolved, | |
| array &$graph, | |
| array &$errors, | |
| int &$total, | |
| int &$completed, | |
| array &$replaces = [] | |
| ): void { | |
| $sockets = []; | |
| while (!empty($pendingPackagist) || !empty($pendingGit) || !empty($sockets)) { | |
| // Connect Packagist requests | |
| $toConnect = MAX_WORKERS - count($sockets); | |
| for ($i = 0; $i < $toConnect && !empty($pendingPackagist); $i++) { | |
| $packageName = array_shift($pendingPackagist); | |
| $socket = createHttpSocket('repo.packagist.org', "/p2/{$packageName}.json", ['Accept: application/json']); | |
| if ($socket) { | |
| $sockets[(int)$socket] = [ | |
| 'socket' => $socket, | |
| 'package' => $packageName, | |
| 'type' => 'packagist', | |
| 'buffer' => '', | |
| ]; | |
| } | |
| } | |
| // Connect Git requests | |
| $toConnect = MAX_WORKERS - count($sockets); | |
| for ($i = 0; $i < $toConnect && !empty($pendingGit); $i++) { | |
| $item = array_shift($pendingGit); | |
| $repoInfo = $item['repo_info']; | |
| $refs = $item['refs'] ?? []; | |
| $refIndex = $item['ref_index'] ?? 0; | |
| $currentRef = $refs[$refIndex] ?? ['ref' => 'main', 'isTag' => false]; | |
| // Build path using the appropriate format | |
| if ($repoInfo['host'] === 'codeberg' && !$currentRef['isTag'] && isset($repoInfo['branch_path_format'])) { | |
| $format = $repoInfo['branch_path_format']; | |
| } else { | |
| $format = $repoInfo['raw_path_format']; | |
| } | |
| $path = sprintf($format, $repoInfo['owner'], $repoInfo['repo'], $currentRef['ref']); | |
| $socket = createHttpSocket($repoInfo['raw_host'], $path); | |
| if ($socket) { | |
| $sockets[(int)$socket] = array_merge($item, [ | |
| 'socket' => $socket, | |
| 'type' => 'git', | |
| 'buffer' => '', | |
| ]); | |
| } | |
| } | |
| if (empty($sockets)) break; | |
| $read = array_map(fn($s) => $s['socket'], $sockets); | |
| $write = null; | |
| $except = null; | |
| if (@stream_select($read, $write, $except, 1, 0) === false) break; | |
| foreach ($read as $socket) { | |
| $id = (int)$socket; | |
| if (!isset($sockets[$id])) continue; | |
| $data = @fread($socket, 65536); | |
| if ($data === false || ($data === '' && feof($socket))) { | |
| $info = $sockets[$id]; | |
| fclose($socket); | |
| unset($sockets[$id]); | |
| $response = parseHttpResponse($info['buffer']); | |
| if ($info['type'] === 'packagist') { | |
| handlePackagistResponse( | |
| $info['package'], $response['body'], $response['code'], | |
| $pendingGit, $constraints, $packageMetadata, $errors | |
| ); | |
| } else { | |
| $newDeps = handleGitResponse( | |
| $info['package'], $response['body'], $response['code'], $info, | |
| $pendingPackagist, $pendingGit, $seen, $constraints, | |
| $resolved, $graph, $errors, $replaces | |
| ); | |
| $total += $newDeps; | |
| $completed++; | |
| showProgress($completed, $total); | |
| } | |
| } elseif ($data !== '') { | |
| $sockets[$id]['buffer'] .= $data; | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Create an HTTPS socket connection | |
| */ | |
| function createHttpSocket(string $host, string $path, array $extraHeaders = []): mixed | |
| { | |
| $context = stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]); | |
| $socket = @stream_socket_client("ssl://{$host}:443", $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $context); | |
| if (!$socket) return null; | |
| $headers = array_merge([ | |
| "GET {$path} HTTP/1.1", | |
| "Host: {$host}", | |
| "User-Agent: " . USER_AGENT, | |
| "Connection: close", | |
| ], $extraHeaders, ["", ""]); | |
| fwrite($socket, implode("\r\n", $headers)); | |
| stream_set_blocking($socket, false); | |
| return $socket; | |
| } | |
| /** | |
| * Parse HTTP response | |
| */ | |
| function parseHttpResponse(string $response): array | |
| { | |
| $parts = explode("\r\n\r\n", $response, 2); | |
| $headers = $parts[0] ?? ''; | |
| $body = $parts[1] ?? ''; | |
| $code = 0; | |
| if (preg_match('/HTTP\/\d\.\d\s+(\d+)/', $headers, $matches)) { | |
| $code = (int)$matches[1]; | |
| } | |
| if (stripos($headers, 'Transfer-Encoding: chunked') !== false) { | |
| $body = decodeChunked($body); | |
| } | |
| return ['code' => $code, 'body' => $body]; | |
| } | |
| /** | |
| * Decode chunked transfer encoding | |
| */ | |
| function decodeChunked(string $body): string | |
| { | |
| $decoded = ''; | |
| $pos = 0; | |
| while ($pos < strlen($body)) { | |
| $lineEnd = strpos($body, "\r\n", $pos); | |
| if ($lineEnd === false) break; | |
| $chunkSize = hexdec(substr($body, $pos, $lineEnd - $pos)); | |
| if ($chunkSize === 0) break; | |
| $pos = $lineEnd + 2; | |
| $decoded .= substr($body, $pos, $chunkSize); | |
| $pos += $chunkSize + 2; | |
| } | |
| return $decoded; | |
| } | |
| /** | |
| * Parse repository URL and return hosting info | |
| * Supports GitHub, GitLab, and Codeberg | |
| */ | |
| function parseRepoUrl(string $repoUrl): ?array | |
| { | |
| // GitHub: github.com/owner/repo or git@github.com:owner/repo | |
| if (preg_match('#github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$#', $repoUrl, $matches)) { | |
| return [ | |
| 'host' => 'github', | |
| 'owner' => $matches[1], | |
| 'repo' => $matches[2], | |
| 'raw_host' => 'raw.githubusercontent.com', | |
| 'raw_path_format' => '/%s/%s/%s/composer.json', // owner/repo/ref | |
| ]; | |
| } | |
| // GitLab: gitlab.com/owner/repo or git@gitlab.com:owner/repo | |
| // GitLab raw URL format: gitlab.com/owner/repo/-/raw/ref/file | |
| if (preg_match('#gitlab\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$#', $repoUrl, $matches)) { | |
| return [ | |
| 'host' => 'gitlab', | |
| 'owner' => $matches[1], | |
| 'repo' => $matches[2], | |
| 'raw_host' => 'gitlab.com', | |
| 'raw_path_format' => '/%s/%s/-/raw/%s/composer.json', // owner/repo/ref | |
| ]; | |
| } | |
| // Codeberg: codeberg.org/owner/repo or git@codeberg.org:owner/repo | |
| // Codeberg raw URL format: codeberg.org/owner/repo/raw/tag/ref/file | |
| if (preg_match('#codeberg\.org[:/]([^/]+)/([^/]+?)(?:\.git)?$#', $repoUrl, $matches)) { | |
| return [ | |
| 'host' => 'codeberg', | |
| 'owner' => $matches[1], | |
| 'repo' => $matches[2], | |
| 'raw_host' => 'codeberg.org', | |
| 'raw_path_format' => '/%s/%s/raw/tag/%s/composer.json', // owner/repo/ref (for tags) | |
| 'branch_path_format' => '/%s/%s/raw/branch/%s/composer.json', // owner/repo/ref (for branches) | |
| ]; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Build raw file URL for a repository | |
| * $ref can be a tag (v1.2.3), branch (main), or commit hash | |
| * $isTag indicates if this is a version tag (affects Codeberg URLs) | |
| */ | |
| function buildRawUrl(array $repoInfo, string $ref, bool $isTag = true): string | |
| { | |
| // Codeberg uses different paths for tags vs branches | |
| if ($repoInfo['host'] === 'codeberg' && !$isTag && isset($repoInfo['branch_path_format'])) { | |
| $format = $repoInfo['branch_path_format']; | |
| } else { | |
| $format = $repoInfo['raw_path_format']; | |
| } | |
| $path = sprintf( | |
| $format, | |
| $repoInfo['owner'], | |
| $repoInfo['repo'], | |
| $ref | |
| ); | |
| return "https://{$repoInfo['raw_host']}{$path}"; | |
| } | |
| /** | |
| * Convert a version string to possible Git tag names | |
| * e.g., "1.2.3" -> ["v1.2.3", "1.2.3"] | |
| */ | |
| function versionToTags(string $version): array | |
| { | |
| // Remove any 'v' prefix first to normalize | |
| $normalized = ltrim($version, 'vV'); | |
| // Try common tag formats | |
| return [ | |
| 'v' . $normalized, // v1.2.3 (most common) | |
| $normalized, // 1.2.3 | |
| ]; | |
| } | |
| /** | |
| * Get the actual PHP version we're running on | |
| * Returns version in X.Y.Z format | |
| */ | |
| function getActualPhpVersion(): string | |
| { | |
| return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION; | |
| } | |
| /** | |
| * Extract the minimum PHP version from a constraint | |
| * e.g., "^8.2" -> "8.2.0", ">=8.1" -> "8.1.0" | |
| */ | |
| function extractMinPhpVersion(string $constraint): ?string | |
| { | |
| // Handle caret: ^8.2 -> 8.2.0 | |
| if (preg_match('/^\^(\d+)\.(\d+)/', $constraint, $m)) { | |
| return "{$m[1]}.{$m[2]}.0"; | |
| } | |
| // Handle tilde: ~8.2 -> 8.2.0 | |
| if (preg_match('/^~(\d+)\.(\d+)/', $constraint, $m)) { | |
| return "{$m[1]}.{$m[2]}.0"; | |
| } | |
| // Handle >= : >=8.1 -> 8.1.0 | |
| if (preg_match('/^>=\s*(\d+\.\d+(?:\.\d+)?)/', $constraint, $m)) { | |
| $parts = explode('.', $m[1]); | |
| while (count($parts) < 3) $parts[] = '0'; | |
| return implode('.', $parts); | |
| } | |
| // Handle exact: 8.2.0 -> 8.2.0 | |
| if (preg_match('/^(\d+\.\d+(?:\.\d+)?)$/', $constraint, $m)) { | |
| $parts = explode('.', $m[1]); | |
| while (count($parts) < 3) $parts[] = '0'; | |
| return implode('.', $parts); | |
| } | |
| // For OR constraints, take the minimum from all parts | |
| if (str_contains($constraint, '|')) { | |
| $parts = preg_split('/\s*\|\|?\s*/', $constraint); | |
| $mins = []; | |
| foreach ($parts as $part) { | |
| $min = extractMinPhpVersion(trim($part)); | |
| if ($min) $mins[] = $min; | |
| } | |
| if (!empty($mins)) { | |
| usort($mins, 'version_compare'); | |
| return $mins[0]; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Handle Packagist API response | |
| */ | |
| function handlePackagistResponse( | |
| string $packageName, | |
| ?string $response, | |
| int $httpCode, | |
| array &$pendingGit, | |
| array &$allConstraints, | |
| array &$packageMetadata, | |
| array &$errors | |
| ): void { | |
| if ($httpCode !== 200 || !$response) { | |
| $errors[] = "Packagist lookup failed for {$packageName} (HTTP {$httpCode})"; | |
| return; | |
| } | |
| $data = json_decode($response, true); | |
| if (!$data || !isset($data['packages'][$packageName])) { | |
| $errors[] = "Invalid Packagist response for {$packageName}"; | |
| return; | |
| } | |
| $versions = expandMinifiedMetadata($data['packages'][$packageName]); | |
| // Filter versions that are compatible with the PHP constraint | |
| // We use the ACTUAL running PHP version to ensure compatibility | |
| // This avoids selecting packages that satisfy ^8.2 but fail on PHP 8.3 | |
| $actualPhp = getActualPhpVersion(); | |
| $versions = array_filter($versions, function($v) use ($actualPhp) { | |
| $pkgPhpReq = $v['require']['php'] ?? null; | |
| if (!$pkgPhpReq) return true; // No PHP requirement, assume compatible | |
| return versionSatisfies($actualPhp, $pkgPhpReq); | |
| }); | |
| $versions = array_values($versions); // Re-index | |
| $packageMetadata[$packageName] = $versions; | |
| // Extract just the constraint strings for version selection | |
| $constraintStrings = array_map( | |
| fn($c) => $c['constraint'], | |
| $allConstraints[$packageName] ?? [['constraint' => '*']] | |
| ); | |
| $version = selectVersion($versions, $constraintStrings); | |
| if (!$version) { | |
| // No version satisfies all constraints - report conflict | |
| $constraintInfo = $allConstraints[$packageName] ?? []; | |
| $conflictDetails = []; | |
| foreach ($constraintInfo as $c) { | |
| $conflictDetails[] = "{$c['constraint']} (from {$c['from']})"; | |
| } | |
| $errors[] = "Version conflict for {$packageName}: no version satisfies " . implode(' AND ', $conflictDetails); | |
| return; | |
| } | |
| if (!isset($version['source']['url'])) { | |
| $errors[] = "No source URL found for {$packageName}"; | |
| return; | |
| } | |
| $repoUrl = $version['source']['url']; | |
| $repoInfo = parseRepoUrl($repoUrl); | |
| $selectedVersion = $version['version'] ?? null; | |
| if ($repoInfo && $selectedVersion) { | |
| // Use the selected version's Git reference (tag) to fetch the correct composer.json | |
| // This ensures we get dependencies for the specific version, not latest | |
| $sourceRef = $version['source']['reference'] ?? null; | |
| $tags = versionToTags($selectedVersion); | |
| // Try source reference first (commit hash), then version tags, then branches | |
| $refs = []; | |
| if ($sourceRef) { | |
| $refs[] = ['ref' => $sourceRef, 'isTag' => false]; | |
| } | |
| foreach ($tags as $tag) { | |
| $refs[] = ['ref' => $tag, 'isTag' => true]; | |
| } | |
| // Fallback to default branches if version tags fail | |
| $refs[] = ['ref' => 'main', 'isTag' => false]; | |
| $refs[] = ['ref' => 'master', 'isTag' => false]; | |
| $firstRef = $refs[0]; | |
| $pendingGit[] = [ | |
| 'package' => $packageName, | |
| 'url' => buildRawUrl($repoInfo, $firstRef['ref'], $firstRef['isTag']), | |
| 'repo_info' => $repoInfo, | |
| 'refs' => $refs, | |
| 'ref_index' => 0, | |
| 'version' => $selectedVersion, | |
| ]; | |
| } elseif (!$repoInfo) { | |
| $errors[] = "Unsupported source host for {$packageName}: {$repoUrl}"; | |
| } else { | |
| $errors[] = "No version found for {$packageName}"; | |
| } | |
| } | |
| /** | |
| * Handle Git response | |
| */ | |
| function handleGitResponse( | |
| string $packageName, | |
| ?string $response, | |
| int $httpCode, | |
| array $handleInfo, | |
| array &$pendingPackagist, | |
| array &$pendingGit, | |
| array &$seen, | |
| array &$allConstraints, | |
| array &$resolved, | |
| array &$graph, | |
| array &$errors, | |
| array &$replaces = [] | |
| ): int { | |
| if ($httpCode === 200 && $response) { | |
| $composer = json_decode($response, true); | |
| if (!$composer) { | |
| $errors[] = "Invalid composer.json for {$packageName}"; | |
| return 0; | |
| } | |
| $resolvedVersion = $handleInfo['version'] ?? null; | |
| $resolved[$packageName] = [ | |
| 'composer' => $composer, | |
| 'version' => $resolvedVersion, | |
| ]; | |
| // Track packages that this package replaces | |
| $packageReplaces = $composer['replace'] ?? []; | |
| foreach ($packageReplaces as $replacedPkg => $replacedVersion) { | |
| // "self.version" means use the package's own version | |
| if ($replacedVersion === 'self.version') { | |
| $replacedVersion = $resolvedVersion; | |
| } | |
| $replaces[$replacedPkg] = [ | |
| 'by' => $packageName, | |
| 'version' => $replacedVersion, | |
| ]; | |
| // Mark as seen so we don't try to resolve it separately | |
| $seen[$replacedPkg] = true; | |
| } | |
| $deps = $composer['require'] ?? []; | |
| $filteredDeps = []; | |
| foreach ($deps as $name => $constraint) { | |
| if (!isPlatformPackage($name)) { | |
| $filteredDeps[$name] = $constraint; | |
| } | |
| } | |
| $graph[$packageName] = array_keys($filteredDeps); | |
| $newCount = 0; | |
| foreach ($filteredDeps as $depName => $depConstraint) { | |
| // Skip if this package is replaced by another | |
| if (isset($replaces[$depName])) { | |
| continue; | |
| } | |
| // Always add the constraint with source tracking | |
| $allConstraints[$depName][] = [ | |
| 'constraint' => $depConstraint, | |
| 'from' => $packageName, | |
| ]; | |
| if (!isset($seen[$depName])) { | |
| $seen[$depName] = true; | |
| $pendingPackagist[] = $depName; | |
| $newCount++; | |
| } | |
| } | |
| return $newCount; | |
| } | |
| // Try next ref (commit hash, tag, or branch) if available | |
| $repoInfo = $handleInfo['repo_info'] ?? null; | |
| $refs = $handleInfo['refs'] ?? []; | |
| $refIndex = ($handleInfo['ref_index'] ?? 0) + 1; | |
| if ($repoInfo && isset($refs[$refIndex])) { | |
| $nextRef = $refs[$refIndex]; | |
| $pendingGit[] = [ | |
| 'package' => $packageName, | |
| 'url' => buildRawUrl($repoInfo, $nextRef['ref'], $nextRef['isTag']), | |
| 'repo_info' => $repoInfo, | |
| 'refs' => $refs, | |
| 'ref_index' => $refIndex, | |
| 'version' => $handleInfo['version'] ?? null, | |
| ]; | |
| } else { | |
| $host = $repoInfo['host'] ?? 'unknown'; | |
| $errors[] = "Failed to fetch composer.json for {$packageName} from {$host}"; | |
| } | |
| return 0; | |
| } | |
| /** | |
| * Expand minified Packagist metadata | |
| */ | |
| function expandMinifiedMetadata(array $versions): array | |
| { | |
| $expanded = []; | |
| $previous = []; | |
| foreach ($versions as $version) { | |
| $current = array_merge($previous, $version); | |
| $expanded[] = $current; | |
| $previous = $current; | |
| } | |
| return $expanded; | |
| } | |
| /** | |
| * Normalize a version string for comparison | |
| * Handles: v1.2.3, 1.2.3, 1.2, 1.2.3.4, etc. | |
| */ | |
| function normalizeVersion(string $version): ?string | |
| { | |
| // Remove 'v' prefix | |
| $version = ltrim($version, 'vV'); | |
| // Handle dev versions | |
| if (str_starts_with($version, 'dev-')) { | |
| return null; // Can't normalize dev versions | |
| } | |
| // Extract base version (before any stability suffix) | |
| if (preg_match('/^(\d+(?:\.\d+)*)/', $version, $matches)) { | |
| $parts = explode('.', $matches[1]); | |
| // Pad to at least 3 parts | |
| while (count($parts) < 3) { | |
| $parts[] = '0'; | |
| } | |
| return implode('.', $parts); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Check if a version is stable (not dev, alpha, beta, RC) | |
| */ | |
| function isStableVersion(string $version): bool | |
| { | |
| return !str_starts_with($version, 'dev-') | |
| && !preg_match('/-(?:alpha|beta|RC|rc|a|b)\d*$/i', $version); | |
| } | |
| /** | |
| * Parse a single version constraint into components | |
| * Returns: ['op' => '>=', 'version' => '1.2.3'] or null for complex/unparseable | |
| */ | |
| function parseConstraintPart(string $constraint): ?array | |
| { | |
| $constraint = trim($constraint); | |
| // Handle exact version: 1.2.3, v1.2.3 | |
| if (preg_match('/^v?(\d+(?:\.\d+)*)$/', $constraint, $m)) { | |
| return ['op' => '=', 'version' => normalizeVersion($m[1])]; | |
| } | |
| // Handle caret: ^1.2.3 (>=1.2.3 <2.0.0 for major>0, >=0.2.3 <0.3.0 for major=0) | |
| if (preg_match('/^\^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/', $constraint, $m)) { | |
| $major = (int)$m[1]; | |
| $minor = isset($m[2]) ? (int)$m[2] : 0; | |
| $patch = isset($m[3]) ? (int)$m[3] : 0; | |
| if ($major === 0) { | |
| // ^0.x.y means >=0.x.y <0.(x+1).0 | |
| return [ | |
| 'op' => 'range', | |
| 'min' => sprintf('%d.%d.%d', $major, $minor, $patch), | |
| 'max' => sprintf('%d.%d.0', $major, $minor + 1), | |
| ]; | |
| } | |
| return [ | |
| 'op' => 'range', | |
| 'min' => sprintf('%d.%d.%d', $major, $minor, $patch), | |
| 'max' => sprintf('%d.0.0', $major + 1), | |
| ]; | |
| } | |
| // Handle tilde: ~1.2.3 (>=1.2.3 <1.3.0), ~1.2 (>=1.2.0 <2.0.0) | |
| if (preg_match('/^~v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/', $constraint, $m)) { | |
| $major = (int)$m[1]; | |
| $minor = isset($m[2]) ? (int)$m[2] : null; | |
| $patch = isset($m[3]) ? (int)$m[3] : 0; | |
| if ($minor === null) { | |
| // ~1 means >=1.0.0 <2.0.0 | |
| return [ | |
| 'op' => 'range', | |
| 'min' => sprintf('%d.0.0', $major), | |
| 'max' => sprintf('%d.0.0', $major + 1), | |
| ]; | |
| } | |
| if (!isset($m[3])) { | |
| // ~1.2 means >=1.2.0 <2.0.0 | |
| return [ | |
| 'op' => 'range', | |
| 'min' => sprintf('%d.%d.0', $major, $minor), | |
| 'max' => sprintf('%d.0.0', $major + 1), | |
| ]; | |
| } | |
| // ~1.2.3 means >=1.2.3 <1.3.0 | |
| return [ | |
| 'op' => 'range', | |
| 'min' => sprintf('%d.%d.%d', $major, $minor, $patch), | |
| 'max' => sprintf('%d.%d.0', $major, $minor + 1), | |
| ]; | |
| } | |
| // Handle comparison operators: >=1.2.3, <=1.2.3, >1.2.3, <1.2.3, =1.2.3, !=1.2.3 | |
| // Note: Must have $ anchor to avoid matching "8.1" from "8.1 - 8.5" (hyphen range) | |
| if (preg_match('/^(>=|<=|>|<|!=|=)?v?(\d+(?:\.\d+)*)$/', $constraint, $m)) { | |
| $op = $m[1] ?: '='; | |
| return ['op' => $op, 'version' => normalizeVersion($m[2])]; | |
| } | |
| // Handle wildcard: 1.2.*, 1.* | |
| if (preg_match('/^v?(\d+)(?:\.(\d+))?\.\*$/', $constraint, $m)) { | |
| $major = (int)$m[1]; | |
| if (isset($m[2])) { | |
| $minor = (int)$m[2]; | |
| return [ | |
| 'op' => 'range', | |
| 'min' => sprintf('%d.%d.0', $major, $minor), | |
| 'max' => sprintf('%d.%d.0', $major, $minor + 1), | |
| ]; | |
| } | |
| return [ | |
| 'op' => 'range', | |
| 'min' => sprintf('%d.0.0', $major), | |
| 'max' => sprintf('%d.0.0', $major + 1), | |
| ]; | |
| } | |
| // Handle hyphen range: "1.0 - 2.0" means >=1.0.0 <2.1.0 (or <3.0.0 for "1 - 2") | |
| // The upper bound is inclusive, but partial versions are filled: "8.3" means "<8.4" | |
| if (preg_match('/^v?(\d+(?:\.\d+)*)\s+-\s+v?(\d+(?:\.\d+)*)$/', $constraint, $m)) { | |
| $minParts = explode('.', $m[1]); | |
| $maxParts = explode('.', $m[2]); | |
| // Normalize min to X.Y.Z | |
| while (count($minParts) < 3) $minParts[] = '0'; | |
| $min = implode('.', $minParts); | |
| // For max: if partial (e.g. "8.3"), treat as "<next version" | |
| // "8.3" -> "<8.4.0", "8" -> "<9.0.0", "8.3.5" -> "<8.3.6" (exclusive) | |
| if (count($maxParts) === 1) { | |
| // Single number: "2" means <3.0.0 | |
| $max = sprintf('%d.0.0', (int)$maxParts[0] + 1); | |
| } elseif (count($maxParts) === 2) { | |
| // Two numbers: "8.3" means <8.4.0 | |
| $max = sprintf('%d.%d.0', (int)$maxParts[0], (int)$maxParts[1] + 1); | |
| } else { | |
| // Full version: "8.3.5" means <=8.3.5, so <8.3.6 | |
| $max = sprintf('%d.%d.%d', (int)$maxParts[0], (int)$maxParts[1], (int)$maxParts[2] + 1); | |
| } | |
| return [ | |
| 'op' => 'range', | |
| 'min' => $min, | |
| 'max' => $max, | |
| ]; | |
| } | |
| // Handle dev-* (always matches dev versions with that name) | |
| if (str_starts_with($constraint, 'dev-')) { | |
| return ['op' => 'dev', 'branch' => substr($constraint, 4)]; | |
| } | |
| // Unparseable | |
| return null; | |
| } | |
| /** | |
| * Check if a version satisfies a single parsed constraint | |
| */ | |
| function versionSatisfiesConstraint(string $version, array $parsed): bool | |
| { | |
| $normalized = normalizeVersion($version); | |
| if ($normalized === null) { | |
| // Dev version - only matches dev constraints | |
| if ($parsed['op'] === 'dev') { | |
| return str_contains($version, $parsed['branch']); | |
| } | |
| return false; | |
| } | |
| switch ($parsed['op']) { | |
| case '=': | |
| return version_compare($normalized, $parsed['version'], '='); | |
| case '!=': | |
| return version_compare($normalized, $parsed['version'], '!='); | |
| case '>': | |
| return version_compare($normalized, $parsed['version'], '>'); | |
| case '>=': | |
| return version_compare($normalized, $parsed['version'], '>='); | |
| case '<': | |
| return version_compare($normalized, $parsed['version'], '<'); | |
| case '<=': | |
| return version_compare($normalized, $parsed['version'], '<='); | |
| case 'range': | |
| return version_compare($normalized, $parsed['min'], '>=') | |
| && version_compare($normalized, $parsed['max'], '<'); | |
| case 'dev': | |
| return false; // Normalized version can't match dev constraint | |
| } | |
| return false; | |
| } | |
| /** | |
| * Check if a version satisfies a full constraint string (may contain | or || and ,) | |
| */ | |
| function versionSatisfies(string $version, string $constraint): bool | |
| { | |
| $constraint = trim($constraint); | |
| // Handle * (any version) | |
| if ($constraint === '*' || $constraint === '') { | |
| return true; | |
| } | |
| // Handle OR (| or ||) - version must satisfy at least one | |
| // Composer uses single | for OR, but || is also valid | |
| if (str_contains($constraint, '|')) { | |
| $orParts = preg_split('/\s*\|\|?\s*/', $constraint); | |
| foreach ($orParts as $orPart) { | |
| if (versionSatisfies($version, $orPart)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| // Check if this is a hyphen range first (before splitting on spaces) | |
| // Hyphen ranges look like "1.0 - 2.0" and must be parsed as a single unit | |
| if (preg_match('/^\s*v?(\d+(?:\.\d+)*)\s+-\s+v?(\d+(?:\.\d+)*)\s*$/', $constraint)) { | |
| $parsed = parseConstraintPart($constraint); | |
| if ($parsed !== null) { | |
| return versionSatisfiesConstraint($version, $parsed); | |
| } | |
| } | |
| // Handle AND (space or comma) - version must satisfy all | |
| $andParts = preg_split('/\s*,\s*/', $constraint); | |
| foreach ($andParts as $andPart) { | |
| $andPart = trim($andPart); | |
| if ($andPart === '') continue; | |
| // Check for space-separated AND parts, but be careful not to break hyphen ranges | |
| // Only split on spaces if there's no hyphen range pattern | |
| if (!preg_match('/^\s*v?\d+(?:\.\d+)*\s+-\s+v?\d+(?:\.\d+)*\s*$/', $andPart)) { | |
| $spaceParts = preg_split('/\s+/', $andPart); | |
| foreach ($spaceParts as $spacePart) { | |
| $spacePart = trim($spacePart); | |
| if ($spacePart === '') continue; | |
| $parsed = parseConstraintPart($spacePart); | |
| if ($parsed === null) continue; | |
| if (!versionSatisfiesConstraint($version, $parsed)) { | |
| return false; | |
| } | |
| } | |
| } else { | |
| // This is a hyphen range, parse it as a whole | |
| $parsed = parseConstraintPart($andPart); | |
| if ($parsed === null) continue; | |
| if (!versionSatisfiesConstraint($version, $parsed)) { | |
| return false; | |
| } | |
| } | |
| } | |
| return true; | |
| } | |
| /** | |
| * Check if a version satisfies ALL constraints in an array | |
| */ | |
| function versionSatisfiesAll(string $version, array $constraints): bool | |
| { | |
| foreach ($constraints as $constraint) { | |
| if (!versionSatisfies($version, $constraint)) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /** | |
| * Select best matching version that satisfies ALL constraints | |
| */ | |
| function selectVersion(array $versions, array $allConstraints): ?array | |
| { | |
| if (empty($versions)) return null; | |
| // Filter to stable versions first | |
| $candidates = array_filter($versions, fn($v) => isStableVersion($v['version'] ?? '')); | |
| if (empty($candidates)) { | |
| $candidates = $versions; | |
| } | |
| // Sort by version descending (prefer latest) | |
| usort($candidates, fn($a, $b) => version_compare( | |
| normalizeVersion($b['version'] ?? '0') ?? '0', | |
| normalizeVersion($a['version'] ?? '0') ?? '0' | |
| )); | |
| // Find first version that satisfies all constraints | |
| foreach ($candidates as $candidate) { | |
| $version = $candidate['version'] ?? ''; | |
| if (versionSatisfiesAll($version, $allConstraints)) { | |
| return $candidate; | |
| } | |
| } | |
| // No version satisfies all constraints | |
| return null; | |
| } | |
| /** | |
| * Find which constraints conflict for a package | |
| */ | |
| function findConflictingConstraints(array $versions, array $allConstraints): array | |
| { | |
| // Try to find which pairs of constraints are incompatible | |
| $conflicts = []; | |
| for ($i = 0; $i < count($allConstraints); $i++) { | |
| for ($j = $i + 1; $j < count($allConstraints); $j++) { | |
| $pair = [$allConstraints[$i], $allConstraints[$j]]; | |
| $hasMatch = false; | |
| foreach ($versions as $v) { | |
| if (versionSatisfiesAll($v['version'] ?? '', $pair)) { | |
| $hasMatch = true; | |
| break; | |
| } | |
| } | |
| if (!$hasMatch) { | |
| $conflicts[] = $pair; | |
| } | |
| } | |
| } | |
| return $conflicts; | |
| } | |
| /** | |
| * Show progress indicator | |
| */ | |
| function showProgress(int $completed, int $total): void | |
| { | |
| static $maxTotal = 0; | |
| $maxTotal = max($maxTotal, $total); | |
| $percent = $maxTotal > 0 ? min(100, round(($completed / $maxTotal) * 100)) : 0; | |
| fwrite(STDERR, "\r[{$percent}%] Resolved {$completed} packages..."); | |
| } | |
| /** | |
| * Build the shrinkwrap composer.json output | |
| */ | |
| function buildShrinkwrapComposer(array $rootComposer, array $result, bool $includeDev): array | |
| { | |
| $output = []; | |
| // Preserve root package metadata | |
| foreach (['name', 'description', 'type', 'license', 'authors', 'homepage', 'keywords'] as $key) { | |
| if (isset($rootComposer[$key])) { | |
| $output[$key] = $rootComposer[$key]; | |
| } | |
| } | |
| // Get root require and require-dev | |
| $rootRequire = $rootComposer['require'] ?? []; | |
| $rootRequireDev = $rootComposer['require-dev'] ?? []; | |
| // Build shrinkwrapped require section with all resolved packages | |
| $allRequire = []; | |
| $allRequireDev = []; | |
| // Keep platform requirements from root | |
| foreach ($rootRequire as $name => $constraint) { | |
| if (isPlatformPackage($name)) { | |
| $allRequire[$name] = $constraint; | |
| } | |
| } | |
| // Get replaced packages (provided by other packages like laravel/framework) | |
| $replaces = $result['replaces'] ?? []; | |
| // Add all resolved packages with their resolved versions | |
| foreach ($result['packages'] as $name => $info) { | |
| // Skip packages that are replaced by others | |
| if (isset($replaces[$name])) { | |
| continue; | |
| } | |
| $version = $info['version'] ?? '*'; | |
| // Determine if this is a dev dependency | |
| if (isset($rootRequireDev[$name])) { | |
| $allRequireDev[$name] = $version; | |
| } else { | |
| $allRequire[$name] = $version; | |
| } | |
| } | |
| // Sort packages alphabetically | |
| ksort($allRequire); | |
| ksort($allRequireDev); | |
| $output['require'] = $allRequire; | |
| if ($includeDev && !empty($allRequireDev)) { | |
| $output['require-dev'] = $allRequireDev; | |
| } | |
| // Preserve other root composer.json sections | |
| foreach (['autoload', 'autoload-dev', 'scripts', 'config', 'extra', 'minimum-stability', 'prefer-stable'] as $key) { | |
| if (isset($rootComposer[$key])) { | |
| $output[$key] = $rootComposer[$key]; | |
| } | |
| } | |
| // Add shrinkwrap metadata | |
| $output['extra'] = $output['extra'] ?? []; | |
| $output['extra']['shrinkwrap'] = [ | |
| 'generated' => date('c'), | |
| 'packages' => count($result['packages']), | |
| 'generator' => 'composer-shrinkwrap.php', | |
| ]; | |
| return $output; | |
| } | |
| // Run | |
| exit(main($argv)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment