437 lines
16 KiB
PHP
437 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* Admin contract audit — menu ↔ PHP schema ↔ REST $this callbacks ↔ build integrity.
|
|
*
|
|
* Usage (from theme root):
|
|
* php tools/audit-admin-contracts.php
|
|
*
|
|
* Exit 1 on hard errors.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
$themeRoot = dirname(__DIR__);
|
|
$errors = [];
|
|
$warnings = [];
|
|
$info = [];
|
|
|
|
function rel(string $path, string $root): string {
|
|
return str_replace('\\', '/', ltrim(str_replace($root, '', $path), '\\/'));
|
|
}
|
|
|
|
/**
|
|
* Extract section keys from composite schema builders (including delegated leaf builders).
|
|
*
|
|
* @return array<string, array{sections: string[], files: string[]}>
|
|
*/
|
|
function collect_php_schema_sections(string $themeRoot): array {
|
|
$map = [];
|
|
|
|
$register = static function (string $domain, string $section, string $file) use (&$map, $themeRoot): void {
|
|
if ($domain === '' || $section === '') {
|
|
return;
|
|
}
|
|
if (!isset($map[$domain])) {
|
|
$map[$domain] = ['sections' => [], 'files' => []];
|
|
}
|
|
$map[$domain]['sections'][$section] = true;
|
|
$fileRel = rel($file, $themeRoot);
|
|
if (!in_array($fileRel, $map[$domain]['files'], true)) {
|
|
$map[$domain]['files'][] = $fileRel;
|
|
}
|
|
};
|
|
|
|
// Primary domain builders.
|
|
$primary = [
|
|
'global' => $themeRoot . '/src/Admin/Schema/GlobalSchema.php',
|
|
'page_display' => $themeRoot . '/src/Admin/Schema/SchemaPageDisplay.php',
|
|
'post_list' => $themeRoot . '/src/Admin/Schema/SchemaPostList.php',
|
|
'permissions' => $themeRoot . '/src/Admin/Schema/SchemaPermissions.php',
|
|
'users' => $themeRoot . '/src/Admin/Schema/SchemaUsers.php',
|
|
'payment' => $themeRoot . '/src/Admin/Schema/SchemaPayment.php',
|
|
'extensions' => $themeRoot . '/src/Admin/Schema/SchemaExtensions.php',
|
|
'tools' => $themeRoot . '/src/Admin/Schema/SchemaTools.php',
|
|
'shop' => $themeRoot . '/src/Admin/Schema/SchemaShop.php',
|
|
];
|
|
|
|
foreach ($primary as $domain => $file) {
|
|
if (!is_file($file)) {
|
|
continue;
|
|
}
|
|
$src = file_get_contents($file);
|
|
if ($src === false) {
|
|
continue;
|
|
}
|
|
// Direct section keys: 'foo' => [ or 'foo' => Class::build()
|
|
if (preg_match_all("/'([a-z0-9_-]+)'\\s*=>\\s*(?:\\[|[A-Za-z0-9_]+::build\\(\\))/", $src, $m)) {
|
|
foreach ($m[1] as $section) {
|
|
if (in_array($section, ['domain', 'title', 'version', 'option_key', 'key', 'sections', 'groups', 'fields'], true)) {
|
|
continue;
|
|
}
|
|
$register($domain, $section, $file);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Leaf builders that return a single section with 'key' => 'section-id'
|
|
$leafFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
|
|
foreach ($leafFiles as $file) {
|
|
$src = file_get_contents($file);
|
|
if ($src === false) {
|
|
continue;
|
|
}
|
|
// Skip pure composites that only delegate.
|
|
$isComposite = (bool) preg_match("/'sections'\\s*=>\\s*\\[/", $src);
|
|
if ($isComposite) {
|
|
continue;
|
|
}
|
|
if (!preg_match("/'key'\\s*=>\\s*'([a-z0-9_-]+)'/", $src, $km)) {
|
|
continue;
|
|
}
|
|
$section = $km[1];
|
|
// Infer domain from filename: class-aksrt-admin-schema-{domain}-{section}.php
|
|
$base = basename($file, '.php');
|
|
$base = preg_replace('/^(class-aksrt-admin-schema-|Schema)/', '', $base);
|
|
$parts = explode('-', $base);
|
|
// domain may be multi-token (page_display is page-display in menus but page_display in PHP).
|
|
// Prefer matching known domains by prefix.
|
|
$domain = null;
|
|
foreach (array_keys($primary) as $candidate) {
|
|
$candidateDash = str_replace('_', '-', $candidate);
|
|
if ($base === $candidateDash || str_starts_with($base, $candidateDash . '-')) {
|
|
$domain = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
if ($domain === null) {
|
|
// Fallback: first token / PSR-4 Schema* short names.
|
|
if (str_contains($base, 'PostList')) {
|
|
$domain = 'post_list';
|
|
} else {
|
|
$domain = str_replace('-', '_', $parts[0] ?? '');
|
|
}
|
|
}
|
|
// Leaf under Schema/PostList/ with key list → post_list/list
|
|
if ($domain === 'post_list' && $section === 'list') {
|
|
$register($domain, $section, $file);
|
|
continue;
|
|
}
|
|
$register($domain, $section, $file);
|
|
}
|
|
|
|
foreach ($map as $domain => $payload) {
|
|
$map[$domain]['sections'] = array_keys($payload['sections']);
|
|
sort($map[$domain]['sections']);
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
function collect_menu_pages(string $themeRoot): array {
|
|
$dir = $themeRoot . '/assets/admin/src/constants/sections';
|
|
$pages = [];
|
|
foreach (glob($dir . '/*.js') ?: [] as $file) {
|
|
$base = basename($file);
|
|
if (in_array($base, ['helpers.js', 'index.js'], true)) {
|
|
continue;
|
|
}
|
|
$src = file_get_contents($file);
|
|
if ($src === false) {
|
|
continue;
|
|
}
|
|
foreach (['schemaPage' => 'schemaPage', 'customPage' => 'customPage', 'deferredPage' => 'deferredPage'] as $fn => $kind) {
|
|
if (!preg_match_all('/' . $fn . '\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)/', $src, $blocks)) {
|
|
continue;
|
|
}
|
|
foreach ($blocks[1] as $block) {
|
|
$page = ['file' => rel($file, $themeRoot), 'kind' => $kind];
|
|
foreach (['key', 'canonicalDomain', 'section', 'route', 'component', 'name'] as $prop) {
|
|
if (preg_match('/' . $prop . ":\\s*'([^']*)'/", $block, $m)) {
|
|
$page[$prop === 'canonicalDomain' ? 'domain' : $prop] = $m[1];
|
|
}
|
|
}
|
|
$pages[] = $page;
|
|
}
|
|
}
|
|
}
|
|
return $pages;
|
|
}
|
|
|
|
/**
|
|
* Collect enum options for fields with balanced-enough heuristics:
|
|
* only consider field objects that include both type radio|select and an options array
|
|
* on nearby lines, keyed by the field key property.
|
|
*
|
|
* @return array{php: array<string, string[]>, js: array<string, string[]>}
|
|
*/
|
|
function collect_enum_maps(string $themeRoot): array {
|
|
$php = [];
|
|
$js = [];
|
|
|
|
$phpFiles = array_merge(glob($themeRoot . '/src/Admin/Schema/*.php') ?: [], glob($themeRoot . '/src/Admin/Schema/*/*.php') ?: []);
|
|
foreach ($phpFiles as $file) {
|
|
$src = file_get_contents($file);
|
|
if ($src === false) {
|
|
continue;
|
|
}
|
|
// Split roughly by field array starts: ['key' => '...'
|
|
if (!preg_match_all("/\\['key'\\s*=>\\s*'([^']+)'([\\s\\S]*?)(?=\\['key'\\s*=>|\\]\\s*,\\s*\\[\\s*'key'|\\]\\s*\\]\\s*,\\s*\\[\\s*'key'|$)/", $src, $chunks, PREG_SET_ORDER)) {
|
|
continue;
|
|
}
|
|
foreach ($chunks as $chunk) {
|
|
$key = $chunk[1];
|
|
$body = $chunk[2];
|
|
if (!preg_match("/'type'\\s*=>\\s*'(radio|select)'/", $body)) {
|
|
continue;
|
|
}
|
|
if (!preg_match("/'options'\\s*=>\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
|
|
continue;
|
|
}
|
|
$values = [];
|
|
if (preg_match_all("/'value'\\s*=>\\s*'([^']*)'/", $om[1], $vm)) {
|
|
$values = $vm[1];
|
|
}
|
|
// Keep first definition; warn later if duplicates differ.
|
|
if (!isset($php[$key])) {
|
|
$php[$key] = $values;
|
|
}
|
|
}
|
|
}
|
|
|
|
$jsRoot = $themeRoot . '/assets/admin/src/schema/sections';
|
|
if (is_dir($jsRoot)) {
|
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($jsRoot, FilesystemIterator::SKIP_DOTS));
|
|
foreach ($iterator as $file) {
|
|
if ($file->getExtension() !== 'js') {
|
|
continue;
|
|
}
|
|
$src = file_get_contents($file->getPathname());
|
|
if ($src === false) {
|
|
continue;
|
|
}
|
|
if (!preg_match_all("/\\{\\s*key:\\s*'([^']+)'([\\s\\S]*?)(?=\\{\\s*key:\\s*'|$)/", $src, $chunks, PREG_SET_ORDER)) {
|
|
continue;
|
|
}
|
|
foreach ($chunks as $chunk) {
|
|
$key = $chunk[1];
|
|
$body = $chunk[2];
|
|
// Stop body at next field-ish boundary if options not closed well.
|
|
if (!preg_match("/type:\\s*'(radio|select)'/", $body)) {
|
|
continue;
|
|
}
|
|
if (!preg_match("/options:\\s*\\[([\\s\\S]*?)\\]/", $body, $om)) {
|
|
continue;
|
|
}
|
|
$values = [];
|
|
if (preg_match_all("/value:\\s*'([^']*)'/", $om[1], $vm)) {
|
|
$values = $vm[1];
|
|
}
|
|
if (!isset($js[$key])) {
|
|
$js[$key] = $values;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ['php' => $php, 'js' => $js];
|
|
}
|
|
|
|
function collect_missing_this_callbacks(string $themeRoot): array {
|
|
$issues = [];
|
|
$adminRoot = $themeRoot . '/src/Admin';
|
|
$methodIndex = [];
|
|
|
|
if (!is_dir($adminRoot)) {
|
|
return $issues;
|
|
}
|
|
|
|
$allPhp = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($adminRoot, FilesystemIterator::SKIP_DOTS));
|
|
foreach ($allPhp as $file) {
|
|
if ($file->getExtension() !== 'php') {
|
|
continue;
|
|
}
|
|
$src = file_get_contents($file->getPathname());
|
|
if ($src === false) {
|
|
continue;
|
|
}
|
|
if (preg_match_all('/function\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(/', $src, $mm)) {
|
|
foreach ($mm[1] as $method) {
|
|
$methodIndex[$method] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($allPhp as $file) {
|
|
if ($file->getExtension() !== 'php') {
|
|
continue;
|
|
}
|
|
$path = $file->getPathname();
|
|
$src = file_get_contents($path);
|
|
if ($src === false) {
|
|
continue;
|
|
}
|
|
if (!preg_match_all("/'callback'\\s*=>\\s*\\[\\s*\\\$this\\s*,\\s*'([^']+)'\\s*\\]/", $src, $matches)) {
|
|
continue;
|
|
}
|
|
foreach ($matches[1] as $method) {
|
|
if (empty($methodIndex[$method])) {
|
|
$issues[] = [
|
|
'file' => rel($path, $themeRoot),
|
|
'method' => $method,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $issues;
|
|
}
|
|
|
|
function audit_build_manifest(string $themeRoot): array {
|
|
$buildDir = $themeRoot . '/assets/admin/build';
|
|
$result = [
|
|
'present' => is_dir($buildDir),
|
|
'entry' => false,
|
|
'missing_chunks' => [],
|
|
'chunks' => [],
|
|
];
|
|
if (!$result['present']) {
|
|
return $result;
|
|
}
|
|
$entry = $buildDir . '/index.js';
|
|
$result['entry'] = is_file($entry);
|
|
if (!$result['entry']) {
|
|
return $result;
|
|
}
|
|
$src = file_get_contents($entry);
|
|
if ($src === false) {
|
|
return $result;
|
|
}
|
|
if (preg_match_all('/\\.\\/([A-Za-z0-9_.-]+\\.js)/', $src, $m)) {
|
|
$result['chunks'] = array_values(array_unique($m[1]));
|
|
foreach ($result['chunks'] as $chunk) {
|
|
if (!is_file($buildDir . '/' . $chunk)) {
|
|
$result['missing_chunks'][] = $chunk;
|
|
}
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
$phpSchemas = collect_php_schema_sections($themeRoot);
|
|
$menuPages = collect_menu_pages($themeRoot);
|
|
$enums = collect_enum_maps($themeRoot);
|
|
$restIssues = collect_missing_this_callbacks($themeRoot);
|
|
$build = audit_build_manifest($themeRoot);
|
|
|
|
$info[] = 'PHP schema domains: ' . implode(', ', array_keys($phpSchemas));
|
|
$info[] = 'Menu pages discovered: ' . count($menuPages);
|
|
|
|
foreach ($menuPages as $page) {
|
|
if (($page['kind'] ?? '') !== 'schemaPage') {
|
|
continue;
|
|
}
|
|
$domain = $page['domain'] ?? '';
|
|
$section = $page['section'] ?? '';
|
|
$key = $page['key'] ?? '(unknown)';
|
|
if ($domain === '' || $section === '') {
|
|
$errors[] = "schemaPage {$key} missing canonicalDomain/section in {$page['file']}";
|
|
continue;
|
|
}
|
|
if (!isset($phpSchemas[$domain])) {
|
|
$errors[] = "schemaPage {$key} domain '{$domain}' has no PHP schema builder";
|
|
continue;
|
|
}
|
|
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
|
|
$errors[] = "schemaPage {$key} section '{$domain}/{$section}' missing in PHP schema; known=[" . implode(',', $phpSchemas[$domain]['sections']) . '] files=' . implode(',', $phpSchemas[$domain]['files']);
|
|
}
|
|
}
|
|
|
|
foreach ($menuPages as $page) {
|
|
if (($page['kind'] ?? '') !== 'customPage') {
|
|
continue;
|
|
}
|
|
$domain = $page['domain'] ?? '';
|
|
$section = $page['section'] ?? '';
|
|
if ($domain === '' || $section === '') {
|
|
continue;
|
|
}
|
|
if (!isset($phpSchemas[$domain])) {
|
|
$warnings[] = "customPage {$page['key']} references domain '{$domain}' without PHP schema map entry";
|
|
continue;
|
|
}
|
|
if (!in_array($section, $phpSchemas[$domain]['sections'], true)) {
|
|
$warnings[] = "customPage {$page['key']} section '{$domain}/{$section}' not in PHP schema (ok if pure operational page)";
|
|
}
|
|
}
|
|
|
|
// Enum mismatches only for shared keys with non-empty option sets on both sides.
|
|
// Heuristic parsers often capture only the default option from dense PHP arrays;
|
|
// treat "PHP is a non-empty proper subset of JS" as a parser warning, not a hard error.
|
|
// Hard error when sets are disjoint or PHP introduces values JS does not have.
|
|
$shared = array_intersect(array_keys($enums['php']), array_keys($enums['js']));
|
|
sort($shared);
|
|
foreach ($shared as $key) {
|
|
$phpValues = $enums['php'][$key];
|
|
$jsValues = $enums['js'][$key];
|
|
if ($phpValues === [] && $jsValues === []) {
|
|
continue;
|
|
}
|
|
$phpSorted = $phpValues;
|
|
$jsSorted = $jsValues;
|
|
sort($phpSorted);
|
|
sort($jsSorted);
|
|
if ($phpSorted === $jsSorted) {
|
|
continue;
|
|
}
|
|
$phpOnly = array_values(array_diff($phpValues, $jsValues));
|
|
$jsOnly = array_values(array_diff($jsValues, $phpValues));
|
|
if ($phpOnly === [] && $jsOnly !== [] && $phpValues !== []) {
|
|
$warnings[] = "enum incomplete parse for '{$key}' (php subset of js): php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . ']';
|
|
continue;
|
|
}
|
|
$errors[] = "enum mismatch for '{$key}': php=[" . implode(',', $phpValues) . '] js=[' . implode(',', $jsValues) . '] php_only=[' . implode(',', $phpOnly) . '] js_only=[' . implode(',', $jsOnly) . ']';
|
|
}
|
|
|
|
foreach ($restIssues as $issue) {
|
|
$errors[] = "REST \$this callback missing: {$issue['method']} in {$issue['file']}";
|
|
}
|
|
|
|
if (!$build['present']) {
|
|
$warnings[] = 'assets/admin/build/ missing — run: cd assets/admin && npm run build';
|
|
} elseif (!$build['entry']) {
|
|
$errors[] = 'assets/admin/build/index.js missing';
|
|
} elseif ($build['missing_chunks']) {
|
|
$errors[] = 'admin build missing chunks: ' . implode(', ', $build['missing_chunks']);
|
|
} else {
|
|
$info[] = 'admin build ok; chunks=' . count($build['chunks']);
|
|
}
|
|
|
|
echo "AKSRT admin contract audit\n";
|
|
echo str_repeat('=', 48) . "\n";
|
|
foreach ($info as $line) {
|
|
echo "[info] {$line}\n";
|
|
}
|
|
foreach ($warnings as $line) {
|
|
echo "[warn] {$line}\n";
|
|
}
|
|
foreach ($errors as $line) {
|
|
echo "[error] {$line}\n";
|
|
}
|
|
echo str_repeat('-', 48) . "\n";
|
|
echo 'errors=' . count($errors) . ' warnings=' . count($warnings) . "\n";
|
|
|
|
$summaryPath = $themeRoot . '/tools/admin-contract-report.json';
|
|
file_put_contents($summaryPath, json_encode([
|
|
'generated_at' => gmdate('c'),
|
|
'errors' => $errors,
|
|
'warnings' => $warnings,
|
|
'info' => $info,
|
|
'domains' => $phpSchemas,
|
|
'page_count' => count($menuPages),
|
|
'build' => $build,
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
echo "wrote tools/admin-contract-report.json\n";
|
|
|
|
// Ignore report in git by default? Keep it for CI artifact; do not commit noise if huge.
|
|
exit(count($errors) > 0 ? 1 : 0);
|