*/ protected const FEATURE_MODULES = ['Identity', 'Settings', 'AuditLog', 'Notifications', 'Taxonomy', 'Media']; /** @var list */ protected const APPLICATION_NAMESPACES = [ 'Netig\\Netslim\\Identity\\Application\\', 'Netig\\Netslim\\Settings\\Application\\', 'Netig\\Netslim\\AuditLog\\Application\\', 'Netig\\Netslim\\Notifications\\Application\\', 'Netig\\Netslim\\Taxonomy\\Application\\', 'Netig\\Netslim\\Media\\Application\\', ]; /** @var list */ protected const INFRASTRUCTURE_NAMESPACES = [ 'Netig\\Netslim\\Identity\\Infrastructure\\', 'Netig\\Netslim\\Settings\\Infrastructure\\', 'Netig\\Netslim\\AuditLog\\Infrastructure\\', 'Netig\\Netslim\\Notifications\\Infrastructure\\', 'Netig\\Netslim\\Taxonomy\\Infrastructure\\', 'Netig\\Netslim\\Media\\Infrastructure\\', ]; /** @var list */ protected const UI_NAMESPACES = [ 'Netig\\Netslim\\Identity\\UI\\', 'Netig\\Netslim\\Taxonomy\\UI\\', 'Netig\\Netslim\\Media\\UI\\', ]; /** @var list */ protected const FRAMEWORK_NAMESPACE_FRAGMENTS = [ 'Slim\\', 'Twig\\', ]; /** @var list */ protected const HTTP_MESSAGE_DEPENDENCIES = [ 'Psr\\Http\\Message\\ServerRequestInterface', 'Psr\\Http\\Message\\ResponseInterface', 'Psr\\Http\\Message\\UploadedFileInterface', ]; /** @var list */ protected const APPLICATION_RUNTIME_DETAIL_FRAGMENTS = [ 'Netig\\Netslim\\Kernel\\Http\\Application\\Session\\SessionManagerInterface', 'usePDO;', 'newPDO(', ]; /** @var list */ protected const USE_CASE_OR_POLICY_FRAGMENTS = [ '\\Application\\UseCase\\', '\\Domain\\Policy\\', ]; /** @var list */ protected const APPLICATION_OR_DOMAIN_FRAGMENTS = [ '\\Application\\', '\\Domain\\', ]; /** @var list */ protected const INFRASTRUCTURE_INSTANTIATION_FRAGMENTS = [ '\\Infrastructure\\', ]; protected const MESSAGE_DOMAIN_FRAMEWORK_AGNOSTIC = 'Domain layer must stay framework-agnostic'; protected const MESSAGE_APPLICATION_PORTS_ONLY = 'Application layer must depend on ports, not framework/runtime details'; protected const MESSAGE_APPLICATION_NO_INFRA_DEPENDENCY = 'Application layer should depend on ports and injected services, not infrastructure implementations'; protected const MESSAGE_UI_NO_INFRA_DEPENDENCY = 'UI layer should talk to interfaces and request adapters, not infrastructure implementations'; protected const MESSAGE_INFRA_NO_UI_DEPENDENCY = 'Infrastructure implementation must not depend on UI classes'; protected const MESSAGE_APPLICATION_SERVICE_DI_ONLY = 'Application services must rely on DI for use cases and policies, not instantiate them directly'; protected const MESSAGE_CONTROLLER_DI_ONLY = 'Controllers must rely on DI and request adapters for application/domain dependencies, not instantiate them directly'; protected const MESSAGE_UI_NO_INFRA_INSTANTIATION = 'UI layer must rely on DI and ports, not instantiate infrastructure or repository implementations directly'; protected const MESSAGE_APPLICATION_NO_INFRA_INSTANTIATION = 'Application layer must rely on interfaces and injected collaborators, not instantiate infrastructure or repository implementations directly'; protected const MESSAGE_WIRING_ONLY_UI_REFERENCE = 'Only infrastructure wiring files may reference UI classes'; protected const MESSAGE_STANDARD_MODULE_STRUCTURE = '%s should only contain the standard modular structure'; protected const MESSAGE_MODULE_DIRECTORY_MISSING = 'Module directory missing for %s'; protected const MESSAGE_NO_ARCHITECTURE_VIOLATIONS = '%s'; /** @return list */ protected function domainFiles(): array { return $this->phpFilesUnder('src/*/Domain'); } /** @return list */ protected function applicationFiles(): array { return $this->phpFilesUnder('src/*/Application'); } /** @return list */ protected function applicationServiceFiles(): array { return array_values(array_filter( $this->applicationFiles(), static fn (string $file): bool => str_ends_with($file, 'ApplicationService.php'), )); } /** @return list */ protected function uiFiles(): array { return $this->phpFilesUnder('src/*/UI'); } /** @return list */ protected function uiControllerFiles(): array { return array_values(array_filter( $this->phpFilesUnder('src/*/UI/Http'), static fn (string $file): bool => str_ends_with($file, 'Controller.php'), )); } /** @return list */ protected function infrastructureFiles(): array { return $this->phpFilesUnder('src/*/Infrastructure'); } /** @return list */ protected function nonWiringInfrastructureFiles(): array { return array_values(array_filter( $this->infrastructureFiles(), static fn (string $file): bool => basename($file) !== 'dependencies.php', )); } /** @return list */ protected function phpFilesUnder(string $relativeDirectory): array { $directories = glob($this->projectPath($relativeDirectory), GLOB_ONLYDIR) ?: []; $files = []; foreach ($directories as $directory) { $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)); foreach ($iterator as $fileInfo) { if (!$fileInfo->isFile() || $fileInfo->getExtension() !== 'php') { continue; } $files[] = $fileInfo->getPathname(); } } sort($files); return array_values(array_unique($files)); } protected function projectPath(string $suffix = ''): string { $root = dirname(__DIR__, 3); return $suffix === '' ? $root : $root . '/' . $suffix; } protected function relativePath(string $absolutePath): string { return substr($absolutePath, strlen($this->projectPath()) + 1); } protected function modulePath(string $module): string { return $this->projectPath('src/' . $module); } /** @return list */ protected function expectedModuleEntries(string $module): array { $entries = match ($module) { 'Taxonomy' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'TaxonomyModule.php', 'UI'], 'Media' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'MediaModule.php', 'Migrations', 'UI'], 'Settings' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'SettingsModule.php'], 'AuditLog' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'AuditLogModule.php', 'Migrations'], 'Notifications' => ['Application', 'Contracts', 'Domain', 'Infrastructure', 'Migrations', 'NotificationsModule.php'], default => ['Application', 'Domain', 'Infrastructure', 'Migrations', 'UI', $module . 'Module.php'], }; sort($entries); return $entries; } protected function normalizedCode(string $file): string { $code = file_get_contents($file); self::assertNotFalse($code, 'Unable to read ' . $file); $normalized = ''; foreach (token_get_all($code) as $token) { if (is_array($token)) { if (in_array($token[0], [T_COMMENT, T_DOC_COMMENT, T_WHITESPACE], true)) { continue; } $normalized .= $token[1]; continue; } $normalized .= $token; } return $normalized; } /** @param list $needles */ protected function containsAny(string $haystack, array $needles): bool { foreach ($needles as $needle) { if (str_contains($haystack, $needle)) { return true; } } return false; } /** * @param list $files * @param list $forbiddenFragments */ protected function assertFilesDoNotReferenceFragments(array $files, array $forbiddenFragments, string $message): void { foreach ($files as $file) { $code = $this->normalizedCode($file); self::assertFalse( $this->containsAny($code, $forbiddenFragments), $this->fileRuleMessage($message, $file), ); } } /** @return list */ protected function importedClasses(string $file): array { $code = file_get_contents($file); self::assertNotFalse($code, 'Unable to read ' . $file); preg_match_all('/^use\s+([^;]+);/m', $code, $matches); return array_values(array_unique(array_map('trim', $matches[1] ?? []))); } protected function applicationMustNotDependOnInfrastructureFragments(): array { return array_filter(array_map( static fn (string $namespace): string => str_replace('\\\\', '\\', $namespace), self::INFRASTRUCTURE_NAMESPACES, )); } protected function uiMustNotDependOnInfrastructureFragments(): array { return $this->applicationMustNotDependOnInfrastructureFragments(); } protected function infrastructureMustNotDependOnUiFragments(): array { return array_filter(array_map( static fn (string $namespace): string => str_replace('\\\\', '\\', $namespace), self::UI_NAMESPACES, )); } /** * @param list $namespaceFragments * @param callable(string): bool $classFilter * @return list */ protected function collectInstantiationViolations(string $file, array $namespaceFragments, callable $classFilter): array { $code = $this->normalizedCode($file); if (!str_contains($code, 'new')) { return []; } $violations = []; foreach ($this->importedClasses($file) as $import) { if (!$classFilter($import) || !$this->containsAny($import, $namespaceFragments)) { continue; } $shortName = substr($import, (int) strrpos($import, '\\') + 1); if (str_contains($code, 'new' . $shortName . '(') || str_contains($code, 'new\\' . $import . '(')) { $violations[] = $this->relativePath($file) . ' instantiates ' . $import; } } return array_values(array_unique($violations)); } protected function isUseCaseOrPolicyClass(string $class): bool { return str_contains($class, '\\Application\\UseCase\\') || str_contains($class, '\\Domain\\Policy\\'); } protected function isApplicationOrDomainClass(string $class): bool { return str_contains($class, '\\Application\\') || str_contains($class, '\\Domain\\'); } protected function isInfrastructureOrRepositoryClass(string $class): bool { return str_contains($class, '\\Infrastructure\\') || str_contains($class, '\\Repository\\'); } protected function assertNoArchitectureViolations(array $violations, string $message): void { self::assertSame([], $violations, sprintf(self::MESSAGE_NO_ARCHITECTURE_VIOLATIONS, $message . ($violations !== [] ? "\n- " . implode("\n- ", $violations) : ''))); } protected function fileRuleMessage(string $message, string $file): string { return sprintf('%s: %s', $message, $this->relativePath($file)); } protected function moduleRuleMessage(string $message, string $module): string { return sprintf($message, $module); } }