Skip to content

Instantly share code, notes, and snippets.

@adamziel
Created December 4, 2025 13:13
Show Gist options
  • Select an option

  • Save adamziel/44ba68b4d3285655eccb9a6d4712b35f to your computer and use it in GitHub Desktop.

Select an option

Save adamziel/44ba68b4d3285655eccb9a6d4712b35f to your computer and use it in GitHub Desktop.
Composer fast resolve
#!/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