Files
Gnuboard7/app/Support/ExtensionDoc/DataModelCollector.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

333 lines
11 KiB
PHP

<?php
namespace App\Support\ExtensionDoc;
use Illuminate\Support\Facades\File;
/**
* 확장 데이터 모델 수집기
*
* 모델·Enum·마이그레이션·Repository 계약을 `_bundled` 소스에서 수집합니다.
* 모델 클래스를 로드하지 않고 소스를 파싱하므로 DB 연결이나 확장 활성화 상태와 무관하게
* 동작합니다 (문서 생성은 설치되지 않은 확장에도 수행되어야 합니다).
*/
class DataModelCollector
{
/**
* Eloquent 관계 정의 메서드.
*
* @var array<int, string>
*/
private const RELATION_METHODS = [
'hasOne', 'hasMany', 'belongsTo', 'belongsToMany',
'hasOneThrough', 'hasManyThrough',
'morphOne', 'morphMany', 'morphTo', 'morphToMany', 'morphedByMany',
];
/**
* 확장의 데이터 모델 표면을 수집합니다.
*
* @param array<string, mixed> $record ExtensionInventory 레코드
* @return array{models: array<int, array<string, mixed>>, enums: array<int, array<string, mixed>>, migrations: array<int, array<string, mixed>>, tables: array<int, string>, repositories: array<int, array<string, mixed>>}
*/
public function collect(array $record): array
{
$models = $this->collectModels($record);
$migrations = $this->collectMigrations($record);
$tables = [];
foreach ($migrations as $migration) {
foreach ($migration['creates'] as $table) {
$tables[$table] = true;
}
}
foreach ($models as $model) {
if ($model['table'] !== null) {
$tables[$model['table']] = true;
}
}
$tables = array_keys($tables);
sort($tables);
return [
'models' => $models,
'enums' => $this->collectEnums($record),
'migrations' => $migrations,
'tables' => $tables,
'repositories' => $this->collectRepositories($record),
];
}
/**
* `src/Models/**` 의 모델을 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array<string, mixed>> 모델 목록
*/
private function collectModels(array $record): array
{
$models = [];
foreach ($this->filesIn($record, 'src/Models') as $file) {
$content = (string) file_get_contents($file);
$short = basename($file, '.php');
$models[] = [
'class' => $short,
'relFile' => $this->relative($record, $file),
'table' => $this->stringProperty($content, 'table'),
'fillable' => $this->arrayPropertyCount($content, 'fillable'),
'softDeletes' => (bool) preg_match('/\buse\s+[^;]*\bSoftDeletes\b/', $content),
'userOverrides' => str_contains($content, 'HasUserOverrides'),
'searchable' => str_contains($content, 'FulltextSearchable') || str_contains($content, 'Laravel\Scout\Searchable'),
'relations' => $this->collectRelations($content),
'summary' => $this->classDocSummary($content),
];
}
usort($models, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
return $models;
}
/**
* 모델 소스에서 관계 정의를 수집합니다.
*
* @param string $content 모델 소스
* @return array<int, array{method: string, type: string, target: string|null}> 관계 목록
*/
private function collectRelations(string $content): array
{
$relations = [];
$alternation = implode('|', self::RELATION_METHODS);
$pattern = '/public\s+function\s+(\w+)\s*\([^)]*\)[^{]*\{(?:[^{}]|\{[^{}]*\})*?\$this->('
.$alternation
.')\s*\(\s*(?:([A-Za-z_\\\\]+)::class)?/s';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$relations[] = [
'method' => $m[1],
'type' => $m[2],
'target' => ($m[3] ?? '') !== '' ? $this->shortName($m[3]) : null,
];
}
}
return $relations;
}
/**
* `src/Enums/**` 의 Enum 을 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array<string, mixed>> Enum 목록
*/
private function collectEnums(array $record): array
{
$enums = [];
foreach ($this->filesIn($record, 'src/Enums') as $file) {
$content = (string) file_get_contents($file);
$backing = null;
if (preg_match('/^\s*enum\s+\w+\s*:\s*(\w+)/m', $content, $bm)) {
$backing = $bm[1];
}
$cases = [];
if (preg_match_all("/^\s*case\s+(\w+)\s*(?:=\s*'([^']*)')?/m", $content, $cm, PREG_SET_ORDER)) {
foreach ($cm as $c) {
$cases[] = ['name' => $c[1], 'value' => $c[2] ?? null];
}
}
$enums[] = [
'class' => basename($file, '.php'),
'relFile' => $this->relative($record, $file),
'backing' => $backing,
'cases' => $cases,
'summary' => $this->classDocSummary($content),
];
}
usort($enums, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
return $enums;
}
/**
* `database/migrations/**` 을 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array<string, mixed>> 마이그레이션 목록 (파일명 정렬)
*/
private function collectMigrations(array $record): array
{
$migrations = [];
foreach ($this->filesIn($record, 'database/migrations') as $file) {
$content = (string) file_get_contents($file);
$creates = [];
if (preg_match_all("/Schema::(?:connection\([^)]*\)->)?create\s*\(\s*'([^']+)'/", $content, $m)) {
$creates = array_values(array_unique($m[1]));
}
$alters = [];
if (preg_match_all("/Schema::(?:connection\([^)]*\)->)?table\s*\(\s*'([^']+)'/", $content, $m)) {
$alters = array_values(array_unique($m[1]));
}
$migrations[] = [
'file' => basename($file),
'relFile' => $this->relative($record, $file),
'creates' => $creates,
'alters' => $alters,
'hasDown' => (bool) preg_match('/function\s+down\s*\(/', $content),
];
}
usort($migrations, static fn (array $a, array $b): int => $a['file'] <=> $b['file']);
return $migrations;
}
/**
* Repository 계약과 구현을 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array<string, mixed>> Repository 목록
*/
private function collectRepositories(array $record): array
{
$repositories = [];
foreach (['src/Repositories', 'src/Contracts/Repositories'] as $sub) {
foreach ($this->filesIn($record, $sub) as $file) {
$content = (string) file_get_contents($file);
$repositories[] = [
'class' => basename($file, '.php'),
'relFile' => $this->relative($record, $file),
'isInterface' => (bool) preg_match('/^\s*interface\s+\w+/m', $content),
'summary' => $this->classDocSummary($content),
];
}
}
usort($repositories, static fn (array $a, array $b): int => $a['class'] <=> $b['class']);
return $repositories;
}
/**
* `protected $x = '...'` 형태의 문자열 프로퍼티 값을 읽습니다.
*
* @param string $content 소스
* @param string $name 프로퍼티명
* @return string|null 값 (없으면 null)
*/
private function stringProperty(string $content, string $name): ?string
{
if (preg_match('/\$'.preg_quote($name, '/')."\s*=\s*'([^']*)'/", $content, $m)) {
return $m[1];
}
return null;
}
/**
* `protected $x = [...]` 형태의 배열 프로퍼티 원소 수를 셉니다.
*
* 근사치입니다 — 문서의 규모 감을 주기 위한 값이며 계약 판정에 쓰지 않습니다.
*
* @param string $content 소스
* @param string $name 프로퍼티명
* @return int|null 원소 수 (프로퍼티 없으면 null)
*/
private function arrayPropertyCount(string $content, string $name): ?int
{
if (! preg_match('/\$'.preg_quote($name, '/').'\s*=\s*\[(.*?)\];/s', $content, $m)) {
return null;
}
return preg_match_all("/'[^']*'/", $m[1]);
}
/**
* 클래스 docblock 의 첫 문장을 요약으로 뽑습니다.
*
* @param string $content 소스
* @return string|null 요약 (없으면 null)
*/
private function classDocSummary(string $content): ?string
{
if (! preg_match('#/\*\*(.*?)\*/\s*(?:final\s+|abstract\s+|readonly\s+)*(?:class|enum|interface|trait)\s+\w+#s', $content, $m)) {
return null;
}
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/^\s*\*\s?/', '', $line) ?? '');
if ($line !== '' && ! str_starts_with($line, '@')) {
return $line;
}
}
return null;
}
/**
* FQCN 에서 클래스 짧은 이름을 뽑습니다.
*
* @param string $fqcn 클래스명
* @return string 짧은 이름
*/
private function shortName(string $fqcn): string
{
$parts = explode('\\', trim($fqcn, '\\'));
return end($parts) ?: $fqcn;
}
/**
* 확장 하위 디렉토리의 PHP 파일을 열거합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param string $sub 확장 루트 기준 하위 경로
* @return array<int, string> PHP 파일 절대 경로
*/
private function filesIn(array $record, string $sub): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
if (! is_dir($dir)) {
return [];
}
$files = [];
foreach (File::allFiles($dir) as $file) {
if ($file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
return $files;
}
/**
* 확장 루트 기준 상대 경로로 변환합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param string $absolute 절대 경로
* @return string 상대 경로 (POSIX 구분자)
*/
private function relative(array $record, string $absolute): string
{
$base = rtrim((string) $record['path'], '/\\').DIRECTORY_SEPARATOR;
$rel = str_starts_with($absolute, $base) ? substr($absolute, strlen($base)) : $absolute;
return str_replace('\\', '/', $rel);
}
}