Files
Gnuboard7/app/Support/ExtensionDoc/TestPathCollector.php
T
HeuJung 11175d35d6 feat(core,docs): 확장별 개발자 문서 생성기와 검사 하네스 구축
번들 확장 20개 중 에이전트·확장개발자가 읽을 수 있는 문서를 가진 확장이 0개였다.
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답하고, 확장을 고치려는
쪽이 실제로 묻는 것(왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안
되는가)은 어디에도 없었다. 그 결과 확장이 발행하는 훅은 확장점인데도 사실상 비공개였다.

이 커밋은 인프라만 담는다. 문서 집필은 확장별로 순차 진행한다.

- `php artisan ext:docgen` — 진입 클래스의 선언형 getter 를 실제로 호출하고 소스를 스캔해
 훅·라우트·권한·메뉴·설정·모델·레이아웃·핸들러·테스트 경로·의존 관계를 실측한다.
 `_bundled` 를 읽는다 (활성 디렉토리에 같은 FQCN 이 로드돼 있으면 eval-rename 으로 우회).
- 자동 생성 블록은 **안쪽만** 교체한다. 사람 서술이 소실될 경로를 만들지 않으려고 파괴적
 재생성 플래그를 두지 않았고, 문서에 없는 블록 키는 주입하지 않고 누락으로 보고한다.
- 필수 문서·섹션·블록 목록은 `ExtensionDocScaffolder::DOCUMENTS` 단일 SSoT 다. 검사
 스크립트는 이를 복제하지 않고 `--check --json` 을 소비하며, PHP 를 못 돌리면 "이상 0건"
 이 아니라 "점검 불가" 로 구분 보고한다.
- 두 audit 룰은 도입 시점 전수가 공허 통과하므로(대상 문서 0건) 픽스처 테스트가 판정식을
 잠근다. 스캐너·mermaid·검사 스크립트 축도 같은 이유로 픽스처를 함께 둔다.

동반 수정: 훅이 넘기는 `file_path` 는 항상 절대경로인데 `FILE_RULES` 다수가 루트로
앵커해 있어 그 규칙들이 조용히 죽어 있었다(추적 파일 440개가 절대경로에서 0건 매치).
`toRepoRelative` 로 정규화하고 세 경로 형태의 판정 일치를 테스트로 고정했다.
문서 인덱스 생성기의 CRLF 문서 제목·TL;DR 추출 누락도 같은 성격이라 함께 고쳤다.
2026-08-31 07:58:44 +09:00

242 lines
8.6 KiB
PHP

