From ffd8d7d1b792f0576a2ed4594428b7113f4927fa Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 26 Oct 2026 13:04:46 +0800 Subject: [PATCH] fix(ticket): flatten TicketRepository traits to PSR-4-visible paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TicketRepository fatalled on /user/tickets/ with "Trait AKSRT\Ticket\TicketQuery not found". src/ classes load only via Composer PSR-4 (AKSRT\ => src/) — there is no bootstrap require list for them — so the autoloader resolved the traits by namespace to src/Ticket/TicketQuery.php and src/Ticket/TicketStatus.php. Both files actually sat in src/Ticket/TicketRepository-traits/, one level deeper, and were therefore invisible: PHP hit the `use` statement with no trait declared and died the moment the repository compiled. The nested directory name imitated the old inc/classes/**/*-traits/ layout, which only worked because inc/bootstrap.php carried an explicit require_once for every one of those files. No such mechanism exists under src/. Move both traits up to sit flat as siblings of the class that uses them — the same shape as GlobalControllerPart1, ModerationPart1 and the rest of src/. No code changes inside the traits; their namespace was already correct. Add tools/audit-psr4-paths.php, which checks every symbol under src/ against the path Composer maps it to and exits 1 on mismatch. It flags this exact layout, so the nesting cannot come back unnoticed. Document it in CLAUDE.md next to the existing admin-contract audit. Co-Authored-By: Claude Opus 5 (1M context) --- .../TicketQuery.php | 0 src/Ticket/TicketRepository.php | 2 + .../TicketStatus.php | 0 tools/audit-psr4-paths.php | 76 +++++++++++++++++++ 4 files changed, 78 insertions(+) rename src/Ticket/{TicketRepository-traits => }/TicketQuery.php (100%) rename src/Ticket/{TicketRepository-traits => }/TicketStatus.php (100%) create mode 100644 tools/audit-psr4-paths.php diff --git a/src/Ticket/TicketRepository-traits/TicketQuery.php b/src/Ticket/TicketQuery.php similarity index 100% rename from src/Ticket/TicketRepository-traits/TicketQuery.php rename to src/Ticket/TicketQuery.php diff --git a/src/Ticket/TicketRepository.php b/src/Ticket/TicketRepository.php index 40fa4f88..fa702b66 100644 --- a/src/Ticket/TicketRepository.php +++ b/src/Ticket/TicketRepository.php @@ -133,6 +133,8 @@ final class TicketRepository { /* ------------------------------------------------------------------ * * 状态机(对外入口,内部规则在 TicketStatus trait) + * + * Trait 必须与宿主类同级平铺:PSR-4 按命名空间寻址,不解析子目录。 * ------------------------------------------------------------------ */ /** diff --git a/src/Ticket/TicketRepository-traits/TicketStatus.php b/src/Ticket/TicketStatus.php similarity index 100% rename from src/Ticket/TicketRepository-traits/TicketStatus.php rename to src/Ticket/TicketStatus.php diff --git a/tools/audit-psr4-paths.php b/tools/audit-psr4-paths.php new file mode 100644 index 00000000..ec6d2ff8 --- /dev/null +++ b/tools/audit-psr4-paths.php @@ -0,0 +1,76 @@ + src/` (composer.json) — no bootstrap require list — so a misplaced + * file loads nowhere and fatals the moment its host class compiles. + * + * Usage (from theme root): + * php tools/audit-psr4-paths.php + * + * Exit 1 on hard errors. + */ + +declare(strict_types=1); + +$themeRoot = dirname(__DIR__); +$srcRoot = $themeRoot . '/src'; +$prefix = 'AKSRT'; + +$rii = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($srcRoot, FilesystemIterator::SKIP_DOTS) +); + +$files = []; +foreach ($rii as $file) { + if ($file->isFile() && strtolower($file->getExtension()) === 'php') { + $files[] = $file->getPathname(); + } +} +sort($files); + +$problems = []; +foreach ($files as $path) { + $src = (string) file_get_contents($path); + + if (!preg_match('/^\s*namespace\s+([^;]+);/m', $src, $nsMatch)) { + continue; + } + $namespace = trim($nsMatch[1]); + if (!str_starts_with($namespace, $prefix . '\\')) { + continue; + } + + if (!preg_match('/^\s*(?:final\s+|abstract\s+|readonly\s+)*(?:class|interface|trait|enum)\s+(\w+)/m', $src, $symMatch)) { + continue; + } + $symbol = $symMatch[1]; + + $relative = str_replace('\\', '/', substr($namespace, strlen($prefix))); + $expected = str_replace('//', '/', $srcRoot . $relative . '/' . $symbol . '.php'); + + if (realpath($expected) !== realpath($path)) { + $problems[] = sprintf( + "%s declares %s\\%s -> expected %s", + str_replace('\\', '/', substr($path, strlen($srcRoot) + 1)), + $namespace, + $symbol, + str_replace('\\', '/', substr($expected, strlen($srcRoot) + 1)) + ); + } +} + +foreach ($problems as $problem) { + fwrite(STDERR, '[error] ' . $problem . PHP_EOL); +} +fwrite(STDERR, sprintf( + 'scanned=%d errors=%d%s', + count($files), + count($problems), + PHP_EOL +)); + +exit($problems === [] ? 0 : 1);