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
master
韩天峰 3 weeks ago
parent cf1f6fb97d
commit d5e748e54c
  1. 3
      .github/actions/unix-arm64-build/action.yml
  2. 3
      .github/workflows/linux-x64.yml
  3. 8
      .github/workflows/windows-build.yml
  4. 24
      README-CN.md
  5. 27
      README.md
  6. 26
      phpunit/src/CompilerProjectConfigurationTest.php
  7. 125
      phpunit/src/Config/ProjectYamlLoaderTest.php
  8. 7
      project-release.yml
  9. 3
      project.yml
  10. 71
      src/Config/ProjectYamlLoader.php

@ -185,6 +185,9 @@ runs:
shell: bash shell: bash
run: | run: |
composer install --no-dev --prefer-dist --no-progress --classmap-authoritative 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}" export TYPEPHP_PACKAGE_VERSION="${GITHUB_REF_NAME}"
php package.php php package.php
test "$(find . -maxdepth 1 -name 'tpc_v*_${{ inputs.os }}_arm64.tar.gz' -type f | wc -l)" -eq 1 test "$(find . -maxdepth 1 -name 'tpc_v*_${{ inputs.os }}_arm64.tar.gz' -type f | wc -l)" -eq 1

@ -472,6 +472,9 @@ jobs:
shell: bash shell: bash
run: | run: |
composer install --no-dev --prefer-dist --no-progress --classmap-authoritative 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}" export TYPEPHP_PACKAGE_VERSION="${GITHUB_REF_NAME}"
php package.php php package.php
test "$(find . -maxdepth 1 -name 'tpc_v*_linux_*.tar.gz' -type f | wc -l)" -eq 1 test "$(find . -maxdepth 1 -name 'tpc_v*_linux_*.tar.gz' -type f | wc -l)" -eq 1

@ -397,6 +397,14 @@ jobs:
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Production Composer install failed with exit code $LASTEXITCODE" 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 $env:TYPEPHP_PACKAGE_VERSION = $env:GITHUB_REF_NAME
php package.php php package.php
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {

@ -337,7 +337,20 @@ ext-deps:
- curl - 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 `PHP_VERSION`、`PHP_VERSION_ID` 和 `PHP_OS_FAMILY`。命令行参数优先于 YAML
中的同名配置。原生链接依赖应写入 `link-libs`;`ext-deps` 会生成 中的同名配置。原生链接依赖应写入 `link-libs`;`ext-deps` 会生成
`ZEND_MOD_REQUIRED`,缺少所需 PHP 扩展时由 Zend 拒绝加载模块。 `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 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 ```bash

@ -369,7 +369,22 @@ ext-deps:
- curl - 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 directory; conditional entries support `PHP_VERSION`, `PHP_VERSION_ID`, and
`PHP_OS_FAMILY`. CLI arguments override their YAML counterparts. Native linker `PHP_OS_FAMILY`. CLI arguments override their YAML counterparts. Native linker
dependencies belong in `link-libs`; `ext-deps` writes `ZEND_MOD_REQUIRED` 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 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: Static analysis and the source-derived coverage matrix are separate checks:
```bash ```bash

@ -0,0 +1,26 @@
<?php
namespace TypePhp\Tests;
use PHPUnit\Framework\TestCase;
use TypePhp\Config\ProjectYamlLoader;
final class CompilerProjectConfigurationTest extends TestCase
{
public function testOnlyReleaseCompilerEmbedsComposerRuntime(): void
{
$root = dirname(__DIR__, 2);
$loader = new ProjectYamlLoader(
PHP_VERSION,
static fn(string $message): never => 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);
}
}

@ -0,0 +1,125 @@
<?php
namespace TypePhp\Tests\Config;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use TypePhp\Config\ProjectYamlLoader;
final class ProjectYamlLoaderTest extends TestCase
{
private string $directory;
private ProjectYamlLoader $loader;
protected function setUp(): void
{
$this->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');
}
}

@ -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

@ -35,9 +35,6 @@ sources:
- vendor/nikic/php-parser/lib/PhpParser/NodeVisitor.php - vendor/nikic/php-parser/lib/PhpParser/NodeVisitor.php
- vendor/nikic/php-parser/lib/PhpParser/NodeVisitorAbstract.php - vendor/nikic/php-parser/lib/PhpParser/NodeVisitorAbstract.php
embedded-files:
- ./vendor
ignore: ignore:
- ./src/Assert.php - ./src/Assert.php
- ./src/polyfills.php - ./src/polyfills.php

@ -22,11 +22,78 @@ final class ProjectYamlLoader
public function load(string $path): array public function load(string $path): array
{ {
$config = Yaml::parseFile($path); return $this->loadFile($path, []);
}
/** @param list<string> $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)) { if (!is_array($config)) {
($this->error)('Project YAML root must be a map'); ($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} */ /** @return array{0: string, 1: string|null} */

Loading…
Cancel
Save