From d5e748e54cc31109194de20df24d5e3110ccfd84 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 21 Sep 2026 14:18:21 +0800 Subject: [PATCH] feat(config): add project YAML include support with release configuration - Add project-release.yml with embedded vendor files for standalone builds - Implement YAML include mechanism in ProjectYamlLoader with circular reference detection - Support single file or list of files in include directive with ordered merging - Add recursive map merging and list replacement for included configurations - Create CompilerProjectConfigurationTest to verify release compiler embedding - Add ProjectYamlLoaderTest with comprehensive include scenarios - Update GitHub workflows to build standalone compiler using project-release.yml - Document include feature usage in README files with examples - Remove embedded-files from development project.yml to reduce initialization cost - Add validation for absolute paths and proper error handling for missing files --- .github/actions/unix-arm64-build/action.yml | 3 + .github/workflows/linux-x64.yml | 3 + .github/workflows/windows-build.yml | 8 ++ README-CN.md | 24 +++- README.md | 27 +++- .../src/CompilerProjectConfigurationTest.php | 26 ++++ phpunit/src/Config/ProjectYamlLoaderTest.php | 125 ++++++++++++++++++ project-release.yml | 7 + project.yml | 3 - src/Config/ProjectYamlLoader.php | 71 +++++++++- 10 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 phpunit/src/CompilerProjectConfigurationTest.php create mode 100644 phpunit/src/Config/ProjectYamlLoaderTest.php create mode 100644 project-release.yml diff --git a/.github/actions/unix-arm64-build/action.yml b/.github/actions/unix-arm64-build/action.yml index a4a3af3d..ab999d26 100644 --- a/.github/actions/unix-arm64-build/action.yml +++ b/.github/actions/unix-arm64-build/action.yml @@ -185,6 +185,9 @@ runs: shell: bash run: | composer install --no-dev --prefer-dist --no-progress --classmap-authoritative + php bin/tpc.php project-release.yml --job 2 --no-progress + test -x ./tpc + ./tpc --version export TYPEPHP_PACKAGE_VERSION="${GITHUB_REF_NAME}" php package.php test "$(find . -maxdepth 1 -name 'tpc_v*_${{ inputs.os }}_arm64.tar.gz' -type f | wc -l)" -eq 1 diff --git a/.github/workflows/linux-x64.yml b/.github/workflows/linux-x64.yml index ba5c510a..a294e020 100644 --- a/.github/workflows/linux-x64.yml +++ b/.github/workflows/linux-x64.yml @@ -472,6 +472,9 @@ jobs: shell: bash run: | composer install --no-dev --prefer-dist --no-progress --classmap-authoritative + php bin/tpc.php project-release.yml --job 2 --no-progress + test -x ./tpc + ./tpc --version export TYPEPHP_PACKAGE_VERSION="${GITHUB_REF_NAME}" php package.php test "$(find . -maxdepth 1 -name 'tpc_v*_linux_*.tar.gz' -type f | wc -l)" -eq 1 diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 496a5e64..7988ae9d 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -397,6 +397,14 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Production Composer install failed with exit code $LASTEXITCODE" } + php bin\tpc.php project-release.yml --job 1 --no-progress + if ($LASTEXITCODE -ne 0) { + throw "Standalone TypePHP release build failed with exit code $LASTEXITCODE" + } + & .\tpc.exe --version + if ($LASTEXITCODE -ne 0) { + throw "Standalone TypePHP release compiler failed with exit code $LASTEXITCODE" + } $env:TYPEPHP_PACKAGE_VERSION = $env:GITHUB_REF_NAME php package.php if ($LASTEXITCODE -ne 0) { diff --git a/README-CN.md b/README-CN.md index 069727fc..effe206d 100644 --- a/README-CN.md +++ b/README-CN.md @@ -337,7 +337,20 @@ ext-deps: - curl ``` -路径以 YAML 文件所在目录为基准。source 可以是文件或目录;条件 source 支持 +项目文件可以通过 `include` 复用公共配置: + +```yaml +include: project.yml +embedded-files: + - vendor +``` + +`include` 可填写一个 YAML 路径或路径列表。被包含文件按顺序合并,当前文件最后覆盖; +映射递归合并,列表整体替换。被包含文件可以继续包含下一级文件,但同一个规范化文件不能 +在一条尚未完成的 include 链中重复出现;已经完成解析的公共文件可以被后续 section 再次 +包含。项目中的相对路径统一以最外层项目文件为基准解析。 + +路径以最外层项目 YAML 文件所在目录为基准。source 可以是文件或目录;条件 source 支持 `PHP_VERSION`、`PHP_VERSION_ID` 和 `PHP_OS_FAMILY`。命令行参数优先于 YAML 中的同名配置。原生链接依赖应写入 `link-libs`;`ext-deps` 会生成 `ZEND_MOD_REQUIRED`,缺少所需 PHP 扩展时由 Zend 拒绝加载模块。 @@ -738,6 +751,15 @@ PHPX_HOME=/path/to/phpx php bin/tpc.php project.yml --job 2 --no-progress php run-tests.php -q -j8 --compiler ./tpc tests/compiler ``` +`project.yml` 有意从磁盘加载 Composer 依赖。PHPT 每个用例都会启动一次编译器, +如果把完整 Composer 运行时嵌入测试编译器,每个用例都会重复承担初始化开销。 +发布打包阶段安装生产依赖,并单独构建可独立运行的编译器: + +```bash +composer install --no-dev --classmap-authoritative +PHPX_HOME=/path/to/phpx php bin/tpc.php project-release.yml --job 2 --no-progress +``` + 静态分析与从测试源码生成的覆盖矩阵是两项独立检查: ```bash diff --git a/README.md b/README.md index fbbfba45..50f63656 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,22 @@ ext-deps: - curl ``` -Paths are resolved relative to the YAML file. A source entry may be a file or +Project files can reuse common settings with `include`: + +```yaml +include: project.yml +embedded-files: + - vendor +``` + +`include` accepts one YAML path or a list. Included files are applied in order, +then the current file overrides them. Maps are merged recursively and lists are +replaced as a whole. Included files may include another file, but the same +canonical file cannot appear twice in one active include chain; this rejects +cycles while allowing a completed common file to be included again by a later +section. Relative project paths are resolved against the outermost project file. + +Paths are resolved relative to the outermost project YAML file. A source entry may be a file or directory; conditional entries support `PHP_VERSION`, `PHP_VERSION_ID`, and `PHP_OS_FAMILY`. CLI arguments override their YAML counterparts. Native linker dependencies belong in `link-libs`; `ext-deps` writes `ZEND_MOD_REQUIRED` @@ -805,6 +820,16 @@ PHPX_HOME=/path/to/phpx php bin/tpc.php project.yml --job 2 --no-progress php run-tests.php -q -j8 --compiler ./tpc tests/compiler ``` +`project.yml` deliberately leaves Composer dependencies on disk. PHPT starts +the compiler once per test, so embedding the complete Composer runtime would +add its initialization cost to every case. Release packaging installs +production dependencies and builds the standalone compiler separately: + +```bash +composer install --no-dev --classmap-authoritative +PHPX_HOME=/path/to/phpx php bin/tpc.php project-release.yml --job 2 --no-progress +``` + Static analysis and the source-derived coverage matrix are separate checks: ```bash diff --git a/phpunit/src/CompilerProjectConfigurationTest.php b/phpunit/src/CompilerProjectConfigurationTest.php new file mode 100644 index 00000000..98cd8b9d --- /dev/null +++ b/phpunit/src/CompilerProjectConfigurationTest.php @@ -0,0 +1,26 @@ + throw new \RuntimeException($message), + ); + $development = $loader->load($root . '/project.yml'); + $release = $loader->load($root . '/project-release.yml'); + + $this->assertArrayNotHasKey('embedded-files', $development); + $this->assertSame(['./vendor'], $release['embedded-files'] ?? null); + + unset($release['embedded-files']); + $this->assertSame($development, $release); + } +} diff --git a/phpunit/src/Config/ProjectYamlLoaderTest.php b/phpunit/src/Config/ProjectYamlLoaderTest.php new file mode 100644 index 00000000..fddcf619 --- /dev/null +++ b/phpunit/src/Config/ProjectYamlLoaderTest.php @@ -0,0 +1,125 @@ +directory = sys_get_temp_dir() . '/typephp-project-yaml-' . bin2hex(random_bytes(6)); + mkdir($this->directory, 0777, true); + $this->loader = new ProjectYamlLoader( + '8.4.14', + static fn(string $message): never => throw new RuntimeException($message), + ); + } + + protected function tearDown(): void + { + foreach (glob($this->directory . '/*.yml') ?: [] as $file) { + unlink($file); + } + rmdir($this->directory); + } + + public function testIncludedConfigurationIsMergedBeforeRootConfiguration(): void + { + file_put_contents($this->directory . '/common.yml', <<<'YAML' +name: common +sources: + - src +defines: + - COMMON=1 +resource: + version-info: + company-name: Common Team + product-name: Common Product +YAML); + file_put_contents($this->directory . '/project.yml', <<<'YAML' +include: common.yml +name: app +defines: + - APP=1 +resource: + version-info: + company-name: Application Team +YAML); + + self::assertSame([ + 'name' => 'app', + 'sources' => ['src'], + 'defines' => ['APP=1'], + 'resource' => [ + 'version-info' => [ + 'company-name' => 'Application Team', + 'product-name' => 'Common Product', + ], + ], + ], $this->loader->load($this->directory . '/project.yml')); + } + + public function testMultipleIncludesAreAppliedInOrder(): void + { + file_put_contents($this->directory . '/first.yml', "name: first\noptimize: 1\n"); + file_put_contents($this->directory . '/second.yml', "name: second\njob: 4\n"); + file_put_contents($this->directory . '/project.yml', <<<'YAML' +include: + - first.yml + - second.yml +name: project +YAML); + + self::assertSame([ + 'name' => 'project', + 'optimize' => 1, + 'job' => 4, + ], $this->loader->load($this->directory . '/project.yml')); + } + + public function testNestedIncludesAreSupported(): void + { + file_put_contents($this->directory . '/grandparent.yml', "optimize: 2\n"); + file_put_contents($this->directory . '/parent.yml', "include: grandparent.yml\njob: 4\n"); + file_put_contents($this->directory . '/project.yml', "include: parent.yml\nname: app\n"); + + self::assertSame([ + 'optimize' => 2, + 'job' => 4, + 'name' => 'app', + ], $this->loader->load($this->directory . '/project.yml')); + } + + public function testCompletedIncludeCanBeLoadedAgain(): void + { + file_put_contents($this->directory . '/shared.yml', "optimize: 2\n"); + file_put_contents($this->directory . '/first.yml', "include: shared.yml\njob: 2\n"); + file_put_contents($this->directory . '/second.yml', "include: shared.yml\njob: 4\n"); + file_put_contents($this->directory . '/project.yml', <<<'YAML' +include: + - first.yml + - second.yml +YAML); + + self::assertSame([ + 'optimize' => 2, + 'job' => 4, + ], $this->loader->load($this->directory . '/project.yml')); + } + + public function testCircularIncludeIsRejectedWhileFileIsBeingParsed(): void + { + file_put_contents($this->directory . '/parent.yml', "include: project.yml\n"); + file_put_contents($this->directory . '/project.yml', "include: parent.yml\n"); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Circular project YAML include'); + $this->loader->load($this->directory . '/project.yml'); + } +} diff --git a/project-release.yml b/project-release.yml new file mode 100644 index 00000000..9bc99402 --- /dev/null +++ b/project-release.yml @@ -0,0 +1,7 @@ +include: project.yml + +# Release binaries carry their Composer runtime as embedded opcodes and files. +# Development and PHPT bootstrap builds use project.yml to avoid paying this +# initialization cost for every compiler process. +embedded-files: + - ./vendor diff --git a/project.yml b/project.yml index c61a5074..501a2aeb 100644 --- a/project.yml +++ b/project.yml @@ -35,9 +35,6 @@ sources: - vendor/nikic/php-parser/lib/PhpParser/NodeVisitor.php - vendor/nikic/php-parser/lib/PhpParser/NodeVisitorAbstract.php -embedded-files: - - ./vendor - ignore: - ./src/Assert.php - ./src/polyfills.php diff --git a/src/Config/ProjectYamlLoader.php b/src/Config/ProjectYamlLoader.php index 2652e000..d7fc97b5 100644 --- a/src/Config/ProjectYamlLoader.php +++ b/src/Config/ProjectYamlLoader.php @@ -22,11 +22,78 @@ final class ProjectYamlLoader public function load(string $path): array { - $config = Yaml::parseFile($path); + return $this->loadFile($path, []); + } + + /** @param list $stack */ + private function loadFile(string $path, array $stack): array + { + $canonicalPath = realpath($path); + if ($canonicalPath === false || !is_file($canonicalPath)) { + ($this->error)('Project YAML file does not exist: `' . $path . '`'); + } + $cycleAt = array_search($canonicalPath, $stack, true); + if ($cycleAt !== false) { + $cycle = array_slice($stack, $cycleAt); + $cycle[] = $canonicalPath; + ($this->error)('Circular project YAML include: ' . implode(' -> ', $cycle)); + } + $stack[] = $canonicalPath; + + $config = Yaml::parseFile($canonicalPath); if (!is_array($config)) { ($this->error)('Project YAML root must be a map'); } - return $config; + + $include = $config['include'] ?? []; + unset($config['include']); + if (is_string($include)) { + $include = [$include]; + } elseif (!is_array($include) || !array_is_list($include)) { + ($this->error)('`include` must be a YAML file path or a list of YAML file paths'); + } + + $merged = []; + foreach ($include as $includedPath) { + if (!is_string($includedPath) || trim($includedPath) === '') { + ($this->error)('Each `include` entry must be a non-empty string'); + } + $includedPath = trim($includedPath); + if (!$this->isAbsolutePath($includedPath)) { + $includedPath = dirname($canonicalPath) . DIRECTORY_SEPARATOR . $includedPath; + } + $merged = $this->mergeConfig( + $merged, + $this->loadFile($includedPath, $stack), + ); + } + + return $this->mergeConfig($merged, $config); + } + + private function isAbsolutePath(string $path): bool + { + return $path !== '' && ( + $path[0] === '/' + || $path[0] === '\\' + || preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1 + ); + } + + private function mergeConfig(array $base, array $override): array + { + foreach ($override as $key => $value) { + if (isset($base[$key]) + && is_array($base[$key]) + && !array_is_list($base[$key]) + && is_array($value) + && !array_is_list($value)) { + $base[$key] = $this->mergeConfig($base[$key], $value); + } else { + $base[$key] = $value; + } + } + return $base; } /** @return array{0: string, 1: string|null} */