[파일, 줄번호, 인자 텍스트] */ 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; } /** * 호출처를 그 호출을 감싸는 메서드 본문과 함께 돌려줍니다. * * 본문에서 호출 인자 텍스트는 제거한다 — `relations:` 값이나 `outerUsing:` 클로저 안의 * `with()` 는 정상 사용이므로 검사 대상이 아니다. * * @return array [파일, 줄번호, 인자, 인자 제외 메서드 본문] */ public static function callSiteWithMethodBodyProvider(): array { $root = dirname(__DIR__, 4); $cases = []; foreach (self::callSiteProvider() as $key => [$relative, $line, $args]) { $content = file_get_contents($root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative)); $body = self::enclosingMethodBody($content, $line); // 호출 인자 텍스트 제거 — 그 안의 with() 는 트레이트가 outer 에 적용하는 정상 경로다 $cases[$key] = [$relative, $line, $args, str_replace($args, '', $body)]; } return $cases; } /** * 지정한 줄을 감싸는 메서드 본문을 돌려줍니다. * * @param string $content 파일 내용 * @param int $line 호출이 위치한 줄번호 * @return string 메서드 본문 (찾지 못하면 빈 문자열) */ private static function enclosingMethodBody(string $content, int $line): string { $offset = 0; $lines = explode("\n", $content); $target = 0; for ($i = 0; $i < $line - 1; $i++) { $target += strlen($lines[$i]) + 1; } preg_match_all('/^\s{4}(?:public|protected|private)\s+function\s+\w+/m', $content, $m, PREG_OFFSET_CAPTURE); $starts = array_map(fn ($x) => $x[1], $m[0]); $starts[] = strlen($content); for ($i = 0; $i < count($starts) - 1; $i++) { if ($starts[$i] <= $target && $target < $starts[$i + 1]) { return substr($content, $starts[$i], $starts[$i + 1] - $starts[$i]); } $offset = $starts[$i]; } return ''; } #[DataProvider('callSiteWithMethodBodyProvider')] public function test_call_site_passes_relations_as_argument(string $file, int $line, string $args, string $body): void { $appliesEagerLoad = preg_match('/(->|::)\s*with(Count)?\s*\(/', $body) === 1; if (! $appliesEagerLoad) { $this->addToAssertionCount(1); return; } $this->assertMatchesRegularExpression( '/\b(relations|withCount)\s*:/', $args, "{$file}:{$line} — 쿼리에 with()/withCount() 를 붙였는데 relations:/withCount: 인자가 없다. " .'트레이트가 outer 에서도 eager load 를 지우므로 관계가 조용히 사라진다 (예외도 오류도 나지 않는다).' ); } #[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(), '지연 조인 호출처를 하나도 찾지 못했다 — 스캔 경로를 확인할 것'); } }