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