*/ 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', 'forum' => $themeRoot . '/src/Admin/Schema/SchemaForum.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, js: array} */ 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);