diff --git a/bin/phpc.php b/bin/phpc.php index db61c743..021c7bb8 100755 --- a/bin/phpc.php +++ b/bin/phpc.php @@ -17,6 +17,7 @@ * phpc init [--force] [target-dir] * phpc test [-- phpunit/ci-local args...] * phpc doctor Probe PHP, LLVM, deps, loopback (issue #253) + * phpc validate-manifest [dir] Validate phpc.json schema and paths (issue #263) */ $repoRoot = realpath(__DIR__.'/..') ?: __DIR__.'/..'; @@ -42,6 +43,7 @@ phpc init [--force] [target-dir] Scaffold phpc.json + public/index.php phpc test [args...] Run ./script/ci-local.sh phpc doctor Probe environment for full local CI + phpc validate-manifest [dir] Validate phpc.json (default: cwd) HELP); exit([] === $args ? 1 : 0); @@ -100,6 +102,27 @@ require $repoRoot.'/vendor/autoload.php'; exit(\PHPCompiler\Doctor::run($repoRoot)); + case 'validate-manifest': + if (!is_file($repoRoot.'/vendor/autoload.php')) { + fwrite(STDERR, "phpc validate-manifest: run composer install first\n"); + exit(1); + } + require $repoRoot.'/vendor/autoload.php'; + $targetDir = $args[0] ?? getcwd(); + if (false === $targetDir || '' === $targetDir) { + fwrite(STDERR, "phpc validate-manifest: cannot resolve target directory\n"); + exit(1); + } + $errors = \PHPCompiler\Web\ManifestValidator::validate($targetDir); + if ([] === $errors) { + fwrite(STDOUT, "phpc.json OK: {$targetDir}\n"); + exit(0); + } + foreach ($errors as $message) { + fwrite(STDERR, $message."\n"); + } + exit(1); + default: fwrite(STDERR, "Unknown command: {$command}\n"); exit(1); diff --git a/docs/bootstrap-inventory.md b/docs/bootstrap-inventory.md index 7749037b..d40d1cd6 100644 --- a/docs/bootstrap-inventory.md +++ b/docs/bootstrap-inventory.md @@ -8,9 +8,9 @@ Regenerate: `php script/bootstrap-inventory.php` | Metric | Count | |--------|------:| -| PHP files on vm.php path | 250 | +| PHP files on vm.php path | 251 | | Source constructs flagged (blockers) | 10 | -| Source constructs flagged (warnings) | 655 | +| Source constructs flagged (warnings) | 656 | ## Compiler CFG gaps (`lib/Compiler.php`) @@ -270,6 +270,7 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: | `lib/VM/Refcount.php` | 0 | 1 | | `lib/VM/Variable.php` | 0 | 4 | | `lib/Web/DevServer.php` | 0 | 1 | +| `lib/Web/ManifestValidator.php` | 0 | 1 | | `lib/Web/Params.php` | 0 | 2 | | `lib/Web/ProjectManifest.php` | 0 | 1 | | `lib/Web/ResponseContext.php` | 0 | 2 | @@ -1330,7 +1331,7 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: ### `lib/Doctor.php` **Warnings** (review for bootstrap subset): -- 10 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler +- 11 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler ### `lib/Frame.php` @@ -1887,6 +1888,11 @@ These `LogicException` messages indicate CFG ops or expressions not yet lowered: **Warnings** (review for bootstrap subset): - 20 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler +### `lib/Web/ManifestValidator.php` + +**Warnings** (review for bootstrap subset): +- 2 class method(s) — PHPCfg Op\Stmt\ClassMethod not lowered in Compiler + ### `lib/Web/Params.php` **Warnings** (review for bootstrap subset): diff --git a/docs/bootstrap-profile.json b/docs/bootstrap-profile.json index e69de29b..97d35f28 100644 --- a/docs/bootstrap-profile.json +++ b/docs/bootstrap-profile.json @@ -0,0 +1,293 @@ +{ + "phase": "B", + "issue": 212, + "entry": "bin/vm.php", + "unsupported_constructs": [ + "try/catch", + "generator yield", + "enum", + "eval()", + "create_function()", + "shell_exec()", + "exec()", + "passthru()" + ], + "compiler_cfg_gaps": [ + "Unsupported class type: ", + "Unsupported class body element: ", + "Unknown Op Type: ", + "Unknown Stmt Type: ", + "Unknown BinaryOp Type: ", + "Unknown CastOp Type: ", + "Unknown UnaryOp Type: ", + "Unsupported expression: ", + "Unknown Literal Operand Type: ", + "Unknown Operand Type: ", + "Unknown Terminal Type: " + ], + "excluded_files": [ + "lib/AOT/Linker.php", + "lib/VM/HashTable.php" + ], + "eligible_files": [ + "bin/vm.php", + "ext/standard/JitBin2hex.php", + "ext/standard/JitDate.php", + "ext/standard/JitEnv.php", + "ext/standard/JitExplode.php", + "ext/standard/JitGetallheaders.php", + "ext/standard/JitHeader.php", + "ext/standard/JitHex2bin.php", + "ext/standard/JitHtmlspecialchars.php", + "ext/standard/JitHttpResponseCode.php", + "ext/standard/JitImplode.php", + "ext/standard/JitJsonEncode.php", + "ext/standard/JitNl2br.php", + "ext/standard/JitNumberFormat.php", + "ext/standard/JitParseUrl.php", + "ext/standard/JitPath.php", + "ext/standard/JitRandomBytes.php", + "ext/standard/JitRealpath.php", + "ext/standard/JitRequestBody.php", + "ext/standard/JitStrPad.php", + "ext/standard/JitStrRepeat.php", + "ext/standard/JitStrReplace.php", + "ext/standard/JitStrSplit.php", + "ext/standard/JitStringConcat.php", + "ext/standard/JitStringIndex.php", + "ext/standard/JitStripTags.php", + "ext/standard/JitStrpos.php", + "ext/standard/JitUrlencode.php", + "ext/standard/JitWebParams.php", + "ext/standard/Module.php", + "ext/standard/VmDate.php", + "ext/standard/VmExit.php", + "ext/standard/VmFs.php", + "ext/standard/VmJson.php", + "ext/standard/VmNumberFormat.php", + "ext/standard/VmScope.php", + "ext/standard/VmString.php", + "ext/standard/abs.php", + "ext/standard/array_combine.php", + "ext/standard/array_count.php", + "ext/standard/array_fill.php", + "ext/standard/array_flip.php", + "ext/standard/array_key_exists.php", + "ext/standard/array_keys.php", + "ext/standard/array_merge.php", + "ext/standard/array_pop.php", + "ext/standard/array_product.php", + "ext/standard/array_push.php", + "ext/standard/array_reverse.php", + "ext/standard/array_search.php", + "ext/standard/array_shift.php", + "ext/standard/array_slice.php", + "ext/standard/array_sum.php", + "ext/standard/array_unique.php", + "ext/standard/array_values.php", + "ext/standard/basename.php", + "ext/standard/bin2hex.php", + "ext/standard/bindec.php", + "ext/standard/boolval.php", + "ext/standard/ceil.php", + "ext/standard/chr.php", + "ext/standard/compact_.php", + "ext/standard/cos.php", + "ext/standard/date.php", + "ext/standard/decbin.php", + "ext/standard/dechex.php", + "ext/standard/decoct.php", + "ext/standard/deg2rad.php", + "ext/standard/dirname.php", + "ext/standard/exp.php", + "ext/standard/explode.php", + "ext/standard/extract_.php", + "ext/standard/file_get_contents.php", + "ext/standard/floatval.php", + "ext/standard/floor.php", + "ext/standard/fmod.php", + "ext/standard/getallheaders_.php", + "ext/standard/getenv_.php", + "ext/standard/gettype.php", + "ext/standard/glob_.php", + "ext/standard/gmdate.php", + "ext/standard/header_.php", + "ext/standard/header_list.php", + "ext/standard/header_remove.php", + "ext/standard/hex2bin.php", + "ext/standard/hexdec.php", + "ext/standard/htmlspecialchars.php", + "ext/standard/http_response_code.php", + "ext/standard/implode.php", + "ext/standard/in_array.php", + "ext/standard/int_max.php", + "ext/standard/int_min.php", + "ext/standard/intdiv.php", + "ext/standard/intval.php", + "ext/standard/is_finite.php", + "ext/standard/is_infinite.php", + "ext/standard/is_nan.php", + "ext/standard/is_numeric.php", + "ext/standard/is_scalar.php", + "ext/standard/json_encode.php", + "ext/standard/lcfirst.php", + "ext/standard/log.php", + "ext/standard/nl2br.php", + "ext/standard/number_format.php", + "ext/standard/octdec.php", + "ext/standard/ord.php", + "ext/standard/parse_url.php", + "ext/standard/pi.php", + "ext/standard/pow.php", + "ext/standard/putenv_.php", + "ext/standard/rad2deg.php", + "ext/standard/random_bytes.php", + "ext/standard/range.php", + "ext/standard/rawurldecode.php", + "ext/standard/rawurlencode.php", + "ext/standard/realpath.php", + "ext/standard/round.php", + "ext/standard/scandir.php", + "ext/standard/sin.php", + "ext/standard/sort_.php", + "ext/standard/sqrt.php", + "ext/standard/str_contains.php", + "ext/standard/str_ends_with.php", + "ext/standard/str_pad.php", + "ext/standard/str_repeat.php", + "ext/standard/str_replace.php", + "ext/standard/str_split.php", + "ext/standard/str_starts_with.php", + "ext/standard/strcmp.php", + "ext/standard/string_ltrim.php", + "ext/standard/string_rtrim.php", + "ext/standard/string_trim.php", + "ext/standard/strip_tags.php", + "ext/standard/stripos.php", + "ext/standard/strncmp.php", + "ext/standard/strpos.php", + "ext/standard/strrev.php", + "ext/standard/strtolower.php", + "ext/standard/strtoupper.php", + "ext/standard/strval.php", + "ext/standard/substr.php", + "ext/standard/tan.php", + "ext/standard/time.php", + "ext/standard/ucfirst.php", + "ext/standard/ucwords.php", + "ext/standard/urldecode.php", + "ext/standard/urlencode.php", + "ext/standard/var_dump.php", + "ext/standard/web_bool.php", + "ext/standard/web_int.php", + "ext/standard/web_string.php", + "ext/types/Module.php", + "ext/types/is_type.php", + "ext/types/mb_strlen.php", + "ext/types/strlen.php", + "lib/Block.php", + "lib/Cli/PhpcInit.php", + "lib/Compiler.php", + "lib/Doctor.php", + "lib/Frame.php", + "lib/Func.php", + "lib/Func/Internal.php", + "lib/Func/JIT.php", + "lib/Func/PHP.php", + "lib/Handler.php", + "lib/JIT.php", + "lib/JIT/Analyzer.php", + "lib/JIT/ArrayBuiltinHelper.php", + "lib/JIT/BasicBlockHelper.php", + "lib/JIT/Builtin.php", + "lib/JIT/Builtin/ErrorHandler.php", + "lib/JIT/Builtin/HttpResponseCode.php", + "lib/JIT/Builtin/Internal.php", + "lib/JIT/Builtin/MemoryManager.php", + "lib/JIT/Builtin/MemoryManager/Native.php", + "lib/JIT/Builtin/MemoryManager/PHP.php", + "lib/JIT/Builtin/Output.php", + "lib/JIT/Builtin/Refcount.php", + "lib/JIT/Builtin/ScriptExit.php", + "lib/JIT/Builtin/StringDateTime.php", + "lib/JIT/Builtin/StringGetenv.php", + "lib/JIT/Builtin/StringHtmlspecialchars.php", + "lib/JIT/Builtin/StringJsonEncode.php", + "lib/JIT/Builtin/StringNl2br.php", + "lib/JIT/Builtin/StringRandomBytes.php", + "lib/JIT/Builtin/StringUcwords.php", + "lib/JIT/Builtin/StringUrldecode.php", + "lib/JIT/Builtin/StringUrlencode.php", + "lib/JIT/Builtin/Type.php", + "lib/JIT/Builtin/Type/HashTable.php", + "lib/JIT/Builtin/Type/MaskedArray.php", + "lib/JIT/Builtin/Type/NativeArray.php", + "lib/JIT/Builtin/Type/Object_.php", + "lib/JIT/Builtin/Type/String_.php", + "lib/JIT/Builtin/Type/Value.php", + "lib/JIT/Builtin/VarArg.php", + "lib/JIT/Call.php", + "lib/JIT/Call/Native.php", + "lib/JIT/Call/Vararg.php", + "lib/JIT/CoalesceHelper.php", + "lib/JIT/Context.php", + "lib/JIT/HashTableHelper.php", + "lib/JIT/Helper.php", + "lib/JIT/IssetHelper.php", + "lib/JIT/JitNativeString.php", + "lib/JIT/JitValueBox.php", + "lib/JIT/JitValueCompare.php", + "lib/JIT/NullsafeHelper.php", + "lib/JIT/OperandName.php", + "lib/JIT/Result.php", + "lib/JIT/Scope.php", + "lib/JIT/StringOffsetHelper.php", + "lib/JIT/SuperglobalInit.php", + "lib/JIT/ValueEchoHelper.php", + "lib/JIT/Variable.php", + "lib/Lint/IncrementDetector.php", + "lib/Lint/Issue.php", + "lib/Lint/LintCompiler.php", + "lib/Lint/Linter.php", + "lib/Lint/ListDestructuringDetector.php", + "lib/Lint/SwitchDetector.php", + "lib/Lint/UnsupportedRegistry.php", + "lib/Module.php", + "lib/ModuleAbstract.php", + "lib/OpCode.php", + "lib/Printer.php", + "lib/Runtime.php", + "lib/VM.php", + "lib/VM/ClassEntry.php", + "lib/VM/ClassProperty.php", + "lib/VM/Context.php", + "lib/VM/ErrorReporter.php", + "lib/VM/ObjectEntry.php", + "lib/VM/Optimizer.php", + "lib/VM/Optimizer/AssignOp.php", + "lib/VM/Refcount.php", + "lib/VM/ScriptExit.php", + "lib/VM/Variable.php", + "lib/Web/DevServer.php", + "lib/Web/ManifestValidator.php", + "lib/Web/Params.php", + "lib/Web/ProjectManifest.php", + "lib/Web/ResponseContext.php", + "lib/Web/Superglobals.php", + "src/cli.php", + "src/llvm-env.php", + "src/macro_functions.php", + "src/tokenizer-compat.php", + "src/yay-php8-compat.php" + ], + "aot_lint_targets": [ + "examples/000-HelloWorld/example.php", + "test/bootstrap-aot/echo_hello.php" + ], + "totals": { + "inventory_files": 251, + "excluded": 2, + "eligible": 249, + "aot_lint_targets": 2 + } +} diff --git a/docs/phpc-json.schema.json b/docs/phpc-json.schema.json new file mode 100644 index 00000000..e520ff4a --- /dev/null +++ b/docs/phpc-json.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/PurHur/php-compiler/docs/phpc-json.schema.json", + "title": "phpc.json project manifest", + "description": "Machine-readable schema for php-compiler web/AOT project manifests (issue #263).", + "type": "object", + "additionalProperties": false, + "properties": { + "binary": { + "type": "string", + "description": "Relative or absolute path to the AOT executable (phpc serve --aot, phpc build -o)." + }, + "public": { + "type": "string", + "description": "Document root for static assets (optional; defaults to project directory when serving)." + }, + "entry": { + "type": "string", + "description": "PHP entry script for phpc build (issue #106; validated when present)." + }, + "index": { + "type": "string", + "description": "Front-controller script path for directory requests (DevServer; optional)." + }, + "includes": { + "type": "array", + "description": "Extra PHP paths to bundle with AOT (issue #106; stub until implemented).", + "items": { + "type": "string" + } + }, + "autoload": { + "type": "object", + "description": "PSR-4 autoload map (issue #155; stub until implemented).", + "additionalProperties": false, + "properties": { + "psr-4": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "required": ["binary"] +} diff --git a/lib/Doctor.php b/lib/Doctor.php index 5451cca2..1ef0530f 100644 --- a/lib/Doctor.php +++ b/lib/Doctor.php @@ -67,6 +67,7 @@ private static function collectChecks(string $repoRoot): array $checks[] = self::checkLlvm($repoRoot); $checks[] = self::checkLoopback($repoRoot); $checks[] = self::checkDockerImage(); + $checks[] = self::checkPhpcJsonManifest($repoRoot); return $checks; } @@ -204,6 +205,34 @@ private static function checkLoopback(string $repoRoot): array ]; } + /** + * @return array{name: string, ok: bool, required: bool, detail: string, hint: string} + */ + private static function checkPhpcJsonManifest(string $repoRoot): array + { + $manifest = $repoRoot.'/phpc.json'; + if (!is_file($manifest)) { + return [ + 'name' => 'phpc.json', + 'ok' => true, + 'required' => false, + 'detail' => 'not present (optional)', + 'hint' => '', + ]; + } + + $errors = \PHPCompiler\Web\ManifestValidator::validate($repoRoot); + $ok = [] === $errors; + + return [ + 'name' => 'phpc.json', + 'ok' => $ok, + 'required' => false, + 'detail' => $ok ? 'valid manifest' : implode('; ', $errors), + 'hint' => $ok ? '' : 'phpc validate-manifest '.$repoRoot, + ]; + } + /** * @return array{name: string, ok: bool, required: bool, detail: string, hint: string} */ diff --git a/lib/Web/ManifestValidator.php b/lib/Web/ManifestValidator.php new file mode 100644 index 00000000..489044db --- /dev/null +++ b/lib/Web/ManifestValidator.php @@ -0,0 +1,163 @@ + */ + private const TOP_LEVEL_KEYS = [ + 'binary', + 'public', + 'entry', + 'index', + 'includes', + 'autoload', + ]; + + /** + * @return list actionable error messages (empty when valid) + */ + public static function validate(string $projectDir): array + { + $dir = realpath($projectDir); + if (false === $dir || !is_dir($dir)) { + return ['project directory not found: '.$projectDir]; + } + + $manifestPath = $dir.'/phpc.json'; + if (!is_file($manifestPath)) { + return ['phpc.json not found in '.$dir]; + } + + $raw = file_get_contents($manifestPath); + if (false === $raw) { + return ['cannot read phpc.json']; + } + + $data = json_decode($raw, true); + if (JSON_ERROR_NONE !== json_last_error()) { + return ['phpc.json is not valid JSON: '.json_last_error_msg()]; + } + if (!is_array($data)) { + return ['phpc.json root must be a JSON object']; + } + + $errors = []; + foreach (array_keys($data) as $key) { + if (!is_string($key) || !in_array($key, self::TOP_LEVEL_KEYS, true)) { + $errors[] = 'unknown key in phpc.json: '.$key; + } + } + + if (!isset($data['binary'])) { + $errors[] = 'missing required key: binary'; + } elseif (!is_string($data['binary']) || '' === $data['binary']) { + $errors[] = 'binary must be a non-empty string'; + } else { + $binaryPath = ProjectManifest::resolveRelativePath($dir, $data['binary']); + if (!is_file($binaryPath)) { + $errors[] = 'binary path not found: '.$data['binary']; + } + } + + if (isset($data['public'])) { + if (!is_string($data['public']) || '' === $data['public']) { + $errors[] = 'public must be a non-empty string'; + } else { + $publicDir = ProjectManifest::resolveRelativePath($dir, $data['public']); + if (!is_dir($publicDir)) { + $errors[] = 'public directory not found: '.$data['public']; + } else { + $index = $publicDir.'/index.php'; + if (!is_file($index)) { + $errors[] = 'missing public/index.php under '.$data['public']; + } + } + } + } + + if (isset($data['entry'])) { + if (!is_string($data['entry']) || '' === $data['entry']) { + $errors[] = 'entry must be a non-empty string'; + } else { + $entryPath = ProjectManifest::resolveRelativePath($dir, $data['entry']); + if (!is_file($entryPath)) { + $errors[] = 'entry path not found: '.$data['entry']; + } + } + } + + if (isset($data['index'])) { + if (!is_string($data['index']) || '' === $data['index']) { + $errors[] = 'index must be a non-empty string'; + } else { + $indexPath = ProjectManifest::resolveRelativePath($dir, $data['index']); + if (!is_file($indexPath)) { + $errors[] = 'index path not found: '.$data['index']; + } + } + } + + if (isset($data['includes'])) { + if (!is_array($data['includes'])) { + $errors[] = 'includes must be an array of strings'; + } else { + foreach ($data['includes'] as $i => $item) { + if (!is_string($item) || '' === $item) { + $errors[] = 'includes['.$i.'] must be a non-empty string'; + } + } + } + } + + if (isset($data['autoload'])) { + $errors = array_merge($errors, self::validateAutoload($data['autoload'])); + } + + return $errors; + } + + /** + * @return list + */ + private static function validateAutoload(mixed $autoload): array + { + if (!is_array($autoload)) { + return ['autoload must be an object']; + } + + $errors = []; + foreach (array_keys($autoload) as $key) { + if (!is_string($key) || 'psr-4' !== $key) { + $errors[] = 'unknown key in autoload: '.(is_string($key) ? $key : '(invalid)'); + } + } + + if (!isset($autoload['psr-4'])) { + return $errors; + } + + $psr4 = $autoload['psr-4']; + if (!is_array($psr4)) { + $errors[] = 'autoload.psr-4 must be an object'; + + return $errors; + } + + foreach ($psr4 as $prefix => $path) { + if (!is_string($prefix) || '' === $prefix) { + $errors[] = 'autoload.psr-4 keys must be non-empty namespace prefixes'; + } + if (!is_string($path) || '' === $path) { + $errors[] = 'autoload.psr-4 values must be non-empty paths'; + } + } + + return $errors; + } +} diff --git a/lib/Web/ProjectManifest.php b/lib/Web/ProjectManifest.php index 7b574c6a..d638bd6b 100644 --- a/lib/Web/ProjectManifest.php +++ b/lib/Web/ProjectManifest.php @@ -15,7 +15,7 @@ final class ProjectManifest public static function resolveBinaryPath(string $startDir, ?string $explicit = null): ?string { if (null !== $explicit && '' !== $explicit) { - $path = self::resolvePath($startDir, $explicit); + $path = self::resolveRelativePath($startDir, $explicit); return is_file($path) ? $path : null; } @@ -30,7 +30,7 @@ public static function resolveBinaryPath(string $startDir, ?string $explicit = n if (is_file($manifest)) { $data = json_decode((string) file_get_contents($manifest), true); if (is_array($data) && isset($data['binary']) && is_string($data['binary'])) { - $candidate = self::resolvePath($dir, $data['binary']); + $candidate = self::resolveRelativePath($dir, $data['binary']); if (is_file($candidate)) { return $candidate; } @@ -44,7 +44,7 @@ public static function resolveBinaryPath(string $startDir, ?string $explicit = n } foreach (['.phpc/bin/app', 'bin/app', 'app'] as $relative) { - $candidate = self::resolvePath($startDir, $relative); + $candidate = self::resolveRelativePath($startDir, $relative); if (is_file($candidate) && is_executable($candidate)) { return $candidate; } @@ -53,7 +53,7 @@ public static function resolveBinaryPath(string $startDir, ?string $explicit = n return null; } - private static function resolvePath(string $baseDir, string $path): string + public static function resolveRelativePath(string $baseDir, string $path): string { if ('/' === $path[0]) { return $path; diff --git a/test/unit/PhpcCliTest.php b/test/unit/PhpcCliTest.php index 757a8f18..286bbc0b 100644 --- a/test/unit/PhpcCliTest.php +++ b/test/unit/PhpcCliTest.php @@ -23,6 +23,7 @@ public function testHelpListsSubcommands(): void $this->assertStringContainsString('phpc lint', $result['stdout']); $this->assertStringContainsString('phpc init', $result['stdout']); $this->assertStringContainsString('phpc doctor', $result['stdout']); + $this->assertStringContainsString('phpc validate-manifest', $result['stdout']); $this->assertStringContainsString('-q', $result['stdout']); $this->assertStringContainsString('$_GET', $result['stdout']); } diff --git a/test/unit/Web/ProjectManifestTest.php b/test/unit/Web/ProjectManifestTest.php new file mode 100644 index 00000000..5b25a050 --- /dev/null +++ b/test/unit/Web/ProjectManifestTest.php @@ -0,0 +1,199 @@ +assertTrue(mkdir($dir)); + try { + file_put_contents($dir.'/phpc.json', '{"binary": "missing"}'); + $errors = ManifestValidator::validate($dir); + $this->assertContains('binary path not found: missing', $errors); + } finally { + $this->removeTree($dir); + } + } + + public function testValidateAcceptsExistingBinaryAndEntry(): void + { + $dir = sys_get_temp_dir().'/phpc_manifest_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($dir)); + $this->assertTrue(mkdir($dir.'/.phpc/bin', 0777, true)); + try { + touch($dir.'/.phpc/bin/app'); + file_put_contents($dir.'/index.php', ' 'index.php', + 'binary' => '.phpc/bin/app', + ], JSON_THROW_ON_ERROR) + ); + $this->assertSame([], ManifestValidator::validate($dir)); + } finally { + $this->removeTree($dir); + } + } + + public function testValidateRejectsUnknownKeys(): void + { + $dir = sys_get_temp_dir().'/phpc_manifest_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($dir)); + $this->assertTrue(mkdir($dir.'/.phpc/bin', 0777, true)); + try { + touch($dir.'/.phpc/bin/app'); + file_put_contents($dir.'/phpc.json', '{"binary": ".phpc/bin/app", "typo": true}'); + $errors = ManifestValidator::validate($dir); + $this->assertContains('unknown key in phpc.json: typo', $errors); + } finally { + $this->removeTree($dir); + } + } + + public function testValidatePublicRequiresIndexPhp(): void + { + $dir = sys_get_temp_dir().'/phpc_manifest_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($dir)); + $this->assertTrue(mkdir($dir.'/.phpc/bin', 0777, true)); + $this->assertTrue(mkdir($dir.'/public', 0777, true)); + try { + touch($dir.'/.phpc/bin/app'); + file_put_contents($dir.'/phpc.json', '{"binary": ".phpc/bin/app", "public": "public"}'); + $errors = ManifestValidator::validate($dir); + $this->assertContains('missing public/index.php under public', $errors); + } finally { + $this->removeTree($dir); + } + } + + public function testPhpcValidateManifestCliMatchesAcceptanceCriteria(): void + { + $dir = sys_get_temp_dir().'/phpc_manifest_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($dir)); + try { + file_put_contents($dir.'/phpc.json', '{"binary": "missing"}'); + $result = $this->runPhpcValidateManifest($dir); + $this->assertSame(1, $result['exit']); + $this->assertStringContainsString('binary path not found: missing', $result['stderr']); + } finally { + $this->removeTree($dir); + } + } + + public function testInitScaffoldPassesValidateManifestAfterTouchBinary(): void + { + $repoRoot = dirname(__DIR__, 3); + $dir = sys_get_temp_dir().'/phpc_init_validate_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($dir)); + try { + $init = $this->runPhpc(['init', $dir], $dir, $repoRoot); + $this->assertSame(0, $init['exit'], $init['stderr']); + $this->assertTrue(mkdir($dir.'/.phpc/bin', 0777, true)); + touch($dir.'/.phpc/bin/app'); + $validate = $this->runPhpc(['validate-manifest', $dir], $dir, $repoRoot); + $this->assertSame(0, $validate['exit'], $validate['stderr']."\n".$validate['stdout']); + $this->assertStringContainsString('phpc.json OK', $validate['stdout']); + } finally { + $this->removeTree($dir); + } + } + + /** + * @return array{exit: int, stdout: string, stderr: string} + */ + private function runPhpcValidateManifest(string $dir): array + { + $repoRoot = dirname(__DIR__, 3); + + return $this->runPhpc(['validate-manifest', $dir], $dir, $repoRoot); + } + + /** + * @param list $phpcArgs + * + * @return array{exit: int, stdout: string, stderr: string} + */ + private function runPhpc(array $phpcArgs, string $cwd, string $repoRoot): array + { + $cmd = array_merge( + self::phpCommand(), + [$repoRoot.'/bin/phpc.php', ...$phpcArgs] + ); + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($cmd, $descriptorSpec, $pipes, $cwd); + $this->assertIsResource($proc); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($proc); + + return [ + 'exit' => is_int($exit) ? $exit : 1, + 'stdout' => false !== $stdout ? $stdout : '', + 'stderr' => false !== $stderr ? $stderr : '', + ]; + } + + private function removeTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir); + if (false === $items) { + return; + } + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + $path = $dir.'/'.$item; + if (is_dir($path)) { + $this->removeTree($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + /** + * @return list + */ + private static function phpCommand(): array + { + $phpEnv = getenv('PHP_COMPILER_PHP'); + if (false !== $phpEnv && '' !== $phpEnv) { + return preg_split('/\s+/', $phpEnv) ?: [PHP_BINARY]; + } + $cmd = [PHP_BINARY]; + $extDir = getenv('PHP_COMPILER_EXT_DIR') ?: '/usr/lib/php/20220829'; + if (is_dir($extDir)) { + foreach (['tokenizer', 'mbstring', 'dom', 'xml', 'xmlwriter', 'ffi', 'posix', 'phar'] as $ext) { + $so = $extDir.'/'.$ext.'.so'; + if (is_file($so)) { + $cmd[] = '-d'; + $cmd[] = 'extension='.$so; + } + } + } + + return $cmd; + } +}