yf_services_lock class

Dependency 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.

Fileclasses/yf_services_lock.class.php
Use_class('services_lock')

Public properties

A site sets them in its config with $PROJECT_CONF['services_lock']['NAME'] = ...;

PropertyDefaultDescription
$CACHE_DIR
null|string
no cache */ Property [ public $CACHE_DIR = NULL directory of cached p2 answers, null = no cache
$CACHE_TTL
int
3600 seconds a cached answer stays valid
$FORK_OWNERS
array
['yfix'] github owners of forks: their converted packages keep "owner/repo" instead of the name in the repository's composer.json
$GIT_RUNNER
null|callable
null fn(array): [int exit code, string stdout] instead of running git (tests)
$IMPLEMENTATIONS
array
['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_GLOBS
array
['services/*.php', 'services/*/*.php', 'share/services/*.php', 'share/services/*/*.php', 'plugins/*/services/*.php', 'plugins/*/services/*/*.php', ... (8 items)]
array (
  0 => 'services/*.php',
  1 => 'services/*/*.php',
  2 => 'share/services/*.php',
  3 => 'share/services/*/*.php',
  4 => 'plugins/*/services/*.php',
  5 => 'plugins/*/services/*/*.php',
  6 => 'plugins/*/share/services/*.php',
  7 => 'plugins/*/share/services/*/*.php',
)
root-relative globs of loader files, same set yf_services::require_php_lib() reads
$MAX_RETRIES
int
25 most walks resolve_tree() starts with intersected constraints
$PACKAGIST_URL
string
'https://repo.packagist.org/p2/' packagist metadata endpoint, "<name>.json" is appended
$README
string
'Generated by services:resolve (yf_services_lock), a subse'...
$README_CONVERTED
string
'Generated by services:lock-from-loader (yf_services_lock)'...
$ROOTS
null|array
null root dirs of loaders and locks instead of YF_PATH, APP_PATH, PROJECT_PATH (tests)
$STABILITY_RANK
array
['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
$TIMEOUT
int
10 seconds
$USER_AGENT
string
'yf-services (+https://github.com/yfix/yf)' User-Agent of the packagist requests
$_composer_cache
array
[] composer.json of a repository at a sha by url, one fetch per converted repository
$_constraint_cache
array
[] parsed constraints by constraint string
$converted
array
[] loader name => lock produced for a required service as a side effect of lock_from_loader()

Public methods 48

carry_over

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;
    }

classes_of

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;
    }

code_files

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;
    }

decode_json

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;
    }

expand_p2

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 versions

Returns 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;
    }

find_loaders

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;
    }

find_provider

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;
    }

git_entry

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' => [],
        ];
    }

github_repo

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);
    }

glob_regex

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 . '$~';
    }

head_is

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)));
    }

index_entry

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;
    }

inside_clone

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);
    }

lint_files

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;
    }

list_files

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;
    }

load_versions

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
@throws yf_services_lock_exception
    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;
    }

loader_config

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 : [];
    }

loader_dirs

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__) . '/'];
    }

loader_repos

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
@throws yf_services_lock_exception
    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;
    }

lock_entry

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)
@throws yf_services_lock_exception
    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;
    }

lock_path

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';
    }

make_temp_dir

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;
    }

package_name

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 ?: [[]];
    }

php_lint

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];
    }

pick_version

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;
    }

read

public function read($path): ?array
  • $path string

Returns 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;
    }

remove_tree

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);
        }
    }

resolve

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
@throws yf_services_lock_exception
    public function resolve(array $roots, array $options = []): array
    {
        return $this->resolve_tree($roots, $options)['lock'];
    }

resolve_ref

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 '' = HEAD

Returns array [sha, version]

@throws yf_services_lock_exception
    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];
    }

resolve_tree

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 $IMPLEMENTATIONS

Returns array ['lock' => lock array, 'tree' => rows name, version, depth, reference, status (locked|seen|pruned|provided), note]

@throws yf_services_lock_exception
    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'] ?? []));
    }

satisfies

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;
    }

tree_row

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];
    }

valid_path

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;
    }

verify_entry

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';
    }

write

public function write($path, array $lock): bool
  • $path string
  • $lock array
@throws yf_services_lock_exception
    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;
    }