chore(admin): add contract audit baseline and incomplete build notice
Introduce tools/audit-admin-contracts.php to fail on missing schemaPage sections, missing REST $this callbacks, and incomplete admin build chunks. Document menu/schema deploy rules and surface a wp-admin error when assets/admin/build is missing modules instead of failing silently.
This commit is contained in:
+5
-1
@@ -21,4 +21,8 @@ restore-helper-monoliths.php
|
|||||||
# OS / editor noise
|
# OS / editor noise
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|
||||||
|
# Generated contract audit artifact
|
||||||
|
tools/admin-contract-report.json
|
||||||
|
|||||||
@@ -66,6 +66,37 @@ class AKSRT_Admin {
|
|||||||
|
|
||||||
$build_dir = trailingslashit(AKSRT_DIR) . 'assets/admin/build/';
|
$build_dir = trailingslashit(AKSRT_DIR) . 'assets/admin/build/';
|
||||||
$build_uri = trailingslashit(AKSRT_URI) . 'assets/admin/build/';
|
$build_uri = trailingslashit(AKSRT_URI) . 'assets/admin/build/';
|
||||||
|
$entry = $build_dir . 'index.js';
|
||||||
|
|
||||||
|
if (!is_file($entry)) {
|
||||||
|
add_action('admin_notices', static function (): void {
|
||||||
|
if (!current_user_can('manage_options')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo '<div class="notice notice-error"><p>';
|
||||||
|
echo esc_html__('AKSRT 管理后台构建缺失:请在 assets/admin 执行 npm run build,并完整部署 build/ 目录。', 'aksrt');
|
||||||
|
echo '</p></div>';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing_chunks = self::find_missing_admin_chunks($build_dir, $entry);
|
||||||
|
if ($missing_chunks !== []) {
|
||||||
|
add_action('admin_notices', static function () use ($missing_chunks): void {
|
||||||
|
if (!current_user_can('manage_options')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo '<div class="notice notice-error"><p>';
|
||||||
|
echo esc_html__('AKSRT 管理后台构建不完整(动态模块缺失),请重新完整部署 assets/admin/build/,不要只覆盖 index.js。缺失:', 'aksrt');
|
||||||
|
echo ' ' . esc_html(implode(', ', array_slice($missing_chunks, 0, 8)));
|
||||||
|
if (count($missing_chunks) > 8) {
|
||||||
|
echo '…';
|
||||||
|
}
|
||||||
|
echo '</p></div>';
|
||||||
|
});
|
||||||
|
// Still enqueue entry so console shows the real import error if an admin insists;
|
||||||
|
// notice makes the deploy mistake explicit.
|
||||||
|
}
|
||||||
|
|
||||||
$css_files = [
|
$css_files = [
|
||||||
'naive-ui.css',
|
'naive-ui.css',
|
||||||
@@ -84,28 +115,51 @@ class AKSRT_Admin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$entry = $build_dir . 'index.js';
|
wp_enqueue_script(
|
||||||
if (file_exists($entry)) {
|
'aksrt-admin-app',
|
||||||
wp_enqueue_script(
|
$build_uri . 'index.js',
|
||||||
'aksrt-admin-app',
|
[],
|
||||||
$build_uri . 'index.js',
|
(string) filemtime($entry),
|
||||||
[],
|
true
|
||||||
(string) filemtime($entry),
|
);
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
wp_localize_script('aksrt-admin-app', 'aksrtAdminData', [
|
wp_localize_script('aksrt-admin-app', 'aksrtAdminData', [
|
||||||
'apiUrl' => esc_url_raw(rest_url(self::API_NAMESPACE)),
|
'apiUrl' => esc_url_raw(rest_url(self::API_NAMESPACE)),
|
||||||
'nonce' => wp_create_nonce('wp_rest'),
|
'nonce' => wp_create_nonce('wp_rest'),
|
||||||
'siteUrl' => home_url('/'),
|
'siteUrl' => home_url('/'),
|
||||||
'adminUrl' => admin_url('/'),
|
'adminUrl' => admin_url('/'),
|
||||||
'themeVersion' => wp_get_theme()->get('Version') ?: '3.4.0-dev',
|
'themeVersion' => wp_get_theme()->get('Version') ?: '3.4.0-dev',
|
||||||
]);
|
'buildOk' => $missing_chunks === [],
|
||||||
}
|
]);
|
||||||
|
|
||||||
wp_enqueue_media();
|
wp_enqueue_media();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse index.js dynamic imports and report missing chunk files.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function find_missing_admin_chunks(string $build_dir, string $entry_file): array {
|
||||||
|
$src = file_get_contents($entry_file);
|
||||||
|
if (!is_string($src) || $src === '') {
|
||||||
|
return ['index.js unreadable'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match_all('/\\.\\/([A-Za-z0-9_.-]+\\.js)/', $src, $matches)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
foreach (array_unique($matches[1]) as $chunk) {
|
||||||
|
if (!is_file($build_dir . $chunk)) {
|
||||||
|
$missing[] = $chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $missing;
|
||||||
|
}
|
||||||
|
|
||||||
public function add_module_type_attribute(string $tag, string $handle): string {
|
public function add_module_type_attribute(string $tag, string $handle): string {
|
||||||
if ('aksrt-admin-app' === $handle) {
|
if ('aksrt-admin-app' === $handle) {
|
||||||
return str_replace('<script ', '<script type="module" ', $tag);
|
return str_replace('<script ', '<script type="module" ', $tag);
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Admin contract map
|
||||||
|
|
||||||
|
Generated by process, not a migration guide. Source of truth for menu ↔ schema wiring.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Every `schemaPage` must have matching PHP section under the same domain.
|
||||||
|
2. Enum field values must be identical in PHP schema and JS schema.
|
||||||
|
3. REST `'callback' => [$this, 'method']` methods must exist (class or trait).
|
||||||
|
4. Deploy `assets/admin/build/` as one atomic unit after `npm run build`.
|
||||||
|
5. No compat aliases, dual-read, or silent frontend catch for missing APIs.
|
||||||
|
|
||||||
|
## Audit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php tools/audit-admin-contracts.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code `1` means hard contract errors.
|
||||||
|
|
||||||
|
## Domain builders
|
||||||
|
|
||||||
|
| Domain | PHP builder map key | Menu constants file |
|
||||||
|
|--------|---------------------|---------------------|
|
||||||
|
| global | `AKSRT_Admin_Schema_Global` | `assets/admin/src/constants/sections/global.js` |
|
||||||
|
| page_display | `AKSRT_Admin_Schema_Page_Display` | `page-display.js` |
|
||||||
|
| post_list | `AKSRT_Admin_Schema_Post_List` | `post-list.js` |
|
||||||
|
| permissions | `AKSRT_Admin_Schema_Permissions` | `permissions.js` |
|
||||||
|
| users | `AKSRT_Admin_Schema_Users` | `users.js` |
|
||||||
|
| payment | `AKSRT_Admin_Schema_Payment` | `payment.js` |
|
||||||
|
| extensions | `AKSRT_Admin_Schema_Extensions` | `extensions.js` |
|
||||||
|
| tools | `AKSRT_Admin_Schema_Tools` | `tools.js` |
|
||||||
|
| shop | `AKSRT_Admin_Schema_Shop` | `shop.js` |
|
||||||
|
| forum | `AKSRT_Admin_Schema_Forum` | `forum.js` |
|
||||||
|
|
||||||
|
## Known custom pages (not schema REST)
|
||||||
|
|
||||||
|
These use dedicated Vue pages + dedicated REST modules:
|
||||||
|
|
||||||
|
- Homepage: `HomepageSettings` + `/settings/homepage`
|
||||||
|
- Footer: `FooterSettings` + footer settings API
|
||||||
|
- Permissions capabilities matrix: `PermissionsCapabilities` + `/permissions/capabilities`
|
||||||
|
- Shop products/orders and other operational tables
|
||||||
|
|
||||||
|
## Deploy checklist
|
||||||
|
|
||||||
|
1. `cd assets/admin && npm ci && npm run build`
|
||||||
|
2. Confirm `build/index.js` and every dynamically imported `./Chunk.hash.js` exist
|
||||||
|
3. Replace the entire remote `assets/admin/build/` directory
|
||||||
|
4. Hard-refresh wp-admin (or purge CDN)
|
||||||
|
5. Re-run `php tools/audit-admin-contracts.php`
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
<?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 . '/inc/classes/admin/schema/class-aksrt-admin-schema-global.php',
|
||||||
|
'page_display' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-page-display.php',
|
||||||
|
'post_list' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-post-list.php',
|
||||||
|
'permissions' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-permissions.php',
|
||||||
|
'users' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-users.php',
|
||||||
|
'payment' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-payment.php',
|
||||||
|
'extensions' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-extensions.php',
|
||||||
|
'tools' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-tools.php',
|
||||||
|
'shop' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-shop.php',
|
||||||
|
'forum' => $themeRoot . '/inc/classes/admin/schema/class-aksrt-admin-schema-forum.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 = glob($themeRoot . '/inc/classes/admin/schema/class-aksrt-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-/', '', $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.
|
||||||
|
$domain = str_replace('-', '_', $parts[0] ?? '');
|
||||||
|
}
|
||||||
|
$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 = glob($themeRoot . '/inc/classes/admin/schema/class-aksrt-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 . '/inc/classes/admin';
|
||||||
|
$methodIndex = [];
|
||||||
|
|
||||||
|
$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