<?php
namespace App\Support\ExtensionDoc;
use Illuminate\Support\Facades\File;
/**
* 확장 테스트 경로 수집기
*
* 확장이 보유한 PHPUnit · Vitest · Playwright 테스트와 시나리오 매니페스트를 수집하고,
* 실행 명령을 규정에 맞는 형태로 조립합니다.
*
* 실행 명령은 문서의 핵심 산출물입니다 — 확장 테스트는 무필터 전체 실행이 금지되어 있고
* 프론트/백엔드가 서로 다른 셸 규약을 요구하므로, 그 형태를 문서가 직접 제시하지 않으면
* 읽는 쪽이 매번 규정을 되짚어야 합니다.
*/
class TestPathCollector
{
/**
* 확장의 테스트 표면을 수집합니다.
*
* @param array<string, mixed> $record ExtensionInventory 레코드
* @return array<string, mixed> 테스트 인벤토리
*/
public function collect(array $record): array
{
$phpunit = $this->countFiles($record, 'tests', 'php', ['Playwright']);
$vitestRoots = $this->vitestRoots($record);
$playwright = $this->countFiles($record, 'tests/Playwright', 'ts');
$scenarios = $this->scenarioManifests($record);
return [
'phpunit' => $phpunit,
'vitest' => $vitestRoots,
'playwright' => $playwright,
'scenarios' => $scenarios,
'commands' => $this->buildCommands($record, $phpunit, $vitestRoots, $playwright),
'testCaseBase' => $this->testCaseBase($record),
];
}
/**
* 확장 테스트의 기저 TestCase 클래스를 찾습니다.
*
* 모듈/플러그인 테스트는 `Tests\TestCase` 직접 상속이 금지되고 확장 전용 기저 클래스를
* 상속해야 하므로, 그 클래스명이 문서에 드러나야 합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return string|null 기저 클래스 파일명 (없으면 null)
*/
private function testCaseBase(array $record): ?string
{
foreach (['ModuleTestCase.php', 'PluginTestCase.php', 'TemplateTestCase.php'] as $name) {
if (is_file($record['path'].DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.$name)) {
return 'tests/'.$name;
}
}
return null;
}
/**
* Vitest 대상 디렉토리를 찾습니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{config: string|null, dirs: array<int, string>, files: int}
*/
private function vitestRoots(array $record): array
{
$config = null;
foreach (['vitest.config.ts', 'vitest.config.js'] as $name) {
if (is_file($record['path'].DIRECTORY_SEPARATOR.$name)) {
$config = $name;
break;
}
}
$dirs = [];
$files = 0;
foreach (['resources/js', 'src', '__tests__'] as $sub) {
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
if (! is_dir($dir)) {
continue;
}
$found = 0;
foreach (File::allFiles($dir) as $file) {
$rel = str_replace('\\', '/', $file->getPathname());
if (! preg_match('/\.(test|spec)\.(ts|tsx)$/', $rel)) {
continue;
}
$found++;
}
if ($found > 0) {
$dirs[] = $sub;
$files += $found;
}
}
return ['config' => $config, 'dirs' => $dirs, 'files' => $files];
}
/**
* 시나리오 매니페스트를 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, string> 매니페스트 상대 경로
*/
private function scenarioManifests(array $record): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'scenarios';
if (! is_dir($dir)) {
return [];
}
$files = [];
foreach (File::allFiles($dir) as $file) {
if (in_array($file->getExtension(), ['yaml', 'yml'], true)) {
$files[] = 'tests/scenarios/'.$file->getFilename();
}
}
sort($files);
return $files;
}
/**
* 테스트 실행 명령을 조립합니다.
*
* 무필터 전체 실행은 규정상 차단되므로 PHPUnit 명령에는 `--filter` 자리를 남깁니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param array{count: int, dirs: array<int, string>} $phpunit PHPUnit 집계
* @param array{config: string|null, dirs: array<int, string>, files: int} $vitest Vitest 집계
* @param array{count: int, dirs: array<int, string>} $playwright Playwright 집계
* @return array<int, array{label: string, command: string, shell: string}> 실행 명령 목록
*/
private function buildCommands(array $record, array $phpunit, array $vitest, array $playwright): array
{
$commands = [];
$rel = $record['relPath'];
if ($phpunit['count'] > 0) {
$commands[] = [
'label' => 'PHPUnit (변경 범위만)',
'command' => "php vendor/bin/phpunit {$rel}/tests --filter='<대상클래스>'",
'shell' => 'Bash',
];
}
if ($vitest['files'] > 0) {
$commands[] = [
'label' => 'Vitest (확장 디렉토리에서)',
'command' => "cd {$rel} && powershell -Command \"npm run test:run -- <대상>\"",
'shell' => 'PowerShell',
];
}
if ($playwright['count'] > 0) {
$commands[] = [
'label' => 'Playwright E2E',
'command' => "npx playwright test {$rel}/tests/Playwright/specs/<대상>.spec.ts",
'shell' => 'Bash',
];
}
return $commands;
}
/**
* 그 확장자에서 "테스트 파일" 로 셀 파일명인지 판정합니다.
*
* 계수 모집단은 문서의 "테스트 N건" 과 AGENTS `## 7. 테스트 실행` 표에 그대로 실리므로,
* 설정 파일·픽스처를 함께 세면 공개 문서가 틀린 수치를 싣는다.
*
* @param string $extension 파일 확장자
* @param string $filename 파일명
* @return bool 테스트 파일이면 true
*/
private static function isTestFilename(string $extension, string $filename): bool
{
return match ($extension) {
'php' => (bool) preg_match('/Test\.php$/', $filename),
'ts', 'tsx' => (bool) preg_match('/\.(test|spec)\.tsx?$/', $filename),
default => true,
};
}
/**
* 하위 디렉토리의 파일 수와 1단계 하위 디렉토리를 집계합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param string $sub 확장 루트 기준 하위 경로
* @param string $extension 대상 확장자
* @param array<int, string> $excludeDirs 제외할 1단계 하위 디렉토리명
* @return array{count: int, dirs: array<int, string>, root: string|null}
*/
private function countFiles(array $record, string $sub, string $extension, array $excludeDirs = []): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
if (! is_dir($dir)) {
return ['count' => 0, 'dirs' => [], 'root' => null];
}
$count = 0;
$dirs = [];
foreach (File::allFiles($dir) as $file) {
if ($file->getExtension() !== $extension) {
continue;
}
// 테스트 파일이 아닌 것이 같은 디렉토리에 산다 — 세면 "테스트 N건" 이 실제
// 테스트 수보다 커진다. PHP 는 기저 TestCase·헬퍼(board 실측: 157 중 2건),
// Playwright 는 `playwright.config.ts` 와 픽스처(gdpr 실측: 5 중 3건)다.
// 두 축이 같은 규율을 갖도록 확장자별 파일명 규칙을 한자리에서 적용한다.
if (! self::isTestFilename($extension, $file->getFilename())) {
continue;
}
$relative = str_replace('\\', '/', $file->getRelativePath());
$first = $relative === '' ? '(root)' : explode('/', $relative)[0];
if (in_array($first, $excludeDirs, true)) {
continue;
}
$count++;
if (! in_array($first, $dirs, true)) {
$dirs[] = $first;
}
}
sort($dirs);
return ['count' => $count, 'dirs' => $dirs, 'root' => $sub];
}
}