chore(agents): implement v2 hardening and enforcement-ready QA

This commit is contained in:
2026-04-01 19:41:56 +02:00
parent 7121732fcf
commit dba589b495
31 changed files with 1349 additions and 78 deletions

View File

@@ -0,0 +1,108 @@
<?php
namespace MintyPHP\App\Module;
use Opis\JsonSchema\Errors\ErrorFormatter;
use Opis\JsonSchema\Errors\ValidationError;
use Opis\JsonSchema\JsonPointer;
use Opis\JsonSchema\Validator;
use RuntimeException;
final class ModuleManifestSchemaValidator
{
private const DEFAULT_SCHEMA_PATH = '.agents/contracts/module-manifest.schema.json';
/** @var array<string, object> */
private static array $schemaCache = [];
public static function assertValid(
array $rawManifest,
string $moduleId,
?string $manifestFile = null,
?string $schemaFile = null
): void {
$schemaPath = self::resolveSchemaPath($schemaFile);
$schema = self::loadSchema($schemaPath);
$payload = json_decode((string) json_encode($rawManifest), false);
if (!is_object($payload)) {
throw new RuntimeException("Manifest schema validation failed for module '{$moduleId}': manifest payload is not serializable.");
}
$validator = new Validator();
$result = $validator->validate($payload, $schema);
if ($result->isValid()) {
return;
}
$location = $manifestFile ?? ("modules/{$moduleId}/module.php");
$message = self::formatValidationErrors($result->error());
throw new RuntimeException(
"Manifest schema validation failed for module '{$moduleId}' ({$location}): {$message}"
);
}
private static function resolveSchemaPath(?string $schemaFile): string
{
if ($schemaFile !== null && trim($schemaFile) !== '') {
return $schemaFile;
}
return dirname(__DIR__, 3) . '/' . self::DEFAULT_SCHEMA_PATH;
}
private static function loadSchema(string $schemaPath): object
{
$normalizedPath = str_replace('\\', '/', $schemaPath);
if (isset(self::$schemaCache[$normalizedPath])) {
return self::$schemaCache[$normalizedPath];
}
if (!is_file($schemaPath)) {
throw new RuntimeException("Module manifest schema not found: {$schemaPath}");
}
$rawSchema = file_get_contents($schemaPath);
if ($rawSchema === false) {
throw new RuntimeException("Unable to read module manifest schema: {$schemaPath}");
}
$decodedSchema = json_decode($rawSchema);
if (!is_object($decodedSchema)) {
throw new RuntimeException("Invalid JSON schema in {$schemaPath}");
}
self::$schemaCache[$normalizedPath] = $decodedSchema;
return $decodedSchema;
}
private static function formatValidationErrors(?ValidationError $error): string
{
if ($error === null) {
return 'unknown schema validation error';
}
$formatter = new ErrorFormatter();
$messages = $formatter->formatFlat(
$error,
static function (ValidationError $validationError) use ($formatter): string {
$path = JsonPointer::pathToString($validationError->data()->fullPath());
$normalizedPath = $path === '' ? '/' : $path;
$message = $formatter->formatErrorMessage($validationError);
return "{$normalizedPath}: {$message}";
}
);
$messages = array_values(array_unique(array_filter(
$messages,
static fn ($message): bool => is_string($message) && trim($message) !== ''
)));
if ($messages === []) {
return 'unknown schema validation error';
}
return implode('; ', array_slice($messages, 0, 3));
}
}