[파일, 줄번호, 인자 텍스트] */ public static function callSiteProvider(): array { // tests/Unit/Repositories/Concerns → 저장소 루트 $root = dirname(__DIR__, 4); $cases = []; foreach (self::SCAN_ROOTS as $scanRoot) { $base = $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $scanRoot); if (! is_dir($base)) { continue; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if (! $file->isFile() || $file->getExtension() !== 'php') { continue; } $path = str_replace('\\', '/', $file->getPathname()); // 트레이트 본체와 테스트 코드는 호출처가 아니다 if (str_contains($path, '/tests/') || str_contains($path, '/Concerns/PaginatesWithDeferredJoin.php')) { continue; } $content = file_get_contents($path); if (! str_contains($content, 'paginateWithDeferredJoin(')) { continue; } foreach (self::extractCallSites($content) as [$line, $args]) { $relative = substr($path, strlen(str_replace('\\', '/', $root)) + 1); $cases["{$relative}:{$line}"] = [$relative, $line, $args]; } } } return $cases; } /** * 파일 내용에서 호출처의 인자 텍스트를 추출합니다. * * @param string $content 파일 내용 * @return array [줄번호, 인자 텍스트] */ private static function extractCallSites(string $content): array { $sites = []; $offset = 0; while (($pos = strpos($content, 'paginateWithDeferredJoin(', $offset)) !== false) { $open = $pos + strlen('paginateWithDeferredJoin(') - 1; $depth = 0; $end = null; for ($i = $open; $i < strlen($content); $i++) { $ch = $content[$i]; if ($ch === '(' || $ch === '[') { $depth++; } elseif ($ch === ')' || $ch === ']') { $depth--; if ($depth === 0) { $end = $i; break; } } } if ($end === null) { break; } $line = substr_count(substr($content, 0, $pos), "\n") + 1; $sites[] = [$line, substr($content, $open + 1, $end - $open - 1)]; $offset = $end; } return $sites; } #[DataProvider('callSiteProvider')] public function test_call_site_declares_columns(string $file, int $line, string $args): void { $this->assertMatchesRegularExpression( '/\bcolumns\s*:/', $args, "{$file}:{$line} — columns: 를 명시해야 한다 (미지정 시 outer 가 읽는 범위가 호출처에서 보이지 않는다)" ); $this->assertDoesNotMatchRegularExpression( '/\bcolumns\s*:\s*\[\s*\]/', $args, "{$file}:{$line} — 빈 컬럼 목록은 어떤 컬럼도 선택하지 않는다" ); } #[DataProvider('callSiteProvider')] public function test_call_site_declares_sort_spec(string $file, int $line, string $args): void { $this->assertMatchesRegularExpression( '/\bsort\s*:/', $args, "{$file}:{$line} — sort: 를 명시해야 한다 (미지정 시 키 컬럼 정렬만 남는다)" ); $this->assertDoesNotMatchRegularExpression( '/\bsort\s*:\s*\[\s*\]/', $args, "{$file}:{$line} — 빈 정렬 스펙은 화면 정렬을 잃는다" ); } public function test_scan_finds_call_sites(): void { // 스캔이 0건이면 위 두 테스트가 조용히 통과해 계약 검사가 무력화된다 $this->assertNotEmpty(self::callSiteProvider(), '지연 조인 호출처를 하나도 찾지 못했다 — 스캔 경로를 확인할 것'); } }