diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45a9cd2..e0f6895 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,9 +75,12 @@ jobs: brew install autoconf automake bison cmake gettext libtool re2c echo "$(brew --prefix bison)/bin" >> "$GITHUB_PATH" + # The version is part of the lock's content hash, so the lock takes the new hash; its packages stay as committed. - name: Set the version of the binary if: env.VERSION != '' - run: composer config version "$VERSION" + run: | + composer config version "$VERSION" + composer update --lock --no-install --no-interaction --no-progress - name: Install the dependencies run: composer install --no-dev --no-interaction --no-progress diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 65cb4f4..ae775d4 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1212,6 +1212,7 @@ private function hasOnlyWhitespace(Tokens $tokens, int $startIndex, int $endInde ->name('*.php') ->in([ __DIR__ . '/bin', + __DIR__ . '/generator', __DIR__ . '/src', __DIR__ . '/tests', ]) diff --git a/README.md b/README.md index b6c4daf..a790261 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Everything SKERNEL downloads and builds lives in its home directory, `~/.skernel ~/.skernel/ ├── cache/ php.net and PECL release answers and Zig mirrors, refreshed daily ├── downloads/ source archives, shared by every build +├── generator// the generator a standalone SKERNEL extracts for builds ├── pkgroot// Zig and the other packages static-php-cli installs ├── runtimes//php-// │ ├── php the static PHP CLI @@ -100,9 +101,9 @@ A directory that exists and is not empty, a Composer that is not found or fails, run [-d|--working-dir=] [--php=] [--watch] [--] [...] ``` -`run` runs the project from source with its managed PHP, in a separate process in the project directory. `` go to the application, so `run swoole:start` starts its Swoole server; put options for the application after `--`. Without `--watch`, SKERNEL exits with the application's exit status. +`run` builds the project as `build` does and then runs it from source with its managed PHP, in a separate process in the project directory. `` go to the application, so `run swoole:start` starts its Swoole server; put options for the application after `--`. Without `--watch`, SKERNEL exits with the application's exit status. -With `--watch`, SKERNEL restarts the application whenever a project file changes. It checks once a second for changes to PHP files, `.env` and `.env.*` files, and `composer.lock`, leaving out the vendor directory, `target/`, and directories whose name starts with a dot, so the logs and caches a running application writes do not restart it. It sends the application SIGTERM and then SIGKILL after 10 seconds. An application that exits on its own is started again by the next change. +With `--watch`, SKERNEL restarts the application whenever a project file changes. It checks once a second for changes to PHP files, `.env` and `.env.*` files, and `composer.lock`, leaving out the vendor directory, `target/`, and directories whose name starts with a dot, so the logs and caches a running application writes do not restart it. It sends the application SIGTERM and then SIGKILL after 10 seconds, and builds the project again before it restarts it. An application that exits on its own, or whose build fails, is started again by the next change. SIGINT and SIGTERM stop the application before SKERNEL exits. The failures of `build`, and an application that cannot start, print a short message on STDERR and exit with status 1. @@ -112,7 +113,7 @@ SIGINT and SIGTERM stop the application before SKERNEL exits. The failures of `b clean [-d|--working-dir=] ``` -`clean` removes the project's `target/` directory and its `/.cache` scan caches, and says what it removed. A package written elsewhere with `--output` or the `package.output` setting stays. The project's dependencies do not have to be installed. +`clean` removes the project's `target/` directory and its `/.cache` build artifacts, and says what it removed. A package written elsewhere with `--output` or the `package.output` setting stays. The project's dependencies do not have to be installed. ### self-update @@ -130,15 +131,25 @@ Running from source, a latest release that cannot be read or has no verified bin build [-d|--working-dir=] [--php=] ``` -`build` runs a full source scan of the project and writes the build artifacts that restore-only and Phar execution load to the project's `/.cache` directory, honouring its `config.vendor-dir`: the scan snapshots, and whatever the project's components write when the build publishes `SuperKernel\Event\ApplicationBuild`, such as the prepared proxies of `super-kernel/aop`. The project must have its dependencies installed, including `super-kernel/context` and `super-kernel/di`, and the scan covers the packages of that installation. +`build` generates the build artifacts that the application loads at start and writes them to the project's `/.cache` directory, honouring its `config.vendor-dir`: the package records with their class maps, the attributes collected from those classes, a build stamp, and whatever the project's components write when the build publishes `SuperKernel\Event\ApplicationBuild`, such as the prepared proxies of `super-kernel/aop`. A SuperKernel application started without them, or with artifacts from an older `composer.lock`, stops with a message that names `skernel build`. The project must have its dependencies installed, including `super-kernel/metadata`, `super-kernel/context`, and `super-kernel/di`, and the build covers the packages of that installation. -The build runs in a separate process of the managed PHP CLI. That process loads the project's own autoloader, bootstraps its container, and then publishes `ApplicationBuild` through the project's PSR-14 event dispatcher, when the project has both the event and a dispatcher, without resolving or running the application. Everything the process writes, including the project's scan diagnostics, goes to STDERR, and on success STDOUT carries one line: +The build runs in two separate processes of the managed PHP CLI, so SKERNEL never loads the project's code itself. The first loads the project's own autoloader, checks that the installed `super-kernel/metadata` uses the build protocol this SKERNEL generates, maps the classes of every installed package, collects the attributes of the classes that declare any, and publishes the records through the project's own `super-kernel/metadata` and `super-kernel/annotator`. A class that cannot be loaded or whose attribute cannot be created is skipped, even when loading it ends the process with a fatal error. The second process bootstraps the container from the records and then publishes `ApplicationBuild` through the project's PSR-14 event dispatcher, when the project has both the event and a dispatcher, without resolving or running the application. + +Everything the processes write goes to STDERR, followed by the skipped classes, one per line, sorted by package and class: + +```text +SKERNEL skipped 2 classes whose attributes could not be collected: + acme/app App\Broken\Listener: Error: Class "App\Missing" not found + acme/app App\Broken\Proxy: fatal error: Declaration of App\Broken\Proxy::run() must be compatible with ... +``` + +On success STDOUT carries one line: ```text Build artifacts written to /path/to/my-app/vendor/.cache. ``` -A missing project or dependencies, an invalid configuration file or `--php` value, a managed PHP that cannot be provided, or a failed build prints a short message on STDERR and exits with status 1. +When the project's `super-kernel/metadata` uses another build protocol, the build stops before it writes anything and says which side to upgrade: SKERNEL, for example with `skernel self-update`, or the project's SuperKernel packages. A project without `super-kernel/metadata` predates SKERNEL-generated artifacts and needs SuperKernel 1.0.0-alpha.2 or later. That, a missing project or dependencies, an invalid configuration file or `--php` value, a managed PHP that cannot be provided, or a failed build prints a short message on STDERR and exits with status 1. ### package diff --git a/README.zh-CN.md b/README.zh-CN.md index 1e916d3..9669d5a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,6 +63,7 @@ SKERNEL 下载和构建的所有内容都放在它的主目录 `~/.skernel` 中 ~/.skernel/ ├── cache/ php.net and PECL release answers and Zig mirrors, refreshed daily ├── downloads/ source archives, shared by every build +├── generator// the generator a standalone SKERNEL extracts for builds ├── pkgroot// Zig and the other packages static-php-cli installs ├── runtimes//php-// │ ├── php the static PHP CLI @@ -100,9 +101,9 @@ Project created in /path/to/my-app. run [-d|--working-dir=] [--php=] [--watch] [--] [...] ``` -`run` 用受管 PHP 从源码运行项目,在项目目录中以单独的进程执行。`` 传给应用,所以 `run swoole:start` 会启动它的 Swoole 服务;要传给应用的选项放在 `--` 之后。不加 `--watch` 时,SKERNEL 以应用的退出码退出。 +`run` 先像 `build` 一样构建项目,再用受管 PHP 从源码运行项目,在项目目录中以单独的进程执行。`` 传给应用,所以 `run swoole:start` 会启动它的 Swoole 服务;要传给应用的选项放在 `--` 之后。不加 `--watch` 时,SKERNEL 以应用的退出码退出。 -加上 `--watch` 后,项目文件变化时 SKERNEL 会重启应用。它每秒检查一次 PHP 文件、`.env` 和 `.env.*` 文件以及 `composer.lock` 的变化,并跳过 vendor 目录、`target/` 和名称以点开头的目录,所以运行中的应用写入日志和缓存不会触发重启。重启时先向应用发送 SIGTERM,10 秒后发送 SIGKILL。应用自行退出后,下一次变化会再次启动它。 +加上 `--watch` 后,项目文件变化时 SKERNEL 会重启应用。它每秒检查一次 PHP 文件、`.env` 和 `.env.*` 文件以及 `composer.lock` 的变化,并跳过 vendor 目录、`target/` 和名称以点开头的目录,所以运行中的应用写入日志和缓存不会触发重启。重启时先向应用发送 SIGTERM,10 秒后发送 SIGKILL,并在重启前重新构建项目。应用自行退出或构建失败后,下一次变化会再次启动它。 SIGINT 和 SIGTERM 会先停止应用,SKERNEL 再退出。出现 `build` 的任一种失败或应用无法启动时,会在 STDERR 上打印一条简短的提示,退出码为 1。 @@ -112,7 +113,7 @@ SIGINT 和 SIGTERM 会先停止应用,SKERNEL 再退出。出现 `build` 的 clean [-d|--working-dir=] ``` -`clean` 删除项目的 `target/` 目录和 `/.cache` 扫描缓存,并说明删除了什么。用 `--output` 或 `package.output` 设置写到其他位置的包不会被删除。项目的依赖不必已安装。 +`clean` 删除项目的 `target/` 目录和 `/.cache` 构建产物,并说明删除了什么。用 `--output` 或 `package.output` 设置写到其他位置的包不会被删除。项目的依赖不必已安装。 ### self-update @@ -130,15 +131,25 @@ self-update build [-d|--working-dir=] [--php=] ``` -`build` 对项目做一次完整的源码扫描,把只恢复快照运行和 Phar 运行要加载的构建产物写到项目的 `/.cache` 目录,vendor 目录遵循项目的 `config.vendor-dir`:包括扫描快照,以及构建发布 `SuperKernel\Event\ApplicationBuild` 时项目各组件写出的产物,例如 `super-kernel/aop` 预先准备好的代理。项目必须已经安装好依赖,其中包括 `super-kernel/context` 和 `super-kernel/di`;扫描覆盖的包以这次安装为准。 +`build` 生成应用启动时加载的构建产物,写到项目的 `/.cache` 目录,vendor 目录遵循项目的 `config.vendor-dir`:包括带类映射的包记录、从这些类收集到的注解、构建戳,以及构建发布 `SuperKernel\Event\ApplicationBuild` 时项目各组件写出的产物,例如 `super-kernel/aop` 预先准备好的代理。SuperKernel 应用在缺少这些产物,或产物来自较旧的 `composer.lock` 时启动,会停止并给出提到 `skernel build` 的提示。项目必须已经安装好依赖,其中包括 `super-kernel/metadata`、`super-kernel/context` 和 `super-kernel/di`;构建覆盖的包以这次安装为准。 -构建在受管 PHP CLI 的一个单独进程中运行。该进程加载项目自己的 autoloader,引导出项目的容器;项目同时具备该事件和事件分发器时,再通过项目的 PSR-14 事件分发器发布 `ApplicationBuild`。整个过程不解析也不运行应用。该进程写出的所有内容(包括项目的扫描诊断)都转到 STDERR;成功时 STDOUT 只有一行: +构建在受管 PHP CLI 的两个单独进程中运行,所以 SKERNEL 自己从不加载项目的代码。第一个进程加载项目自己的 autoloader,确认已安装的 `super-kernel/metadata` 使用本 SKERNEL 生成的构建协议,为每个已安装的包建立类映射,收集声明了注解的类的注解,再通过项目自己的 `super-kernel/metadata` 和 `super-kernel/annotator` 发布这些记录。无法加载的类,或注解无法创建的类,会被跳过,即使加载它时进程因致命错误而结束也是如此。第二个进程从这些记录引导出容器;项目同时具备该事件和事件分发器时,再通过项目的 PSR-14 事件分发器发布 `ApplicationBuild`。整个过程不解析也不运行应用。 + +这些进程写出的所有内容都转到 STDERR,之后列出被跳过的类,每行一个,按包和类排序: + +```text +SKERNEL skipped 2 classes whose attributes could not be collected: + acme/app App\Broken\Listener: Error: Class "App\Missing" not found + acme/app App\Broken\Proxy: fatal error: Declaration of App\Broken\Proxy::run() must be compatible with ... +``` + +成功时 STDOUT 只有一行: ```text Build artifacts written to /path/to/my-app/vendor/.cache. ``` -项目或依赖不存在、配置文件或 `--php` 的值无效、无法提供受管 PHP,或构建失败时,会在 STDERR 上打印一条简短的提示,退出码为 1。 +项目的 `super-kernel/metadata` 使用其他构建协议时,构建会在写入任何内容之前停止,并说明该升级哪一方:SKERNEL(例如用 `skernel self-update`),或项目的 SuperKernel 包。没有 `super-kernel/metadata` 的项目早于由 SKERNEL 生成产物的做法,需要 SuperKernel 1.0.0-alpha.2 或更高版本。出现这种情况,或项目或依赖不存在、配置文件或 `--php` 的值无效、无法提供受管 PHP、构建失败时,会在 STDERR 上打印一条简短的提示,退出码为 1。 ### package diff --git a/bin/bootstrap.php b/bin/bootstrap.php index c28a82e..b4056ec 100755 --- a/bin/bootstrap.php +++ b/bin/bootstrap.php @@ -3,10 +3,17 @@ declare(strict_types=1); +use SKERNEL\Generator\Entry\SelfBuild; use SuperKernel\Context\ApplicationContext; use SuperKernel\Contract\ApplicationInterface; require dirname(__DIR__) . '/vendor/autoload.php'; -// Isolate startup in an immediately invoked static closure; never requires the flow to exit or throw. -(static fn(): never => ApplicationContext::getContainer()->get(ApplicationInterface::class)->run())(); +// From source, SKERNEL generates the build artifacts its container restores, and another start from the same tree +// waits until they are restored; a PHAR carries them from its build. +$lock = '' === Phar::running(false) ? SelfBuild::run(dirname(__DIR__)) : null; +$container = ApplicationContext::getContainer(); +$lock?->release(); + +// Isolate the application run in an immediately invoked static closure; never requires the flow to exit or throw. +(static fn(): never => $container->get(ApplicationInterface::class)->run())(); diff --git a/composer.json b/composer.json index 87ee6aa..70a7cdd 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "ext-zlib": "*", "composer/semver": "^3.6.0", "psr/log": "^3.0.2", + "super-kernel/annotator": "dev-main", "super-kernel/attribute": "dev-main", "super-kernel/command": "dev-main", "super-kernel/context": "dev-main", @@ -21,6 +22,7 @@ "super-kernel/exception-dispatcher": "dev-main", "super-kernel/framework": "dev-main", "super-kernel/logger": "dev-main", + "super-kernel/metadata": "dev-main", "symfony/console": "^8.1.9", "symfony/process": "^8.1.10" }, @@ -30,6 +32,7 @@ }, "autoload": { "psr-4": { + "SKERNEL\\Generator\\": "generator/src/", "Src\\": "src/" } }, diff --git a/composer.lock b/composer.lock index 689fe34..5f45306 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": "860bb3b65b744971a20e49dcff2b6ad4", + "content-hash": "ac5db679a5f1d5a3a34b5114386a7316", "packages": [ { "name": "composer/semver", @@ -347,12 +347,12 @@ "source": { "type": "git", "url": "https://github.com/super-kernel/annotator.git", - "reference": "251eb837f3d5dec8102219671e7e9f253debf7e5" + "reference": "3e4cb8a77f608be15abb0d3cbc393a5eb71dd7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/super-kernel/annotator/zipball/251eb837f3d5dec8102219671e7e9f253debf7e5", - "reference": "251eb837f3d5dec8102219671e7e9f253debf7e5", + "url": "https://api.github.com/repos/super-kernel/annotator/zipball/3e4cb8a77f608be15abb0d3cbc393a5eb71dd7fd", + "reference": "3e4cb8a77f608be15abb0d3cbc393a5eb71dd7fd", "shasum": "" }, "require": { @@ -383,7 +383,7 @@ "issues": "https://github.com/super-kernel/annotator/issues", "source": "https://github.com/super-kernel/annotator/tree/main" }, - "time": "2026-11-10T05:05:16+00:00" + "time": "2026-11-10T08:50:41+00:00" }, { "name": "super-kernel/attribute", @@ -472,49 +472,6 @@ }, "time": "2026-11-10T05:05:58+00:00" }, - { - "name": "super-kernel/composer", - "version": "dev-main", - "source": { - "type": "git", - "url": "https://github.com/super-kernel/composer.git", - "reference": "2fe0935351ec07aa937e34774e402adc332a65e7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/super-kernel/composer/zipball/2fe0935351ec07aa937e34774e402adc332a65e7", - "reference": "2fe0935351ec07aa937e34774e402adc332a65e7", - "shasum": "" - }, - "require": { - "php": ">=8.4.27 <8.5", - "super-kernel/attribute": "self.version", - "super-kernel/contract": "self.version", - "super-kernel/scan-isolation": "self.version", - "super-kernel/tokenizer": "self.version" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.95.27", - "phpunit/phpunit": "^13.4.5" - }, - "default-branch": true, - "type": "library", - "autoload": { - "psr-4": { - "SuperKernel\\Composer\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Composer package metadata and cached class graphs for SuperKernel.", - "support": { - "issues": "https://github.com/super-kernel/composer/issues", - "source": "https://github.com/super-kernel/composer/tree/main" - }, - "time": "2026-11-10T05:06:12+00:00" - }, { "name": "super-kernel/context", "version": "dev-main", @@ -610,33 +567,29 @@ "source": { "type": "git", "url": "https://github.com/super-kernel/di.git", - "reference": "e3d6fe168899e05db418240a20d450b6d8e4032b" + "reference": "ed1a880310977dc5b6de748d304cba6e7ed734b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/super-kernel/di/zipball/e3d6fe168899e05db418240a20d450b6d8e4032b", - "reference": "e3d6fe168899e05db418240a20d450b6d8e4032b", + "url": "https://api.github.com/repos/super-kernel/di/zipball/ed1a880310977dc5b6de748d304cba6e7ed734b8", + "reference": "ed1a880310977dc5b6de748d304cba6e7ed734b8", "shasum": "" }, "require": { "php": ">=8.4.27 <8.5", "psr/container": "^2.0.2", - "psr/log": "^3.0.2", "super-kernel/annotator": "self.version", "super-kernel/attribute": "self.version", - "super-kernel/composer": "self.version", "super-kernel/contract": "self.version", - "super-kernel/reflector": "self.version", - "super-kernel/scan-isolation": "self.version", - "super-kernel/tokenizer": "self.version" + "super-kernel/metadata": "self.version", + "super-kernel/reflector": "self.version" }, "provide": { "psr/container-implementation": "2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.95.27", - "phpunit/phpunit": "^13.4.5", - "super-kernel/logger": "self.version" + "phpunit/phpunit": "^13.4.5" }, "default-branch": true, "type": "library", @@ -654,7 +607,7 @@ "issues": "https://github.com/super-kernel/di/issues", "source": "https://github.com/super-kernel/di/tree/main" }, - "time": "2026-11-10T05:06:38+00:00" + "time": "2026-11-10T08:51:03+00:00" }, { "name": "super-kernel/event", @@ -875,21 +828,20 @@ "time": "2026-11-10T05:08:35+00:00" }, { - "name": "super-kernel/reflector", + "name": "super-kernel/metadata", "version": "dev-main", "source": { "type": "git", - "url": "https://github.com/super-kernel/reflector.git", - "reference": "18e9553f71bf81784d9c99186bb90136b8c9a5b2" + "url": "https://github.com/super-kernel/metadata.git", + "reference": "9d8dda4b7a9c230493adc837cef8ed91fd9acdc9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/super-kernel/reflector/zipball/18e9553f71bf81784d9c99186bb90136b8c9a5b2", - "reference": "18e9553f71bf81784d9c99186bb90136b8c9a5b2", + "url": "https://api.github.com/repos/super-kernel/metadata/zipball/9d8dda4b7a9c230493adc837cef8ed91fd9acdc9", + "reference": "9d8dda4b7a9c230493adc837cef8ed91fd9acdc9", "shasum": "" }, "require": { - "ext-reflection": "*", "php": ">=8.4.27 <8.5", "super-kernel/attribute": "self.version", "super-kernel/contract": "self.version" @@ -902,79 +854,77 @@ "type": "library", "autoload": { "psr-4": { - "SuperKernel\\Reflector\\": "src/" + "SuperKernel\\Metadata\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Process-local cached native reflection service for SuperKernel.", + "description": "Read-only restoration of build-generated package metadata for SuperKernel.", "support": { - "issues": "https://github.com/super-kernel/reflector/issues", - "source": "https://github.com/super-kernel/reflector/tree/main" + "issues": "https://github.com/super-kernel/metadata/issues", + "source": "https://github.com/super-kernel/metadata/tree/main" }, - "time": "2026-11-10T05:09:31+00:00" + "time": "2026-11-10T08:41:07+00:00" }, { - "name": "super-kernel/scan-isolation", + "name": "super-kernel/reflector", "version": "dev-main", "source": { "type": "git", - "url": "https://github.com/super-kernel/scan-isolation.git", - "reference": "e6b4fe003019aaa5436c8582f5a5742973ab5205" + "url": "https://github.com/super-kernel/reflector.git", + "reference": "18e9553f71bf81784d9c99186bb90136b8c9a5b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/super-kernel/scan-isolation/zipball/e6b4fe003019aaa5436c8582f5a5742973ab5205", - "reference": "e6b4fe003019aaa5436c8582f5a5742973ab5205", + "url": "https://api.github.com/repos/super-kernel/reflector/zipball/18e9553f71bf81784d9c99186bb90136b8c9a5b2", + "reference": "18e9553f71bf81784d9c99186bb90136b8c9a5b2", "shasum": "" }, "require": { + "ext-reflection": "*", "php": ">=8.4.27 <8.5", - "super-kernel/attribute": "self.version" + "super-kernel/attribute": "self.version", + "super-kernel/contract": "self.version" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.95.27", "phpunit/phpunit": "^13.4.5" }, - "suggest": { - "ext-pcntl": "Enables preferred direct-fork anonymous Closure execution on supported platforms." - }, "default-branch": true, "type": "library", "autoload": { "psr-4": { - "SuperKernel\\ScanIsolation\\": "src/" + "SuperKernel\\Reflector\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Anonymous scan callback isolation for SuperKernel.", + "description": "Process-local cached native reflection service for SuperKernel.", "support": { - "issues": "https://github.com/super-kernel/scan-isolation/issues", - "source": "https://github.com/super-kernel/scan-isolation/tree/main" + "issues": "https://github.com/super-kernel/reflector/issues", + "source": "https://github.com/super-kernel/reflector/tree/main" }, - "time": "2026-11-10T05:09:44+00:00" + "time": "2026-11-10T05:09:31+00:00" }, { - "name": "super-kernel/tokenizer", + "name": "super-kernel/scan-isolation", "version": "dev-main", "source": { "type": "git", - "url": "https://github.com/super-kernel/tokenizer.git", - "reference": "b8f80aa5f72a71abaf697baf036d79c35a580c9a" + "url": "https://github.com/super-kernel/scan-isolation.git", + "reference": "e6b4fe003019aaa5436c8582f5a5742973ab5205" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/super-kernel/tokenizer/zipball/b8f80aa5f72a71abaf697baf036d79c35a580c9a", - "reference": "b8f80aa5f72a71abaf697baf036d79c35a580c9a", + "url": "https://api.github.com/repos/super-kernel/scan-isolation/zipball/e6b4fe003019aaa5436c8582f5a5742973ab5205", + "reference": "e6b4fe003019aaa5436c8582f5a5742973ab5205", "shasum": "" }, "require": { - "ext-tokenizer": "*", "php": ">=8.4.27 <8.5", "super-kernel/attribute": "self.version" }, @@ -982,23 +932,26 @@ "friendsofphp/php-cs-fixer": "^3.95.27", "phpunit/phpunit": "^13.4.5" }, + "suggest": { + "ext-pcntl": "Enables preferred direct-fork anonymous Closure execution on supported platforms." + }, "default-branch": true, "type": "library", "autoload": { "psr-4": { - "SuperKernel\\Tokenizer\\": "src/" + "SuperKernel\\ScanIsolation\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "PHP class-like declaration tokenizer for SuperKernel.", + "description": "Anonymous scan callback isolation for SuperKernel.", "support": { - "issues": "https://github.com/super-kernel/tokenizer/issues", - "source": "https://github.com/super-kernel/tokenizer/tree/main" + "issues": "https://github.com/super-kernel/scan-isolation/issues", + "source": "https://github.com/super-kernel/scan-isolation/tree/main" }, - "time": "2026-11-10T05:10:42+00:00" + "time": "2026-11-10T05:09:44+00:00" }, { "name": "symfony/console", @@ -5401,6 +5354,7 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { + "super-kernel/annotator": 20, "super-kernel/attribute": 20, "super-kernel/command": 20, "super-kernel/context": 20, @@ -5410,7 +5364,8 @@ "super-kernel/event-dispatcher": 20, "super-kernel/exception-dispatcher": 20, "super-kernel/framework": 20, - "super-kernel/logger": 20 + "super-kernel/logger": 20, + "super-kernel/metadata": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/generator/generate.php b/generator/generate.php new file mode 100644 index 0000000..4955b44 --- /dev/null +++ b/generator/generate.php @@ -0,0 +1,23 @@ +getPathname(), strlen(__DIR__) + 5, -4); + class_exists('SKERNEL\\Generator\\' . str_replace('/', '\\', $relative)); +} + +exit(ChildProcess::run($argv)); diff --git a/generator/src/Attribute/AttributeConverter.php b/generator/src/Attribute/AttributeConverter.php new file mode 100644 index 0000000..c17eb11 --- /dev/null +++ b/generator/src/Attribute/AttributeConverter.php @@ -0,0 +1,54 @@ +> $attributes + * + * @return list In declaration order + * + * @throws ReflectionException When an attribute class cannot be reflected + */ + public static function convert(array $attributes): array + { + $collected = []; + + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + $reflection = new ReflectionClass($attribute->getName()); + $types = [ + $reflection->getName(), + ]; + $parent = $reflection->getParentClass(); + + while (false !== $parent) { + $types[] = $parent->getName(); + $parent = $parent->getParentClass(); + } + + foreach ($reflection->getInterfaceNames() as $interface) { + $types[] = $interface; + } + + $collected[] = new CollectedAttribute($instance, array_values(array_unique($types))); + } + + return $collected; + } +} diff --git a/generator/src/Attribute/AttributeScanner.php b/generator/src/Attribute/AttributeScanner.php new file mode 100644 index 0000000..b86cf8f --- /dev/null +++ b/generator/src/Attribute/AttributeScanner.php @@ -0,0 +1,119 @@ + $records + * @param array $fatalClasses Classes that ended an earlier run, with the fatal + * error message + * @param Closure(CollectedAttribute): object $convert Builds the published model of one attribute, such as + * the annotator's, and throws when it cannot + * + * @return list One per record, in record order + */ + public static function scan(array $records, array $fatalClasses, ScanProgress $progress, Closure $convert): array + { + $sequence = 0; + $packages = []; + + foreach ($records as $record) { + $package = $record->declaration->name; + $collected = [ + 'classes' => [], + 'methods' => [], + 'properties' => [], + ]; + $diagnostics = []; + + foreach ($record->classMap->classes as $class => $file) { + if (isset($fatalClasses[$class])) { + $diagnostics[] = new Diagnostic( + ++$sequence, + $package, + $class, + 'fatal error', + $fatalClasses[$class], + ); + + continue; + } + + if (!isset($record->classMap->attributeFiles[$file])) { + continue; + } + + $progress->package = $package; + $progress->class = $class; + + try { + $attributes = ClassAttributeCollector::collect(new ReflectionClass($class)); + $converted = [ + 'classes' => array_map($convert, $attributes->class), + 'methods' => [], + 'properties' => [], + ]; + + foreach ([ + 'methods' => $attributes->methods, + 'properties' => $attributes->properties, + ] as $kind => $members) { + foreach ($members as $member => $list) { + $converted[$kind][$member] = array_map($convert, $list); + } + } + } catch (Throwable $exception) { + $diagnostics[] = new Diagnostic( + ++$sequence, + $package, + $class, + $exception::class, + $exception->getMessage(), + ); + + continue; + } finally { + $progress->package = null; + $progress->class = null; + } + + foreach ($converted as $kind => $value) { + if ([] !== $value) { + $collected[$kind][$class] = $value; + } + } + } + + $packages[] = new PackageAttributes( + $package, + $record->declaration->reference, + $collected['classes'], + $collected['methods'], + $collected['properties'], + $diagnostics, + ); + } + + return $packages; + } +} diff --git a/generator/src/Attribute/ClassAttributeCollector.php b/generator/src/Attribute/ClassAttributeCollector.php new file mode 100644 index 0000000..a9a5568 --- /dev/null +++ b/generator/src/Attribute/ClassAttributeCollector.php @@ -0,0 +1,68 @@ + $reflection + * + * @throws ReflectionException When an attribute class cannot be reflected + */ + public static function collect(ReflectionClass $reflection): ClassAttributes + { + $class = $reflection->getName(); + $classAttributes = AttributeConverter::convert($reflection->getAttributes()); + $members = [ + 'methods' => [], + 'properties' => [], + ]; + + foreach ([ + 'methods' => $reflection->getMethods(), + 'properties' => $reflection->getProperties(), + ] as $kind => $declarations) { + usort( + $declarations, + static fn(ReflectionMethod|ReflectionProperty $left, ReflectionMethod|ReflectionProperty $right): int + => strcmp($left->getName(), $right->getName()), + ); + + foreach ($declarations as $member) { + if ($class !== $member->getDeclaringClass()->getName()) { + continue; + } + + $attributes = AttributeConverter::convert($member->getAttributes()); + + if ([] !== $attributes) { + $members[$kind][$member->getName()] = $attributes; + } + } + } + + return new ClassAttributes( + $classAttributes, + $members['methods'], + $members['properties'], + ); + } +} diff --git a/generator/src/Attribute/ClassAttributes.php b/generator/src/Attribute/ClassAttributes.php new file mode 100644 index 0000000..1f60dd7 --- /dev/null +++ b/generator/src/Attribute/ClassAttributes.php @@ -0,0 +1,22 @@ + $class + * @param array> $methods Non-empty lists keyed by method name + * @param array> $properties Non-empty lists keyed by property name + */ + public function __construct(public array $class, public array $methods, public array $properties) + { + } +} diff --git a/generator/src/Attribute/CollectedAttribute.php b/generator/src/Attribute/CollectedAttribute.php new file mode 100644 index 0000000..d58d0e7 --- /dev/null +++ b/generator/src/Attribute/CollectedAttribute.php @@ -0,0 +1,20 @@ + $types The attribute's class first, then its parent classes, then its interfaces + */ + public function __construct(public object $instance, public array $types) + { + } +} diff --git a/generator/src/Attribute/PackageAttributes.php b/generator/src/Attribute/PackageAttributes.php new file mode 100644 index 0000000..e6e49d7 --- /dev/null +++ b/generator/src/Attribute/PackageAttributes.php @@ -0,0 +1,32 @@ +> $classes + * @param array>> $methods + * @param array>> $properties + * @param list $diagnostics + */ + public function __construct( + public string $package, + public ?string $reference, + public array $classes, + public array $methods, + public array $properties, + public array $diagnostics, + ) + { + } +} diff --git a/generator/src/Attribute/ScanProgress.php b/generator/src/Attribute/ScanProgress.php new file mode 100644 index 0000000..ab16edb --- /dev/null +++ b/generator/src/Attribute/ScanProgress.php @@ -0,0 +1,18 @@ + ScanRuleSource::Psr0, + 'psr-4' => ScanRuleSource::Psr4, + ] as $field => $source) { + if (!property_exists($autoload, $field)) { + continue; + } + + $mappings = [] === $autoload->{$field} ? new stdClass() : $autoload->{$field}; + + if (!$mappings instanceof stdClass) { + throw new GeneratorException(sprintf('autoload.%s must be an object.', $field)); + } + + foreach (get_object_vars($mappings) as $prefix => $paths) { + $prefix = (string) $prefix; + + if (str_contains($prefix, "\0")) { + throw new GeneratorException(sprintf('autoload.%s contains a null byte in a prefix.', $field)); + } + + if (ScanRuleSource::Psr4 === $source && '' !== $prefix && !str_ends_with($prefix, '\\')) { + throw new GeneratorException(sprintf( + 'PSR-4 namespace prefix "%s" must end in a backslash.', + $prefix, + )); + } + + $paths = is_string($paths) + ? [ + $paths, + ] + : $paths; + + if (!is_array($paths) || !array_is_list($paths) || [] === $paths) { + throw new GeneratorException(sprintf( + 'autoload.%s.%s must be a string or a non-empty string list.', + $field, + $prefix, + )); + } + + foreach ($paths as $path) { + if (!is_string($path)) { + throw new GeneratorException(sprintf( + 'autoload.%s.%s must contain only strings.', + $field, + $prefix, + )); + } + + $relativePath = AutoloadPath::normalize($path); + $resolved = AutoloadPath::resolveExisting($packageRoot, $relativePath); + + if (null !== $resolved && is_dir($resolved)) { + $rules[] = new ScanRule($relativePath, $source, ScanRuleKind::Directory, $prefix); + } + } + } + } + + foreach (AutoloadStringList::read($autoload, 'classmap') as $path) { + $relativePath = AutoloadPath::normalize($path, true); + + if (!str_contains($relativePath, '*')) { + $resolved = AutoloadPath::resolveExisting($packageRoot, $relativePath); + $kind = match (true) { + null === $resolved => null, + is_dir($resolved) => ScanRuleKind::Directory, + is_file($resolved) => ScanRuleKind::File, + default => null, + }; + + if (null !== $kind) { + $rules[] = new ScanRule($relativePath, ScanRuleSource::Classmap, $kind, null); + } + + continue; + } + + $matched = false; + $matches = glob($packageRoot . '/' . $relativePath, GLOB_NOSORT); + + foreach (false === $matches ? [] : $matches as $match) { + $resolvedMatch = realpath($match); + + if (false === $resolvedMatch || (!is_dir($resolvedMatch) && !is_file($resolvedMatch))) { + continue; + } + + if (!FilesystemPath::isWithin(str_replace('\\', '/', $resolvedMatch), $packageRoot)) { + throw new GeneratorException(sprintf( + 'Classmap glob "%s" resolves outside the package root.', + $relativePath, + )); + } + + $matched = true; + } + + if ($matched) { + $rules[] = new ScanRule($relativePath, ScanRuleSource::Classmap, ScanRuleKind::Glob, null); + } + } + + foreach (AutoloadStringList::read($autoload, 'files') as $file) { + AutoloadPath::normalize($file); + } + + foreach (AutoloadStringList::read($autoload, 'exclude-from-classmap') as $pattern) { + $exclusions[] = ExclusionPattern::normalize($pattern); + } + + return new ScanRuleSet($rules, $exclusions); + } +} diff --git a/generator/src/Autoload/AutoloadStringList.php b/generator/src/Autoload/AutoloadStringList.php new file mode 100644 index 0000000..4657bdd --- /dev/null +++ b/generator/src/Autoload/AutoloadStringList.php @@ -0,0 +1,48 @@ + The declared strings, or an empty list when the field is absent + * + * @throws GeneratorException When the field is present but not a list of strings + */ + public static function read(stdClass $autoload, string $field): array + { + if (!property_exists($autoload, $field)) { + return []; + } + + $values = $autoload->{$field}; + + if (!is_array($values) || !array_is_list($values)) { + throw new GeneratorException(sprintf('autoload.%s must be a string list.', $field)); + } + + foreach ($values as $value) { + if (!is_string($value)) { + throw new GeneratorException(sprintf('autoload.%s must contain only strings.', $field)); + } + } + + return $values; + } +} diff --git a/generator/src/Autoload/ExclusionPattern.php b/generator/src/Autoload/ExclusionPattern.php new file mode 100644 index 0000000..33b38c8 --- /dev/null +++ b/generator/src/Autoload/ExclusionPattern.php @@ -0,0 +1,71 @@ + */ + public array $exclusions; + + /** + * @param list $inclusions + * @param list $exclusions Normalized patterns; a leading `/` anchors one at the package root + */ + public function __construct(public array $inclusions = [], array $exclusions = []) + { + $exclusions = array_values(array_unique($exclusions)); + sort($exclusions, SORT_STRING); + $this->exclusions = $exclusions; + } + + public function merge(self $other): self + { + return new self( + array_merge($this->inclusions, $other->inclusions), + array_merge($this->exclusions, $other->exclusions), + ); + } + + public function isExcluded(string $packageRelativePath): bool + { + if (str_contains($packageRelativePath, "\0")) { + return false; + } + + $segments = explode('/', ltrim(str_replace('\\', '/', $packageRelativePath), '/')); + + if (in_array('..', $segments, true)) { + return false; + } + + $path = implode('/', array_values(array_filter( + $segments, + static fn(string $segment): bool => '' !== $segment && '.' !== $segment, + ))); + + return array_any( + $this->exclusions, + static function (string $pattern) use ($path): bool { + $regex = strtr(preg_quote(trim($pattern, '/'), '~'), [ + '\\*\\*' => '.+?', + '\\*' => '[^/]+?', + ]); + + return 1 === preg_match('~\\A' . $regex . '(?:\\z|/)~', $path); + }, + ); + } +} diff --git a/generator/src/ClassMap/CandidateDirectoryCollector.php b/generator/src/ClassMap/CandidateDirectoryCollector.php new file mode 100644 index 0000000..234f0c6 --- /dev/null +++ b/generator/src/ClassMap/CandidateDirectoryCollector.php @@ -0,0 +1,73 @@ +> $files Candidate files and their matching rules, updated in place + * + * @throws GeneratorException When the directory resolves outside the package root or cannot be scanned + */ + public static function collect( + string $directory, + string $packageRoot, + ScanRuleSet $scanRules, + ScanRule $rule, + array &$files, + ): void + { + $canonicalDirectory = FilesystemPath::canonicalDirectory($directory); + + if (null === $canonicalDirectory) { + return; + } + + if (!FilesystemPath::isWithin($canonicalDirectory, $packageRoot)) { + throw new GeneratorException(sprintf( + 'Class scan directory %s is outside package root %s.', + $canonicalDirectory, + $packageRoot, + )); + } + + try { + $entries = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($canonicalDirectory, FilesystemIterator::SKIP_DOTS), + ); + + /** @var SplFileInfo $entry */ + foreach ($entries as $entry) { + if ($entry->isFile()) { + CandidateFileCollector::collect($entry->getPathname(), $packageRoot, $scanRules, $rule, $files); + } + } + } catch (GeneratorException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new GeneratorException( + sprintf('Unable to scan class directory %s.', $canonicalDirectory), + previous: $exception, + ); + } + } +} diff --git a/generator/src/ClassMap/CandidateFileCollector.php b/generator/src/ClassMap/CandidateFileCollector.php new file mode 100644 index 0000000..069448c --- /dev/null +++ b/generator/src/ClassMap/CandidateFileCollector.php @@ -0,0 +1,63 @@ +> $files Candidate files and their matching rules, updated in place + * + * @throws GeneratorException When the file resolves outside the package root or to the root itself + */ + public static function collect( + string $file, + string $packageRoot, + ScanRuleSet $scanRules, + ScanRule $rule, + array &$files, + ): void + { + $canonicalFile = realpath($file); + + if (!is_string($canonicalFile) || !is_file($canonicalFile)) { + return; + } + + $canonicalFile = str_replace('\\', '/', $canonicalFile); + + if (!FilesystemPath::isWithin($canonicalFile, $packageRoot) || $canonicalFile === $packageRoot) { + throw new GeneratorException(sprintf( + 'Class scan file %s is outside package root %s.', + $canonicalFile, + $packageRoot, + )); + } + + if ($scanRules->isExcluded(ltrim(substr($canonicalFile, strlen($packageRoot)), '/'))) { + return; + } + + $files[$canonicalFile][] = $rule; + } +} diff --git a/generator/src/ClassMap/ClassMap.php b/generator/src/ClassMap/ClassMap.php new file mode 100644 index 0000000..0082ad3 --- /dev/null +++ b/generator/src/ClassMap/ClassMap.php @@ -0,0 +1,21 @@ + $classes Canonical absolute files, sorted by class + * @param array $attributeFiles Files that contain at least one attribute + */ + public function __construct(public array $classes = [], public array $attributeFiles = []) + { + } +} diff --git a/generator/src/ClassMap/ClassMapScanner.php b/generator/src/ClassMap/ClassMapScanner.php new file mode 100644 index 0000000..d2de587 --- /dev/null +++ b/generator/src/ClassMap/ClassMapScanner.php @@ -0,0 +1,140 @@ +inclusions as $rule) { + $candidate = $packageRoot . '/' . $rule->relativePath; + + switch ($rule->kind) { + case ScanRuleKind::File: + CandidateFileCollector::collect($candidate, $packageRoot, $scanRules, $rule, $files); + + break; + + case ScanRuleKind::Directory: + CandidateDirectoryCollector::collect($candidate, $packageRoot, $scanRules, $rule, $files); + + break; + + case ScanRuleKind::Glob: + $matches = glob($candidate, GLOB_NOSORT); + + if (false === $matches) { + throw new GeneratorException(sprintf('Unable to expand classmap pattern %s.', $candidate)); + } + + sort($matches, SORT_STRING); + + foreach ($matches as $match) { + if (is_dir($match)) { + CandidateDirectoryCollector::collect($match, $packageRoot, $scanRules, $rule, $files); + } elseif (is_file($match)) { + CandidateFileCollector::collect($match, $packageRoot, $scanRules, $rule, $files); + } + } + + break; + } + } + + $paths = array_keys($files); + sort($paths, SORT_STRING); + $selected = []; + $attributeFiles = []; + + foreach ($paths as $file) { + $source = @file_get_contents($file); + $parsed = false === $source ? null : SourceParser::parse($source); + + if (null === $parsed) { + continue; + } + + foreach ($parsed->classNames as $className) { + $priority = null; + + foreach ($files[$file] as $rule) { + if (ScanRuleSource::Classmap === $rule->source) { + $priority = 30; + + continue; + } + + $prefix = $rule->namespacePrefix ?? ''; + + if ('' !== $prefix && !str_starts_with($className, $prefix)) { + continue; + } + + $candidatePriority = ScanRuleSource::Psr4 === $rule->source ? 20 : 10; + + if (null === $priority || $candidatePriority > $priority) { + $priority = $candidatePriority; + } + } + + if (null === $priority || (isset($selected[$className]) && $priority <= $selected[$className][1])) { + continue; + } + + $selected[$className] = [ + $file, + $priority, + ]; + + if ($parsed->hasAttributes) { + $attributeFiles[$file] = true; + } + } + } + + $classes = []; + + foreach ($selected as $className => [$file]) { + $classes[$className] = $file; + } + + ksort($classes, SORT_STRING); + + return new ClassMap($classes, $attributeFiles); + } +} diff --git a/generator/src/ClassMap/ParsedSource.php b/generator/src/ClassMap/ParsedSource.php new file mode 100644 index 0000000..7623422 --- /dev/null +++ b/generator/src/ClassMap/ParsedSource.php @@ -0,0 +1,20 @@ + $classNames Sorted, unique, and non-empty + */ + public function __construct(public array $classNames, public bool $hasAttributes) + { + } +} diff --git a/generator/src/ClassMap/SourceParser.php b/generator/src/ClassMap/SourceParser.php new file mode 100644 index 0000000..7879359 --- /dev/null +++ b/generator/src/ClassMap/SourceParser.php @@ -0,0 +1,143 @@ + $tokens */ + $tokens = token_get_all($source, TOKEN_PARSE); + } catch (CompileError) { + return null; + } + + $namespace = ''; + $classNames = []; + $hasAttributes = false; + $tokenCount = count($tokens); + + for ($index = 0; $index < $tokenCount; ++$index) { + $token = $tokens[$index]; + + if (!is_array($token)) { + continue; + } + + if (T_ATTRIBUTE === $token[0]) { + $hasAttributes = true; + + continue; + } + + if (T_NAMESPACE === $token[0]) { + // Collect the declared name up to the statement terminator or the bracketed body. + $namespace = ''; + + for (++$index; $index < $tokenCount; ++$index) { + $namespaceToken = $tokens[$index]; + + if (';' === $namespaceToken || '{' === $namespaceToken) { + break; + } + + if ( + is_array($namespaceToken) + && in_array($namespaceToken[0], [ + T_STRING, + T_NAME_QUALIFIED, + T_NS_SEPARATOR, + ], true) + ) { + $namespace .= $namespaceToken[1]; + } + } + + $namespace = trim($namespace, '\\'); + + continue; + } + + if (!in_array($token[0], [ + T_CLASS, + T_INTERFACE, + T_TRAIT, + T_ENUM, + ], true)) { + continue; + } + + // The first meaningful token after the keyword names the declaration; anonymous classes have none. + $name = null; + + for ($nameIndex = $index + 1; $nameIndex < $tokenCount; ++$nameIndex) { + $nameToken = $tokens[$nameIndex]; + + if ( + is_array($nameToken) + && in_array($nameToken[0], [ + T_WHITESPACE, + T_COMMENT, + T_DOC_COMMENT, + ], true) + ) { + continue; + } + + $name = is_array($nameToken) && T_STRING === $nameToken[0] ? $nameToken[1] : null; + + break; + } + + if (null !== $name) { + $classNames['' === $namespace ? $name : $namespace . '\\' . $name] = true; + } + } + + if ([] === $classNames) { + return null; + } + + $classNames = array_keys($classNames); + sort($classNames, SORT_STRING); + + return new ParsedSource($classNames, $hasAttributes); + } +} diff --git a/generator/src/Composer/DeclarationReader.php b/generator/src/Composer/DeclarationReader.php new file mode 100644 index 0000000..555173d --- /dev/null +++ b/generator/src/Composer/DeclarationReader.php @@ -0,0 +1,87 @@ + $packages Installed package names + */ + public function __construct(public bool $development, public array $packages) + { + } + + /** + * @throws GeneratorException When the installation state is missing, unreadable, or invalid + */ + public static function read(string $vendorDirectory): self + { + $path = $vendorDirectory . '/composer/installed.php'; + + if (!is_file($path)) { + throw new GeneratorException(sprintf( + 'Composer installation state is unavailable: %s. Install the project\'s dependencies first.', + $path, + )); + } + + try { + $state = (static fn(): mixed => require $path)(); + } catch (Throwable $exception) { + throw new GeneratorException(sprintf('Unable to read %s.', $path), previous: $exception); + } + + if ( + !is_array($state) + || !is_array($state['root'] ?? null) + || !is_bool($state['root']['dev'] ?? null) + || !is_array($state['versions'] ?? null) + ) { + throw new GeneratorException(sprintf('Invalid Composer installation state: %s.', $path)); + } + + $packages = []; + + foreach (array_keys($state['versions']) as $name) { + $packages[(string) $name] = true; + } + + return new self($state['root']['dev'], $packages); + } + + /** + * Reports whether a lock entry is installed: production entries always are, development ones only with dev + * dependencies or when Composer lists them. + */ + public function contains(string $name, bool $development): bool + { + return !$development || $this->development || isset($this->packages[$name]); + } +} diff --git a/generator/src/Composer/JsonField.php b/generator/src/Composer/JsonField.php new file mode 100644 index 0000000..35839f0 --- /dev/null +++ b/generator/src/Composer/JsonField.php @@ -0,0 +1,73 @@ +{$field}; + + if (!is_string($value)) { + throw new GeneratorException(sprintf( + '%s field "%s" must be a string; %s given.', + $context, + $field, + get_debug_type($value), + )); + } + + return $value; + } + + /** + * Accepts an empty JSON array for an empty object, as Composer writes some empty maps. + * + * @throws GeneratorException When the field is present but neither an object nor an empty array + */ + public static function object(stdClass $document, string $field, string $context): ?stdClass + { + if (!property_exists($document, $field)) { + return null; + } + + $value = $document->{$field}; + + if ([] === $value) { + return new stdClass(); + } + + if (!$value instanceof stdClass) { + throw new GeneratorException(sprintf( + '%s field "%s" must be an object; %s given.', + $context, + $field, + get_debug_type($value), + )); + } + + return $value; + } +} diff --git a/generator/src/Composer/JsonFile.php b/generator/src/Composer/JsonFile.php new file mode 100644 index 0000000..616c156 --- /dev/null +++ b/generator/src/Composer/JsonFile.php @@ -0,0 +1,51 @@ +getMessage()), + previous: $exception, + ); + } + + if (!$document instanceof stdClass) { + throw new GeneratorException(sprintf('%s must contain a JSON object.', $path)); + } + + return $document; + } +} diff --git a/generator/src/Composer/PackageName.php b/generator/src/Composer/PackageName.php new file mode 100644 index 0000000..4a110ed --- /dev/null +++ b/generator/src/Composer/PackageName.php @@ -0,0 +1,32 @@ + + * + * @throws GeneratorException When a Composer file is missing or invalid, or an installed package has no directory + */ + public static function read(string $projectDirectory, string $vendorDirectory): array + { + $installed = InstalledState::read($vendorDirectory); + $root = DeclarationReader::read( + JsonFile::decode($projectDirectory . '/composer.json'), + true, + false, + 'composer.json', + ); + $lockPath = $projectDirectory . '/composer.lock'; + $lock = JsonFile::decode($lockPath); + $declarations = []; + + foreach ([ + 'packages' => false, + 'packages-dev' => true, + ] as $field => $development) { + $entries = $lock->{$field} ?? null; + + if (!is_array($entries)) { + throw new GeneratorException(sprintf('%s must declare the list "%s".', $lockPath, $field)); + } + + foreach ($entries as $index => $entry) { + $context = sprintf('composer.lock %s[%d]', $field, $index); + + if (!$entry instanceof stdClass) { + throw new GeneratorException(sprintf('%s must be an object.', $context)); + } + + $declaration = DeclarationReader::read($entry, false, $development, $context); + + if ($declaration->name === $root->name || isset($declarations[$declaration->name])) { + throw new GeneratorException(sprintf( + 'composer.lock declares the package "%s" more than once.', + $declaration->name, + )); + } + + $declarations[$declaration->name] = $declaration; + } + } + + ksort($declarations, SORT_STRING); + $records = [ + new PackageRecord( + $root, + $projectDirectory, + ClassMapScanner::scan( + $projectDirectory, + $installed->development + ? AutoloadRuleParser::parse($root->autoload, $projectDirectory)->merge( + AutoloadRuleParser::parse($root->autoloadDev, $projectDirectory), + ) + : AutoloadRuleParser::parse($root->autoload, $projectDirectory), + ), + ), + ]; + + foreach ($declarations as $declaration) { + if ($installed->contains($declaration->name, $declaration->development)) { + $records[] = self::locate($declaration, $vendorDirectory); + } + } + + return $records; + } + + /** + * @throws GeneratorException When the package has no directory below the vendor directory or invalid autoload rules + */ + private static function locate(PackageDeclaration $declaration, string $vendorDirectory): PackageRecord + { + if ('metapackage' === $declaration->composerType) { + return new PackageRecord($declaration, null, new ClassMap()); + } + + $candidate = $vendorDirectory . '/' . $declaration->name; + $directory = FilesystemPath::canonicalDirectory($candidate) ?? throw new GeneratorException(sprintf( + 'Package "%s" is not installed at %s. Install the project\'s dependencies first.', + $declaration->name, + $candidate, + )); + + try { + $scanRules = AutoloadRuleParser::parse($declaration->autoload, $directory); + } catch (GeneratorException $exception) { + throw new GeneratorException(sprintf( + 'Invalid autoload metadata for locked package "%s": %s', + $declaration->name, + $exception->getMessage(), + ), previous: $exception); + } + + return new PackageRecord($declaration, $directory, ClassMapScanner::scan($directory, $scanRules)); + } +} diff --git a/generator/src/Composer/RequirementParser.php b/generator/src/Composer/RequirementParser.php new file mode 100644 index 0000000..c8cdba3 --- /dev/null +++ b/generator/src/Composer/RequirementParser.php @@ -0,0 +1,92 @@ + Platform requirements sorted by name + * + * @throws GeneratorException When a section is not an object, or a name, constraint, or duplicate is invalid + */ + public static function parse(stdClass $document, bool $root, string $context): array + { + $requirements = []; + $seen = []; + $sections = $root + ? [ + 'require' => false, + 'require-dev' => true, + ] + : [ + 'require' => false, + ]; + + foreach ($sections as $field => $development) { + $section = JsonField::object($document, $field, $context) ?? new stdClass(); + + foreach (get_object_vars($section) as $name => $constraint) { + $name = (string) $name; + + if (!PackageName::isPackage($name) && !PackageName::isPlatform($name)) { + throw new GeneratorException(sprintf( + 'Requirement name "%s" in %s %s is invalid or not lowercase.', + $name, + $context, + $field, + )); + } + + if (!is_string($constraint)) { + throw new GeneratorException(sprintf( + 'Requirement constraint for "%s" in %s %s must be a string.', + $name, + $context, + $field, + )); + } + + if (isset($seen[$name])) { + throw new GeneratorException(sprintf( + 'Requirement "%s" in %s must not appear in both require and require-dev.', + $name, + $context, + )); + } + + $seen[$name] = true; + + if (PackageName::isPlatform($name)) { + $requirements[] = new Requirement($name, $constraint, $development); + } + } + } + + usort( + $requirements, + static fn(Requirement $left, Requirement $right): int => strcmp($left->name, $right->name), + ); + + return $requirements; + } +} diff --git a/generator/src/Composer/VendorDirectory.php b/generator/src/Composer/VendorDirectory.php new file mode 100644 index 0000000..66be6b1 --- /dev/null +++ b/generator/src/Composer/VendorDirectory.php @@ -0,0 +1,35 @@ +{'vendor-dir'} : 'vendor'; + $directory = is_string($configured) ? FilesystemPath::absolute($projectDirectory, $configured) : null; + + return $directory ?? throw new GeneratorException( + 'composer.json config.vendor-dir must be a non-empty, resolvable path.', + ); + } +} diff --git a/generator/src/Constants/ReportStatus.php b/generator/src/Constants/ReportStatus.php new file mode 100644 index 0000000..471bc1c --- /dev/null +++ b/generator/src/Constants/ReportStatus.php @@ -0,0 +1,23 @@ + $diagnostics + * + * @return list A header and one line per class sorted by package and class, or nothing for no diagnostic; + * control characters are escaped + */ + public static function format(array $diagnostics): array + { + if ([] === $diagnostics) { + return []; + } + + usort( + $diagnostics, + static fn(Diagnostic $left, Diagnostic $right): int => strcmp($left->package, $right->package) + ?: strcmp($left->class, $right->class), + ); + $escape = static fn(string $value): string => preg_replace_callback( + '/[\x00-\x1f\x7f]/', + static fn(array $match): string => match ($match[0]) { + "\n" => '\\n', + "\r" => '\\r', + "\t" => '\\t', + default => sprintf('\\x%02X', ord($match[0])), + }, + $value, + ); + $lines = [ + 1 === count($diagnostics) + ? 'SKERNEL skipped 1 class whose attributes could not be collected:' + : sprintf('SKERNEL skipped %d classes whose attributes could not be collected:', count($diagnostics)), + ]; + + foreach ($diagnostics as $diagnostic) { + $lines[] = sprintf( + ' %s %s: %s: %s', + $escape($diagnostic->package), + $escape($diagnostic->class), + $escape($diagnostic->type), + $escape($diagnostic->message), + ); + } + + return $lines; + } +} diff --git a/generator/src/Entry/BuildLock.php b/generator/src/Entry/BuildLock.php new file mode 100644 index 0000000..424f0c2 --- /dev/null +++ b/generator/src/Entry/BuildLock.php @@ -0,0 +1,65 @@ +handle = $handle; + } + + /** + * Releases the lock; releasing it again does nothing, and the process's exit releases it too. + */ + public function release(): void + { + if (null === $this->handle) { + return; + } + + flock($this->handle, LOCK_UN); + fclose($this->handle); + $this->handle = null; + } +} diff --git a/generator/src/Entry/ChildProcess.php b/generator/src/Entry/ChildProcess.php new file mode 100644 index 0000000..a9b1110 --- /dev/null +++ b/generator/src/Entry/ChildProcess.php @@ -0,0 +1,116 @@ + $arguments The script, the request file, and the report file + * + * @return int 0 when the generator finished and wrote its report, 64 for a wrong invocation + */ + public static function run(array $arguments): int + { + if (3 !== count($arguments)) { + fwrite(STDERR, "Usage: generate.php \n"); + + return 64; + } + + [, $requestPath, $reportPath] = $arguments; + $progress = new ScanProgress(); + $finished = false; + ini_set('display_errors', '0'); + ini_set('log_errors', '0'); + register_shutdown_function(static function () use (&$finished, $progress, $reportPath): void { + if ($finished) { + return; + } + + $error = error_get_last(); + $message = null !== $error && in_array($error['type'], [ + E_ERROR, + E_PARSE, + E_CORE_ERROR, + E_COMPILE_ERROR, + E_USER_ERROR, + ], true) + ? $error['message'] + : 'The process exited before the build finished.'; + + try { + (null === $progress->class + ? new Report(ReportStatus::Failed, $message) + : new Report(ReportStatus::ClassFatal, $message, $progress->package, $progress->class)) + ->write($reportPath); + } catch (Throwable) { + // Without a report SKERNEL reports the exit status instead. + } + }); + + try { + $request = Request::read($requestPath); + require $request->vendorDirectory . '/autoload.php'; + $report = Report::built(Generator::generate( + $request->projectDirectory, + $request->vendorDirectory, + $request->skernelVersion, + $request->fatalClasses, + $progress, + )); + } catch (ProtocolMismatchException $exception) { + $report = new Report( + ReportStatus::ProtocolMismatch, + $exception->getMessage(), + projectProtocol: $exception->project, + ); + } catch (GeneratorException $exception) { + $report = new Report(ReportStatus::Failed, $exception->getMessage()); + } catch (Throwable $exception) { + $report = new Report( + ReportStatus::Failed, + sprintf('%s: %s', $exception::class, $exception->getMessage()), + ); + } + + $finished = true; + $report->write($reportPath); + + return 0; + } +} diff --git a/generator/src/Entry/SelfBuild.php b/generator/src/Entry/SelfBuild.php new file mode 100644 index 0000000..e1312ba --- /dev/null +++ b/generator/src/Entry/SelfBuild.php @@ -0,0 +1,47 @@ +getMessage())); + + exit(1); + } + } +} diff --git a/generator/src/Exception/GeneratorException.php b/generator/src/Exception/GeneratorException.php new file mode 100644 index 0000000..81c3324 --- /dev/null +++ b/generator/src/Exception/GeneratorException.php @@ -0,0 +1,14 @@ + 'The project does not install super-kernel/metadata, so SKERNEL cannot publish its ' + . 'build artifacts. Require SuperKernel 1.0.0-alpha.2 or later.', + $project > $generator => sprintf( + 'The project\'s super-kernel/metadata uses build protocol %d, but this SKERNEL generates protocol %d. ' + . 'Upgrade SKERNEL, for example with "skernel self-update".', + $project, + $generator, + ), + default => sprintf( + 'The project\'s super-kernel/metadata uses build protocol %d, but this SKERNEL generates protocol %d. ' + . 'Upgrade the project\'s SuperKernel packages, or use a SKERNEL release that generates protocol %d.', + $project, + $generator, + $project, + ), + }); + } +} diff --git a/generator/src/Exchange/Report.php b/generator/src/Exchange/Report.php new file mode 100644 index 0000000..83737aa --- /dev/null +++ b/generator/src/Exchange/Report.php @@ -0,0 +1,158 @@ + $diagnostics For a build: the skipped classes in scan order + */ + public function __construct( + public ReportStatus $status, + public ?string $message = null, + public ?string $package = null, + public ?string $class = null, + public ?int $projectProtocol = null, + public int $packages = 0, + public int $classes = 0, + public array $diagnostics = [], + ) + { + } + + public static function built(GenerationResult $result): self + { + return new self( + ReportStatus::Built, + packages: $result->packages, + classes: $result->classes, + diagnostics: $result->diagnostics, + ); + } + + /** + * @throws GeneratorException When the file cannot be written + */ + public function write(string $path): void + { + try { + $json = json_encode([ + 'status' => $this->status->value, + 'message' => $this->message, + 'package' => $this->package, + 'class' => $this->class, + 'project' => $this->projectProtocol, + 'packages' => $this->packages, + 'classes' => $this->classes, + 'diagnostics' => array_map(static fn(Diagnostic $diagnostic): array => [ + 'sequence' => $diagnostic->sequence, + 'package' => $diagnostic->package, + 'class' => $diagnostic->class, + 'type' => $diagnostic->type, + 'message' => $diagnostic->message, + ], $this->diagnostics), + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } catch (JsonException $exception) { + throw new GeneratorException('Unable to encode the generator report.', previous: $exception); + } + + if (false === @file_put_contents($path, $json)) { + throw new GeneratorException(sprintf('Unable to write the generator report %s.', $path)); + } + } + + /** + * @return self|null The report, or null when the child wrote none + * + * @throws GeneratorException When the file holds something other than a report + */ + public static function read(string $path): ?self + { + $contents = @file_get_contents($path); + + if (false === $contents || '' === $contents) { + return null; + } + + try { + $report = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new GeneratorException(sprintf('Invalid generator report %s.', $path), previous: $exception); + } + + $status = is_array($report) && is_string($report['status'] ?? null) + ? ReportStatus::tryFrom($report['status']) + : null; + + if ( + null === $status + || !is_int($report['packages'] ?? null) + || !is_int($report['classes'] ?? null) + || !is_array($report['diagnostics'] ?? null) + ) { + throw new GeneratorException(sprintf('Invalid generator report %s.', $path)); + } + + $diagnostics = []; + + foreach ($report['diagnostics'] as $diagnostic) { + if ( + !is_array($diagnostic) + || !is_int($diagnostic['sequence'] ?? null) + || 1 > $diagnostic['sequence'] + || !is_string($diagnostic['package'] ?? null) + || !is_string($diagnostic['class'] ?? null) + || !is_string($diagnostic['type'] ?? null) + || !is_string($diagnostic['message'] ?? null) + ) { + throw new GeneratorException(sprintf('Invalid diagnostic in the generator report %s.', $path)); + } + + $diagnostics[] = new Diagnostic( + $diagnostic['sequence'], + $diagnostic['package'], + $diagnostic['class'], + $diagnostic['type'], + $diagnostic['message'], + ); + } + + return new self( + $status, + is_string($report['message'] ?? null) ? $report['message'] : null, + is_string($report['package'] ?? null) ? $report['package'] : null, + is_string($report['class'] ?? null) ? $report['class'] : null, + is_int($report['project'] ?? null) ? $report['project'] : null, + $report['packages'], + $report['classes'], + $diagnostics, + ); + } +} diff --git a/generator/src/Exchange/Request.php b/generator/src/Exchange/Request.php new file mode 100644 index 0000000..8d2def7 --- /dev/null +++ b/generator/src/Exchange/Request.php @@ -0,0 +1,94 @@ + $fatalClasses Classes that ended an earlier run, with the fatal error message + */ + public function __construct( + public string $projectDirectory, + public string $vendorDirectory, + public string $skernelVersion, + public array $fatalClasses = [], + ) + { + } + + /** + * @throws GeneratorException When the file cannot be written + */ + public function write(string $path): void + { + try { + $json = json_encode([ + 'project' => $this->projectDirectory, + 'vendor' => $this->vendorDirectory, + 'skernel' => $this->skernelVersion, + 'fatal' => (object) $this->fatalClasses, + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } catch (JsonException $exception) { + throw new GeneratorException('Unable to encode the generator request.', previous: $exception); + } + + if (false === @file_put_contents($path, $json)) { + throw new GeneratorException(sprintf('Unable to write the generator request %s.', $path)); + } + } + + /** + * @throws GeneratorException When the file is missing or not a request + */ + public static function read(string $path): self + { + $contents = @file_get_contents($path); + + try { + $request = false === $contents ? null : json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $request = null; + } + + if ( + !is_array($request) + || !is_string($request['project'] ?? null) + || !is_string($request['vendor'] ?? null) + || !is_string($request['skernel'] ?? null) + || '' === $request['skernel'] + || !is_array($request['fatal'] ?? null) + ) { + throw new GeneratorException(sprintf('Invalid generator request %s.', $path)); + } + + $fatalClasses = []; + + foreach ($request['fatal'] as $class => $message) { + $fatalClasses[(string) $class] = (string) $message; + } + + return new self($request['project'], $request['vendor'], $request['skernel'], $fatalClasses); + } +} diff --git a/generator/src/GenerationResult.php b/generator/src/GenerationResult.php new file mode 100644 index 0000000..121149b --- /dev/null +++ b/generator/src/GenerationResult.php @@ -0,0 +1,24 @@ + $diagnostics Skipped classes in scan order + */ + public function __construct(public int $packages, public int $classes, public array $diagnostics) + { + } +} diff --git a/generator/src/Generator.php b/generator/src/Generator.php new file mode 100644 index 0000000..af973a1 --- /dev/null +++ b/generator/src/Generator.php @@ -0,0 +1,87 @@ + $fatalClasses Classes that ended an earlier run, with their fatal errors + * + * @throws ProtocolMismatchException When the project's super-kernel/metadata uses another protocol or is missing + * @throws GeneratorException When the project cannot be read + * @throws Throwable When super-kernel/metadata or the annotator fails to publish + */ + public static function generate( + string $projectDirectory, + string $vendorDirectory, + ?string $skernelVersion, + array $fatalClasses = [], + ?ScanProgress $progress = null, + ): GenerationResult + { + $project = FilesystemPath::canonicalDirectory($projectDirectory) + ?? throw new GeneratorException(sprintf('The project directory %s does not exist.', $projectDirectory)); + $vendor = FilesystemPath::canonicalDirectory($vendorDirectory) ?? throw new GeneratorException(sprintf( + 'The vendor directory %s does not exist. Install the project\'s dependencies first.', + $vendorDirectory, + )); + Protocol::assertCompatible(); + $records = ProjectReader::read($project, $vendor); + $manager = PackageManagerFactory::create($records); + $attributes = AttributeScanner::scan( + $records, + $fatalClasses, + $progress ?? new ScanProgress(), + AttributeMetadataFactory::create(...), + ); + ArtifactPublisher::publish( + $manager, + $attributes, + $project, + $vendor, + $skernelVersion ?? $records[0]->declaration->version ?? 'dev', + ); + $classes = 0; + $diagnostics = []; + + foreach ($records as $record) { + $classes += count($record->classMap->classes); + } + + foreach ($attributes as $package) { + foreach ($package->diagnostics as $diagnostic) { + $diagnostics[] = $diagnostic; + } + } + + return new GenerationResult(count($records), $classes, $diagnostics); + } +} diff --git a/generator/src/Model/PackageDeclaration.php b/generator/src/Model/PackageDeclaration.php new file mode 100644 index 0000000..11f4ddb --- /dev/null +++ b/generator/src/Model/PackageDeclaration.php @@ -0,0 +1,37 @@ + $requirements Platform requirements sorted by name + */ + public function __construct( + public string $name, + public ?string $version, + public ?string $reference, + public string $composerType, + public bool $root, + public bool $development, + public ?stdClass $autoload, + public ?stdClass $autoloadDev, + public array $requirements, + ) + { + } +} diff --git a/generator/src/Model/PackageRecord.php b/generator/src/Model/PackageRecord.php new file mode 100644 index 0000000..6ab33ed --- /dev/null +++ b/generator/src/Model/PackageRecord.php @@ -0,0 +1,27 @@ + $attributes One per package of the manager, with `AttributeMetadata` models + * @param non-empty-string $skernelVersion + * + * @throws Throwable When a publisher fails + */ + public static function publish( + PackageManager $manager, + array $attributes, + string $projectDirectory, + string $vendorDirectory, + string $skernelVersion, + ): void + { + $vendorDir = new VendorDir($vendorDirectory); + $publisher = new MetadataPublisher($vendorDir, new BasePath($projectDirectory)); + $annotations = []; + + foreach ($attributes as $package) { + $diagnostics = []; + + foreach ($package->diagnostics as $diagnostic) { + $diagnostics[] = [ + 'sequence' => $diagnostic->sequence, + 'package' => $diagnostic->package, + 'class' => $diagnostic->class, + 'exception_type' => $diagnostic->type, + 'message' => $diagnostic->message, + ]; + } + + $annotations[] = new PackageAnnotations( + $package->package, + $package->reference, + $package->classes, + $package->methods, + $package->properties, + $diagnostics, + ); + } + + $publisher->invalidate(); + $publisher->publishPackages($manager); + new AnnotationPublisher($vendorDir)->publish($manager, $annotations); + $publisher->commit($skernelVersion); + } +} diff --git a/generator/src/Publish/AttributeMetadataFactory.php b/generator/src/Publish/AttributeMetadataFactory.php new file mode 100644 index 0000000..6d181b5 --- /dev/null +++ b/generator/src/Publish/AttributeMetadataFactory.php @@ -0,0 +1,25 @@ +instance, $attribute->types); + } +} diff --git a/generator/src/Publish/PackageManagerFactory.php b/generator/src/Publish/PackageManagerFactory.php new file mode 100644 index 0000000..43781fc --- /dev/null +++ b/generator/src/Publish/PackageManagerFactory.php @@ -0,0 +1,71 @@ + $records The root first, then the installed lock packages + * + * @throws GeneratorException When super-kernel/metadata rejects a package + */ + public static function create(array $records): PackageManager + { + $packages = []; + $classes = []; + + foreach ($records as $record) { + $declaration = $record->declaration; + $requirements = []; + + foreach ($declaration->requirements as $requirement) { + $requirements[] = new EnvironmentRequirement( + $requirement->name, + $requirement->constraint, + $declaration->name, + $requirement->development, + ); + } + + try { + $packages[] = new Package( + $declaration->name, + $declaration->version, + $declaration->reference, + $declaration->composerType, + $record->locatedPath, + $requirements, + ); + } catch (InvalidArgumentException $exception) { + throw new GeneratorException(sprintf( + 'The package "%s" cannot be recorded: %s', + $declaration->name, + $exception->getMessage(), + ), previous: $exception); + } + + $classes[$declaration->name] = $record->classMap->classes; + } + + $root = array_shift($packages); + + return new PackageManager($root, $packages, $classes); + } +} diff --git a/generator/src/Publish/Protocol.php b/generator/src/Publish/Protocol.php new file mode 100644 index 0000000..9841bc6 --- /dev/null +++ b/generator/src/Publish/Protocol.php @@ -0,0 +1,40 @@ + + generator/src src diff --git a/src/Build/GeneratorLocator.php b/src/Build/GeneratorLocator.php new file mode 100644 index 0000000..aa48970 --- /dev/null +++ b/src/Build/GeneratorLocator.php @@ -0,0 +1,117 @@ +extract($source) : $source; + } + + /** + * Copies the generator to `generator//` below the SKERNEL home, assembled in a job below + * `work/` and published by atomic rename, and reuses a copy with the same digest. + * + * @return string The extracted directory + * + * @throws RuntimeException When the generator cannot be read, or the copy cannot be written or published + */ + public function extract(string $source): string + { + $files = []; + + /** @var SplFileInfo $entry */ + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), + ) as $entry) { + if ($entry->isFile()) { + $contents = file_get_contents($entry->getPathname()); + + if (false === $contents) { + throw new RuntimeException(sprintf('Unable to read %s.', $entry->getPathname())); + } + + $files[substr($entry->getPathname(), strlen($source) + 1)] = $contents; + } + } + + ksort($files, SORT_STRING); + $context = hash_init('sha256'); + + foreach ($files as $path => $contents) { + hash_update($context, $path . "\0" . strlen($contents) . "\0" . $contents); + } + + $home = $this->home->directory(); + $directory = $home . '/generator/' . hash_final($context); + + if (is_file($directory . '/generate.php')) { + return $directory; + } + + $job = $home . '/work/' . uniqid('generator-', true); + + try { + foreach ($files as $path => $contents) { + Directory::create(dirname($job . '/' . $path)); + + if (false === file_put_contents($job . '/' . $path, $contents)) { + throw new RuntimeException(sprintf('Unable to write %s/%s.', $job, $path)); + } + } + + Directory::create(dirname($directory)); + + if (!@rename($job, $directory) && !is_file($directory . '/generate.php')) { + throw new RuntimeException(sprintf('Unable to publish the generator to %s.', $directory)); + } + } finally { + Directory::remove($job); + } + + return $directory; + } +} diff --git a/src/Build/MetadataGenerator.php b/src/Build/MetadataGenerator.php new file mode 100644 index 0000000..b0c1794 --- /dev/null +++ b/src/Build/MetadataGenerator.php @@ -0,0 +1,124 @@ + The skipped classes in scan order + * + * @throws RuntimeException When the PHP binary is not found, the project's build protocol differs, or the build + * fails + */ + public function generate( + Project $project, + string $phpBinary, + string $skernelVersion, + OutputInterface $output, + ): array + { + $command = [ + Executable::find($phpBinary, 'PHP'), + $this->generatorLocator->directory() . '/generate.php', + ]; + $request = tempnam(sys_get_temp_dir(), 'skernel-request-'); + $reportPath = tempnam(sys_get_temp_dir(), 'skernel-report-'); + $fatalClasses = []; + + if (false === $request || false === $reportPath) { + throw new RuntimeException('Unable to create the temporary files of the build.'); + } + + try { + while (true) { + new Request( + $project->directory, + $project->vendorDirectory, + $skernelVersion, + $fatalClasses, + )->write($request); + + if (false === file_put_contents($reportPath, '')) { + throw new RuntimeException(sprintf('Unable to reset the build report %s.', $reportPath)); + } + + $status = ProcessRunner::run( + [ + ...$command, + $request, + $reportPath, + ], + $project->directory, + $output, + sprintf('the PHP binary %s', $phpBinary), + ); + $report = Report::read($reportPath) + ?? throw new RuntimeException(sprintf('The build ended with exit status %d.', $status)); + + switch ($report->status) { + case ReportStatus::Built: + return $report->diagnostics; + + case ReportStatus::ProtocolMismatch: + throw new RuntimeException( + new ProtocolMismatchException($report->projectProtocol, Protocol::supported()) + ->getMessage(), + ); + + case ReportStatus::ClassFatal: + if (null === $report->class || isset($fatalClasses[$report->class])) { + throw new RuntimeException(sprintf( + 'The build ended with a fatal error: %s', + $report->message, + )); + } + + $fatalClasses[$report->class] = (string) $report->message; + + break; + + case ReportStatus::Failed: + throw new RuntimeException(sprintf('The build failed: %s', $report->message)); + } + } + } catch (GeneratorException $exception) { + throw new RuntimeException($exception->getMessage(), 0, $exception); + } finally { + @unlink($request); + @unlink($reportPath); + } + } +} diff --git a/src/Build/ProjectBuilder.php b/src/Build/ProjectBuilder.php index 189f93a..c945f70 100644 --- a/src/Build/ProjectBuilder.php +++ b/src/Build/ProjectBuilder.php @@ -4,6 +4,7 @@ namespace Src\Build; +use SKERNEL\Generator\Diagnostic\DiagnosticFormatter; use Src\Process\PhpScript; use Src\Project\Project; use Symfony\Component\Console\Exception\RuntimeException; @@ -13,11 +14,12 @@ use function var_export; /** - * Writes a project's build artifacts in a separate PHP process without creating or running the application. + * Writes a project's build artifacts in separate PHP processes without creating or running the application. * - * The process bootstraps the project's container, which writes its scan snapshots, then publishes - * `SuperKernel\Event\ApplicationBuild` so that components write the artifacts restore-only hosts load. Projects - * without that event or a PSR-14 dispatcher have no listener for it, so the event is skipped. + * The first process generates the package and annotation records. The second bootstraps the project's container from + * them, without any class the first loaded, and publishes `SuperKernel\Event\ApplicationBuild` so that components + * write the artifacts they prepare at build time, such as AOP proxies. Projects without that event or a PSR-14 + * dispatcher have no listener for it, so the event is skipped. */ final readonly class ProjectBuilder { @@ -40,19 +42,36 @@ class_exists(SuperKernel\Event\ApplicationBuild::class) PHP; + public function __construct(private MetadataGenerator $metadataGenerator) + { + } + /** - * Runs the build in the project directory with the given PHP binary, a path or a name looked up in PATH, and - * forwards everything the process writes. + * Runs the build in the project directory with the given PHP binary, a path or a name looked up in PATH, forwards + * everything the processes write, and then lists the classes whose attributes could not be collected. * - * @return int The exit status of the build process + * @param string $skernelVersion The running SKERNEL's version, `UNKNOWN` or empty when it has none * - * @throws RuntimeException When the PHP binary is not found or cannot be started, or the temporary entry script - * cannot be written + * @throws RuntimeException When the PHP binary is not found or cannot be started, the project's build protocol + * differs, or either process fails */ - public function build(Project $project, string $phpBinary, OutputInterface $output): int + public function build(Project $project, string $phpBinary, string $skernelVersion, OutputInterface $output): void { + $diagnostics = $this->metadataGenerator->generate( + $project, + $phpBinary, + '' === $skernelVersion || 'UNKNOWN' === $skernelVersion ? 'dev' : $skernelVersion, + $output, + ); $entry = sprintf(self::ENTRY, var_export($project->vendorDirectory . '/autoload.php', true)); + $status = PhpScript::run($entry, $phpBinary, [], $project->directory, $output); - return PhpScript::run($entry, $phpBinary, [], $project->directory, $output); + if (0 !== $status) { + throw new RuntimeException(sprintf('The project build failed with exit status %d.', $status)); + } + + foreach (DiagnosticFormatter::format($diagnostics) as $line) { + $output->writeln($line, OutputInterface::OUTPUT_RAW); + } } } diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index a577a1d..6d999b7 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -16,9 +16,9 @@ use function sprintf; /** - * Scans the project with its managed PHP and writes the build artifacts that restore-only and Phar execution load. + * Generates the project's build artifacts with its managed PHP; the application loads them at start. */ -#[AttributeCommand(name: 'build', description: 'Scan the project and write its build artifacts')] +#[AttributeCommand(name: 'build', description: 'Generate the build artifacts that the project loads at start')] final class BuildCommand extends Command { public function __construct( @@ -41,18 +41,18 @@ protected function configure(): void /** * @throws RuntimeException When the project or its configuration is invalid, its managed PHP cannot be provided, - * or the build cannot start or fails + * the project's build protocol differs, or the build cannot start or fails */ protected function execute(InputInterface $input, OutputInterface $output): int { $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; [$project, $runtime] = $this->projectRuntime->resolve($input, $errorOutput); - $status = $this->projectBuilder->build($project, $runtime->php, $errorOutput); - - if (0 !== $status) { - throw new RuntimeException(sprintf('The project build failed with exit status %d.', $status)); - } - + $this->projectBuilder->build( + $project, + $runtime->php, + (string) $this->getApplication()?->getVersion(), + $errorOutput, + ); $output->writeln( sprintf('Build artifacts written to %s/.cache.', $project->vendorDirectory), OutputInterface::OUTPUT_RAW, diff --git a/src/Command/CleanCommand.php b/src/Command/CleanCommand.php index c18c095..aa28afc 100644 --- a/src/Command/CleanCommand.php +++ b/src/Command/CleanCommand.php @@ -16,9 +16,9 @@ use function sprintf; /** - * Removes what `build` and `package` write by default: the `target/` directory and the scan caches. + * Removes what `build` and `package` write by default: the `target/` directory and the build artifacts. */ -#[AttributeCommand(name: 'clean', description: "Remove the project's build output and scan caches")] +#[AttributeCommand(name: 'clean', description: "Remove the project's build output and build artifacts")] final class CleanCommand extends Command { public function __construct(private readonly ProjectLocator $projectLocator) diff --git a/src/Command/PackageCommand.php b/src/Command/PackageCommand.php index 5c80156..23844d3 100644 --- a/src/Command/PackageCommand.php +++ b/src/Command/PackageCommand.php @@ -153,12 +153,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $php ?? $settings->php, $errorOutput, ); - $status = $this->projectBuilder->build($staged, $managed->php, $errorOutput); - - if (0 !== $status) { - throw new RuntimeException(sprintf('The project build failed with exit status %d.', $status)); - } - + $this->projectBuilder->build( + $staged, + $managed->php, + (string) $this->getApplication()?->getVersion(), + $errorOutput, + ); $archive = $staging . '/' . $name . '.phar'; $status = $this->pharBuilder->build($staged, $archive, $phar, $managed->php, $errorOutput); diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php index 0ac41d2..ca4facb 100644 --- a/src/Command/RunCommand.php +++ b/src/Command/RunCommand.php @@ -15,9 +15,10 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Runs the project from source with its managed PHP, and with --watch restarts it whenever its files change. + * Builds and runs the project from source with its managed PHP, and with --watch rebuilds and restarts it whenever its + * files change. */ -#[AttributeCommand(name: 'run', description: 'Run the project from source, and restart it on changes with --watch')] +#[AttributeCommand(name: 'run', description: 'Build and run the project, and restart it on changes with --watch')] final class RunCommand extends Command { public function __construct( @@ -48,7 +49,7 @@ protected function configure(): void * @return int The exit status of the application * * @throws RuntimeException When the project or its configuration is invalid, its managed PHP cannot be provided, - * or the application cannot start + * or the application cannot be built or started */ protected function execute(InputInterface $input, OutputInterface $output): int { @@ -60,6 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $runtime->php, $input->getArgument('arguments'), $input->getOption('watch'), + (string) $this->getApplication()?->getVersion(), $output, $errorOutput, ); diff --git a/src/Run/ProjectRunner.php b/src/Run/ProjectRunner.php index dbec127..e324411 100644 --- a/src/Run/ProjectRunner.php +++ b/src/Run/ProjectRunner.php @@ -4,6 +4,7 @@ namespace Src\Run; +use Src\Build\ProjectBuilder; use Src\Process\Executable; use Src\Project\Project; use Symfony\Component\Console\Exception\RuntimeException; @@ -28,9 +29,10 @@ /** * Runs a project from source in a separate PHP process, and with watching restarts it whenever its files change. * - * The process runs the standard application entry sequence in the project directory. A watched application that exits - * on its own is started again by the next change, so that a fix restarts one that crashed. SIGINT and SIGTERM stop the - * application first, so that it does not outlive SKERNEL. + * Every start builds the project first, so the application loads artifacts that match its sources. The process runs + * the standard application entry sequence in the project directory. A watched application that exits on its own, or + * whose build fails, is started again by the next change, so that a fix restarts one that crashed. SIGINT and SIGTERM + * stop the application first, so that it does not outlive SKERNEL. */ final readonly class ProjectRunner { @@ -56,20 +58,26 @@ /** Seconds that a stopped application has to exit before it is killed */ private const int STOP_TIMEOUT = 10; + public function __construct(private ProjectBuilder $projectBuilder) + { + } + /** - * @param string $phpBinary A path or a name looked up in PATH - * @param list $arguments The arguments of the application + * @param string $phpBinary A path or a name looked up in PATH + * @param list $arguments The arguments of the application + * @param string $skernelVersion The running SKERNEL's version, `UNKNOWN` or empty when it has none * * @return int The exit status of the application * - * @throws RuntimeException When the PHP binary is not found or cannot be started, or the temporary entry script - * cannot be written + * @throws RuntimeException When the PHP binary is not found or cannot be started, the first build fails, or the + * temporary entry script cannot be written */ public function run( Project $project, string $phpBinary, array $arguments, bool $watch, + string $skernelVersion, OutputInterface $output, OutputInterface $errorOutput, ): int @@ -90,7 +98,19 @@ public function run( throw new RuntimeException(sprintf('Unable to write the temporary PHP script %s.', $script)); } - $start = static function () use ($command, $script, $arguments, $project, $output, $errorOutput): Process { + $builder = $this->projectBuilder; + $start = static function () use ( + $builder, + $phpBinary, + $skernelVersion, + $command, + $script, + $arguments, + $project, + $output, + $errorOutput, + ): Process { + $builder->build($project, $phpBinary, $skernelVersion, $errorOutput); $process = new Process([ ...$command, $script, @@ -140,19 +160,19 @@ public function run( $reported = false; while (true) { - $running = $process->isRunning(); + $running = null !== $process && $process->isRunning(); if ($stopping) { - $process->stop(self::STOP_TIMEOUT); + $process?->stop(self::STOP_TIMEOUT); - return $process->getExitCode() ?? 1; + return $process?->getExitCode() ?? 1; } if (!$running && !$watch) { - return $process->getExitCode() ?? 1; + return $process?->getExitCode() ?? 1; } - if (!$running && !$reported) { + if (null !== $process && !$running && !$reported) { $errorOutput->writeln(sprintf( 'The application exited with status %d; waiting for a change.', $process->getExitCode() ?? 1, @@ -167,9 +187,18 @@ public function run( if ($current !== $snapshot) { $snapshot = $current; $errorOutput->writeln('The project changed; restarting.', OutputInterface::OUTPUT_RAW); - $process->stop(self::STOP_TIMEOUT); - $process = $start(); - $reported = false; + $process?->stop(self::STOP_TIMEOUT); + + try { + $process = $start(); + $reported = false; + } catch (RuntimeException $exception) { + $process = null; + $errorOutput->writeln( + $exception->getMessage() . ' Waiting for a change.', + OutputInterface::OUTPUT_RAW, + ); + } } } diff --git a/tests/BootstrapTest.php b/tests/BootstrapTest.php index f9c500f..e2062a8 100644 --- a/tests/BootstrapTest.php +++ b/tests/BootstrapTest.php @@ -67,6 +67,23 @@ public function testVersionReportsSKERNELWithTheRootPackageVersion(): void self::assertSame($expected, end($lines)); } + /** @throws JsonException When the root manifest or the build stamp is not valid JSON */ + public function testStartingFromSourceGeneratesTheBuildArtifactsOfSKERNELItself(): void + { + $root = dirname(__DIR__); + $manifest = json_decode((string) file_get_contents($root . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); + $stamp = $root . '/vendor/.cache/build.json'; + @unlink($stamp); + + $output = $this->bootstrap('--version', $status); + + self::assertSame(0, $status, implode("\n", $output)); + self::assertFileExists($stamp); + $build = json_decode((string) file_get_contents($stamp), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(1, $build['protocol']); + self::assertSame($manifest['version'] ?? 'dev', $build['skernel']); + } + public function testOverviewOpensWithTheSKERNELLogo(): void { $output = $this->bootstrap('--no-ansi', $status); @@ -85,7 +102,7 @@ public function testStandardOutputCarriesOnlyTheCommandOutput(): void self::assertSame('SKERNEL', $listing['application']['name']); } - public function testScanDiagnosticsOfSKERNELItselfAreNotWritten(): void + public function testSkippedClassesOfSKERNELItselfAreNotWritten(): void { $output = $this->bootstrap('--version', $status, '2>&1 >/dev/null'); @@ -160,7 +177,8 @@ public function testBuildRejectsAPhpOptionThatIsNotAVersion(): void public function testBuildRejectsADirectoryWithoutAProject(): void { $missing = dirname(__DIR__) . '/missing'; - $output = $this->bootstrap('build -d ' . escapeshellarg($missing), $status, '2>&1 >/dev/null'); + // A wide terminal keeps Console from breaking the long path inside a word. + $output = $this->bootstrap('build -d ' . escapeshellarg($missing), $status, '2>&1 >/dev/null', 'COLUMNS=1000'); $message = (string) preg_replace('/\s+/', ' ', implode(' ', $output)); self::assertSame(1, $status); diff --git a/tests/Integration/PackageCommandTest.php b/tests/Integration/PackageCommandTest.php index 764acdf..0b0193a 100644 --- a/tests/Integration/PackageCommandTest.php +++ b/tests/Integration/PackageCommandTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use RecursiveIteratorIterator; use Src\Filesystem\Directory; +use Src\Tests\Support\BuildApiStandIn; use Src\Toolchain\DailyCache; use Src\Toolchain\ExtensionRequirements; use Src\Toolchain\ExtensionVersions; @@ -57,6 +58,8 @@ final class PackageCommandTest extends TestCase namespace SuperKernel\Context; + require __DIR__ . '/build-api.php'; + final class ApplicationContext { public static function getContainer(): object @@ -104,7 +107,7 @@ protected function setUp(): void 'php' => '>=8.4', ], ])); - $this->write($this->project . '/composer.lock', '{}'); + $this->write($this->project . '/composer.lock', '{"packages": [], "packages-dev": []}'); $this->write($this->project . '/src/App.php', 'write($this->project . '/.env', 'SECRET=1'); $this->write($this->project . '/.git/HEAD', 'ref: refs/heads/main'); @@ -162,8 +165,11 @@ public function testPackagesAPharWithoutMetadataSecretsOrOldOutput(): void 'composer.json', 'composer.lock', 'src/App.php', + 'vendor/.cache/build.json', 'vendor/autoload.php', + 'vendor/build-api.php', 'vendor/composer/installed.json', + 'vendor/composer/installed.php', ], $this->entries($this->project . '/target/demo.phar')); exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($this->project . '/target/demo.phar'), $run, $status); @@ -275,6 +281,7 @@ private function composer(): string private function vendor(string $directory): void { $this->write($directory . '/vendor/autoload.php', self::AUTOLOAD); + BuildApiStandIn::write($directory, $directory . '/vendor'); $this->write($directory . '/vendor/composer/installed.json', (string) json_encode([ 'packages' => [ [ diff --git a/tests/Integration/ProjectCommandsTest.php b/tests/Integration/ProjectCommandsTest.php index 70a583c..77908be 100644 --- a/tests/Integration/ProjectCommandsTest.php +++ b/tests/Integration/ProjectCommandsTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\RequiresOperatingSystemFamily; use PHPUnit\Framework\TestCase; use Src\Filesystem\Directory; +use Src\Tests\Support\BuildApiStandIn; use Src\Toolchain\DailyCache; use Src\Toolchain\ExtensionRequirements; use Src\Toolchain\ExtensionVersions; @@ -58,6 +59,8 @@ final class ProjectCommandsTest extends TestCase namespace SuperKernel\Context; + require __DIR__ . '/build-api.php'; + final class ApplicationContext { public static function getContainer(): object @@ -104,6 +107,7 @@ protected function setUp(): void 'name' => 'acme/demo', ])); $this->write($this->project . '/vendor/autoload.php', self::AUTOLOAD); + BuildApiStandIn::write($this->project, $this->project . '/vendor'); $this->write($this->project . '/vendor/composer/installed.json', (string) json_encode([ 'packages' => [ [ @@ -140,7 +144,7 @@ protected function tearDown(): void Directory::remove($this->directory); } - public function testCleanRemovesTheBuildOutputAndTheScanCaches(): void + public function testCleanRemovesTheBuildOutputAndTheBuildArtifacts(): void { $this->write($this->project . '/target/demo', 'package'); $this->write($this->project . '/vendor/.cache/classes/demo.ser', 'cache'); diff --git a/tests/Support/BuildApiStandIn.php b/tests/Support/BuildApiStandIn.php new file mode 100644 index 0000000..3e0dffb --- /dev/null +++ b/tests/Support/BuildApiStandIn.php @@ -0,0 +1,153 @@ +vendorDir->directory . '/.cache'); + file_put_contents($this->vendorDir->directory . '/.cache/build.json', $skernelVersion); + } + } + } + + namespace SuperKernel\Annotator\Model { + final class AttributeMetadata + { + public function __construct(mixed ...$arguments) + { + } + } + + final class PackageAnnotations + { + public function __construct(mixed ...$arguments) + { + } + } + } + + namespace SuperKernel\Annotator\Cache { + final class AnnotationPublisher + { + public function __construct(mixed ...$arguments) + { + } + + public function publish(object $manager, array $annotations): void + { + } + } + } + + PHP; + + /** + * Writes `build-api.php`, which the stand-in autoloader requires, and an installation state without packages; a + * project without a lock file also gets an empty one. + */ + public static function write(string $projectDirectory, string $vendorDirectory): void + { + if (!is_dir($vendorDirectory . '/composer')) { + Assert::assertTrue(mkdir($vendorDirectory . '/composer', 0o700, true)); + } + + Assert::assertNotFalse(file_put_contents($vendorDirectory . '/build-api.php', self::SOURCE)); + Assert::assertNotFalse(file_put_contents( + $vendorDirectory . '/composer/installed.php', + " ['dev' => false], 'versions' => []];", + )); + + if (!is_file($projectDirectory . '/composer.lock')) { + Assert::assertNotFalse(file_put_contents( + $projectDirectory . '/composer.lock', + '{"packages": [], "packages-dev": []}', + )); + } + } +} diff --git a/tests/Unit/Build/GeneratorLocatorTest.php b/tests/Unit/Build/GeneratorLocatorTest.php new file mode 100644 index 0000000..5f87877 --- /dev/null +++ b/tests/Unit/Build/GeneratorLocatorTest.php @@ -0,0 +1,79 @@ +directory = (string) realpath($directory); + putenv('SKERNEL_HOME=' . $this->directory . '/home'); + } + + protected function tearDown(): void + { + putenv('SKERNEL_HOME'); + Directory::remove($this->directory); + } + + public function testRunsTheGeneratorInPlaceFromSource(): void + { + self::assertSame(dirname(__DIR__, 3) . '/generator', new GeneratorLocator(new Home())->directory()); + } + + public function testExtractsTheGeneratorOfAPharToTheHomeByItsDigestAndReusesIt(): void + { + $phar = $this->directory . '/skernel.phar'; + $process = new Process([ + PHP_BINARY, + '-d', + 'phar.readonly=0', + '-r', + '$phar = new Phar(' . var_export($phar, true) . ');' + . '$phar->addFromString("generator/generate.php", "addFromString("generator/src/Generator.php", "addFromString("src/App.php", "run(), $process->getErrorOutput()); + + $locator = new GeneratorLocator(new Home()); + $directory = $locator->extract('phar://' . $phar . '/generator'); + + self::assertSame($this->directory . '/home/generator', dirname($directory)); + self::assertMatchesRegularExpression('/\A[0-9a-f]{64}\z/', basename($directory)); + self::assertStringEqualsFile($directory . '/generate.php', 'directory . '/home/work/*')); + + self::assertTrue(unlink($directory . '/src/Generator.php')); + self::assertSame($directory, $locator->extract('phar://' . $phar . '/generator')); + self::assertFileDoesNotExist($directory . '/src/Generator.php'); + } +} diff --git a/tests/Unit/Build/ProjectBuilderTest.php b/tests/Unit/Build/ProjectBuilderTest.php index 8623547..74f25d7 100644 --- a/tests/Unit/Build/ProjectBuilderTest.php +++ b/tests/Unit/Build/ProjectBuilderTest.php @@ -5,15 +5,20 @@ namespace Src\Tests\Unit\Build; use PHPUnit\Framework\TestCase; +use Src\Build\GeneratorLocator; +use Src\Build\MetadataGenerator; use Src\Build\ProjectBuilder; +use Src\Filesystem\Directory; use Src\Project\Project; +use Src\Tests\Support\BuildApiStandIn; +use Src\Toolchain\Home; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Output\BufferedOutput; use function dirname; use function file_put_contents; use function mkdir; -use function rmdir; +use function realpath; use function sprintf; use function sys_get_temp_dir; use function tempnam; @@ -65,14 +70,27 @@ final class ApplicationBuild PHP; + private const string LOADER = <<<'PHP' + namespace { + if (%s) { + require __DIR__ . '/build-api.php'; + } + + spl_autoload_register(static function (string $class): void { + if (str_starts_with($class, 'App\\')) { + require dirname(__DIR__) . '/src/' . substr($class, 4) . '.php'; + } + }); + } + + PHP; + private ?string $directory = null; protected function tearDown(): void { if (null !== $this->directory) { - unlink($this->directory . '/vendor/autoload.php'); - rmdir($this->directory . '/vendor'); - rmdir($this->directory); + Directory::remove($this->directory); } } @@ -85,9 +103,10 @@ public function testRejectsAPhpBinaryThatIsNotFound(): void $this->expectExceptionMessageIs('The PHP binary skernel-missing-php was not found.'); try { - new ProjectBuilder()->build( + $this->builder()->build( new Project($root, $root . '/vendor', null, null), 'skernel-missing-php', + '1.0.0', $output, ); } finally { @@ -95,40 +114,110 @@ public function testRejectsAPhpBinaryThatIsNotFound(): void } } - public function testPublishesTheBuildEventAfterCreatingTheContainer(): void + public function testGeneratesTheArtifactsAndPublishesTheBuildEventAfterCreatingTheContainer(): void { $output = new BufferedOutput(); + $project = $this->project(true, true); + $this->builder()->build($project, PHP_BINARY, '1.2.3', $output); - self::assertSame(0, new ProjectBuilder()->build($this->project(true, true), PHP_BINARY, $output)); self::assertSame("container\ndispatched SuperKernel\\Event\\ApplicationBuild\n", $output->fetch()); + self::assertStringEqualsFile($project->vendorDirectory . '/.cache/build.json', '1.2.3'); + } + + public function testStampsDevWhenTheRunningSkernelHasNoVersion(): void + { + $project = $this->project(true, true); + $this->builder()->build($project, PHP_BINARY, 'UNKNOWN', new BufferedOutput()); + + self::assertStringEqualsFile($project->vendorDirectory . '/.cache/build.json', 'dev'); } public function testSkipsTheBuildEventWithoutADispatcher(): void { $output = new BufferedOutput(); + $this->builder()->build($this->project(true, false), PHP_BINARY, '1.0.0', $output); - self::assertSame(0, new ProjectBuilder()->build($this->project(true, false), PHP_BINARY, $output)); self::assertSame("container\n", $output->fetch()); } public function testSkipsTheBuildEventWhenTheProjectDoesNotDeclareIt(): void { $output = new BufferedOutput(); + $this->builder()->build($this->project(false, true), PHP_BINARY, '1.0.0', $output); - self::assertSame(0, new ProjectBuilder()->build($this->project(false, true), PHP_BINARY, $output)); self::assertSame("container\n", $output->fetch()); } - /** Writes a temporary project whose autoloader declares a recording container in place of SuperKernel's. */ - private function project(bool $event, bool $dispatcher): Project + public function testListsSkippedClassesAfterTheBuildAndSkipsAClassThatEndsTheProcess(): void + { + $project = $this->project(false, false); + $this->source('Broken', '#[Missing] final class Broken {}'); + $this->source('Fatal', '#[\Attribute] final class Fatal implements \Countable {}'); + $this->source('Kept', '#[\Attribute] final class Kept {}'); + $output = new BufferedOutput(); + $this->builder()->build($project, PHP_BINARY, '1.0.0', $output); + + self::assertSame( + "container\n" + . "SKERNEL skipped 2 classes whose attributes could not be collected:\n" + . " acme/app App\\Broken: Error: Attribute class \"App\\Missing\" not found\n" + . ' acme/app App\\Fatal: fatal error: Class App\\Fatal contains 1 abstract method and must therefore be ' + . "declared abstract or implement the remaining methods (Countable::count)\n", + $output->fetch(), + ); + } + + public function testRejectsAProjectWithoutTheMetadataPublisher(): void + { + $project = $this->project(true, true, false); + $output = new BufferedOutput(); + + try { + $this->builder()->build($project, PHP_BINARY, '1.0.0', $output); + self::fail('Built a project without super-kernel/metadata.'); + } catch (RuntimeException $exception) { + self::assertStringStartsWith( + 'The project does not install super-kernel/metadata', + $exception->getMessage(), + ); + } + + self::assertSame('', $output->fetch()); + } + + private function builder(): ProjectBuilder + { + return new ProjectBuilder(new MetadataGenerator(new GeneratorLocator(new Home()))); + } + + /** + * Writes a temporary project named acme/app, whose autoloader declares a recording container in place of + * SuperKernel's and maps `App\` to `src/`. + */ + private function project(bool $event, bool $dispatcher, bool $metadata = true): Project { $directory = tempnam(sys_get_temp_dir(), 'skernel-project-'); self::assertIsString($directory); - self::assertTrue(unlink($directory) && mkdir($directory . '/vendor', 0o700, true)); + self::assertTrue(unlink($directory) && mkdir($directory . '/src', 0o700, true)); + $directory = (string) realpath($directory); $this->directory = $directory; - $autoload = "directory . '/src/' . $class . '.php', + " */ + private array $files = []; + + private Closure $loader; + + protected function setUp(): void + { + parent::setUp(); + $this->namespace = 'GeneratorFixture' . str_replace('.', '', uniqid('', true)); + $this->loader = function (string $class): void { + if (isset($this->files[$class])) { + require $this->files[$class]; + } + }; + spl_autoload_register($this->loader); + } + + protected function tearDown(): void + { + spl_autoload_unregister($this->loader); + parent::tearDown(); + } + + public function testCollectsClassMethodAndPropertyAttributesOfDeclaredMembersByPackage(): void + { + $this->declare('Contract', 'interface Contract {}'); + $this->declare('BaseAttribute', 'abstract class BaseAttribute implements Contract {}'); + $this->declare('Marker', <<<'PHP' + #[\Attribute(\Attribute::TARGET_ALL | \Attribute::IS_REPEATABLE)] + final class Marker extends BaseAttribute + { + public function __construct(public string $value = '') + { + } + } + PHP); + $this->declare('Base', <<<'PHP' + class Base + { + #[Marker('inherited')] + public function inherited(): void + { + } + } + PHP); + $service = $this->declare('Service', <<<'PHP' + #[Marker('class'), Marker('again')] + final class Service extends Base + { + #[Marker('property')] + public int $count = 0; + + #[Marker('zeta')] + public function zeta(): void + { + } + + #[Marker('alpha')] + public function alpha(): void + { + } + + public function plain(): void + { + } + } + PHP); + $other = $this->declare('Other', '#[Marker] final class Other {}'); + [$first, $second] = AttributeScanner::scan([ + $this->record('acme/first', 'first-reference', [ + 'Service', + 'Base', + ]), + $this->record('acme/second', null, [ + 'Other', + ]), + ], [], new ScanProgress(), $this->identity(...)); + + self::assertSame('acme/first', $first->package); + self::assertSame('first-reference', $first->reference); + self::assertSame([ + $this->className('Base'), + $service, + ], array_keys($first->methods)); + self::assertSame([ + 'class', + 'again', + ], $this->values($first->classes[$service])); + self::assertSame([ + $this->className('Marker'), + $this->className('BaseAttribute'), + $this->className('Contract'), + ], $first->classes[$service][0]->types); + self::assertSame([ + 'alpha', + 'zeta', + ], array_keys($first->methods[$service])); + self::assertSame([ + 'count', + ], array_keys($first->properties[$service])); + self::assertSame([ + 'inherited', + ], array_keys($first->methods[$this->className('Base')])); + self::assertSame([], $first->diagnostics); + self::assertSame('acme/second', $second->package); + self::assertNull($second->reference); + self::assertSame([ + $other, + ], array_keys($second->classes)); + } + + public function testSkipsFailingClassesWithDiagnosticsInScanOrderAcrossPackages(): void + { + $this->declare('Once', '#[\Attribute] final class Once {}'); + $this->declare('Failing', <<<'PHP' + #[\Attribute] + final class Failing + { + public function __construct() + { + throw new \RuntimeException("Attribute\nfailed"); + } + } + PHP); + $this->declare('Kept', '#[Once] final class Kept {}'); + $this->declare('MissingParent', '#[Once] final class MissingParent extends Absent {}'); + $this->declare('Repeated', '#[Once, Once] final class Repeated {}'); + $this->declare('Throwing', 'final class Throwing { #[Failing] public function run(): void {} }'); + $this->declare('UnknownAttribute', '#[Unknown] final class UnknownAttribute {}'); + $this->declare('Fatal', '#[Once] final class Fatal {}'); + $progress = new ScanProgress(); + [$first, $second] = AttributeScanner::scan([ + $this->record('acme/first', null, [ + 'Kept', + 'MissingParent', + 'Repeated', + ]), + $this->record('acme/second', null, [ + 'Throwing', + 'UnknownAttribute', + 'Fatal', + ]), + ], [ + $this->className('Fatal') => 'Cannot redeclare a method', + ], $progress, $this->identity(...)); + + self::assertSame([ + $this->className('Kept'), + ], array_keys($first->classes)); + self::assertSame([ + '1|acme/first|MissingParent|Error', + '2|acme/first|Repeated|Error', + ], $this->diagnostics($first->diagnostics)); + self::assertSame([ + '3|acme/second|Fatal|fatal error', + '4|acme/second|Throwing|RuntimeException', + '5|acme/second|UnknownAttribute|Error', + ], $this->diagnostics($second->diagnostics)); + self::assertSame("Attribute\nfailed", $second->diagnostics[1]->message); + self::assertSame('Cannot redeclare a method', $second->diagnostics[0]->message); + self::assertFalse(class_exists($this->className('Fatal'), false)); + self::assertNull($progress->class); + self::assertNull($progress->package); + } + + public function testSkipsAClassWithAnAttributeThatCannotBeSerializedWithoutPartialMetadata(): void + { + $this->declare('Plain', '#[\Attribute] final class Plain {}'); + $this->declare('Holder', <<<'PHP' + #[\Attribute] + final class Holder + { + public \Closure $factory; + + public function __construct() + { + $this->factory = static fn(): int => 1; + } + } + PHP); + $this->declare('Holding', <<<'PHP' + #[Plain] + final class Holding + { + #[Holder] + public function run(): void + { + } + } + PHP); + $kept = $this->declare('Kept', '#[Plain] final class Kept {}'); + [$package] = AttributeScanner::scan([ + $this->record('acme/first', null, [ + 'Holding', + 'Kept', + ]), + ], [], new ScanProgress(), AttributeMetadataFactory::create(...)); + + self::assertSame([ + $kept, + ], array_keys($package->classes)); + self::assertInstanceOf(AttributeMetadata::class, $package->classes[$kept][0]); + self::assertSame([], $package->methods); + self::assertSame([ + '1|acme/first|Holding|InvalidArgumentException', + ], $this->diagnostics($package->diagnostics)); + self::assertSame( + 'Attribute ' . $this->className('Holder') . ' must support trusted serialization and restoration.', + $package->diagnostics[0]->message, + ); + } + + public function testDoesNotLoadAClassWhoseSourceContainsNoAttributes(): void + { + $this->declare('Unmarked', 'final class Unmarked extends Absent {}'); + [$package] = AttributeScanner::scan([ + $this->record('acme/first', null, [ + 'Unmarked', + ]), + ], [], new ScanProgress(), $this->identity(...)); + + self::assertSame([], $package->classes); + self::assertSame([], $package->diagnostics); + self::assertFalse(class_exists($this->className('Unmarked'), false)); + } + + /** + * Writes a fixture declaration in this test's namespace and makes it autoloadable. + * + * @return class-string + */ + private function declare(string $class, string $declaration): string + { + $name = $this->className($class); + $this->files[$name] = $this->write( + $class . '.php', + "namespace . ";\n\n" . $declaration . "\n", + ); + + return $name; + } + + /** + * @return class-string + */ + private function className(string $class): string + { + return $this->namespace . '\\' . $class; + } + + /** + * @param list $classes Short names in this test's namespace + */ + private function record(string $package, ?string $reference, array $classes): PackageRecord + { + $map = []; + $attributeFiles = []; + + foreach ($classes as $class) { + $file = $this->files[$this->className($class)]; + $map[$this->className($class)] = $file; + + if (true === SourceParser::parse((string) file_get_contents($file))?->hasAttributes) { + $attributeFiles[$file] = true; + } + } + + ksort($map); + + return new PackageRecord( + new PackageDeclaration($package, null, $reference, 'library', false, false, null, null, []), + $this->directory, + new ClassMap($map, $attributeFiles), + ); + } + + private function identity(CollectedAttribute $attribute): CollectedAttribute + { + return $attribute; + } + + /** + * @param list $attributes + * + * @return list + */ + private function values(array $attributes): array + { + return array_map(static fn(CollectedAttribute $attribute): string => $attribute->instance->value, $attributes); + } + + /** + * @param list $diagnostics + * + * @return list + */ + private function diagnostics(array $diagnostics): array + { + return array_map( + fn(Diagnostic $diagnostic): string => $diagnostic->sequence . '|' . $diagnostic->package . '|' + . str_replace($this->namespace . '\\', '', $diagnostic->class) . '|' . $diagnostic->type, + $diagnostics, + ); + } +} diff --git a/tests/Unit/Generator/AutoloadRuleParserTest.php b/tests/Unit/Generator/AutoloadRuleParserTest.php new file mode 100644 index 0000000..1280107 --- /dev/null +++ b/tests/Unit/Generator/AutoloadRuleParserTest.php @@ -0,0 +1,222 @@ +directory . '/' . $directory, 0o700, true)); + } + + $this->write('class-file.php', 'write('functions.php', 'autoload([ + 'psr-4' => [ + 'App\\' => [ + 'src/', + 'src/', + 'lib/', + ], + '' => '', + ], + 'psr-0' => [ + 'Legacy\\' => 'lib/', + 'Pear_' => 'pear/', + 'UniqueClass' => '', + '' => 'src/', + ], + 'classmap' => [ + 'class-dir/', + 'class-file.php', + 'addons/*/lib/', + 'missing/', + ], + 'files' => [ + 'functions.php', + ], + 'exclude-from-classmap' => [ + '/Tests/', + '**/Test/', + 'cache/*', + ], + ]), $this->directory); + + self::assertSame([ + 'classmap|-|addons/*/lib|glob', + 'classmap|-|class-dir|directory', + 'classmap|-|class-file.php|file', + 'psr-0|Legacy\\|lib|directory', + 'psr-0|Pear_|pear|directory', + 'psr-0|UniqueClass|.|directory', + 'psr-0||src|directory', + 'psr-4|App\\|lib|directory', + 'psr-4|App\\|src|directory', + 'psr-4|App\\|src|directory', + 'psr-4||.|directory', + ], $this->keys($rules->inclusions)); + self::assertSame([ + '**/Test/', + '/Tests/', + 'cache/*', + ], $rules->exclusions); + self::assertTrue($rules->isExcluded('Tests/Example.php')); + self::assertFalse($rules->isExcluded('src/Tests/Example.php')); + self::assertFalse($rules->isExcluded('TestsExtra/Example.php')); + self::assertFalse($rules->isExcluded('Test/Example.php')); + self::assertTrue($rules->isExcluded('src/Test/Example.php')); + self::assertTrue($rules->isExcluded('cache/unit/deep/File.php')); + self::assertFalse($rules->isExcluded('src/cache/unit/File.php')); + self::assertTrue($rules->isExcluded('src\\Test\\Example.php')); + self::assertFalse($rules->isExcluded('../Tests/Example.php')); + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testRejectsUnsafeAndMalformedDeclarations(): void + { + self::assertTrue(mkdir($this->directory . '/src')); + + foreach ([ + 'PSR-4 prefix' => [ + 'psr-4' => [ + 'MissingSlash' => 'src/', + ], + ], + 'mapping list' => [ + 'psr-4' => [ + 'Valid\\' => [], + ], + ], + 'absolute path' => [ + 'psr-4' => [ + 'Valid\\' => '/tmp/src', + ], + ], + 'Windows absolute path' => [ + 'psr-4' => [ + 'Valid\\' => 'C:\\tmp\\src', + ], + ], + 'parent path' => [ + 'classmap' => [ + '../outside', + ], + ], + 'null byte' => [ + 'files' => [ + "safe\0unsafe.php", + ], + ], + 'unsupported wildcard' => [ + 'classmap' => [ + 'src/?/lib', + ], + ], + 'unsafe exclusion' => [ + 'exclude-from-classmap' => [ + '../outside', + ], + ], + 'Windows absolute exclusion' => [ + 'exclude-from-classmap' => [ + '/C:/outside', + ], + ], + 'classmap object' => [ + 'classmap' => [ + 'key' => 'src/', + ], + ], + ] as $label => $autoload) { + try { + AutoloadRuleParser::parse($this->autoload($autoload), $this->directory); + self::fail('Accepted ' . $label . '.'); + } catch (GeneratorException $exception) { + if ('PSR-4 prefix' === $label) { + self::assertStringContainsString('must end in a backslash', $exception->getMessage()); + } + } + } + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testRejectsSymlinkEscapesForDirectPathsAndClassmapGlobs(): void + { + foreach ([ + 'escape', + 'glob/*', + ] as $index => $declaredPath) { + $root = $this->directory . '/project-' . $index; + $outside = $this->directory . '/outside-' . $index; + self::assertTrue(mkdir($root . '/glob', 0o700, true) && mkdir($outside)); + self::assertTrue( + symlink($outside, 'glob/*' === $declaredPath ? $root . '/glob/outside' : $root . '/escape'), + ); + + try { + AutoloadRuleParser::parse($this->autoload([ + 'classmap' => [ + $declaredPath, + ], + ]), $root); + self::fail('Accepted the escaping path ' . $declaredPath . '.'); + } catch (GeneratorException $exception) { + self::assertStringContainsString('outside the package root', $exception->getMessage()); + } + } + } + + /** + * @param array $autoload + * + * @throws JsonException When the fixture cannot be encoded + */ + private function autoload(array $autoload): stdClass + { + return json_decode(json_encode($autoload, JSON_THROW_ON_ERROR), false, 512, JSON_THROW_ON_ERROR); + } + + /** + * @param list $rules + * + * @return list + */ + private function keys(array $rules): array + { + $keys = array_map( + static fn(ScanRule $rule): string => $rule->source->value . '|' . ($rule->namespacePrefix ?? '-') . '|' + . $rule->relativePath . '|' . $rule->kind->value, + $rules, + ); + sort($keys, SORT_STRING); + + return $keys; + } +} diff --git a/tests/Unit/Generator/BuildLockTest.php b/tests/Unit/Generator/BuildLockTest.php new file mode 100644 index 0000000..726d47b --- /dev/null +++ b/tests/Unit/Generator/BuildLockTest.php @@ -0,0 +1,43 @@ +directory . '/vendor'); + + self::assertFileExists($this->directory . '/vendor/.cache/skernel.lock'); + self::assertFalse($this->lockedElsewhere()); + + $lock->release(); + $lock->release(); + + self::assertTrue($this->lockedElsewhere()); + } + + /** + * Tries to take the lock without waiting in a separate PHP process. + */ + private function lockedElsewhere(): bool + { + $process = new Process([ + PHP_BINARY, + '-r', + 'exit(flock(fopen(' . var_export($this->directory . '/vendor/.cache/skernel.lock', true) + . ', "c"), LOCK_EX | LOCK_NB) ? 0 : 1);', + ]); + + return 0 === $process->run(); + } +} diff --git a/tests/Unit/Generator/ChildProcessTest.php b/tests/Unit/Generator/ChildProcessTest.php new file mode 100644 index 0000000..547f596 --- /dev/null +++ b/tests/Unit/Generator/ChildProcessTest.php @@ -0,0 +1,139 @@ +project(null); + $report = $this->generate(); + + self::assertSame(ReportStatus::ProtocolMismatch, $report->status); + self::assertNull($report->projectProtocol); + self::assertStringContainsString('does not install super-kernel/metadata', (string) $report->message); + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testReportsAnotherProtocol(): void + { + $this->project(2); + $report = $this->generate(); + + self::assertSame(ReportStatus::ProtocolMismatch, $report->status); + self::assertSame(2, $report->projectProtocol); + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testReportsTheClassThatEndedTheProcessAndWritesNothing(): void + { + $this->project(1); + $this->write('src/Broken.php', <<<'PHP' + generate(); + + self::assertSame(ReportStatus::ClassFatal, $report->status); + self::assertSame('acme/app', $report->package); + self::assertSame('App\\Broken', $report->class); + self::assertStringContainsString('abstract method', (string) $report->message); + self::assertDirectoryDoesNotExist($this->directory . '/vendor/.cache'); + } + + public function testRejectsAWrongInvocation(): void + { + $process = new Process([ + PHP_BINARY, + dirname(__DIR__, 3) . '/generator/generate.php', + ]); + + self::assertSame(64, $process->run()); + self::assertStringContainsString('Usage: generate.php', $process->getErrorOutput()); + } + + private function generate(): Report + { + $request = $this->directory . '/request.json'; + $reportPath = $this->directory . '/report.json'; + new Request($this->directory, $this->directory . '/vendor', '1.0.0')->write($request); + $process = new Process([ + PHP_BINARY, + dirname(__DIR__, 3) . '/generator/generate.php', + $request, + $reportPath, + ], $this->directory); + + $process->run(); + $report = Report::read($reportPath); + self::assertNotNull($report, $process->getErrorOutput()); + self::assertSame('', $process->getErrorOutput()); + + return $report; + } + + /** + * Writes a project whose fake autoloader maps `App\` to `src/` and, given a protocol, declares it with stand-ins + * for the metadata models that the generator builds before it scans. + * + * @throws JsonException When a fixture cannot be encoded + */ + private function project(?int $protocol): void + { + $this->writeJson('composer.json', [ + 'name' => 'acme/app', + 'autoload' => [ + 'psr-4' => [ + 'App\\' => 'src/', + ], + ], + ]); + $this->writeJson('composer.lock', [ + 'packages' => [], + 'packages-dev' => [], + ]); + $this->write('vendor/composer/installed.php', " ['dev' => true], 'versions' => []];"); + $this->write('vendor/autoload.php', sprintf(<<<'PHP' + directory . '/src/', true), var_export($protocol, true))); + } +} diff --git a/tests/Unit/Generator/ClassMapScannerTest.php b/tests/Unit/Generator/ClassMapScannerTest.php new file mode 100644 index 0000000..7813d14 --- /dev/null +++ b/tests/Unit/Generator/ClassMapScannerTest.php @@ -0,0 +1,94 @@ +write('src/Declarations.source', <<<'PHP' + write('src/Rejected.source', 'directory, new ScanRuleSet([ + new ScanRule('src', ScanRuleSource::Classmap, ScanRuleKind::Directory, null), + ])); + + self::assertSame([ + 'Acme\\Multiple\\Behavior' => $source, + 'Acme\\Multiple\\Contract' => $source, + 'Acme\\Multiple\\Service' => $source, + 'Acme\\Multiple\\State' => $source, + ], $classMap->classes); + self::assertSame([], $classMap->attributeFiles); + } + + public function testExplicitClassmapWinsOverPsrDirectoryCandidatesAndAcceptsArbitrarySuffixes(): void + { + $feature = $this->write('Feature.source', 'write('Resources/Deprecated.source', 'write('Resources/stubs/Deprecated.source', ' $feature, + 'Deprecated' => $stub, + ], ClassMapScanner::scan($this->directory, new ScanRuleSet([ + new ScanRule('.', ScanRuleSource::Psr4, ScanRuleKind::Directory, 'Acme\\Polyfill\\'), + new ScanRule('Resources/stubs', ScanRuleSource::Classmap, ScanRuleKind::Directory, null), + ]))->classes); + } + + public function testPsr4OutranksPsr0AndPrefixesLimitWhatARuleClaims(): void + { + $file = $this->write('src/Acme/Service.php', <<<'PHP' + write('src/Other.php', 'directory, new ScanRuleSet([ + new ScanRule('src', ScanRuleSource::Psr0, ScanRuleKind::Directory, ''), + new ScanRule('src', ScanRuleSource::Psr4, ScanRuleKind::Directory, 'Acme\\'), + ])); + + self::assertSame([ + 'Acme\\Service' => $file, + 'Other\\Other' => $this->directory . '/src/Other.php', + ], $classMap->classes); + self::assertSame([ + $file => true, + ], $classMap->attributeFiles); + } + + public function testExcludedFilesContributeNoClass(): void + { + $this->write('src/Tests/ExampleTest.php', 'write('src/Example.php', ' $kept, + ], ClassMapScanner::scan($this->directory, new ScanRuleSet([ + new ScanRule('src', ScanRuleSource::Classmap, ScanRuleKind::Directory, null), + ], [ + '**/Tests/', + ]))->classes); + } +} diff --git a/tests/Unit/Generator/DiagnosticFormatterTest.php b/tests/Unit/Generator/DiagnosticFormatterTest.php new file mode 100644 index 0000000..45a2892 --- /dev/null +++ b/tests/Unit/Generator/DiagnosticFormatterTest.php @@ -0,0 +1,37 @@ +directory . '/request.json'; + new Request('/project', '/project/vendor', '1.0.0', [ + 'App\\Broken' => 'Cannot redeclare', + ])->write($path); + $request = Request::read($path); + + self::assertSame('/project', $request->projectDirectory); + self::assertSame('/project/vendor', $request->vendorDirectory); + self::assertSame('1.0.0', $request->skernelVersion); + self::assertSame([ + 'App\\Broken' => 'Cannot redeclare', + ], $request->fatalClasses); + + $this->write('invalid.json', '{"project": "/project"}'); + $this->expectException(GeneratorException::class); + Request::read($this->directory . '/invalid.json'); + } + + public function testReportsRoundTripAndAMissingReportReadsAsNone(): void + { + $path = $this->directory . '/report.json'; + Report::built(new GenerationResult(3, 12, [ + new Diagnostic(1, 'acme/app', 'App\\Broken', 'Error', "Multi\nline"), + ]))->write($path); + $report = Report::read($path); + + self::assertNotNull($report); + self::assertSame(ReportStatus::Built, $report->status); + self::assertSame(3, $report->packages); + self::assertSame(12, $report->classes); + self::assertEquals([ + new Diagnostic(1, 'acme/app', 'App\\Broken', 'Error', "Multi\nline"), + ], $report->diagnostics); + + new Report(ReportStatus::ClassFatal, 'Fatal', 'acme/app', 'App\\Broken')->write($path); + $report = Report::read($path); + self::assertSame(ReportStatus::ClassFatal, $report?->status); + self::assertSame('acme/app', $report->package); + self::assertSame('App\\Broken', $report->class); + self::assertSame('Fatal', $report->message); + + self::assertNull(Report::read($this->directory . '/missing.json')); + $this->write('empty.json', ''); + self::assertNull(Report::read($this->directory . '/empty.json')); + $this->write('invalid.json', '{"status": "unknown"}'); + $this->expectException(GeneratorException::class); + Report::read($this->directory . '/invalid.json'); + } + + public function testProtocolMismatchesNameTheSideToUpgrade(): void + { + self::assertSame( + 'The project does not install super-kernel/metadata, so SKERNEL cannot publish its build artifacts. ' + . 'Require SuperKernel 1.0.0-alpha.2 or later.', + new ProtocolMismatchException(null, 1)->getMessage(), + ); + self::assertSame( + 'The project\'s super-kernel/metadata uses build protocol 2, but this SKERNEL generates protocol 1. ' + . 'Upgrade SKERNEL, for example with "skernel self-update".', + new ProtocolMismatchException(2, 1)->getMessage(), + ); + self::assertSame( + 'The project\'s super-kernel/metadata uses build protocol 1, but this SKERNEL generates protocol 2. ' + . 'Upgrade the project\'s SuperKernel packages, or use a SKERNEL release that generates protocol 1.', + new ProtocolMismatchException(1, 2)->getMessage(), + ); + } +} diff --git a/tests/Unit/Generator/GeneratorTestCase.php b/tests/Unit/Generator/GeneratorTestCase.php new file mode 100644 index 0000000..e1f74fe --- /dev/null +++ b/tests/Unit/Generator/GeneratorTestCase.php @@ -0,0 +1,70 @@ +directory = (string) realpath($directory); + } + + protected function tearDown(): void + { + Directory::remove($this->directory); + } + + /** + * Writes a file below the temporary directory, creating its parents. + */ + protected function write(string $path, string $contents): string + { + $file = $this->directory . '/' . $path; + + if (!is_dir(dirname($file))) { + self::assertTrue(mkdir(dirname($file), 0o700, true)); + } + + self::assertNotFalse(file_put_contents($file, $contents)); + + return $file; + } + + /** + * @param array $document + * + * @throws JsonException When the document cannot be encoded + */ + protected function writeJson(string $path, array $document): string + { + return $this->write($path, json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/tests/Unit/Generator/ProjectReaderTest.php b/tests/Unit/Generator/ProjectReaderTest.php new file mode 100644 index 0000000..80ae338 --- /dev/null +++ b/tests/Unit/Generator/ProjectReaderTest.php @@ -0,0 +1,399 @@ +fixture(false, false); + $records = $this->read(); + + self::assertSame([ + 'fixture/project', + 'fixture/production', + ], $this->names($records)); + self::assertSame([ + 'Application\\Example' => $this->directory . '/src/Example.php', + ], $records[0]->classMap->classes); + self::assertSame([ + 'Production\\Example' => $this->directory . '/vendor/fixture/production/src/Example.php', + ], $records[1]->classMap->classes); + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testDevelopmentInstallationIncludesDevelopmentPackagesAndRootDevelopmentClasses(): void + { + $this->fixture(true, true); + $records = $this->read(); + + self::assertSame([ + 'fixture/project', + 'fixture/development', + 'fixture/production', + ], $this->names($records)); + self::assertSame([ + 'ApplicationTests\\Example' => $this->directory . '/tests/Example.php', + 'Application\\Example' => $this->directory . '/src/Example.php', + ], $records[0]->classMap->classes); + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testExplicitlyInstalledDevelopmentPackageIsKeptInProductionMode(): void + { + $this->fixture(false, true, true); + + self::assertSame([ + 'fixture/project', + 'fixture/development', + 'fixture/production', + ], $this->names($this->read())); + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testMissingPackageDirectoriesAndInvalidInstallationStateAreFatal(): void + { + foreach ([ + 'missing production directory' => fn() => $this->fixture(false, false, false, false), + 'recorded development package' => fn() => $this->fixture(false, false, true), + 'development mode' => fn() => $this->fixture(true, false), + ] as $label => $fixture) { + $this->tearDown(); + $this->setUp(); + $fixture(); + + try { + $this->read(); + self::fail('Read a project with a ' . $label . '.'); + } catch (GeneratorException $exception) { + self::assertStringContainsString('is not installed', $exception->getMessage()); + } + } + + $this->tearDown(); + $this->setUp(); + $this->fixture(false, false); + $this->write('vendor/composer/installed.php', " []];"); + $this->expectException(GeneratorException::class); + $this->expectExceptionMessage('Invalid Composer installation state'); + $this->read(); + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testReadsDeclaredFieldsRequirementsReferencesAndMetapackages(): void + { + $this->writeJson('composer.json', [ + 'name' => 'Acme/Project', + 'type' => 'project', + 'require' => [ + 'php' => '>=8.4', + 'ext-json' => '*', + 'acme/library' => '^1.0', + ], + 'require-dev' => [ + 'ext-xdebug' => '*', + ], + ]); + $this->writeJson('composer.lock', [ + 'packages' => [ + [ + 'name' => 'acme/library', + 'version' => '1.2.3', + 'source' => [ + 'reference' => 'source-reference', + ], + 'dist' => [ + 'reference' => 'dist-reference', + ], + 'require' => [ + 'ext-swoole' => '^6.0', + ], + 'require-dev' => [ + 'ext-pcov' => '*', + ], + ], + [ + 'name' => 'acme/meta', + 'version' => '1.0.0', + 'type' => 'metapackage', + 'source' => [ + 'reference' => 'meta-reference', + ], + ], + ], + 'packages-dev' => [], + ]); + $this->installed(false, [ + 'acme/library', + 'acme/meta', + ]); + self::assertTrue(mkdir($this->directory . '/vendor/acme/library', 0o700, true)); + [$root, $library, $meta] = $this->read(); + + self::assertSame('acme/project', $root->declaration->name); + self::assertNull($root->declaration->version); + self::assertNull($root->declaration->reference); + self::assertSame('project', $root->declaration->composerType); + self::assertSame($this->directory, $root->locatedPath); + self::assertSame([ + 'ext-json|*|0', + 'ext-xdebug|*|1', + 'php|>=8.4|0', + ], $this->requirements($root)); + self::assertSame('1.2.3', $library->declaration->version); + self::assertSame('dist-reference', $library->declaration->reference); + self::assertSame('library', $library->declaration->composerType); + self::assertSame($this->directory . '/vendor/acme/library', $library->locatedPath); + self::assertSame([ + 'ext-swoole|^6.0|0', + ], $this->requirements($library)); + self::assertSame('meta-reference', $meta->declaration->reference); + self::assertNull($meta->locatedPath); + self::assertSame([], $meta->classMap->classes); + } + + /** @throws JsonException When a fixture cannot be encoded */ + public function testRejectsInvalidDeclarations(): void + { + foreach ([ + 'root without a name' => [ + [ + 'description' => 'A project without a name.', + ], + [], + 'must declare a string name', + ], + 'uppercase requirement' => [ + [ + 'name' => 'acme/project', + 'require' => [ + 'Acme/Library' => '^1.0', + ], + ], + [], + 'invalid or not lowercase', + ], + 'requirement in both sections' => [ + [ + 'name' => 'acme/project', + 'require' => [ + 'php' => '>=8.4', + ], + 'require-dev' => [ + 'php' => '>=8.4', + ], + ], + [], + 'both require and require-dev', + ], + 'target-dir' => [ + [ + 'name' => 'acme/project', + 'target-dir' => 'Legacy', + ], + [], + '"target-dir"', + ], + 'lock entry without a version' => [ + [ + 'name' => 'acme/project', + ], + [ + [ + 'name' => 'acme/library', + ], + ], + 'must declare a string version', + ], + 'duplicate lock entry' => [ + [ + 'name' => 'acme/project', + ], + [ + [ + 'name' => 'acme/project', + 'version' => '1.0.0', + ], + ], + 'more than once', + ], + 'invalid locked autoload' => [ + [ + 'name' => 'acme/project', + ], + [ + [ + 'name' => 'acme/library', + 'version' => '1.0.0', + 'autoload' => [ + 'psr-4' => [ + 'Acme\\' => '../outside', + ], + ], + ], + ], + 'Invalid autoload metadata for locked package "acme/library"', + ], + ] as $label => [$manifest, $packages, $message]) { + $this->tearDown(); + $this->setUp(); + $this->writeJson('composer.json', $manifest); + $this->writeJson('composer.lock', [ + 'packages' => $packages, + 'packages-dev' => [], + ]); + $this->installed(false, []); + self::assertTrue(mkdir($this->directory . '/vendor/acme/library', 0o700, true)); + + try { + $this->read(); + self::fail('Accepted a project with a ' . $label . '.'); + } catch (GeneratorException $exception) { + self::assertStringContainsString($message, $exception->getMessage(), $label); + } + } + } + + /** + * @return non-empty-list + * + * @throws GeneratorException When the project cannot be read + */ + private function read(): array + { + return ProjectReader::read($this->directory, $this->directory . '/vendor'); + } + + /** + * @param list $records + * + * @return list + */ + private function names(array $records): array + { + return array_map(static fn(PackageRecord $record): string => $record->declaration->name, $records); + } + + /** + * @return list + */ + private function requirements(PackageRecord $record): array + { + return array_map( + static fn(Requirement $requirement): string => $requirement->name . '|' . $requirement->constraint . '|' + . (int) $requirement->development, + $record->declaration->requirements, + ); + } + + /** + * Writes a project with one production and one development dependency. + * + * @throws JsonException When a fixture cannot be encoded + */ + private function fixture( + bool $development, + bool $developmentInstalled, + bool $recordDevelopment = false, + bool $productionInstalled = true, + ): void + { + $this->writeJson('composer.json', [ + 'name' => 'fixture/project', + 'autoload' => [ + 'psr-4' => [ + 'Application\\' => 'src/', + ], + ], + 'autoload-dev' => [ + 'psr-4' => [ + 'ApplicationTests\\' => 'tests/', + ], + ], + ]); + $this->writeJson('composer.lock', [ + 'packages' => [ + [ + 'name' => 'fixture/production', + 'version' => '1.0.0', + 'autoload' => [ + 'psr-4' => [ + 'Production\\' => 'src/', + ], + ], + ], + ], + 'packages-dev' => [ + [ + 'name' => 'fixture/development', + 'version' => '1.0.0', + 'autoload' => [ + 'psr-4' => [ + 'Development\\' => 'src/', + ], + ], + ], + ], + ]); + $this->write('src/Example.php', 'write('tests/Example.php', 'write( + 'vendor/fixture/production/src/Example.php', + 'write( + 'vendor/fixture/development/src/Example.php', + 'installed($development, $installed); + } + + /** + * @param list $packages + */ + private function installed(bool $development, array $packages): void + { + $versions = []; + + foreach ($packages as $package) { + $versions[$package] = [ + 'dev_requirement' => false, + ]; + } + + $this->write('vendor/composer/installed.php', ' [ + 'dev' => $development, + ], + 'versions' => $versions, + ], true) . ';'); + } +} diff --git a/tests/Unit/Generator/SourceParserTest.php b/tests/Unit/Generator/SourceParserTest.php new file mode 100644 index 0000000..ce9d8ff --- /dev/null +++ b/tests/Unit/Generator/SourceParserTest.php @@ -0,0 +1,100 @@ +classNames); + self::assertFalse($parsed->hasAttributes); + } + + public function testSupportsBracketedAndGlobalNamespaces(): void + { + self::assertSame([ + 'First\\Space\\One', + 'GlobalClass', + ], SourceParser::parse(<<<'PHP' + classNames); + } + + public function testTracksMultipleUnbracketedNamespacesAndDeduplicatesNames(): void + { + self::assertSame([ + 'First\\Space\\Duplicate', + 'First\\Space\\Zed', + 'Second\\Space\\Alpha', + ], SourceParser::parse(<<<'PHP' + classNames); + } + + public function testReportsWhetherTheSourceUsesNativeAttributes(): void + { + self::assertTrue(SourceParser::parse('hasAttributes); + self::assertFalse(SourceParser::parse('hasAttributes); + } + + public function testReturnsNullForEmptyOrDeclarationFreeSource(): void + { + self::assertNull(SourceParser::parse('')); + self::assertNull(SourceParser::parse('