From 7ecb0ca188af1c6f41895b441a073e11124df9d1 Mon Sep 17 00:00:00 2001 From: Houssem Eddine BENABDALLAH Date: Sat, 31 Oct 2026 00:14:07 +0100 Subject: [PATCH] 2.0.0: PHP 8.5 minimum, files parsed in worker processes, faster builds --- .github/workflows/binaries.yml | 2 +- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 14 ++ Dockerfile | 2 + README.fr.md | 8 +- README.md | 8 +- bin/dev | 9 +- composer.json | 4 +- composer.lock | 265 ++++++++++++++------------- corpus/RESULTS.md | 22 +-- corpus/baseline.json | 42 ++--- server.json | 4 +- src/Builder/GraphBuilder.php | 58 ++++-- src/Builder/ParallelExtraction.php | 216 ++++++++++++++++++++++ src/Extractor/CachingExtractor.php | 79 ++++++-- src/Extractor/DocTypeResolver.php | 20 +- src/Extractor/ExtractionVisitor.php | 26 +++ src/Extractor/PendingCall.php | 19 ++ src/Extractor/SignatureExtractor.php | 47 +++++ src/Extractor/TypeExpr.php | 47 ++++- src/Graph/Edge.php | 19 ++ src/Graph/Node.php | 19 ++ tests/MethodTemplatesTest.php | 14 ++ tests/ParallelExtractionTest.php | 87 +++++++++ tests/TypeExprTest.php | 32 ++++ tests/VendorSignaturesTest.php | 23 +++ 26 files changed, 863 insertions(+), 225 deletions(-) create mode 100644 src/Builder/ParallelExtraction.php create mode 100644 src/Extractor/SignatureExtractor.php create mode 100644 tests/ParallelExtractionTest.php create mode 100644 tests/TypeExprTest.php diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 8acbd78..e6b7f3f 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -29,7 +29,7 @@ jobs: ref: ${{ inputs.ref }} - uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.5' ini-values: phar.readonly=0 coverage: none - run: composer install --no-interaction --no-progress diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f66b8f9..a8af06e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.2', '8.3', '8.4', '8.5'] + php: ['8.5'] steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 575db30..b04734e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ phpgraph follows [semantic versioning](https://semver.org) from 1.0.0; what stays stable is listed in [docs/STABILITY.md](docs/STABILITY.md), and changes only in a major version (2.0.0), announced here. +## 2.0.0 (2026-10-31) + +### Changed +- **PHP 8.5 or newer** is needed to run phpgraph from Composer or its PHAR (8.2 before): `composer require` no longer + installs it on PHP 8.2 to 8.4. The binaries and the Docker image bring their own PHP and change nothing for their + users; a project on an older PHP keeps being analysed, whatever PHP it is written for. Nothing else changes: the + commands, the MCP tools and `graph.json` are those of 1.6.0, and every graph of the corpus is identical. +- Builds are faster. The files are parsed by one worker process per processor, with PHP's `pcntl` extension (in the + binaries and the Docker image; not on Windows, where they are parsed one after the other); `PHPGRAPH_WORKERS` sets + the number of workers, `1` parses in one process. The dependencies' signatures are read without the bodies of their + methods, a chain of calls is typed once instead of once per call in it, and the extraction cache reads back only + the files a build reuses. Docker image, 12-thread laptop: Sylius 28 → 10 s, Akeneo 26 → 17 s; with the cache of + the previous build, Sylius 14 → 8 s. Peak memory +12 to +20 % with workers, −4 to −28 % without. + ## 1.6.0 (2026-10-30) ### Added diff --git a/Dockerfile b/Dockerfile index 691c24f..3db1872 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,8 @@ ARG PHPGRAPH_VERSION=dev # image has it. 8.5.12: CVE-2026-109618, remote code execution after an out-of-memory error (GHSA-w8hw-w9fx-j79w). ARG PHP_MINIMUM=8.5.12 RUN php -r 'if (version_compare(PHP_VERSION, getenv("PHP_MINIMUM"), "<")) { fwrite(STDERR, "PHP " . PHP_VERSION . " is older than " . getenv("PHP_MINIMUM") . "\n"); exit(1); }' +# pcntl: the files are parsed by one worker process per processor (ParallelExtraction). +RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS && docker-php-ext-install pcntl && apk del .build-deps # Opcache with its JIT: builds about 20% faster, the same graph (measured on Sylius and Akeneo). RUN printf 'opcache.enable_cli=1\nopcache.jit=tracing\nopcache.jit_buffer_size=128M\n' > /usr/local/etc/php/conf.d/phpgraph-opcache.ini LABEL org.opencontainers.image.title="phpgraph" \ diff --git a/README.fr.md b/README.fr.md index bf78baa..3ceeee0 100644 --- a/README.fr.md +++ b/README.fr.md @@ -14,7 +14,7 @@ CI Version Packagist Téléchargements - PHP 8.2+ + PHP 8.5+ Licence : MIT Listed on mcpservers.org

@@ -62,7 +62,7 @@ $env:PHPGRAPH_VERSION = "dev-main"; irm https://raw.githubusercontent.com/hbenab Le script télécharge le binaire, vérifie son SHA-256 et le place dans `~/.local/bin` (Windows : `%LOCALAPPDATA%\Programs\phpgraph`, ajouté au PATH) ; `PHPGRAPH_INSTALL_DIR` change le dossier. Le relancer met à jour. `dev-main` est reconstruit à chaque push sur `main` (la pré-version `edge`). Le binaire est un runtime PHP statique qui contient phpgraph, construit avec [static-php-cli](https://github.com/crazywhalecc/static-php-cli). -Avec PHP 8.2 ou plus, ou avec Docker : +Avec PHP 8.5 ou plus, ou avec Docker : ```bash composer require --dev hbenabdallah/phpgraph # ou : composer global require hbenabdallah/phpgraph @@ -135,7 +135,7 @@ phpgraph serve [chemin] # le serveur MCP, sur stdio phpgraph mcp-config claude|codex|cursor|json [--docker image] ``` -Options de `build` : `-e` pour exclure des chemins (répétable), `--no-vendor` pour ne pas lire les dépendances, `--no-cache` pour tout analyser à nouveau. `vendor`, `node_modules`, `var`, `.git` et les fichiers ignorés par les `.gitignore` du projet lui-même ne sont pas analysés ; un dépôt git au-dessus du projet n'applique jamais ses règles, si bien qu'un projet copié dans un dossier ignoré est quand même lu. Quand le dossier contient des fichiers PHP mais qu'ils sont tous exclus, `build` échoue en le disant, et chaque réponse MCP commence par cet avertissement au lieu de servir un graphe vide. La mémoire n'est pas limitée par défaut ; `PHPGRAPH_MEMORY_LIMIT=2G` fixe une limite. Avec PHP installé, opcache et son JIT accélèrent les builds d'environ 20 % : `php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=128M vendor/bin/phpgraph build` (l'image Docker et les commandes affichées par `mcp-config` les utilisent déjà). +Options de `build` : `-e` pour exclure des chemins (répétable), `--no-vendor` pour ne pas lire les dépendances, `--no-cache` pour tout analyser à nouveau. `vendor`, `node_modules`, `var`, `.git` et les fichiers ignorés par les `.gitignore` du projet lui-même ne sont pas analysés ; un dépôt git au-dessus du projet n'applique jamais ses règles, si bien qu'un projet copié dans un dossier ignoré est quand même lu. Quand le dossier contient des fichiers PHP mais qu'ils sont tous exclus, `build` échoue en le disant, et chaque réponse MCP commence par cet avertissement au lieu de servir un graphe vide. La mémoire n'est pas limitée par défaut ; `PHPGRAPH_MEMORY_LIMIT=2G` fixe une limite. Les fichiers sont analysés par un processus par processeur, avec l'extension `pcntl` de PHP (dans les binaires et l'image Docker ; pas sous Windows, où ils sont analysés l'un après l'autre) : Sylius se construit en 10 s au lieu de 28 s sur un portable à 12 threads. `PHPGRAPH_WORKERS=4` fixe le nombre de processus, `PHPGRAPH_WORKERS=1` analyse dans un seul processus. Avec PHP installé, opcache et son JIT accélèrent les builds d'environ 20 % : `php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=128M vendor/bin/phpgraph build` (l'image Docker et les commandes affichées par `mcp-config` les utilisent déjà). ## Ce que contient le graphe @@ -332,7 +332,7 @@ Sylius (le commit du corpus), octobre 2026, sans LLM : phpgraph 1.0.2, [codebase **Exécute-t-il mon code ?** Non. Il analyse les fichiers PHP et lit la configuration JSON, YAML et XML. Même `autoload_classmap.php` et les configurateurs de services PHP sont analysés, pas inclus. -**Quelles versions de PHP peut-il analyser ?** Tout ce que `nikic/php-parser` 5 sait lire : la syntaxe de PHP 7 et 8, et celle de PHP 4 et 5 retirée depuis (`$s{0}`, `=& new`, octaux comme `08`), pour un fichier que la grammaire la plus récente refuse. phpgraph lui-même demande PHP 8.2 ou plus. +**Quelles versions de PHP peut-il analyser ?** Tout ce que `nikic/php-parser` 5 sait lire : la syntaxe de PHP 7 et 8, et celle de PHP 4 et 5 retirée depuis (`$s{0}`, `=& new`, octaux comme `08`), pour un fichier que la grammaire la plus récente refuse. phpgraph lui-même demande PHP 8.5 ou plus. **Avec quels agents fonctionne-t-il ?** Tout client MCP sur stdio : [Sherpa](https://github.com/hbenabdallah/sherpa), Claude Code, Codex, Cursor, Claude Desktop, et d'autres. `mcp-config` affiche la configuration des plus courants. diff --git a/README.md b/README.md index 729668c..1a2ece8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ CI Packagist version Downloads - PHP 8.2+ + PHP 8.5+ License: MIT Listed on mcpservers.org

@@ -62,7 +62,7 @@ $env:PHPGRAPH_VERSION = "dev-main"; irm https://raw.githubusercontent.com/hbenab The script downloads the binary, checks its SHA-256 and puts it in `~/.local/bin` (Windows: `%LOCALAPPDATA%\Programs\phpgraph`, added to the PATH); `PHPGRAPH_INSTALL_DIR` changes the directory. Run it again to update. `dev-main` is rebuilt on every push to `main` (the `edge` pre-release). The binary is a static PHP runtime with phpgraph inside, built with [static-php-cli](https://github.com/crazywhalecc/static-php-cli). -With PHP 8.2+ installed, or with Docker: +With PHP 8.5+ installed, or with Docker: ```bash composer require --dev hbenabdallah/phpgraph # or: composer global require hbenabdallah/phpgraph @@ -135,7 +135,7 @@ phpgraph serve [path] # the MCP server, over stdio phpgraph mcp-config claude|codex|cursor|json [--docker image] ``` -`build` options: `-e` to exclude paths (repeatable), `--no-vendor` not to read dependencies, `--no-cache` to parse every file again. `vendor`, `node_modules`, `var`, `.git` and the files ignored by the project's own `.gitignore` files are skipped; a git repository above the project never applies its rules, so a project copied into an ignored directory is still read. When the directory holds PHP files but every one is excluded, `build` fails and says so, and every MCP answer starts with that warning instead of serving an empty graph. Memory is unlimited by default; `PHPGRAPH_MEMORY_LIMIT=2G` sets a limit. With PHP installed, opcache and its JIT make builds about 20% faster: `php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=128M vendor/bin/phpgraph build` (the Docker image and the commands printed by `mcp-config` already use them). +`build` options: `-e` to exclude paths (repeatable), `--no-vendor` not to read dependencies, `--no-cache` to parse every file again. `vendor`, `node_modules`, `var`, `.git` and the files ignored by the project's own `.gitignore` files are skipped; a git repository above the project never applies its rules, so a project copied into an ignored directory is still read. When the directory holds PHP files but every one is excluded, `build` fails and says so, and every MCP answer starts with that warning instead of serving an empty graph. Memory is unlimited by default; `PHPGRAPH_MEMORY_LIMIT=2G` sets a limit. The files are parsed by one worker process per processor, with PHP's `pcntl` extension (in the binaries and the Docker image; not on Windows, where they are parsed one after the other): Sylius builds in 10 s instead of 28 s on a 12-thread laptop. `PHPGRAPH_WORKERS=4` sets the number of workers, `PHPGRAPH_WORKERS=1` parses in one process. With PHP installed, opcache and its JIT make builds about 20% faster: `php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=128M vendor/bin/phpgraph build` (the Docker image and the commands printed by `mcp-config` already use them). ## What the graph contains @@ -332,7 +332,7 @@ Sylius (the corpus commit), October 2026, without any LLM: phpgraph 1.0.2, [code **Does it execute my code?** No. It parses PHP files and reads JSON, YAML and XML configuration. Even `autoload_classmap.php` and PHP service configurators are parsed, not included. -**Which PHP versions can it analyse?** Anything `nikic/php-parser` 5 parses: PHP 7 and 8 syntax, and the PHP 4 and 5 syntax removed since (`$s{0}`, `=& new`, octals like `08`), for a file the newest grammar rejects. phpgraph itself needs PHP 8.2 or newer. +**Which PHP versions can it analyse?** Anything `nikic/php-parser` 5 parses: PHP 7 and 8 syntax, and the PHP 4 and 5 syntax removed since (`$s{0}`, `=& new`, octals like `08`), for a file the newest grammar rejects. phpgraph itself needs PHP 8.5 or newer. **Which agents does it work with?** Any MCP client over stdio: [Sherpa](https://github.com/hbenabdallah/sherpa), Claude Code, Codex, Cursor, Claude Desktop, and others. `mcp-config` prints the configuration for the common ones. diff --git a/bin/dev b/bin/dev index 57567d5..620e2a8 100755 --- a/bin/dev +++ b/bin/dev @@ -1,4 +1,11 @@ #!/usr/bin/env sh # Runs a command in a PHP container when PHP is not installed locally: bin/dev composer check +# The image is composer:2 with pcntl, which parses the files in worker processes (ParallelExtraction), built once; +# `docker rmi phpgraph-dev` makes the next run build it again from the latest composer:2. # Host network: containers cannot reach a local DNS resolver such as systemd-resolved (127.0.0.53). -exec docker run --rm -i --network host -v "$(pwd)":/app -w /app -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer composer:2 "$@" +IMAGE=phpgraph-dev +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + printf 'FROM composer:2\nRUN apk add --no-cache --virtual .build $PHPIZE_DEPS && docker-php-ext-install pcntl && apk del .build\n' \ + | docker build --network host -q -t "$IMAGE" - >&2 || exit 1 +fi +exec docker run --rm -i --network host -v "$(pwd)":/app -w /app -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer "$IMAGE" "$@" diff --git a/composer.json b/composer.json index ce00e23..37ecc6c 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "source": "https://github.com/hbenabdallah/phpgraph" }, "require": { - "php": ">=8.2", + "php": ">=8.5", "nikic/php-parser": "^5.0", "phpstan/phpdoc-parser": "^2.3", "symfony/console": "^7.0", @@ -74,7 +74,7 @@ }, "config": { "platform": { - "php": "8.2.0" + "php": "8.5.0" }, "sort-packages": true } diff --git a/composer.lock b/composer.lock index f346b3e..cbfd9e7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9589a73ba4b3f59f5e4388457744b509", + "content-hash": "130b75aeddb50a1e02e3175525b4fa40", "packages": [ { "name": "nikic/php-parser", @@ -65,16 +65,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.6", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1427af4647235b4a73b13940b1f1258d3584f36e" + "reference": "e3647094ed8f0f1f67393d812b793a5143e25af3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e", - "reference": "1427af4647235b4a73b13940b1f1258d3584f36e", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/e3647094ed8f0f1f67393d812b793a5143e25af3", + "reference": "e3647094ed8f0f1f67393d812b793a5143e25af3", "shasum": "" }, "require": { @@ -106,9 +106,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.4.0" }, - "time": "2026-09-27T20:07:45+00:00" + "time": "2026-10-28T20:28:58+00:00" }, { "name": "psr/container", @@ -165,16 +165,16 @@ }, { "name": "symfony/console", - "version": "v7.4.20", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d" + "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", - "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", + "url": "https://api.github.com/repos/symfony/console/zipball/8012f842110b414fd1cc641adb0ed8423353b0bb", + "reference": "8012f842110b414fd1cc641adb0ed8423353b0bb", "shasum": "" }, "require": { @@ -239,7 +239,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.20" + "source": "https://github.com/symfony/console/tree/v7.4.22" }, "funding": [ { @@ -259,7 +259,7 @@ "type": "tidelift" } ], - "time": "2026-09-18T15:55:00+00:00" + "time": "2026-10-28T11:08:27+00:00" }, { "name": "symfony/deprecation-contracts", @@ -334,16 +334,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.20", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e" + "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", - "reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e", + "url": "https://api.github.com/repos/symfony/finder/zipball/e13420a8e0a76bdde1e7be09f923e62ad13739f2", + "reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2", "shasum": "" }, "require": { @@ -378,7 +378,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.20" + "source": "https://github.com/symfony/finder/tree/v7.4.22" }, "funding": [ { @@ -398,7 +398,7 @@ "type": "tidelift" } ], - "time": "2026-09-26T06:31:10+00:00" + "time": "2026-10-19T16:56:54+00:00" }, { "name": "symfony/polyfill-ctype", @@ -485,16 +485,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.43.0", + "version": "v1.43.3", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", - "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5a8f33c87aeecffce24e721b5064fab05f4becf8", + "reference": "5a8f33c87aeecffce24e721b5064fab05f4becf8", "shasum": "" }, "require": { @@ -543,7 +543,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.3" }, "funding": [ { @@ -563,7 +563,7 @@ "type": "tidelift" } ], - "time": "2026-09-25T15:46:47+00:00" + "time": "2026-10-15T09:18:53+00:00" }, { "name": "symfony/polyfill-intl-normalizer", @@ -652,16 +652,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.43.0", + "version": "v1.43.3", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", - "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a", "shasum": "" }, "require": { @@ -713,7 +713,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.3" }, "funding": [ { @@ -733,7 +733,7 @@ "type": "tidelift" } ], - "time": "2026-09-24T15:19:04+00:00" + "time": "2026-10-28T09:35:49+00:00" }, { "name": "symfony/service-contracts", @@ -824,35 +824,34 @@ }, { "name": "symfony/string", - "version": "v7.4.19", + "version": "v8.1.10", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46" + "reference": "6c6110979c0150393bbb336170e286d51ab4d76c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/02731ce2bd26514bec9efc0baef2cd0b0cf67f46", - "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46", + "url": "https://api.github.com/repos/symfony/string/zipball/6c6110979c0150393bbb336170e286d51ab4d76c", + "reference": "6c6110979c0150393bbb336170e286d51ab4d76c", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.33", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -891,7 +890,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.19" + "source": "https://github.com/symfony/string/tree/v8.1.10" }, "funding": [ { @@ -911,20 +910,20 @@ "type": "tidelift" } ], - "time": "2026-09-11T14:46:58+00:00" + "time": "2026-10-23T17:06:53+00:00" }, { "name": "symfony/yaml", - "version": "v7.4.20", + "version": "v7.4.22", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7" + "reference": "1ed6afa13d48c579c51cbf32e38f50686313497b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", - "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", + "url": "https://api.github.com/repos/symfony/yaml/zipball/1ed6afa13d48c579c51cbf32e38f50686313497b", + "reference": "1ed6afa13d48c579c51cbf32e38f50686313497b", "shasum": "" }, "require": { @@ -967,7 +966,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.20" + "source": "https://github.com/symfony/yaml/tree/v7.4.22" }, "funding": [ { @@ -987,7 +986,7 @@ "type": "tidelift" } ], - "time": "2026-09-26T06:31:10+00:00" + "time": "2026-10-18T14:39:27+00:00" } ], "packages-dev": [ @@ -1133,16 +1132,16 @@ }, { "name": "composer/semver", - "version": "3.5.0", + "version": "3.6.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5" + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", - "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", + "url": "https://api.github.com/repos/composer/semver/zipball/d92543b7ede9c4ec6b59853cefb061e39866d2cf", + "reference": "d92543b7ede9c4ec6b59853cefb061e39866d2cf", "shasum": "" }, "require": { @@ -1194,7 +1193,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.5.0" + "source": "https://github.com/composer/semver/tree/3.6.0" }, "funding": [ { @@ -1206,7 +1205,7 @@ "type": "github" } ], - "time": "2026-09-24T14:38:51+00:00" + "time": "2026-10-29T09:13:34+00:00" }, { "name": "composer/xdebug-handler", @@ -1276,42 +1275,42 @@ }, { "name": "ergebnis/agent-detector", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/ergebnis/agent-detector.git", - "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + "reference": "0cd815cec1207873c37624d33f0f2853523152f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", - "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/0cd815cec1207873c37624d33f0f2853523152f3", + "reference": "0cd815cec1207873c37624d33f0f2853523152f3", "shasum": "" }, "require": { "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.51.0", - "ergebnis/license": "^2.7.0", + "ergebnis/composer-normalize": "^2.54.0", + "ergebnis/license": "^2.8.0", "ergebnis/php-cs-fixer-config": "^6.60.2", - "ergebnis/phpstan-rules": "^2.13.1", - "ergebnis/phpunit-slow-test-detector": "^2.24.0", - "ergebnis/rector-rules": "^1.18.1", + "ergebnis/phpstan-rules": "^2.14.0", + "ergebnis/phpunit-slow-test-detector": "^2.26.0", + "ergebnis/rector-rules": "^1.18.5", "fakerphp/faker": "^1.24.1", "infection/infection": "^0.26.6", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.54", - "phpstan/phpstan-deprecation-rules": "^2.0.4", - "phpstan/phpstan-phpunit": "^2.0.16", - "phpstan/phpstan-strict-rules": "^2.0.10", + "phpstan/phpstan": "^2.2.14", + "phpstan/phpstan-deprecation-rules": "^2.0.5", + "phpstan/phpstan-phpunit": "^2.1.1", + "phpstan/phpstan-strict-rules": "^2.1.0", "phpunit/phpunit": "^9.6.34", - "rector/rector": "^2.4.2" + "rector/rector": "^2.7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.2-dev" + "dev-main": "1.3-dev" }, "composer-normalize": { "indent-size": 2, @@ -1341,7 +1340,7 @@ "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/agent-detector" }, - "time": "2026-05-07T08:19:07+00:00" + "time": "2026-10-15T11:13:32+00:00" }, { "name": "evenement/evenement", @@ -1736,11 +1735,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.16", + "version": "2.3.4", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", - "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f20ce6f3c82556cfcfc086b152445a12d1d64fff", + "reference": "f20ce6f3c82556cfcfc086b152445a12d1d64fff", "shasum": "" }, "require": { @@ -1796,7 +1795,7 @@ "type": "github" } ], - "time": "2026-09-25T09:31:51+00:00" + "time": "2026-10-28T22:38:53+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2147,16 +2146,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.56", + "version": "11.5.57", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", - "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/36b75bf56a8535623f5b3e24b27ea1823759da1a", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a", "shasum": "" }, "require": { @@ -2166,7 +2165,7 @@ "ext-libxml": "*", "ext-mbstring": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.4", + "myclabs/deep-copy": "^1.14.0", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", @@ -2229,7 +2228,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.57" }, "funding": [ { @@ -2237,7 +2236,7 @@ "type": "other" } ], - "time": "2026-07-06T14:52:39+00:00" + "time": "2026-10-05T05:47:54+00:00" }, { "name": "psr/event-dispatcher", @@ -3905,24 +3904,25 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.4.17", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", - "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/security-http": "<7.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -3931,14 +3931,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/stopwatch": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3966,7 +3966,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" }, "funding": [ { @@ -3986,7 +3986,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4070,25 +4070,26 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.18", + "version": "v8.1.9", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "90d412aa5277c6819db39e7605aa46b1019e3232" + "reference": "43d109c803d22c00bb820ed45df250b6a8792ca2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232", - "reference": "90d412aa5277c6819db39e7605aa46b1019e3232", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/43d109c803d22c00bb820ed45df250b6a8792ca2", + "reference": "43d109c803d22c00bb820ed45df250b6a8792ca2", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0|^8.0" + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4116,7 +4117,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.18" + "source": "https://github.com/symfony/filesystem/tree/v8.1.9" }, "funding": [ { @@ -4136,24 +4137,24 @@ "type": "tidelift" } ], - "time": "2026-08-23T10:03:40+00:00" + "time": "2026-10-06T20:05:50+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.4.8", + "version": "v8.1.10", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + "reference": "68cc43038d234c69d510c62f37bd0058b40d548a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", - "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/68cc43038d234c69d510c62f37bd0058b40d548a", + "reference": "68cc43038d234c69d510c62f37bd0058b40d548a", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", @@ -4187,7 +4188,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + "source": "https://github.com/symfony/options-resolver/tree/v8.1.10" }, "funding": [ { @@ -4207,7 +4208,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-10-18T14:56:39+00:00" }, { "name": "symfony/polyfill-php80", @@ -4455,20 +4456,20 @@ }, { "name": "symfony/process", - "version": "v7.4.19", + "version": "v8.1.10", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "ed0ae095b86994d370d5791612e55984f15aa30e" + "reference": "08aa69fa0998bb65a089f9e148a2c0f8b391398e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/ed0ae095b86994d370d5791612e55984f15aa30e", - "reference": "ed0ae095b86994d370d5791612e55984f15aa30e", + "url": "https://api.github.com/repos/symfony/process/zipball/08aa69fa0998bb65a089f9e148a2c0f8b391398e", + "reference": "08aa69fa0998bb65a089f9e148a2c0f8b391398e", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -4496,7 +4497,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.19" + "source": "https://github.com/symfony/process/tree/v8.1.10" }, "funding": [ { @@ -4516,24 +4517,24 @@ "type": "tidelift" } ], - "time": "2026-09-02T12:38:34+00:00" + "time": "2026-10-25T14:40:25+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.4.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + "reference": "21c07b026905d596e8379caeb115d87aa479499d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", - "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/21c07b026905d596e8379caeb115d87aa479499d", + "reference": "21c07b026905d596e8379caeb115d87aa479499d", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4.1", "symfony/service-contracts": "^2.5|^3" }, "type": "library", @@ -4562,7 +4563,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + "source": "https://github.com/symfony/stopwatch/tree/v8.1.0" }, "funding": [ { @@ -4582,7 +4583,7 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "theseer/tokenizer", @@ -4641,11 +4642,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.2" + "php": ">=8.5" }, "platform-dev": {}, "platform-overrides": { - "php": "8.2.0" + "php": "8.5.0" }, "plugin-api-version": "2.9.0" } diff --git a/corpus/RESULTS.md b/corpus/RESULTS.md index 1f0e26d..c487aec 100644 --- a/corpus/RESULTS.md +++ b/corpus/RESULTS.md @@ -6,17 +6,17 @@ phpgraph could not type, the main target of type inference work. Arrows compare | Project | Files | Failed | Duplicates | Classes | Methods | Edges | Vendor files read | Seconds | Memory MB | |---|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| php-ddd-example | 304 | 0 | 0 | 295 | 770 | 4182 | 111 | 0.77 | 30 | -| sylius | 4946 | 0 | 1 | 4449 | 23781 | 140715 | 679 | 12.71 | 706 | -| akeneo-pim | 8416 | 0 | 1 | 8447 | 38595 | 218452 | 762 | 18.74 | 724 | -| prestashop | 7850 | 0 | 5 | 7291 | 32895 | 187245 | 647 | 20.43 | 654 | -| ecotone-quickstart | 583 | 0 | 5 | 443 | 901 | 6823 | 76 | 1.14 | 38 | -| bookstack | 1513 | 0 | 0 | 698 | 4076 | 22834 | 393 | 6.57 | 156 | -| wordpress | 1899 | 0 | 50 | 840 | 7580 | 43373 | 0 | 9.66 | 187 | -| laravel-rabbitmq-microservices | 127 | 0 | 0 | 71 | 74 | 762 | 124 | 0.76 | 42 | -| art-gallery | 266 | 0 | 0 | 140 | 148 | 1693 | 122 | 0.98 | 48 | -| ticket-booking | 366 | 0 | 0 | 291 | 737 | 5040 | 220 | 1.28 | 34 | -| stayhub | 260 | 0 | 0 | 112 | 323 | 2035 | 156 | 2.33 | 56 | +| php-ddd-example | 304 | 0 | 0 | 295 | 770 | 4182 | 111 | 0.62 | 34 | +| sylius | 4946 | 0 | 1 | 4449 | 23781 | 140715 | 679 | 9.16 | 610 | +| akeneo-pim | 8416 | 0 | 1 | 8447 | 38595 | 218452 | 762 | 14.29 | 892 | +| prestashop | 7850 | 0 | 5 | 7291 | 32895 | 187245 | 647 | 12.67 | 766 | +| ecotone-quickstart | 583 | 0 | 5 | 443 | 901 | 6823 | 76 | 1.01 | 44 | +| bookstack | 1513 | 0 | 0 | 698 | 4076 | 22834 | 393 | 3.66 | 170 | +| wordpress | 1899 | 0 | 50 | 840 | 7580 | 43373 | 0 | 5.08 | 191 | +| laravel-rabbitmq-microservices | 127 | 0 | 0 | 71 | 74 | 762 | 124 | 0.67 | 40 | +| art-gallery | 266 | 0 | 0 | 140 | 148 | 1693 | 122 | 0.88 | 48 | +| ticket-booking | 366 | 0 | 0 | 291 | 737 | 5040 | 220 | 0.97 | 38 | +| stayhub | 260 | 0 | 0 | 112 | 323 | 2035 | 156 | 1.91 | 54 | ## Method calls in application code diff --git a/corpus/baseline.json b/corpus/baseline.json index dc091ae..aeaf83d 100644 --- a/corpus/baseline.json +++ b/corpus/baseline.json @@ -108,8 +108,8 @@ "contextPairs": 14, "layerFirst": false }, - "seconds": 0.77, - "peakMemoryMb": 30 + "seconds": 0.62, + "peakMemoryMb": 34 }, { "name": "sylius", @@ -239,8 +239,8 @@ "contextPairs": 0, "layerFirst": false }, - "seconds": 12.71, - "peakMemoryMb": 706 + "seconds": 9.16, + "peakMemoryMb": 610 }, { "name": "akeneo-pim", @@ -369,8 +369,8 @@ "contextPairs": 13, "layerFirst": false }, - "seconds": 18.74, - "peakMemoryMb": 724 + "seconds": 14.29, + "peakMemoryMb": 892 }, { "name": "prestashop", @@ -493,8 +493,8 @@ "contextPairs": 293, "layerFirst": true }, - "seconds": 20.43, - "peakMemoryMb": 654 + "seconds": 12.67, + "peakMemoryMb": 766 }, { "name": "ecotone-quickstart", @@ -604,8 +604,8 @@ "contextPairs": 0, "layerFirst": false }, - "seconds": 1.14, - "peakMemoryMb": 38 + "seconds": 1.01, + "peakMemoryMb": 44 }, { "name": "bookstack", @@ -701,8 +701,8 @@ "contextPairs": 0, "layerFirst": false }, - "seconds": 6.57, - "peakMemoryMb": 156 + "seconds": 3.66, + "peakMemoryMb": 170 }, { "name": "wordpress", @@ -821,8 +821,8 @@ "contextPairs": 0, "layerFirst": false }, - "seconds": 9.66, - "peakMemoryMb": 187 + "seconds": 5.08, + "peakMemoryMb": 191 }, { "name": "laravel-rabbitmq-microservices", @@ -920,8 +920,8 @@ "contextPairs": 0, "layerFirst": false }, - "seconds": 0.76, - "peakMemoryMb": 42 + "seconds": 0.67, + "peakMemoryMb": 40 }, { "name": "art-gallery", @@ -1015,7 +1015,7 @@ "contextPairs": 0, "layerFirst": false }, - "seconds": 0.98, + "seconds": 0.88, "peakMemoryMb": 48 }, { @@ -1119,8 +1119,8 @@ "contextPairs": 0, "layerFirst": false }, - "seconds": 1.28, - "peakMemoryMb": 34 + "seconds": 0.97, + "peakMemoryMb": 38 }, { "name": "stayhub", @@ -1220,7 +1220,7 @@ "contextPairs": 0, "layerFirst": false }, - "seconds": 2.33, - "peakMemoryMb": 56 + "seconds": 1.91, + "peakMemoryMb": 54 } ] diff --git a/server.json b/server.json index 235f327..b4ff93a 100644 --- a/server.json +++ b/server.json @@ -7,11 +7,11 @@ "url": "https://github.com/hbenabdallah/phpgraph", "source": "github" }, - "version": "1.6.0", + "version": "2.0.0", "packages": [ { "registryType": "oci", - "identifier": "ghcr.io/hbenabdallah/phpgraph:1.6.0", + "identifier": "ghcr.io/hbenabdallah/phpgraph:2.0.0", "transport": { "type": "stdio" }, diff --git a/src/Builder/GraphBuilder.php b/src/Builder/GraphBuilder.php index 0f86218..8564c46 100644 --- a/src/Builder/GraphBuilder.php +++ b/src/Builder/GraphBuilder.php @@ -13,6 +13,7 @@ use PhpGraph\Extractor\PendingRequest; use PhpGraph\Extractor\PhpFileExtractor; use PhpGraph\Extractor\RouteFact; +use PhpGraph\Extractor\SignatureExtractor; use PhpGraph\Extractor\TypeExpr; use PhpGraph\Graph\Confidence; use PhpGraph\Graph\Edge; @@ -22,16 +23,17 @@ use PhpGraph\Graph\Relation; use PhpGraph\Vendor\ComposerClassLocator; use PhpGraph\Vendor\VendorSignatures; -use PhpParser\Error; final class GraphBuilder { /** - * @param bool $readVendor read the signatures of dependency classes (vendor/) so call chains go through them + * @param bool $readVendor read the signatures of dependency classes (vendor/) so call chains go through them + * @param int|null $workers processes parsing the files, null for one per processor (ParallelExtraction) */ public function __construct( private readonly FileExtractor $extractor = new PhpFileExtractor(), private readonly bool $readVendor = true, + private readonly ?int $workers = null, ) { } @@ -60,6 +62,13 @@ public function build(string $root, array $excludePatterns = [], ?array $service $routingFiles = []; $configurationFiles = []; $schemaFiles = []; + /** @var list $phpFiles */ + $phpFiles = []; + $unchanged = []; + /** @var array $codes */ + $codes = []; + /** @var array $stats */ + $stats = []; foreach ($finder as $file) { $relativePath = str_replace('\\', '/', $file->getRelativePathname()); @@ -93,23 +102,34 @@ public function build(string $root, array $excludePatterns = [], ?array $service // Same size and modification time as in the last build of this process: the same file. $known = $previous?->files[$relativePath] ?? null; + $phpFiles[] = $relativePath; if ($known !== null && $known['mtime'] === $file->getMTime() && $known['size'] === $file->getSize()) { - $extractions[$relativePath] = $known['extraction']; - $files[$relativePath] = $known; + $unchanged[$relativePath] = $known; continue; } + $codes[$relativePath] = $file->getContents(); + $stats[$relativePath] = ['mtime' => (int) $file->getMTime(), 'size' => (int) $file->getSize()]; + } + $environment = hash_final($environment); - try { - $extraction = $this->extractor->extract($file->getContents(), $relativePath); - } catch (Error $error) { - $failures[$relativePath] = $error->getMessage(); + // Parsed together, in worker processes, then taken in the order of the files: the first declaration wins. + $parsed = (new ParallelExtraction($this->extractor, $this->workers))->extract($codes); + unset($codes); + foreach ($phpFiles as $relativePath) { + if (isset($unchanged[$relativePath])) { + $extractions[$relativePath] = $unchanged[$relativePath]['extraction']; + $files[$relativePath] = $unchanged[$relativePath]; + continue; + } + $extraction = $parsed[$relativePath]; + if (\is_string($extraction)) { + $failures[$relativePath] = $extraction; continue; } $extractions[$relativePath] = $extraction; - $files[$relativePath] = ['mtime' => (int) $file->getMTime(), 'size' => (int) $file->getSize(), 'extraction' => $extraction, 'signature' => BuildState::signature($extraction)]; + $files[$relativePath] = $stats[$relativePath] + ['extraction' => $extraction, 'signature' => BuildState::signature($extraction)]; $changed[$relativePath] = true; } - $environment = hash_final($environment); $serviceMap = ServiceMap::detect($root, $composerFiles, $services); $graph = new Graph(); @@ -199,18 +219,20 @@ public function build(string $root, array $excludePatterns = [], ?array $service $service = $serviceMap->serviceOf((string) $path); // A name written in this file, as a project id: this service's class, the shared root code, or external. $id = static fn (string $name): string => $names->canonical($name, $service); + /** @var \WeakMap $mapped shared by the calls of a chain: each holds the links below it */ + $mapped = new \WeakMap(); foreach ($extraction->edges as $edge) { $graph->addEdge(new Edge($id($edge->source), $id($edge->target), $edge->relation, $edge->confidence)); } foreach ($extraction->pendingCalls as $call) { - $pending[$path][] = new PendingCall($id($call->source), $call->receiver?->map($id), $call->method, $call->referenceOnMiss, $call->line, $call->named, $call->closure); + $pending[$path][] = new PendingCall($id($call->source), $call->receiver?->map($id, $mapped), $call->method, $call->referenceOnMiss, $call->line, $call->named, $call->closure); } foreach ($extraction->returnTypes as $method => $type) { $returnTypes[$id($method)] ??= $type === TypeExpr::STATIC ? $type : $id($type); } if ($extraction->returned !== null) { - $fileReturns['file:' . $path] = $extraction->returned->map($id); + $fileReturns['file:' . $path] = $extraction->returned->map($id, $mapped); } foreach ($extraction->invokedParameters as $method => $parameters) { $invokedParameters[$id($method)] = $parameters; @@ -263,7 +285,7 @@ public function build(string $root, array $excludePatterns = [], ?array $service $callArguments[$id($caller)][] = [strtolower($method), $line, array_map(static fn (?string $class): ?string => $class === null || $class === '' ? $class : $id($class), $arguments), $parameters]; } foreach ($extraction->parameterPasses as [$caller, $parameter, $receiver, $method, $argument]) { - $parameterPasses[$id($caller)][] = [$parameter, $receiver->map($id), $method, $argument]; + $parameterPasses[$id($caller)][] = [$parameter, $receiver->map($id, $mapped), $method, $argument]; } foreach ($extraction->propertyHolds as $class => $holds) { $propertyHolds[$id($class)] = array_map($id, $holds); @@ -284,7 +306,7 @@ public function build(string $root, array $excludePatterns = [], ?array $service $containerArguments[$service][] = $argument; } foreach ($extraction->propertyReads as [$reader, $property]) { - $propertyReads[] = [$id($reader), $property->map($id)]; + $propertyReads[] = [$id($reader), $property->map($id, $mapped)]; } $fileIds[(string) $path] = $id; $member = fn (string $name): string => str_contains($name, '::') ? $this->qualifiedMember($name, $id) : $id($name); @@ -303,7 +325,7 @@ public function build(string $root, array $excludePatterns = [], ?array $service foreach ($extraction->requests as $request) { $requests[] = [new PendingRequest( $id($request->source), - $request->receiver?->map($id), + $request->receiver?->map($id, $mapped), $request->staticClass === null ? null : $id($request->staticClass), $request->httpMethod, $request->path, @@ -334,11 +356,11 @@ public function build(string $root, array $excludePatterns = [], ?array $service $dispatches[] = [new PendingDispatch( $id($dispatch->source), $dispatch->method, - $dispatch->receiver?->map($id), + $dispatch->receiver?->map($id, $mapped), $dispatch->receiverIsThis, $dispatch->staticClass === null ? null : $id($dispatch->staticClass), $dispatch->isFunction, - $dispatch->message?->map($id), + $dispatch->message?->map($id, $mapped), $dispatch->hasArgument, $key($dispatch->routingKey), $dispatch->passesParameter, @@ -360,7 +382,7 @@ public function build(string $root, array $excludePatterns = [], ?array $service } else { $locator = new ComposerClassLocator($this->readVendor ? $vendorDirectories : []); // Without dependencies to read, PHP's own classes still are. - $vendor = new VendorSignatures($locator, $this->extractor); + $vendor = new VendorSignatures($locator, new SignatureExtractor()); } $methodsByClass = $this->methodsByClass($graph); diff --git a/src/Builder/ParallelExtraction.php b/src/Builder/ParallelExtraction.php new file mode 100644 index 0000000..570e736 --- /dev/null +++ b/src/Builder/ParallelExtraction.php @@ -0,0 +1,216 @@ + $codes relative path => source + * + * @return array relative path => its extraction, or why it could not be parsed + */ + public function extract(array $codes): array + { + $cache = $this->extractor instanceof CachingExtractor ? $this->extractor : null; + $results = []; + $toParse = []; + foreach ($codes as $path => $code) { + $reused = $cache?->reuse($code, $path); + if ($reused === null) { + $toParse[$path] = $code; + } else { + $results[$path] = $reused; + } + } + + foreach ($this->parse($cache?->inner() ?? $this->extractor, $toParse) as $path => $result) { + $results[$path] = $cache !== null && $result instanceof FileExtraction ? $cache->keep($toParse[$path], $path, $result) : $result; + } + + return $results; + } + + /** + * The worker processes whose extractions the last call used, 0 when it parsed the files itself. + */ + public function workersUsed(): int + { + return $this->workersUsed; + } + + public static function processors(): int + { + $configured = getenv('PHPGRAPH_WORKERS'); + if (\is_string($configured) && ctype_digit($configured)) { + return max(1, (int) $configured); + } + $cpuInfo = is_readable('/proc/cpuinfo') ? (string) file_get_contents('/proc/cpuinfo') : ''; + if ($cpuInfo !== '') { + return max(1, preg_match_all('/^processor\s*:/m', $cpuInfo)); + } + if (PHP_OS_FAMILY === 'Darwin') { + return max(1, (int) shell_exec('sysctl -n hw.logicalcpu')); + } + + return 1; + } + + /** + * @param array $codes + * + * @return array + */ + private function parse(FileExtractor $extractor, array $codes): array + { + $workers = min($this->workers ?? self::processors(), intdiv(\count($codes), self::FILES_PER_WORKER)); + $this->workersUsed = 0; + if ($workers < 2 || !\function_exists('pcntl_fork') || !\function_exists('posix_kill')) { + return self::parseHere($extractor, $codes); + } + + $children = []; + foreach ($this->shares($codes, $workers) as $share) { + $output = tempnam(sys_get_temp_dir(), 'phpgraph-'); + $pid = $output === false ? -1 : pcntl_fork(); + if ($pid === 0) { + self::work($extractor, $share, $output); + } + $children[] = ['pid' => $pid, 'share' => $share, 'output' => $output]; + } + + $results = []; + foreach ($children as $child) { + $parsed = null; + if ($child['pid'] > 0) { + pcntl_waitpid($child['pid'], $status); + $parsed = self::read((string) $child['output']); + } + if ($parsed !== null) { + ++$this->workersUsed; + } + if (\is_string($child['output'])) { + @unlink($child['output']); + } + $results += $parsed ?? self::parseHere($extractor, $child['share']); + } + + return $results; + } + + /** + * The files split into one share per worker, of about the same size: the largest file first, each to the share + * holding the least. + * + * @param array $codes + * + * @return list> + */ + private function shares(array $codes, int $workers): array + { + $sizes = array_map('strlen', $codes); + arsort($sizes); + $shares = array_fill(0, $workers, []); + $loads = array_fill(0, $workers, 0); + foreach ($sizes as $path => $size) { + $lightest = 0; + foreach ($loads as $worker => $load) { + if ($load < $loads[$lightest]) { + $lightest = $worker; + } + } + $shares[$lightest][$path] = $codes[$path]; + $loads[$lightest] += $size; + } + + return array_values(array_filter($shares, static fn (array $share): bool => $share !== [])); + } + + /** + * @param array $codes + */ + private static function work(FileExtractor $extractor, array $codes, string $output): never + { + try { + // One payload per file: unserialize() slows down far more than the size on one large payload (5.5 s + // instead of 0.9 s for PrestaShop's files). + file_put_contents($output, serialize(array_map('serialize', self::parseHere($extractor, $codes)))); + } catch (\Throwable) { + // Nothing written: the parent parses these files again itself, and meets the exception there. + } + posix_kill(posix_getpid(), \SIGKILL); + + exit(1); + } + + /** + * @param array $codes + * + * @return array + */ + private static function parseHere(FileExtractor $extractor, array $codes): array + { + $results = []; + foreach ($codes as $path => $code) { + try { + $results[$path] = $extractor->extract($code, $path); + } catch (Error $error) { + $results[$path] = $error->getMessage(); + } + } + + return $results; + } + + /** + * @return array|null null when the worker left nothing readable + */ + private static function read(string $output): ?array + { + $content = @file_get_contents($output); + $payloads = \is_string($content) && $content !== '' ? @unserialize($content, ['allowed_classes' => false]) : false; + if (!\is_array($payloads)) { + return null; + } + + $parsed = []; + foreach ($payloads as $path => $payload) { + $result = \is_string($payload) ? @unserialize($payload, ['allowed_classes' => CachingExtractor::ALLOWED_CLASSES]) : null; + if (!$result instanceof FileExtraction && !\is_string($result)) { + return null; + } + $parsed[(string) $path] = $result; + } + + return $parsed; + } +} diff --git a/src/Extractor/CachingExtractor.php b/src/Extractor/CachingExtractor.php index 16a4e31..8cd6625 100644 --- a/src/Extractor/CachingExtractor.php +++ b/src/Extractor/CachingExtractor.php @@ -19,7 +19,8 @@ */ final class CachingExtractor implements FileExtractor { - private const ALLOWED_CLASSES = [ + /** The classes an extraction is made of: all that unserialize() may create when reading one back. */ + public const ALLOWED_CLASSES = [ FileExtraction::class, Node::class, Edge::class, PendingCall::class, TypeExpr::class, HandlerFact::class, PendingDispatch::class, RouteFact::class, PendingRequest::class, NodeKind::class, Relation::class, Confidence::class, ConditionalReturn::class, ConditionalTest::class, ConditionalClass::class, ConditionalArgument::class, @@ -27,7 +28,12 @@ final class CachingExtractor implements FileExtractor DefinitionKind::class, Template::class, TemplateKind::class, PendingHelperCall::class, HelperArgument::class, ]; - /** @var array loaded from the previous build */ + /** + * The extractions of the cache file, each still serialized until a build reuses it: reading them all at once + * took half the build of a project whose files had not changed (9 s for Sylius). + * + * @var array + */ private array $previous = []; /** @var array made or reused by this process */ @@ -60,16 +66,44 @@ public function beginBuild(): void public function extract(string $code, string $relativePath): FileExtraction { - $key = hash('xxh128', $relativePath . "\0" . $code); - $extraction = $this->current[$key] ?? $this->previous[$key] ?? null; + return $this->reuse($code, $relativePath) ?? $this->keep($code, $relativePath, $this->extractor->extract($code, $relativePath)); + } + /** + * The extraction kept for this content of the file, or null when it must be parsed. + */ + public function reuse(string $code, string $relativePath): ?FileExtraction + { + $key = self::key($code, $relativePath); + $extraction = $this->current[$key] ?? $this->restored($key); if ($extraction === null) { - ++$this->misses; - $extraction = $this->extractor->extract($code, $relativePath); - } else { - ++$this->hits; + return null; } + ++$this->hits; + + return $this->remember($key, $relativePath, $extraction); + } + + /** + * Keeps an extraction parsed elsewhere, by a worker process, as if this extractor had parsed it. + */ + public function keep(string $code, string $relativePath, FileExtraction $extraction): FileExtraction + { + ++$this->misses; + + return $this->remember(self::key($code, $relativePath), $relativePath, $extraction); + } + /** + * What parses the files the cache does not hold. + */ + public function inner(): FileExtractor + { + return $this->extractor; + } + + private function remember(string $key, string $relativePath, FileExtraction $extraction): FileExtraction + { // Behind the cache file only for an extraction it does not hold; files gone are checked when saving. if (!isset($this->previous[$key])) { $this->dirty = true; @@ -83,6 +117,27 @@ public function extract(string $code, string $relativePath): FileExtraction return $this->current[$key] = $extraction; } + private function restored(string $key): ?FileExtraction + { + $stored = $this->previous[$key] ?? null; + if (!\is_string($stored)) { + return $stored; + } + $extraction = @unserialize($stored, ['allowed_classes' => self::ALLOWED_CLASSES]); + if (!$extraction instanceof FileExtraction) { + unset($this->previous[$key]); + + return null; + } + + return $this->previous[$key] = $extraction; + } + + private static function key(string $code, string $relativePath): string + { + return hash('xxh128', $relativePath . "\0" . $code); + } + /** * Whether the cache file is behind what this process extracted. */ @@ -116,7 +171,7 @@ public function save(?array $files = null): void } $temporary = $this->path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($temporary, self::version() . "\n" . serialize($entries)) === false || !rename($temporary, $this->path)) { + if (file_put_contents($temporary, self::version() . "\n" . serialize(array_map('serialize', $entries))) === false || !rename($temporary, $this->path)) { @unlink($temporary); return; @@ -136,7 +191,7 @@ public function stats(): array } /** - * @return array + * @return array key => its extraction, serialized */ private function load(): array { @@ -150,11 +205,11 @@ private function load(): array return []; } - $entries = @unserialize($payload, ['allowed_classes' => self::ALLOWED_CLASSES]); + $entries = @unserialize($payload, ['allowed_classes' => false]); $extractions = []; foreach (\is_array($entries) ? $entries : [] as $key => $entry) { - if ($entry instanceof FileExtraction) { + if (\is_string($entry)) { $extractions[(string) $key] = $entry; } } diff --git a/src/Extractor/DocTypeResolver.php b/src/Extractor/DocTypeResolver.php index 46341ad..1d2c0db 100644 --- a/src/Extractor/DocTypeResolver.php +++ b/src/Extractor/DocTypeResolver.php @@ -645,7 +645,7 @@ private function elementType(TypeNode $type, array $templates = []): ?string if ($type instanceof ArrayTypeNode && !$type->type instanceof GenericTypeNode) { return $this->resolve($type->type, $templates); } - if (!$type instanceof GenericTypeNode || !\in_array(strtolower(ltrim($type->type->name, '\\')), self::COLLECTIONS, true) || $type->genericTypes === []) { + if (!$type instanceof GenericTypeNode || !($type->type instanceof IdentifierTypeNode) || !\in_array(strtolower(ltrim($type->type->name, '\\')), self::COLLECTIONS, true) || $type->genericTypes === []) { return null; } @@ -679,8 +679,8 @@ private function resolve(TypeNode $type, array $templates = []): ?string } $name = match (true) { $type instanceof IdentifierTypeNode => $type->name, - $type instanceof GenericTypeNode => $type->type->name, - $type instanceof CallableTypeNode => $type->identifier->name, + $type instanceof GenericTypeNode => (string) $type->type, + $type instanceof CallableTypeNode => (string) $type->identifier, default => null, }; if ($name === null) { @@ -745,12 +745,12 @@ private function generic(TypeNode $type, array $methodTemplates, array $bound = $arguments = []; if ($type instanceof GenericTypeNode) { - $name = $type->type->name; + $name = (string) $type->type; foreach ($type->genericTypes as $argument) { $arguments[] = $this->generic($argument, $methodTemplates, $bound) ?? GenericType::UNKNOWN; } - } elseif ($type instanceof CallableTypeNode && strtolower(ltrim($type->identifier->name, '\\')) === 'closure') { - $name = $type->identifier->name; + } elseif ($type instanceof CallableTypeNode && strtolower(ltrim((string) $type->identifier, '\\')) === 'closure') { + $name = (string) $type->identifier; } elseif ($type instanceof IdentifierTypeNode) { $name = $type->name; } else { @@ -854,12 +854,12 @@ private function binders(PhpDocNode $doc, array $positions, array $templates): a continue; } $member = $members[0]; - if ($member instanceof GenericTypeNode && $member->type->name === 'class-string' && \count($member->genericTypes) === 1 + if ($member instanceof GenericTypeNode && $member->type instanceof IdentifierTypeNode && $member->type->name === 'class-string' && \count($member->genericTypes) === 1 && $member->genericTypes[0] instanceof IdentifierTypeNode && \in_array($member->genericTypes[0]->name, $templates, true)) { $binders[$member->genericTypes[0]->name] = ['class', $position]; } elseif ($member instanceof IdentifierTypeNode && \in_array($member->name, $templates, true)) { $binders[$member->name] ??= ['value', $position]; - } elseif ($member instanceof CallableTypeNode && \in_array(strtolower(ltrim($member->identifier->name, '\\')), ['callable', 'closure'], true) + } elseif ($member instanceof CallableTypeNode && \in_array(strtolower(ltrim((string) $member->identifier, '\\')), ['callable', 'closure'], true) && $member->returnType instanceof IdentifierTypeNode && \in_array($member->returnType->name, $templates, true)) { $binders[$member->returnType->name] ??= ['closure', $position]; } @@ -943,7 +943,7 @@ private function callableParameters(TypeNode $type, array $methodTemplates): ?ar $member = array_shift($members); if ($member instanceof UnionTypeNode) { array_push($members, ...$member->types); - } elseif ($member instanceof CallableTypeNode && \in_array(strtolower(ltrim($member->identifier->name, '\\')), ['callable', 'closure'], true)) { + } elseif ($member instanceof CallableTypeNode && \in_array(strtolower(ltrim((string) $member->identifier, '\\')), ['callable', 'closure'], true)) { $callable = $member; } } @@ -1016,7 +1016,7 @@ private function conditional(TypeNode $type, array $parameters, int $depth = 0): } $position = $parameters[substr($type->parameterName, 1)]; $target = $type->targetType; - $template = $target instanceof GenericTypeNode && $target->type->name === 'class-string' && \count($target->genericTypes) === 1 + $template = $target instanceof GenericTypeNode && $target->type instanceof IdentifierTypeNode && $target->type->name === 'class-string' && \count($target->genericTypes) === 1 && $target->genericTypes[0] instanceof IdentifierTypeNode ? $target->genericTypes[0]->name : null; $test = match (true) { $target instanceof IdentifierTypeNode && strtolower($target->name) === 'null' => ConditionalTest::IsNull, diff --git a/src/Extractor/ExtractionVisitor.php b/src/Extractor/ExtractionVisitor.php index c4f7df4..56ef709 100644 --- a/src/Extractor/ExtractionVisitor.php +++ b/src/Extractor/ExtractionVisitor.php @@ -175,6 +175,14 @@ final class ExtractionVisitor extends NodeVisitorAbstract /** @var array closure passed as an argument => what it returns, once read */ private array $closureReturns = []; + /** + * The types of the calls and property fetches already read, with the variables and closures they were read with: + * each call of a chain reads the chain below it, typed once instead of once per call above it. + * + * @var \WeakMap, int}> + */ + private \WeakMap $chainTypes; + /** @var list the closures being read, innermost last */ private array $closureNodes = []; @@ -269,6 +277,7 @@ final class ExtractionVisitor extends NodeVisitorAbstract public function __construct(private readonly string $path) { $this->fileId = 'file:' . $path; + $this->chainTypes = new \WeakMap(); $this->bus = new BusExtractor(); $this->configuration = new ConfigurationHelpers(fn (Name $name): ?string => $this->resolveName($name)); $this->inConfigurationDirectory = preg_match('#(^|/)config/#', $path) === 1; @@ -716,6 +725,7 @@ private function onCallable(string $id, string $label, NodeKind $kind, Stmt\Clas $this->localTypes = []; $this->pinned = []; $this->outerScopes = []; + $this->chainTypes = new \WeakMap(); foreach ($node->params as $param) { foreach ($this->typeNames($param->type) as $type) { @@ -2771,6 +2781,22 @@ private function bindingArguments(array $args): array * The type of an expression, possibly depending on other files: chains of calls and property fetches. */ private function typeOf(Expr $expr): ?TypeExpr + { + if (!$expr instanceof Expr\MethodCall && !$expr instanceof Expr\NullsafeMethodCall && !$expr instanceof Expr\PropertyFetch && !$expr instanceof Expr\NullsafePropertyFetch) { + return $this->typeOfUncached($expr); + } + // Read again only when a variable or a closure's return changed since: the same array is the same variables. + $read = $this->chainTypes[$expr] ?? null; + if ($read !== null && $read[1] === $this->localTypes && $read[2] === \count($this->closureReturns)) { + return $read[0]; + } + $type = $this->typeOfUncached($expr); + $this->chainTypes[$expr] = [$type, $this->localTypes, \count($this->closureReturns)]; + + return $type; + } + + private function typeOfUncached(Expr $expr): ?TypeExpr { if ($expr instanceof Expr\Variable && \is_string($expr->name)) { if ($expr->name === 'this') { diff --git a/src/Extractor/PendingCall.php b/src/Extractor/PendingCall.php index 11eecfb..861322a 100644 --- a/src/Extractor/PendingCall.php +++ b/src/Extractor/PendingCall.php @@ -22,4 +22,23 @@ public function __construct( public ?array $closure = null, ) { } + + /** + * Its properties in order, without their names: unserialize() then fills the declared properties instead of + * building a table of them for each object (5 times the memory, 3 times the time on 200 000 objects). + * + * @return array{string, ?TypeExpr, string, bool, ?int, list, array{int, int|string}|null} + */ + public function __serialize(): array + { + return [$this->source, $this->receiver, $this->method, $this->referenceOnMiss, $this->line, $this->named, $this->closure]; + } + + /** + * @param array{string, ?TypeExpr, string, bool, ?int, list, array{int, int|string}|null} $data + */ + public function __unserialize(array $data): void + { + [$this->source, $this->receiver, $this->method, $this->referenceOnMiss, $this->line, $this->named, $this->closure] = $data; + } } diff --git a/src/Extractor/SignatureExtractor.php b/src/Extractor/SignatureExtractor.php new file mode 100644 index 0000000..4f9f981 --- /dev/null +++ b/src/Extractor/SignatureExtractor.php @@ -0,0 +1,47 @@ +parser = new PhpCodeParser(); + } + + public function extract(string $code, string $relativePath): FileExtraction + { + $statements = $this->parser->parse($code); + $nameResolver = new NameResolver(); + $bodies = new class extends NodeVisitorAbstract { + public function enterNode(Node $node): null + { + // An abstract method keeps its null body: it is what tells it apart. + if (($node instanceof Node\Stmt\ClassMethod && $node->stmts !== null) || $node instanceof Node\Stmt\Function_) { + $node->stmts = []; + } + + return null; + } + }; + $statements = (new NodeTraverser($bodies, $nameResolver, new DocTypeResolver($nameResolver->getNameContext())))->traverse($statements); + + $visitor = new ExtractionVisitor($relativePath); + (new NodeTraverser($visitor))->traverse($statements); + + return $visitor->result(); + } +} diff --git a/src/Extractor/TypeExpr.php b/src/Extractor/TypeExpr.php index ae272cd..49a76cb 100644 --- a/src/Extractor/TypeExpr.php +++ b/src/Extractor/TypeExpr.php @@ -47,6 +47,25 @@ private function __construct( ) { } + /** + * Its properties in order, without their names: unserialize() then fills the declared properties instead of + * building a table of them for each object (5 times the memory, 3 times the time on 200 000 objects). + * + * @return array{?string, ?self, ?string, bool, bool, ?string, list, list, array{int|string, int}|null, array, list, array{self, list, bool, 3?: array{self, int, bool}}|null} + */ + public function __serialize(): array + { + return [$this->className, $this->receiver, $this->member, $this->isProperty, $this->isElement, $this->generic, $this->functions, $this->arguments, $this->closure, $this->argumentTypes, $this->members, $this->test]; + } + + /** + * @param array{?string, ?self, ?string, bool, bool, ?string, list, list, array{int|string, int}|null, array, list, array{self, list, bool, 3?: array{self, int, bool}}|null} $data + */ + public function __unserialize(array $data): void + { + [$this->className, $this->receiver, $this->member, $this->isProperty, $this->isElement, $this->generic, $this->functions, $this->arguments, $this->closure, $this->argumentTypes, $this->members, $this->test] = $data; + } + /** * A type nothing is known of: a variable assigned an expression of unknown type, an untyped parameter. Where * branches meet, it makes the union partial. @@ -222,30 +241,46 @@ public static function propertyOf(self $receiver, string $property): self * The same expression with every class name passed through $map: names written in a file, made project ids. * * @param \Closure(string): string $map + * @param \WeakMap|null $mapped the expressions already mapped with $map: the calls of a chain share its + * lower links, mapped once + */ + public function map(\Closure $map, ?\WeakMap $mapped = null): self + { + if ($mapped === null) { + /** @var \WeakMap $mapped */ + $mapped = new \WeakMap(); + } + + return $mapped[$this] ??= $this->mapOnce($map, $mapped); + } + + /** + * @param \Closure(string): string $map + * @param \WeakMap $mapped */ - public function map(\Closure $map): self + private function mapOnce(\Closure $map, \WeakMap $mapped): self { $arguments = array_map( static fn (string $argument): string => str_starts_with($argument, 'class:') ? 'class:' . $map(substr($argument, 6)) : $argument, $this->arguments, ); - $argumentTypes = array_map(static fn (self $type): self => $type->map($map), $this->argumentTypes); + $argumentTypes = array_map(static fn (self $type): self => $type->map($map, $mapped), $this->argumentTypes); if ($this->members !== []) { - return new self(null, null, null, false, false, null, [], [], null, [], array_map(static fn (self $member): self => $member->map($map), $this->members)); + return new self(null, null, null, false, false, null, [], [], null, [], array_map(static fn (self $member): self => $member->map($map, $mapped), $this->members)); } if ($this->test !== null) { $call = $this->test[3] ?? null; return $call === null - ? self::narrowed($this->test[0]->map($map), array_map($map, $this->test[1]), $this->test[2]) - : self::asserted($this->test[0]->map($map), $call[0]->map($map), $call[1], $this->test[2], $call[2]); + ? self::narrowed($this->test[0]->map($map, $mapped), array_map($map, $this->test[1]), $this->test[2]) + : self::asserted($this->test[0]->map($map, $mapped), $call[0]->map($map, $mapped), $call[1], $this->test[2], $call[2]); } if ($this->functions !== []) { return new self(null, null, null, false, false, null, array_map($map, $this->functions), $arguments, null, $argumentTypes); } if ($this->receiver !== null) { - return new self(null, $this->receiver->map($map), $this->member, $this->isProperty, $this->isElement, null, [], $arguments, $this->closure, $argumentTypes); + return new self(null, $this->receiver->map($map, $mapped), $this->member, $this->isProperty, $this->isElement, null, [], $arguments, $this->closure, $argumentTypes); } $className = $this->className === null ? null : $map($this->className); $generic = $this->generic === null ? null : GenericType::mapNames($this->generic, $map); diff --git a/src/Graph/Edge.php b/src/Graph/Edge.php index cfc6f37..fb0ea90 100644 --- a/src/Graph/Edge.php +++ b/src/Graph/Edge.php @@ -25,6 +25,25 @@ public function __construct( $this->lines = $lines === '0' ? '' : $lines; } + /** + * Its properties in order, without their names: unserialize() then fills the declared properties instead of + * building a table of them for each object (5 times the memory, 3 times the time on 200 000 objects). + * + * @return array{string, string, Relation, Confidence, string, string} + */ + public function __serialize(): array + { + return [$this->source, $this->target, $this->relation, $this->confidence, $this->lines, $this->via]; + } + + /** + * @param array{string, string, Relation, Confidence, string, string} $data + */ + public function __unserialize(array $data): void + { + [$this->source, $this->target, $this->relation, $this->confidence, $this->lines, $this->via] = $data; + } + public function via(): string { return $this->via; diff --git a/src/Graph/Node.php b/src/Graph/Node.php index eb01a98..5ac79c5 100644 --- a/src/Graph/Node.php +++ b/src/Graph/Node.php @@ -25,6 +25,25 @@ public function __construct( ) { } + /** + * Its properties in order, without their names: unserialize() then fills the declared properties instead of + * building a table of them for each object (5 times the memory, 3 times the time on 200 000 objects). + * + * @return array{string, string, NodeKind, ?string, ?int, ?string, list, ?string} + */ + public function __serialize(): array + { + return [$this->id, $this->label, $this->kind, $this->file, $this->line, $this->service, $this->modifiers, $this->alias]; + } + + /** + * @param array{string, string, NodeKind, ?string, ?int, ?string, list, ?string} $data + */ + public function __unserialize(array $data): void + { + [$this->id, $this->label, $this->kind, $this->file, $this->line, $this->service, $this->modifiers, $this->alias] = $data; + } + /** * @return array{id: string, label: string, kind: string, file: ?string, line: ?int, service?: string, modifiers?: list, alias?: string} */ diff --git a/tests/MethodTemplatesTest.php b/tests/MethodTemplatesTest.php index 7499bdc..56685b7 100644 --- a/tests/MethodTemplatesTest.php +++ b/tests/MethodTemplatesTest.php @@ -53,6 +53,20 @@ public function testWhatAClosureReturnsBindsTheTemplate(): void self::assertFalse($this->hasEdge($graph, 'App\Checkout::either', 'App\Order::ship', Relation::Calls, Confidence::Inferred), 'returns of different types: unknown'); } + public function testAChainIsTypedAgainOnceItsClosureIsRead(): void + { + $result = $this->buildProject(self::MANAGER + [ + 'src/Shipment.php' => 'namespace App; class Shipment { public function order(): Order { return new Order(); } }', + 'src/Dispatch.php' => 'namespace App; use Lib\Manager; class Dispatch { public function __construct(private Manager $manager) {}' + . ' public function run(): void { $order = $this->manager->transaction(function () { return new Shipment(); })->order(); $order->ship(); } }', + ]); + + self::assertTrue( + $this->hasEdge($result->graph, 'App\Dispatch::run', 'App\Order::ship', Relation::Calls, Confidence::Inferred), + 'the chain is first typed before its closure is read, then again for the assignment, with what the closure returns', + ); + } + public function testTheTypeOfAValueBindsTheTemplateOfAFunction(): void { $result = $this->buildProject([ diff --git a/tests/ParallelExtractionTest.php b/tests/ParallelExtractionTest.php new file mode 100644 index 0000000..ef4e4ce --- /dev/null +++ b/tests/ParallelExtractionTest.php @@ -0,0 +1,87 @@ +sources(90); + $codes['src/Broken.php'] = "extract($codes); + $parallel = new ParallelExtraction(new PhpFileExtractor(), 3); + $shared = $parallel->extract($codes); + + self::assertSame(3, $parallel->workersUsed(), 'pcntl is available where the tests run (bin/dev, CI)'); + self::assertEquals($alone, $shared); + self::assertIsString($shared['src/Broken.php'], 'a file that cannot be parsed comes back as the parser error'); + self::assertInstanceOf(FileExtraction::class, $shared['src/Class0.php']); + } + + public function testAFewFilesAreParsedWithoutWorkers(): void + { + $extraction = new ParallelExtraction(new PhpFileExtractor(), 4); + $extraction->extract($this->sources(30)); + + self::assertSame(0, $extraction->workersUsed(), 'forking costs more than parsing 30 files'); + } + + public function testTheCacheKeepsWhatTheWorkersParsed(): void + { + $codes = $this->sources(60); + $cache = new CachingExtractor(new PhpFileExtractor(), $this->root . '/cache.bin'); + + (new ParallelExtraction($cache, 2))->extract($codes); + (new ParallelExtraction($cache, 2))->extract($codes); + + self::assertSame(['parsed' => \count($codes), 'reused' => \count($codes)], $cache->stats(), 'the second extraction reuses all the first one parsed'); + self::assertTrue($cache->isDirty()); + } + + public function testABuildWithWorkersGivesTheSameGraph(): void + { + $sources = []; + foreach ($this->sources(80) as $path => $code) { + $sources[$path] = substr($code, \strlen("write($sources); + + $alone = (new GraphBuilder(workers: 1))->build($this->root); + $parallel = (new GraphBuilder(workers: 4))->build($this->root); + + self::assertSame($alone->graph->toArray(), $parallel->graph->toArray()); + self::assertSame($alone->calls->toArray(), $parallel->calls->toArray()); + } + + /** + * Classes calling the one before them, and a duplicate declaration: the first file read must still win. + * + * @return array + */ + private function sources(int $count): array + { + $codes = []; + for ($index = 0; $index < $count; ++$index) { + $previous = max(0, $index - 1); + $codes["src/Class{$index}.php"] = "previous->run(); }\n}\n"; + } + $codes['src/Duplicate.php'] = " $mapped */ + $mapped = new \WeakMap(); + $names = 0; + $map = static function (string $name) use (&$names): string { + ++$names; + + return 'service@' . $name; + }; + + $first = $query->map($map, $mapped); + $second = $where->map($map, $mapped); + + self::assertSame($first, $second->receiver, 'the lower link of the chain is mapped once, for both calls'); + self::assertSame(1, $names); + self::assertSame('service@app\repository->query()->where()', $second->key()); + } +} diff --git a/tests/VendorSignaturesTest.php b/tests/VendorSignaturesTest.php index a8aab6a..1823084 100644 --- a/tests/VendorSignaturesTest.php +++ b/tests/VendorSignaturesTest.php @@ -7,6 +7,7 @@ use PhpGraph\Builder\BuildResult; use PhpGraph\Builder\GraphBuilder; use PhpGraph\Extractor\PhpFileExtractor; +use PhpGraph\Extractor\SignatureExtractor; use PhpGraph\Graph\Confidence; use PhpGraph\Graph\Relation; use PHPUnit\Framework\TestCase; @@ -92,6 +93,28 @@ public function testAProjectClassShadowsAVendorClassOfTheSameName(): void self::assertTrue($this->hasCall($result, 'Acme\Orm\Report::run', 'Acme\Orm\Query::count')); } + public function testSignaturesAreReadWithoutWhatTheMethodsDo(): void + { + $code = "query(); return \$query->first(); }\n" + . " abstract protected function query(): Query;\n" + . " public function __construct(private Connection \$connection) { \$this->connection->open(); }\n" + . "}\n" + . "function repository(): Repository { return make(Repository::class)->fresh(); }\n"; + + $full = (new PhpFileExtractor())->extract($code, 'vendor/acme/orm/src/Repository.php'); + $signatures = (new SignatureExtractor())->extract($code, 'vendor/acme/orm/src/Repository.php'); + + self::assertEquals($full->nodes, $signatures->nodes, 'the same declarations, the abstract method still abstract'); + self::assertSame($full->returnTypes, $signatures->returnTypes); + self::assertSame($full->genericReturns, $signatures->genericReturns); + self::assertSame($full->templates, $signatures->templates); + self::assertSame($full->propertyTypes, $signatures->propertyTypes, 'promoted constructor parameters'); + self::assertNotSame([], $full->pendingCalls); + self::assertSame([], $signatures->pendingCalls, 'the bodies are not read'); + } + private function build(string $code, bool $readVendor = true): BuildResult { foreach (self::VENDOR + ['src/a.php' => " $content) {