번들 확장 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 추출 누락도 같은 성격이라 함께 고쳤다.
333 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|