['/vendor/', '/tests/', '/cache/', '/node_modules/'], 'skip_directories' => [], 'extensions' => ['php'], 'max_depth' => 20, 'follow_links' => false, 'cache_enabled' => false, 'cache_ttl' => 3600, 'opcache_compile' => false, 'require_files_without_classes' => false, 'debug' => false, ]; private static ?string $currentFile = null; public function __construct() { $this->basePath = $this->normalizePath($_SERVER['PWD'] ?? APP_PATH ?? getcwd()); $this->manifest = new ScanManifest(); } public function setConfig(array $config): void { $this->config = array_merge($this->config, $config); } public function scan(string $path, ?string $cacheFile = null): ChangeSet { $path = $this->normalizePath($path); $this->changeSet = new ChangeSet(); $this->visitedFiles = []; $cacheLoaded = false; if ($this->config['cache_enabled']) { $cacheFile = $cacheFile ?? $this->getDefaultCacheFile(); if ($this->loadFromCache($cacheFile, $path)) { $this->replayManifest($path); return $this->changeSet; } $cacheLoaded = $this->manifest->all() !== []; } $this->scanDirectory($path); $this->detectRemovedFiles($path); if ($cacheLoaded) { $this->replayManifest($path, $this->changeSet->getChangedFiles()); } $this->syncLegacyState(); if ($this->config['cache_enabled'] && $cacheFile !== null) { $this->saveToCache($cacheFile); } return $this->changeSet; } public function scanFiles(array $files, ?string $scopePath = null, ?string $cacheFile = null, bool $force = false, bool $replayManifest = true): ChangeSet { $this->changeSet = new ChangeSet(); $this->visitedFiles = []; $cacheLoaded = false; if ($scopePath !== null) { $scopePath = $this->normalizePath($scopePath); } if ($this->config['cache_enabled'] && $scopePath !== null) { $cacheFile = $cacheFile ?? $this->getDefaultCacheFile(); $this->loadFromCache($cacheFile, $scopePath); $cacheLoaded = $this->manifest->all() !== []; } foreach (array_values(array_unique($files)) as $file) { $path = $this->normalizePath($file); if (file_exists($path)) { $this->processFile($path, $force); continue; } $this->markRemovedFile($path); } if ($replayManifest && $cacheLoaded && $scopePath !== null) { $this->replayManifest($scopePath, $this->changeSet->getChangedFiles()); } $this->syncLegacyState(); if ($this->config['cache_enabled'] && $scopePath !== null && $cacheFile !== null) { $this->saveToCache($cacheFile); } return $this->changeSet; } public function expandDependentFiles(array $files, ?string $scopePath = null): array { $expanded = []; foreach (array_values(array_unique($files)) as $file) { $path = $this->normalizePath($file); $expanded[$path] = true; } if ($scopePath === null || $expanded === []) { return array_keys($expanded); } $scopePath = $this->normalizePath($scopePath); $scopeFiles = $this->collectFiles($scopePath); $symbols = []; foreach (array_keys($expanded) as $file) { if (!file_exists($file)) { continue; } foreach ($this->parseDeclaredSymbols($file) as $class => $kind) { $symbols[$class] = $kind; } } if ($symbols === []) { return array_keys($expanded); } $processedSymbols = []; do { $newSymbols = array_diff_key($symbols, $processedSymbols); if ($newSymbols === []) { break; } $processedSymbols += $newSymbols; $changed = false; foreach ($scopeFiles as $file) { $file = $this->normalizePath($file); if (isset($expanded[$file]) || !file_exists($file)) { continue; } $dependencies = $this->parseFileDependencies($file); if (array_intersect_key($dependencies, $symbols) === []) { continue; } $expanded[$file] = true; foreach ($this->parseDeclaredSymbols($file) as $class => $kind) { if (!isset($symbols[$class])) { $symbols[$class] = $kind; $changed = true; } } } } while ($changed); return array_keys($expanded); } private function scanDirectory(string $path, int $depth = 0): void { if ($depth > $this->config['max_depth']) { return; } try { $dir = new DirectoryIterator($path); } catch (Throwable $e) { $this->logError($e, ['path' => $path, 'action' => 'open_directory']); return; } foreach ($dir as $item) { if ($this->shouldSkipItem($item)) { continue; } $realPath = $this->normalizePath($item->getRealPath() ?: $item->getPathname()); if ($item->isDir()) { if ($this->shouldSkipDirectory($realPath)) { continue; } if ($item->isLink() && !$this->config['follow_links']) { continue; } $this->scanDirectory($realPath, $depth + 1); continue; } if ($item->isFile()) { $this->processFile($realPath); } } } private function shouldSkipItem(DirectoryIterator $item): bool { return $item->isDot() || str_starts_with($item->getFilename(), '.'); } private function shouldSkipDirectory(string $path): bool { $path = rtrim($this->normalizePath($path), '/') . '/'; $skipDirs = array_merge( $this->config['skip_directories'], config('site.scanner.skip', []) ); if (in_array($path, $skipDirs, true)) { return true; } foreach ($this->config['skip_patterns'] as $pattern) { if (strpos($path, $pattern) !== false) { return true; } } return false; } private function processFile(string $path, bool $force = false): void { $path = $this->normalizePath($path); if (!in_array(pathinfo($path, PATHINFO_EXTENSION), $this->config['extensions'], true)) { return; } $this->visitedFiles[$path] = true; if (!$force && !$this->shouldProcessFile($path)) { return; } try { $oldClasses = $this->manifest->getClasses($path); $this->invalidateClasses($oldClasses); $newClasses = $this->loadAndParseFile($path); $this->manifest->set($path, (int)filemtime($path), $newClasses); $this->changeSet?->addChangedFile($path); foreach (array_diff($oldClasses, $newClasses) as $class) { $this->changeSet?->addRemovedClass($class); } foreach ($newClasses as $class) { $this->changeSet?->addChangedClass($class); } } catch (Throwable $e) { $this->logError($e, [ 'file' => $path, 'action' => 'process_file', ]); if ($e instanceof \ParseError) { throw $e; } } } private function shouldProcessFile(string $path): bool { if (!file_exists($path)) { return false; } $mtime = (int)filemtime($path); $cachedMtime = $this->manifest->getMtime($path); return $cachedMtime === null || $cachedMtime < $mtime; } private function loadAndParseFile(string $path): array { $this->optimizeWithOpcache($path); self::$currentFile = $path; $classes = array_keys($this->parseDeclaredSymbols($path)); $loadFile = $classes === [] ? (bool)$this->config['require_files_without_classes'] : false; foreach ($classes as $class) { if (!$this->isDeclaredSymbol($class)) { $loadFile = true; break; } } try { if ($loadFile) { $before = $this->getDeclaredSymbols(); require_once $path; if ($classes === []) { $after = $this->getDeclaredSymbols(); $classes = array_values(array_diff($after, $before)); } } foreach ($classes as $class) { if (class_exists($class)) { $this->analyzeClass($class); } } return $classes; } finally { self::$currentFile = null; } } private function optimizeWithOpcache(string $path): void { if (!$this->config['opcache_compile']) { return; } if ($this->hasOpcache === null) { $this->hasOpcache = function_exists('opcache_invalidate') && function_exists('opcache_compile_file'); } if (!$this->hasOpcache) { return; } try { opcache_invalidate($path, true); opcache_compile_file($path); } catch (Throwable $e) { if ($this->config['debug']) { $this->logError($e, [ 'file' => $path, 'action' => 'opcache_optimize', ]); } } } private function parseDeclaredSymbols(string $path): array { $tokens = token_get_all(file_get_contents($path) ?: ''); $namespace = ''; $classes = []; $count = count($tokens); for ($i = 0; $i < $count; $i++) { $token = $tokens[$i]; if (!is_array($token)) { continue; } if ($token[0] === T_NAMESPACE) { $namespace = $this->parseNamespace($tokens, $i + 1); continue; } if (!$this->isClassLikeToken($token[0])) { continue; } if ($token[0] === T_CLASS && ($this->isAnonymousClass($tokens, $i) || $this->isClassConstant($tokens, $i))) { continue; } $name = $this->parseClassLikeName($tokens, $i + 1); if ($name === null) { continue; } $classes[ltrim($namespace . '\\' . $name, '\\')] = $this->getClassLikeKind($tokens, $i); } return $classes; } private function parseNamespace(array $tokens, int $offset): string { $namespace = ''; $count = count($tokens); for ($i = $offset; $i < $count; $i++) { $token = $tokens[$i]; if ($token === ';' || $token === '{') { break; } if (is_array($token) && in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NS_SEPARATOR], true)) { $namespace .= $token[1]; } } return trim($namespace, '\\'); } private function parseClassLikeName(array $tokens, int $offset): ?string { $count = count($tokens); for ($i = $offset; $i < $count; $i++) { $token = $tokens[$i]; if (!is_array($token)) { continue; } if ($token[0] === T_STRING) { return $token[1]; } } return null; } private function isClassLikeToken(int $token): bool { return $token === T_CLASS || $token === T_INTERFACE || $token === T_TRAIT || (defined('T_ENUM') && $token === T_ENUM); } private function isAnonymousClass(array $tokens, int $offset): bool { for ($i = $offset - 1; $i >= 0; $i--) { $token = $tokens[$i]; if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { continue; } return is_array($token) && $token[0] === T_NEW; } return false; } private function isClassConstant(array $tokens, int $offset): bool { for ($i = $offset - 1; $i >= 0; $i--) { $token = $tokens[$i]; if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { continue; } return is_array($token) && $token[0] === T_DOUBLE_COLON; } return false; } private function parseFileDependencies(string $path): array { $tokens = token_get_all(file_get_contents($path) ?: ''); $namespace = ''; $imports = []; $dependencies = []; $count = count($tokens); $braceDepth = 0; for ($i = 0; $i < $count; $i++) { $token = $tokens[$i]; if ($token === '{') { $braceDepth++; continue; } if ($token === '}') { $braceDepth = max(0, $braceDepth - 1); continue; } if (!is_array($token)) { continue; } if ($token[0] === T_NAMESPACE) { $namespace = $this->parseNamespace($tokens, $i + 1); continue; } if ($token[0] === T_USE) { if ($braceDepth === 0) { foreach ($this->parseUseImports($tokens, $i + 1, $namespace) as $alias => $class) { $imports[$alias] = $class; } continue; } foreach ($this->parseNameList($tokens, $i + 1, [';', '{']) as $name) { $dependencies[$this->resolveClassName($name, $namespace, $imports)] = true; } continue; } if ($token[0] === T_EXTENDS || $token[0] === T_IMPLEMENTS) { foreach ($this->parseNameList($tokens, $i + 1, ['{']) as $name) { $dependencies[$this->resolveClassName($name, $namespace, $imports)] = true; } } } return $dependencies; } private function parseUseImports(array $tokens, int $offset, string $namespace): array { $imports = []; $count = count($tokens); for ($i = $offset; $i < $count; $i++) { $token = $tokens[$i]; if ($token === ';') { break; } if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { continue; } if (is_array($token) && in_array($token[0], [T_FUNCTION, T_CONST], true)) { return []; } $name = $this->collectQualifiedName($tokens, $i); if ($name === null) { continue; } $alias = null; $j = $i + 1; while ($j < $count) { $next = $tokens[$j]; if ($next === ',' || $next === ';') { break; } if (is_array($next) && $next[0] === T_AS) { $aliasOffset = $j + 1; $alias = $this->collectQualifiedName($tokens, $aliasOffset); break; } $j++; } $class = trim($name, '\\'); $short = $alias ?: basename(str_replace('\\', '/', $class)); $imports[strtolower($short)] = $class; $i = $j; } return $imports; } private function parseNameList(array $tokens, int $offset, array $stoppers): array { $names = []; $count = count($tokens); for ($i = $offset; $i < $count; $i++) { $token = $tokens[$i]; if (is_string($token) && in_array($token, $stoppers, true)) { break; } $name = $this->collectQualifiedName($tokens, $i); if ($name !== null) { $names[] = $name; } } return $names; } private function collectQualifiedName(array $tokens, int &$offset): ?string { $count = count($tokens); $name = ''; $started = false; for ($i = $offset; $i < $count; $i++) { $token = $tokens[$i]; if (!$started && is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { continue; } if (is_array($token) && in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NS_SEPARATOR], true)) { $name .= $token[1]; $started = true; $offset = $i; continue; } break; } return $name === '' ? null : $name; } private function resolveClassName(string $name, string $namespace, array $imports): string { $name = trim($name, '\\'); if ($name === '') { return $name; } $parts = explode('\\', $name); $first = strtolower($parts[0]); if (isset($imports[$first])) { array_shift($parts); return $imports[$first] . ($parts === [] ? '' : '\\' . implode('\\', $parts)); } return $namespace === '' ? $name : $namespace . '\\' . $name; } private function getClassLikeKind(array $tokens, int $offset): string { $token = $tokens[$offset]; if (!is_array($token)) { return 'class'; } if ($token[0] === T_TRAIT) { return 'trait'; } if ($token[0] === T_INTERFACE) { return 'interface'; } if (defined('T_ENUM') && $token[0] === T_ENUM) { return 'enum'; } for ($i = $offset - 1; $i >= 0; $i--) { $previous = $tokens[$i]; if (is_array($previous) && in_array($previous[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { continue; } return is_array($previous) && $previous[0] === T_ABSTRACT ? 'abstract_class' : 'class'; } return 'class'; } private function getDeclaredSymbols(): array { return array_merge(get_declared_classes(), get_declared_interfaces(), get_declared_traits()); } private function isDeclaredSymbol(string $class): bool { return class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false) || (function_exists('enum_exists') && enum_exists($class, false)); } private function analyzeClass(string $class): void { try { $reflect = $this->container->getReflectionClass($class); if (!$reflect->isInstantiable() || $reflect->isTrait() || $reflect->isEnum() || $reflect->isInterface()) { return; } if ($this->shouldSkipClass($reflect)) { return; } $this->analyzeClassMethods($reflect, $class); } catch (Throwable $e) { $this->logError($e, [ 'class' => $class, 'action' => 'analyze_class', ]); } } private function shouldSkipClass(ReflectionClass $reflect): bool { $attributes = array_map(fn($attr) => $attr->getName(), $reflect->getAttributes()); return in_array(Skip::class, $attributes, true) || in_array(\Attribute::class, $attributes, true); } private function analyzeClassMethods(ReflectionClass $reflect, string $class): void { foreach ($reflect->getMethods() as $method) { $declaringClass = $method->getDeclaringClass(); if ($method->isStatic() || ($declaringClass->getName() !== $class && !$declaringClass->isTrait())) { continue; } $this->processMethodAttributes($method, $class); } } private function processMethodAttributes(ReflectionMethod $method, string $class): void { foreach ($method->getAttributes() as $attribute) { $attributeName = $attribute->getName(); if (!class_exists($attributeName)) { continue; } try { $instance = $attribute->newInstance(); if ($instance instanceof InjectMethodInterface) { $instance->dispatch($class, $method->getName()); } } catch (Throwable $e) { $this->logError($e, [ 'class' => $class, 'method' => $method->getName(), 'attribute' => $attributeName, 'action' => 'process_attribute', ]); } } } private function getDefaultCacheFile(): string { $cacheDir = defined('APP_PATH') ? rtrim(str_replace('\\', '/', APP_PATH), '/') . '/storage/.kiri-scanner-cache/' : sys_get_temp_dir() . '/kiri-scanner-cache/'; if (!is_dir($cacheDir)) { mkdir($cacheDir, 0755, true); } $projectHash = md5(__DIR__); return $cacheDir . 'scanner_' . $projectHash . '.json'; } private function loadFromCache(string $cacheFile, string $path): bool { if (!file_exists($cacheFile)) { return false; } if (filemtime($cacheFile) + $this->config['cache_ttl'] < time()) { return false; } $data = json_decode(file_get_contents($cacheFile), true); if (!is_array($data) || !isset($data['manifest'])) { return false; } $this->manifest->fromArray($data['manifest']); $this->syncLegacyState(); return !$this->hasDirectoryChanged($path); } private function hasDirectoryChanged(string $path): bool { $currentFiles = $this->collectFiles($path); $cachedFiles = $this->manifest->paths(rtrim($path, '/\\') . '/'); sort($currentFiles); sort($cachedFiles); if ($currentFiles !== $cachedFiles) { return true; } foreach ($currentFiles as $file) { if (!file_exists($file)) { return true; } if ($this->manifest->getMtime($file) !== (int)filemtime($file)) { return true; } } return false; } private function saveToCache(string $cacheFile): void { $data = [ 'manifest' => $this->manifest->all(), 'timestamp' => time(), 'version' => '2.0', ]; file_put_contents($cacheFile, json_encode($data, JSON_PRETTY_PRINT)); $this->cleanupOldCacheFiles(dirname($cacheFile)); } private function cleanupOldCacheFiles(string $cacheDir): void { $files = glob($cacheDir . '/scanner_*.json'); $now = time(); foreach ($files as $file) { if (filemtime($file) + (7 * 24 * 3600) < $now) { unlink($file); } } } private function logError(Throwable $e, array $context = []): void { $fullContext = array_merge($context, [ 'message' => $e->getMessage(), 'code' => $e->getCode(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), 'time' => date('Y-m-d H:i:s'), ]); if (function_exists('error')) { error($e, $fullContext); } else { error_log(json_encode($fullContext)); } if ($this->config['debug'] && php_sapi_name() === 'cli') { echo "Scanner Error: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}\n"; if ($context !== []) { echo 'Context: ' . json_encode($context) . "\n"; } } } public function load_directory(string $path): void { $this->scan($path); } protected function parseFile($file): void { $absolutePath = $this->normalizePath($this->basePath . '/' . ltrim((string)$file, '/')) . '.php'; if (file_exists($absolutePath)) { $this->processFile($absolutePath); } } protected function skipNames(ReflectionClass $reflect): array { return array_map(fn($attr) => $attr->getName(), $reflect->getAttributes()); } public function getStats(): array { return [ 'total_files' => count($this->files), 'cached_mtimes' => count($this->fileMtimes), 'base_path' => $this->basePath, 'config' => $this->config, 'manifest_entries' => count($this->manifest->all()), ]; } /** * 返回 Master 扫描产生的完整清单数据,供 Worker 轻量重建注解路由 * @return array{string: array{mtime: int, classes: string[]}} */ public function getManifestClasses(): array { return $this->manifest->all(); } public function reset(): void { $this->files = []; $this->fileMtimes = []; $this->hasOpcache = null; $this->manifest = new ScanManifest(); $this->changeSet = null; $this->visitedFiles = []; } public static function getCurrentFile(): ?string { return self::$currentFile; } private function detectRemovedFiles(string $path): void { $prefix = rtrim($path, '/\\') . '/'; foreach ($this->manifest->paths($prefix) as $file) { if (!isset($this->visitedFiles[$file])) { $this->markRemovedFile($file); } } } private function markRemovedFile(string $path): void { if (!$this->manifest->has($path)) { return; } $classes = $this->manifest->remove($path); $this->invalidateClasses($classes); foreach ($classes as $class) { $this->changeSet?->addRemovedClass($class); } $this->changeSet?->addRemovedFile($path); } private function syncLegacyState(): void { $this->files = array_keys($this->manifest->all()); $this->fileMtimes = []; foreach ($this->manifest->all() as $path => $entry) { $this->fileMtimes[$path] = (int)$entry['mtime']; } } private function collectFiles(string $path, int $depth = 0): array { if ($depth > $this->config['max_depth']) { return []; } $files = []; try { $dir = new DirectoryIterator($path); } catch (Throwable) { return []; } foreach ($dir as $item) { if ($this->shouldSkipItem($item)) { continue; } $realPath = $this->normalizePath($item->getRealPath() ?: $item->getPathname()); if ($item->isDir()) { if ($this->shouldSkipDirectory($realPath)) { continue; } if ($item->isLink() && !$this->config['follow_links']) { continue; } $files = array_merge($files, $this->collectFiles($realPath, $depth + 1)); continue; } if ($item->isFile() && in_array(pathinfo($realPath, PATHINFO_EXTENSION), $this->config['extensions'], true)) { $files[] = $realPath; } } return $files; } private function normalizePath(string $path): string { $resolved = realpath($path) ?: $path; return str_replace('\\', '/', $resolved); } private function replayManifest(string $path, array $skipPaths = []): void { $skip = array_fill_keys(array_map([$this, 'normalizePath'], $skipPaths), true); $prefix = rtrim($path, '/\\') . '/'; foreach ($this->manifest->paths($prefix) as $file) { if (isset($skip[$file])) { continue; } foreach ($this->manifest->getClasses($file) as $class) { if (class_exists($class)) { $this->analyzeClass($class); } } } } private function invalidateClasses(array $classes): void { foreach ($classes as $class) { if (method_exists($this->container, 'forgetClass')) { $this->container->forgetClass($class); } if (class_exists(\Kiri\Router\Defer\DeferRegistry::class)) { \Kiri\Router\Defer\DeferRegistry::removeClass($class); } } } }