Files
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

528 lines
18 KiB
PHP

<?php
namespace App\Support\ExtensionDoc;
use Illuminate\Support\Facades\File;
/**
* 확장 훅 인벤토리
*
* 확장이 **발행하는 훅**과 **구독하는 훅**(리스너의 `getSubscribedHooks()`)을 수집합니다.
*
* 발행 훅의 1차 출처는 확장이 `getHooks()` 로 **선언한 목록**입니다. 소스 스캔은 그 선언을
* 보강할 뿐입니다 — 훅 이름을 `self::PLUGIN_ID.'.consent.granted'` 처럼 조립하는 확장이
* 실재하고, 그런 호출은 리터럴 스캔이 원리상 읽을 수 없기 때문입니다. 스캔만 믿으면 훅을
* 12곳에서 발행하는 확장이 "훅을 발행하지 않습니다" 로 문서화됩니다(실제 `sirsoft-gdpr`).
* 선언은 유형과 ko/en 설명까지 갖고 있어 스캔이 만들 수 없는 정보를 준다는 이점도 있습니다.
*
* 구독 훅은 `_bundled` 소스 파일을 직접 파싱합니다. 리스너 클래스를 로드해 static 메서드를
* 호출하면 활성 디렉토리에 이미 로드된 동일 FQCN 이 우선하므로, `_bundled` 를 고쳐도 그
* 변경이 문서에 반영되지 않습니다. 문서 SSoT 는 `_bundled` 소스이므로 파싱으로 읽습니다.
*/
class HookInventory
{
/**
* 훅 발행 호출 형태 → 훅 유형.
*
* @var array<string, string>
*/
private const EMIT_FORMS = [
'doAction' => 'action',
'applyFilters' => 'filter',
'broadcast' => 'broadcast',
];
/**
* 훅 이름 리터럴을 갖는 발행 호출을 찾는 패턴.
*
* 첫 인자가 리터럴이 아닌(변수/보간) 호출은 이름을 정적으로 알 수 없으므로 별도 집계한다.
*/
private const EMIT_LITERAL = "/HookManager::(doAction|applyFilters|broadcast)\s*\(\s*'([^']+)'/";
/**
* 발행 호출 전수(리터럴 여부 무관)를 세는 패턴.
*/
private const EMIT_ANY = '/HookManager::(doAction|applyFilters|broadcast)\s*\(/';
/**
* 확장의 훅 표면을 수집합니다.
*
* @param array<string, mixed> $record ExtensionInventory 레코드
* @param array<int, mixed> $declared 확장이 `getHooks()` 로 선언한 발행 훅 목록
* @return array{published: array<int, array<string, mixed>>, publishedSites: int, publishedDynamic: int, publishedUndeclared: int, subscribed: array<int, array<string, mixed>>, listeners: array<int, array<string, mixed>>}
*/
public function collect(array $record, array $declared = []): array
{
$published = $this->collectPublished($record, $declared);
$listeners = $this->collectListeners($record);
$subscribed = [];
foreach ($listeners as $listener) {
foreach ($listener['hooks'] as $hook) {
$subscribed[] = $hook + ['listener' => $listener['class'], 'listenerFile' => $listener['relFile']];
}
}
usort($subscribed, static fn (array $a, array $b): int => [$a['name'], $a['listener']] <=> [$b['name'], $b['listener']]);
return [
'published' => $published['hooks'],
'publishedSites' => $published['sites'],
'publishedDynamic' => $published['dynamic'],
'publishedUndeclared' => $published['undeclared'],
'subscribed' => $subscribed,
'listeners' => $listeners,
];
}
/**
* 확장이 발행하는 훅을 수집합니다.
*
* 선언(`getHooks()`)이 1차 출처이고 소스 스캔이 보강합니다. 선언에만 있는 훅은 호출
* 지점 없이 표에 남고(이름·유형·설명은 선언이 준다), 스캔에만 있는 훅은 `declared`
* 를 false 로 실어 "선언되지 않음" 을 문서가 드러낼 수 있게 합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param array<int, mixed> $declared `getHooks()` 선언 목록
* @return array{hooks: array<int, array<string, mixed>>, sites: int, dynamic: int, undeclared: int}
* sites 는 리터럴/동적을 합한 전체 호출 지점 수
*/
private function collectPublished(array $record, array $declared = []): array
{
$byName = $this->declaredHooks($declared);
$sites = 0;
$literalSites = 0;
foreach ($this->phpFiles($record) as $file) {
$content = (string) file_get_contents($file);
if (! str_contains($content, 'HookManager::')) {
continue;
}
$sites += preg_match_all(self::EMIT_ANY, $content);
if (! preg_match_all(self::EMIT_LITERAL, $content, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
continue;
}
foreach ($matches as $m) {
$literalSites++;
$form = $m[1][0];
$name = $m[2][0];
$line = substr_count(substr($content, 0, (int) $m[0][1]), "\n") + 1;
$rel = $this->relative($record, $file);
if (! isset($byName[$name])) {
$byName[$name] = [
'name' => $name,
'type' => self::EMIT_FORMS[$form] ?? $form,
'description' => null,
'parameters' => [],
'declared' => false,
'sites' => [],
];
}
$scannedType = self::EMIT_FORMS[$form] ?? $form;
if (($byName[$name]['typeDeclared'] ?? true) === false
&& $byName[$name]['type'] !== $scannedType) {
// 선언이 유형을 말하지 않았고 소스가 다른 유형으로 발행한다 —
// 기본값이 사실을 덮은 자리이므로 실측을 따르고 표에 사유를 남긴다.
$byName[$name]['type'] = $scannedType;
$byName[$name]['typeInferred'] = true;
}
$byName[$name]['sites'][] = ['file' => $rel, 'line' => $line];
}
}
ksort($byName);
$undeclared = 0;
foreach ($byName as $hook) {
if ($hook['declared'] === false) {
$undeclared++;
}
}
return [
'hooks' => array_values($byName),
'sites' => $sites,
'dynamic' => max(0, $sites - $literalSites),
'undeclared' => $undeclared,
];
}
/**
* `getHooks()` 선언을 훅 이름 색인으로 정규화합니다.
*
* 선언 형식이 어긋난 항목(이름 없음 등)은 조용히 버리지 않고 건너뛰되, 이름만 있으면
* 유형·설명이 없어도 표에 남깁니다 — 발행 사실 자체가 확장점 공개의 핵심입니다.
*
* @param array<int, mixed> $declared 선언 목록
* @return array<string, array<string, mixed>> 훅 이름 → 항목
*/
private function declaredHooks(array $declared): array
{
$byName = [];
foreach ($declared as $hook) {
if (! is_array($hook)) {
continue;
}
$name = $hook['name'] ?? null;
if (! is_string($name) || $name === '') {
continue;
}
$description = $hook['description'] ?? null;
if (is_array($description)) {
// ko 우선 — 확장 문서는 한국어다. ko 가 없으면 첫 값을 쓴다.
$description = $description['ko'] ?? (reset($description) ?: null);
}
$byName[$name] = [
'name' => $name,
// 유형 미기재를 조용히 `action` 으로 굳히면, 실제로 filter 인 훅이
// action 으로 문서화되어 구독하는 쪽이 반환값 계약을 잘못 읽는다.
// 미기재 사실을 남겨 스캔 결과와 어긋날 때 드러나게 한다.
'type' => is_string($hook['type'] ?? null) ? $hook['type'] : 'action',
'typeDeclared' => is_string($hook['type'] ?? null),
'description' => is_string($description) && $description !== '' ? $description : null,
'parameters' => is_array($hook['parameters'] ?? null) ? $hook['parameters'] : [],
'declared' => true,
'sites' => [],
];
}
return $byName;
}
/**
* 확장의 훅 리스너와 각 리스너가 구독하는 훅을 수집합니다.
*
* `src/Listeners/` 를 스캔해 리스너와 그 구독 훅을 모읍니다. 명시 등록(`getHookListeners()`)
* 여부는 여기서 판정하지 않습니다 — 스캐폴더가 선언형 표면의 `getHookListeners` 값과
* 대조해 표기합니다. 이 배열에는 `registered` 키가 없습니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array<string, mixed>> 리스너 목록
*/
private function collectListeners(array $record): array
{
$listenerDir = $record['path'].DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'Listeners';
$listeners = [];
if (! is_dir($listenerDir)) {
return $listeners;
}
foreach (File::allFiles($listenerDir) as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$path = $file->getPathname();
$content = (string) file_get_contents($path);
$class = $this->fqcnOf($content);
$listeners[] = [
'class' => $class ?? $file->getFilenameWithoutExtension(),
'shortClass' => $file->getFilenameWithoutExtension(),
'relFile' => $this->relative($record, $path),
'implementsContract' => str_contains($content, 'HookListenerInterface'),
'hooks' => $this->parseSubscribedHooks($content),
];
}
usort($listeners, static fn (array $a, array $b): int => $a['shortClass'] <=> $b['shortClass']);
return $listeners;
}
/**
* `getSubscribedHooks()` 메서드 본문에서 구독 훅 선언을 파싱합니다.
*
* 반환 배열의 최상위 항목만 봅니다 — `'훅이름' => [ ... ]` 형태의 키가 훅 이름이고,
* 값 슬라이스에서 method/priority/type 을 읽습니다. `type` 미선언은 action 으로 간주하되
* 그 사실을 `typeDeclared` 로 남깁니다 (filter 훅의 type 마커 누락은 별도 룰이 검사).
*
* @param string $content 리스너 소스
* @return array<int, array<string, mixed>> 구독 훅 목록
*/
private function parseSubscribedHooks(string $content): array
{
$body = $this->extractReturnArray($content, 'getSubscribedHooks');
if ($body === null) {
return [];
}
$hooks = [];
foreach ($this->splitTopLevel($body) as $item) {
if (! preg_match("/^\s*'([^']+)'\s*=>\s*(.*)$/s", $item, $m)) {
continue;
}
$value = $m[2];
$type = null;
if (preg_match("/'type'\s*=>\s*'([^']+)'/", $value, $tm)) {
$type = $tm[1];
}
$method = null;
if (preg_match("/'method'\s*=>\s*'([^']+)'/", $value, $mm)) {
$method = $mm[1];
} elseif (preg_match("/^\s*'([^']+)'\s*$/", $value, $sm)) {
$method = $sm[1];
}
$priority = null;
if (preg_match("/'priority'\s*=>\s*(-?\d+)/", $value, $pm)) {
$priority = (int) $pm[1];
}
$hooks[] = [
'name' => $m[1],
'method' => $method,
'priority' => $priority,
'type' => $type ?? 'action',
'typeDeclared' => $type !== null,
];
}
return $hooks;
}
/**
* 지정 메서드의 `return [...]` 배열 본문을 대괄호 균형으로 잘라냅니다.
*
* 정규식 하나로 배열을 잡으려 하면 중첩 배열에서 끊기므로, 여는 대괄호부터
* 문자열·주석을 건너뛰며 깊이를 세어 대응하는 닫는 위치를 찾습니다.
*
* @param string $content 소스
* @param string $method 메서드명
* @return string|null 배열 본문 (대괄호 제외, 없으면 null)
*/
private function extractReturnArray(string $content, string $method): ?string
{
if (! preg_match('/function\s+'.preg_quote($method, '/').'\s*\(/', $content, $m, PREG_OFFSET_CAPTURE)) {
return null;
}
$from = (int) $m[0][1];
$returnPos = strpos($content, 'return [', $from);
if ($returnPos === false) {
return null;
}
$open = $returnPos + strlen('return ');
$close = $this->matchBracket($content, $open);
return $close === null ? null : substr($content, $open + 1, $close - $open - 1);
}
/**
* 여는 대괄호 위치에 대응하는 닫는 대괄호 위치를 찾습니다.
*
* @param string $s 소스
* @param int $open 여는 대괄호 인덱스
* @return int|null 닫는 대괄호 인덱스 (불균형이면 null)
*/
private function matchBracket(string $s, int $open): ?int
{
$depth = 0;
$len = strlen($s);
for ($i = $open; $i < $len; $i++) {
$ch = $s[$i];
if ($ch === "'" || $ch === '"') {
$i = $this->skipString($s, $i);
continue;
}
if ($ch === '/' && $i + 1 < $len && ($s[$i + 1] === '/' || $s[$i + 1] === '*')) {
$i = $this->skipComment($s, $i);
continue;
}
if ($ch === '[') {
$depth++;
} elseif ($ch === ']') {
$depth--;
if ($depth === 0) {
return $i;
}
}
}
return null;
}
/**
* 배열 본문을 최상위 쉼표 기준으로 분할합니다.
*
* @param string $body 배열 본문
* @return array<int, string> 항목 목록 (빈 항목 제외)
*/
private function splitTopLevel(string $body): array
{
$items = [];
$buf = '';
$depth = 0;
$len = strlen($body);
for ($i = 0; $i < $len; $i++) {
$ch = $body[$i];
if ($ch === "'" || $ch === '"') {
$end = $this->skipString($body, $i);
$buf .= substr($body, $i, $end - $i + 1);
$i = $end;
continue;
}
if ($ch === '/' && $i + 1 < $len && ($body[$i + 1] === '/' || $body[$i + 1] === '*')) {
$i = $this->skipComment($body, $i);
continue;
}
if ($ch === '[' || $ch === '(') {
$depth++;
} elseif ($ch === ']' || $ch === ')') {
$depth--;
}
if ($ch === ',' && $depth === 0) {
if (trim($buf) !== '') {
$items[] = $buf;
}
$buf = '';
continue;
}
$buf .= $ch;
}
if (trim($buf) !== '') {
$items[] = $buf;
}
return $items;
}
/**
* 문자열 리터럴의 끝 인덱스를 찾습니다 (이스케이프 인지).
*
* @param string $s 소스
* @param int $start 따옴표 인덱스
* @return int 닫는 따옴표 인덱스 (미종료 시 문자열 끝)
*/
private function skipString(string $s, int $start): int
{
$quote = $s[$start];
$len = strlen($s);
for ($i = $start + 1; $i < $len; $i++) {
if ($s[$i] === '\\') {
$i++;
continue;
}
if ($s[$i] === $quote) {
return $i;
}
}
return $len - 1;
}
/**
* 주석의 끝 인덱스를 찾습니다.
*
* @param string $s 소스
* @param int $start `/` 인덱스
* @return int 주석 마지막 문자 인덱스
*/
private function skipComment(string $s, int $start): int
{
if ($s[$start + 1] === '/') {
$end = strpos($s, "\n", $start);
return $end === false ? strlen($s) - 1 : $end;
}
$end = strpos($s, '*/', $start);
return $end === false ? strlen($s) - 1 : $end + 1;
}
/**
* 소스에서 FQCN 을 추출합니다.
*
* @param string $content 소스
* @return string|null FQCN (없으면 null)
*/
private function fqcnOf(string $content): ?string
{
if (! preg_match('/^\s*namespace\s+([^;]+);/m', $content, $nm)) {
return null;
}
if (! preg_match('/^\s*(?:final\s+|abstract\s+)?class\s+(\w+)/m', $content, $cm)) {
return null;
}
return trim($nm[1]).'\\'.$cm[1];
}
/**
* 훅 발행 스캔 대상 PHP 파일을 열거합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return \Generator<string> PHP 파일 절대 경로
*/
private function phpFiles(array $record): \Generator
{
foreach (['src', 'database', 'upgrades', 'resources', 'config'] as $sub) {
$dir = $record['path'].DIRECTORY_SEPARATOR.$sub;
if (! is_dir($dir)) {
continue;
}
foreach (File::allFiles($dir) as $file) {
if ($file->getExtension() === 'php') {
yield $file->getPathname();
}
}
}
if ($record['entryFile'] !== null) {
yield $record['entryFile'];
}
}
/**
* 확장 루트 기준 상대 경로로 변환합니다.
*
* @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);
}
}