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

195 lines
6.4 KiB
PHP

<?php
namespace App\Support\ExtensionDoc;
/**
* 확장 의존 관계 수집기
*
* manifest 의 `dependencies` 선언을 정방향(내가 의존하는 확장)과 역방향(나에게 의존하는
* 확장) 양쪽으로 해석합니다.
*
* 역방향이 이 수집기의 존재 이유입니다 — 운영자가 "이 확장을 끄면 무엇이 같이 죽는가" 를
* 알아야 하는데, 그 정보는 어느 한 manifest 에도 없고 번들 전수를 교차 스캔해야만 나옵니다.
* 확장명을 하드코딩하지 않고 인벤토리 스캔 결과에서 도출하므로 신규 확장이 자동 편입됩니다.
*/
class DependencyGraphCollector
{
/**
* @var array<int, array<string, mixed>>|null 전수 인벤토리 캐시
*/
private ?array $universe = null;
/**
* @param ExtensionInventory $inventory 번들 확장 인벤토리
*/
public function __construct(private readonly ExtensionInventory $inventory) {}
/**
* 확장의 의존 관계를 수집합니다.
*
* @param array<string, mixed> $record ExtensionInventory 레코드
* @return array{requires: array<int, array{type: string, id: string, constraint: string, bundled: bool}>, requiredBy: array<int, array{type: string, id: string, constraint: string}>, coreVersion: string|null}
*/
public function collect(array $record): array
{
return [
'requires' => $this->requires($record),
'requiredBy' => $this->requiredBy($record),
'coreVersion' => $this->coreConstraint($record),
];
}
/**
* 이 확장이 의존하는 확장 목록을 반환합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array{type: string, id: string, constraint: string, bundled: bool}>
*/
private function requires(array $record): array
{
$requires = [];
foreach ($this->declaredDependencies($record) as $type => $entries) {
foreach ($entries as $id => $constraint) {
$requires[] = [
'type' => $type,
'id' => (string) $id,
'constraint' => (string) $constraint,
'bundled' => $this->isBundled($type, (string) $id),
];
}
}
usort($requires, static fn (array $a, array $b): int => [$a['type'], $a['id']] <=> [$b['type'], $b['id']]);
return $requires;
}
/**
* 이 확장에 의존하는 확장 목록을 반환합니다 (번들 전수 교차 스캔).
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array{type: string, id: string, constraint: string}>
*/
private function requiredBy(array $record): array
{
$selfType = $this->pluralize((string) $record['type']);
$selfId = (string) $record['id'];
$dependents = [];
foreach ($this->allExtensions() as $other) {
if ($other['id'] === $selfId && $other['type'] === $record['type']) {
continue;
}
$declared = $this->declaredDependencies($other);
$constraint = $declared[$selfType][$selfId] ?? null;
if ($constraint === null) {
continue;
}
$dependents[] = [
'type' => (string) $other['type'],
'id' => (string) $other['id'],
'constraint' => (string) $constraint,
];
}
usort($dependents, static fn (array $a, array $b): int => [$a['type'], $a['id']] <=> [$b['type'], $b['id']]);
return $dependents;
}
/**
* manifest 의 코어 버전 제약을 읽습니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return string|null 코어 버전 제약 (없으면 null)
*/
private function coreConstraint(array $record): ?string
{
$value = $record['manifest']['g7_version'] ?? ($record['manifest']['requires']['g7_version'] ?? null);
return is_string($value) && $value !== '' ? $value : null;
}
/**
* manifest 의 dependencies 선언을 정규화합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{modules: array<string, string>, plugins: array<string, string>}
*/
private function declaredDependencies(array $record): array
{
$declared = $record['manifest']['dependencies'] ?? [];
// templates 도 정규화한다 — pluralize() 가 'templates' 를 만드는데 여기에 그 키가
// 없으면 템플릿의 역방향 의존(`requiredBy`)이 구조적으로 항상 빈 배열이 된다.
$normalized = ['modules' => [], 'plugins' => [], 'templates' => []];
if (! is_array($declared)) {
return $normalized;
}
foreach (['modules', 'plugins', 'templates'] as $key) {
$entries = $declared[$key] ?? [];
if (! is_array($entries)) {
continue;
}
foreach ($entries as $id => $constraint) {
if (is_string($constraint)) {
$normalized[$key][(string) $id] = $constraint;
}
}
}
return $normalized;
}
/**
* 유형 단수형을 manifest dependencies 의 복수 키로 바꿉니다.
*
* @param string $type 확장 유형
* @return string 복수 키 (`modules` | `plugins` | `templates`)
*/
private function pluralize(string $type): string
{
return $type.'s';
}
/**
* 대상이 번들 확장인지 확인합니다.
*
* @param string $pluralType 복수 키
* @param string $id 확장 식별자
* @return bool 번들 여부
*/
private function isBundled(string $pluralType, string $id): bool
{
$singular = rtrim($pluralType, 's');
foreach ($this->allExtensions() as $ext) {
if ($ext['type'] === $singular && $ext['id'] === $id) {
return true;
}
}
return false;
}
/**
* 번들 확장 전수를 반환합니다 (1회 스캔 후 캐시).
*
* @return array<int, array<string, mixed>> 확장 레코드 목록
*/
private function allExtensions(): array
{
if ($this->universe === null) {
$this->universe = $this->inventory->collect('all');
}
return $this->universe;
}
}