chore(repo): track full tests/ and tools/ suites, drop per-file whitelist
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
<?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), '\\/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the source of the first balanced [ ... ] array assigned to $needle,
|
||||
* brackets included; null when absent or unbalanced.
|
||||
*/
|
||||
function extract_balanced_block(string $src, string $needle): ?string {
|
||||
$pos = strpos($src, $needle);
|
||||
if ($pos === false) {
|
||||
return null;
|
||||
}
|
||||
$open = strpos($src, '[', $pos);
|
||||
if ($open === false) {
|
||||
return null;
|
||||
}
|
||||
$depth = 0;
|
||||
$len = strlen($src);
|
||||
for ($i = $open; $i < $len; $i++) {
|
||||
$ch = $src[$i];
|
||||
if ($ch === "'" || $ch === '"') {
|
||||
$quote = $ch;
|
||||
$i++;
|
||||
while ($i < $len) {
|
||||
if ($src[$i] === '\\') {
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
if ($src[$i] === $quote) {
|
||||
break;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($ch === '[') {
|
||||
$depth++;
|
||||
} elseif ($ch === ']') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return substr($src, $open, $i - $open + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* String keys written directly inside an outer [ ... ] block (bracket depth 1),
|
||||
* regardless of whether their value is an inline array, Class::build() or
|
||||
* self::helper(). Nested 'k => v' pairs deeper down are ignored.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
function top_level_array_keys(string $block): array {
|
||||
$keys = [];
|
||||
$depth = 0;
|
||||
$len = strlen($block);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ch = $block[$i];
|
||||
if ($ch === "'" || $ch === '"') {
|
||||
$start = $i;
|
||||
$quote = $ch;
|
||||
$i++;
|
||||
while ($i < $len) {
|
||||
if ($block[$i] === '\\') {
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
if ($block[$i] === $quote) {
|
||||
break;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
$j = $i + 1;
|
||||
while ($j < $len && ctype_space($block[$j])) {
|
||||
$j++;
|
||||
}
|
||||
if ($depth === 1 && substr($block, $j, 2) === '=>') {
|
||||
$keys[] = substr($block, $start + 1, $i - $start - 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($ch === '[') {
|
||||
$depth++;
|
||||
} elseif ($ch === ']') {
|
||||
$depth--;
|
||||
}
|
||||
}
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
// Only keys at the first level of the 'sections' block are sections;
|
||||
// whole-file regexes also match nested field/group arrays ('all' => [,
|
||||
// 'fields' => [, ...) and miss self::helper() delegates.
|
||||
$block = extract_balanced_block($src, "'sections'");
|
||||
if ($block === null) {
|
||||
continue;
|
||||
}
|
||||
foreach (top_level_array_keys($block) 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);
|
||||
Reference in New Issue
Block a user