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 추출 누락도 같은 성격이라 함께 고쳤다.
This commit is contained in:
HeuJung
2026-08-31 07:58:44 +09:00
parent 266b723b9d
commit 11175d35d6
16 changed files with 6984 additions and 3 deletions
+29 -1
View File
@@ -102,13 +102,14 @@
| [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
| [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
### 확장 시스템 [extension/](docs/extension/) (30개)
### 확장 시스템 [extension/](docs/extension/) (31개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [cache-driver.md](docs/extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) | 모든 캐시 저장은 CacheInterface 사용 (Cache:: 직접 호출 금지) |
| [changelog-rules.md](docs/extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) | 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가) |
| [editor-spec.md](docs/extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) | editor-spec.json = 편집기 팔레트/스타일 컨트롤/중첩 규칙/샘플 데이터/레시피의 선언 (... |
| [extension-documentation.md](docs/extension/extension-documentation.md) | 확장 개발자 문서 (Extension Documentation) | 확장마다 AGENTS.md(개발자·에이전트용) + README.md(사람용) + docs/(상세) 를 갖는다 |
| [extension-manager.md](docs/extension/extension-manager.md) | ExtensionManager (확장 관리자) | composer.json 수정 없음 - 런타임 오토로드 방식 사용 |
| [extension-update-system.md](docs/extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) | 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여) |
| [hooks.md](docs/extension/hooks.md) | 훅 시스템 (Hook System) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
@@ -461,6 +462,32 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
> 상세: [module-assets.md](docs/extension/module-assets.md) "사용자 추가 에셋", [static-asset-publishing.md](docs/backend/static-asset-publishing.md)
> 정적 검사가 외부 자산 URL 과 번들 확장의 `custom/` 배포를 차단한다. 서술자 형태와 교체 2경로 보존은 테스트가 잠근다.
### 확장은 자기 개발자 문서를 소유한다
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답한다. 확장을 고치려는 쪽이 실제로 묻는 것은 그 앞이다 — **왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안 되는가.** 그 답이 코드 안에만 있으면 매번 `src/` 전체를 훑어 구조를 재발견하게 되고, 확장이 발행하는 훅은 확장점인데도 사실상 비공개가 된다.
확장마다 `AGENTS.md`(고치는 쪽) · `README.md`(도입·운영 쪽) · `docs/**`(상세)를 두고, 코드에서 실측되는 표는 `php artisan ext:docgen` 이 유지한다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 확장 표면(훅·라우트·권한·모델·레이아웃·핸들러)을 바꾸고 그 확장 문서를 그대로 둠 | 같은 작업 단위에 `ext:docgen --scope={type}:{id}` 재실행 + 낡은 서술 정정 |
| 자동 생성 블록(`@generated:*`) 안쪽을 손으로 고침 | 생성기가 교체하는 자리다 — 코드를 고치거나 블록 **밖**에 서술한다 |
| 생성기에 파괴적 재생성 플래그(`--force`)를 추가 | 기본 동작이 "블록 안쪽 교체" 다. 사람 서술이 소실될 경로를 만들지 않는다 |
| 문서에 없는 블록 키를 생성기가 임의 위치에 주입 | 누락으로 보고하고 사람이 마커 자리를 정한다 (문서 구조는 사람 소유) |
| 필수 문서·섹션·블록 목록을 검사 스크립트에 복제 | `ExtensionDocScaffolder::DOCUMENTS` 단일 SSoT — 스크립트는 `ext:docgen --check --json` 을 소비한다 |
| `TODO:` 마커를 추측으로 채움 | 코드 근거를 읽어 서술한다 — 다섯 자리(의도·흐름·금지패턴·사용방법·트러블슈팅)는 생성기가 채울 수 없는 **왜** 다 |
| `5. 수정 시 동반 의무` 에 코어 횡단 규정을 전부 나열 | 그 확장에 **실제로 걸리는 것만** 추린다 — 전부 적으면 정작 걸리는 항목이 묻힌다 |
| 신규 확장을 문서 없이 스캐폴딩 | `php artisan ext:docgen --scope={type}:{id} --init` 으로 골격을 함께 만든다 — 없으면 21번째 확장부터 다시 문서 없이 태어난다 |
| 확장 문서를 활성 디렉토리에서 작성 | `_bundled` 에서만 작성하고 update 커맨드로 반영 (문서만이면 빌드 불필요) |
| 확장이 훅을 추가할 때 코어 문서를 고침 | 훅 집계는 그 확장의 `docs/extension-points.md` 소유 — 코어에는 총계와 링크만 |
이 결함군은 오류를 남기지 않는다. 문서가 코드와 어긋난 채로 계속 읽히는 것이 유일한 증상이며, 훅 이름이 어긋나면 그 확장을 잡으려던 쪽이 **잡히지 않는 훅을 구독**하게 된다(예외도 경고도 없이 리스너가 호출되지 않을 뿐이다).
mermaid 문법 오류는 GitHub 렌더 시점에만 드러난다. 구조 검사가 잡을 수 있는 것은 선언된 다이어그램 종류·빈 본문·괄호 균형까지이므로, 새 형식은 실제 렌더를 눈으로 확인한다.
> 상세: [extension-documentation.md](docs/extension/extension-documentation.md)
> 정적 검사가 확장 표면 변경 시 문서 미동반과 미채움 마커 잔존을 검출한다. 생성기의 비파괴 계약(블록 밖 손실 0 · 재실행 멱등 · 미존재 키 미주입)과 필수 문서·섹션·블록 목록은 테스트가 잠근다.
### 의존성 감사 신호는 거짓일 수 있다
`npm audit --omit=dev` 는 **`dependencies` 에 선언된 것만** 본다. 실행에 쓰이는 라이브러리가 `devDependencies` 에 있으면 그 패키지는 검사 대상에서 통째로 빠지고, 취약점이 있어도 감사는 0건을 돌려준다. "운영 의존성 취약점 없음" 이라는 완료 조건이 취약한 상태로도 충족된 것처럼 보인다.
@@ -1388,6 +1415,7 @@ php artisan migrate:rollback
| 수정 대상 파일 패턴 | 작업 전 필수 참조 |
| ------------------- | ------------------ |
| `(modules\|plugins\|templates)/_bundled/{id}/**` (그 확장의 소스 전반) | 그 확장의 `AGENTS.md` · `docs/README.md` — 설계 의도·디렉토리 지도·확장점·**수정 시 동반 의무**·금지 패턴. 수정 후 표면이 바뀌었으면 `php artisan ext:docgen --scope={type}:{id}` ([extension-documentation.md](docs/extension/extension-documentation.md)) |
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md), [api-documentation.md](docs/backend/api-documentation.md) |
| `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
+2
View File
@@ -22,6 +22,8 @@
- 프록시 뒤에서 구동 중인데 신뢰 프록시가 지정되지 않았으면 관리자 대시보드가 그 사실을 알립니다. 환경설정 > 고급 에서 사이트가 인식한 접속 방식과 방문자 IP 를 확인할 수 있고, 서버에서 `php artisan trusted-proxy:status` 로도 확인할 수 있으며, 설치 마법사도 설치 단계에서 함께 안내합니다. HTTPS 를 쓰지 않는 사이트도 대상입니다 — 이 경우 화면은 정상이지만 방문자 IP 기록과 결제 통보 수신이 어긋나 있어도 드러나지 않기 때문입니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
- 사이트 설정 문제로 화면 구성 파일이 브라우저에 차단된 경우, 네트워크 오류와 구분되는 안내를 표시합니다. 새로고침해도 낫지 않는 상황이므로 [새로고침] 버튼을 두지 않으며, 원인과 조치 방법은 운영자가 확인할 수 있도록 브라우저 콘솔에 남깁니다. (#124 @lyg-kaban 님께서 건의해주셨습니다.)
- 사이트가 쓰는 외부 라이브러리의 알려진 취약점을 한 번에 점검하는 명령이 추가되었습니다. `php artisan security:audit-dependencies` 로 코어와 설치된 모든 확장을 함께 확인할 수 있고, 개발 대시보드에서도 실행할 수 있습니다. 점검 도구가 원리상 볼 수 없는 동봉 라이브러리는 버전 목록으로 함께 표시해 운영자가 직접 확인할 수 있게 했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
- 확장(모듈·플러그인·템플릿)이 개발자 문서를 갖추기 위한 체계가 마련되었습니다. 확장마다 `AGENTS.md`(확장을 고치는 사람용 — 설계 의도·확장점·수정 시 동반 의무·금지 패턴)와 `README.md`(도입 검토·운영자용 — 기능·설치·사용 방법·트러블슈팅), `docs/` 상세 문서를 두는 형식을 정의했으며, 문서 내용은 확장별로 순차 반영됩니다.
- 확장 문서에서 코드로 확인되는 부분(발행·구독 훅, 라우트, 권한, 메뉴, 설정 항목, 모델과 테이블, 레이아웃, 액션 핸들러, 테스트 실행 경로, 다른 확장과의 의존 관계)을 `php artisan ext:docgen` 이 자동으로 채우고 유지합니다. 사람이 쓴 서술은 손대지 않고 자동 생성 표만 교체하며, `php artisan ext:docgen --check` 로 문서가 코드와 어긋났는지 확인할 수 있습니다. 개발 대시보드에서도 실행할 수 있습니다.
### Changed
@@ -0,0 +1,468 @@
<?php
namespace App\Console\Commands\Extension;
use App\Support\ExtensionDoc\DataModelCollector;
use App\Support\ExtensionDoc\DeclarativeSurfaceCollector;
use App\Support\ExtensionDoc\DependencyGraphCollector;
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
use App\Support\ExtensionDoc\ExtensionInventory;
use App\Support\ExtensionDoc\FrontendInventory;
use App\Support\ExtensionDoc\HookInventory;
use App\Support\ExtensionDoc\TestPathCollector;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
/**
* 확장 개발자 문서 생성 커맨드
*
* 번들 확장의 `AGENTS.md` · `README.md` · `docs/**` 를 스캐폴딩하고, 코드에서 실측 가능한
* 부분(훅·라우트·권한·모델·레이아웃·테스트 경로)을 자동 생성 블록 안에 갱신합니다.
*
* 기본 동작이 **블록 안쪽 교체**이므로 사람이 쓴 서술이 소실될 경로가 없습니다.
* 그래서 `--force` 같은 파괴적 플래그를 두지 않고, 신규 파일 생성만 `--init` 으로 분리합니다.
*/
class ExtDocgenCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'ext:docgen
{--scope=all : 범위 (all, module:vendor-id, plugin:vendor-id, template:vendor-id)}
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
{--check : 생성하지 않고 누락·드리프트만 리포트}
{--json : 기계 판독 출력}
{--dry-run : 대상과 실측 집계만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = '번들 확장의 개발자 문서(AGENTS.md/README.md/docs)를 실측 기반으로 생성·갱신합니다';
/**
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못해 대상에서 빠진 디렉토리
*/
private array $malformed = [];
/**
* 커맨드를 실행합니다.
*
* @param ExtensionInventory $inventory 번들 확장 인벤토리
* @param DeclarativeSurfaceCollector $surface 선언형 표면 수집기
* @param HookInventory $hooks 훅 인벤토리
* @param DataModelCollector $data 데이터 모델 수집기
* @param FrontendInventory $frontend 프론트 인벤토리
* @param TestPathCollector $tests 테스트 경로 수집기
* @param DependencyGraphCollector $deps 의존 관계 수집기
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @return int 종료 코드
*/
public function handle(
ExtensionInventory $inventory,
DeclarativeSurfaceCollector $surface,
HookInventory $hooks,
DataModelCollector $data,
FrontendInventory $frontend,
TestPathCollector $tests,
DependencyGraphCollector $deps,
ExtensionDocScaffolder $scaffolder
): int {
$scope = (string) $this->option('scope');
// `--check --dry-run` 은 dry-run 분기가 먼저 반환해 이슈 배열이 전부 빈 채로 남는다 —
// 결과가 "이상 0건" 과 같은 모양이라 검사한 적 없는 실행이 통과로 보인다. 두 모드는
// 함께 쓸 수 없다고 명시적으로 거부한다.
// `--init` 도 같은 성질이다 — dry-run·check 와 함께 주면 조용히 무시되어
// "골격을 만들라고 시켰는데 아무 일도 없었다" 가 성공으로 보인다.
$conflict = match (true) {
$this->option('check') && $this->option('dry-run') => '--check 와 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 검사를 수행하지 않습니다).',
$this->option('init') && $this->option('dry-run') => '--init 과 --dry-run 은 함께 쓸 수 없습니다 (dry-run 은 파일을 만들지 않습니다).',
$this->option('init') && $this->option('check') => '--init 과 --check 는 함께 쓸 수 없습니다 (check 는 파일을 만들지 않습니다).',
default => null,
};
if ($conflict !== null) {
$message = $conflict;
if ($this->option('json')) {
$this->line((string) json_encode(
['scope' => $scope, 'extensions' => [], 'error' => $message],
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
));
return self::FAILURE;
}
$this->error($message);
return self::FAILURE;
}
try {
$records = $inventory->collect($scope);
$this->malformed = $inventory->malformed();
} catch (InvalidArgumentException $e) {
if ($this->option('json')) {
$this->line((string) json_encode(
['scope' => $scope, 'extensions' => [], 'error' => $e->getMessage()],
JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
));
return self::FAILURE;
}
$this->error($e->getMessage());
return self::FAILURE;
}
if ($records === []) {
$message = "범위 '{$scope}' 에 해당하는 번들 확장이 없습니다.";
if ($this->option('json')) {
$this->line((string) json_encode(['scope' => $scope, 'extensions' => [], 'error' => $message], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return self::FAILURE;
}
$this->warn($message);
return self::FAILURE;
}
$results = [];
foreach ($records as $record) {
// 선언형 표면을 먼저 모은다 — 발행 훅의 1차 출처가 그 안의 `getHooks()` 선언이다.
$collectedSurface = $surface->collect($record);
$declaredHooks = $collectedSurface['values']['getHooks'] ?? [];
$ctx = [
'record' => $record,
'surface' => $collectedSurface,
'hooks' => $hooks->collect($record, is_array($declaredHooks) ? $declaredHooks : []),
'data' => $data->collect($record),
'frontend' => $frontend->collect($record),
'tests' => $tests->collect($record),
'deps' => $deps->collect($record),
];
$results[] = $this->processExtension($ctx, $scaffolder);
}
return $this->report($results, $scope);
}
/**
* 단일 확장을 처리합니다.
*
* @param array<string, mixed> $ctx 수집 컨텍스트
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @return array<string, mixed> 처리 결과
*/
private function processExtension(array $ctx, ExtensionDocScaffolder $scaffolder): array
{
$record = $ctx['record'];
$stats = ExtensionDocScaffolder::statsOf($ctx);
$result = [
'type' => $record['type'],
'id' => $record['id'],
'relPath' => $record['relPath'],
'version' => $record['version'],
'stats' => $stats,
'surfaceAvailable' => $ctx['surface']['available'],
'surfaceReason' => $ctx['surface']['reason'],
'surfaceErrors' => $ctx['surface']['errors'],
'documents' => [],
'created' => [],
'updated' => [],
'missingDocuments' => [],
'missingSections' => [],
'missingBlocks' => [],
'driftedBlocks' => [],
'orphanBlocks' => [],
'unfilled' => [],
];
if ($this->option('dry-run')) {
$result['documents'] = ExtensionDocScaffolder::documentsForType($record['type']);
return $result;
}
// --init 은 파일을 만든 **뒤에** 블록을 다시 렌더한다.
// `docs-index` · `doc-toc` 블록은 문서 파일의 존재 여부를 읽어 링크와 상태를 채우므로,
// 생성 전에 렌더한 본문은 방금 만든 문서를 전부 "미작성" 으로 표기한다.
if ($this->option('init') && ! $this->option('check')) {
$this->initSkeletons($ctx, $scaffolder, $result);
}
$bodies = $scaffolder->renderBlocks($ctx);
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
if (! is_file($abs)) {
$result['missingDocuments'][] = $doc;
continue;
}
$content = (string) File::get($abs);
// 섹션 골격 검사 — 헤딩 누락은 문서가 형식을 벗어났다는 신호다.
// 유형별로 절 이름이 달라지는 자리가 있으므로 sectionsFor() 판정을 쓴다.
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
$result['missingSections'][] = $doc.' → '.$section;
}
}
// 미채움 마커 잔량
foreach (ExtensionDocScaffolder::todoMarkers() as $marker) {
$count = substr_count($content, $marker);
if ($count > 0) {
$result['unfilled'][] = ['doc' => $doc, 'marker' => $marker, 'count' => $count];
}
}
$docBodies = [];
foreach (ExtensionDocScaffolder::blocksFor($doc) as $key) {
if (array_key_exists($key, $bodies)) {
$docBodies[$key] = $bodies[$key];
}
}
$merged = ExtensionDocScaffolder::replaceBlocks($content, $docBodies);
foreach ($merged['missing'] as $key) {
$result['missingBlocks'][] = $doc.' → '.$key;
}
// 문서에 실재하지만 `DOCUMENTS` 가 모르는 블록 — 생성기가 순회 대상으로 삼지
// 않으므로 **영영 갱신되지 않고 누락으로도 보고되지 않는다**. 낡은 실측을 단 채
// `--check` 는 이상 0건을 보고한다. 절을 옮기거나 목록을 재편하면 즉시 생긴다.
foreach (ExtensionDocScaffolder::presentBlockKeys($content) as $key) {
if (! in_array($key, ExtensionDocScaffolder::blocksFor($doc), true)) {
$result['orphanBlocks'][] = $doc.' → '.$key;
}
}
if ($this->option('check')) {
if (! $merged['unchanged']) {
foreach ($merged['replaced'] as $key) {
if ($this->blockDiffers($content, $key, $docBodies[$key])) {
$result['driftedBlocks'][] = $doc.' → '.$key;
}
}
}
continue;
}
if (! $merged['unchanged']) {
File::put($abs, $merged['content']);
// 방금 만든 파일은 '생성' 으로만 보고한다 (같은 실행의 2차 렌더는 생성의 일부).
if (! in_array($doc, $result['created'], true)) {
$result['updated'][] = $doc;
}
}
}
return $result;
}
/**
* 문서가 없는 자리에 골격 파일을 만듭니다 (기존 파일은 건드리지 않습니다).
*
* @param array<string, mixed> $ctx 수집 컨텍스트
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @param array<string, mixed> $result 처리 결과 (created 누적)
*/
private function initSkeletons(array $ctx, ExtensionDocScaffolder $scaffolder, array &$result): void
{
$record = $ctx['record'];
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
if (is_file($abs)) {
continue;
}
File::ensureDirectoryExists(dirname($abs));
File::put($abs, $scaffolder->skeleton($doc, $ctx));
$result['created'][] = $doc;
}
}
/**
* 문서에 이미 들어 있는 블록 본문이 새로 렌더한 본문과 다른지 판정합니다.
*
* @param string $content 문서 내용
* @param string $key 블록 키
* @param string $body 새 본문
* @return bool 다르면 true
*/
private function blockDiffers(string $content, string $key, string $body): bool
{
$startPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+START\b.*?-->/s';
$endPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+END\s*-->/s';
if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) {
return false;
}
$from = (int) $sm[0][1] + strlen($sm[0][0]);
if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $from)) {
return false;
}
$existing = trim(substr($content, $from, ((int) $em[0][1]) - $from));
return $existing !== trim($body);
}
/**
* 처리 결과를 출력하고 종료 코드를 결정합니다.
*
* @param array<int, array<string, mixed>> $results 확장별 결과
* @param string $scope 범위
* @return int 종료 코드
*/
private function report(array $results, string $scope): int
{
$issues = 0;
foreach ($results as $result) {
$issues += count($result['missingDocuments'])
+ count($result['missingSections'])
+ count($result['missingBlocks'])
+ count($result['driftedBlocks'])
+ count($result['orphanBlocks']);
}
if ($this->option('json')) {
$this->line((string) json_encode([
'scope' => $scope,
'mode' => $this->mode(),
'extensions' => $results,
'malformed' => $this->malformed,
'issues' => $issues,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
return ($this->option('check') && $issues > 0) ? self::FAILURE : self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->info(count($results).'개 확장 (scope='.$scope.')');
$this->newLine();
foreach ($results as $result) {
$parts = [];
foreach ($result['stats'] as $label => $value) {
// 세지 못한 지표는 `null` 로 온다. 그대로 보간하면 빈 칸이 되어
// "0" 과도 "확인 못함" 과도 구분되지 않는다.
$parts[] = $value === null
? "{$label} ".ExtensionDocScaffolder::STAT_UNMEASURED
: "{$label} {$value}";
}
$this->line(sprintf(' [%s] %s v%s', $result['type'], $result['id'], $result['version']));
$this->line(' '.implode(' · ', $parts));
$this->line(' 문서 '.count($result['documents']).'종: '.implode(', ', $result['documents']));
if (! $result['surfaceAvailable'] && $result['surfaceReason'] !== null) {
$this->line(' 선언형 표면: '.$result['surfaceReason']);
}
if ($result['surfaceErrors'] !== []) {
foreach ($result['surfaceErrors'] as $getter => $message) {
$this->line(" ⚠ {$getter}(): {$message}");
}
}
}
return self::SUCCESS;
}
foreach ($results as $result) {
$label = sprintf('[%s] %s', $result['type'], $result['id']);
if ($result['created'] !== []) {
$this->info("{$label} 생성: ".implode(', ', $result['created']));
}
if ($result['updated'] !== []) {
$this->info("{$label} 갱신: ".implode(', ', $result['updated']));
}
if ($result['missingDocuments'] !== []) {
$this->warn("{$label} 문서 없음: ".implode(', ', $result['missingDocuments']));
}
if ($result['missingBlocks'] !== []) {
$this->warn("{$label} 자동 생성 블록 없음: ".implode(', ', $result['missingBlocks']));
}
if ($result['missingSections'] !== []) {
$this->warn("{$label} 필수 섹션 없음: ".implode(', ', $result['missingSections']));
}
if ($result['driftedBlocks'] !== []) {
$this->warn("{$label} 블록 드리프트(코드 실측과 불일치): ".implode(', ', $result['driftedBlocks']));
}
if ($result['orphanBlocks'] !== []) {
$this->warn("{$label} 갱신 대상이 아닌 자동 생성 블록(고아): ".implode(', ', $result['orphanBlocks']));
}
// 미채움 잔량은 계산만 하고 `--json` 에만 실려 있었다 — 계획이 이 마커를 둔
// 이유가 "잔량 집계" 이므로 사람이 읽는 출력에도 낸다.
if ($result['unfilled'] !== []) {
$total = array_sum(array_column($result['unfilled'], 'count'));
$this->line("{$label} 미채움 마커 {$total}건: ".implode(', ', array_map(
static fn (array $u): string => $u['doc'].' → '.$u['marker'].'×'.$u['count'],
$result['unfilled'],
)));
}
foreach ($result['surfaceErrors'] as $getter => $message) {
$this->warn("{$label} 선언형 표면 수집 실패 {$getter}(): {$message}");
}
}
foreach ($this->malformed as $bad) {
$this->warn(sprintf(
'[%s] %s — %s 를 읽지 못해 검사 대상에서 빠졌습니다 (%s). "확장이 없음" 이 아니라 "읽지 못함" 입니다.',
$bad['type'], $bad['id'], $bad['manifest'], $bad['reason'],
));
}
$this->newLine();
$this->info(sprintf('%d개 확장 처리 (scope=%s, mode=%s) — 이슈 %d건', count($results), $scope, $this->mode(), $issues));
if ($this->option('check') && $issues > 0) {
$this->line('`php artisan ext:docgen --init` 으로 골격을 만들고, `php artisan ext:docgen` 으로 블록을 갱신하세요.');
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* 현재 실행 모드를 반환합니다.
*
* @return string 모드 문자열
*/
private function mode(): string
{
if ($this->option('dry-run')) {
return 'dry-run';
}
if ($this->option('check')) {
return 'check';
}
if ($this->option('init')) {
return 'init';
}
return 'update';
}
}
@@ -0,0 +1,332 @@
<?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);
}
}
@@ -0,0 +1,364 @@
<?php
namespace App\Support\ExtensionDoc;
use App\Extension\AbstractModule;
use App\Extension\AbstractPlugin;
use ReflectionClass;
use Throwable;
/**
* 확장 선언형 표면 수집기
*
* `AbstractModule` / `AbstractPlugin` 이 이미 갖고 있는 선언형 getter 를 실제로 호출해
* 라우트·권한·메뉴·훅·설정·스케줄 등의 표면을 읽습니다. 정규식으로 소스를 긁는 방식과 달리
* 상속 기본값(`getRoutes()` 가 파일 존재 여부로 계산하는 값 등)까지 정확히 반영됩니다.
*
* 읽기 대상은 항상 `_bundled` 소스입니다. 활성 디렉토리에 같은 FQCN 이 이미 로드되어 있으면
* PHP 는 클래스를 재정의할 수 없으므로, `ModuleManager::evalFreshModule()` 과 같은 방식으로
* 진입 클래스명만 바꿔 메모리에 다시 로드합니다 (namespace 유지 → use/extends 정상 동작).
*
* 확장 getter 는 DB·파일시스템·다른 확장에 의존할 수 있으므로 개별 호출을 각각 격리합니다.
* 한 getter 의 실패가 나머지 수집을 중단시키지 않으며, 실패 사유는 `errors` 로 올라가
* 문서에 "수집 실패" 로 드러납니다 (조용한 누락 금지).
*/
class DeclarativeSurfaceCollector
{
/**
* 수집 대상 getter 와 문서상 라벨.
*
* 확장 유형에 없는 getter 는 `method_exists` 로 건너뜁니다 (모듈 전용 · 플러그인 전용 혼재).
*
* @var array<string, string>
*/
public const GETTERS = [
// 라우트·마이그레이션·뷰
'getRoutes' => '라우트 파일',
'getMigrations' => '마이그레이션 경로',
'getViews' => '뷰 경로',
'getSeeders' => '시더',
'getDynamicTables' => '동적 테이블',
// 권한·역할·메뉴
'getPermissions' => '권한 정의',
'getDynamicPermissionIdentifiers' => '동적 권한 식별자',
'getRoles' => '역할 정의',
'getDynamicRoleIdentifiers' => '동적 역할 식별자',
'getAdminMenus' => '관리자 메뉴',
'getCustomMenus' => '사용자 메뉴',
'getDynamicMenuSlugs' => '동적 메뉴 slug',
// 확장점
'getHooks' => '발행 훅 선언',
'getHookListeners' => '훅 리스너',
'getChannels' => '브로드캐스트 채널',
'getSchedules' => '스케줄',
'getMiddleware' => '미들웨어',
'getLayoutExtensions' => '레이아웃 확장',
'getNotificationDefinitions' => '알림 정의',
'getBenchmarkProfiles' => '성능 계측 프로파일',
// 본인인증
'getIdentityPolicies' => 'IDV 정책',
'getIdentityPurposes' => 'IDV 목적',
'getIdentityMessages' => 'IDV 메시지',
// 설정
'getConfig' => 'config 파일',
'getConfigValues' => 'config 값',
'getSettingsSchema' => '설정 스키마',
'getSettingsDefaultsPath' => '설정 기본값 경로',
'getSettingsLayout' => '설정 레이아웃',
'getSettingsRoute' => '설정 라우트',
'getSeoConfigPath' => 'SEO 설정 경로',
// 에셋
'getAssets' => '프론트 에셋',
'getAssetLoadingConfig' => '에셋 로딩 설정',
'getBuiltAssetPaths' => '빌드 산출물 경로',
'getTrustedScriptHosts' => '신뢰 스크립트 호스트',
'getStorageDisk' => '스토리지 디스크',
'getCacheStore' => '캐시 스토어',
// 메타
'getDependencies' => '의존 확장',
'getRequiredCoreVersion' => '코어 최소 버전',
'getLicense' => '라이선스',
'getGithubUrl' => 'GitHub URL',
];
/**
* 확장의 선언형 표면을 수집합니다.
*
* @param array<string, mixed> $record ExtensionInventory 레코드
* @return array{available: bool, reason: string|null, values: array<string, mixed>, errors: array<string, string>, endpoints: int}
* available=false 이면 values 는 비고 reason 에 사유가 담깁니다.
*/
public function collect(array $record): array
{
$empty = ['available' => false, 'reason' => null, 'values' => [], 'errors' => [], 'endpoints' => 0];
// 확장마다 초기화한다 — 남겨 두면 앞 확장의 실패가 다음 확장의 사유로 새어 나간다.
$this->pathInjectionError = null;
if (($record['entryFile'] ?? null) === null || ($record['entryClass'] ?? null) === null) {
$empty['reason'] = '진입 클래스 없음 (템플릿은 선언형 표면을 갖지 않습니다)';
return $empty;
}
$restore = $this->registerBundledAutoloader($record);
try {
$instance = $this->instantiate($record);
} catch (Throwable $e) {
$restore();
$empty['reason'] = '진입 클래스 로드 실패: '.$e->getMessage();
return $empty;
}
if ($instance === null) {
$restore();
$empty['reason'] = '진입 클래스 인스턴스화 실패: '.$record['entryClass'];
return $empty;
}
$values = [];
$errors = [];
if ($this->pathInjectionError !== null) {
$errors['__path_injection'] = $this->pathInjectionError;
}
try {
foreach (array_keys(self::GETTERS) as $getter) {
if (! method_exists($instance, $getter)) {
continue;
}
try {
$values[$getter] = $instance->{$getter}();
} catch (Throwable $e) {
$errors[$getter] = $e->getMessage();
}
}
} finally {
$restore();
}
return [
'available' => true,
'reason' => null,
'values' => $values,
'errors' => $errors,
'endpoints' => $this->countEndpoints($values['getRoutes'] ?? []),
];
}
/**
* 선언된 라우트 파일에 등록된 엔드포인트 수를 셉니다.
*
* 집계 배지가 말하는 "라우트 수" 는 **주소(엔드포인트) 개수**입니다 — 라우트 파일 개수가
* 아닙니다. 확장 대부분이 파일 1~2개에 수십 개의 주소를 담으므로 파일 수는 규모를 전혀
* 알려주지 않습니다. 이 값은 `docs/api/README.md` 목차의 엔드포인트 수와 같은 것을 세므로
* 두 표가 서로 다른 숫자를 말하지 않습니다.
*
* `Route::match(['GET','POST'], ...)` 는 한 번 등록되지만 주소는 메서드 수만큼이므로
* 배열 길이로 셉니다 (API 문서 생성기와 같은 기준).
*
* @param mixed $routes `getRoutes()` 반환값 (종류 => 파일 경로)
* @return int 엔드포인트 수 (셀 수 없으면 0)
*/
private function countEndpoints(mixed $routes): int
{
if (! is_array($routes)) {
return 0;
}
$verbs = 'get|post|put|patch|delete|options|any|dualSuffix|dualSuffixSegment|dualAsset';
$total = 0;
foreach ($routes as $path) {
if (! is_string($path) || ! is_file($path)) {
continue;
}
$source = (string) file_get_contents($path);
$total += preg_match_all('/Route::(?:'.$verbs.')\s*\(/', $source);
// match 는 메서드 배열의 길이만큼 주소를 만든다.
if (preg_match_all('/Route::match\s*\(\s*\[([^\]]*)\]/', $source, $m) > 0) {
foreach ($m[1] as $methods) {
$total += max(1, preg_match_all('/[\'"]/', $methods) / 2);
}
}
}
return (int) $total;
}
/**
* 진입 클래스를 인스턴스화합니다.
*
* 같은 FQCN 이 이미 로드되어 있으면(활성 디렉토리 확장이 부팅된 경우) 클래스명을 바꿔
* eval 로 다시 로드합니다. 그렇지 않으면 `_bundled` 파일을 직접 include 합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return object|null 확장 인스턴스 (실패 시 null)
*/
/**
* 직전 인스턴스화에서 발생한 경로 주입 실패 사유 (없으면 null).
*/
private ?string $pathInjectionError = null;
private function instantiate(array $record): ?object
{
$fqcn = (string) $record['entryClass'];
$entryFile = (string) $record['entryFile'];
if (! class_exists($fqcn, false)) {
require_once $entryFile;
return class_exists($fqcn, false) ? new $fqcn : null;
}
return $this->evalFreshEntry($record);
}
/**
* 이미 로드된 FQCN 을 피해 `_bundled` 진입 클래스를 새 이름으로 다시 로드합니다.
*
* PHP 는 동일 프로세스에서 클래스를 재정의할 수 없으므로 클래스명만 치환합니다.
* namespace 는 유지하므로 use/extends/implements 가 그대로 동작합니다.
* eval 로 만든 클래스는 `ReflectionClass::getFileName()` 이 비정상이라
* 경로 프로퍼티를 리플렉션으로 직접 주입합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return object|null 확장 인스턴스 (실패 시 null)
*/
private function evalFreshEntry(array $record): ?object
{
$content = @file_get_contents((string) $record['entryFile']);
if ($content === false) {
return null;
}
$short = (string) $record['entryClassShort'];
$uid = '_extdoc_'.bin2hex(random_bytes(6));
$renamed = preg_replace('/\bclass\s+'.preg_quote($short, '/').'\b/', 'class '.$short.$uid, $content, 1);
if (! is_string($renamed)) {
return null;
}
$renamed = preg_replace('/^<\?php\s*/', '', $renamed);
if (! is_string($renamed)) {
return null;
}
eval($renamed);
$freshClass = $record['namespace'].'\\'.$short.$uid;
if (! class_exists($freshClass, false)) {
return null;
}
$instance = new $freshClass;
$this->pathInjectionError = $this->injectExtensionPath($instance, (string) $record['path']);
return $instance;
}
/**
* eval 로 로드한 인스턴스에 확장 디렉토리 경로를 주입합니다.
*
* @param object $instance 확장 인스턴스
* @param string $path 확장 디렉토리 절대 경로
*/
private function injectExtensionPath(object $instance, string $path): ?string
{
$targets = [
AbstractModule::class => 'modulePath',
AbstractPlugin::class => 'pluginPath',
];
foreach ($targets as $class => $property) {
if (! $instance instanceof $class) {
continue;
}
try {
$ref = new ReflectionClass($class);
if (! $ref->hasProperty($property)) {
continue;
}
$prop = $ref->getProperty($property);
$prop->setAccessible(true);
$prop->setValue($instance, $path);
} catch (Throwable $e) {
// 경로 주입 실패는 예외를 던지지 않는다. 그런데 경로 기반 getter(`getRoutes`
// `getMigrations` 등)는 그 상태에서 **예외 없이 빈 값**을 돌려주므로 getter 별
// try/catch 에도 걸리지 않는다 — "없음" 으로 굳는 침묵 경로다. 사유를 올린다.
return $property.' 주입 실패: '.$e->getMessage();
}
}
return null;
}
/**
* `_bundled` composer.json 의 PSR-4 매핑을 임시 오토로더로 등록합니다.
*
* 아직 로드되지 않은 확장 내부 클래스(Listener·Model 등)가 활성 디렉토리가 아니라
* `_bundled` 소스에서 해석되도록 합니다. 수집이 끝나면 반드시 해제해야 하므로
* 해제 클로저를 돌려줍니다 (프로세스 잔류 시 이후 코드가 `_bundled` 를 보게 됨).
*
* @param array<string, mixed> $record 확장 레코드
* @return \Closure 해제 클로저
*/
private function registerBundledAutoloader(array $record): \Closure
{
$composerPath = $record['path'].DIRECTORY_SEPARATOR.'composer.json';
$psr4 = [];
if (is_file($composerPath)) {
$composer = json_decode((string) file_get_contents($composerPath), true);
$declared = $composer['autoload']['psr-4'] ?? null;
if (is_array($declared)) {
foreach ($declared as $prefix => $dir) {
$dirs = is_array($dir) ? $dir : [$dir];
foreach ($dirs as $one) {
$psr4[(string) $prefix][] = rtrim($record['path'].DIRECTORY_SEPARATOR.trim((string) $one, '/\\'), '/\\');
}
}
}
}
if ($psr4 === []) {
return static function (): void {};
}
$loader = static function (string $class) use ($psr4): void {
foreach ($psr4 as $prefix => $dirs) {
if (! str_starts_with($class, $prefix)) {
continue;
}
$relative = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($prefix))).'.php';
foreach ($dirs as $dir) {
$file = $dir.DIRECTORY_SEPARATOR.$relative;
if (is_file($file)) {
require_once $file;
return;
}
}
}
};
spl_autoload_register($loader, true, true);
return static function () use ($loader): void {
spl_autoload_unregister($loader);
};
}
}
@@ -0,0 +1,194 @@
<?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;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,322 @@
<?php
namespace App\Support\ExtensionDoc;
use App\Extension\ExtensionManager;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
/**
* 번들 확장 인벤토리
*
* `{modules,plugins,templates}/_bundled/*` 를 패턴 스캔해 확장 목록과 manifest 를
* 로드합니다. 확장명을 하드코딩하지 않으므로 신규 확장이 추가되면 자동으로 편입됩니다
* (동적 로딩 원칙).
*
* 문서 생성 대상은 `_bundled` 뿐입니다. 활성 디렉토리는 update 커맨드의 산출물이며
* 비번들 제3자 확장이 섞여 있어 집필 대상이 아닙니다.
*/
class ExtensionInventory
{
/**
* @var string 모듈 유형
*/
public const TYPE_MODULE = 'module';
/**
* @var string 플러그인 유형
*/
public const TYPE_PLUGIN = 'plugin';
/**
* @var string 템플릿 유형
*/
public const TYPE_TEMPLATE = 'template';
/**
* 유형 → 저장소 최상위 디렉토리 / manifest 파일명 / 진입 클래스 파일명 매핑
*
* @var array<string, array{dir: string, manifest: string, entryFile: string|null, entryClass: string|null, rootNamespace: string|null}>
*/
private const TYPE_MAP = [
self::TYPE_MODULE => [
'dir' => 'modules',
'manifest' => 'module.json',
'entryFile' => 'module.php',
'entryClass' => 'Module',
'rootNamespace' => 'Modules',
],
self::TYPE_PLUGIN => [
'dir' => 'plugins',
'manifest' => 'plugin.json',
'entryFile' => 'plugin.php',
'entryClass' => 'Plugin',
'rootNamespace' => 'Plugins',
],
self::TYPE_TEMPLATE => [
'dir' => 'templates',
'manifest' => 'template.json',
'entryFile' => null,
'entryClass' => null,
'rootNamespace' => null,
],
];
/**
* 지원 유형 목록을 반환합니다.
*
* @return array<int, string> 유형 목록
*/
public static function types(): array
{
return array_keys(self::TYPE_MAP);
}
/**
* 유형에 대응하는 저장소 디렉토리명을 반환합니다.
*
* @param string $type 확장 유형
* @return string|null 디렉토리명 (미지원 유형이면 null)
*/
public static function directoryFor(string $type): ?string
{
return self::TYPE_MAP[$type]['dir'] ?? null;
}
/**
* scope 문자열을 파싱합니다.
*
* @param string $scope `all` | `module:{id}` | `plugin:{id}` | `template:{id}`
* @return array{type: string|null, id: string|null} 파싱 결과 (all 이면 둘 다 null)
*/
public static function parseScope(string $scope): array
{
$scope = trim($scope);
if ($scope === '' || $scope === 'all') {
return ['type' => null, 'id' => null];
}
// 해석 실패를 "전체" 로 되돌리지 않는다. `--scope=modules:board`(복수형 오타)나
// `--scope=modul:board` 가 조용히 번들 20개 전 문서를 기록하게 되기 때문이다.
// 반면 `--scope=module:없는id` 는 이미 명확한 실패로 처리되므로, 두 오타가 정반대로
// 다뤄지는 비대칭을 없앤다.
if (! str_contains($scope, ':')) {
throw new InvalidArgumentException(
"scope 형식이 올바르지 않습니다: '{$scope}'. all | module:{id} | plugin:{id} | template:{id} 중 하나여야 합니다."
);
}
[$type, $id] = explode(':', $scope, 2);
$type = trim($type);
$id = trim($id);
if (! isset(self::TYPE_MAP[$type])) {
throw new InvalidArgumentException(
"알 수 없는 확장 유형입니다: '{$type}'. ".implode(' | ', array_keys(self::TYPE_MAP)).' 중 하나여야 합니다.'
);
}
if ($id === '') {
throw new InvalidArgumentException("scope 에 확장 식별자가 없습니다: '{$scope}'.");
}
return ['type' => $type, 'id' => $id];
}
/**
* scope 에 해당하는 번들 확장 목록을 수집합니다.
*
* @param string $scope 범위 (`all` | `{type}:{id}`)
* @return array<int, array<string, mixed>> 확장 레코드 목록 (유형 → 식별자 정렬)
*/
public function collect(string $scope = 'all'): array
{
$parsed = self::parseScope($scope);
$records = [];
$this->malformed = [];
foreach (self::TYPE_MAP as $type => $meta) {
if ($parsed['type'] !== null && $parsed['type'] !== $type) {
continue;
}
$bundledRoot = base_path($meta['dir'].'/_bundled');
if (! is_dir($bundledRoot)) {
continue;
}
foreach (File::directories($bundledRoot) as $dirPath) {
$id = basename($dirPath);
if ($parsed['id'] !== null && $parsed['id'] !== $id) {
continue;
}
$record = $this->buildRecord($type, $id, $dirPath);
if ($record !== null) {
$records[] = $record;
}
}
}
usort($records, function (array $a, array $b): int {
return [$a['type'], $a['id']] <=> [$b['type'], $b['id']];
});
return $records;
}
/**
* 단일 확장 레코드를 조회합니다.
*
* @param string $type 확장 유형
* @param string $id 확장 식별자
* @return array<string, mixed>|null 확장 레코드 (없으면 null)
*/
public function find(string $type, string $id): ?array
{
$records = $this->collect("{$type}:{$id}");
return $records[0] ?? null;
}
/**
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못한 디렉토리
*/
private array $malformed = [];
/**
* 직전 `collect()` 에서 manifest 파싱에 실패한 디렉토리 목록을 반환합니다.
*
* 호출자가 이 목록을 보고해야 "확장이 없다" 와 "읽지 못했다" 가 구분됩니다.
*
* @return array<int, array{type: string, id: string, manifest: string, reason: string}> 실패 목록
*/
public function malformed(): array
{
return $this->malformed;
}
/**
* 확장 레코드를 조립합니다.
*
* manifest 가 없거나 JSON 파싱에 실패한 디렉토리는 확장이 아니므로 제외합니다
* (`_backup_*` · 업데이트 중 임시 디렉토리 등).
*
* @param string $type 확장 유형
* @param string $id 확장 식별자
* @param string $dirPath 확장 절대 경로
* @return array<string, mixed>|null 확장 레코드 (manifest 부재 시 null)
*/
private function buildRecord(string $type, string $id, string $dirPath): ?array
{
$meta = self::TYPE_MAP[$type];
$manifestPath = $dirPath.DIRECTORY_SEPARATOR.$meta['manifest'];
if (! is_file($manifestPath)) {
return null;
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (! is_array($manifest)) {
// manifest 가 있는데 못 읽은 것은 "확장이 아님"(`_backup_*` 등) 과 다르다.
// 조용히 탈락시키면 그 확장이 문서 체계에서 통째로 사라지고, 검사 대상 수
// 자체가 줄어 "20개 중 5개 보유" 분모까지 함께 줄어 회귀로 보이지 않는다.
$this->malformed[] = [
'type' => $type,
'id' => $id,
'manifest' => $meta['manifest'],
'reason' => json_last_error_msg(),
];
return null;
}
$namespace = $meta['rootNamespace'] !== null
? $meta['rootNamespace'].'\\'.ExtensionManager::directoryToNamespace($id)
: null;
$entryFile = $meta['entryFile'] !== null
? $dirPath.DIRECTORY_SEPARATOR.$meta['entryFile']
: null;
return [
'type' => $type,
'id' => $id,
'label' => self::typeLabel($type),
'path' => $dirPath,
'relPath' => $meta['dir'].'/_bundled/'.$id,
'manifest' => $manifest,
'manifestFile' => $meta['manifest'],
'manifestPath' => $manifestPath,
'namespace' => $namespace,
'entryFile' => ($entryFile !== null && is_file($entryFile)) ? $entryFile : null,
'entryClass' => $namespace !== null ? $namespace.'\\'.$meta['entryClass'] : null,
'entryClassShort' => $meta['entryClass'],
'docsPath' => $dirPath.DIRECTORY_SEPARATOR.'docs',
'version' => (string) ($manifest['version'] ?? ''),
'name' => self::localizedName($manifest, $id),
'description' => self::localized($manifest['description'] ?? ''),
];
}
/**
* 유형의 한국어 라벨을 반환합니다.
*
* @param string $type 확장 유형
* @return string 한국어 라벨
*/
public static function typeLabel(string $type): string
{
return match ($type) {
self::TYPE_MODULE => '모듈',
self::TYPE_PLUGIN => '플러그인',
self::TYPE_TEMPLATE => '템플릿',
default => $type,
};
}
/**
* manifest 의 name 을 한국어 우선으로 해석합니다.
*
* @param array<string, mixed> $manifest manifest 배열
* @param string $fallback 이름이 없을 때 사용할 값
* @return string 확장명
*/
public static function localizedName(array $manifest, string $fallback): string
{
$name = self::localized($manifest['name'] ?? '');
return $name !== '' ? $name : $fallback;
}
/**
* 다국어 값(문자열 또는 로케일 배열)을 한국어 우선으로 해석합니다.
*
* @param mixed $value manifest 값
* @return string 해석된 문자열 (해석 불가 시 빈 문자열)
*/
public static function localized(mixed $value): string
{
if (is_string($value)) {
return $value;
}
if (is_array($value)) {
foreach (['ko', 'en'] as $locale) {
if (isset($value[$locale]) && is_string($value[$locale])) {
return $value[$locale];
}
}
foreach ($value as $item) {
if (is_string($item)) {
return $item;
}
}
}
return '';
}
}
@@ -0,0 +1,531 @@
<?php
namespace App\Support\ExtensionDoc;
use Illuminate\Support\Facades\File;
/**
* 확장 프론트엔드 진입점 수집기
*
* 레이아웃 JSON · 액션 핸들러 · 전역 재등록 진입점 · 컴포넌트 · 빌드 산출물을 수집합니다.
*
* 레이아웃 경로는 유형마다 다릅니다 — 모듈/플러그인은 `resources/layouts/`, 템플릿은
* `layouts/` 가 루트입니다. 유형별 분기를 이 수집기 한 곳에 두어, 소비자(스캐폴더·검사
* 스크립트)가 경로 규약을 각자 알 필요가 없게 합니다.
*/
class FrontendInventory
{
/**
* 확장의 프론트엔드 표면을 수집합니다.
*
* @param array<string, mixed> $record ExtensionInventory 레코드
* @return array<string, mixed> 프론트 인벤토리
*/
public function collect(array $record): array
{
$layouts = $this->collectLayouts($record);
return [
'layoutRoot' => $this->layoutRoot($record),
'layouts' => $layouts,
'layoutGroups' => $this->groupLayouts($layouts),
'layoutExtensions' => $this->collectJsonFiles($record, $this->layoutExtensionRoot($record)),
'handlers' => $this->collectHandlers($record),
'entryPoints' => $this->collectEntryPoints($record),
'components' => $this->collectComponents($record),
'builtAssets' => $this->collectBuiltAssets($record),
'vendoredAssets' => $this->collectVendoredAssets($record),
'customDir' => is_dir($record['path'].DIRECTORY_SEPARATOR.'custom'),
'editorSpec' => $this->collectEditorSpec($record),
'routesJson' => is_file($record['path'].DIRECTORY_SEPARATOR.'routes.json') ? 'routes.json' : null,
'routeCount' => $this->countDeclaredRoutes($record),
];
}
/**
* `routes.json` 에 선언된 주소 수를 셉니다 (셀 수 없으면 null).
*
* 템플릿은 선언형 표면(`getRoutes()`)을 갖지 않으므로 집계 배지의 "라우트 수" 가
* 구조적으로 항상 0 이 됩니다 — 주소를 `routes.json` 에 적기 때문입니다. 0 은 사실이
* 아닌데 템플릿에는 "확인하지 못함" 안내도 붙지 않아(선언형 표면 부재가 정상이라)
* 단서 없이 사실처럼 읽힙니다. 실측은 `sirsoft-basic` 40 · `sirsoft-admin_basic` 29.
*
* "라우트 수 = 주소 개수" 규율은 모듈·플러그인과 같습니다.
*
* 읽지 못한 것과 없는 것은 구분합니다 — 파일이 없으면 `null`, 파일이 깨졌어도 `null`
* 입니다. 0 을 돌려주면 "주소가 없다" 는 사실 주장이 됩니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return int|null 주소 수, 셀 수 없으면 null
*/
private function countDeclaredRoutes(array $record): ?int
{
$path = $record['path'].DIRECTORY_SEPARATOR.'routes.json';
if (! is_file($path)) {
return null;
}
$data = json_decode((string) @file_get_contents($path), true);
if (! is_array($data) || ! isset($data['routes']) || ! is_array($data['routes'])) {
return null;
}
return count(array_filter(
$data['routes'],
static fn (mixed $row): bool => is_array($row) && isset($row['path']),
));
}
/**
* 유형별 레이아웃 루트(확장 루트 기준 상대 경로)를 반환합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return string 레이아웃 루트
*/
private function layoutRoot(array $record): string
{
return $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'layouts' : 'resources/layouts';
}
/**
* 유형별 레이아웃 확장 조각 루트(확장 루트 기준 상대 경로)를 반환합니다.
*
* 레이아웃과 같은 규율이다 — 템플릿은 확장 루트 직속, 모듈·플러그인은 `resources/`
* 아래. 형제인 `layoutRoot()` 만 유형을 갈랐던 탓에 템플릿의 조각이 항상 빈 목록으로
* 수집돼(`sirsoft-basic` 실측 1건) 문서에 "없음" 으로 실렸다.
*
* @param array<string, mixed> $record 확장 레코드
* @return string 레이아웃 확장 루트
*/
private function layoutExtensionRoot(array $record): string
{
return $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'extensions' : 'resources/extensions';
}
/**
* 레이아웃 JSON 을 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array<string, mixed>> 레이아웃 목록
*/
private function collectLayouts(array $record): array
{
$layouts = [];
foreach ($this->collectJsonFiles($record, $this->layoutRoot($record)) as $rel) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
$root = $this->layoutRoot($record).'/';
$inner = str_starts_with($rel, $root) ? substr($rel, strlen($root)) : $rel;
$segments = explode('/', $inner);
$layouts[] = [
'relFile' => $rel,
'name' => basename($inner, '.json'),
'group' => count($segments) > 1 ? $segments[0] : '(root)',
'partial' => str_starts_with(basename($inner), '_'),
'extends' => $this->readJsonString($abs, 'extends'),
];
}
return $layouts;
}
/**
* 레이아웃을 그룹(admin/user 등)별로 집계합니다.
*
* @param array<int, array<string, mixed>> $layouts 레이아웃 목록
* @return array<string, int> 그룹 => 개수
*/
private function groupLayouts(array $layouts): array
{
$groups = [];
foreach ($layouts as $layout) {
$groups[$layout['group']] = ($groups[$layout['group']] ?? 0) + 1;
}
ksort($groups);
return $groups;
}
/**
* 액션 핸들러 이름을 수집합니다.
*
* `handlerMap` 객체의 최상위 키가 핸들러 이름이며, 엔트리포인트가
* `{identifier}.{name}` 으로 네임스페이스를 붙여 등록합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{namespace: string|null, names: array<int, string>, source: string|null}
*/
private function collectHandlers(array $record): array
{
$candidates = [
'resources/js/handlers/index.ts',
'src/handlers/index.ts',
'resources/js/index.ts',
'src/index.ts',
];
foreach ($candidates as $rel) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (! is_file($abs)) {
continue;
}
$content = (string) file_get_contents($abs);
$names = [];
// 핸들러 맵 이름은 확장마다 다르다 (`handlerMap` / `handlers` + alias 재수출).
// 앞선 후보가 alias 대입(`handlerMap = handlers;`)이면 객체 리터럴이 아니므로 비고,
// 다음 후보에서 실제 리터럴을 찾는다.
foreach (['handlerMap', 'handlers'] as $objectName) {
$names = $this->objectKeys($content, $objectName);
if ($names !== []) {
break;
}
}
if ($names === []) {
$names = $this->literalHandlerNames($content);
}
if ($names !== []) {
return [
'namespace' => $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? null : $record['id'],
'names' => $names,
'source' => $rel,
];
}
}
return ['namespace' => null, 'names' => [], 'source' => null];
}
/**
* 전역 재등록 진입점(`window.__[Name]`)과 초기화 함수를 수집합니다.
*
* 로케일 전환 후 액션이 무반응이 되는 결함을 막는 계약이므로, 노출 여부 자체가
* 문서에 드러나야 합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{global: string|null, initFunction: string|null, source: string|null}
*/
private function collectEntryPoints(array $record): array
{
$candidates = ['resources/js/index.ts', 'src/index.ts', 'src/index.tsx'];
foreach ($candidates as $rel) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (! is_file($abs)) {
continue;
}
$content = (string) file_get_contents($abs);
$global = null;
if (preg_match('/window[^;\n]*\)\.\s*(__\w+)\s*=/', $content, $m)) {
$global = $m[1];
}
$init = null;
foreach (['initModule', 'initPlugin', 'initTemplate'] as $fn) {
if (preg_match('/function\s+'.$fn.'\s*\(/', $content)) {
$init = $fn;
break;
}
}
if ($global !== null || $init !== null) {
return ['global' => $global, 'initFunction' => $init, 'source' => $rel];
}
}
return ['global' => null, 'initFunction' => null, 'source' => null];
}
/**
* 템플릿이 제공하는 컴포넌트를 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{total: int, byCategory: array<string, int>, root: string|null}
*/
private function collectComponents(array $record): array
{
$root = 'src/components';
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $root);
if (! is_dir($dir)) {
return ['total' => 0, 'byCategory' => [], 'root' => null];
}
$byCategory = [];
$total = 0;
foreach (File::allFiles($dir) as $file) {
if ($file->getExtension() !== 'tsx') {
continue;
}
if (str_contains(str_replace('\\', '/', $file->getPathname()), '/__tests__/')) {
continue;
}
$relative = str_replace('\\', '/', $file->getRelativePath());
$category = $relative === '' ? '(root)' : explode('/', $relative)[0];
$byCategory[$category] = ($byCategory[$category] ?? 0) + 1;
$total++;
}
ksort($byCategory);
return ['total' => $total, 'byCategory' => $byCategory, 'root' => $root];
}
/**
* 커밋된 빌드 산출물을 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, string> 산출물 상대 경로
*/
private function collectBuiltAssets(array $record): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.'dist';
if (! is_dir($dir)) {
return [];
}
$files = [];
foreach (File::allFiles($dir) as $file) {
if (! in_array($file->getExtension(), ['js', 'css'], true)) {
continue;
}
$rel = $this->relative($record, $file->getPathname());
if (str_starts_with($rel, 'dist/vendor/')) {
continue;
}
$files[] = $rel;
}
sort($files);
return $files;
}
/**
* 동봉(self-hosted) 제3자 자산을 수집합니다.
*
* 외부 CDN 대신 확장이 자기 서버에서 제공하는 구동 자산이며, 버전이 디렉토리명에
* 드러나므로 문서에 그대로 노출합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, string> `{라이브러리}/{버전}` 목록
*/
private function collectVendoredAssets(array $record): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.'dist'.DIRECTORY_SEPARATOR.'vendor';
if (! is_dir($dir)) {
return [];
}
$entries = [];
foreach (File::directories($dir) as $libPath) {
$lib = basename($libPath);
$versions = array_map('basename', File::directories($libPath));
if ($versions === []) {
$entries[] = $lib;
continue;
}
foreach ($versions as $version) {
$entries[] = $lib.'/'.$version;
}
}
sort($entries);
return $entries;
}
/**
* 편집기 스펙(단일 파일 / 분할) 보유 형태를 판정합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{manifest: bool, split: int}
*/
private function collectEditorSpec(array $record): array
{
$manifest = is_file($record['path'].DIRECTORY_SEPARATOR.'editor-spec.json');
$splitDir = $record['path'].DIRECTORY_SEPARATOR.'editor-spec';
$split = 0;
if (is_dir($splitDir)) {
foreach (File::allFiles($splitDir) as $file) {
if ($file->getExtension() === 'json') {
$split++;
}
}
}
return ['manifest' => $manifest, 'split' => $split];
}
/**
* 하위 디렉토리의 JSON 파일 상대 경로를 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param string $sub 확장 루트 기준 하위 경로
* @return array<int, string> 상대 경로 목록 (정렬)
*/
private function collectJsonFiles(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() === 'json') {
$files[] = $this->relative($record, $file->getPathname());
}
}
sort($files);
return $files;
}
/**
* JSON 파일에서 최상위 문자열 키 값을 읽습니다.
*
* @param string $absolute 파일 절대 경로
* @param string $key 키
* @return string|null 값 (없거나 문자열이 아니면 null)
*/
private function readJsonString(string $absolute, string $key): ?string
{
$data = json_decode((string) @file_get_contents($absolute), true);
return is_array($data) && isset($data[$key]) && is_string($data[$key]) ? $data[$key] : null;
}
/**
* `export const {name} = { ... }` 객체의 최상위 키를 뽑습니다.
*
* @param string $content TS 소스
* @param string $name 객체 변수명
* @return array<int, string> 키 목록
*/
private function objectKeys(string $content, string $name): array
{
// 타입 주석이 붙은 선언(`handlerMap: Record<string, (...a) => unknown> = {`)까지 잡되,
// alias 대입(`handlerMap = handlers;`)은 잡지 않는다. `[^;{]*` 가 문(statement) 경계를
// 넘지 못하게 하고, `=\s*\{` 로 객체 리터럴 대입만 받는다.
if (! preg_match('/\b'.preg_quote($name, '/').'\b[^;{]*=\s*\{/', $content, $m, PREG_OFFSET_CAPTURE)) {
return [];
}
$open = strpos($content, '{', (int) $m[0][1]);
if ($open === false) {
return [];
}
$depth = 0;
$len = strlen($content);
$close = null;
for ($i = $open; $i < $len; $i++) {
$ch = $content[$i];
if ($ch === '{') {
$depth++;
} elseif ($ch === '}') {
$depth--;
if ($depth === 0) {
$close = $i;
break;
}
}
}
if ($close === null) {
return [];
}
$body = substr($content, $open + 1, $close - $open - 1);
$keys = [];
foreach (explode("\n", $body) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '//') || str_starts_with($line, '*') || str_starts_with($line, '/*')) {
continue;
}
if (preg_match('/^([A-Za-z_$][\w$]*)\s*[,:]/', $line, $km)) {
$keys[] = $km[1];
}
}
return array_values(array_unique($keys));
}
/**
* `registerHandler(...)` 인자에서 핸들러 이름을 뽑습니다.
*
* 두 형태를 모두 봅니다 — 리터럴(`'name'`)과 식별자 보간 템플릿 리터럴
* (`` `${PLUGIN_IDENTIFIER}.name` ``). 후자를 놓치면 핸들러 맵을 쓰지 않고 개별
* 등록하는 확장(gdpr 등)이 "핸들러 0개" 로 잘못 집계됩니다.
*
* @param string $content TS 소스
* @return array<int, string> 핸들러 이름 목록
*/
private function literalHandlerNames(string $content): array
{
$names = [];
if (preg_match_all("/registerHandler\s*\(\s*'([^']+)'/", $content, $m)) {
foreach ($m[1] as $name) {
$names[] = $this->stripIdentifierPrefix($name);
}
}
if (preg_match_all('/registerHandler\s*\(\s*`\$\{[^}]+\}\.([A-Za-z_$][\w$]*)`/', $content, $m)) {
$names = array_merge($names, $m[1]);
}
return array_values(array_unique($names));
}
/**
* `{identifier}.{handler}` 형태의 이름에서 확장 식별자 접두를 떼어냅니다.
*
* 등록 코드는 네임스페이스를 붙인 전체 이름을 쓰지만, 표는 핸들러 이름과 호출 이름을
* 각각 보여주므로 접두를 벗긴 이름이 필요합니다.
*
* @param string $name 등록된 이름
* @return string 접두를 뗀 핸들러 이름
*/
private function stripIdentifierPrefix(string $name): string
{
$pos = strrpos($name, '.');
return $pos === false ? $name : substr($name, $pos + 1);
}
/**
* 확장 루트 기준 상대 경로로 변환합니다.
*
* @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);
}
}
+527
View File
@@ -0,0 +1,527 @@
<?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);
}
}
@@ -0,0 +1,241 @@
<?php
namespace App\Support\ExtensionDoc;
use Illuminate\Support\Facades\File;
/**
* 확장 테스트 경로 수집기
*
* 확장이 보유한 PHPUnit · Vitest · Playwright 테스트와 시나리오 매니페스트를 수집하고,
* 실행 명령을 규정에 맞는 형태로 조립합니다.
*
* 실행 명령은 문서의 핵심 산출물입니다 — 확장 테스트는 무필터 전체 실행이 금지되어 있고
* 프론트/백엔드가 서로 다른 셸 규약을 요구하므로, 그 형태를 문서가 직접 제시하지 않으면
* 읽는 쪽이 매번 규정을 되짚어야 합니다.
*/
class TestPathCollector
{
/**
* 확장의 테스트 표면을 수집합니다.
*
* @param array<string, mixed> $record ExtensionInventory 레코드
* @return array<string, mixed> 테스트 인벤토리
*/
public function collect(array $record): array
{
$phpunit = $this->countFiles($record, 'tests', 'php', ['Playwright']);
$vitestRoots = $this->vitestRoots($record);
$playwright = $this->countFiles($record, 'tests/Playwright', 'ts');
$scenarios = $this->scenarioManifests($record);
return [
'phpunit' => $phpunit,
'vitest' => $vitestRoots,
'playwright' => $playwright,
'scenarios' => $scenarios,
'commands' => $this->buildCommands($record, $phpunit, $vitestRoots, $playwright),
'testCaseBase' => $this->testCaseBase($record),
];
}
/**
* 확장 테스트의 기저 TestCase 클래스를 찾습니다.
*
* 모듈/플러그인 테스트는 `Tests\TestCase` 직접 상속이 금지되고 확장 전용 기저 클래스를
* 상속해야 하므로, 그 클래스명이 문서에 드러나야 합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return string|null 기저 클래스 파일명 (없으면 null)
*/
private function testCaseBase(array $record): ?string
{
foreach (['ModuleTestCase.php', 'PluginTestCase.php', 'TemplateTestCase.php'] as $name) {
if (is_file($record['path'].DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.$name)) {
return 'tests/'.$name;
}
}
return null;
}
/**
* Vitest 대상 디렉토리를 찾습니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{config: string|null, dirs: array<int, string>, files: int}
*/
private function vitestRoots(array $record): array
{
$config = null;
foreach (['vitest.config.ts', 'vitest.config.js'] as $name) {
if (is_file($record['path'].DIRECTORY_SEPARATOR.$name)) {
$config = $name;
break;
}
}
$dirs = [];
$files = 0;
foreach (['resources/js', 'src', '__tests__'] as $sub) {
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
if (! is_dir($dir)) {
continue;
}
$found = 0;
foreach (File::allFiles($dir) as $file) {
$rel = str_replace('\\', '/', $file->getPathname());
if (! preg_match('/\.(test|spec)\.(ts|tsx)$/', $rel)) {
continue;
}
$found++;
}
if ($found > 0) {
$dirs[] = $sub;
$files += $found;
}
}
return ['config' => $config, 'dirs' => $dirs, 'files' => $files];
}
/**
* 시나리오 매니페스트를 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, string> 매니페스트 상대 경로
*/
private function scenarioManifests(array $record): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'scenarios';
if (! is_dir($dir)) {
return [];
}
$files = [];
foreach (File::allFiles($dir) as $file) {
if (in_array($file->getExtension(), ['yaml', 'yml'], true)) {
$files[] = 'tests/scenarios/'.$file->getFilename();
}
}
sort($files);
return $files;
}
/**
* 테스트 실행 명령을 조립합니다.
*
* 무필터 전체 실행은 규정상 차단되므로 PHPUnit 명령에는 `--filter` 자리를 남깁니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param array{count: int, dirs: array<int, string>} $phpunit PHPUnit 집계
* @param array{config: string|null, dirs: array<int, string>, files: int} $vitest Vitest 집계
* @param array{count: int, dirs: array<int, string>} $playwright Playwright 집계
* @return array<int, array{label: string, command: string, shell: string}> 실행 명령 목록
*/
private function buildCommands(array $record, array $phpunit, array $vitest, array $playwright): array
{
$commands = [];
$rel = $record['relPath'];
if ($phpunit['count'] > 0) {
$commands[] = [
'label' => 'PHPUnit (변경 범위만)',
'command' => "php vendor/bin/phpunit {$rel}/tests --filter='<대상클래스>'",
'shell' => 'Bash',
];
}
if ($vitest['files'] > 0) {
$commands[] = [
'label' => 'Vitest (확장 디렉토리에서)',
'command' => "cd {$rel} && powershell -Command \"npm run test:run -- <대상>\"",
'shell' => 'PowerShell',
];
}
if ($playwright['count'] > 0) {
$commands[] = [
'label' => 'Playwright E2E',
'command' => "npx playwright test {$rel}/tests/Playwright/specs/<대상>.spec.ts",
'shell' => 'Bash',
];
}
return $commands;
}
/**
* 그 확장자에서 "테스트 파일" 로 셀 파일명인지 판정합니다.
*
* 계수 모집단은 문서의 "테스트 N건" 과 AGENTS `## 7. 테스트 실행` 표에 그대로 실리므로,
* 설정 파일·픽스처를 함께 세면 공개 문서가 틀린 수치를 싣는다.
*
* @param string $extension 파일 확장자
* @param string $filename 파일명
* @return bool 테스트 파일이면 true
*/
private static function isTestFilename(string $extension, string $filename): bool
{
return match ($extension) {
'php' => (bool) preg_match('/Test\.php$/', $filename),
'ts', 'tsx' => (bool) preg_match('/\.(test|spec)\.tsx?$/', $filename),
default => true,
};
}
/**
* 하위 디렉토리의 파일 수와 1단계 하위 디렉토리를 집계합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param string $sub 확장 루트 기준 하위 경로
* @param string $extension 대상 확장자
* @param array<int, string> $excludeDirs 제외할 1단계 하위 디렉토리명
* @return array{count: int, dirs: array<int, string>, root: string|null}
*/
private function countFiles(array $record, string $sub, string $extension, array $excludeDirs = []): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
if (! is_dir($dir)) {
return ['count' => 0, 'dirs' => [], 'root' => null];
}
$count = 0;
$dirs = [];
foreach (File::allFiles($dir) as $file) {
if ($file->getExtension() !== $extension) {
continue;
}
// 테스트 파일이 아닌 것이 같은 디렉토리에 산다 — 세면 "테스트 N건" 이 실제
// 테스트 수보다 커진다. PHP 는 기저 TestCase·헬퍼(board 실측: 157 중 2건),
// Playwright 는 `playwright.config.ts` 와 픽스처(gdpr 실측: 5 중 3건)다.
// 두 축이 같은 규율을 갖도록 확장자별 파일명 규칙을 한자리에서 적용한다.
if (! self::isTestFilename($extension, $file->getFilename())) {
continue;
}
$relative = str_replace('\\', '/', $file->getRelativePath());
$first = $relative === '' ? '(root)' : explode('/', $relative)[0];
if (in_array($first, $excludeDirs, true)) {
continue;
}
$count++;
if (! in_array($first, $dirs, true)) {
$dirs[] = $first;
}
}
sort($dirs);
return ['count' => $count, 'dirs' => $dirs, 'root' => $sub];
}
}
+3 -2
View File
@@ -11,7 +11,7 @@
|----------|---------|----------|
| [백엔드](backend/) | 37개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 |
| [확장 시스템](extension/) | 31개 | 정상 |
| [확장 시스템](extension/) | 32개 | 정상 |
| 공통 | 20개 | 정상 |
| [AI 도구](ai-tools/) | - | 정상 |
@@ -223,13 +223,14 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 |
| [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 |
### 확장 시스템 (31개)
### 확장 시스템 (32개)
| 문서 | 제목 |
|------|------|
| [cache-driver.md](extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) |
| [changelog-rules.md](extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) |
| [editor-spec.md](extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) |
| [extension-documentation.md](extension/extension-documentation.md) | 확장 개발자 문서 (Extension Documentation) |
| [extension-manager.md](extension/extension-manager.md) | ExtensionManager (확장 관리자) |
| [extension-update-system.md](extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) |
| [hooks.md](extension/hooks.md) | 훅 시스템 (Hook System) |
+7
View File
@@ -84,6 +84,13 @@ G7은 **동적 로딩** 기반의 확장 시스템을 제공합니다:
| [permissions.md](permissions.md) | Role, Permission, 자동 관리 |
| [menus.md](menus.md) | 메뉴 권한, 시더 |
### 확장 문서화
| 문서 | 설명 |
|------|------|
| [extension-documentation.md](extension-documentation.md) | AGENTS.md/README.md/docs 역할 경계, 자동 생성 블록 규약, `ext:docgen` |
| [changelog-rules.md](changelog-rules.md) | 버전 상향 시 CHANGELOG 기재 의무 |
---
## 확장 타입별 네이밍 규칙
+260
View File
@@ -0,0 +1,260 @@
# 확장 개발자 문서 (Extension Documentation)
> 번들 확장이 갖추는 `AGENTS.md` · `README.md` · `docs/**` 의 역할 경계, 골격, 자동 생성 규약, 갱신 의무.
## TL;DR (5초 요약)
```text
1. 확장마다 AGENTS.md(개발자·에이전트용) + README.md(사람용) + docs/(상세) 를 갖는다
2. 같은 사실은 한쪽만 SSoT — 소개·기능·요구사항은 README, 설계 의도·확장점·동반 의무는 AGENTS
3. 코드에서 실측되는 표는 `php artisan ext:docgen` 이 @generated 블록 안쪽만 교체한다
4. 블록 밖 전부가 사람 영역 — 생성기는 그 자리를 절대 건드리지 않으며 파괴적 재생성 플래그가 없다
5. 사람만 쓸 수 있는 다섯 자리는 TODO 마커로 남는다 (의도 · 흐름 · 금지패턴 · 사용방법 · 트러블슈팅)
```
## 목차
- [1. 왜 두 문서인가](#1-왜-두-문서인가)
- [2. 파일 배치](#2-파일-배치)
- [3. 역할 경계와 SSoT](#3-역할-경계와-ssot)
- [4. 자동 생성 블록 규약](#4-자동-생성-블록-규약)
- [5. 미채움 마커](#5-미채움-마커)
- [6. `ext:docgen` 사용법](#6-extdocgen-사용법)
- [7. 갱신 의무](#7-갱신-의무)
- [8. 체크리스트](#8-체크리스트)
---
## 1. 왜 두 문서인가
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답한다. 확장을 수정하려는 사람이 실제로 필요로 하는 것은 그 앞의 질문이다 — **왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안 되는가.**
그 답이 코드 안에만 있으면 세 가지가 생긴다.
- 확장을 수정할 때마다 `src/` 전체를 훑어 구조를 재발견한다.
- 확장이 발행하는 훅이 코드 안에만 있어 확장점이 사실상 비공개가 된다.
- 확장 저장소만 받은 제3자에게는 참고할 문서가 `CHANGELOG.md` 뿐이다.
독자가 둘이므로 문서도 둘이다. 도입을 검토하고 운영하는 사람은 "무엇을 해결해 주는가" 를 묻고, 확장을 고치는 사람은 "어디를 어떻게 건드리는가" 를 묻는다. 한 문서에 섞으면 양쪽 모두에게 길어진다.
## 2. 파일 배치
```text
{ext}/
├─ AGENTS.md 에이전트·확장개발자 진입점
├─ README.md 사람(도입검토자·운영자) 진입점 — 한국어
├─ CHANGELOG.md 변경 이력
└─ docs/
├─ README.md 문서 통합 목차 + 실측 집계
├─ architecture.md 설계 의도 · 계층 지도 · 디렉토리 맵
├─ extension-points.md 발행/구독 훅 · 미들웨어 · 채널 · 스케줄 [모듈·플러그인]
├─ data-model.md 모델 · 소유 테이블 · 마이그레이션 · Enum [모듈·플러그인]
├─ settings.md 설정 스키마 · 권한 · 메뉴 · 라우트 · 의존 [모듈·플러그인]
├─ frontend.md 레이아웃 · 핸들러 · 전역 진입점 · 에셋 [모듈·플러그인]
├─ components.md 제공 컴포넌트 [템플릿]
├─ layouts.md 레이아웃 목록 · 라우트 매핑 [템플릿]
├─ handlers.md 템플릿 전용 핸들러 · 부트스트랩 [템플릿]
└─ api/ API 레퍼런스 (별도 체계)
```
템플릿은 API·모델·훅 축이 사실상 비므로 골격이 다르다. `ext:docgen` 이 manifest 유형을 보고 골격을 고르므로 유형을 직접 지정할 필요가 없다.
이 문서들은 `{ext}/` 안에 있으므로 **릴리즈 페이로드에 실리는 공개 배포물**이다. 확장 저장소를 받은 사람이 그대로 읽는다.
## 3. 역할 경계와 SSoT
같은 사실은 한쪽만 SSoT 로 두고 반대쪽은 링크한다. 다만 두 문서 모두 **자기 독자에게는 자족적**이어야 한다 — "저쪽을 보라" 만 남기면 어느 쪽도 읽히지 않는다.
| README.md (사람) | AGENTS.md (에이전트·확장개발자) |
|---|---|
| 확장 소개 · 해결하는 문제 **(SSoT)** | 개발 의도 · 설계 원칙 **(SSoT)** |
| 핵심 기능 목록 **(SSoT)** | 아키텍처 · 계층 지도 · 디렉토리 맵 |
| 동작 방식 다이어그램 (운영자 눈높이) | 핵심 흐름 (코드 경로 · 계층 통과 순서) |
| 요구사항 · 의존성 · 연동 확장 **(SSoT)** | 도메인 모델 · 소유 테이블 요약 |
| 설치 · 활성화 | 확장점: 발행 훅 · 구독 훅 · 필터 **(SSoT)** |
| 관리자 설정 화면 사용법 (템플릿은 이 자리에 **제공 컴포넌트** 요약) | 라우트 · 권한 · 설정 스키마 요약 |
| 운영 트러블슈팅 | 프론트 진입점 · 레이아웃 · 핸들러 |
| 라이선스 | **수정 시 동반 의무 체크리스트** |
### 필수 섹션 (유형별)
`ext:docgen --check` 가 요구하는 헤딩이다. 낱말이 본문 어딘가에 있는 것으로는 충족되지 않고 **헤딩**이어야 한다.
| 문서 | 필수 섹션 |
|---|---|
| `AGENTS.md` | `TL;DR (5초 요약)` · `1. 이 확장은 무엇인가` · `2. 디렉토리 지도` · `3. 핵심 흐름` · `4. 확장점` · `5. 수정 시 동반 의무` · `6. 금지 패턴` · `7. 테스트 실행` · `8. 문서 목차` |
| `README.md` | `소개` · `주요 기능` · `동작 방식` · `요구 사항` · `설치` · `관리자 설정`(템플릿은 `제공 컴포넌트`) · `사용 방법` · `다른 확장과의 연동` · `문서` · `트러블슈팅` · `변경 이력` · `라이선스` |
| `docs/README.md` | `문서 목차` |
| `docs/architecture.md` | `설계 의도` · `계층 지도` · `디렉토리` |
| `docs/extension-points.md` (모듈·플러그인) | `발행 훅` · `구독 훅` · `훅 리스너` · `레이아웃 확장` · `미들웨어` · `브로드캐스트 채널` · `스케줄` · `알림 정의` |
| `docs/data-model.md` (모듈·플러그인) | `모델` · `소유 테이블` · `마이그레이션` · `Enum` · `Repository` |
| `docs/settings.md` (모듈·플러그인) | `설정 스키마` · `권한` · `메뉴` · `라우트` · `의존 관계` |
| `docs/frontend.md` (모듈·플러그인) | `레이아웃` · `액션 핸들러` · `전역 진입점` · `에셋` |
| `docs/components.md` (템플릿) | `제공 컴포넌트` |
| `docs/layouts.md` (템플릿) | `레이아웃 목록` · `라우트 매핑` |
| `docs/handlers.md` (템플릿) | `템플릿 전용 핸들러` · `부트스트랩` |
목록의 SSoT 는 생성기이므로, 직접 옮겨 적기보다 `ext:docgen --init` 이 만든 골격에서 시작하는 편이 어긋나지 않는다.
### AGENTS.md 의 `5. 수정 시 동반 의무`
이 절이 문서의 실효성 핵심이다. 코어 횡단 규정 중 **그 확장에 실제로 걸리는 것만** 추린다. 전부 나열하면 체크리스트 전체가 형식적으로 읽히고, 정작 걸리는 항목이 묻힌다.
예를 들어 이커머스는 통화 스냅샷과 주문 통화 전 사슬이, 게시판은 비밀글 게이트의 하위 리소스 재적용이, 결제 플러그인은 청구 금액 계약과 샌드박스 실호출이 그 자리에 온다.
`ext:docgen --init` 이 확장이 실제로 보유한 표면(마이그레이션 · 발행 훅 · 라우트 · 레이아웃 · 빌드 산출물 · 다국어)만 골라 초안을 만든다. 사람은 거기에 그 확장 고유의 항목을 보탠다.
### 시각 자료
스크린샷을 두지 않고 mermaid 다이어그램과 표로 대체한다. 이미지 파일이 없으므로 릴리즈 용량이 늘지 않고, UI 가 바뀔 때마다 다시 촬영할 의무도 없다. GitHub 이 mermaid 를 네이티브로 렌더한다.
mermaid 문법 오류는 렌더 시점에만 드러나므로, 새 형식을 도입할 때는 실제 렌더를 눈으로 확인한다.
## 4. 자동 생성 블록 규약
생성기는 **마커 안쪽만** 쓴다.
```markdown
<!-- @generated:hooks-published START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
| 훅 이름 | 유형 | 발행 위치 |
| ... |
<!-- @generated:hooks-published END -->
<!-- @intent START -->
이 훅은 ... (사람이 쓰는 영역 — 생성기가 건드리지 않는다)
<!-- @intent END -->
```
블록 밖 전부가 사람 영역이다. 계약은 세 가지다.
1. **블록 안쪽 교체가 유일한 쓰기 동작이다.** 그래서 `--force` 같은 파괴적 플래그가 없다. 신규 파일 생성만 `--init` 으로 분리되어 있고, 그 경로도 기존 파일을 덮어쓰지 않는다.
2. **문서에 없는 블록 키는 주입하지 않는다.** 생성기가 임의 위치에 표를 끼워 넣으면 사람이 잡아 둔 문서 구조가 흔들린다. 대신 누락으로 보고하여 사람이 마커 놓을 자리를 정한다.
3. **재실행은 멱등이다.** 같은 코드 상태에서 두 번 돌리면 한 글자도 바뀌지 않는다.
`@intent` 블록은 사람 영역임을 눈에 띄게 표시하는 장치일 뿐이며, 생성기는 `@generated` 마커만 찾는다.
### 블록 목록
| 블록 키 | 내용 | 출처 |
|---|---|---|
| `badges` | 버전 · 유형 · 코어 제약 · 라이선스 · 의존 배지 | manifest |
| `requirements` | 코어 버전 · PHP · 의존 확장 · 외부 호스트 | manifest · composer.json |
| `install` | 설치 · 활성화 · 업데이트 커맨드 | manifest |
| `integrations` · `dependencies` | 정방향/역방향 의존 확장 | 번들 전수 교차 스캔 |
| `docs-index` · `doc-toc` | 문서 목차와 작성 상태 | 파일 존재 여부 |
| `stats` | 훅 · 구독 훅 · 라우트 · 모델 · 테이블 · 마이그레이션 · 레이아웃 · 핸들러 8지표 실측 집계 | 전 수집기 |
| `directory-map` | 경로별 역할과 수정 시 절차 | 디렉토리 구조 |
| `extension-points-summary` | 확장점 종류별 개수와 상세 링크 | 훅·선언형 표면 |
| `hooks-published` · `hooks-subscribed` · `listeners` | 발행/구독 훅과 리스너 | 소스 스캔 |
| `layout-extensions` · `middleware` · `channels` · `schedules` · `notifications` | 선언형 확장점 | 진입 클래스 getter |
| `models` · `tables` · `migrations` · `enums` · `repositories` | 데이터 모델 | 소스 파싱 |
| `settings-schema` · `settings-summary` · `permissions` · `menus` · `routes` | 설정 스키마 · README 용 설정 요약(키·의미·기본값) · 권한 · 메뉴 · 라우트 | 진입 클래스 getter |
| `layouts` · `handlers` · `frontend-entry` · `assets` | 프론트 표면 | 레이아웃/TS 스캔 |
| `components` · `layout-map` | 템플릿 컴포넌트·라우트 매핑 | 컴포넌트 스캔 · routes.json |
| `test-commands` | 테스트 종류별 개수와 실행 명령 | 테스트 경로 스캔 |
## 5. 미채움 마커
생성기가 채울 수 없는 자리 — 코드에서 실측되지 않는 **왜** — 에는 다섯 종류의 마커가 남는다.
| 마커 | 자리 | 주 위치 |
|---|---|---|
| `TODO: 의도` | 설계 의도 · 소개 · 주요 기능 | AGENTS `1`, README `소개`·`주요 기능` |
| `TODO: 흐름` | 핵심 흐름 · 동작 방식 다이어그램 | AGENTS `3`, README `동작 방식` |
| `TODO: 금지패턴` | 금지 패턴 표 | AGENTS `6` |
| `TODO: 사용방법` | 운영자 사용 시나리오 | README `사용 방법` |
| `TODO: 트러블슈팅` | 증상 → 원인 → 조치 | README `트러블슈팅` |
마커 종류를 다섯으로 고정하는 이유는 잔량을 집계할 수 있게 하기 위해서다. 자유 문구로 남기면 어느 자리가 비었는지 셀 수 없다.
`의도` 와 `흐름` 은 두 문서 모두에 나타난다 — 같은 축을 다른 독자에게 서술하는 자리이기 때문이다. 나머지 셋은 한쪽에만 있다.
골격을 만든 직후에는 마커가 반드시 존재하며, 그 상태로 커밋하는 것이 정상 흐름이다. 결함이 아니라 집필 진행 상태다. 다만 잔량이 **늘어나는 것**은 회귀다.
## 6. `ext:docgen` 사용법
```bash
php artisan ext:docgen
{--scope=all : all | module:{id} | plugin:{id} | template:{id}}
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
{--check : 생성하지 않고 누락·드리프트만 리포트}
{--json : 기계 판독 출력}
{--dry-run : 대상과 실측 집계만 출력}
```
전형적인 흐름:
```bash
# 1. 대상과 실측 규모 확인
php artisan ext:docgen --scope=module:sirsoft-board --dry-run
# 2. 골격 생성 (없는 문서만)
php artisan ext:docgen --scope=module:sirsoft-board --init
# 3. TODO 마커 자리를 코드 근거로 채운다 (사람)
# 4. 표면을 고친 뒤 자동 생성 블록 갱신
php artisan ext:docgen --scope=module:sirsoft-board
# 5. 문서와 코드가 어긋나지 않는지 확인
php artisan ext:docgen --check
```
작업 위치는 언제나 `_bundled` 다. 활성 디렉토리 반영은 update 커맨드로만 한다 (문서만 바뀌었다면 빌드는 불필요하다).
```bash
php artisan {module|plugin|template}:update {id} --force
```
### 수집 대상은 `_bundled` 소스다
선언형 표면(라우트 · 권한 · 메뉴 · 훅 리스너 · 설정 스키마 등)은 진입 클래스의 getter 를 실제로 호출해 읽는다. 정규식으로 소스를 긁는 방식과 달리 상속 기본값까지 정확히 반영된다.
같은 확장이 활성 디렉토리에서 이미 부팅되어 있으면 PHP 는 같은 클래스를 다시 정의할 수 없으므로, 진입 클래스명만 바꿔 `_bundled` 파일을 메모리에 다시 읽는다. 그래서 활성 디렉토리에 반영하기 전이라도 `_bundled` 의 변경이 문서에 그대로 나타난다.
getter 하나가 실패해도 나머지 수집은 계속되며, 실패 사유가 리포트에 드러난다 — 조용한 누락이 없다.
## 7. 갱신 의무
확장 표면을 바꾸면 같은 작업 단위에서 문서를 함께 갱신한다.
| 바꾼 것 | 해야 할 것 |
|---|---|
| 발행 훅 추가 · 이름 변경 | `ext:docgen` 재실행 — **다른 확장이 잡는 계약이 바뀐다** |
| 라우트 · 권한 · 메뉴 · 설정 스키마 | `ext:docgen` 재실행 |
| 모델 · 마이그레이션 | `ext:docgen` 재실행 + 업그레이드 스텝 |
| 레이아웃 · 핸들러 · 에셋 | `ext:docgen` 재실행 |
| 설계 방침 · 계층 구조 | 블록 **밖** 서술을 직접 갱신 (생성기가 채우지 못한다) |
| 새로 발견한 오용 | `6. 금지 패턴` 에 행 추가 |
자동 생성 블록이 아무리 정확해도 블록 밖 서술이 낡으면 문서 전체가 의심스러워진다. 생성기 재실행은 갱신의 절반이다.
### 신규 확장
새 확장을 만들면 `php artisan ext:docgen --scope={type}:{id} --init` 로 문서 골격을 먼저 만든다. 확장이 문서를 갖고 태어나야 하며, 만들자마자 `TODO` 마커 자리를 채우는 것이 첫 작업이다.
## 8. 체크리스트
```text
□ AGENTS.md · README.md · docs/ 필수 문서가 유형에 맞게 있는가?
□ 필수 섹션 헤딩이 모두 있는가?
□ 자동 생성 블록 마커가 모두 있는가?
□ `ext:docgen --check` 가 드리프트 0 인가?
□ TODO 마커 다섯 자리를 코드 근거로 채웠는가? (추측 서술 금지)
□ mermaid 다이어그램이 실제로 렌더되는가? (눈으로 확인)
□ 배지 값이 manifest 와 일치하는가? (생성기가 채우므로 직접 쓰지 않는다)
□ README 와 AGENTS 가 같은 사실을 각자 서술하고 있지는 않은가? (SSoT 한쪽 + 링크)
□ `5. 수정 시 동반 의무` 가 이 확장에 실제로 걸리는 것만 담고 있는가?
□ 버전 상향 시 CHANGELOG 에 기재했는가?
```
---
## 관련 문서
- [module-basics.md](module-basics.md) — 모듈 개발 기초
- [plugin-development.md](plugin-development.md) — 플러그인 개발 가이드
- [template-basics.md](template-basics.md) — 템플릿 시스템 기초
- [hooks.md](hooks.md) — 훅 시스템 (발행/구독 규약)
- [changelog-rules.md](changelog-rules.md) — CHANGELOG 규칙
- [../backend/api-documentation.md](../backend/api-documentation.md) — API 레퍼런스 문서 규정
+26
View File
@@ -1052,6 +1052,28 @@ if (isset($_GET['ajax_action'])) {
</div>
</div>
<!-- 확장 개발자 문서 -->
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
<div class="flex items-center gap-2 mb-3">
<span class="text-lg">📗</span>
<span class="text-xs font-medium text-slate-200">확장 개발자 문서</span>
</div>
<div class="flex flex-wrap gap-2">
<button onclick="runCommand('ext:docgen --dry-run')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-slate-600 hover:bg-slate-700 text-white text-xs font-medium rounded transition-colors" title="번들 확장별 훅·모델·마이그레이션·레이아웃 실측 집계와 문서 대상을 출력합니다. 파일을 만들거나 고치지 않습니다.">
<span>확장 문서 대상·집계 확인</span>
<span class="text-[10px] opacity-60">(ext:docgen --dry-run)</span>
</button>
<button onclick="runCommand('ext:docgen --check')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors" title="문서 누락·필수 섹션 누락·자동 생성 블록이 코드 실측과 어긋나는지 검사합니다. 파일을 고치지 않습니다.">
<span>확장 문서 drift 검사</span>
<span class="text-[10px] opacity-60">(ext:docgen --check)</span>
</button>
<button onclick="runCommand('ext:docgen')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded transition-colors" title="자동 생성 블록 안쪽만 코드 실측으로 갱신합니다. 블록 밖 사람이 쓴 서술은 건드리지 않습니다.">
<span>확장 문서 갱신</span>
<span class="text-[10px] opacity-60">(ext:docgen)</span>
</button>
</div>
</div>
<!-- 유지보수 -->
<div class="bg-slate-900/40 rounded-xl p-4 border border-slate-700/30">
<div class="flex items-center gap-2 mb-3">
@@ -1582,6 +1604,10 @@ if (isset($_GET['ajax_action'])) {
*/
const COMMAND_TIMEOUTS = {
'security:audit-dependencies': 300000,
// 확장 20개의 진입 클래스를 실제로 부팅해 선언형 getter 40종을 호출한다.
// 기본 60초를 넘기면 화면은 타임아웃을 띄우는데 서버는 계속 문서를 쓰므로,
// "실패했다" 와 "성공했는데 화면이 포기했다" 가 구분되지 않는다.
'ext:docgen': 300000,
};
/**
@@ -0,0 +1,945 @@
<?php
namespace Tests\Feature\Documentation;
use App\Support\ExtensionDoc\DataModelCollector;
use App\Support\ExtensionDoc\DeclarativeSurfaceCollector;
use App\Support\ExtensionDoc\DependencyGraphCollector;
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
use App\Support\ExtensionDoc\ExtensionInventory;
use App\Support\ExtensionDoc\FrontendInventory;
use App\Support\ExtensionDoc\HookInventory;
use App\Support\ExtensionDoc\TestPathCollector;
use Tests\TestCase;
/**
* 확장 개발자 문서 계약 (#601)
*
* 세션 훅은 CI 에서 돌지 않는다. 문서 체계를 실제로 잠그는 것은 이 테스트다.
*
* 확장 목록을 **열거하지 않는다** — `_bundled` 를 스캔하므로 새 확장이 추가되면 자동으로
* 검사 대상에 편입된다. 그래서 21번째 확장이 문서 없이 태어나면 이 테스트가 먼저 붉어진다.
*
* 문서가 아직 없는 확장은 집필 백로그(`DOC_BACKLOG`)로 선언한다. 백로그는 **줄어들기만
* 해야 한다** — 목록에 없는 확장이 문서를 잃거나 새 확장이 문서 없이 들어오면 실패한다.
* 백로그가 비면 그 순간부터 전수 강제로 바뀌며, 별도 코드 변경이 필요 없다.
*/
class ExtensionDocContractTest extends TestCase
{
/**
* 개발자 문서 집필 대기 중인 확장 (`{type}/{id}`).
*
* S1 은 인프라만 구축했고 집필은 S2(파일럿 3 + 동형군 6)·S3(잔여 11)에서 수행한다.
* 확장 하나를 집필할 때마다 이 목록에서 그 줄을 지운다. 목록에 항목을 **추가하는 것은
* 금지**다 — 추가는 곧 문서 없는 확장을 새로 들이는 것이고, 그러면 이 계약이 무의미해진다.
*
* @var array<int, string>
*/
private const DOC_BACKLOG = [
'module/gnuboard7-hello_module',
'module/sirsoft-board',
'module/sirsoft-ecommerce',
'module/sirsoft-page',
'plugin/gnuboard7-hello_plugin',
'plugin/sirsoft-ckeditor5',
'plugin/sirsoft-daum_postcode',
'plugin/sirsoft-gdpr',
'plugin/sirsoft-marketing',
'plugin/sirsoft-message_bizppurio',
'plugin/sirsoft-pay_kginicis',
'plugin/sirsoft-pay_nhnkcp',
'plugin/sirsoft-pay_nicepayments',
'plugin/sirsoft-tosspayments',
'plugin/sirsoft-verification_kginicis',
'plugin/sirsoft-verification_nhnkcp',
'template/gnuboard7-hello_admin_template',
'template/gnuboard7-hello_user_template',
'template/sirsoft-admin_basic',
'template/sirsoft-basic',
];
/**
* 집필 백로그의 상한 (S1 종료 시점의 번들 확장 수).
*
* "추가 금지" 를 주석으로만 적으면 강제되지 않습니다 — 줄 하나를 보태면 문서 없는 확장이
* 조용히 통과하고 아무 테스트도 red 가 되지 않습니다. 상한을 상수로 고정해, 백로그를
* 늘리려면 이 숫자를 올리는 **눈에 보이는 행위**를 거치게 합니다. 집필이 진행되면 이
* 숫자도 함께 내려갑니다 (백로그는 줄어들기만 하므로 상한도 단조 감소한다).
*/
private const DOC_BACKLOG_CEILING = 20;
/**
* `_bundled` 스캔이 번들 확장 전수를 발견하는지 확인합니다.
*/
public function test_inventory_discovers_every_bundled_extension(): void
{
$records = (new ExtensionInventory)->collect('all');
$this->assertNotEmpty($records, '번들 확장을 하나도 발견하지 못했습니다.');
foreach ($records as $record) {
$this->assertDirectoryExists($record['path']);
$this->assertFileExists($record['manifestPath']);
$this->assertNotSame('', $record['id']);
$this->assertContains($record['type'], ExtensionInventory::types());
}
// 디스크의 manifest 개수와 스캔 결과가 일치해야 한다 (조용한 누락 금지).
$onDisk = 0;
foreach ([['modules', 'module.json'], ['plugins', 'plugin.json'], ['templates', 'template.json']] as [$dir, $manifest]) {
$root = base_path($dir.'/_bundled');
if (! is_dir($root)) {
continue;
}
$onDisk += count(glob($root.'/*/'.$manifest) ?: []);
}
$this->assertSame($onDisk, count($records), '스캔 결과가 디스크의 manifest 수와 다릅니다.');
}
/**
* 백로그에 없는 확장은 필수 문서를 갖추고 있어야 합니다.
*
* 백로그 자신도 검사 대상입니다 — 실재하지 않는 확장이 남아 있으면 목록이 낡은 것이고,
* 문서를 갖춘 확장이 남아 있으면 지워야 할 줄을 지우지 않은 것입니다.
*/
public function test_documented_extensions_have_every_required_document(): void
{
$records = (new ExtensionInventory)->collect('all');
$backlog = array_flip(self::DOC_BACKLOG);
$ids = [];
$missing = [];
$staleBacklog = [];
foreach ($records as $record) {
$key = $record['type'].'/'.$record['id'];
$ids[$key] = true;
$present = [];
$absent = [];
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
if (is_file($abs)) {
$present[] = $doc;
} else {
$absent[] = $doc;
}
}
if (isset($backlog[$key])) {
if ($absent === []) {
$staleBacklog[] = $key.' — 문서가 완비되었으니 DOC_BACKLOG 에서 지우세요';
}
continue;
}
foreach ($absent as $doc) {
$missing[] = $key.' → '.$doc;
}
}
foreach (array_keys($backlog) as $key) {
if (! isset($ids[$key])) {
$staleBacklog[] = $key.' — 존재하지 않는 확장입니다 (DOC_BACKLOG 에서 지우세요)';
}
}
$this->assertSame([], $missing, "필수 문서 누락:\n".implode("\n", $missing));
$this->assertSame([], $staleBacklog, "DOC_BACKLOG 가 낡았습니다:\n".implode("\n", $staleBacklog));
// 백로그는 줄어들기만 한다 — 상한을 **정확히** 일치시켜 래칫으로 만든다.
//
// `<=` 로 두면 항목 하나를 집필해 지우는 순간(20 → 19) 상한 20 아래에 빈자리가
// 하나 영구히 열린다. 그 다음부터는 문서 없는 확장을 백로그에 얹어도 red 가 되지
// 않는다 — 막으려던 것이 첫 집필과 동시에 되살아난다. `===` 는 백로그에서 한 줄을
// 지울 때 상한도 함께 내리게 강제하고, 늘리려면 그 숫자를 올리는 행위가 diff 에 남는다.
$this->assertSame(
self::DOC_BACKLOG_CEILING,
count(self::DOC_BACKLOG),
'DOC_BACKLOG 와 DOC_BACKLOG_CEILING 이 어긋났습니다. 확장을 집필해 백로그에서 '
.'지웠다면 상한도 같은 수만큼 내리세요. 늘려야 할 근거가 정말 있다면 상한을 '
.'올리고 그 사유를 남기세요 — 그것이 "문서 없는 확장을 새로 들인다" 는 선언입니다.',
);
$this->assertSame(
array_values(array_unique(self::DOC_BACKLOG)),
array_values(self::DOC_BACKLOG),
'DOC_BACKLOG 에 중복 항목이 있습니다 (상한 판정이 왜곡됩니다).',
);
}
/**
* 작성된 문서는 필수 섹션과 자동 생성 블록을 갖추고 있어야 합니다.
*
* 문서를 만들다 만 상태(헤딩만 있고 블록이 없거나 그 반대)를 잡습니다.
*
* 백로그 확장은 제외합니다 — 표준 골격 이전에 손으로 쓰인 README 5개가 그 상태이며,
* 재정비는 집필 단계의 작업입니다. 백로그에서 빠지는 순간 이 검사가 전면 적용됩니다.
*/
public function test_existing_documents_have_required_sections_and_blocks(): void
{
$records = (new ExtensionInventory)->collect('all');
$backlog = array_flip(self::DOC_BACKLOG);
$problems = [];
foreach ($records as $record) {
if (isset($backlog[$record['type'].'/'.$record['id']])) {
continue;
}
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
if (! is_file($abs)) {
continue;
}
$content = (string) file_get_contents($abs);
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
$label = $record['type'].'/'.$record['id'].' → '.$doc;
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
$problems[] = $label.' : 필수 섹션 없음 — '.$section;
}
}
$present = ExtensionDocScaffolder::presentBlockKeys($content);
foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) {
if (! in_array($block, $present, true)) {
$problems[] = $label.' : 자동 생성 블록 없음 — '.$block;
}
}
}
}
$this->assertSame([], $problems, "문서 골격 위반:\n".implode("\n", $problems));
}
/**
* 자동 생성 블록의 훅 목록이 소스 실측과 일치해야 합니다 (문서 부패 검출).
*
* 훅은 다른 확장이 잡는 계약이므로, 문서와 코드가 어긋나면 그 확장이 잡을 수 없는
* 훅 이름을 문서가 광고하게 됩니다.
*/
public function test_generated_blocks_match_measured_source(): void
{
$inventory = new ExtensionInventory;
$scaffolder = new ExtensionDocScaffolder;
$drifted = [];
foreach ($inventory->collect('all') as $record) {
$documents = array_filter(
ExtensionDocScaffolder::documentsForType($record['type']),
fn (string $doc): bool => is_file($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc)),
);
if ($documents === []) {
continue;
}
$bodies = $scaffolder->renderBlocks($this->contextFor($record, $inventory));
foreach ($documents as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
$content = (string) file_get_contents($abs);
$subject = [];
foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) {
if (isset($bodies[$block])) {
$subject[$block] = $bodies[$block];
}
}
$merged = ExtensionDocScaffolder::replaceBlocks($content, $subject);
if (! $merged['unchanged']) {
$drifted[] = $record['type'].'/'.$record['id'].' → '.$doc;
}
}
}
$this->assertSame(
[],
$drifted,
"자동 생성 블록이 코드 실측과 어긋납니다 (`php artisan ext:docgen` 재실행 필요):\n".implode("\n", $drifted),
);
}
/**
* 드리프트 판정식 자체가 살아 있어야 합니다 (공허 통과 방지).
*
* 위 검사는 **문서를 가진 확장**만 대상으로 하는데, 표준 골격으로 전환된 확장이 아직
* 없어 실질 대상이 0건입니다. 그래서 판정식이 깨져도 계속 초록입니다 — "드리프트 없음"
* 과 "드리프트를 보지 않음" 이 구분되지 않습니다. 낡은 훅 표를 심은 합성 문서로 판정식이
* 실제로 드리프트를 잡는지 고정합니다.
*/
public function test_drift_detection_actually_detects_drift(): void
{
$stale = "# 확장점\n\n"
.ExtensionDocScaffolder::wrap('hooks-published', '| 훅 이름 | 유형 |\n|---|---|\n| `옛.훅.이름` | action |')
."\n";
$fresh = '| 훅 이름 | 유형 |'."\n".'|---|---|'."\n".'| `새.훅.이름` | action |';
$drifted = ExtensionDocScaffolder::replaceBlocks($stale, ['hooks-published' => $fresh]);
$this->assertFalse(
$drifted['unchanged'],
'블록 본문이 실측과 다른데 드리프트로 판정되지 않았습니다 — 판정식이 죽어 있습니다.',
);
$this->assertStringContainsString('새.훅.이름', $drifted['content']);
$this->assertStringNotContainsString('옛.훅.이름', $drifted['content']);
// 같은 본문이면 드리프트가 아니어야 한다 (거짓 양성 금지).
$same = ExtensionDocScaffolder::replaceBlocks($drifted['content'], ['hooks-published' => $fresh]);
$this->assertTrue($same['unchanged'], '동일 본문인데 드리프트로 판정했습니다.');
}
/**
* 훅 이름을 조립해 발행하는 확장도 발행 훅 표에 실려야 합니다.
*
* 리터럴 스캔만으로는 `self::PLUGIN_ID.'.consent.granted'` 형태를 읽지 못해, 훅을 12곳에서
* 발행하는 확장이 "훅을 발행하지 않습니다" 로 문서화됩니다. 그래서 확장이 `getHooks()` 로
* 선언한 목록이 1차 출처입니다 — 이 계약이 깨지면 그 확장의 확장점이 통째로 비공개가 되고,
* 문서는 사실이 아닌 문장을 광고합니다.
*/
public function test_declared_hooks_reach_the_published_table(): void
{
$inventory = new ExtensionInventory;
$scaffolder = new ExtensionDocScaffolder;
$records = array_values(array_filter(
$inventory->collect('plugin:sirsoft-gdpr'),
fn (array $r): bool => $r['id'] === 'sirsoft-gdpr',
));
$this->assertNotEmpty($records, 'sirsoft-gdpr 를 찾지 못했습니다.');
$ctx = $this->contextFor($records[0], $inventory);
$this->assertGreaterThan(
0,
$ctx['hooks']['publishedSites'],
'이 확장은 훅 발행 호출을 갖고 있어야 합니다 (전제 확인).',
);
$this->assertNotSame(
[],
$ctx['hooks']['published'],
'발행 호출이 있는데 발행 훅이 0종입니다 — 선언이 반영되지 않았습니다.',
);
$block = $scaffolder->renderBlocks($ctx)['hooks-published'];
$this->assertStringNotContainsString(
'훅을 발행하지 않습니다',
$block,
'훅을 발행하는 확장에 "발행하지 않습니다" 가 렌더되었습니다.',
);
foreach ($ctx['hooks']['published'] as $hook) {
$this->assertStringContainsString(
$hook['name'],
$block,
"선언된 훅 '{$hook['name']}' 이 발행 훅 표에 없습니다.",
);
}
}
/**
* 자동 생성 블록 교체가 블록 밖 텍스트를 손상하지 않아야 합니다.
*
* 이 계약이 깨지면 사람이 쓴 서술이 재생성 때마다 소실됩니다 — `api:docgen` 이 과거
* 34,000줄을 날린 사고가 그 형태였고, 그래서 이 생성기에는 파괴적 재생성 플래그가 없습니다.
*/
public function test_block_replacement_preserves_human_text(): void
{
$human = [
'intro' => '사람이 쓴 서론 — 생성기가 건드리면 안 된다.',
'intent' => '사람이 쓴 의도 서술 — 블록 사이에 있어도 보존되어야 한다.',
'outro' => '사람이 쓴 마지막 문단.',
];
$document = "# 제목\n\n{$human['intro']}\n\n"
.ExtensionDocScaffolder::wrap('models', "| 옛 |\n|---|\n| 표 |')")
."\n\n<!-- @intent START -->\n{$human['intent']}\n<!-- @intent END -->\n\n"
.ExtensionDocScaffolder::wrap('tables', '옛 테이블 표')
."\n\n## 마무리\n\n{$human['outro']}\n";
$bodies = [
'models' => "| 새 |\n|---|\n| 표 |",
'tables' => '새 테이블 표',
'enums' => '문서에 마커가 없는 블록 — 주입되면 안 된다',
];
$result = ExtensionDocScaffolder::replaceBlocks($document, $bodies);
foreach ($human as $key => $text) {
$this->assertStringContainsString($text, $result['content'], "사람 서술 손실: {$key}");
}
$this->assertStringContainsString('| 새 |', $result['content']);
$this->assertStringNotContainsString('| 옛 |', $result['content']);
$this->assertStringContainsString('새 테이블 표', $result['content']);
$this->assertStringNotContainsString('옛 테이블 표', $result['content']);
$this->assertStringNotContainsString(
'주입되면 안 된다',
$result['content'],
'문서에 마커가 없는 블록은 임의 위치에 주입하지 않는다 (누락으로 보고).',
);
$this->assertSame(['enums'], $result['missing']);
$this->assertSame(['models', 'tables'], $result['replaced']);
// 멱등: 같은 본문으로 다시 돌리면 한 글자도 바뀌지 않아야 한다.
$again = ExtensionDocScaffolder::replaceBlocks($result['content'], [
'models' => $bodies['models'],
'tables' => $bodies['tables'],
]);
$this->assertTrue($again['unchanged'], '재실행이 멱등이 아닙니다.');
}
/**
* 미채움 마커 목록이 PHP · 검사 스크립트 · audit 룰 세 곳에서 일치해야 합니다.
*
* 세 곳이 갈라지면 한쪽이 세지 않는 마커가 생기고, 그 자리는 영영 집계되지 않습니다.
*/
public function test_todo_marker_list_is_consistent_across_tooling(): void
{
$markers = ExtensionDocScaffolder::todoMarkers();
$this->assertCount(5, $markers, '미채움 마커는 5종으로 고정입니다.');
$script = (string) file_get_contents(base_path('.claude/scripts/check-extension-docs.cjs'));
$rule = (string) file_get_contents(base_path('.claude/scripts/audit/rules/extension-doc-unfilled-markers.cjs'));
foreach ($markers as $marker) {
$this->assertStringContainsString(
"'{$marker}'",
$script,
"check-extension-docs.cjs 에 마커 '{$marker}' 가 없습니다.",
);
$this->assertStringContainsString(
"'{$marker}'",
$rule,
"extension-doc-unfilled-markers.cjs 에 마커 '{$marker}' 가 없습니다.",
);
}
// 포함만 단언하면 방향이 하나뿐이라 JS 쪽 **초과** 항목(6번째 마커·오탈자 잔여)이
// 그대로 살아 있다. 두 도구가 세는 모집단이 갈라지면 잔량 집계가 서로 다른 답을
// 내는데, 그 차이는 어느 쪽 출력에도 드러나지 않는다.
foreach ([$script, $rule] as $i => $source) {
preg_match_all("/'(TODO: [^']+)'/u", $source, $m);
$found = array_values(array_unique($m[1]));
sort($found);
$expected = $markers;
sort($expected);
$this->assertSame(
$expected,
$found,
($i === 0 ? 'check-extension-docs.cjs' : 'extension-doc-unfilled-markers.cjs')
.' 의 마커 목록이 PHP SSoT 와 다릅니다 (초과 또는 누락).',
);
}
}
/**
* 모든 번들 확장에서 수집기와 렌더러가 예외 없이 동작해야 합니다.
*
* 확장 하나의 특이 구조(다국어 배열 라벨 등)가 생성 전체를 중단시키는 것을 막습니다.
*/
public function test_every_extension_renders_without_error(): void
{
$inventory = new ExtensionInventory;
$scaffolder = new ExtensionDocScaffolder;
foreach ($inventory->collect('all') as $record) {
$label = $record['type'].'/'.$record['id'];
$ctx = $this->contextFor($record, $inventory);
$this->assertSame(
[],
$ctx['surface']['errors'],
"{$label}: 선언형 표면 수집 중 실패한 getter 가 있습니다.",
);
// 수집 **실패** 는 errors 에 남지 않는다 — 진입 클래스 로드·인스턴스화 실패는
// 조기 반환이라 errors 가 빈 배열인 채 available=false 가 된다. errors 만
// 단언하면 모듈 20개의 표면이 전부 죽어도 이 테스트는 초록이고, 그 사이 문서에는
// "확인하지 못했습니다" 가 대량으로 기록된다. 템플릿은 선언형 표면을 갖지 않는
// 것이 정상이므로 대상에서 뺀다.
if ($record['type'] !== ExtensionInventory::TYPE_TEMPLATE) {
$this->assertTrue(
$ctx['surface']['available'],
"{$label}: 선언형 표면을 읽지 못했습니다 — ".($ctx['surface']['reason'] ?? '사유 미기록'),
);
}
$blocks = $scaffolder->renderBlocks($ctx);
$this->assertNotEmpty($blocks, "{$label}: 렌더된 블록이 없습니다.");
foreach ($blocks as $key => $body) {
$this->assertIsString($body, "{$label}: 블록 '{$key}' 본문이 문자열이 아닙니다.");
$this->assertNotSame('', trim($body), "{$label}: 블록 '{$key}' 본문이 비었습니다.");
}
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$skeleton = $scaffolder->skeleton($doc, $ctx);
$this->assertNotSame('', trim($skeleton), "{$label}: {$doc} 골격이 비었습니다.");
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
$this->assertTrue(
ExtensionDocScaffolder::hasSection($skeleton, $section),
"{$label}: {$doc} 골격에 섹션 '{$section}' **헤딩**이 없습니다.",
);
}
foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) {
$this->assertContains(
$block,
ExtensionDocScaffolder::presentBlockKeys($skeleton),
"{$label}: {$doc} 골격에 블록 '{$block}' 마커가 없습니다.",
);
}
}
}
}
/**
* 섹션 판정이 낱말 등장이 아니라 헤딩을 봅니다.
*
* 절 이름을 부분문자열로 찾으면 이 축은 실패할 수 없습니다. README 는 자동 생성
* 인라인 목차가 12개 절 이름을 전부 담고, `docs/data-model.md` 의 모델 표는 헤더에
* `| 모델 | 테이블 |` 을 싣습니다 — 헤딩을 통째로 지워도 통과합니다. 실측 선례:
* `sirsoft-gdpr/README.md` 는 `## 변경 이력` 헤딩이 없는데 본문에 그 낱말이 3회
* 등장한다는 이유로 "섹션 있음" 으로 판정됐습니다.
*/
public function test_section_detection_requires_a_heading_not_a_word(): void
{
$section = '변경 이력';
// 헤딩 없이 낱말만 있는 형태 — 인라인 목차 · 표 헤더 · 산문
$decoys = [
"[소개](#소개) · [{$section}](#변경-이력) · [라이선스](#라이선스)\n",
"| 항목 | {$section} |\n|---|---|\n| a | b |\n",
"회원/게스트의 모든 동의 {$section}을 조회할 수 있습니다.\n",
"`## {$section}` 처럼 적으면 됩니다.\n",
];
foreach ($decoys as $i => $decoy) {
$this->assertFalse(
ExtensionDocScaffolder::hasSection($decoy, $section),
"낱말 등장(#{$i})이 섹션 존재로 판정됐습니다 — 헤딩을 지워도 통과하게 됩니다.",
);
}
foreach (["## {$section}\n", "### {$section}\n", "본문\n\n## {$section} \n\n다음"] as $i => $real) {
$this->assertTrue(
ExtensionDocScaffolder::hasSection($real, $section),
"실제 헤딩(#{$i})을 섹션 없음으로 판정했습니다.",
);
}
// 접두 일치로 다른 절을 인정하지 않는다 (`문서` 가 `문서 목차` 를 먹으면 안 된다).
$this->assertFalse(ExtensionDocScaffolder::hasSection("## 문서 목차\n", '문서'));
$this->assertTrue(ExtensionDocScaffolder::hasSection("## 문서 목차\n", '문서 목차'));
}
/**
* 골격의 절 아래에 **그 절의 블록**이 놓이는지 단언합니다.
*
* 헤딩 존재와 블록 마커 존재를 각각만 보면 짝이 어긋나도 전부 초록입니다 — 절을
* 하나 끼우는 순간 그 뒤 블록이 통째로 밀려 엉뚱한 헤딩 밑에 박히는데, 그 상태가
* 어떤 게이트에도 걸리지 않습니다.
*/
public function test_skeleton_places_each_block_under_its_own_section(): void
{
$inventory = new ExtensionInventory;
$scaffolder = new ExtensionDocScaffolder;
foreach ($inventory->collect('all') as $record) {
$ctx = $this->contextFor($record, $inventory);
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
if (! ExtensionDocScaffolder::pairsSectionsWithBlocks($doc)) {
continue;
}
$skeleton = $scaffolder->skeleton($doc, $ctx);
$overrides = ExtensionDocScaffolder::DOCUMENTS[$doc]['sectionOverrides'][$record['type']] ?? [];
foreach (ExtensionDocScaffolder::DOCUMENTS[$doc]['blocks'] as $section => $blockKey) {
$heading = '## '.($overrides[$section] ?? $section);
$headingPos = mb_strpos($skeleton, $heading);
$blockPos = mb_strpos($skeleton, ExtensionDocScaffolder::GEN_PREFIX.$blockKey.' START');
$this->assertNotFalse($headingPos, "{$record['id']}: {$doc} 에 '{$heading}' 헤딩이 없습니다.");
$this->assertNotFalse($blockPos, "{$record['id']}: {$doc} 에 '{$blockKey}' 블록이 없습니다.");
$this->assertGreaterThan(
$headingPos,
$blockPos,
"{$record['id']}: {$doc} 의 '{$blockKey}' 블록이 '{$heading}' 절 아래에 있지 않습니다.",
);
// 다음 절이 시작되기 전에 그 블록이 나와야 한다 (다른 절로 밀리지 않음).
$nextHeading = mb_strpos($skeleton, '
## ', $headingPos + mb_strlen($heading));
if ($nextHeading !== false) {
$this->assertLessThan(
$nextHeading,
$blockPos,
"{$record['id']}: {$doc} 의 '{$blockKey}' 블록이 다음 절로 밀려 있습니다.",
);
}
}
}
}
}
/**
* 프로세스 경계를 넘는 리터럴 2종이 PHP·JS 양쪽에서 일치하는지 단언합니다.
*
* 코어 인덱스 스캐너는 `@generated:stats` 마커와 "확인하지 못했습니다" 문장을 **문자열로**
* 다시 적어 두고 파싱합니다. 생성기 쪽 문구가 바뀌면 스캐너가 조용히 실패해 인덱스가
* 다시 `훅 0 · 라우트 0` 을 사실로 싣습니다 — 미채움 마커 5종에는 이미 양방향 집합
* 단언이 있는데 같은 성질의 이 두 리터럴만 그 취급을 받지 못했습니다.
*/
public function test_cross_process_literals_match_the_index_scanner(): void
{
$scanner = base_path('.claude/scripts/generate-docs-index.cjs');
if (! is_file($scanner)) {
$this->markTestSkipped('내부 스캐너가 없는 배포본입니다.');
}
$js = (string) file_get_contents($scanner);
// 생성기가 실제로 방출하는 마커에서 키 부분을 뽑아 스캐너 소스와 대조한다.
// (스캐너는 정규식으로 공백을 흡수하므로 마커 전문이 리터럴로 있지는 않다.)
$wrapped = ExtensionDocScaffolder::wrap('stats', '**훅 수**: 1');
$this->assertStringContainsString('@generated:stats START', $wrapped);
$this->assertStringContainsString('@generated:stats END', $wrapped);
$this->assertStringContainsString(
'@generated:stats',
$js,
'인덱스 스캐너가 집계 블록 마커를 그대로 알고 있어야 합니다.',
);
// 수집 실패 문구 — 통지는 둘(표면 전체 실패 · 개별 getter 실패)이고 스캐너는 그
// 둘이 공유하는 어구를 찾는다. 소스에 리터럴이 있는지만 보면 "어딘가에 그 문자열이
// 있다" 만 확인할 뿐, **실제로 방출되는 문장**이 그것을 담는지는 보지 않는다 —
// 개별 getter 실패 통지가 다른 어구로 시작해 스캐너에 통째로 새던 것이 그 사각이다.
$marker = ExtensionDocScaffolder::SURFACE_NOTICE_MARKER;
$this->assertStringContainsString(
// 스캐너 정규식은 `**` 를 이스케이프하므로 그 형태로 대조한다.
str_replace('**', '\*\*', $marker),
$js,
'인덱스 스캐너가 표면 실패 판정 어구를 읽지 못하면 점검 불가가 0 으로 굳습니다.',
);
// 두 통지가 실제로 그 어구를 담고 방출되는지 렌더 결과로 단언한다.
$inventory = new ExtensionInventory;
$record = $inventory->find('module', 'sirsoft-board');
$this->assertNotNull($record, '대조 기준 확장(sirsoft-board)이 없습니다.');
$ctx = $this->contextFor($record, $inventory);
$scaffolder = new ExtensionDocScaffolder;
// (1) 개별 getter 실패 — 본문은 살리고 사유를 덧붙이는 통지
$partial = $ctx;
$partial['surface']['errors'] = ['getRoutes' => '테스트 주입'];
foreach (['stats', 'hooks-published', 'hooks-subscribed', 'permissions'] as $key) {
$this->assertStringContainsString(
$marker,
$scaffolder->renderBlock($key, $partial),
"개별 getter 실패 시 `{$key}` 블록에 판정 어구가 실려야 인덱스가 그 수치를 점검 불가로 가릅니다.",
);
}
// (2) 표면 전체 실패 — 본문을 대체하는 통지
$whole = $ctx;
$whole['surface']['available'] = false;
$whole['surface']['reason'] = '테스트 주입';
$whole['surface']['errors'] = [];
// 본문을 대체하는 블록(permissions 등)뿐 아니라, 읽어낸 사실을 함께 싣는 블록
// (수치·훅 표)에도 붙어야 한다. 그 셋만 통지 대상에서 빠져 있으면 진입 클래스를
// 통째로 못 읽은 확장의 문서가 "발행 훅 N종 … 이 중 N종은 선언에 없어 자동 감지"
// 라는 거짓 문장을 경고 없이 싣고, 인덱스 스캐너는 그 수치를 실측으로 옮긴다.
foreach (['permissions', 'stats', 'hooks-published', 'hooks-subscribed'] as $key) {
$this->assertStringContainsString(
$marker,
$scaffolder->renderBlock($key, $whole),
"표면 전체 실패 시 `{$key}` 블록에도 같은 판정 어구가 실려야 합니다.",
);
}
}
/**
* 세지 못한 지표가 0 으로 굳지 않는지 단언합니다.
*
* 수집기는 셀 수 없는 지표를 `null` 로 올립니다("0 을 돌려주면 주소가 없다는 사실
* 주장이 된다"). 그 계약은 소비처가 지키지 않으면 그 자리에서 끝납니다 — 배지가
* `?? 0` 으로 받으면 수집기가 구분해 올린 값이 다시 0 이 되고, 템플릿은 표면 실패
* 통지 대상이 아니라 단서도 남지 않아 인덱스가 그 0 을 실측으로 옮깁니다.
*/
public function test_unmeasured_stats_do_not_collapse_to_zero(): void
{
$inventory = new ExtensionInventory;
$record = $inventory->find('template', 'sirsoft-basic');
$this->assertNotNull($record, '대조 기준 템플릿(sirsoft-basic)이 없습니다.');
$ctx = $this->contextFor($record, $inventory);
// 정상 경로 — 실측이 그대로 실린다.
$this->assertIsInt(
ExtensionDocScaffolder::statsOf($ctx)['라우트 수'],
'`routes.json` 을 읽은 템플릿은 주소 수가 정수여야 합니다.',
);
// 세지 못한 경로 — null 이 0 으로 바뀌지 않아야 한다.
$unmeasured = $ctx;
$unmeasured['frontend']['routeCount'] = null;
$this->assertNull(
ExtensionDocScaffolder::statsOf($unmeasured)['라우트 수'],
'세지 못한 지표를 0 으로 받으면 "주소가 없다" 는 사실 주장이 됩니다.',
);
$block = (new ExtensionDocScaffolder)->renderBlock('stats', $unmeasured);
$this->assertStringContainsString(
ExtensionDocScaffolder::STAT_UNMEASURED,
$block,
'세지 못한 지표는 수치 자리에 그 사실이 드러나야 합니다.',
);
$this->assertStringNotContainsString(
'**라우트 수**: 0',
$block,
'세지 못한 주소 수가 0 으로 실리면 안 됩니다.',
);
$this->assertStringContainsString(
ExtensionDocScaffolder::SURFACE_NOTICE_MARKER,
$block,
'단서가 없으면 인덱스 스캐너가 이 블록을 실측으로 읽습니다.',
);
}
/**
* 상세 문서로 거는 앵커가 그 문서의 실제 절 이름인지 단언합니다.
*
* 요약 표는 절 이름을 문자열로 다시 적어 앵커를 만듭니다. 절 이름을 바꾸면 헤딩
* 검사는 통과하는데 요약의 링크만 조용히 끊깁니다 — 앵커 유효성을 보는 장치가
* 없었습니다.
*/
public function test_summary_anchors_point_at_real_sections(): void
{
$source = (string) file_get_contents(
(string) (new \ReflectionClass(ExtensionDocScaffolder::class))->getFileName()
);
preg_match_all(
'/docLink\(\$ctx,\s*\'([^\']+)\',\s*\'([^\']+)\'\)/u',
$source,
$matches,
PREG_SET_ORDER
);
$this->assertNotEmpty($matches, 'docLink 호출을 하나도 찾지 못했습니다 — 판정식이 낡았습니다.');
foreach ($matches as [, $doc, $anchor]) {
$known = [];
foreach (['module', 'plugin', 'template'] as $type) {
$known = array_merge($known, ExtensionDocScaffolder::sectionsFor($doc, $type));
}
$this->assertContains(
$anchor,
$known,
"docLink 가 '{$doc}' 의 '{$anchor}' 절을 가리키는데 그런 절이 없습니다 — 링크가 끊깁니다.",
);
}
}
/**
* 템플릿 유형은 API·모델·훅 문서를 요구하지 않아야 합니다.
*
* 유형별 골격 분기가 사라지면 템플릿에 채울 수 없는 문서가 요구됩니다.
*/
public function test_document_set_differs_by_extension_type(): void
{
$module = ExtensionDocScaffolder::documentsForType(ExtensionInventory::TYPE_MODULE);
$template = ExtensionDocScaffolder::documentsForType(ExtensionInventory::TYPE_TEMPLATE);
foreach (['AGENTS.md', 'README.md', 'docs/README.md', 'docs/architecture.md'] as $shared) {
$this->assertContains($shared, $module);
$this->assertContains($shared, $template);
}
$this->assertContains('docs/data-model.md', $module);
$this->assertContains('docs/extension-points.md', $module);
$this->assertNotContains('docs/data-model.md', $template);
$this->assertNotContains('docs/extension-points.md', $template);
$this->assertContains('docs/components.md', $template);
$this->assertContains('docs/layouts.md', $template);
$this->assertNotContains('docs/components.md', $module);
}
/**
* 고아 블록(문서에 실재하지만 필수 목록 밖) 검출이 살아 있는지 단언합니다.
*
* 이 축은 저장소에 문서가 0세트인 동안 실측이 항상 0건이라, 검출부가 깨져도
* `--check` 이슈 수만 줄고 아무 게이트도 붉어지지 않습니다. 검출식을 직접 겨눕니다.
*/
public function test_orphan_block_detection_is_alive(): void
{
$doc = 'AGENTS.md';
$known = ExtensionDocScaffolder::blocksFor($doc);
$this->assertNotEmpty($known, "{$doc} 의 필수 블록 목록이 비었습니다 — 모집단이 없습니다.");
$legit = $known[0];
$content = "# 제목\n\n"
.ExtensionDocScaffolder::wrap($legit, '본문')."\n\n"
.ExtensionDocScaffolder::wrap('legacy-orphan', '낡은 실측')."\n";
$present = ExtensionDocScaffolder::presentBlockKeys($content);
$this->assertContains($legit, $present, '필수 블록을 찾지 못했습니다.');
$this->assertContains(
'legacy-orphan',
$present,
'목록 밖 블록을 찾지 못하면 그 블록은 영영 갱신되지 않고 누락으로도 보고되지 않습니다.',
);
$orphans = array_values(array_filter(
$present,
static fn (string $key): bool => ! in_array($key, $known, true),
));
$this->assertSame(['legacy-orphan'], $orphans, '고아 판정이 필수 블록까지 잡거나 놓치고 있습니다.');
}
/**
* 유형별로 다른 표면을 생성기가 실제 파일 배치대로 서술하는지 단언합니다.
*
* 이 축의 결함은 예외도 경고도 남기지 않습니다 — 없는 파일을 요구하거나, 있는
* 디렉토리를 통째로 빠뜨린 문서가 조용히 커밋됩니다. 그 문서는 골격에 한 번만
* 쓰이고 자동 생성 블록이 아니라 재생성으로 고쳐지지도 않으므로, 틀린 채로 굳습니다.
*/
public function test_skeleton_matches_actual_extension_layout(): void
{
$inventory = new ExtensionInventory;
$scaffolder = new ExtensionDocScaffolder;
$records = $inventory->collect();
$this->assertNotEmpty($records, '확장을 하나도 찾지 못했습니다 — 모집단이 비었습니다.');
$checkedTemplate = 0;
$checkedControllers = 0;
foreach ($records as $record) {
$ctx = $this->contextFor($record, $inventory);
$agents = $scaffolder->skeleton('AGENTS.md', $ctx);
if ($record['type'] === ExtensionInventory::TYPE_TEMPLATE) {
$checkedTemplate++;
// 번들 템플릿은 PHP 패키지가 아니라 composer.json 을 갖지 않는다.
$this->assertFileDoesNotExist(
$record['path'].DIRECTORY_SEPARATOR.'composer.json',
"전제가 깨졌습니다: {$record['id']} 에 composer.json 이 생겼습니다.",
);
$this->assertStringNotContainsString(
'`composer.json` 동기화',
$agents,
"{$record['id']}: 템플릿에 없는 composer.json 동기화를 동반 의무로 요구하고 있습니다.",
);
// 주소는 routes.json 에 있다 — 선언형 표면만 보면 구조적으로 항상 0 이다.
$declared = $ctx['frontend']['routeCount'] ?? null;
if (is_file($record['path'].DIRECTORY_SEPARATOR.'routes.json')) {
$this->assertIsInt(
$declared,
"{$record['id']}: routes.json 이 있는데 주소 수를 세지 못했습니다.",
);
$this->assertSame(
$declared,
ExtensionDocScaffolder::statsOf($ctx)['라우트 수'],
"{$record['id']}: 집계 배지의 라우트 수가 routes.json 실측과 다릅니다.",
);
}
}
// 컨트롤러 자리는 두 갈래(`src/Http/Controllers/` · `src/Controllers/`)다.
// 실재하는 갈래가 디렉토리 지도에 나타나지 않으면 그 확장 문서에서
// "API 표면 변경 시 api:docgen 재실행" 절차가 통째로 사라진다.
foreach (['src/Http/Controllers', 'src/Controllers'] as $candidate) {
if (! is_dir($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $candidate))) {
continue;
}
$checkedControllers++;
$this->assertStringContainsString(
$candidate.'/',
$agents,
"{$record['id']}: {$candidate}/ 가 실재하는데 디렉토리 지도에 없습니다.",
);
}
// 다른 확장 화면에 주입하는 레이아웃 조각도 유형마다 자리가 다르다.
$extRoot = $record['type'] === ExtensionInventory::TYPE_TEMPLATE
? 'extensions'
: 'resources/extensions';
if (is_dir($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $extRoot))) {
$this->assertNotEmpty(
$ctx['frontend']['layoutExtensions'],
"{$record['id']}: {$extRoot}/ 가 실재하는데 레이아웃 확장 조각이 수집되지 않았습니다.",
);
}
}
$this->assertGreaterThan(0, $checkedTemplate, '템플릿을 하나도 검사하지 않았습니다.');
$this->assertGreaterThan(0, $checkedControllers, '컨트롤러 디렉토리를 하나도 검사하지 않았습니다.');
}
/**
* 수집 컨텍스트를 만듭니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param ExtensionInventory $inventory 인벤토리 (역방향 의존 스캔에 사용)
* @return array<string, mixed> 수집 컨텍스트
*/
private function contextFor(array $record, ExtensionInventory $inventory): array
{
// 커맨드와 **같은 방식**으로 조립해야 한다. 발행 훅의 1차 출처가 선언형 표면의
// `getHooks()` 이므로, 그것을 넘기지 않으면 여기서 만든 블록이 커맨드가 쓰는 블록과
// 달라진다 — 문서가 생기는 순간 드리프트 검사가 없는 드리프트를 보고하게 된다.
$surface = (new DeclarativeSurfaceCollector)->collect($record);
$declaredHooks = $surface['values']['getHooks'] ?? [];
return [
'record' => $record,
'surface' => $surface,
'hooks' => (new HookInventory)->collect($record, is_array($declaredHooks) ? $declaredHooks : []),
'data' => (new DataModelCollector)->collect($record),
'frontend' => (new FrontendInventory)->collect($record),
'tests' => (new TestPathCollector)->collect($record),
'deps' => (new DependencyGraphCollector($inventory))->collect($record),
];
}
}