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:
@@ -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) |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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 기재 의무 |
|
||||
|
||||
---
|
||||
|
||||
## 확장 타입별 네이밍 규칙
|
||||
|
||||
@@ -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 레퍼런스 문서 규정
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user