diff --git a/README.md b/README.md index fc02616..f318768 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ php webman typephp:doctor # 默认输出到 dist/ php webman typephp:package +# SaiAdmin:自动发现核心、app、support 与已安装插件服务端业务代码 +php webman typephp:package --profile=saiadmin + # dist/ 已存在时,显式确认覆盖 php webman typephp:package --force @@ -67,6 +70,9 @@ php webman typephp:package --refresh-main 默认 builder 为 `tinywan/typephp-webman-builder:v0.1.3`。编译在 Docker 中完成,宿主机不需要 C++、Clang 或 TypePHP 编译器。 +SaiAdmin 的支持矩阵、开发规范、存量迁移、配置样例、验收脚本和实跑证据见 +[`docs/saiadmin-aot/`](docs/saiadmin-aot/README.md)。 + ### 4. 启动产物 将 `dist/` 复制到兼容的 Linux x86_64/glibc 服务器,在目录内启动: @@ -106,6 +112,7 @@ dist/ ├── lib/ # 随包发布的底层系统与扩展动态依赖库 (ldd 完整收集) ├── runtime/ # 运行时缓存与日志目录 (logs, views) ├── build-manifest.json # 输入、镜像与时间等构建元数据 +├── source-coverage.json # SaiAdmin profile 的逐业务文件 AOT 覆盖清单 ├── config/ # 项目运行时配置(若存在) ├── public/ # 静态资源(若存在) └── app/view/ # 视图模板(若存在) @@ -118,6 +125,7 @@ dist/ | 命令 | 说明 | | --- | --- | | `php webman typephp:package` | 使用默认 builder 构建 Linux portable-dir | +| `php webman typephp:package --profile=saiadmin` | 使用锁版本、失败关闭的 SaiAdmin 自动发现与兼容规则构建 | | `php webman typephp:package --force` | 覆盖已有输出,并保留旧目录备份 | | `php webman typephp:package --refresh-main` | 强制从最新官方 stub 刷新 `main.php`(旧文件自动备份) | | `php webman typephp:package --image=...` | 使用指定且经过验证的 Docker 镜像 | @@ -145,6 +153,12 @@ return [ `--image` 的优先级最高;未指定时使用上述 `docker.image`,配置缺失时才回退至 `tinywan/typephp-webman-builder:v0.1.3`。 +### SaiAdmin profile + +`--profile=saiadmin` 要求存在 `plugin/saiadmin` 和有效的 `composer.lock`。当前首个支持矩阵固定为 SaiAdmin `6.1.1`、ThinkORM `v3.0.34`、Carbon `3.13.2`;未知版本会在进入 Docker 编译前失败。 + +profile 会自动发现根 `app/`、`support/`、SaiAdmin 核心和每个 `plugin/*/app/`,并编译完整 Composer 依赖树。兼容副本只写入 `.typephp/build/`,普通 PHP 源码不变。构建输出中的 `source-coverage.json` 逐项记录业务 PHP 是直接编译还是由哪个 AOT 副本替代;业务文件未分类、被排除却没有等价副本,或漂移到未知依赖版本时都会终止构建。 + ## 🎯 可信 MVP 边界 当前第一阶段只承诺已经验证的组合: diff --git a/docker/AssignOpTrait.php b/docker/AssignOpTrait.php index 7edaefa..e619052 100644 --- a/docker/AssignOpTrait.php +++ b/docker/AssignOpTrait.php @@ -287,20 +287,18 @@ protected function parseAssignToList(Expr $left, Expr $right): string continue; } if ($item instanceof ArrayItem) { - $key = $item->key ? $this->parseArrayKey($item->key) : (string) $k; + $value = new Expr\ArrayDimFetch( + new Variable($tmpVar), + $item->key ?? new Node\Scalar\Int_($k), + ); if ($item->value instanceof Expr\List_) { - $nestedTmp = $this->genTmpVarName(); - $this->addLocalVar($nestedTmp, Type::ARRAY); - $code .= $this->getIndent() . "{$nestedTmp} = {$tmpVar}.item({$key});" . PHP_EOL; $code .= $this->getIndent() - . $this->parseAssignToList($item->value, new Variable($nestedTmp)) + . $this->parseAssignToList($item->value, $value) . PHP_EOL; } else { - $var = $this->parseWritableIdentifier($item->value); - if ($this->isVarExpr($item->value) and !$this->hasVar($var)) { - $this->addLocalVar($var, Type::VAR); - } - $code .= $this->getIndent() . "{$var} = {$tmpVar}.item({$key});" . PHP_EOL; + $code .= $this->getIndent() + . $this->parseAssignFinally($item->value, $value) + . ';' . PHP_EOL; } } else { $this->unsupportedSyntax($item); @@ -885,7 +883,7 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string return $pythonOperator; } $propertyWriteTarget = $this->preparePropertyWriteTarget($node->var); - $this->guardLiteralDivisionByZero($node->expr, $op); + $this->guardLiteralDivisionByZero($node->var, $node->expr, $op); // A compound division/modulo on a NATIVE scalar slot with a proven // zero divisor cannot fall through to the raw C++ operator (SIGFPE @@ -894,7 +892,7 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string // lower the whole expression to the PHP-semantics binary operation // and leave the target untouched. if (($op === '/=' || $op === '%=') - && !$this->nativeTypes + && $this->varIntTypes && $this->isZeroLiteral($node->expr) && $this->isVarExpr($node->var) && $this->hasVar((string) $this->parseIdentifier($node->var)) @@ -903,7 +901,7 @@ protected function parseAssignOp(Expr\AssignOp $node, string $op): string // std::int()/std::float() values are an explicit opt-in to native // C++ arithmetic; changing them to PHP semantics here would be as // wrong as the undefined raw operation. Keep the compile-time - // rejection native_types mode uses. + // rejection varint_types mode uses. if ($this->isExplicitNativeArithmeticExpr($node->var)) { $this->fatalError($node->expr, 'Cannot divide or modulo by zero'); } @@ -1201,7 +1199,7 @@ protected function parseNativePropertyAssignOp(Expr\AssignOp $node, string $op): // A direct zend_long reference would bypass that behavior completely. // Native objects cannot cross the Variant boundary and retain their // native C++ property access path. - if (!$this->nativeTypes + if ($this->varIntTypes && $def->type === Type::INT && !$this->isNativeObjectClass($this->detectClassOfExpr($node->var->var)) && in_array($op, ['+=', '-=', '*=', '/=', '%=', '**=', '<<=', '>>=', '&=', '|=', '^='], true) diff --git a/docker/CompilerBase.php b/docker/CompilerBase.php index de45868..15f0820 100644 --- a/docker/CompilerBase.php +++ b/docker/CompilerBase.php @@ -153,6 +153,83 @@ class CompilerBase implements PropertyAccessContext use LoopVarOptimizer; use SsaPropOptimizer; + /** + * Dynamic reference slots can contain Throwable objects. Defer their + * validation to throwValue(), just like ordinary dynamic variables. + */ + protected function parseThrow(mixed $expr): string + { + if ($this->method === '__destruct') { + $this->warning($expr, "Throwing exception in {$this->getFullClassName()}::__destruct() may cause memory leak"); + } + $class = $this->detectDeclaredClassOfExpr($expr->expr); + if ($this->isNativeObjectClass($class)) { + $this->fatalError($expr, 'Native objects cannot be thrown as Zend exceptions'); + } + $type = $this->detectTypeOfExpr($expr->expr); + if ($this->isNewExpr($expr->expr)) { + $ex = $this->parseExpr($expr->expr); + return 'php::throwException(' . $ex . ')'; + } + if ($this->isVarExpr($expr->expr)) { + $ex = $this->parseIdentifier($expr->expr); + if ($type === Type::OBJECT) { + return 'php::throwException(' . $ex . ')'; + } + } else { + $ex = $this->parseExpr($expr->expr); + } + if (!in_array($type, [Type::VAR, Type::REF, Type::OBJECT], true) && $class === '') { + $this->fatalError($expr, 'Can only throw objects'); + } + return 'php::throwValue(' . $ex . ')'; + } + + /** + * Parameterized toArray() methods on ordinary PHP classes are application + * APIs, not TypePHP conversion hooks (for example JsonResource::toArray(Request)). + */ + protected function assertKeywordConversionMethodSignature( + NodeAbstract $node, + string $class, + string $method, + FunctionDef $function, + string $expectedType, + bool $nativeClass, + ): void { + $kind = $nativeClass ? 'Native conversion method' : 'Conversion method'; + if ($function->argInfoList !== []) { + if (!$nativeClass && strtolower($method) === 'toarray') { + return; + } + $this->fatalError($node, "{$kind} `{$class}::{$method}()` must not accept arguments"); + } + $hasExactReturnType = $function->returnType === $expectedType; + if ($expectedType === Type::VAR) { + $hasExactReturnType = in_array(strtolower($function->returnTypeStr), ['mixed', 'any'], true); + } + if ($function->returnsByRef || $function->returnNullable || !$hasExactReturnType) { + $expectedTypeName = match ($expectedType) { + Type::INT => 'int', + Type::FLOAT => 'float', + Type::STR => 'string', + Type::BOOL => 'bool', + Type::ARRAY => 'array', + Type::STREAM => 'Stream', + Type::BIGINT => 'BigInt', + Type::BIGFLOAT => 'BigFloat', + Type::DECIMAL => 'Decimal', + Type::OBJECT => 'object', + Type::VAR => 'mixed` or `any', + default => $expectedType, + }; + $this->fatalError( + $node, + "{$kind} `{$class}::{$method}()` must return exactly `{$expectedTypeName}`", + ); + } + } + public const string DEFAULT_PHP_VERSION = '8.5'; protected const string NATIVE_PROPERTY_VALUE_VAR = 'var'; protected const string NATIVE_PROPERTY_VALUE_DYNAMIC = 'dynamic'; @@ -490,7 +567,7 @@ protected function getBoolValue(Expr\ConstFetch $expr): string protected array $nativeClassDeclarations = []; /** @var array Request-reset initialization flags for Native static locals. */ protected array $nativeStaticInitializers = []; - protected bool $nativeTypes = false; + protected bool $varIntTypes = false; protected bool $decimalTypes = false; protected bool $bigintTypes = false; protected string $rootPath; @@ -997,7 +1074,7 @@ protected function removeCommonPrefix(string $short, string $long): string return $this->getPlatform()->removeCommonPrefix($short, $long); } - protected function getVarType(string $name): string + protected function getRawVarType(string $name): string { if ($this->hasLocalVar($name)) { return $this->context->localVars[$name]; @@ -1009,6 +1086,16 @@ protected function getVarType(string $name): string return Type::VAR; } + /** + * Return the value type visible to expressions. A native C++ reference has + * the same operators and assignment rules as its referenced value; only + * ABI/binding code should inspect getRawVarType(). + */ + protected function getVarType(string $name): string + { + return Type::getReferencedType($this->getRawVarType($name)); + } + /** * Resolve the ClassDef for an object expression (variable or $this). */ @@ -1071,7 +1158,7 @@ protected function resetClass(): void protected function resetFile(): void { $this->indentLevel = 0; - $this->nativeTypes = false; + $this->varIntTypes = false; $this->decimalTypes = false; $this->bigintTypes = false; $this->classesDefineInFile = []; @@ -1348,6 +1435,21 @@ protected function getFunctionCallCache(): string return 'typephp_get_function_call_cache(FunctionCallCacheId{' . $id . '})'; } + /** Return the function-local late-static-bound class entry. */ + protected function getCalledCeExpr(): string + { + $this->context->needsCalledCe = true; + return '_typephp_called_ce'; + } + + /** Return the function-local late-static-bound class name. */ + protected function getCalledClassExpr(): string + { + $this->context->needsCalledCe = true; + $this->context->needsCalledClass = true; + return '_typephp_called_class'; + } + protected function getClassEntryPtr(string $className): string { $id = $this->getClassId($className); @@ -2499,9 +2601,9 @@ protected function parseReturn(Node\Stmt\Return_ $v): string // runtime overflow promotes the result to float. Keep the Variant // representation through the return boundary so a declared scalar // return type observes and rejects that float exactly as PHP does. - // `use native_types` intentionally opts into native C++ arithmetic + // `use varint_types` intentionally opts into native C++ arithmetic // semantics and is therefore excluded from this check. - if (!$this->nativeTypes && $type === Type::INT && $this->exprCanOverflowInt($v->expr)) { + if ($this->varIntTypes && $type === Type::INT && $this->exprCanOverflowInt($v->expr)) { $type = Type::VAR; } $nativeExpressionClass = $this->detectClassOfExpr($v->expr); @@ -2831,9 +2933,21 @@ protected function getNativeMethod(CallLike $expr, string $class, string $method } if (!$this->hasClass($classDef->extends)) { if ($classDef->inheritedFromInternalClass) { - if (!Reflection::hasMethod($classDef->extends, $method) and !Reflection::hasMethod($classDef->extends, $method . '__call')) { + $lateStaticCall = $expr instanceof Expr\StaticCall + && $this->isNameExpr($expr->class) + && strtolower($expr->class->toString()) === 'static'; + if ($lateStaticCall && !$this->isCurrentClassFinal()) { + return false; + } + $magicMethod = $expr instanceof Expr\StaticCall ? '__callStatic' : '__call'; + if ($classDef->hasMethod($magicMethod)) { + return false; + } + if (!Reflection::hasMethod($classDef->extends, $method) + && !Reflection::hasMethod($classDef->extends, $magicMethod) + ) { $this->fatalError($expr, 'Class `' . $classDef->getNamespacedName() . '` inherits from a internal class, but the class `' . - $classDef->extends . '` does not have a `' . $method . '` method or a `__call` magic method'); + $classDef->extends . '` does not have a `' . $method . '` method or a `' . $magicMethod . '` magic method'); } else { $this->climate->cyan('Dynamically calling internal class method `' . $classDef->extends . '::' . $method . '()`'); throw new DynamicCall(); @@ -3015,7 +3129,7 @@ protected function detectTypeOfExpr($expr): string case 'Expr_UnaryPlus': $innerType = $this->detectTypeOfExpr($expr->expr); if ( - !$this->nativeTypes + $this->varIntTypes && $exprType === 'Expr_UnaryMinus' && $innerType === Type::INT && $this->constantIntValue($expr->expr) === PHP_INT_MIN @@ -3103,7 +3217,7 @@ protected function detectTypeOfExpr($expr): string if ($leftType === Type::FLOAT || $rightType === Type::FLOAT) { return Type::FLOAT; } - if (!$this->nativeTypes && $leftType === Type::INT && $rightType === Type::INT) { + if ($this->varIntTypes && $leftType === Type::INT && $rightType === Type::INT) { $op = match ($exprType) { 'Expr_BinaryOp_Plus' => '+', 'Expr_BinaryOp_Minus' => '-', @@ -3936,7 +4050,7 @@ protected function parseNew(Expr\New_ $expr): string if ($this->classDef?->nativeObject) { $this->fatalError($expr, 'Native classes do not support `new static()`'); } - $cePtr = Symbol::getCalledCe(); + $cePtr = $this->getCalledCeExpr(); } else { if ($className === 'self') { $className = $this->getFullClassName(); @@ -4134,7 +4248,7 @@ protected function resolveInstanceofClassPtr(NodeAbstract $class): string if (!$this->classDef) { $this->fatalError($class, 'Cannot use "static" outside a class'); } - return Symbol::getCalledCe(); + return $this->getCalledCeExpr(); } else { $className = $this->getNamespacedClassName($className); } @@ -5217,6 +5331,16 @@ protected function genLocalVarDecl(array $localVars): string protected function genScopeVarDecl(): string { $code = ''; + if ($this->context->needsCalledCe) { + $code .= $this->getIndent() + . 'zend_class_entry *const _typephp_called_ce = typephp_get_called_ce(this_);' + . PHP_EOL; + } + if ($this->context->needsCalledClass) { + $code .= $this->getIndent() + . 'php::Str const _typephp_called_class = typephp_get_called_class(_typephp_called_ce);' + . PHP_EOL; + } if ($this->context->hasMultiLevelBreak) { $code .= $this->getIndent() . 'int _brk_flag = 0;' . PHP_EOL; } @@ -5276,13 +5400,11 @@ protected function genScopeVarDecl(): string $code .= $this->getIndent() . $info['type'] . ' &' . $name . ' = ' . $zvalMacro . '(' . $info['getter'] . '.unwrap_ptr());' . PHP_EOL; } } - foreach ($this->context->staticPropRefs as $name => $info) { - $getter = Symbol::getStaticProperty() . '(' . $info['classPtr'] . ', ' . $info['offsetExpr'] . ')'; - if (($info['kind'] ?? 'zval') === 'var') { - $code .= $this->getIndent() . Type::VAR . ' ' . $name . ' = ' . $getter . ';' . PHP_EOL; - } else { - $code .= $this->getIndent() . 'zval *' . $name . ' = ' . $getter . '.unwrap_ptr();' . PHP_EOL; - } + foreach ($this->context->staticPropRefs as $info) { + $code .= $this->getIndent() . 'zval *' . $info['name'] . ' = nullptr;' . PHP_EOL; + $code .= $this->getIndent() . 'const auto ' . $info['accessorName'] . ' = [&]() {' + . ' return typephp_get_static_property_cached(' . $info['name'] . ', [&]() {' + . ' return ' . $info['resolver'] . '; }); };' . PHP_EOL; } return $code; } diff --git a/docker/Dockerfile b/docker/Dockerfile index ea6cb37..04fa504 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -10,6 +10,10 @@ COPY CompilerDiagnosticTrait.php /opt/typephp/vendor/swoole/typephp/src/Diagnost COPY gen_stub.php /opt/typephp/vendor/swoole/typephp/src/gen_stub.php COPY SwitchTrait.php /opt/typephp/vendor/swoole/typephp/src/Parser/SwitchTrait.php COPY CompilerBase.php /opt/typephp/vendor/swoole/typephp/src/CompilerBase.php +COPY patch_native_type_compatibility_trait.php /tmp/patch_native_type_compatibility_trait.php +RUN php /tmp/patch_native_type_compatibility_trait.php && rm /tmp/patch_native_type_compatibility_trait.php +COPY patch_method_call_trait.php /tmp/patch_method_call_trait.php +RUN php /tmp/patch_method_call_trait.php && rm /tmp/patch_method_call_trait.php COPY ArrayExpressionTrait.php /opt/typephp/vendor/swoole/typephp/src/Parser/ArrayExpressionTrait.php COPY FuncCallOptimizer.php /opt/typephp/vendor/swoole/typephp/src/Optimizer/FuncCallOptimizer.php COPY AssignOpTrait.php /opt/typephp/vendor/swoole/typephp/src/Parser/AssignOpTrait.php diff --git a/docker/FuncCallOptimizer.php b/docker/FuncCallOptimizer.php index 7e5cf83..d563724 100644 --- a/docker/FuncCallOptimizer.php +++ b/docker/FuncCallOptimizer.php @@ -1202,7 +1202,8 @@ protected function genCompact(string $name, Node\Expr\FuncCall $expr, array $con $this->fatalError($expr, 'The argument of compact function can only be literal string'); } $var = $arg->value->value; - if (!$this->hasVar($var) && $var !== 'this') { + $cVar = $this->escapeVarName($var); + if (!$this->hasVar($cVar) && $var !== 'this') { $this->fatalError($arg->value, "Undefined variable `{$var}` in compact()"); } if ($this->isSuperGlobal($var)) { @@ -1215,7 +1216,6 @@ protected function genCompact(string $name, Node\Expr\FuncCall $expr, array $con $this->fatalError($expr, 'Cannot use compact("this") outside of class method'); } } - $cVar = $this->escapeVarName($var); $list[] = '{' . $key . '.str(), php::Var(' . $cVar . ')}'; } diff --git a/docker/Translator.php b/docker/Translator.php index 1183dbe..05cca88 100644 --- a/docker/Translator.php +++ b/docker/Translator.php @@ -55,6 +55,7 @@ use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\NodeAbstract; +use PhpParser\NodeFinder; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\NodeVisitor\CloningVisitor; @@ -85,6 +86,15 @@ class Translator extends Preprocessor protected array $argInfoHeaderFiles = []; protected array $registerSymbols = []; + protected function genArgumentDeclaration(ArgInfo $argInfo): string + { + if ($argInfo->byRef) { + return Type::REF . ' ' . $argInfo->name; + } + + return parent::genArgumentDeclaration($argInfo); + } + /** Generated per-file teardown functions for persistent AST class constants. */ protected array $releaseAstConstantFns = []; @@ -1770,11 +1780,19 @@ protected function getSourceCompileCommandOptions(string $sourceFile, ?string $l return $this->getProjectRuntimeEntryCompileCommandOptions(); } - return match ($language) { + $options = match ($language) { null => $this->getCompileCommandOptions(), 'c' => $this->getCCompileCommandOptions(), default => $this->getNativeCompileCommandOptions($language), }; + + // The generated registration unit is enormous but not a business hot path. + // Compiling it at -O0 bounds GCC memory without weakening user source optimization. + if (str_starts_with(basename($sourceFile), 'extension-')) { + return $options->with('optimize', 0); + } + + return $options; } protected function buildCompileFileCommand(string $sourceFile, string $objectFile): string @@ -1810,12 +1828,24 @@ public function compile(array $sourceFiles): array // Windows: compile the resource file (icon, version info, etc.) $this->compileResourceFile(); + $serialObjects = []; + if ($this->getPlatform()->supportsPcntlParallelCompile() && $job > 1) { + foreach ($sourceFiles as $index => $sourceFile) { + if (str_starts_with(basename($sourceFile), 'extension-')) { + $this->climate->lightBlue('Compiling generated extension unit serially to bound peak memory'); + $serialObjects = $this->compileSourceFile([$sourceFile]); + unset($sourceFiles[$index]); + } + } + $sourceFiles = array_values($sourceFiles); + } + if (!$this->getPlatform()->supportsPcntlParallelCompile() or $job <= 1) { return $this->compileSourceFile($sourceFiles); } // Unix/Linux/macOS compile in parallel using pcntl - return $this->compileWithPcntl($sourceFiles, $job); + return array_merge($serialObjects, $this->compileWithPcntl($sourceFiles, $job)); } protected function preparePhpXPrecompiledHeader(): void @@ -4791,9 +4821,13 @@ protected function genWrapperFunctionArgs( $argExpr = 'php::getCallArg(' . $k . ')'; } } - $cppType = $this->getDefaultArgumentType($argInfo); + $cppType = $argInfo->byRef + ? Type::REF + : $this->getDefaultArgumentType($argInfo); $declaredClass = $argInfo->declaredClass ?: $argInfo->class; - if ($this->isStrictScalarType($argInfo->type)) { + if ($argInfo->byRef) { + $expr = $argExpr; + } elseif ($this->isStrictScalarType($argInfo->type)) { $rawVar = 'raw_' . $var; $cppCode .= $this->getIndent() . Type::VAR . ' ' . $rawVar . ' = ' . $argExpr . ';' . PHP_EOL; $cppCode .= $this->genStrictScalarParamCheck( @@ -4957,6 +4991,33 @@ protected function genClassWrapper(ClassDef|InterfaceDef $classDef): string /** * @throws \Exception */ + /** + * PHP closure references are dynamic Zend reference slots even when their + * current value is scalar or array. Mark them before statement generation + * so an earlier assignment is not narrowed to incompatible C++ storage. + * + * @param list $stmts + */ + private function markClosureReferenceVariables(array $stmts): void + { + $finder = new NodeFinder(); + /** @var list $closures */ + $closures = $finder->findInstanceOf($stmts, Node\Expr\Closure::class); + foreach ($closures as $closure) { + foreach ($closure->uses as $useItem) { + if ( + !$useItem->byRef + || !$useItem->var instanceof Node\Expr\Variable + || !is_string($useItem->var->name) + ) { + continue; + } + $name = $this->parseIdentifier($useItem->var); + $this->context->localVars[$name] = Type::REF; + } + } + } + protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): string { $this->resetFunction(); @@ -4991,9 +5052,11 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): } } foreach ($this->functionDef->argInfoList as $argInfo) { - $argumentType = $argInfo->variadic + $argumentType = $argInfo->byRef + ? Type::REF + : ($argInfo->variadic ? Type::ARRAY - : ($this->getNativeObjectArgumentType($argInfo) ?? $argInfo->type); + : ($this->getNativeObjectArgumentType($argInfo) ?? $argInfo->type)); $this->addArgument($argInfo->name, $argumentType); if (!$argInfo->variadic and $argInfo->declaredClass) { $this->addObject($argInfo->name, $argInfo->declaredClass); @@ -5007,6 +5070,9 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): } } $this->initializeImmutableFunctionContext(); + if ($v->stmts) { + $this->markClosureReferenceVariables($v->stmts); + } if ($this->functionDef->generator) { try { @@ -5029,10 +5095,10 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): $this->context->ssaBuilder = $ssaBuilder; $this->analyzeStableObjects($ssaBuilder); // Range-proven loop counters are safe to narrow even without - // `use native_types`: the optimizer rejects counters whose PHP + // `use varint_types`: the optimizer rejects counters whose PHP // integer semantics could widen to float or otherwise escape. $optimizedLoopVars = $this->optimizeLoopVars($ssaBuilder); - if ($this->nativeTypes) { + if ($this->varIntTypes) { // Narrow local variable types based on SSA analysis. $this->optimizeVarTypes($ssaBuilder); // Narrow native property accesses. @@ -5049,7 +5115,6 @@ protected function parseFunction(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): $this->context->localVars[$varName] = $type; } } - $stmts = ''; $this->indentLevel++; try { diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7dc8f30..e698480 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -114,9 +114,13 @@ fi # Parallelize the C++ codegen/compile phase across available cores. job:1 makes # the ~371-file g++ phase take 40+ minutes (exceeding the command timeout); -# cap at 8 to bound peak memory since each PHPX-template g++ job can be large. -jobs="$(nproc 2>/dev/null || echo 2)" -[[ "$jobs" -gt 8 ]] && jobs=8 +# cap at 4 to bound peak memory since each PHPX-template g++ job can be large. +jobs="${TYPEPHP_JOBS:-$(nproc 2>/dev/null || echo 2)}" +if [[ ! "$jobs" =~ ^[1-9][0-9]*$ ]]; then + echo '[ERROR] TYPEPHP_JOBS must be a positive integer.' >&2 + exit 2 +fi +[[ "$jobs" -gt 4 ]] && jobs=4 [[ "$jobs" -lt 1 ]] && jobs=1 sed -i "s/^job:.*/job: ${jobs}/" "$project_file" @@ -279,8 +283,26 @@ for resource in config public; do done mkdir -p "$stage_dir/runtime/logs" "$stage_dir/runtime/views" if [[ -d app/view ]]; then mkdir -p "$stage_dir/app"; cp -a app/view "$stage_dir/app/"; fi -if [[ -f app/functions.php ]]; then copy_file app/functions.php "$stage_dir/app/functions.php"; fi +if [[ -f app/functions.php && ! -f "$build_dir/source-coverage.json" ]]; then + copy_file app/functions.php "$stage_dir/app/functions.php" +fi +runtime_resources_file="$build_dir/runtime-resources.list" +if [[ -f "$runtime_resources_file" ]]; then + while IFS= read -r resource || [[ -n "$resource" ]]; do + [[ -z "$resource" ]] && continue + if ! validate_relative_path "$resource"; then + echo "[ERROR] Unsafe runtime resource path in manifest: $resource" >&2 + exit 2 + fi + if [[ -e "$workspace/$resource" ]]; then + mkdir -p "$stage_dir/$(dirname -- "$resource")" + cp -a "$workspace/$resource" "$stage_dir/$resource" + fi + done < "$runtime_resources_file" + copy_file "$runtime_resources_file" "$stage_dir/runtime-resources.list" +fi [[ -f "$build_dir/build-manifest.json" ]] && copy_file "$build_dir/build-manifest.json" "$stage_dir/build-manifest.json" +[[ -f "$build_dir/source-coverage.json" ]] && copy_file "$build_dir/source-coverage.json" "$stage_dir/source-coverage.json" readonly final_dir="$real_workspace/$output_dir" if [[ -e "$final_dir" ]]; then @@ -288,6 +310,7 @@ if [[ -e "$final_dir" ]]; then echo "[ERROR] Output '$output_dir' exists; use --force to replace it." >&2 exit 1 fi + mkdir -p "$real_workspace/.typephp" mv "$final_dir" "$real_workspace/.typephp/previous-$build_id" fi # stage_dir is on native storage while final_dir is on the 9P bind mount, so this diff --git a/docker/gen_stub.php b/docker/gen_stub.php index c4962c1..bdb31e2 100644 --- a/docker/gen_stub.php +++ b/docker/gen_stub.php @@ -58,6 +58,11 @@ PHP_85_VERSION_ID, ]; +function &refval(mixed &$value): mixed +{ + return $value; +} + // file_put_contents() but with a success message printed after saving function reportFilePutContents(string $filename, string $content): void { getTranslator()->writeFile($filename, $content); diff --git a/docker/patch_method_call_trait.php b/docker/patch_method_call_trait.php new file mode 100644 index 0000000..2104cb6 --- /dev/null +++ b/docker/patch_method_call_trait.php @@ -0,0 +1,75 @@ +args !== []) { + $this->fatalError($expr, "The {$methodName} method does not accept parameters"); + } + if ($methodName === 'toObject') { + return $this->genToObjectCall($expr, $object); + } + if ($methodName === 'toRef') { + return $this->genToRefCall($expr); + } + $receiverClass = $class; + if ($receiverClass === '' && !$this->isVarExpr($expr->var)) { + $receiverClass = $this->detectClassOfExpr($expr->var); + } + // A declared conversion method is called directly. Otherwise + // php::toArray() applies the PHP-compatible object-property + // fallback (and invokes a real toArray() method when present). + $useDeclaredToArray = $methodName === 'toArray' + && $receiverClass !== '' + && $this->objectTypeDeclaresMethod($receiverClass, $methodName); + if (!$useDeclaredToArray) { + return $this->genToConvertCall($object, $methodName, $receiverType); + } +PHP; + +$after = <<<'PHP' + $receiverClass = $class; + if ($receiverClass === '' && !$this->isVarExpr($expr->var)) { + $receiverClass = $this->detectClassOfExpr($expr->var); + } + // A declared toArray(...) API is an ordinary PHP method, not + // the argument-free TypePHP conversion keyword. + $useDeclaredToArray = $methodName === 'toArray' + && $receiverClass !== '' + && $this->objectTypeDeclaresMethod($receiverClass, $methodName); + $useDynamicToArray = $methodName === 'toArray' + && ($receiverClass === '' + || $receiverType === Type::VAR + || $this->objectTypeDeclaresMethod($receiverClass, '__call')); + $useObjectToArray = $useDeclaredToArray || $useDynamicToArray; + if (!isset(self::KEYWORD_METHOD_WITH_ARGUMENTS[$methodName]) + && $expr->args !== [] + && !$useObjectToArray + ) { + $this->fatalError($expr, "The {$methodName} method does not accept parameters"); + } + if ($methodName === 'toObject') { + return $this->genToObjectCall($expr, $object); + } + if ($methodName === 'toRef') { + return $this->genToRefCall($expr); + } + if (!$useObjectToArray) { + return $this->genToConvertCall($object, $methodName, $receiverType); + } +PHP; + +if (substr_count($source, $before) !== 1) { + throw new RuntimeException('Unexpected MethodCallTrait keyword-dispatch source'); +} + +$patched = str_replace($before, $after, $source); +if (file_put_contents($path, $patched) === false) { + throw new RuntimeException("Unable to write {$path}"); +} diff --git a/docker/patch_native_type_compatibility_trait.php b/docker/patch_native_type_compatibility_trait.php new file mode 100644 index 0000000..7b90e91 --- /dev/null +++ b/docker/patch_native_type_compatibility_trait.php @@ -0,0 +1,44 @@ +type) + ) { + return $var; + } +PHP; +$typedProxy = <<<'PHP' + $reference = $this->addTmpVar(Type::REF); + $wrapper = $this->genTmpVarName(); + $this->context->beforeStmtLines[] = $reference . ' = ' . $this->convertToRef($arg->value) . ';'; + $this->context->beforeStmtLines[] = 'php::RefWrap<' . $expectedType . '> ' + . $wrapper . '(' . $reference . ');'; + $this->context->afterStmtLines[] = $wrapper . '.commit();'; + return $wrapper . '.typed()'; +PHP; +foreach ([$typedDirect, $typedProxy] as $typedRefBoundary) { + if (substr_count($source, $typedRefBoundary) !== 1) { + throw new RuntimeException('Unexpected typed reference ABI boundary in NativeTypeCompatibilityTrait.php'); + } + $source = str_replace($typedRefBoundary, '', $source); +} + +$patched = str_replace($before, $after, $source); +if (file_put_contents($path, $patched) === false) { + throw new RuntimeException('Unable to patch NativeTypeCompatibilityTrait.php'); +} diff --git a/docs/saiadmin-aot/README.md b/docs/saiadmin-aot/README.md new file mode 100644 index 0000000..1690dc3 --- /dev/null +++ b/docs/saiadmin-aot/README.md @@ -0,0 +1,25 @@ +# Webman TypePHP AOT 配套 + +本目录是 `tinywan/webman-typephp` 的使用规范、迁移指南、配置样例和验收脚本,不包含另一套编译器或打包插件。 + +推荐流程: + +1. 安装并锁定 `tinywan/webman-typephp`、builder、PHP、Webman 和 Composer 依赖。 +2. 按 [开发规范](docs/development-standard.md) 约束新代码。 +3. 对照 [支持矩阵](docs/compatibility-matrix.md),存量项目按 [迁移指南](docs/migration-guide.md) 只处理真实编译错误。 +4. 使用现有插件执行 `php webman typephp:doctor` 和 `php webman typephp:package --profile=saiadmin`。 +5. 用 `scripts/verify-package.sh` 检查产物契约。 +6. 仅在隔离环境中,用 `scripts/accept-linux.sh` 完成验证码、登录、用户信息和权限拒绝验收。 + +本 PR 的 Linux amd64 完整编译、产物检查、启动和业务验收记录见 +[验证证据](docs/verification-evidence.md)。 + +边界: + +- 业务 PHP 必须进入 AOT 编译,不得借 `ignore` 静默回退解释执行。 +- 配置、模板、静态数据和明确登记的第三方动态资源可以随包。 +- AOT 专用兼容改写只生成到 `.typephp/build/`;普通 PHP源码和执行路径保持不变。 +- 不因 AOT 适配修改 Webman;SaiAdmin 只修复已复现、无法由生成副本解决的问题。 +- 构建成功不等于运行成功,静态检查不等于业务验收。 + +本目录不含任何业务仓库源码、数据、凭据、内网地址或构建产物。 diff --git a/docs/saiadmin-aot/config/typephp-aot.example.php b/docs/saiadmin-aot/config/typephp-aot.example.php new file mode 100644 index 0000000..2ac08aa --- /dev/null +++ b/docs/saiadmin-aot/config/typephp-aot.example.php @@ -0,0 +1,29 @@ + true, + 'docker' => [ + // 发布使用时可进一步固定为 registry tag@sha256:digest。 + 'image' => 'tinywan/typephp-webman-builder:v0.1.3', + ], + 'build' => [ + 'output_name' => 'webman-server', + 'dist_dir' => 'dist', + 'clean_build' => true, + ], + 'runtime_resources' => [ + 'config', + 'public', + 'app/view', + // 只增加模板、静态数据或经登记的第三方动态资源。 + ], + 'ignore' => [ + 'config', + 'public', + 'runtime', + 'app/view', + // 不得增加自有业务 PHP 目录。 + ], +]; diff --git a/docs/saiadmin-aot/docs/compatibility-matrix.md b/docs/saiadmin-aot/docs/compatibility-matrix.md new file mode 100644 index 0000000..0e74c66 --- /dev/null +++ b/docs/saiadmin-aot/docs/compatibility-matrix.md @@ -0,0 +1,31 @@ +# SaiAdmin AOT 支持矩阵 + +首版只验收 Linux amd64。下列组合按一个不可拆分的构建基线处理;任一依赖漂移都必须先更新 fixture、规则命中数和完整运行证据。 + +| 组件 | 锁定版本 | +| --- | --- | +| PHP | 8.4.25 | +| TypePHP Linux builder | `tinywan/typephp-linux-x64:v0.8.0@sha256:f18cac640edf52126acc1ad781f220f9fe547f7c8db925dcc38d4854f9436f90` | +| SaiAdmin | 6.1.1 | +| ThinkORM | v3.0.34 | +| Carbon | 3.13.2 | +| Webman framework | `fa352016aac4c9e21c8781cc127afd25ee144795` | +| Workerman | `69bfc7765fff55bc3792560c715ae3ca8eadccb5` | + +## 已验证的兼容规则 + +| 归属 | 问题类别 | AOT 处理 | +| --- | --- | --- | +| Webman / Workerman | 顶层守卫与初始化、固定参数回调、引用捕获、switch 落空、运行期清理 | 生成锁定结构的副本,并在入口补回等价初始化 | +| ThinkORM | 参数与局部变量类型漂移、动态解析调用参数数目、结果集跨类型、运行期连接状态清理、Collection 回调少声明键参数 | 生成类型稳定且调用参数显式的副本,并声明集合回调的值与键参数 | +| Carbon | 变量变量、魔术单位调用、继承常量、DatePeriod 声明顺序和类型漂移 | 生成显式方法、常量和稳定局部变量副本 | +| SaiAdmin | 验证码颜色/字体、编译类 protected 默认属性反射缺失、零参控制器无法接收 Webman 请求参数 | 使用随包字体;对 6.1.1 的登录与安装匿名动作生成显式等价副本;为全部已登记零参路由动作生成显式 `Request` 参数 | + +规则只匹配已记录的依赖版本和源码结构。未知版本、零命中、重复命中或无法证明等价的写法必须终止构建;不得改成运行时解释业务代码。 + +## 未承诺范围 + +- Windows 可执行文件和 DLL。 +- 未来 SaiAdmin 或依赖版本的自动兼容。 +- 未经完整编译和业务验收的第三方插件。 +- 依赖动态 `include`、`eval`、闭包 rebinding 或不稳定魔术调用且无法静态等价转换的业务功能。 diff --git a/docs/saiadmin-aot/docs/development-standard.md b/docs/saiadmin-aot/docs/development-standard.md new file mode 100644 index 0000000..279aaef --- /dev/null +++ b/docs/saiadmin-aot/docs/development-standard.md @@ -0,0 +1,40 @@ +# AOT 开发规范 + +## 依赖与构建 + +- `composer.lock`、builder 镜像标签及 digest、PHP 小版本、Webman/Workerman 提交和扩展集合必须作为一次验收组合记录。 +- 使用项目内 `tinywan/webman-typephp` 命令生成入口、编译配置和 portable-dir,不复制其编译与打包逻辑。 +- Linux amd64 构建显式使用 `--platform linux/amd64`;发布前在目标 glibc 基线或更旧基线上验收。 + +## 代码边界 + +- 业务控制器、Logic、Service、Model、中间件、命令和进程代码必须编译。 +- `ignore` 只允许配置、模板、静态数据、安装器/测试夹具以及明确登记的第三方动态资源。 +- 被 `ignore` 的 PHP 声明必须由插件生成等价 `.typephp/build/*.php` 并参与编译,或被列为非业务运行时动态代码并说明原因、所有者和验收方式。 +- 不用“功能未调用”掩盖编译失败。未支持能力应在 AOT 路径显式失败,并保留普通 PHP 行为。 +- PHP 文件顶层只允许声明;禁止在顶层执行函数调用、条件分支或初始化语句。 +- 禁止变量变量和无法静态确定目标的魔术静态调用;业务代码禁止动态 `include`、`require` 和 `eval`。 +- 一个局部变量不得在同一作用域跨不兼容类型重复赋值;需要不同类型时拆成具有明确含义的变量。 +- `switch` 的每个非空分支必须显式 `break`、`return` 或 `throw`,禁止依赖 fall-through。 +- 调用参数必须符合声明;允许接收额外参数的回调应显式声明可变参数。 +- 动态调用不得依赖参数的隐式引用语义;引用参数必须在声明和调用处明确并能被 TypePHP 静态分析。 +- 禁止业务代码使用 `Closure::bind()`、`Closure::bindTo()` 或 `Closure::call()` 改写闭包作用域。 +- 上述规则若能安全且等价转换,可以只在 `.typephp/build/` 生成 AOT 副本;不能证明等价时必须报告文件、行号、规则和迁移建议,并终止出包。 + +## 修改原则 + +- 先保留失败日志和最小复现,再增加针对性 fixture。 +- 优先修改插件生成的 AOT 副本;不直接修改 vendor。 +- 每个替换必须锁定输入字面量或结构,版本漂移时失败或由测试暴露。 +- 同一阻碍两轮没有新证据即停止扩张,记录编译器限制或上游缺口。 + +## 验收分层 + +1. 插件测试和 PHP 语法检查。 +2. Linux amd64 全量编译成功。 +3. portable-dir 文件、ELF 架构、动态库、配置和运行资源检查。 +4. 隔离环境启动成功。 +5. 登录成功及至少一条权限拒绝路径成功。 +6. 相同改动的普通 PHP 回归。 + +任何上一层通过都不能替代下一层。 diff --git a/docs/saiadmin-aot/docs/migration-guide.md b/docs/saiadmin-aot/docs/migration-guide.md new file mode 100644 index 0000000..21ff048 --- /dev/null +++ b/docs/saiadmin-aot/docs/migration-guide.md @@ -0,0 +1,57 @@ +# 存量项目迁移指南 + +## 1. 建立基线 + +记录当前提交和工作树状态,PHP、Composer、Docker、Webman、Workerman、TypePHP、builder 与扩展版本,以及普通 PHP 回归和原始编译 Fatal。不要运行安装器或数据库初始化。 + +## 2. 划分输入 + +- 必须编译:所有自有业务 PHP。 +- 随包资源:配置、模板、静态文件和语言/数据文件。 +- 明确登记的第三方动态代码:仅在编译器当前不能表示、且运行时加载方案已经验收时使用。 + +逐项检查生成的 `project.linux.yml`。业务文件被 `ignore` 时,必须能对应到参与编译的 `.typephp/build` 等价副本;否则迁移失败。 + +## 3. 逐个处理真实错误 + +每轮只处理日志中的首个阻碍:确认失败文件和锁定版本,判断责任层,在现有插件增加最小 AOT 生成改写及 fixture,然后重新全量编译。只有出现新失败位置才算取得新证据;禁止排除整块业务目录来“先出包”。 + +升级了插件的 `main.php.stub` 后,存量项目必须显式使用 `--refresh-main` 重建入口并审查备份,不能只更新依赖后沿用旧入口。 + +若第三方代码依赖 TypePHP 当前不能表示的运行时反射或动态行为,必须同时: + +1. 在 `runtime_resources` 中逐文件登记; +2. 在 `ignore` 中使用相同路径; +3. 通过 Webman `autoload.files` 在使用前加载; +4. 证明自有业务 PHP 没有进入该清单。 + +## 4. 产物和运行验收 + +编译完成后: + +```bash +AOT_DIST=/absolute/path/to/dist ./scripts/verify-package.sh +``` + +准备隔离数据库、测试账号和未授权接口后: + +```bash +AOT_ACCEPT_ISOLATED=YES \ +AOT_DIST=/absolute/path/to/dist \ +AOT_BASE_URL=http://127.0.0.1:8787 \ +AOT_CAPTCHA_PATH=/core/captcha \ +AOT_LOGIN_PATH=/core/login \ +AOT_LOGIN_BODY_FILE=/absolute/path/to/login.json \ +AOT_TOKEN_PATH=data.access_token \ +AOT_USER_INFO_PATH=/core/system/user \ +AOT_DENIED_PATH=/core/post/index \ +AOT_DENIED_EXPECT_STATUS=200 \ +AOT_DENIED_EXPECT_CODE=400 \ +./scripts/accept-linux.sh +``` + +`AOT_TOKEN_PATH` 是登录 JSON 中 Bearer token 的点分路径。脚本会验证验证码响应含 UUID 和图片、登录 token、登录后用户信息,以及权限拒绝的 HTTP 状态和业务码。登录请求文件应由隔离环境夹具写入有效且尚未消费的验证码;脚本不会创建或迁移数据库,凭据只从本地文件读取且不会输出。宿主特有路径和响应断言不得进入公开仓库。 + +## 5. 回归与发布 + +重新运行普通 PHP 基线。提交、推送、镜像发布和部署均是独立授权动作;发布必须重建完整 portable-dir 后重新验收。 diff --git a/docs/saiadmin-aot/docs/verification-evidence.md b/docs/saiadmin-aot/docs/verification-evidence.md new file mode 100644 index 0000000..3a49759 --- /dev/null +++ b/docs/saiadmin-aot/docs/verification-evidence.md @@ -0,0 +1,155 @@ +# SaiAdmin Linux amd64 AOT 验证证据 + +验收编号:`AOT-SAIADMIN-LINUX-01` + +本记录对应 `feat/saiadmin-aot-profile` 的首版候选。验证于 2026-09-14 +在 Mac 上的隔离 Linux amd64 容器环境完成。原始日志包含本地路径和一次性测试账号, +因此不提交原始文件;下文保留版本、数量、哈希、返回码和可复现命令。 + +## 验证结论 + +- Linux amd64 AOT 完整编译、链接和 portable-dir 打包成功。 +- 所有已发现的自有业务 PHP 和已安装插件业务 PHP 均进入 AOT 覆盖清单。 +- AOT 产物成功启动,并通过验证码、登录、登录后用户信息、权限拒绝和中立插件路由。 +- 相同业务路径在普通 PHP 下通过,AOT 适配没有改变普通运行方式。 +- 一次性测试账号、数据库卷、运行容器和隔离网络已在验收后销毁。 + +## 固定组合 + +| 项目 | 验证值 | +| --- | --- | +| 目标 | Linux amd64 / glibc portable-dir | +| builder PHP | 8.4.25 | +| TypePHP | `swoole/typephp v0.8.0` | +| builder 基线 | `tinywan/typephp-linux-x64:v0.8.0@sha256:f18cac640edf52126acc1ad781f220f9fe547f7c8db925dcc38d4854f9436f90` | +| 候选镜像 ID | `sha256:bf2dc10f8470bac08b958bf2f457969d43eb6b6a77d123c66dc7b9864f363922` | +| SaiAdmin | 6.1.1 | +| ThinkORM | v3.0.34 | +| Carbon | 3.13.2 | +| Webman framework | `fa352016aac4c9e21c8781cc127afd25ee144795` | +| Workerman | `69bfc7765fff55bc3792560c715ae3ca8eadccb5` | + +## 环境与自动化测试 + +`php webman typephp:doctor` 的关键结果: + +```text +PHP 8.4.25: OK +Docker CLI 29.1.3 and daemon: OK +Host Clang: optional +``` + +插件测试和静态检查: + +```text +Pest: 128 passed, 1,369 assertions +Existing fixture warning: 1 +PHP 8.4 syntax: passed +Shell syntax: passed +git diff --check: passed +``` + +## 完整编译 + +执行命令: + +```bash +php webman typephp:doctor +php webman typephp:package --profile=saiadmin +``` + +最终构建记录: + +```text +Source roots scanned: 200 +PHP inputs generated and prechecked: 2,279 +Native objects compiled and linked: 2,283 / 2,283 +Build exit code: 0 +portable-dir size: 230 MB +Runtime resources recorded: 74 +``` + +中立插件 `plugin/aot_probe/app/controller/ProbeController.php` 由 profile 自动发现并 +直接编译,没有在 profile 中手工登记。 + +## 产物完整性 + +保留产物于 2026-09-15 再次运行 `scripts/verify-package.sh`,结果: + +```text +ELF 64-bit LSB pie executable, x86-64 +Portable-dir contract OK +profile=saiadmin +compiled=131 +generated=11 +covered files=142 +runtime resources=74 +business PHP leaked into portable-dir=0 +unresolved ldd dependency=0 +``` + +产物身份: + +| 文件 | SHA-256 | +| --- | --- | +| `webman-server.bin` | `a248c3fdd7fe7e95dfacf6d0557e320bfb19f7a5d9053ac5605329b696aee2ff` | +| `build-manifest.json` | `379a8d19fcd1d76af463efe567cd83d51ee9414a4b0f173096a5c2ac9315e862` | +| `source-coverage.json` | `5fd8bd86bed3987cc43fd97dd0c7f8047664b235a0b2b3bb571ea9ed785d2d77` | + +## 启动与业务路径 + +portable-dir 在隔离 Linux amd64 容器中启动,Workerman 记录: + +```text +Workerman[main.php] start in DEBUG mode +40 workers [OK] +``` + +业务验收结果: + +| 路径 | HTTP | JSON 业务码 | 验证内容 | +| --- | ---: | ---: | --- | +| 验证码 | 200 | 200 | 返回非空 UUID 和 `data:image/` 图片 | +| 登录 | 200 | 200 | 返回非空 Bearer access token | +| 登录后用户信息 | 200 | 200 | 返回当前隔离用户数据 | +| 权限拒绝 | 200 | 400 | 无权限用户被拒绝访问受保护资源 | +| 中立插件 `/aot-probe` | 200 | 200 | 返回 `data.aot=true` | + +`scripts/accept-linux.sh` 的完成结果: + +```text +Portable-dir contract OK +Isolated captcha, login, user-info, and permission-denial acceptance passed. +``` + +普通 PHP 使用同一依赖组合、隔离数据库和业务请求执行对照: + +```text +captcha: HTTP 200 / JSON 200 +login: HTTP 200 / JSON 200 +user-info: HTTP 200 / JSON 200 +permission denial: HTTP 200 / JSON 400 +``` + +## 复现 + +维护者可以在一次性 SaiAdmin 6.1.1 测试项目中执行: + +```bash +php webman typephp:doctor +php webman typephp:package --profile=saiadmin + +AOT_DIST=/absolute/path/to/dist \ + ./vendor/tinywan/webman-typephp/docs/saiadmin-aot/scripts/verify-package.sh +``` + +业务验收需要由调用方提供隔离环境的 URL、接口路径和临时登录请求文件。必填变量及 +安全开关记录在 `scripts/accept-linux.sh` 中。脚本拒绝在未显式设置 +`AOT_ACCEPT_ISOLATED=YES` 时启动。 + +## 证据边界 + +- 本文件是脱敏后的本地隔离验收记录,不是公开托管的二进制或完整原始日志。 +- 哈希只能标识本次保留产物,不能替代维护者在自己的锁定环境中重建。 +- GitHub 当前没有为该分支返回 CI checks;合并前建议维护者重跑插件测试和完整构建。 +- Windows exe/DLL 未验证,不属于本次支持范围。 diff --git a/docs/saiadmin-aot/examples/plugin/aot_probe/README.md b/docs/saiadmin-aot/examples/plugin/aot_probe/README.md new file mode 100644 index 0000000..69e7267 --- /dev/null +++ b/docs/saiadmin-aot/examples/plugin/aot_probe/README.md @@ -0,0 +1,9 @@ +# 中立插件自动发现样例 + +将 `aot_probe` 目录复制到 SaiAdmin 项目的 `plugin/` 后,再执行: + +```bash +php webman typephp:package --profile=saiadmin +``` + +覆盖清单必须新增 `plugin/aot_probe/app/controller/ProbeController.php`,产物中不得保留该业务 PHP。该样例只用于验证插件自动发现,不需要在 TypePHP 配置中手工登记。 diff --git a/docs/saiadmin-aot/examples/plugin/aot_probe/app/controller/ProbeController.php b/docs/saiadmin-aot/examples/plugin/aot_probe/app/controller/ProbeController.php new file mode 100644 index 0000000..0f86358 --- /dev/null +++ b/docs/saiadmin-aot/examples/plugin/aot_probe/app/controller/ProbeController.php @@ -0,0 +1,16 @@ + 200, 'data' => ['aot' => true]]); + } +} diff --git a/docs/saiadmin-aot/examples/plugin/aot_probe/config/route.php b/docs/saiadmin-aot/examples/plugin/aot_probe/config/route.php new file mode 100644 index 0000000..0e98eb3 --- /dev/null +++ b/docs/saiadmin-aot/examples/plugin/aot_probe/config/route.php @@ -0,0 +1,8 @@ +&2 + exit 64 +fi + +dist_dir=${AOT_DIST:-} +base_url=${AOT_BASE_URL:-} +captcha_path=${AOT_CAPTCHA_PATH:-} +login_path=${AOT_LOGIN_PATH:-} +login_body_file=${AOT_LOGIN_BODY_FILE:-} +login_status=${AOT_LOGIN_EXPECT_STATUS:-200} +token_path=${AOT_TOKEN_PATH:-data.access_token} +user_info_path=${AOT_USER_INFO_PATH:-} +user_info_status=${AOT_USER_INFO_EXPECT_STATUS:-200} +denied_path=${AOT_DENIED_PATH:-} +denied_status=${AOT_DENIED_EXPECT_STATUS:-200} +denied_code=${AOT_DENIED_EXPECT_CODE:-400} + +for required_value in "$dist_dir" "$base_url" "$captcha_path" "$login_path" "$login_body_file" "$user_info_path" "$denied_path"; do + [[ -n "$required_value" ]] || { + echo "Missing required AOT acceptance environment variable." >&2 + exit 64 + } +done + +if [[ "$dist_dir" != /* || "$login_body_file" != /* || ! -f "$login_body_file" ]]; then + echo "AOT_DIST and AOT_LOGIN_BODY_FILE must be valid absolute paths." >&2 + exit 64 +fi + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +AOT_DIST="$dist_dir" "$script_dir/verify-package.sh" + +response_dir=$(mktemp -d) +cleanup() { + "$dist_dir/start.sh" stop >/dev/null 2>&1 || true + rm -rf -- "$response_dir" +} +trap cleanup EXIT INT TERM + +(cd "$dist_dir" && ./start.sh start) + +ready=0 +for _ in {1..30}; do + if curl --fail --silent --show-error "$base_url" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 +done +[[ $ready -eq 1 ]] || { + echo "Service did not become ready within 30 seconds." >&2 + exit 70 +} + +actual_captcha_status=$(curl --silent --show-error --output "$response_dir/captcha.json" \ + --write-out '%{http_code}' "$base_url$captcha_path") +[[ "$actual_captcha_status" == 200 ]] || { + echo "Captcha status mismatch: expected 200, got $actual_captcha_status." >&2 + exit 71 +} +php -r ' + $value = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + $uuid = $value["data"]["uuid"] ?? null; + $image = $value["data"]["image"] ?? null; + if (($value["code"] ?? null) !== 200 || !is_string($uuid) || $uuid === "" + || !is_string($image) || !str_starts_with($image, "data:image/")) { + exit(2); + } +' "$response_dir/captcha.json" || { + echo "Captcha response contract failed." >&2 + exit 71 +} + +actual_login_status=$(curl --silent --show-error --output "$response_dir/login.json" \ + --write-out '%{http_code}' --header 'Content-Type: application/json' \ + --data-binary "@$login_body_file" "$base_url$login_path") +[[ "$actual_login_status" == "$login_status" ]] || { + echo "Login status mismatch: expected $login_status, got $actual_login_status." >&2 + exit 71 +} + +token=$(php -r ' + $value = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + foreach (explode(".", $argv[2]) as $key) { + if (!is_array($value) || !array_key_exists($key, $value)) { + exit(2); + } + $value = $value[$key]; + } + if (!is_string($value) || $value === "") { + exit(3); + } + echo $value; +' "$response_dir/login.json" "$token_path") || { + echo "Login token not found at JSON path: $token_path." >&2 + exit 71 +} + +actual_user_info_status=$(curl --silent --show-error --output "$response_dir/user-info.json" \ + --write-out '%{http_code}' --header "Authorization: Bearer $token" "$base_url$user_info_path") +[[ "$actual_user_info_status" == "$user_info_status" ]] || { + echo "User-info status mismatch: expected $user_info_status, got $actual_user_info_status." >&2 + exit 72 +} +php -r ' + $value = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + if (($value["code"] ?? null) !== 200 || !is_array($value["data"] ?? null)) { + exit(2); + } +' "$response_dir/user-info.json" || { + echo "User-info response contract failed." >&2 + exit 72 +} + +actual_denied_status=$(curl --silent --show-error --output "$response_dir/denied.json" \ + --write-out '%{http_code}' --header "Authorization: Bearer $token" "$base_url$denied_path") +[[ "$actual_denied_status" == "$denied_status" ]] || { + echo "Permission denial mismatch: expected $denied_status, got $actual_denied_status." >&2 + exit 72 +} +php -r ' + $value = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + if (($value["code"] ?? null) !== (int) $argv[2]) { + exit(2); + } +' "$response_dir/denied.json" "$denied_code" || { + echo "Permission denial JSON code mismatch: expected $denied_code." >&2 + exit 72 +} + +echo "Isolated captcha, login, user-info, and permission-denial acceptance passed." diff --git a/docs/saiadmin-aot/scripts/verify-package.sh b/docs/saiadmin-aot/scripts/verify-package.sh new file mode 100755 index 0000000..731c46c --- /dev/null +++ b/docs/saiadmin-aot/scripts/verify-package.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +dist_dir=${AOT_DIST:-} +binary_name=${AOT_BINARY_NAME:-webman-server.bin} + +if [[ -z "$dist_dir" || "$dist_dir" != /* ]]; then + echo "AOT_DIST must be an absolute portable-dir path." >&2 + exit 64 +fi + +required=("$binary_name" webman-server start.sh libphp.so libphpx.so php.ini build-manifest.json) +for relative_path in "${required[@]}"; do + [[ -e "$dist_dir/$relative_path" ]] || { + echo "Missing required artifact: $relative_path" >&2 + exit 65 + } +done + +if [[ ! -d "$dist_dir/ext" || ! -d "$dist_dir/lib" || ! -d "$dist_dir/runtime" ]]; then + echo "Missing ext/, lib/, or runtime/ directory." >&2 + exit 65 +fi + +file "$dist_dir/$binary_name" | grep -Eq 'ELF 64-bit.*x86-64' || { + echo "Native binary is not Linux amd64 ELF." >&2 + exit 66 +} + +php -r ' + $manifest = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + foreach (["output_name", "builder_image", "built_at"] as $key) { + if (!isset($manifest[$key]) || $manifest[$key] === "") { + throw new RuntimeException("Missing manifest key: ".$key); + } + } + if (!isset($manifest["runtime_resources"]) || !is_array($manifest["runtime_resources"])) { + throw new RuntimeException("Missing manifest runtime_resources."); + } + $dist = $argv[2]; + if (($manifest["profile"] ?? null) === "saiadmin") { + $coverageFile = $dist."/source-coverage.json"; + if (!is_file($coverageFile)) { + throw new RuntimeException("Missing SaiAdmin source coverage manifest."); + } + $coverage = json_decode(file_get_contents($coverageFile), true, 512, JSON_THROW_ON_ERROR); + if (($coverage["profile"] ?? null) !== "saiadmin" || !is_array($coverage["files"] ?? null)) { + throw new RuntimeException("Invalid SaiAdmin source coverage manifest."); + } + $counts = $coverage["counts"] ?? []; + if (($counts["compiled"] ?? -1) + ($counts["generated"] ?? -1) !== count($coverage["files"])) { + throw new RuntimeException("SaiAdmin source coverage counts do not match its file list."); + } + if (($manifest["inputs"]["source_coverage_hash"] ?? "") !== sha1_file($coverageFile)) { + throw new RuntimeException("SaiAdmin source coverage hash mismatch."); + } + foreach ($coverage["files"] as $file) { + if (!is_array($file) || !in_array($file["mode"] ?? null, ["compiled", "generated"], true)) { + throw new RuntimeException("Invalid SaiAdmin source coverage entry."); + } + $path = $file["path"] ?? ""; + if (!is_string($path) || $path === "" || str_starts_with($path, "/") || str_contains($path, "..")) { + throw new RuntimeException("Unsafe SaiAdmin business source path."); + } + if (file_exists($dist."/".$path)) { + throw new RuntimeException("Business PHP leaked into portable-dir: ".$path); + } + } + } + foreach ($manifest["runtime_resources"] as $resource) { + if (!is_string($resource) || $resource === "" || str_starts_with($resource, "/") || str_contains($resource, "..")) { + throw new RuntimeException("Unsafe runtime resource path."); + } + if (!file_exists($dist."/".$resource)) { + throw new RuntimeException("Missing runtime resource: ".$resource); + } + } +' "$dist_dir/build-manifest.json" "$dist_dir" + +echo "Portable-dir contract OK: $dist_dir" diff --git a/src/Commands/PackageCommand.php b/src/Commands/PackageCommand.php index fe18465..168dc5e 100644 --- a/src/Commands/PackageCommand.php +++ b/src/Commands/PackageCommand.php @@ -30,6 +30,13 @@ protected function configure(): void ->setName('typephp:package') ->setDescription('Build Webman project into a Linux x86_64 portable-dir using TypePHP Docker builder') ->addOption('image', null, InputOption::VALUE_REQUIRED, 'Docker builder image reference', null) + ->addOption( + 'profile', + null, + InputOption::VALUE_REQUIRED, + 'Optional compatibility profile (supported: saiadmin)', + null, + ) ->addOption( 'output-dir', null, @@ -67,6 +74,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $outputDir = trim((string) $input->getOption('output-dir'), '/\\'); $outputName = (string) $input->getOption('output-name'); $force = (bool) $input->getOption('force'); + $profile = $input->getOption('profile'); + $jobs = $pluginConfig['build']['jobs'] ?? 4; + + if (!is_int($jobs) || $jobs < 1 || $jobs > 4) { + $output->writeln('[ERROR] build.jobs must be an integer between 1 and 4.'); + return Command::FAILURE; + } // 1. 严格参数校验,防止任何路径穿越或格式错误 if (!preg_match('/^[A-Za-z0-9._-]+$/', $outputName)) { @@ -107,7 +121,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - // 3. 检查 Docker 环境 + // 3. 执行与 doctor 相同的强制环境门禁;不允许用源码改写掩盖宿主环境问题。 + if (version_compare(PHP_VERSION, '8.4', '<') || version_compare(PHP_VERSION, '8.6', '>=')) { + $output->writeln( + '[ERROR] Environment check failed: PHP >= 8.4 and < 8.6 is required. Run typephp:doctor.', + ); + return Command::FAILURE; + } + $dockerCheck = new Process(['docker', '--version']); $dockerCheck->run(); if (!$dockerCheck->isSuccessful()) { @@ -141,6 +162,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // 动态合并配置并生成 project.linux.yml $extraConfig = [ + 'profile' => is_string($profile) && $profile !== '' ? $profile : null, 'build' => [ 'output_name' => $outputName, ], @@ -158,7 +180,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // 汇总各类 AOT 生成源的输入摘要:标签用于终端提示,同名