yf_services_lock classDependency lock of a services/ loader: walks the packagist p2 metadata of the loader's composer_names without composer, picks versions, prunes what the platform already provides and writes services/<name>.lock.json. Resolving only: nothing here downloads a package or touches the autoloader.
| File | classes/yf_services_lock.class.php |
|---|---|
| Use | _class('services_lock') |
A site sets them in its config with $PROJECT_CONF['services_lock']['NAME'] = ...;
| Property | Default | Description |
|---|---|---|
$CACHE_DIRnull|string |
no cache */
Property [ public $CACHE_DIR = NULL |
directory of cached p2 answers, null = no cache |
$CACHE_TTLint |
3600 |
seconds a cached answer stays valid |
$FORK_OWNERSarray |
['yfix'] |
github owners of forks: their converted packages keep "owner/repo" instead of the name in the repository's composer.json |
$GIT_RUNNERnull|callable |
null |
fn(array): [int exit code, string stdout] instead of running git (tests) |
$IMPLEMENTATIONSarray |
['psr/http-message-implementation' => 'guzzlehttp/psr7:^2.0', 'psr/http-factory-implementation' => 'guzzlehttp/psr7:^2.0', 'psr/http-client-implementation' => 'php-http/curl-client:^2.3', 'php-http/client-implementation' => 'php-http/curl-client:^2.3', 'php-http/async-client-implementation' => 'php-http/curl-client:^2.3'] |
virtual package => 'vendor/package:constraint' that satisfies it when nothing in the tree provides it; the loaders of the framework chain php-http/curl-client (client) and guzzlehttp/psr7 (messages, factories) |
$LOADER_GLOBSarray |
|
root-relative globs of loader files, same set yf_services::require_php_lib() reads |
$MAX_RETRIESint |
25 |
most walks resolve_tree() starts with intersected constraints |
$PACKAGIST_URLstring |
'https://repo.packagist.org/p2/' |
packagist metadata endpoint, "<name>.json" is appended |
$READMEstring |
'Generated by services:resolve (yf_services_lock), a subse'... |
|
$README_CONVERTEDstring |
'Generated by services:lock-from-loader (yf_services_lock)'... |
|
$ROOTSnull|array |
null |
root dirs of loaders and locks instead of YF_PATH, APP_PATH, PROJECT_PATH (tests) |
$STABILITY_RANKarray |
['stable' => 0, 'RC' => 1, 'beta' => 2, 'alpha' => 3, 'dev' => 4] |
stability name => rank, a version is allowed when its rank is not above the allowed one |
$TIMEOUTint |
10 |
seconds |
$USER_AGENTstring |
'yf-services (+https://github.com/yfix/yf)' |
User-Agent of the packagist requests |
$_composer_cachearray |
[] |
composer.json of a repository at a sha by url, one fetch per converted repository |
$_constraint_cachearray |
[] |
parsed constraints by constraint string |
$convertedarray |
[] |
loader name => lock produced for a required service as a side effect of lock_from_loader() |
public function carry_over(array $lock, ?array $old): array
Keep what a person or services:fetch added to an earlier lock of the same loader: `resources`, `files_sha256`, the lock's `inlined_services` and, while the package stays at the same sha, `resources_files` and `autoload.classmap_index`.
$lock array freshly resolved$old ?array the lock file as it was public function carry_over(array $lock, ?array $old): array
{
foreach ($lock['packages'] as $name => &$entry) {
$prev = $old['packages'][$name] ?? null;
if ( ! is_array($prev)) {
continue;
}
$same = ($prev['source']['reference'] ?? ($prev['dist']['reference'] ?? 'a')) === ($entry['source']['reference'] ?? ($entry['dist']['reference'] ?? 'b'));
foreach (['resources', 'files_sha256', 'resources_files'] as $key) {
if (isset($prev[$key]) && ($same || $key === 'resources')) {
$entry[$key] = $prev[$key];
}
}
if ($same && isset($prev['autoload']['classmap_index'])) {
$entry['autoload']['classmap_index'] = $prev['autoload']['classmap_index'];
}
}
unset($entry);
if ( ! empty($old['inlined_services'])) {
$lock['inlined_services'] = array_values(array_unique(array_merge((array) $old['inlined_services'], (array) ($lock['inlined_services'] ?? []))));
}
return $lock;
}
public function classes_of($code): array
Fully qualified names of the classes, interfaces, traits and enums a PHP source declares.
$code string public function classes_of($code): array
{
$out = [];
$ns = '';
$tokens = array_values(array_filter(PhpToken::tokenize($code), static fn ($t) => ! $t->isIgnorable()));
for ($i = 0, $n = count($tokens); $i < $n; $i++) {
$t = $tokens[$i];
$next = $tokens[$i + 1] ?? null;
if ($t->is(T_NAMESPACE)) {
$ns = $next && $next->is([T_NAME_QUALIFIED, T_STRING]) ? $next->text . '\\' : '';
} elseif ($t->is([T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM]) && $next && $next->is(T_STRING)
&& ! (($tokens[$i - 1] ?? null) && $tokens[$i - 1]->is([T_DOUBLE_COLON, T_NEW]))) {
$out[] = $ns . $next->text;
}
}
return $out;
}
public function code_files(array $entry, $clone): array
PHP files a lock can load from a checkout: `files`, the classmap index and classmap paths, the psr-4 / psr-0 directories (a repository root without tests and docs).
$entry array$clone string public function code_files(array $entry, $clone): array
{
$php = ['php', 'inc'];
$is_code = static fn ($rel) => is_string($rel) && in_array(strtolower(pathinfo($rel, PATHINFO_EXTENSION)), ['php', 'inc'], true);
$out = [];
foreach ((array) ($entry['autoload']['files'] ?? []) as $rel) {
$is_code($rel) && $this->inside_clone($clone, $rel) && is_file($clone . '/' . $rel) && $out[$rel] = true;
}
foreach ((array) ($entry['autoload']['classmap_index'] ?? []) as $rel) {
$is_code($rel) && $this->inside_clone($clone, $rel) && is_file($clone . '/' . $rel) && $out[$rel] = true;
}
$dirs = array_map(static fn ($p) => trim((string) $p, '/'), (array) ($entry['autoload']['classmap'] ?? []));
foreach (['psr-4', 'psr-0'] as $kind) {
foreach ((array) ($entry['autoload'][$kind] ?? []) as $paths) {
foreach ((array) $paths as $dir) {
$dirs[] = trim((string) $dir, '/');
}
}
}
foreach (array_unique($dirs) as $dir) {
if ($dir !== '') {
foreach ($this->list_files($clone, [$dir], $php) as $rel) {
$out[$rel] = true;
}
continue;
}
foreach (glob($clone . '/*') ?: [] as $path) {
$base = basename($path);
if (is_dir($path) && ! is_link($path) && $base[0] !== '.' && ! in_array(strtolower($base), ['tests', 'test', 'docs', 'doc', 'examples', 'example', 'vendor', 'benchmarks', 'bin'], true)) {
foreach ($this->list_files($clone, [$base], $php) as $rel) {
$out[$rel] = true;
}
} elseif (is_file($path) && ! is_link($path) && $is_code($base)) {
$out[$base] = true;
}
}
}
$out = array_keys($out);
sort($out);
return $out;
}
public function constraint_stability($constraint): string
Lowest stability a constraint asks for ("^4.0@alpha", "1.0.0-beta1", "dev-master"), or '' when it names none.
$constraint string public function constraint_stability($constraint): string
{
$found = '';
$consider = function ($name) use (&$found) {
$name = ['rc' => 'RC', 'a' => 'alpha', 'b' => 'beta'][strtolower($name)] ?? strtolower($name);
if ($found === '' || $this->STABILITY_RANK[$name] > $this->STABILITY_RANK[$found]) {
$found = $name;
}
};
if (preg_match('/(?:^|[\s,|])dev-/i', $constraint)) {
$consider('dev');
}
if (preg_match_all('/@(dev|alpha|beta|rc)\b/i', $constraint, $m)) {
array_map($consider, $m[1]);
}
if (preg_match_all('/\d[._-]?(alpha|beta|rc|dev|a|b)(?![a-z])/i', $constraint, $m)) {
array_map($consider, $m[1]);
}
return $found;
}
public function decode_json($json): ?array
$json string public function decode_json($json): ?array
{
$data = json_decode($json, true, 64);
return is_array($data) ? $data : null;
}
public function default_platform(): array
['php' => PHP_VERSION, 'ext-<name>' => version] of the running PHP.
public function default_platform(): array
{
$platform = ['php' => PHP_VERSION];
foreach (get_loaded_extensions() as $ext) {
$platform['ext-' . str_replace(' ', '-', strtolower($ext))] = phpversion($ext) ?: '*';
}
return $platform;
}
public function expand_p2(array $versions): array
Expand the minified p2 list: every version is the previous one plus its changed keys, "__unset" removes a key (composer's MetadataMinifier::expand()).
$versions array public function expand_p2(array $versions): array
{
$expanded = [];
$current = null;
foreach ($versions as $data) {
if ( ! is_array($data)) {
continue;
}
if ($current === null) {
$current = $data;
} else {
foreach ($data as $key => $val) {
if ($val === '__unset') {
unset($current[$key]);
} else {
$current[$key] = $val;
}
}
}
$expanded[] = $current;
}
return $expanded;
}
public function fetch_packagist($name): ?array
p2 metadata of a package from packagist, optionally cached in $CACHE_DIR.
$name string "vendor/package", "~dev" suffix for the dev versionsReturns null|array decoded JSON, null when the package is unknown or the request failed
public function fetch_packagist($name): ?array
{
$name = strtolower((string) $name);
if ( ! preg_match('#^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9]([_.-]{0,2}[a-z0-9]+)*(~dev)?$#D', $name)) {
return null;
}
$cache_file = '';
if ($this->CACHE_DIR) {
$cache_file = rtrim($this->CACHE_DIR, '/') . '/' . str_replace('/', '__', $name) . '.json';
clearstatcache(true, $cache_file);
if (is_file($cache_file) && time() - (int) filemtime($cache_file) < $this->CACHE_TTL) {
$cached = $this->decode_json((string) file_get_contents($cache_file));
if ($cached) {
return $cached;
}
}
}
$context = stream_context_create(['http' => [
'timeout' => $this->TIMEOUT,
'ignore_errors' => true,
'header' => 'User-Agent: ' . $this->USER_AGENT . "\r\nAccept: application/json\r\n",
]]);
// the response headers come from the stream's wrapper_data: the magic response-header variable is
// deprecated in PHP 8.5 even when it only appears in the source
$fp = @fopen($this->PACKAGIST_URL . $name . '.json', 'r', false, $context);
if ( ! $fp) {
return null;
}
$meta = stream_get_meta_data($fp);
$body = stream_get_contents($fp);
fclose($fp);
$first = (string) (((array) ($meta['wrapper_data'] ?? []))[0] ?? '');
$status = preg_match('~ (\d{3}) ~', $first, $m) ? (int) $m[1] : 0;
if ($body === false || $status !== 200) {
return null;
}
$data = $this->decode_json($body);
if ($data && $cache_file && (is_dir($this->CACHE_DIR) || mkdir($this->CACHE_DIR, 0755, true) || is_dir($this->CACHE_DIR))) {
file_put_contents($cache_file, $body, LOCK_EX);
}
return $data;
}
public function find_loaders($dirs = null): array
Loader files by name, a later root overrides an earlier one like in require_php_lib().
$dirs null|array public function find_loaders($dirs = null): array
{
$found = [];
foreach ($dirs ?? $this->loader_dirs() as $dir) {
foreach ($this->LOADER_GLOBS as $glob) {
foreach (glob(rtrim($dir, '/') . '/' . $glob) ?: [] as $path) {
$name = substr(basename($path), 0, -4);
if ($name !== '_yf_autoloader') {
$found[$name] = $path;
}
}
}
}
ksort($found);
return $found;
}
public function find_provider(array $locked, $name, $constraint, array $kinds = ['replace', 'provide']): ?string
A locked package (other than $name itself) that `replace`s or `provide`s $name with a version satisfying the constraint.
$locked array name => lock entry$name string$constraint string$kinds array public function find_provider(array $locked, $name, $constraint, array $kinds = ['replace', 'provide']): ?string
{
foreach ($locked as $package => $entry) {
if ($package === $name) {
continue;
}
foreach ($kinds as $kind) {
if ( ! isset($entry[$kind][$name])) {
continue;
}
$version = (string) $entry[$kind][$name];
if (trim($version) === '*') {
return (string) $package;
}
try {
if ($this->satisfies($version === 'self.version' ? (string) $entry['version'] : $version, $constraint)) {
return (string) $package;
}
} catch (yf_services_lock_exception $e) {
// a provide / replace version that is not a version ("*") never satisfies
}
}
}
return null;
}
public function git_entry(array $repo): array
Lock entry of one pinned repository: version (tag or dev-<branch>), full sha, git source, empty autoload.
$repo array a row of loader_repos() public function git_entry(array $repo): array
{
[$sha, $version] = $this->resolve_ref($repo['url'], $repo['ref']);
try {
$normalized = $this->normalize_version($version);
} catch (yf_services_lock_exception $e) {
$normalized = $version;
}
return [
'version' => $version,
'version_normalized' => $normalized,
'source' => ['type' => 'git', 'url' => $repo['url'], 'reference' => $sha],
'autoload' => [],
'require' => [],
];
}
public function github_repo($url): string
$url string public function github_repo($url): string
{
if ( ! class_exists('yf_services_autoload', false)) {
require_once __DIR__ . '/yf_services_autoload.class.php';
}
return (new yf_services_autoload())->github_repo($url);
}
public function glob_regex($glob): string
$glob string "*" within a segment, "**" across segments, "?" one character public function glob_regex($glob): string
{
$re = '';
$glob = trim((string) $glob, '/');
for ($i = 0, $n = strlen($glob); $i < $n; $i++) {
$c = $glob[$i];
if ($c === '*' && ($glob[$i + 1] ?? '') === '*') {
$re .= '.*';
$i++;
} elseif ($c === '*') {
$re .= '[^/]*';
} elseif ($c === '?') {
$re .= '[^/]';
} else {
$re .= preg_quote($c, '~');
}
}
return '~^' . $re . '$~';
}
public function head_is($dir, $sha): bool
$dir string a git checkout$sha string public function head_is($dir, $sha): bool
{
$out = '';
return $this->git(['-C', $dir, 'rev-parse', 'HEAD'], $out) && hash_equals(strtolower((string) $sha), strtolower(trim($out)));
}
public function index_entry(array $entry, $clone_dir): array
classmap_index (class => path relative to the package root) and resources_files of a package, from its checkout.
$entry array a lock package$clone_dir string public function index_entry(array $entry, $clone_dir): array
{
$clone_dir = rtrim($clone_dir, '/');
$paths = [];
foreach ((array) ($entry['autoload']['classmap'] ?? []) as $path) {
$paths[] = trim((string) $path, '/');
}
if ($paths) {
$index = [];
foreach ($this->list_files($clone_dir, $paths, ['php', 'inc', 'hh']) as $rel) {
foreach ($this->classes_of((string) file_get_contents($clone_dir . '/' . $rel)) as $class) {
$index[$class] = $rel;
}
}
ksort($index);
$entry['autoload']['classmap_index'] = $index;
}
if ( ! empty($entry['resources'])) {
$regexes = array_map([$this, 'glob_regex'], (array) $entry['resources']);
$files = [];
foreach ($this->list_files($clone_dir, [''], null) as $rel) {
foreach ($regexes as $re) {
if (preg_match($re, $rel)) {
$files[] = $rel;
break;
}
}
}
sort($files);
$entry['resources_files'] = $files;
}
return $entry;
}
public function inlined_services(array $config, array $lock): array
The `require_services` of a loader whose repository is already a package of the lock (same github.com owner/repo): the lock's tree replaces them, so a lock-served loader must not clone them again.
$config array loader config$lock array a resolved lock public function inlined_services(array $config, array $lock): array
{
$have = [];
foreach ((array) ($lock['packages'] ?? []) as $entry) {
$repo = strtolower($this->github_repo((string) ($entry['source']['url'] ?? '')));
$repo !== '' && $have[$repo] = true;
}
$loaders = null;
$out = [];
foreach ((array) ($config['require_services'] ?? []) as $service) {
$loaders ??= $this->find_loaders();
if ( ! isset($loaders[$service])) {
continue;
}
$repos = $this->loader_repos((array) ($this->loader_config($loaders[$service])['git_urls'] ?? []), false);
if ($repos && ! array_diff(array_map(static fn ($r) => strtolower($r['repo']), $repos), array_keys($have))) {
$out[] = (string) $service;
}
}
return $out;
}
public function inside_clone($root, $rel): bool
A path from a lock that stays inside the checkout: clean relative path, no symlink on the way, real path under the root.
$root string$rel string public function inside_clone($root, $rel): bool
{
if ($rel === '') {
return true;
}
if ( ! is_string($rel) || $rel[0] === '/' || strpbrk($rel, "\\\0:") !== false || preg_match('~[\x00-\x1f\x7f]~', $rel)) {
return false;
}
$path = rtrim($root, '/');
foreach (explode('/', $rel) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
$path .= '/' . $segment;
if (is_link($path)) {
return false;
}
}
$real = realpath($path);
$root_real = realpath($root);
return $real !== false && $root_real !== false && strpos($real . '/', $root_real . '/') === 0;
}
public function is_platform_requirement($name): bool
php, extensions, libraries and composer's own pseudo packages are never locked.
$name string public function is_platform_requirement($name): bool
{
return (bool) preg_match('/^(php(-64bit|-ipv6|-zts|-debug)?|(ext|lib)-[a-z0-9_.+-]+|composer(-plugin-api|-runtime-api)?|hhvm)$/i', (string) $name);
}
public function lint_files($root, array $rels, $runner = null): array
`php -l` with every error level on over the files: a parse error and any "Deprecated" line is a problem.
$root string$rels array$runner null|callable fn(string $absolute_path): [int exit code, string output] public function lint_files($root, array $rels, $runner = null): array
{
$runner ??= [$this, 'php_lint'];
$problems = [];
foreach ($rels as $rel) {
[$code, $output] = $runner(rtrim($root, '/') . '/' . $rel);
$output = trim((string) $output);
if ($code !== 0) {
$problems[] = $rel . ' does not compile: ' . strtok($output, "\n");
} elseif (preg_match('~^(?:PHP )?Deprecated:.*$~m', $output, $m)) {
$problems[] = $rel . ' is deprecated on PHP ' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . ': ' . $m[0];
}
}
return $problems;
}
public function list_files($root, array $paths, $extensions = null): array
Files under the given paths of a checkout (a path is a directory or a file, '' is the root), relative, sorted, no .git.
$root string$paths array$extensions null|array null = any public function list_files($root, array $paths, $extensions = null): array
{
$out = [];
foreach ($paths as $path) {
if ( ! $this->inside_clone($root, (string) $path)) {
continue;
}
$base = $path === '' ? $root : $root . '/' . $path;
if (is_file($base)) {
$out[$path] = true;
continue;
}
if ( ! is_dir($base)) {
continue;
}
$it = new RecursiveIteratorIterator(new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
static fn ($f) => $f->getFilename() !== '.git' && ! $f->isLink()
));
foreach ($it as $file) {
if ($file->isFile() && ($extensions === null || in_array(strtolower($file->getExtension()), $extensions, true))) {
$out[ltrim(substr($file->getPathname(), strlen($root)), '/')] = true;
}
}
}
$out = array_keys($out);
sort($out);
return $out;
}
public function load_versions($name, $constraint, $fetch, $by = 'root'): array
Expanded versions of a package, plus its ~dev list when the constraint asks for dev.
$name string$constraint string$fetch callable$by string public function load_versions($name, $constraint, $fetch, $by = 'root'): array
{
$data = $fetch($name);
if ( ! is_array($data) || ! isset($data['packages'][$name]) || ! is_array($data['packages'][$name])) {
throw new yf_services_lock_not_found($name . ': package ' . $constraint . ' (required by ' . $by . ') not found');
}
$versions = $this->expand_p2($data['packages'][$name]);
if ($this->constraint_stability($constraint) === 'dev') {
$dev = $fetch($name . '~dev');
if (is_array($dev) && isset($dev['packages'][$name]) && is_array($dev['packages'][$name])) {
$versions = array_merge($versions, $this->expand_p2($dev['packages'][$name]));
}
}
return $versions;
}
public function loader_config($path): array
Config array of a loader file, read the way yf_services::require_php_lib() does ($return_config = true).
$path string public function loader_config($path): array
{
$config = (static function ($path, $return_config) {
return include $path;
})($path, true);
return is_array($config) ? $config : [];
}
public function loader_dirs(): array
Root directories that may hold loaders, in require_php_lib() order (framework, app) plus the project.
public function loader_dirs(): array
{
if ($this->ROOTS !== null) {
return $this->ROOTS;
}
$dirs = [];
foreach (['YF_PATH', 'APP_PATH', 'PROJECT_PATH'] as $const) {
if (defined($const) && strlen((string) constant($const))) {
$dirs[rtrim((string) constant($const), '/') . '/'] = true;
}
}
return $dirs ? array_keys($dirs) : [dirname(__DIR__) . '/'];
}
public function loader_repos(array $git_urls, $strict = true): array
git_urls "url[~ref]" => "dir/" as rows dir => url, ref, repo ("owner/repo"), sha-less; the order of the config is kept.
$git_urls array$strict bool throw on a url that is not on github.com or a dir that is not a clean relative path public function loader_repos(array $git_urls, $strict = true): array
{
$out = [];
foreach ($git_urls as $git_url => $lib_dir) {
$ref = '';
$url = (string) $git_url;
if (strpos($url, '~') !== false) {
[$url, $ref] = explode('~', $url, 2);
}
$repo = $this->github_repo($url);
$dir = rtrim((string) $lib_dir, '/') . '/';
if ($repo === '' || ! $this->valid_path(rtrim($dir, '/'))) {
if ($strict) {
throw new yf_services_lock_exception('git_urls "' . $git_url . '" => "' . $lib_dir . '": ' . ($repo === '' ? 'only github.com repositories can be converted' : 'not a clean relative directory'));
}
continue;
}
$out[$dir] = ['url' => 'https://github.com/' . $repo . '.git', 'ref' => $ref, 'repo' => $repo];
}
return $out;
}
public function lock_entry(array $pick): array
$pick array one expanded p2 version public function lock_entry(array $pick): array
{
$entry = [
'version' => (string) $pick['version'],
'version_normalized' => (string) ($pick['version_normalized'] ?? $this->normalize_version($pick['version'])),
];
$dist = (array) ($pick['dist'] ?? []);
$entry['dist'] = array_filter([
'type' => $dist['type'] ?? 'zip',
'url' => $dist['url'] ?? '',
'reference' => $dist['reference'] ?? '',
], static fn ($v) => $v !== '');
if (isset($pick['source']['reference'])) {
$entry['source'] = ['reference' => $pick['source']['reference']];
// the lazy autoloader derives the raw.githubusercontent.com url from it
foreach (['type', 'url'] as $key) {
if (isset($pick['source'][$key])) {
$entry['source'] = [$key => $pick['source'][$key]] + $entry['source'];
}
}
}
$entry['autoload'] = (array) ($pick['autoload'] ?? []);
$entry['require'] = [];
foreach ((array) ($pick['require'] ?? []) as $dep => $c) {
$dep = strtolower((string) $dep);
if ( ! $this->is_platform_requirement($dep)) {
$entry['require'][$dep] = (string) $c;
}
}
foreach (['provide', 'replace', 'license'] as $key) {
if ( ! empty($pick[$key])) {
$entry[$key] = $pick[$key];
}
}
return $entry;
}
public function lock_from_loader(array $config, array $options = []): array
Convert a loader whose source is `git_urls` (GitHub) plus `autoload_config` / `require_once` / `require_services` into a lock, without packagist: the ref each git url pins (`url~tag`, else the default branch) becomes a full sha through `git ls-remote`, `autoload_config` is read the way yf_autoloader::process_yf_autoload() does (a namespace prefix is psr-4, the prefix is cut from the file name unless `no_cut_prefix:`; anything that is not a namespace becomes a classmap built from a shallow checkout), `require_once` becomes `files`, each required service is referenced by name in `services` (converted on the way when it has no lock, else listed in `unconverted` with the reason), a callable `manual` sets `manual: true`. A required service that only supplied the path of a `require_once` file is not loaded at run time: the file is a `files` entry of a package of this lock and the service is listed in `inlined_services`.
$config array loader config as returned by loader_config()$options array verify: check out every package, require each `files` / `resources_files` entry to exist there and lint the code (default true, needs index; false or index false: no checkout); lint: the `php -l` pass of verify (default true); lint_runner: callable fn(string $file): [int exit code, string output] (default `php -d error_reporting=-1 -l`); name: loader name; package_names: ['owner/repo' => 'vendor/package'] (default: the name in the repository's composer.json, `owner/repo` for forks, see $FORK_OWNERS); convert_required: bool (default true); index: build classmap_index from a checkout (default true); checkout: callable fn(array $package_entry, string $dest): bool (default shallow_checkout()); previous: the lock file as it was, its classmap_index is reused while the sha is unchanged; skip_abandoned: do not convert required services that are flagged abandoned (default true); platform_php: lowest PHP the code must run on, the `require.php` of each repository's composer.json at the pinned sha is checked against it (a repository without composer.json or `require.php` passes); fetcher: callable fn(string $url): string|false for that file (default the HTTPS fetch of services_autoload) public function lock_from_loader(array $config, array $options = []): array
{
$name = (string) ($options['name'] ?? '');
$stack = (array) ($options['_stack'] ?? []);
$git_urls = (array) ($config['git_urls'] ?? []);
$repos = $this->loader_repos($git_urls);
$floor = (string) ($options['platform_php'] ?? '');
$fetcher = $options['fetcher'] ?? null;
// composer.json of every repository: the php floor and the package name come from it
$read_composer = $floor !== '' || $fetcher !== null;
$names = [];
$entries = [];
$roots = [];
foreach ($repos as $dir => $repo) {
$entry = $this->git_entry($repo);
$composer = $read_composer ? $this->composer_data($entry, $fetcher) : [];
$package = $this->package_name($repo['repo'], (array) ($options['package_names'] ?? []), (string) ($composer['name'] ?? ''));
if (isset($entries[$package])) {
throw new yf_services_lock_exception('git_urls lists ' . $repo['repo'] . ' twice');
}
$floor !== '' && $this->check_php_floor($package, $entry, $floor, $composer);
$names[$dir] = $package;
$entries[$package] = $entry;
$roots[$package] = $entry['version'];
}
$owner = static function (string $path, array $dirs): ?array {
$best = null;
foreach ($dirs as $dir => $package) {
if (strncmp($path, $dir, strlen($dir)) === 0 && ($best === null || strlen($dir) > strlen($best[0]))) {
$best = [$dir, $package];
}
}
return $best;
};
foreach ((array) ($config['autoload_config'] ?? []) as $lib_dir => $prefix) {
$found = $owner((string) $lib_dir, $names);
$rel = $found ? substr((string) $lib_dir, strlen($found[0])) : '';
if ( ! $found || ($rel !== '' && ! $this->valid_path(rtrim($rel, '/')))) {
throw new yf_services_lock_exception('autoload_config dir "' . $lib_dir . '" is not inside a git_urls dir');
}
$this->add_autoload_rule($entries[$found[1]], rtrim($rel, '/') === '' ? '' : rtrim($rel, '/') . '/', (string) $prefix);
}
$services = [];
$unconverted = [];
$required_dirs = [];
$service_requires = [];
foreach ((array) ($config['require_services'] ?? []) as $service) {
$service = (string) $service;
$loaders = $this->find_loaders();
if ( ! isset($loaders[$service])) {
$unconverted[$service] = 'no such loader';
continue;
}
$service_config = $this->loader_config($loaders[$service]);
$service_requires[$service] = (array) ($service_config['require_once'] ?? []);
foreach ($this->loader_repos((array) ($service_config['git_urls'] ?? []), false) as $dir => $repo) {
$required_dirs[$dir] = $repo + ['service' => $service];
}
if ($this->read($this->lock_path($service))) {
$services[] = $service;
} elseif (in_array($service, $stack, true) || $service === $name) {
$unconverted[$service] = 'circular require_services';
} elseif (isset($service_config['abandoned']) && ($options['skip_abandoned'] ?? true)) {
$unconverted[$service] = 'abandoned';
} elseif ( ! ($options['convert_required'] ?? true)) {
$unconverted[$service] = 'no lock';
} elseif (empty($service_config['git_urls'])) {
$unconverted[$service] = ! empty($service_config['composer_names']) ? 'packagist-backed, run services:resolve' : 'no git_urls';
} else {
try {
$this->converted[$service] = $this->lock_from_loader($service_config, ['name' => $service, '_stack' => array_merge($stack, [$name]), 'previous' => null] + $options);
$services[] = $service;
} catch (yf_services_lock_exception $e) {
$unconverted[$service] = $e->getMessage();
}
}
}
$inlined = [];
// package of a required service's repository that this lock has to carry itself
$required_package = function (string $dir) use (&$entries, $required_dirs, $options, $read_composer, $fetcher, $floor): string {
$entry = $this->git_entry($required_dirs[$dir]);
$composer = $read_composer ? $this->composer_data($entry, $fetcher) : [];
$package = $this->package_name($required_dirs[$dir]['repo'], (array) ($options['package_names'] ?? []), (string) ($composer['name'] ?? ''));
if ( ! isset($entries[$package])) {
$floor !== '' && $this->check_php_floor($package, $entry, $floor, $composer);
$entries[$package] = $entry;
}
return $package;
};
$required_owner = static fn (string $path) => $owner($path, array_map(static fn ($r) => $r['repo'], $required_dirs));
$before = [];
foreach ((array) ($config['require_once'] ?? []) as $path) {
$path = (string) $path;
$found = $owner($path, $names);
if ($found) {
$this->add_file($entries[$found[1]], substr($path, strlen($found[0])));
continue;
}
$found = $required_owner($path);
if ( ! $found) {
throw new yf_services_lock_exception('require_once "' . $path . '" is not inside a git_urls dir of the loader or of a required service');
}
$package = $required_package($found[0]);
// the file comes from the lock itself: the required service is only needed for its clone path, not loaded at run time
$service = $required_dirs[$found[0]]['service'];
if ( ! in_array($service, $services, true) && ! isset($inlined[$service])) {
$inlined[$service] = true;
unset($unconverted[$service]);
// what the service itself requires (Minify.php) was loaded before this loader's own files
foreach ($service_requires[$service] as $service_path) {
$own = $required_owner((string) $service_path);
if ($own && $required_dirs[$own[0]]['service'] === $service) {
$before[] = [$required_package($own[0]), substr((string) $service_path, strlen($own[0]))];
}
}
}
$this->add_file($entries[$package], substr($path, strlen($found[0])));
}
foreach (array_reverse($before) as [$package, $rel]) {
$files = array_values(array_diff((array) ($entries[$package]['autoload']['files'] ?? []), [$rel]));
$entries[$package]['autoload']['files'] = array_merge([$rel], $files);
}
if ( ! $entries) {
throw new yf_services_lock_exception('the loader has no git_urls and no require_once into a required service: ' . ( ! empty($config['composer_names']) ? 'packagist-backed, use services:resolve' : 'nothing to convert'));
}
foreach ($entries as $package => $entry) {
$prev = $options['previous']['packages'][$package] ?? null;
$prev = is_array($prev) ? $prev : [];
if ( ! empty($prev['resources'])) {
$entry['resources'] = $prev['resources'];
}
$need_classmap = ! empty($entry['autoload']['classmap']);
$need_resources = ! empty($entry['resources']);
$entries[$package] = $entry;
$verify = ($options['index'] ?? true) && ($options['verify'] ?? true);
if ( ! ($options['index'] ?? true) || ( ! $need_classmap && ! $need_resources && ! $verify)) {
continue;
}
// the earlier indexes stay valid while the sha and the lists they were built from are the same; a verified lock is checked out anyway
$same = ($prev['source']['reference'] ?? '') === $entry['source']['reference'];
if ( ! $verify && $same && ( ! $need_classmap || (isset($prev['autoload']['classmap_index']) && ($prev['autoload']['classmap'] ?? []) == $entry['autoload']['classmap']))
&& ( ! $need_resources || isset($prev['resources_files']))) {
$need_classmap && $entries[$package]['autoload']['classmap_index'] = $prev['autoload']['classmap_index'];
$need_resources && $entries[$package]['resources_files'] = $prev['resources_files'];
continue;
}
$base = $this->make_temp_dir();
try {
if ($base === '' || ! ($options['checkout'] ?? [$this, 'shallow_checkout'])($entry, $base . '/co')) {
throw new yf_services_lock_exception($package . ': cannot check out ' . $entry['source']['url'] . ' at ' . substr($entry['source']['reference'], 0, 7) . ' to build and check its indexes');
}
$entries[$package] = $this->index_entry($entry, $base . '/co');
if ($verify) {
$problems = $this->verify_entry($entries[$package], $base . '/co', $options);
if ($problems) {
throw new yf_services_lock_exception($package . ' at ' . substr($entry['source']['reference'], 0, 7) . ': ' . implode('; ', array_slice($problems, 0, 5)) . (count($problems) > 5 ? ' (and ' . (count($problems) - 5) . ' more)' : ''));
}
}
} finally {
$base !== '' && $this->remove_tree($base);
}
}
ksort($entries);
$lock = [
'_readme' => $this->README_CONVERTED,
'generated' => gmdate('Y-m-d\TH:i:s\Z'),
'converted_from' => 'loader',
'source' => ['type' => 'git'],
'roots' => $roots ?: array_map(static fn ($e) => $e['version'], $entries),
'packages' => $entries,
];
$services && $lock['services'] = $services;
$unconverted && $lock['unconverted'] = $unconverted;
$inlined && $lock['inlined_services'] = array_keys($inlined);
! empty($config['manual']) && $lock['manual'] = true;
return $lock;
}
public function lock_path($loader_name, $dirs = null): string
services/<name>.lock.json in the root that owns the loader (a project override wins over the framework).
$loader_name string$dirs null|array public function lock_path($loader_name, $dirs = null): string
{
$dirs = $dirs ?? $this->loader_dirs();
$owner = $dirs[0] ?? '';
foreach ($dirs as $dir) {
if (array_key_exists($loader_name, $this->find_loaders([$dir]))) {
$owner = $dir;
}
}
return rtrim($owner, '/') . '/services/' . $loader_name . '.lock.json';
}
public function make_temp_dir(): string
New empty directory under the system temp dir with an unpredictable name, '' when it cannot be created.
public function make_temp_dir(): string
{
$dir = sys_get_temp_dir() . '/yf-services-' . bin2hex(random_bytes(8));
return mkdir($dir, 0700) ? $dir : '';
}
public function normalize_implementations(array $map): array
['virtual' => 'vendor/package:constraint'] as ['virtual' => ['vendor/package' => 'constraint']].
$map array public function normalize_implementations(array $map): array
{
$out = [];
foreach ($map as $virtual => $target) {
$virtual = strtolower(trim((string) $virtual));
$roots = $this->normalize_roots([(string) $target]);
if ($virtual === '' || count($roots) !== 1 || (string) array_key_first($roots) === '') {
throw new yf_services_lock_exception('invalid implementation for "' . $virtual . '"');
}
$out[$virtual] = $roots;
}
return $out;
}
public function normalize_roots(array $roots): array
$roots array public function normalize_roots(array $roots): array
{
$out = [];
foreach ($roots as $name => $constraint) {
if (is_int($name)) {
[$name, $constraint] = array_pad(explode(':', (string) $constraint, 2), 2, '');
}
$name = strtolower(trim((string) $name));
$constraint = trim((string) $constraint);
$constraint = $constraint === '' ? '*' : $constraint;
if (isset($out[$name]) && $out[$name] !== $constraint) {
throw new yf_services_lock_exception($name . ': roots require both ' . $out[$name] . ' and ' . $constraint);
}
$out[$name] = $constraint;
}
return $out;
}
public function normalize_version($version): string
Canonical form of a version, comparable with version_compare(): "v3.30.0" => "3.30.0.0", "1.0.0-RC2" => "1.0.0.0-RC2". Branches ("dev-master") are returned as they are.
$version string public function normalize_version($version): string
{
$version = trim((string) $version);
if (stripos($version, 'dev-') === 0) {
return $version;
}
$re = '/^v?(\d+)(?:\.(\d+|x))?(?:\.(\d+|x))?(?:\.(\d+|x))?(?:[._-]?(stable|beta|b|rc|alpha|a|patch|pl|p|dev)[._-]?(\d*))?(?:\+.*)?$/i';
if ( ! preg_match($re, $version, $m)) {
throw new yf_services_lock_exception('invalid version "' . $version . '"');
}
$parts = [];
for ($i = 1; $i <= 4; $i++) {
$p = $m[$i] ?? '';
$parts[] = $p === '' ? '0' : (strtolower($p) === 'x' ? '9999999' : (string) (int) $p);
}
$out = implode('.', $parts);
$suffix = strtolower($m[5] ?? '');
if ($suffix !== '' && $suffix !== 'stable') {
$names = ['a' => 'alpha', 'b' => 'beta', 'rc' => 'RC', 'patch' => 'pl', 'p' => 'pl'];
$out .= '-' . ($names[$suffix] ?? $suffix) . ($m[6] ?? '');
}
return $out;
}
public function package_name($repo, array $map, $composer_name = ''): string
"vendor/package" of a converted repository: the option map, else the name in the repository's composer.json (so a locked upstream repository is the package composer knows), else, and for forks, "owner/repo" lower case.
$repo string$map array 'owner/repo' => 'vendor/package'$composer_name string public function package_name($repo, array $map, $composer_name = ''): string
{
$own = in_array(strtolower((string) strtok((string) $repo, '/')), array_map('strtolower', $this->FORK_OWNERS), true);
$default = ! $own && preg_match('~^[A-Za-z0-9_][A-Za-z0-9_.-]*/[A-Za-z0-9_][A-Za-z0-9_.-]*$~D', $composer_name) ? $composer_name : $repo;
$name = strtolower((string) ($map[$repo] ?? $map[strtolower($repo)] ?? $default));
if ( ! preg_match('~^[a-z0-9_][a-z0-9_.-]*/[a-z0-9_][a-z0-9_.-]*$~D', $name) || strpos($name, '..') !== false) {
throw new yf_services_lock_exception('"' . $name . '" is not a valid package name');
}
return $name;
}
public function parse_constraint($constraint): array
Constraint as OR groups of AND comparators: [[['>=', '1.2.0.0-dev'], ['<', '2.0.0.0-dev']], ...]; a group without comparators matches everything.
$constraint string public function parse_constraint($constraint): array
{
if (isset($this->_constraint_cache[$constraint])) {
return $this->_constraint_cache[$constraint];
}
$c = preg_replace(['/@(?:dev|alpha|beta|rc|stable)\b/i', '/\s+as\s+\S+/i'], '', trim($constraint));
$groups = [];
foreach (preg_split('/\s*\|\|?\s*/', (string) $c) ?: [] as $part) {
$part = trim($part);
$comparators = [];
if (preg_match('/^(\S+)\s+-\s+(\S+)$/', $part, $m)) {
$comparators = $this->hyphen_range($m[1], $m[2]);
} else {
$part = (string) preg_replace('/(>=|<=|<>|!=|==|>|<|=|\^|~)\s+/', '$1', $part);
foreach (preg_split('/[\s,]+/', $part, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) {
foreach ($this->parse_token($token) as $comparator) {
$comparators[] = $comparator;
}
}
}
$groups[] = $comparators;
}
return $this->_constraint_cache[$constraint] = $groups ?: [[]];
}
public function php_lint($file): array
$file string public function php_lint($file): array
{
$proc = proc_open([PHP_BINARY, '-d', 'error_reporting=-1', '-d', 'display_errors=1', '-d', 'log_errors=0', '-l', $file], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if ( ! is_resource($proc)) {
return [1, 'cannot run ' . PHP_BINARY];
}
$output = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), $output];
}
public function pick_version(array $versions, $constraint, $min_stability = 'stable', $php_floor = null): ?array
Highest version of the expanded p2 list that satisfies the constraint and the allowed stability.
$versions array$constraint array|string a list: the version must satisfy every one$min_stability string$php_floor null|string skip versions whose `require.php` this PHP version does not satisfy public function pick_version(array $versions, $constraint, $min_stability = 'stable', $php_floor = null): ?array
{
$constraints = (array) $constraint;
$allowed = $this->STABILITY_RANK[$min_stability];
foreach ($constraints as $c) {
$allowed = max($allowed, $this->STABILITY_RANK[$this->constraint_stability($c) ?: 'stable']);
}
$best = null;
$best_norm = '';
foreach ($versions as $v) {
if ( ! isset($v['version'])) {
continue;
}
try {
$norm = (string) ($v['version_normalized'] ?? $this->normalize_version($v['version']));
if ($this->STABILITY_RANK[$this->version_stability($norm)] > $allowed) {
continue;
}
foreach ($constraints as $c) {
if ( ! $this->satisfies($norm, $c)) {
continue 2;
}
}
$need_php = trim((string) ($v['require']['php'] ?? ''));
if ($php_floor !== null && $need_php !== '' && ! $this->satisfies($php_floor, $need_php)) {
continue;
}
} catch (yf_services_lock_exception $e) {
continue;
}
$is_branch = stripos($norm, 'dev-') === 0;
$best_is_branch = $best !== null && stripos($best_norm, 'dev-') === 0;
if ($best === null || ($best_is_branch && ! $is_branch) || ( ! $is_branch && version_compare($norm, $best_norm, '>'))) {
$best = $v;
$best_norm = $norm;
}
}
return $best;
}
public function read($path): ?array
$path stringReturns null|array null when the file is missing or not a lock
public function read($path): ?array
{
if ( ! is_file($path)) {
return null;
}
$data = $this->decode_json((string) file_get_contents($path));
return $data && isset($data['packages']) ? $data : null;
}
public function remove_tree($dir)
$dir string public function remove_tree($dir)
{
if (is_link($dir) || is_file($dir)) {
@unlink($dir);
} elseif (is_dir($dir)) {
foreach (scandir($dir) as $f) {
if ($f !== '.' && $f !== '..') {
$this->remove_tree($dir . '/' . $f);
}
}
@rmdir($dir);
}
}
public function resolve(array $roots, array $options = []): array
Resolve the roots into a lock array, see resolve_tree().
$roots array ['vendor/package' => 'constraint'], a list of "vendor/package:constraint" strings works too$options array skip, platform, fetch, stability public function resolve(array $roots, array $options = []): array
{
return $this->resolve_tree($roots, $options)['lock'];
}
public function resolve_ref($url, $ref): array
Full sha and version label of a ref of a GitHub repository through `git ls-remote`: a branch, else a tag (the commit an annotated tag points at), else with no ref the default branch.
$url string https://github.com/owner/repo.git$ref string '' = HEADReturns array [sha, version]
public function resolve_ref($url, $ref): array
{
$out = '';
if ($ref === '') {
if ( ! $this->git(['ls-remote', '--symref', '--', $url, 'HEAD'], $out)) {
throw new yf_services_lock_exception('git ls-remote failed for ' . $url);
}
$branch = preg_match('~^ref:\s+refs/heads/(\S+)\s+HEAD$~m', $out, $m) ? $m[1] : '';
if ( ! preg_match('~^([0-9a-f]{40})\s+HEAD$~m', $out, $m2)) {
throw new yf_services_lock_exception('git ls-remote of ' . $url . ' has no HEAD');
}
return [$m2[1], 'dev-' . ($branch !== '' ? $branch : 'HEAD')];
}
if ( ! preg_match('~^[A-Za-z0-9._/+-]+$~D', $ref) || $ref[0] === '-' || strpos($ref, '..') !== false) {
throw new yf_services_lock_exception('"' . $ref . '" is not a usable git ref');
}
if ( ! $this->git(['ls-remote', '--', $url, 'refs/heads/' . $ref, 'refs/tags/' . $ref, 'refs/tags/' . $ref . '^{}'], $out)) {
throw new yf_services_lock_exception('git ls-remote failed for ' . $url);
}
$refs = [];
foreach (explode("\n", $out) as $line) {
if (preg_match('~^([0-9a-f]{40})\s+(\S+)$~', trim($line), $m)) {
$refs[$m[2]] = $m[1];
}
}
if (isset($refs['refs/heads/' . $ref])) {
return [$refs['refs/heads/' . $ref], 'dev-' . $ref];
}
$sha = $refs['refs/tags/' . $ref . '^{}'] ?? $refs['refs/tags/' . $ref] ?? '';
if ($sha === '') {
throw new yf_services_lock_exception('ref "' . $ref . '" not found in ' . $url);
}
return [$sha, $ref];
}
public function resolve_tree(array $roots, array $options = []): array
Walk the dependency tree. No SAT solver: the highest version satisfying the first constraint met is locked; a later constraint it does not satisfy restarts the walk once with every constraint seen for that package intersected, and only when no version satisfies them all is it a conflict. A `require` on a virtual package (or on a package a locked one `replace`s) is satisfied by a locked package that `provide`s / `replace`s it with a satisfying version, else by the `implementations` map; the decision is recorded as `provided_by` on the requirer.
$roots array$options array skip: package names to prune; platform: ext-* / php the caller provides (default: this PHP and its loaded extensions); fetch: callable $name => decoded p2 array|null (default fetch_packagist()); stability: lowest allowed stability, default 'stable'; platform_php: lowest PHP the locked code must run on, a version whose `require.php` it does not satisfy is skipped like an unstable one (default: no check); implementations: ['virtual/package' => 'vendor/package:constraint'] layered over $IMPLEMENTATIONSReturns array ['lock' => lock array, 'tree' => rows name, version, depth, reference, status (locked|seen|pruned|provided), note]
public function resolve_tree(array $roots, array $options = []): array
{
$roots = $this->normalize_roots($roots);
$skip = array_flip(array_map('strtolower', (array) ($options['skip'] ?? [])));
$platform = [];
foreach ((array) ($options['platform'] ?? $this->default_platform()) as $k => $v) {
$platform[strtolower(is_int($k) ? (string) $v : (string) $k)] = true;
}
$fetch = $options['fetch'] ?? [$this, 'fetch_packagist'];
$min_stability = (string) ($options['stability'] ?? 'stable');
$php_floor = isset($options['platform_php']) && $options['platform_php'] !== '' ? (string) $options['platform_php'] : null;
if ( ! isset($this->STABILITY_RANK[$min_stability])) {
throw new yf_services_lock_exception('unknown stability "' . $min_stability . '"');
}
$implementations = $this->normalize_implementations(array_merge($this->IMPLEMENTATIONS, (array) ($options['implementations'] ?? [])));
$versions_cache = [];
$narrow = [];
$versions_for = function (string $name, array $constraints, string $by) use (&$versions_cache, $fetch) {
$dev = (int) (bool) array_filter($constraints, fn ($c) => $this->constraint_stability($c) === 'dev');
$versions_cache[$name . '|' . $dev] ??= $this->load_versions($name, implode(' ', $constraints), $fetch, $by);
return $versions_cache[$name . '|' . $dev];
};
$walk = function () use (&$narrow, $versions_for, $roots, $skip, $platform, $min_stability, $php_floor, $implementations) {
$locked = [];
$pruned = [];
$tree = [];
$seen = [];
$provided = [];
$deferred = [];
$provide = function (string $name, string $constraint, string $by, int $depth, string $provider) use (&$provided, &$tree) {
$provided[$by][$name] = ['constraint' => $constraint, 'by' => $provider];
$tree[] = $this->tree_row($name, '', $depth, '', 'provided', 'provided by ' . $provider . ' (required by ' . $by . ')');
};
$visit = function (string $name, string $constraint, string $by, int $depth) use (&$visit, &$locked, &$pruned, &$tree, &$seen, &$deferred, &$narrow, &$provide, $versions_for, $skip, $platform, $min_stability, $php_floor) {
$seen[$name][] = [$constraint, $by];
if (isset($skip[$name])) {
$pruned[$name] = true;
$tree[] = $this->tree_row($name, '', $depth, '', 'pruned', 'skip');
return;
}
if (isset($pruned[$name])) {
$tree[] = $this->tree_row($name, '', $depth, '', 'pruned', $pruned[$name] === true ? 'skip' : $pruned[$name]);
return;
}
if (isset($locked[$name])) {
$entry = $locked[$name];
if ( ! $this->satisfies($entry['version'], $constraint)) {
$message = $name . ': ' . $constraint . ' (required by ' . $by . ') conflicts with ' . $entry['version']
. ' locked for ' . $entry['_constraint'] . ' (required by ' . $entry['_by'] . ')';
$all = array_values(array_unique(array_column($seen[$name], 0)));
sort($all);
if (($narrow[$name] ?? []) == $all) {
throw new yf_services_lock_exception($message);
}
// a lower version may satisfy every constraint: walk again with them intersected, else a real conflict
if ($this->pick_version($versions_for($name, $all, $by), $all, $min_stability, $php_floor) === null) {
throw new yf_services_lock_exception($message);
}
$narrow[$name] = $all;
throw new yf_services_lock_retry($message);
}
$tree[] = $this->tree_row($name, $entry['version'], $depth, $entry['dist']['reference'] ?? '', 'seen', '');
return;
}
$by_package = $this->find_provider($locked, $name, $constraint);
if ($by_package !== null) {
$provide($name, $constraint, $by, $depth, $by_package);
return;
}
$constraints = $narrow[$name] ?? [$constraint];
try {
$versions = $versions_for($name, $constraints, $by);
} catch (yf_services_lock_not_found $e) {
$deferred[] = [$name, $constraint, $by, $depth, $e->getMessage()];
return;
}
$pick = $this->pick_version($versions, $constraints, $min_stability, $php_floor);
if ($pick === null) {
throw new yf_services_lock_exception($name . ': no version satisfies ' . implode(' and ', $constraints) . ' (required by ' . $by . ', stability ' . $min_stability . ($php_floor !== null ? ', php ' . $php_floor : '') . ')');
}
$provides = array_map('strtolower', array_keys((array) ($pick['provide'] ?? [])));
if ($provides && ! array_diff($provides, array_keys($platform))) {
$note = 'provided by platform: ' . implode(', ', $provides);
$pruned[$name] = $note;
$tree[] = $this->tree_row($name, (string) $pick['version'], $depth, (string) ($pick['dist']['reference'] ?? ''), 'pruned', $note);
return;
}
$entry = $this->lock_entry($pick);
$entry['_constraint'] = $constraint;
$entry['_by'] = $by;
$locked[$name] = $entry;
$tree[] = $this->tree_row($name, $entry['version'], $depth, (string) ($entry['dist']['reference'] ?? ''), 'locked', '');
foreach ((array) ($pick['require'] ?? []) as $dep => $dep_constraint) {
$dep = strtolower((string) $dep);
if ( ! $this->is_platform_requirement($dep)) {
$visit($dep, trim((string) $dep_constraint) === '' ? '*' : (string) $dep_constraint, $name, $depth + 1);
}
}
};
foreach ($roots as $name => $constraint) {
$visit($name, $constraint, 'root', 0);
}
// virtual packages met before their provider was locked, then the implementations map
while ($deferred) {
$progress = false;
foreach ($deferred as $i => [$name, $constraint, $by, $depth]) {
$by_package = $this->find_provider($locked, $name, $constraint);
if ($by_package !== null) {
$provide($name, $constraint, $by, $depth, $by_package);
unset($deferred[$i]);
$progress = true;
}
}
if ($progress) {
continue;
}
$i = array_key_first($deferred);
[$name, $constraint, $by, $depth, $message] = $deferred[$i];
unset($deferred[$i]);
if ( ! isset($implementations[$name])) {
throw new yf_services_lock_exception($message);
}
[$impl, $impl_constraint] = [key($implementations[$name]), reset($implementations[$name])];
$visit($impl, $impl_constraint, 'implementation of ' . $name, $depth);
$by_package = $this->find_provider($locked, $name, $constraint);
if ($by_package === null) {
throw new yf_services_lock_exception($name . ': ' . $impl . ' ' . $impl_constraint . ' (implementations) does not provide or replace a version satisfying ' . $constraint . ' (required by ' . $by . ')');
}
$provide($name, $constraint, $by, $depth, $by_package);
}
// a package that another locked one replaces (for every constraint seen) is not locked separately
$dropped = false;
foreach (array_keys($locked) as $name) {
if ( ! isset($locked[$name])) {
continue;
}
foreach ($locked as $other => $other_entry) {
if ($other === $name || ! isset($other_entry['replace'][$name])) {
continue;
}
$replaced = true;
foreach ($seen[$name] as [$constraint]) {
$replaced = $replaced && $this->find_provider([$other => $other_entry], $name, $constraint, ['replace']) !== null;
}
if ($replaced) {
foreach ($seen[$name] as [$constraint, $by]) {
$provided[$by][$name] = ['constraint' => $constraint, 'by' => $other];
}
foreach ($tree as &$row) {
if ($row['name'] === $name && $row['status'] === 'locked') {
$row['status'] = 'provided';
$row['note'] = 'replaced by ' . $other;
}
}
unset($row, $locked[$name]);
$dropped = true;
break;
}
}
}
if ($dropped) {
// the dependencies of a replaced package stay only while a locked package still needs them (directly or as a provider)
$reach = [];
$stack = array_keys(array_intersect_key($roots, $locked));
foreach ((array) ($provided['root'] ?? []) as $info) {
$stack[] = $info['by'];
}
while ($stack) {
$node = array_pop($stack);
if (isset($reach[$node]) || ! isset($locked[$node])) {
continue;
}
$reach[$node] = true;
$stack = array_merge($stack, array_keys((array) $locked[$node]['require']));
foreach ((array) ($provided[$node] ?? []) as $info) {
$stack[] = $info['by'];
}
}
foreach (array_diff_key($locked, $reach) as $name => $unused) {
foreach ($tree as &$row) {
if ($row['name'] === $name && $row['status'] === 'locked') {
$row['status'] = 'pruned';
$row['note'] = 'only needed by a replaced package';
}
}
unset($row, $locked[$name]);
}
}
ksort($locked);
foreach ($locked as $name => $entry) {
unset($entry['_constraint'], $entry['_by']);
$entry['require'] = array_intersect_key((array) $entry['require'], $locked);
if ( ! empty($provided[$name])) {
ksort($provided[$name]);
$entry['provided_by'] = $provided[$name];
}
$locked[$name] = $entry;
}
$lock = [
'_readme' => $this->README,
'generated' => gmdate('Y-m-d\TH:i:s\Z'),
'roots' => $roots,
'packages' => $locked,
];
if ( ! empty($provided['root'])) {
ksort($provided['root']);
$lock['provided_by'] = $provided['root'];
}
return ['lock' => $lock, 'tree' => $tree];
};
for ($attempt = 0; ; $attempt++) {
try {
return $walk();
} catch (yf_services_lock_retry $e) {
if ($attempt >= $this->MAX_RETRIES) {
throw new yf_services_lock_exception('gave up after ' . $this->MAX_RETRIES . ' restarts with intersected constraints (MAX_RETRIES): ' . $e->getMessage());
}
}
}
}
public function roots_from_config(array $config): array
Roots of a loader config: composer_names "vendor/package:constraint", a missing constraint means "*".
$config array public function roots_from_config(array $config): array
{
return $this->normalize_roots((array) ($config['composer_names'] ?? []));
}
public function satisfies($version, $constraint): bool
Does the version satisfy the composer constraint ("^1.2 || ~2.0,!=2.0.3", "1.2.*", ">=1.0 <2.0", "dev-master")?
$version string$constraint string public function satisfies($version, $constraint): bool
{
$groups = $this->parse_constraint((string) $constraint);
$version = $this->normalize_version($version);
$is_branch = stripos($version, 'dev-') === 0;
foreach ($groups as $comparators) {
$ok = true;
foreach ($comparators as [$op, $cv]) {
if ($is_branch || stripos($cv, 'dev-') === 0) {
$same = strcasecmp($version, $cv) === 0;
$ok = $op === '!=' ? ! $same : ($op === '==' && $same);
} else {
$ok = version_compare($version, $cv, $op);
}
if ( ! $ok) {
break;
}
}
if ($ok) {
return true;
}
}
return false;
}
public function shallow_checkout(array $entry, $dest): bool
Shallow checkout of a locked package at its sha into $dest (github.com sources only): fetch of the sha, else a clone of the tag named like the version. The checked-out HEAD must be the locked sha, whichever way it got there.
$entry array a lock package$dest string empty or missing directory public function shallow_checkout(array $entry, $dest): bool
{
$url = (string) ($entry['source']['url'] ?? '');
$sha = (string) ($entry['source']['reference'] ?? ($entry['dist']['reference'] ?? ''));
if ($url === '' && preg_match('~^https://api\.github\.com/repos/([^/]+/[^/]+)/zipball/~', (string) ($entry['dist']['url'] ?? ''), $m)) {
$url = 'https://github.com/' . $m[1] . '.git';
}
$repo = $this->github_repo($url);
if ($repo === '' || ! preg_match('~^[0-9a-f]{40}$~', $sha)) {
return false;
}
$url = 'https://github.com/' . $repo . '.git';
if ( ! is_dir($dest) && ! mkdir($dest, 0700, true)) {
return false;
}
if ($this->git(['init', '-q', '--', $dest]) && $this->git(['-C', $dest, 'remote', 'add', 'origin', $url])
&& $this->git(['-C', $dest, 'fetch', '-q', '--depth', '1', 'origin', $sha]) && $this->git(['-C', $dest, 'checkout', '-q', 'FETCH_HEAD'])
&& $this->head_is($dest, $sha)) {
return true;
}
$tmp = $dest . '.tag';
$ok = false;
if ($this->git(['clone', '-q', '--depth', '1', '--branch', (string) ($entry['version'] ?? ''), '--', $url, $tmp]) && $this->head_is($tmp, $sha)) {
$this->remove_tree($dest);
$ok = rename($tmp, $dest);
}
$this->remove_tree($tmp);
return $ok;
}
public function tree_row($name, $version, $depth, $reference, $status, $note): array
$name string$version string$depth int$reference string$status string$note string public function tree_row($name, $version, $depth, $reference, $status, $note): array
{
return ['name' => $name, 'version' => $version, 'depth' => $depth, 'reference' => $reference, 'status' => $status, 'note' => $note];
}
public function valid_path($path): bool
No "..", no absolute path, no empty or "." segment, no backslash, colon or control character.
public function valid_path($path): bool
{
if ( ! is_string($path) || $path === '' || strpbrk($path, "\\\0:") !== false || $path[0] === '/' || preg_match('~[\x00-\x1f\x7f]~', $path)) {
return false;
}
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
public function verify_entry(array $entry, $clone, array $options = []): array
What is wrong with a package in its checkout: a `files` or `resources_files` entry that is not there, a PHP file that does not compile or that compiles with a deprecation on this PHP (see lint_files()).
$entry array a lock package$clone string checkout of its sha$options array lint (default true), lint_runner public function verify_entry(array $entry, $clone, array $options = []): array
{
$problems = [];
foreach (['files' => (array) ($entry['autoload']['files'] ?? []), 'resources_files' => (array) ($entry['resources_files'] ?? [])] as $kind => $list) {
foreach ($list as $rel) {
if ( ! is_string($rel) || ! $this->inside_clone($clone, $rel) || ! is_file($clone . '/' . $rel)) {
$problems[] = $kind . ' entry "' . (is_string($rel) ? $rel : '?') . '" does not exist in the checkout';
}
}
}
if ($options['lint'] ?? true) {
$problems = array_merge($problems, $this->lint_files($clone, $this->code_files($entry, $clone), $options['lint_runner'] ?? null));
}
return $problems;
}
public function version_stability($version): string
stable, RC, beta, alpha or dev.
$version string public function version_stability($version): string
{
$version = (string) $version;
if (stripos($version, 'dev-') === 0 || preg_match('/-dev$/i', $version)) {
return 'dev';
}
if (preg_match('/-(alpha|beta|RC)\d*$/', $this->normalize_version($version), $m)) {
return $m[1];
}
return 'stable';
}
public function write($path, array $lock): bool
$path string$lock array public function write($path, array $lock): bool
{
try {
$json = json_encode($lock, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n";
} catch (JsonException $e) {
throw new yf_services_lock_exception('cannot encode the lock: ' . $e->getMessage(), 0, $e);
}
$dir = dirname($path);
if ( ! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) {
throw new yf_services_lock_exception('cannot create directory ' . $dir);
}
if (file_put_contents($path, $json, LOCK_EX) === false) {
throw new yf_services_lock_exception('cannot write ' . $path);
}
return true;
}