From 11175d35d6e89106462d27095017d80d6bb77529 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 31 Aug 2026 07:58:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(core,docs):=20=ED=99=95=EC=9E=A5=EB=B3=84?= =?UTF-8?q?=20=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EB=AC=B8=EC=84=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EA=B8=B0=EC=99=80=20=EA=B2=80=EC=82=AC=20=ED=95=98?= =?UTF-8?q?=EB=84=A4=EC=8A=A4=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 번들 확장 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 추출 누락도 같은 성격이라 함께 고쳤다. --- AGENTS.md | 30 +- CHANGELOG.md | 2 + .../Commands/Extension/ExtDocgenCommand.php | 468 +++ .../ExtensionDoc/DataModelCollector.php | 332 ++ .../DeclarativeSurfaceCollector.php | 364 +++ .../ExtensionDoc/DependencyGraphCollector.php | 194 ++ .../ExtensionDoc/ExtensionDocScaffolder.php | 2733 +++++++++++++++++ .../ExtensionDoc/ExtensionInventory.php | 322 ++ .../ExtensionDoc/FrontendInventory.php | 531 ++++ app/Support/ExtensionDoc/HookInventory.php | 527 ++++ .../ExtensionDoc/TestPathCollector.php | 241 ++ docs/README.md | 5 +- docs/extension/README.md | 7 + docs/extension/extension-documentation.md | 260 ++ resources/views/dev-dashboard.blade.php | 26 + .../ExtensionDocContractTest.php | 945 ++++++ 16 files changed, 6984 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/Extension/ExtDocgenCommand.php create mode 100644 app/Support/ExtensionDoc/DataModelCollector.php create mode 100644 app/Support/ExtensionDoc/DeclarativeSurfaceCollector.php create mode 100644 app/Support/ExtensionDoc/DependencyGraphCollector.php create mode 100644 app/Support/ExtensionDoc/ExtensionDocScaffolder.php create mode 100644 app/Support/ExtensionDoc/ExtensionInventory.php create mode 100644 app/Support/ExtensionDoc/FrontendInventory.php create mode 100644 app/Support/ExtensionDoc/HookInventory.php create mode 100644 app/Support/ExtensionDoc/TestPathCollector.php create mode 100644 docs/extension/extension-documentation.md create mode 100644 tests/Feature/Documentation/ExtensionDocContractTest.php diff --git a/AGENTS.md b/AGENTS.md index faf244d8..b1135ab7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 은 `` 글리프라 박스 크기가 곧 `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) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ba5408..a0b38a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.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 diff --git a/app/Console/Commands/Extension/ExtDocgenCommand.php b/app/Console/Commands/Extension/ExtDocgenCommand.php new file mode 100644 index 00000000..d5119b6d --- /dev/null +++ b/app/Console/Commands/Extension/ExtDocgenCommand.php @@ -0,0 +1,468 @@ + 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 $ctx 수집 컨텍스트 + * @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더 + * @return array 처리 결과 + */ + 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 $ctx 수집 컨텍스트 + * @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더 + * @param array $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'; + $endPattern = '//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> $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'; + } +} diff --git a/app/Support/ExtensionDoc/DataModelCollector.php b/app/Support/ExtensionDoc/DataModelCollector.php new file mode 100644 index 00000000..923adee9 --- /dev/null +++ b/app/Support/ExtensionDoc/DataModelCollector.php @@ -0,0 +1,332 @@ + + */ + private const RELATION_METHODS = [ + 'hasOne', 'hasMany', 'belongsTo', 'belongsToMany', + 'hasOneThrough', 'hasManyThrough', + 'morphOne', 'morphMany', 'morphTo', 'morphToMany', 'morphedByMany', + ]; + + /** + * 확장의 데이터 모델 표면을 수집합니다. + * + * @param array $record ExtensionInventory 레코드 + * @return array{models: array>, enums: array>, migrations: array>, tables: array, repositories: array>} + */ + 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 $record 확장 레코드 + * @return array> 모델 목록 + */ + 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 관계 목록 + */ + 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 $record 확장 레코드 + * @return array> 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 $record 확장 레코드 + * @return array> 마이그레이션 목록 (파일명 정렬) + */ + 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 $record 확장 레코드 + * @return array> 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 $record 확장 레코드 + * @param string $sub 확장 루트 기준 하위 경로 + * @return array 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 $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); + } +} diff --git a/app/Support/ExtensionDoc/DeclarativeSurfaceCollector.php b/app/Support/ExtensionDoc/DeclarativeSurfaceCollector.php new file mode 100644 index 00000000..0bdb0d23 --- /dev/null +++ b/app/Support/ExtensionDoc/DeclarativeSurfaceCollector.php @@ -0,0 +1,364 @@ + + */ + 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 $record ExtensionInventory 레코드 + * @return array{available: bool, reason: string|null, values: array, errors: array, 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 $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 $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 $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); + }; + } +} diff --git a/app/Support/ExtensionDoc/DependencyGraphCollector.php b/app/Support/ExtensionDoc/DependencyGraphCollector.php new file mode 100644 index 00000000..0c9c5d19 --- /dev/null +++ b/app/Support/ExtensionDoc/DependencyGraphCollector.php @@ -0,0 +1,194 @@ +>|null 전수 인벤토리 캐시 + */ + private ?array $universe = null; + + /** + * @param ExtensionInventory $inventory 번들 확장 인벤토리 + */ + public function __construct(private readonly ExtensionInventory $inventory) {} + + /** + * 확장의 의존 관계를 수집합니다. + * + * @param array $record ExtensionInventory 레코드 + * @return array{requires: array, requiredBy: array, coreVersion: string|null} + */ + public function collect(array $record): array + { + return [ + 'requires' => $this->requires($record), + 'requiredBy' => $this->requiredBy($record), + 'coreVersion' => $this->coreConstraint($record), + ]; + } + + /** + * 이 확장이 의존하는 확장 목록을 반환합니다. + * + * @param array $record 확장 레코드 + * @return array + */ + 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 $record 확장 레코드 + * @return array + */ + 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 $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 $record 확장 레코드 + * @return array{modules: array, plugins: array} + */ + 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> 확장 레코드 목록 + */ + private function allExtensions(): array + { + if ($this->universe === null) { + $this->universe = $this->inventory->collect('all'); + } + + return $this->universe; + } +} diff --git a/app/Support/ExtensionDoc/ExtensionDocScaffolder.php b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php new file mode 100644 index 00000000..f0c6b9ec --- /dev/null +++ b/app/Support/ExtensionDoc/ExtensionDocScaffolder.php @@ -0,0 +1,2733 @@ +, sections: array, blocks: array}> + */ + public const DOCUMENTS = [ + 'AGENTS.md' => [ + 'types' => ['module', 'plugin', 'template'], + 'sections' => ['TL;DR (5초 요약)', '1. 이 확장은 무엇인가', '2. 디렉토리 지도', '3. 핵심 흐름', '4. 확장점', '5. 수정 시 동반 의무', '6. 금지 패턴', '7. 테스트 실행', '8. 문서 목차'], + 'blocks' => ['directory-map', 'extension-points-summary', 'test-commands', 'docs-index'], + ], + 'README.md' => [ + 'types' => ['module', 'plugin', 'template'], + 'sections' => ['소개', '주요 기능', '동작 방식', '요구 사항', '설치', '관리자 설정', '사용 방법', '다른 확장과의 연동', '문서', '트러블슈팅', '변경 이력', '라이선스'], + // 템플릿은 관리자 설정 화면을 갖지 않는다 — 같은 자리에 제공 컴포넌트 요약을 둔다. + 'sectionOverrides' => [ + 'template' => ['관리자 설정' => '제공 컴포넌트'], + ], + 'blocks' => ['badges', 'requirements', 'install', 'settings-summary', 'integrations', 'docs-index'], + ], + 'docs/README.md' => [ + 'types' => ['module', 'plugin', 'template'], + 'sections' => ['문서 목차'], + 'blocks' => ['stats', 'doc-toc'], + ], + 'docs/architecture.md' => [ + 'types' => ['module', 'plugin', 'template'], + 'sections' => ['설계 의도', '계층 지도', '디렉토리'], + 'blocks' => ['directory-map'], + ], + 'docs/extension-points.md' => [ + 'types' => ['module', 'plugin'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '발행 훅' => 'hooks-published', + '구독 훅' => 'hooks-subscribed', + '훅 리스너' => 'listeners', + '레이아웃 확장' => 'layout-extensions', + '미들웨어' => 'middleware', + '브로드캐스트 채널' => 'channels', + '스케줄' => 'schedules', + '알림 정의' => 'notifications', + ], + ], + 'docs/data-model.md' => [ + 'types' => ['module', 'plugin'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '모델' => 'models', + '소유 테이블' => 'tables', + '마이그레이션' => 'migrations', + 'Enum' => 'enums', + 'Repository' => 'repositories', + ], + ], + 'docs/settings.md' => [ + 'types' => ['module', 'plugin'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '설정 스키마' => 'settings-schema', + '권한' => 'permissions', + '메뉴' => 'menus', + '라우트' => 'routes', + '의존 관계' => 'dependencies', + ], + ], + 'docs/frontend.md' => [ + 'types' => ['module', 'plugin'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '레이아웃' => 'layouts', + '액션 핸들러' => 'handlers', + '전역 진입점' => 'frontend-entry', + '에셋' => 'assets', + ], + ], + 'docs/components.md' => [ + 'types' => ['template'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '제공 컴포넌트' => 'components', + ], + ], + 'docs/layouts.md' => [ + 'types' => ['template'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '레이아웃 목록' => 'layouts', + '라우트 매핑' => 'layout-map', + ], + ], + 'docs/handlers.md' => [ + 'types' => ['template'], + // 절 ↔ 블록은 **키로** 묶는다. 순번 결합은 절을 하나 끼우는 순간 그 뒤 블록이 + // 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재·블록 존재를 각각만 보는 게이트는 + // 그 어긋남을 잡지 못한다. 필수 절 목록은 이 배열의 키에서 파생한다(중복 정의 없음). + 'blocks' => [ + '템플릿 전용 핸들러' => 'handlers', + '부트스트랩' => 'frontend-entry', + ], + ], + ]; + + /** + * 확장 유형에 해당하는 문서 목록을 반환합니다. + * + * @param string $type 확장 유형 + * @return array 문서 상대 경로 목록 + */ + public static function documentsForType(string $type): array + { + $docs = []; + + foreach (self::DOCUMENTS as $rel => $meta) { + if (in_array($type, $meta['types'], true)) { + $docs[] = $rel; + } + } + + return $docs; + } + + /** + * 문서의 필수 섹션 목록을 확장 유형에 맞춰 반환합니다. + * + * 같은 문서라도 유형에 따라 한 절의 정체가 달라집니다 — 템플릿 README 의 `관리자 설정` + * 자리는 설정 화면이 없으므로 `제공 컴포넌트` 가 됩니다. 검사 스크립트·계약 테스트가 + * 같은 판정을 쓰도록 이 메서드를 단일 출처로 둡니다. + * + * @param string $doc 문서 상대 경로 + * @param string $type 확장 유형 + * @return array 필수 섹션 목록 + */ + /** + * 선언형 표면(`AbstractModule`/`AbstractPlugin` 의 getter)을 읽어야 렌더되는 블록 키. + * + * 이 목록의 블록은 표면 수집이 실패했을 때 "없음" 이 아니라 "확인하지 못함" 으로 + * 렌더된다. 판정은 `renderBlock()` 한 곳에서만 하며, 개별 렌더러는 가드를 갖지 않는다 + * — 가드를 흩어 놓으면 새 렌더러가 조용히 빠지고 그 누락이 "0개" 로 보인다. + * + * `stats` 는 수치를 남기고 경고를 덧붙이는 형태라 별도 취급한다(`renderStats`). + * `hooks-published` / `hooks-subscribed` 는 선언 외에 소스 스캔 결과도 실려 있어 + * 통째로 대체하면 읽어낸 사실까지 버리므로, 빈 결과일 때만 사유를 갈라 적는다. + */ + /** + * 표면을 읽지 못했음을 알리는 두 통지가 공유하는 판정 어구. + * + * 통지는 둘이다 — 표면 전체를 못 읽은 경우(`surfaceUnavailable`)와 개별 getter 만 + * 던진 경우(`surfaceErrorsNotice`). 코어 인덱스 스캐너는 집계 블록에서 이 어구를 + * 찾아 수치를 "점검 불가" 로 가르는데, 두 문장이 서로 다른 어구로 시작하면 한쪽만 + * 잡힌다. 실제로 후자가 스캐너에 잡히지 않아 `라우트 0` 이 확장의 사실로 인덱스에 + * 실릴 수 있었다 — 두 문장이 이 상수를 함께 쓰게 해서 갈라질 자리를 없앤다. + * + * 프로세스 경계를 넘는 리터럴이므로 스캐너와의 일치는 계약 테스트가 잠근다. + */ + public const SURFACE_NOTICE_MARKER = '**읽지 못했다**는 뜻입니다'; + + /** + * 세지 못한 지표의 표시 문자열. + * + * 수집기는 셀 수 없는 지표를 `null` 로 올립니다. 그 자리에 0 을 넣으면 "없다" 는 + * 사실 주장이 되고, 코어 문서 인덱스는 그 0 을 실측으로 옮깁니다 — 배지와 콘솔이 + * 같은 문자열을 쓰도록 여기 한 곳에 둡니다. + */ + public const STAT_UNMEASURED = '확인 못함'; + + private const SURFACE_DEPENDENT_BLOCKS = [ + 'requirements', + 'extension-points-summary', + 'listeners', + 'assets', + 'layout-extensions', + 'middleware', + 'channels', + 'schedules', + 'notifications', + 'settings-schema', + 'settings-summary', + 'permissions', + 'menus', + 'routes', + ]; + + /** + * 표면에 의존하지만 본문을 통째로 대체하지는 않는 블록 키. + * + * `stats` 는 수치를, `hooks-*` 는 소스 스캔 결과를 함께 실으므로 표면 실패에도 + * 읽어낸 사실을 남긴다. 다만 **사유는 붙어야 한다** — 붙지 않으면 `getHooks()` 가 + * 던졌을 때 선언 훅 전량이 표에서 빠진 채 "발행 훅 N종" 이 경고 없이 실린다. + * 세 키를 여기 모아 두어 통지 대상이 `renderBlock()` 한 곳에서만 정해지게 한다. + */ + private const SURFACE_NOTICE_EXTRA_BLOCKS = [ + 'stats', + 'hooks-published', + 'hooks-subscribed', + ]; + + public static function sectionsFor(string $doc, string $type): array + { + $meta = self::DOCUMENTS[$doc] ?? null; + if ($meta === null) { + return []; + } + + $overrides = $meta['sectionOverrides'][$type] ?? []; + + // 절 ↔ 블록을 키로 묶은 문서는 `sections` 를 따로 두지 않는다 — 두 벌을 두면 + // 한쪽만 고쳐도 게이트가 초록인 채로 골격이 어긋난다. + $sections = $meta['sections'] ?? array_keys($meta['blocks']); + + return array_map( + static fn (string $section): string => $overrides[$section] ?? $section, + $sections, + ); + } + + /** + * 문서의 절과 자동 생성 블록이 키로 짝지어져 있는지 판정합니다. + * + * @param string $doc 문서 상대 경로 + * @return bool 연관 배열이면 true + */ + public static function pairsSectionsWithBlocks(string $doc): bool + { + $blocks = self::DOCUMENTS[$doc]['blocks'] ?? []; + + return $blocks !== [] && ! array_is_list($blocks); + } + + /** + * 문서가 담아야 하는 자동 생성 블록 키 목록을 반환합니다. + * + * `DOCUMENTS[$doc]['blocks']` 는 문서에 따라 목록이거나 `절 => 블록` 연관 배열입니다. + * 소비자가 그 형태를 알 필요가 없도록 이 접근자가 항상 목록으로 돌려줍니다. + * + * @param string $doc 문서 상대 경로 + * @return array 블록 키 목록 + */ + public static function blocksFor(string $doc): array + { + return array_values(self::DOCUMENTS[$doc]['blocks'] ?? []); + } + + /** + * 문서에 그 절의 **헤딩**이 있는지 판정합니다. + * + * 절 이름을 단순 부분문자열로 찾으면 이 축은 사실상 실패할 수 없습니다 — 절 이름이 + * `모델` · `테이블` · `Enum` · `구독 훅` · `미들웨어` · `스케줄` · `레이아웃` · `문서` + * 처럼 같은 문서의 자동 생성 표 헤더에 필연적으로 등장하는 낱말이고, README 는 자동 + * 생성 인라인 목차가 12개 절 이름을 전부 담기 때문입니다. 헤딩을 통째로 지워도 + * 통과하므로 "검사했다" 와 "검사하지 못했다" 가 구분되지 않습니다. + * + * @param string $content 문서 본문 + * @param string $section 절 이름 (헤딩 접두 `#` 없이) + * @return bool 헤딩 존재 여부 + */ + public static function hasSection(string $content, string $section): bool + { + // PCRE 의 \h 는 CR 을 포함하지 않는다 — CRLF 문서는 줄 끝이 CR 이라 + // 줄끝 앵커가 매치되지 않아 **모든 헤딩을 놓친다**(있는 절을 없다고 보고). + // 같은 결함군을 generate-docs-index.cjs 에서 이미 겪었으므로 여기서도 줄 끝 CR 을 허용한다. + $pattern = '/^[^\S\r\n]*#{1,6}[^\S\r\n]+'.preg_quote($section, '/').'[^\S\r\n]*\r?$/mu'; + + return preg_match($pattern, $content) === 1; + } + + /** + * 미채움 마커 5종을 반환합니다. + * + * @return array 마커 리터럴 + */ + public static function todoMarkers(): array + { + return [ + self::TODO_INTENT, + self::TODO_FLOW, + self::TODO_FORBIDDEN, + self::TODO_USAGE, + self::TODO_TROUBLESHOOTING, + ]; + } + + /** + * 자동 생성 블록을 마커로 감쌉니다. + * + * @param string $key 블록 키 + * @param string $body 블록 본문 + * @return string 마커를 포함한 블록 + */ + public static function wrap(string $key, string $body): string + { + return self::startMarker($key)."\n".trim($body)."\n".self::endMarker($key); + } + + /** + * 블록 시작 마커를 만듭니다. + * + * @param string $key 블록 키 + * @return string 시작 마커 + */ + public static function startMarker(string $key): string + { + return self::GEN_PREFIX.$key.' START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->'; + } + + /** + * 블록 종료 마커를 만듭니다. + * + * @param string $key 블록 키 + * @return string 종료 마커 + */ + public static function endMarker(string $key): string + { + return self::GEN_PREFIX.$key.' END -->'; + } + + /** + * 문서에 존재하는 자동 생성 블록 키를 찾습니다. + * + * @param string $content 문서 내용 + * @return array 블록 키 목록 + */ + public static function presentBlockKeys(string $content): array + { + if (! preg_match_all('//s'; + $endPattern = '//s'; + + if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) { + $missing[] = $key; + + continue; + } + + $startPos = (int) $sm[0][1]; + $searchFrom = $startPos + strlen($sm[0][0]); + + if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $searchFrom)) { + $missing[] = $key; + + continue; + } + + $endPos = (int) $em[0][1]; + $endLen = strlen($em[0][0]); + + $content = substr($content, 0, $startPos) + .$start."\n".trim($body)."\n".self::endMarker($key) + .substr($content, $endPos + $endLen); + + $replaced[] = $key; + } + + return [ + 'content' => $content, + 'replaced' => $replaced, + 'missing' => $missing, + 'unchanged' => $content === $original, + ]; + } + + /** + * 확장의 모든 자동 생성 블록 본문을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return array 블록 키 => 본문 + */ + public function renderBlocks(array $ctx): array + { + $keys = []; + foreach (self::documentsForType($ctx['record']['type']) as $doc) { + foreach (self::blocksFor($doc) as $key) { + $keys[$key] = true; + } + } + + $bodies = []; + foreach (array_keys($keys) as $key) { + $bodies[$key] = $this->renderBlock($key, $ctx); + } + + return $bodies; + } + + /** + * 단일 자동 생성 블록 본문을 렌더합니다. + * + * @param string $key 블록 키 + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 본문 + */ + public function renderBlock(string $key, array $ctx): string + { + // 선언형 표면에 의존하는 블록은 여기 한 곳에서 갈린다. + // + // 가드를 렌더러마다 손으로 달면 새 렌더러가 추가될 때마다 새는데, 결과가 "0개" · + // "없습니다" 라 이상으로 보이지 않는다. 실제로 확장점 요약 · 요구 사항 · 리스너 · + // 에셋 4개가 그렇게 빠져 있었고, 같은 실행이 만든 상세 문서는 "확인하지 못했습니다" + // 라 한 산출물 안에서 두 문서가 반대되는 사실을 말하고 있었다. + $surfaceDependent = in_array($key, self::SURFACE_DEPENDENT_BLOCKS, true); + + if ($surfaceDependent && ($unavailable = $this->surfaceUnavailable($ctx)) !== null) { + return $unavailable; + } + + $body = $this->renderBlockBody($key, $ctx); + + // 본문을 통째로 대체하지 않는 블록(수치·훅 표)에도 사유는 붙어야 한다. + // + // 표면 실패는 두 갈래다 — 진입 클래스를 통째로 읽지 못한 경우(`surfaceUnavailable`)와 + // 클래스는 읽고 개별 getter 가 던진 경우(`surfaceErrorsNotice`). 뒤쪽만 배선하면 + // 앞쪽에서 `stats` 는 수치를, 훅 표는 "선언에 없어 소스에서 자동 감지" 를 경고 없이 + // 싣고, 코어 인덱스 스캐너는 그 수치를 실측으로 옮긴다. 두 갈래를 같은 자리에서 고른다. + $noticeEligible = $surfaceDependent + || in_array($key, self::SURFACE_NOTICE_EXTRA_BLOCKS, true); + + // 렌더러가 자체 인라인 가드로 이미 사유를 실었으면 겹쳐 붙이지 않는다. + $notice = $noticeEligible && ! str_contains($body, self::SURFACE_NOTICE_MARKER) + ? ($this->surfaceUnavailable($ctx) ?? $this->surfaceErrorsNotice($ctx)) + : null; + + if ($notice !== null) { + $body .= ' + +'.$notice; + } + + return $body; + } + + /** + * 블록 본문을 렌더합니다 (표면 가용성 판정은 호출자가 담당). + * + * @param string $key 블록 키 + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderBlockBody(string $key, array $ctx): string + { + return match ($key) { + 'badges' => $this->renderBadges($ctx), + 'requirements' => $this->renderRequirements($ctx), + 'install' => $this->renderInstall($ctx), + 'integrations' => $this->renderIntegrations($ctx), + 'docs-index' => $this->renderDocsIndex($ctx), + 'directory-map' => $this->renderDirectoryMap($ctx), + 'extension-points-summary' => $this->renderExtensionPointsSummary($ctx), + 'test-commands' => $this->renderTestCommands($ctx), + 'stats' => $this->renderStats($ctx), + 'doc-toc' => $this->renderDocToc($ctx), + 'hooks-published' => $this->renderHooksPublished($ctx), + 'hooks-subscribed' => $this->renderHooksSubscribed($ctx), + 'listeners' => $this->renderListeners($ctx), + 'layout-extensions' => $this->renderLayoutExtensions($ctx), + 'middleware' => $this->renderMiddleware($ctx), + 'channels' => $this->renderChannels($ctx), + 'schedules' => $this->renderSchedules($ctx), + 'notifications' => $this->renderNotifications($ctx), + 'models' => $this->renderModels($ctx), + 'tables' => $this->renderTables($ctx), + 'migrations' => $this->renderMigrations($ctx), + 'enums' => $this->renderEnums($ctx), + 'repositories' => $this->renderRepositories($ctx), + 'settings-schema' => $this->renderSettingsSchema($ctx), + 'settings-summary' => $this->renderSettingsSummary($ctx), + 'permissions' => $this->renderPermissions($ctx), + 'menus' => $this->renderMenus($ctx), + 'routes' => $this->renderRoutes($ctx), + 'dependencies' => $this->renderDependencies($ctx), + 'layouts' => $this->renderLayouts($ctx), + 'handlers' => $this->renderHandlers($ctx), + 'frontend-entry' => $this->renderFrontendEntry($ctx), + 'assets' => $this->renderAssets($ctx), + 'components' => $this->renderComponents($ctx), + 'layout-map' => $this->renderLayoutMap($ctx), + default => $this->none('알 수 없는 블록 키: '.$key), + }; + } + + // ----------------------------------------------------------------------- + // 블록 렌더러 + // ----------------------------------------------------------------------- + + /** + * README 히어로 배지를 렌더합니다. + * + * 값은 전부 manifest 에서 옵니다. 정적 이미지 배지라 브라우저가 화면을 그리기 위해 + * 도달해야 하는 구동 자산이 아닙니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderBadges(array $ctx): string + { + $record = $ctx['record']; + $manifest = $record['manifest']; + + $badges = []; + $badges[] = $this->badge('version', (string) ($manifest['version'] ?? '-'), '0066FF'); + $badges[] = $this->badge('type', ExtensionInventory::typeLabel($record['type']), '555555'); + + $core = $ctx['deps']['coreVersion'] ?? null; + if ($core !== null) { + $badges[] = $this->badge('G7', $core, '1F883D'); + } + + $license = $manifest['license'] ?? null; + if (is_string($license) && $license !== '') { + $badges[] = $this->badge('license', $license, '8250DF'); + } + + foreach ($ctx['deps']['requires'] as $dep) { + $badges[] = $this->badge('requires', $dep['id'], 'BF8700'); + } + + return '

'."\n ".implode("\n ", $badges)."\n".'

'; + } + + /** + * shields.io 정적 배지 마크다운을 만듭니다. + * + * @param string $label 라벨 + * @param string $message 값 + * @param string $color 색상 (hex, `#` 없음) + * @return string 이미지 마크다운 + */ + private function badge(string $label, string $message, string $color): string + { + $encode = static fn (string $s): string => rawurlencode(str_replace(['-', '_'], ['--', '__'], $s)); + + return sprintf( + '%s %s', + $encode($label), + $encode($message), + $color, + $this->escape($label), + $this->escape($message), + ); + } + + /** + * 요구 사항 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderRequirements(array $ctx): string + { + $rows = []; + $rows[] = ['G7 코어', $this->code($ctx['deps']['coreVersion'] ?? '(제약 없음)')]; + $rows[] = ['PHP', $this->code($this->composerPhp($ctx) ?? '^8.2')]; + + foreach ($ctx['deps']['requires'] as $dep) { + $label = ExtensionInventory::typeLabel(rtrim($dep['type'], 's')); + $rows[] = ["의존 {$label}", $this->code($dep['id']).' '.$this->code($dep['constraint'])]; + } + + $hosts = $ctx['surface']['values']['getTrustedScriptHosts'] ?? []; + if (is_array($hosts) && $hosts !== []) { + $rows[] = ['외부 스크립트 호스트', implode(', ', array_map(fn ($h) => $this->code((string) $h), $hosts))]; + } + + return $this->table(['항목', '값'], $rows); + } + + /** + * 설치 절차를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderInstall(array $ctx): string + { + $record = $ctx['record']; + $type = $record['type']; + $id = $record['id']; + + $lines = []; + $lines[] = '```bash'; + $lines[] = '# 번들 설치 (코어에 동봉된 소스에서 설치)'; + $lines[] = "php artisan {$type}:install {$id}"; + $lines[] = ''; + $lines[] = '# 활성화'; + $lines[] = "php artisan {$type}:activate {$id}"; + $lines[] = ''; + $lines[] = '# 업데이트 (번들 소스 기준 강제 반영)'; + $lines[] = "php artisan {$type}:update {$id} --force"; + $lines[] = '```'; + + $github = $record['manifest']['github_url'] ?? null; + if (is_string($github) && $github !== '') { + $lines[] = ''; + $lines[] = '저장소: '.$github; + } + + return implode("\n", $lines); + } + + /** + * 다른 확장과의 연동(정방향·역방향)을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderIntegrations(array $ctx): string + { + $sections = []; + + $requires = $ctx['deps']['requires']; + $sections[] = '**이 확장이 의존하는 확장**'; + $sections[] = ''; + $sections[] = $requires === [] + ? '없음 — 코어만으로 동작합니다.' + : $this->table( + ['확장', '유형', '버전 제약', '번들'], + array_map(fn (array $d): array => [ + $this->code($d['id']), + ExtensionInventory::typeLabel(rtrim($d['type'], 's')), + $this->code($d['constraint']), + $d['bundled'] ? '✅' : '—', + ], $requires), + ); + + $requiredBy = $ctx['deps']['requiredBy']; + $sections[] = ''; + $sections[] = '**이 확장에 의존하는 확장** (이 확장을 비활성화하면 함께 영향을 받습니다)'; + $sections[] = ''; + $sections[] = $requiredBy === [] + ? '없음.' + : $this->table( + ['확장', '유형', '요구 버전'], + array_map(fn (array $d): array => [ + $this->code($d['id']), + ExtensionInventory::typeLabel($d['type']), + $this->code($d['constraint']), + ], $requiredBy), + ); + + return implode("\n", $sections); + } + + /** + * 문서 목차를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderDocsIndex(array $ctx): string + { + $record = $ctx['record']; + $rows = []; + + foreach (self::documentsForType($record['type']) as $doc) { + if ($doc === 'AGENTS.md' || $doc === 'README.md') { + continue; + } + + $exists = is_file($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc)); + $rows[] = [ + $exists ? "[{$doc}]({$doc})" : $this->code($doc), + $this->documentPurpose($doc), + $exists ? '✅' : '미작성', + ]; + } + + if (is_dir($record['docsPath'].DIRECTORY_SEPARATOR.'api')) { + $rows[] = ['[docs/api/](docs/api/README.md)', 'API 레퍼런스 (엔드포인트별 파라미터·응답 필드)', '✅']; + } + + // 다른 행은 전부 `is_file()` 로 판정하는데 이 행만 무조건 ✅ 였다 — CHANGELOG 가 + // 없는 확장(21번째 시나리오)에서 문서가 "있음" 이라고 거짓말하고 링크가 404 가 된다. + $hasChangelog = is_file($record['path'].DIRECTORY_SEPARATOR.'CHANGELOG.md'); + $rows[] = [ + $hasChangelog ? '[CHANGELOG.md](CHANGELOG.md)' : $this->code('CHANGELOG.md'), + '변경 이력', + $hasChangelog ? '✅' : '미작성', + ]; + + return $this->table(['문서', '내용', '상태'], $rows); + } + + /** + * `docs/README.md` 의 목차를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderDocToc(array $ctx): string + { + $record = $ctx['record']; + $rows = []; + + foreach (self::documentsForType($record['type']) as $doc) { + if (! str_starts_with($doc, 'docs/') || $doc === 'docs/README.md') { + continue; + } + + $name = basename($doc); + $exists = is_file($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc)); + $rows[] = [ + $exists ? "[{$name}]({$name})" : $this->code($name), + $this->documentPurpose($doc), + ]; + } + + if (is_dir($record['docsPath'].DIRECTORY_SEPARATOR.'api')) { + $rows[] = ['[api/](api/README.md)', 'API 레퍼런스']; + } + + // 진입점 링크도 존재 확인 후 건다 — 없는 파일로 링크하면 404 가 된다. + foreach ([['AGENTS.md', '에이전트·확장개발자 진입점'], ['README.md', '사람(도입검토자·운영자) 진입점']] as [$name, $purpose]) { + $exists = is_file($record['path'].DIRECTORY_SEPARATOR.$name); + $rows[] = [$exists ? "[../{$name}](../{$name})" : $this->code("../{$name}"), $purpose]; + } + + return $this->table(['문서', '내용'], $rows); + } + + /** + * 문서의 용도 설명을 반환합니다. + * + * @param string $doc 문서 상대 경로 + * @return string 용도 + */ + private function documentPurpose(string $doc): string + { + return match ($doc) { + 'docs/README.md' => '문서 통합 목차와 실측 집계', + 'docs/architecture.md' => '설계 의도·계층 지도·디렉토리 맵', + 'docs/extension-points.md' => '발행/구독 훅·미들웨어·채널·스케줄', + 'docs/data-model.md' => '모델·소유 테이블·마이그레이션·Enum', + 'docs/settings.md' => '설정 스키마·권한·메뉴·라우트·의존 관계', + 'docs/frontend.md' => '레이아웃·액션 핸들러·전역 진입점·에셋', + 'docs/components.md' => '템플릿이 제공하는 컴포넌트', + 'docs/layouts.md' => '레이아웃 목록과 라우트 매핑', + 'docs/handlers.md' => '템플릿 전용 핸들러와 부트스트랩', + default => '-', + }; + } + + /** + * 디렉토리 지도를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderDirectoryMap(array $ctx): string + { + $record = $ctx['record']; + $rows = []; + + foreach ($this->directoryCandidates($record['type'], $record['id']) as $path => [$role, $procedure]) { + $abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, rtrim($path, '/')); + if (! file_exists($abs)) { + continue; + } + + $rows[] = [$this->code($path), $role, $procedure]; + } + + return $this->table(['경로', '역할', '수정 시 필요한 절차'], $rows); + } + + /** + * 유형별 디렉토리 후보와 설명을 반환합니다. + * + * @param string $type 확장 유형 + * @param string $id 확장 식별자 + * @return array 경로 => [역할, 절차] + */ + private function directoryCandidates(string $type, string $id): array + { + $updateCmd = "`php artisan {$type}:update {$id} --force`"; + $buildCmd = "`php artisan {$type}:build` → {$updateCmd}"; + + $common = [ + 'CHANGELOG.md' => ['변경 이력', '버전 상향 시 항목 추가 (미기재 시 버전 상향 불가)'], + // 편집기 컴포넌트 선언은 레이아웃 저작자가 읽는 props 계약이다(실측 15파일). + // 자리가 없으면 그 선언을 고쳐도 `docs/components.md` 갱신 요구가 걸리지 않는다. + 'components.json' => ['편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약)', $updateCmd], + // 확장이 셸로 호출하는 외부 실행파일·인증서·WSDL 자리다(실측 6파일, 결제 플러그인). + // 비면 그 기능이 죽는데 코드에는 "bin/ 에 복사하세요" 안내만 있고 문서에는 자리가 없었다. + 'bin/' => ['확장이 실행하는 외부 바이너리·인증서', '교체 시 OS별 파일과 권한을 함께 확인 (비면 해당 기능 정지)'], + 'docs/' => ['개발자 문서', '표면 변경 시 `php artisan ext:docgen` 재실행'], + 'lang/' => ['다국어', '키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화'], + 'custom/' => ['운영자 추가 에셋 자리', '저작자는 두지 않는다 (운영자 소유 — 보존 계층이 덮지 않음)'], + ]; + + if ($type === ExtensionInventory::TYPE_TEMPLATE) { + return [ + 'template.json' => ['manifest (버전 SSoT)', 'version 변경 시 package.json·package-lock.json 동기화'], + 'routes.json' => ['라우트 → 레이아웃 매핑', $updateCmd], + 'layouts/' => ['레이아웃 JSON', $updateCmd.' (빌드 불필요)'], + // 모듈·플러그인의 `resources/extensions/` 와 같은 개념인데 템플릿은 + // 확장 루트 직속에 둔다. 유형 분기를 놓치면 다른 확장 화면에 끼워 넣는 + // 조각을 고쳐도 문서에 반영할 자리가 없다. + 'extensions/' => ['다른 확장 화면에 주입하는 레이아웃 조각', $updateCmd.' (빌드 불필요)'], + 'seo-config.json' => ['SEO 렌더 설정', $updateCmd], + 'src/components/' => ['React 컴포넌트', $buildCmd], + 'src/handlers/' => ['템플릿 전용 액션 핸들러', $buildCmd], + 'dist/' => ['커밋되는 빌드 산출물', '`--production` 으로 재빌드 (sourceMappingURL 잔존 금지)'], + 'editor-spec.json' => ['레이아웃 편집기 스펙', $updateCmd], + 'editor-spec/' => ['분할 편집기 스펙', $updateCmd], + 'tests/' => ['테스트', '변경 범위만 필터 실행'], + ...$common, + ]; + } + + $entry = $type === ExtensionInventory::TYPE_MODULE ? 'module' : 'plugin'; + + return [ + "{$entry}.json" => ['manifest (버전 SSoT)', 'version 변경 시 package.json·package-lock.json·composer.json 동기화'], + "{$entry}.php" => ['진입 클래스 (선언형 표면 SSoT)', '표면 변경 시 `ext:docgen` 재실행 + 코어 최소 버전 검토'], + // 컨트롤러 자리는 두 갈래다. 번들 플러그인 5개(pay_kginicis · pay_nhnkcp · + // pay_nicepayments · message_bizppurio · tosspayments, 49파일)는 `src/Controllers/` + // 를 쓴다. 한 갈래만 적으면 그 5개 문서에서 컨트롤러 행이 통째로 사라져 + // "API 표면 변경 시 `api:docgen` 재실행" 절차가 어디에도 남지 않는다. + // 렌더러가 `file_exists` 로 거르므로 두 행을 함께 두어도 실재하는 쪽만 나온다. + 'src/Http/Controllers/' => ['컨트롤러', 'API 표면 변경 시 `api:docgen` 재실행'], + 'src/Controllers/' => ['컨트롤러', 'API 표면 변경 시 `api:docgen` 재실행'], + 'src/Http/Requests/' => ['FormRequest (검증 SSoT)', '검증 규칙은 Service 가 아니라 여기에 둔다'], + 'src/Http/Resources/' => ['API 리소스', '목록 응답은 화면이 실제로 그리는 것만 싣는다'], + 'src/Services/' => ['비즈니스 로직', 'Repository 인터페이스 주입 (구체 클래스 금지)'], + 'src/Repositories/' => ['데이터 접근', '목록 쿼리는 컬럼 프루닝·정렬 화이트리스트 확인'], + 'src/Models/' => ['Eloquent 모델', '스키마 변경 시 마이그레이션 + 업그레이드 스텝 동반'], + 'src/Listeners/' => ['훅 리스너', 'Repository 경유 (Model·DB 파사드 직접 접근 금지)'], + 'src/Enums/' => ['상태·타입·분류', '문자열 리터럴 대신 Enum 을 SSoT 로 둔다'], + 'src/routes/' => ['라우트', '모든 라우트에 `name()` 필수'], + 'src/lang/' => ['백엔드 다국어', 'ko·en 동시 반영 + 번들 ja 팩 동기화'], + 'database/migrations/' => ['마이그레이션', '한국어 comment + `down()` 필수, 기설치본은 업그레이드 스텝으로 백필'], + 'database/seeders/' => ['시더', 'composer autoload 등록 + `extension:update-autoload`'], + 'upgrades/' => ['업그레이드 스텝', 'DB·설정 구조 변경 시 작성 (모듈/플러그인 전용)'], + 'resources/layouts/' => ['레이아웃 JSON', $updateCmd.' (빌드 불필요)'], + // 모듈·플러그인의 라우트 → 레이아웃 매핑은 `resources/routes.json`(단일) 또는 + // `resources/routes/*.json`(분할)에 있다(실측 7파일). 여기 자리가 없으면 + // "라우트를 고쳤는데 문서에 반영할 곳이 없다" 가 된다 — editor-spec 과 같은 형태다. + 'resources/routes.json' => ['라우트 → 레이아웃 매핑', $updateCmd], + 'resources/routes/' => ['라우트 → 레이아웃 매핑 (분할)', $updateCmd], + 'resources/js/' => ['프론트 엔트리·핸들러', $buildCmd], + 'resources/extensions/' => ['다른 확장 레이아웃에 주입하는 조각', $updateCmd], + // 모듈·플러그인도 편집기 스펙을 소유한다(실측 10개). 여기 자리가 없으면 + // "editor-spec 을 고쳤는데 문서에 반영할 곳이 없다" 가 된다. + 'editor-spec.json' => ['레이아웃 편집기 스펙', $updateCmd], + 'editor-spec/' => ['분할 편집기 스펙', $updateCmd], + 'dist/' => ['커밋되는 빌드 산출물', '`--production` 으로 재빌드 (sourceMappingURL 잔존 금지)'], + 'config/' => ['확장 config', '설정 기본값은 settings 스키마와 어긋나지 않게'], + 'tests/' => ['테스트', '변경 범위만 필터 실행'], + ...$common, + ]; + } + + /** + * AGENTS.md 확장점 요약을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderExtensionPointsSummary(array $ctx): string + { + $hooks = $ctx['hooks']; + $surface = $ctx['surface']['values']; + $frontend = $ctx['frontend']; + + $rows = [ + ['발행 훅', (string) count($hooks['published']).'개', $this->docLink($ctx, 'docs/extension-points.md', '발행 훅')], + ['구독 훅', (string) count($hooks['subscribed']).'개', $this->docLink($ctx, 'docs/extension-points.md', '구독 훅')], + ['훅 리스너', (string) count($hooks['listeners']).'개', $this->docLink($ctx, 'docs/extension-points.md', '훅 리스너')], + ['레이아웃 확장', (string) count($frontend['layoutExtensions']).'개', $this->docLink($ctx, 'docs/extension-points.md', '레이아웃 확장')], + ['미들웨어', (string) $this->countOf($surface['getMiddleware'] ?? []).'개', $this->docLink($ctx, 'docs/extension-points.md', '미들웨어')], + ['브로드캐스트 채널', (string) $this->countOf($surface['getChannels'] ?? []).'개', $this->docLink($ctx, 'docs/extension-points.md', '브로드캐스트 채널')], + ['스케줄', (string) $this->countOf($surface['getSchedules'] ?? []).'개', $this->docLink($ctx, 'docs/extension-points.md', '스케줄')], + ['알림 정의', (string) $this->countOf($surface['getNotificationDefinitions'] ?? []).'개', $this->docLink($ctx, 'docs/extension-points.md', '알림 정의')], + ]; + + if ($ctx['record']['type'] === ExtensionInventory::TYPE_TEMPLATE) { + $rows = [ + ['제공 컴포넌트', (string) $frontend['components']['total'].'개', $this->docLink($ctx, 'docs/components.md', '제공 컴포넌트')], + ['레이아웃', (string) count($frontend['layouts']).'개', $this->docLink($ctx, 'docs/layouts.md', '레이아웃 목록')], + ['전용 핸들러', (string) count($frontend['handlers']['names']).'개', $this->docLink($ctx, 'docs/handlers.md', '템플릿 전용 핸들러')], + ]; + } + + return $this->table(['확장점', '수', '상세'], $rows); + } + + /** + * 테스트 실행 명령을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderTestCommands(array $ctx): string + { + $tests = $ctx['tests']; + $lines = []; + + $rows = [ + ['PHPUnit', (string) $tests['phpunit']['count'].'개', $tests['phpunit']['count'] > 0 ? $this->code($ctx['record']['relPath'].'/tests') : '—'], + ['Vitest', (string) $tests['vitest']['files'].'개', $tests['vitest']['config'] !== null ? $this->code($tests['vitest']['config']) : '—'], + ['Playwright', (string) $tests['playwright']['count'].'개', $tests['playwright']['count'] > 0 ? $this->code('tests/Playwright') : '—'], + ['시나리오 매니페스트', (string) count($tests['scenarios']).'개', $tests['scenarios'] === [] ? '—' : $this->code('tests/scenarios')], + ]; + + $lines[] = $this->table(['종류', '개수', '위치'], $rows); + + if ($tests['testCaseBase'] !== null) { + $lines[] = ''; + $lines[] = '기저 TestCase: '.$this->code($tests['testCaseBase']).' — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).'; + } + + if ($tests['commands'] !== []) { + $lines[] = ''; + $lines[] = '```bash'; + foreach ($tests['commands'] as $command) { + $lines[] = '# '.$command['label'].' ('.$command['shell'].')'; + $lines[] = $command['command']; + $lines[] = ''; + } + $lines[] = '```'; + $lines[] = ''; + $lines[] = '무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.'; + } + + return implode("\n", $lines); + } + + /** + * `docs/README.md` 집계 배지 라인을 렌더합니다. + * + * 코어 문서 인덱스 생성기가 이 라인을 읽어 확장 문서 표를 채우므로, 형식이 계약입니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderStats(array $ctx): string + { + $stats = self::statsOf($ctx); + + $parts = []; + $unmeasured = []; + + foreach ($stats as $label => $value) { + if ($value === null) { + $unmeasured[] = $label; + $parts[] = "**{$label}**: ".self::STAT_UNMEASURED; + + continue; + } + + $parts[] = "**{$label}**: {$value}"; + } + + $line = implode(' · ', $parts); + + // 선언형 표면을 못 읽었으면 0 은 실측이 아니다 — 수치만 내보내면 "없음" 으로 읽힌다. + if (($unavailable = $this->surfaceUnavailable($ctx)) !== null) { + return $line."\n\n".$unavailable; + } + + // 표면과 무관하게 개별 지표를 세지 못하는 경우가 있다 — 템플릿은 선언형 표면을 + // 갖지 않아 위 안내의 대상이 아니고(`surfaceUnavailable` 이 null 을 돌려준다), + // 그 대신 `routes.json` 을 읽지 못하면 여기서 단서를 남겨야 한다. 남기지 않으면 + // 코어 인덱스 스캐너가 이 블록을 실측으로 읽어 세지 못한 수치를 사실로 옮긴다. + if ($unmeasured !== []) { + $names = array_map(static fn (string $l): string => '`'.$l.'`', $unmeasured); + + $line .= "\n\n".$this->none(sprintf( + '아래 수치를 세지 못했습니다 (%s). 항목이 없다는 뜻이 아니라 %s.', + implode(' · ', $names), + self::SURFACE_NOTICE_MARKER, + )); + } + + return $line; + } + + /** + * 집계 배지에 쓰이는 실측 수치를 반환합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return array 라벨 => 수치 (세지 못한 지표는 null) + */ + public static function statsOf(array $ctx): array + { + return [ + '훅 수' => count($ctx['hooks']['published']), + '구독 훅 수' => count($ctx['hooks']['subscribed']), + // 주소를 어디에 선언하는지가 유형마다 다르다. 모듈·플러그인은 `getRoutes()` 가 + // 가리키는 라우트 파일이고, 템플릿은 `routes.json` 이다. 선언형 표면만 보면 + // 템플릿은 구조적으로 항상 0 이 되는데(실측 40·29) 템플릿에는 "확인하지 못함" + // 안내도 붙지 않아 그 0 이 단서 없이 사실로 읽힌다. + // 템플릿은 셀 수 없으면 `null` 이 온다 — `?? 0` 으로 받으면 수집기가 구분해 + // 올린 "읽지 못함" 이 이 자리에서 "주소 0개" 라는 사실 주장으로 바뀐다. + '라우트 수' => $ctx['record']['type'] === 'template' + ? ($ctx['frontend']['routeCount'] === null + ? null + : (int) $ctx['frontend']['routeCount']) + : (int) ($ctx['surface']['endpoints'] ?? 0), + '모델 수' => count($ctx['data']['models']), + '테이블 수' => count($ctx['data']['tables']), + '마이그레이션 수' => count($ctx['data']['migrations']), + '레이아웃 수' => count($ctx['frontend']['layouts']), + '핸들러 수' => count($ctx['frontend']['handlers']['names']), + ]; + } + + /** + * 발행 훅 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderHooksPublished(array $ctx): string + { + $hooks = $ctx['hooks']; + + // 표가 비어 있어도 호출 지점이 있으면 "발행하지 않는다" 가 아니다 — 이름을 정적으로 + // 읽지 못했을 뿐이다. 이 두 상태를 같은 문장으로 보고하면 훅을 발행하는 확장이 + // 발행하지 않는다고 문서화된다. + if ($hooks['published'] === []) { + if ($hooks['publishedSites'] > 0) { + return $this->none(sprintf( + '훅 발행 호출이 %d곳 있으나 훅 이름을 확인하지 못했습니다 — 이름이 상수·변수로 조립되어 ' + .'정적으로 읽을 수 없습니다. 이 확장의 진입 클래스에 `getHooks()` 로 발행 훅을 선언하면 ' + .'이 표가 채워집니다.', + $hooks['publishedSites'], + )); + } + + // 발행 훅의 1차 출처는 진입 클래스의 `getHooks()` 선언이다. 그 클래스를 읽지 + // 못했으면 남는 것은 리터럴 스캔 결과뿐이고, 스캔은 이름 조립형 발행을 원리상 + // 읽지 못한다 — 그 조합에서 "발행하지 않습니다" 는 거짓이 된다. + if (($unavailable = $this->surfaceUnavailable($ctx)) !== null) { + return $unavailable; + } + + return $this->none('이 확장은 훅을 발행하지 않습니다.'); + } + + $rows = []; + foreach ($hooks['published'] as $hook) { + $sites = $hook['sites']; + + if ($sites === []) { + // 선언에는 있으나 리터럴 호출이 잡히지 않은 훅 — 이름 조립형 발행이다. + $where = '선언 (호출 위치 미확인)'; + } else { + $extra = count($sites) > 1 ? ' 외 '.(count($sites) - 1).'곳' : ''; + $where = $this->code($sites[0]['file'].':'.$sites[0]['line']).$extra; + } + + $rows[] = [ + $this->code($hook['name']), + $hook['type'], + // 자동 생성 블록 안이라 `TODO:` 마커를 쓰지 않는다 — 손으로 채워도 다음 + // 재생성에서 지워지고, 채울 수 없는 자리가 미채움 잔량만 부풀린다. + $hook['description'] ?? '—', + $where, + ]; + } + + $notes = [sprintf( + '발행 훅 %d종 / 호출 지점 %d곳.', + count($hooks['published']), + $hooks['publishedSites'], + )]; + + if (($hooks['publishedUndeclared'] ?? 0) > 0) { + $notes[] = sprintf( + '이 중 %d종은 `getHooks()` 선언에 없어 소스에서 자동 감지한 것입니다 — 선언에 추가하면 유형과 설명이 함께 실립니다.', + $hooks['publishedUndeclared'], + ); + } + + if ($hooks['publishedDynamic'] > 0) { + $notes[] = sprintf( + '훅 이름이 상수·변수로 조립된 호출이 %d곳 있어 호출 위치가 표에 다 실리지 않을 수 있습니다.', + $hooks['publishedDynamic'], + ); + } + + return implode(' ', $notes)."\n\n".$this->table(['훅 이름', '유형', '설명', '발행 위치'], $rows); + } + + /** + * 구독 훅 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderHooksSubscribed(array $ctx): string + { + $subscribed = $ctx['hooks']['subscribed']; + + if ($subscribed === []) { + // 구독 훅은 `getHookListeners()` 선언과 리스너 스캔을 합쳐 만든다 — 진입 클래스를 + // 읽지 못한 상태에서 "구독하지 않습니다" 는 확인 결과가 아니라 미확인이다. + if (($unavailable = $this->surfaceUnavailable($ctx)) !== null) { + return $unavailable; + } + + return $this->none('이 확장은 훅을 구독하지 않습니다.'); + } + + $rows = []; + foreach ($subscribed as $hook) { + $rows[] = [ + $this->code($hook['name']), + $hook['typeDeclared'] ? $hook['type'] : $hook['type'].' (미선언)', + $this->code($this->shortName($hook['listener'])), + $hook['method'] !== null ? $this->code($hook['method']) : '-', + $hook['priority'] !== null ? (string) $hook['priority'] : '-', + ]; + } + + return $this->table(['훅 이름', '유형', '리스너', '메서드', '우선순위'], $rows); + } + + /** + * 훅 리스너 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderListeners(array $ctx): string + { + $listeners = $ctx['hooks']['listeners']; + + if ($listeners === []) { + return $this->none('훅 리스너가 없습니다.'); + } + + $registered = $ctx['surface']['values']['getHookListeners'] ?? []; + $registeredShort = []; + if (is_array($registered)) { + foreach ($registered as $class) { + if (is_string($class)) { + $registeredShort[$this->shortName($class)] = true; + } + } + } + + $rows = []; + foreach ($listeners as $listener) { + $rows[] = [ + $this->code($listener['shortClass']), + (string) count($listener['hooks']).'개', + isset($registeredShort[$listener['shortClass']]) ? '명시 등록' : '자동 발견', + $listener['implementsContract'] ? '✅' : '❌', + $this->code($listener['relFile']), + ]; + } + + return $this->table(['리스너', '구독 훅', '등록 방식', 'HookListenerInterface', '파일'], $rows); + } + + /** + * 레이아웃 확장 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderLayoutExtensions(array $ctx): string + { + $files = $ctx['frontend']['layoutExtensions']; + $declared = $ctx['surface']['values']['getLayoutExtensions'] ?? []; + + if ($files === [] && ! is_array($declared)) { + return $this->none('레이아웃 확장이 없습니다.'); + } + + if ($files === [] && $declared === []) { + return $this->none('레이아웃 확장이 없습니다.'); + } + + $rows = []; + foreach ($files as $file) { + $rows[] = [$this->code($file), '다른 확장/템플릿 레이아웃에 주입되는 조각']; + } + + if (is_array($declared)) { + foreach ($declared as $key => $value) { + $rows[] = [$this->code(is_string($key) ? $key : (string) $value), '`getLayoutExtensions()` 선언']; + } + } + + return $this->table(['대상', '설명'], $rows); + } + + /** + * 미들웨어 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderMiddleware(array $ctx): string + { + $middleware = $ctx['surface']['values']['getMiddleware'] ?? []; + + if (! is_array($middleware) || $middleware === []) { + return $this->none('등록하는 미들웨어가 없습니다.'); + } + + $rows = []; + foreach ($middleware as $key => $entry) { + $class = is_array($entry) ? ($entry['class'] ?? $entry['middleware'] ?? null) : $entry; + $targets = is_array($entry) ? ($entry['targets'] ?? null) : null; + + $rows[] = [ + $this->code(is_string($class) ? $this->shortName($class) : (is_string($key) ? $key : '-')), + is_array($targets) ? implode(', ', array_map(fn ($t) => $this->code((string) $t), $targets)) : '-', + is_array($entry) && isset($entry['priority']) ? (string) $entry['priority'] : '-', + ]; + } + + return $this->table(['미들웨어', '부착 대상(targets)', '우선순위'], $rows); + } + + /** + * 브로드캐스트 채널 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderChannels(array $ctx): string + { + $channels = $ctx['surface']['values']['getChannels'] ?? []; + + if (! is_array($channels) || $channels === []) { + return $this->none('등록하는 브로드캐스트 채널이 없습니다.'); + } + + $rows = []; + foreach ($channels as $name => $value) { + $rows[] = [$this->code(is_string($name) ? $name : (string) $value), is_string($name) ? '인가 콜백 등록' : '채널 선언']; + } + + return $this->table(['채널', '비고'], $rows); + } + + /** + * 스케줄 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderSchedules(array $ctx): string + { + $schedules = $ctx['surface']['values']['getSchedules'] ?? []; + + if (! is_array($schedules) || $schedules === []) { + return $this->none('등록하는 스케줄이 없습니다.'); + } + + $rows = []; + foreach ($schedules as $key => $schedule) { + if (! is_array($schedule)) { + $rows[] = [$this->code((string) $key), $this->code((string) $schedule), '-']; + + continue; + } + + $rows[] = [ + $this->code((string) ($schedule['name'] ?? $schedule['command'] ?? $key)), + $this->code((string) ($schedule['expression'] ?? $schedule['cron'] ?? $schedule['frequency'] ?? '-')), + (string) ($schedule['description'] ?? '-'), + ]; + } + + return $this->table(['스케줄', '주기', '설명'], $rows); + } + + /** + * 알림 정의 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderNotifications(array $ctx): string + { + $definitions = $ctx['surface']['values']['getNotificationDefinitions'] ?? []; + + if (! is_array($definitions) || $definitions === []) { + return $this->none('등록하는 알림 정의가 없습니다.'); + } + + $rows = []; + foreach ($definitions as $key => $definition) { + $name = is_string($key) ? $key : (is_array($definition) ? ($definition['key'] ?? $definition['event'] ?? '-') : (string) $definition); + $channels = is_array($definition) ? ($definition['channels'] ?? null) : null; + + $rows[] = [ + $this->code((string) $name), + is_array($channels) ? implode(', ', array_map(fn ($c) => $this->code((string) $c), $channels)) : '-', + ]; + } + + return $this->table(['알림 키', '채널'], $rows); + } + + /** + * 모델 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderModels(array $ctx): string + { + $models = $ctx['data']['models']; + + if ($models === []) { + return $this->none('소유 모델이 없습니다.'); + } + + $rows = []; + foreach ($models as $model) { + $flags = []; + if ($model['softDeletes']) { + $flags[] = 'SoftDeletes'; + } + if ($model['userOverrides']) { + $flags[] = 'HasUserOverrides'; + } + if ($model['searchable']) { + $flags[] = '검색 색인'; + } + + $relations = array_map( + fn (array $r): string => $r['method'].'→'.($r['target'] ?? '?'), + array_slice($model['relations'], 0, 6), + ); + if (count($model['relations']) > 6) { + $relations[] = '외 '.(count($model['relations']) - 6).'개'; + } + + $rows[] = [ + $this->code($model['class']), + $model['table'] !== null ? $this->code($model['table']) : '(규약)', + $model['fillable'] !== null ? (string) $model['fillable'] : '-', + $relations === [] ? '-' : implode(', ', $relations), + $flags === [] ? '-' : implode(', ', $flags), + ]; + } + + return $this->table(['모델', '테이블', 'fillable', '관계', '특성'], $rows); + } + + /** + * 소유 테이블 목록을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderTables(array $ctx): string + { + $tables = $ctx['data']['tables']; + + if ($tables === []) { + return $this->none('소유 테이블이 없습니다.'); + } + + $byTable = []; + foreach ($ctx['data']['models'] as $model) { + if ($model['table'] !== null) { + $byTable[$model['table']][] = $model['class']; + } + } + + $rows = []; + foreach ($tables as $table) { + $rows[] = [ + $this->code($table), + isset($byTable[$table]) ? implode(', ', array_map(fn ($c) => $this->code($c), $byTable[$table])) : '-', + ]; + } + + return $this->table(['테이블', '모델'], $rows); + } + + /** + * 마이그레이션 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderMigrations(array $ctx): string + { + $migrations = $ctx['data']['migrations']; + + if ($migrations === []) { + return $this->none('마이그레이션이 없습니다.'); + } + + $rows = []; + foreach ($migrations as $migration) { + $rows[] = [ + $this->code($migration['file']), + $migration['creates'] === [] ? '-' : implode(', ', array_map(fn ($t) => $this->code($t), $migration['creates'])), + $migration['alters'] === [] ? '-' : implode(', ', array_map(fn ($t) => $this->code($t), $migration['alters'])), + $migration['hasDown'] ? '✅' : '❌', + ]; + } + + return sprintf('마이그레이션 %d개.', count($migrations))."\n\n" + .$this->table(['파일', '생성 테이블', '변경 테이블', 'down()'], $rows); + } + + /** + * Enum 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderEnums(array $ctx): string + { + $enums = $ctx['data']['enums']; + + if ($enums === []) { + return $this->none('Enum 이 없습니다.'); + } + + $rows = []; + foreach ($enums as $enum) { + $cases = array_map(fn (array $c): string => $c['value'] ?? $c['name'], array_slice($enum['cases'], 0, 8)); + if (count($enum['cases']) > 8) { + $cases[] = '외 '.(count($enum['cases']) - 8).'개'; + } + + $rows[] = [ + $this->code($enum['class']), + $enum['backing'] !== null ? $this->code($enum['backing']) : '(pure)', + (string) count($enum['cases']), + $cases === [] ? '-' : implode(', ', array_map(fn ($c) => $this->code((string) $c), $cases)), + ]; + } + + return $this->table(['Enum', 'backing', 'case 수', 'case'], $rows); + } + + /** + * Repository 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderRepositories(array $ctx): string + { + $repositories = $ctx['data']['repositories']; + + if ($repositories === []) { + return $this->none('Repository 가 없습니다.'); + } + + $rows = []; + foreach ($repositories as $repository) { + $rows[] = [ + $this->code($repository['class']), + $repository['isInterface'] ? '인터페이스' : '구현', + $repository['summary'] ?? '-', + ]; + } + + return $this->table(['클래스', '종류', '설명'], $rows); + } + + /** + * 설정 스키마 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderSettingsSchema(array $ctx): string + { + $schema = $ctx['surface']['values']['getSettingsSchema'] ?? []; + $defaultsPath = $ctx['surface']['values']['getSettingsDefaultsPath'] ?? null; + $layout = $ctx['surface']['values']['getSettingsLayout'] ?? null; + + $lines = []; + + if (is_array($schema) && $schema !== []) { + $rows = []; + foreach ($this->flattenSchema($schema) as $key => $meta) { + // label/description 은 다국어 배열일 수 있다 — 문자열 캐스팅하면 경고가 예외로 + // 승격되어 생성 전체가 중단된다. + $label = ExtensionInventory::localized($meta['label'] ?? ''); + if ($label === '') { + $label = ExtensionInventory::localized($meta['description'] ?? ''); + } + + $rows[] = [ + $this->code($key), + $this->scalarLabel($meta['type'] ?? null), + $this->scalar($meta['default'] ?? null), + $label !== '' ? $label : '-', + ]; + } + $lines[] = $this->table(['키', '타입', '기본값', '설명'], $rows); + } else { + $lines[] = $this->none('`getSettingsSchema()` 선언이 없습니다.'); + } + + $extra = []; + if (is_string($defaultsPath) && $defaultsPath !== '') { + $extra[] = '기본값 파일: '.$this->code($this->relativeToExtension($ctx, $defaultsPath)); + } + if (is_string($layout) && $layout !== '') { + $extra[] = '설정 화면 레이아웃: '.$this->code($layout); + } + + if ($extra !== []) { + $lines[] = ''; + $lines[] = implode(' · ', $extra); + } + + return implode("\n", $lines); + } + + /** + * README 의 관리자 설정 요약을 렌더합니다. + * + * 운영자가 읽는 자리이므로 `docs/settings.md` 의 개발자용 스키마 표보다 얕게 — 키·의미· + * 기본값만 둡니다. 템플릿은 관리자 설정 화면을 갖지 않으므로 같은 자리에 제공 컴포넌트 + * 요약을 놓습니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderSettingsSummary(array $ctx): string + { + if ($ctx['record']['type'] === ExtensionInventory::TYPE_TEMPLATE) { + return $this->renderComponents($ctx); + } + + $schema = $ctx['surface']['values']['getSettingsSchema'] ?? []; + + if (! is_array($schema) || $schema === []) { + $route = $ctx['surface']['values']['getSettingsRoute'] ?? null; + $layout = $ctx['surface']['values']['getSettingsLayout'] ?? null; + + if (is_string($layout) && $layout !== '') { + return '관리자 설정 화면이 있습니다 (레이아웃: '.$this->code($layout).'). 설정 항목은 화면에서 확인하세요.' + .(is_string($route) && $route !== '' ? ' 경로: '.$this->code($route) : ''); + } + + return $this->none('별도의 관리자 설정 항목이 없습니다.'); + } + + $rows = []; + foreach ($this->flattenSchema($schema) as $key => $meta) { + $label = ExtensionInventory::localized($meta['label'] ?? ''); + if ($label === '') { + $label = ExtensionInventory::localized($meta['description'] ?? ''); + } + + $rows[] = [ + $this->code($key), + $label !== '' ? $label : '-', + $this->scalar($meta['default'] ?? null), + ]; + } + + return $this->table(['키', '의미', '기본값'], $rows) + ."\n\n".'개발자용 상세(타입·검증·저장 위치)는 '.$this->docLink($ctx, 'docs/settings.md', '설정 스키마').' 를 보세요.'; + } + + /** + * 권한 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderPermissions(array $ctx): string + { + $permissions = $ctx['surface']['values']['getPermissions'] ?? []; + $categories = is_array($permissions) ? ($permissions['categories'] ?? []) : []; + + if (! is_array($categories) || $categories === []) { + return $this->none('선언된 권한이 없습니다.'); + } + + $rows = []; + foreach ($categories as $category) { + if (! is_array($category)) { + continue; + } + + $actions = []; + foreach ($category['permissions'] ?? [] as $permission) { + if (is_array($permission) && isset($permission['action'])) { + $actions[] = (string) $permission['action']; + } + } + + $rows[] = [ + $this->code((string) ($category['identifier'] ?? '-')), + ExtensionInventory::localized($category['name'] ?? ''), + $actions === [] ? '-' : implode(', ', array_map(fn ($a) => $this->code($a), $actions)), + isset($category['resource_route_key']) ? $this->code((string) $category['resource_route_key']) : '-', + ]; + } + + return $this->table(['카테고리', '이름', '액션', '라우트 키'], $rows); + } + + /** + * 메뉴 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderMenus(array $ctx): string + { + $rows = []; + + foreach ([['getAdminMenus', '관리자'], ['getCustomMenus', '사용자']] as [$getter, $label]) { + $menus = $ctx['surface']['values'][$getter] ?? []; + if (! is_array($menus)) { + continue; + } + + foreach ($menus as $menu) { + if (! is_array($menu)) { + continue; + } + + $children = is_array($menu['children'] ?? null) ? count($menu['children']) : 0; + + $rows[] = [ + $label, + $this->code((string) ($menu['slug'] ?? '-')), + ExtensionInventory::localized($menu['name'] ?? ''), + isset($menu['url']) && is_string($menu['url']) ? $this->code($menu['url']) : '-', + $children > 0 ? (string) $children.'개' : '-', + ]; + } + } + + if ($rows === []) { + return $this->none('등록하는 메뉴가 없습니다.'); + } + + return $this->table(['구분', 'slug', '이름', 'URL', '하위'], $rows); + } + + /** + * 라우트 파일 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderRoutes(array $ctx): string + { + $routes = $ctx['surface']['values']['getRoutes'] ?? []; + + if (! is_array($routes) || $routes === []) { + return $this->none('라우트 파일이 없습니다.'); + } + + $type = $ctx['record']['type'] === ExtensionInventory::TYPE_MODULE ? 'modules' : 'plugins'; + $id = $ctx['record']['id']; + + $rows = []; + foreach ($routes as $kind => $path) { + $prefix = $kind === 'api' ? "/api/{$type}/{$id}/..." : "/{$type}/{$id}/..."; + + $rows[] = [ + $this->code((string) $kind), + $this->code($this->relativeToExtension($ctx, (string) $path)), + $this->code($prefix), + ]; + } + + $note = '확장 라우트는 **활성 상태인 확장의 것만** 등록됩니다. 라우트 정의를 바꾸면 라우트 캐시 재생성이 필요합니다.'; + + return $this->table(['종류', '파일', 'URL prefix'], $rows)."\n\n".$note; + } + + /** + * 의존 관계 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderDependencies(array $ctx): string + { + return $this->renderIntegrations($ctx); + } + + /** + * 레이아웃 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderLayouts(array $ctx): string + { + $frontend = $ctx['frontend']; + $layouts = $frontend['layouts']; + + if ($layouts === []) { + return $this->none('레이아웃 JSON 이 없습니다.'); + } + + $groupRows = []; + foreach ($frontend['layoutGroups'] as $group => $count) { + $groupRows[] = [$this->code($group), (string) $count.'개']; + } + + $rows = []; + foreach ($layouts as $layout) { + $rows[] = [ + $this->code($layout['name']), + $this->code($layout['group']), + $layout['partial'] ? 'partial' : '화면', + $layout['extends'] !== null ? $this->code($layout['extends']) : '-', + ]; + } + + return sprintf('레이아웃 %d개 (루트: `%s`).', count($layouts), $frontend['layoutRoot'])."\n\n" + .$this->table(['그룹', '개수'], $groupRows)."\n\n" + .$this->table(['레이아웃', '그룹', '종류', 'extends'], $rows); + } + + /** + * 템플릿 라우트 → 레이아웃 매핑을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderLayoutMap(array $ctx): string + { + $record = $ctx['record']; + $routesJson = $record['path'].DIRECTORY_SEPARATOR.'routes.json'; + + if (! is_file($routesJson)) { + return $this->none('`routes.json` 이 없습니다.'); + } + + $data = json_decode((string) file_get_contents($routesJson), true); + $routes = is_array($data) ? ($data['routes'] ?? $data) : []; + + if (! is_array($routes) || $routes === []) { + return $this->none('`routes.json` 에 라우트 선언이 없습니다.'); + } + + $rows = []; + foreach ($routes as $key => $route) { + if (is_string($route)) { + $rows[] = [$this->code((string) $key), $this->code($route), '-']; + + continue; + } + + if (! is_array($route)) { + continue; + } + + $rows[] = [ + $this->code((string) ($route['path'] ?? $key)), + $this->code((string) ($route['layout'] ?? '-')), + (string) ($route['name'] ?? '-'), + ]; + } + + return $this->table(['경로', '레이아웃', '이름'], $rows); + } + + /** + * 액션 핸들러 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderHandlers(array $ctx): string + { + $handlers = $ctx['frontend']['handlers']; + + if ($handlers['names'] === []) { + return $this->none('등록하는 액션 핸들러가 없습니다.'); + } + + $namespace = $handlers['namespace']; + $rows = []; + + foreach ($handlers['names'] as $name) { + $rows[] = [ + $this->code($name), + $namespace !== null ? $this->code("{$namespace}.{$name}") : '(템플릿 전용 — 네임스페이스 없음)', + ]; + } + + return sprintf('핸들러 %d개 (정의: `%s`).', count($handlers['names']), (string) $handlers['source'])."\n\n" + .$this->table(['핸들러', '레이아웃에서 부르는 이름'], $rows); + } + + /** + * 프론트 전역 진입점을 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderFrontendEntry(array $ctx): string + { + $entry = $ctx['frontend']['entryPoints']; + + if ($entry['source'] === null) { + return $this->none('프론트 엔트리포인트가 없습니다.'); + } + + $rows = [ + ['엔트리 파일', $this->code((string) $entry['source'])], + ['전역 객체', $entry['global'] !== null ? $this->code('window.'.$entry['global']) : '**미노출**'], + ['재등록 진입점', $entry['initFunction'] !== null ? $this->code($entry['initFunction'].'()') : '**미노출**'], + ]; + + $note = $entry['global'] === null || $entry['initFunction'] === null + ? '재등록 진입점이 전역에 고정 이름으로 노출되지 않으면 로케일 전환 후 이 확장의 액션이 전부 무반응이 됩니다 (오류·토스트 없음).' + : '로케일 전환 시 코어가 이 진입점을 호출해 핸들러를 다시 등록합니다. 진입점은 핸들러 재등록만 수행하고 1회성 부팅 작업을 포함하지 않습니다.'; + + return $this->table(['항목', '값'], $rows)."\n\n".$note; + } + + /** + * 에셋 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderAssets(array $ctx): string + { + $frontend = $ctx['frontend']; + $loading = $ctx['surface']['values']['getAssetLoadingConfig'] ?? []; + + $rows = []; + foreach ($frontend['builtAssets'] as $asset) { + $rows[] = [$this->code($asset), '빌드 산출물 (커밋 대상)']; + } + foreach ($frontend['vendoredAssets'] as $asset) { + $rows[] = [$this->code('dist/vendor/'.$asset), '동봉 제3자 자산 (자체 제공)']; + } + if ($frontend['customDir']) { + $rows[] = [$this->code('custom/'), '운영자 추가 에셋 (확장 교체 시 보존)']; + } + + // 편집기 스펙은 수집만 하고 렌더하지 않으면 죽은 수집이 된다. 모듈·플러그인도 + // 소유하며(실측 10개), 검사 룰이 그 파일 변경에 문서 동반을 요구한다. + $editorSpec = $frontend['editorSpec'] ?? ['manifest' => false, 'split' => 0]; + if (! empty($editorSpec['manifest'])) { + $rows[] = [$this->code('editor-spec.json'), '레이아웃 편집기 스펙 (manifest)']; + } + if (($editorSpec['split'] ?? 0) > 0) { + $rows[] = [ + $this->code('editor-spec/'), + sprintf('분할 편집기 스펙 %d개', (int) $editorSpec['split']), + ]; + } + + // manifest 가 선언했는데 디스크에 없는 산출물은 그 자체가 신호다 — 브라우저에서는 + // 404 가 되고 서버 로그에는 흔적이 없다. 위 스캔은 `dist/` 관례만 보므로, 관례를 + // 벗어난 경로를 선언한 확장은 여기서만 드러난다. + $declared = $ctx['surface']['values']['getBuiltAssetPaths'] ?? []; + $missing = []; + + if (is_array($declared)) { + foreach ($declared as $declaredPath) { + if (! is_string($declaredPath) || $declaredPath === '') { + continue; + } + + $rel = ltrim(str_replace('\\', '/', $declaredPath), '/'); + if (in_array($rel, $frontend['builtAssets'], true)) { + continue; + } + + $abs = $ctx['record']['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel); + if (! is_file($abs)) { + $missing[] = $rel; + + continue; + } + + $rows[] = [$this->code($rel), '빌드 산출물 (manifest 선언)']; + } + } + + if ($rows === [] && $missing === []) { + return $this->none('프론트 에셋이 없습니다.'); + } + + $lines = $rows === [] ? [] : [$this->table(['경로', '구분'], $rows)]; + + if ($missing !== []) { + $lines[] = ''; + $lines[] = $this->none(sprintf( + 'manifest 가 선언했으나 디스크에 없는 산출물: %s — 빌드하지 않았거나 경로가 어긋났습니다.', + implode(', ', array_map(fn (string $p): string => $this->code($p), $missing)), + )); + } + + if (is_array($loading) && $loading !== []) { + $lines[] = ''; + $lines[] = '로딩 설정: '.$this->code(json_encode($loading, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '-'); + } + + return implode("\n", $lines); + } + + /** + * 템플릿 제공 컴포넌트 표를 렌더합니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function renderComponents(array $ctx): string + { + $components = $ctx['frontend']['components']; + + if ($components['total'] === 0) { + return $this->none('제공 컴포넌트가 없습니다.'); + } + + $rows = []; + foreach ($components['byCategory'] as $category => $count) { + $rows[] = [$this->code($category), (string) $count.'개']; + } + + return sprintf('컴포넌트 %d개 (루트: `%s`).', $components['total'], (string) $components['root'])."\n\n" + .$this->table(['분류', '개수'], $rows); + } + + // ----------------------------------------------------------------------- + // 골격 생성 + // ----------------------------------------------------------------------- + + /** + * 문서 골격 전문을 만듭니다 (신규 파일 전용). + * + * @param string $doc 문서 상대 경로 + * @param array $ctx 수집 컨텍스트 + * @return string 문서 전문 + */ + public function skeleton(string $doc, array $ctx): string + { + return match ($doc) { + 'AGENTS.md' => $this->skeletonAgents($ctx), + 'README.md' => $this->skeletonReadme($ctx), + 'docs/README.md' => $this->skeletonDocsReadme($ctx), + 'docs/architecture.md' => $this->skeletonSectioned($doc, $ctx, '설계 의도와 계층 구조'), + default => $this->skeletonSectioned($doc, $ctx, $this->documentPurpose($doc)), + }; + } + + /** + * AGENTS.md 골격을 만듭니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 문서 전문 + */ + private function skeletonAgents(array $ctx): string + { + $record = $ctx['record']; + $name = $record['name']; + $label = ExtensionInventory::typeLabel($record['type']); + + $lines = []; + $lines[] = "# {$name} — 에이전트 가이드"; + $lines[] = ''; + $lines[] = "> 이 문서는 이 {$label}을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 [README.md](README.md) 를 보세요."; + $lines[] = ''; + $lines[] = '## TL;DR (5초 요약)'; + $lines[] = ''; + $lines[] = '```text'; + $lines[] = "1. 유형: {$label} ({$record['id']}) — ".self::TODO_INTENT.' (소유 도메인 한 줄)'; + $lines[] = '2. 확장 방식: '.self::TODO_INTENT.' (이 확장을 건드리지 않고 붙이는 방법)'; + $lines[] = '3. 건드리면 안 되는 것: '.self::TODO_FORBIDDEN; + $lines[] = '4. 작업 위치: `'.$record['relPath'].'` — 활성 디렉토리 직접 수정 금지'; + $lines[] = "5. 반영: `php artisan {$record['type']}:update {$record['id']} --force`"; + $lines[] = '```'; + $lines[] = ''; + $lines[] = '## 1. 이 확장은 무엇인가'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 개발 의도, 해결하는 문제, 설계 원칙, 의도적으로 하지 않는 것을 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 2. 디렉토리 지도'; + $lines[] = ''; + $lines[] = self::wrap('directory-map', $this->renderBlock('directory-map', $ctx)); + $lines[] = ''; + $lines[] = '## 3. 핵심 흐름'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_FLOW.' — 대표 시나리오 2~3개를 Controller → FormRequest → Service → Repository → Model 경로로 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 4. 확장점'; + $lines[] = ''; + $lines[] = self::wrap('extension-points-summary', $this->renderBlock('extension-points-summary', $ctx)); + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 이 확장을 수정하지 않고 동작을 바꾸는 방법(어느 훅을 잡는가)을 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 5. 수정 시 동반 의무'; + $lines[] = ''; + $lines[] = $this->obligationChecklist($ctx); + $lines[] = ''; + $lines[] = '## 6. 금지 패턴'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_FORBIDDEN.' — 이 확장에서 실제로 발생했거나 발생할 수 있는 오용을 표로 적습니다.', + '', + '| 금지 | 올바른 사용 | 이유 |', + '|---|---|---|', + '| - | - | - |', + ]); + $lines[] = ''; + $lines[] = '## 7. 테스트 실행'; + $lines[] = ''; + $lines[] = self::wrap('test-commands', $this->renderBlock('test-commands', $ctx)); + $lines[] = ''; + $lines[] = '## 8. 문서 목차'; + $lines[] = ''; + $lines[] = self::wrap('docs-index', $this->renderBlock('docs-index', $ctx)); + $lines[] = ''; + + return implode("\n", $lines); + } + + /** + * 수정 시 동반 의무 체크리스트 초안을 만듭니다. + * + * 확장이 실제로 보유한 표면만 항목으로 남깁니다 — 걸리지 않는 의무를 나열하면 + * 체크리스트 전체가 형식적으로 읽힙니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 마크다운 + */ + private function obligationChecklist(array $ctx): string + { + $record = $ctx['record']; + $items = []; + + $items[] = "`_bundled` 에서만 수정하고 `php artisan {$record['type']}:update {$record['id']} --force` 로 반영"; + + // 동기화 대상은 유형마다 다르다. 템플릿은 PHP 패키지가 아니라 `composer.json` 을 + // 갖지 않는데(번들 템플릿 4개 전부 0건), 3유형 공통 문구를 내면 **없는 파일**을 + // 체크 항목으로 요구하게 된다. 이 절은 골격에 한 번만 쓰이고 자동 생성 블록이 + // 아니라 재생성으로 고쳐지지도 않으므로, 틀린 채로 20세트에 굳는다. + $items[] = $record['type'] === 'template' + ? 'manifest version 상향 시 `package.json` · `package-lock.json` 동기화 + CHANGELOG 기재' + : 'manifest version 상향 시 `package.json` · `package-lock.json` · `composer.json` 동기화 + CHANGELOG 기재'; + + if ($ctx['data']['migrations'] !== []) { + $items[] = '스키마 변경 시 마이그레이션(한국어 comment + `down()`) + 기설치본 백필용 업그레이드 스텝'; + } + + if ($ctx['hooks']['published'] !== []) { + $items[] = '발행 훅 추가·이름 변경 시 `php artisan ext:docgen` 재실행 (구독하는 확장의 계약이 바뀝니다)'; + } + + // 표면을 못 읽었으면 "라우트가 없다" 가 아니라 "모른다" 다. 여기서 조건만 보고 + // 항목을 빼면 그 누락에 아무 신호가 남지 않는데, 이 절은 골격에 한 번만 쓰이고 + // 자동 생성 블록이 아니라 재생성으로 복구되지도 않는다. + $errors = $ctx['surface']['errors'] ?? []; + $routesKnown = ($ctx['surface']['available'] ?? false) === true + && ! array_key_exists('getRoutes', $errors) + && ! array_key_exists('__path_injection', $errors); + + if (! $routesKnown && $record['type'] !== 'template') { + $items[] = '라우트 선언을 읽지 못했습니다 — API 표면이 있다면 `php artisan api:docgen --scope='.$record['type'].':'.$record['id'].'` 동반 여부를 직접 확인하세요.'; + } elseif (($ctx['surface']['values']['getRoutes'] ?? []) !== []) { + $items[] = 'API 표면 변경 시 `php artisan api:docgen --scope='.$record['type'].':'.$record['id'].'` 재실행 + `docs/api/**` 갱신'; + } + + if ($ctx['frontend']['layouts'] !== []) { + $items[] = '레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인'; + } + + if ($ctx['frontend']['builtAssets'] !== []) { + $items[] = 'TSX/TS 변경 시 `--production` 재빌드 후 `dist/` 커밋 (sourceMappingURL 잔존 금지)'; + } + + if (is_dir($record['path'].DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'lang') + || is_dir($record['path'].DIRECTORY_SEPARATOR.'lang')) { + $items[] = '다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화'; + } + + $items[] = self::TODO_INTENT.' — 이 확장에만 걸리는 코어 횡단 규정을 추려 추가합니다.'; + + $lines = []; + foreach ($items as $item) { + $lines[] = '- [ ] '.$item; + } + + return implode("\n", $lines); + } + + /** + * README.md 골격을 만듭니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 문서 전문 + */ + private function skeletonReadme(array $ctx): string + { + $record = $ctx['record']; + $name = $record['name']; + $label = ExtensionInventory::typeLabel($record['type']); + $description = $record['description'] !== '' ? $record['description'] : self::TODO_INTENT; + + // 인라인 TOC 는 필수 섹션 목록에서 만든다 — 유형별 절 이름 차이(관리자 설정 ↔ 제공 + // 컴포넌트)를 두 곳에 적으면 한쪽만 고쳐 링크가 끊긴다. + $sections = self::sectionsFor('README.md', $record['type']); + // 절 이름은 override 를 거쳐 나오므로 위치가 아니라 **원래 절 이름**으로 되짚는다. + // 인덱스로 집으면 README 절을 하나 끼워 넣는 순간 엉뚱한 절이 설정 자리가 되는데, + // 헤딩은 그 자리에서 만들어지므로 필수 섹션 검사도 함께 통과해 드러나지 않는다. + $settingsSection = self::sectionsFor('README.md', $record['type'])[ + array_search('관리자 설정', self::DOCUMENTS['README.md']['sections'], true) + ]; + $toc = array_map( + fn (string $s): string => '['.$s.'](#'.str_replace(' ', '-', $s).')', + $sections, + ); + + $lines = []; + $lines[] = '

'; + $lines[] = sprintf( + ' %s', + rawurlencode(str_replace(['-', '_'], ['--', '__'], $name)), + rawurlencode(str_replace(['-', '_'], ['--', '__'], $record['id'])), + $this->escape($name), + ); + $lines[] = '

'; + $lines[] = ''; + $lines[] = '

'; + $lines[] = " G7 {$label} · {$record['id']}
"; + $lines[] = ' '.$this->escape($description); + $lines[] = '

'; + $lines[] = ''; + $lines[] = self::wrap('badges', $this->renderBlock('badges', $ctx)); + $lines[] = ''; + $lines[] = '---'; + $lines[] = ''; + $lines[] = implode(' · ', $toc); + $lines[] = ''; + $lines[] = '---'; + $lines[] = ''; + $lines[] = '## 소개'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 무엇을 해결하는가 · 어떤 상황에 쓰는가 · 의도적으로 하지 않는 것.', + ]); + $lines[] = ''; + $lines[] = '## 주요 기능'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 영역별 기능을 표로 적습니다.', + '', + '| 영역 | 설명 |', + '|---|---|', + '| - | - |', + ]); + $lines[] = ''; + $lines[] = '## 동작 방식'; + $lines[] = ''; + // 템플릿은 요청 흐름 대신 레이아웃 상속 구조가 이 자리의 답이다. + $lines[] = $record['type'] === ExtensionInventory::TYPE_TEMPLATE + ? $this->intentBlock([ + self::TODO_FLOW.' — 레이아웃 상속도를 둡니다 (베이스 레이아웃 → 자식 레이아웃).', + '', + '```mermaid', + 'flowchart TD', + ' base[_base] --> child1[목록 화면]', + ' base --> child2[상세 화면]', + '```', + ]) + : $this->intentBlock([ + self::TODO_FLOW.' — 운영자 눈높이의 mermaid 흐름도를 1~2개 둡니다 (요청 흐름 / 상태 전이 / 확장 간 관계).', + '', + '```mermaid', + 'flowchart LR', + ' A[운영자] --> B[화면]', + ' B --> C[처리]', + '```', + ]); + $lines[] = ''; + $lines[] = '## 요구 사항'; + $lines[] = ''; + $lines[] = self::wrap('requirements', $this->renderBlock('requirements', $ctx)); + $lines[] = ''; + $lines[] = '## 설치'; + $lines[] = ''; + $lines[] = self::wrap('install', $this->renderBlock('install', $ctx)); + $lines[] = ''; + $lines[] = '## '.$settingsSection; + $lines[] = ''; + $lines[] = self::wrap('settings-summary', $this->renderBlock('settings-summary', $ctx)); + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_USAGE.' — 위 표가 답하지 않는 것(각 항목을 언제 바꾸는가 · 바꾸면 무엇이 달라지는가)을 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 사용 방법'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_USAGE.' — 대표 시나리오 2~3개를 운영자 관점 단계로 적습니다.', + ]); + $lines[] = ''; + $lines[] = '## 다른 확장과의 연동'; + $lines[] = ''; + $lines[] = self::wrap('integrations', $this->renderBlock('integrations', $ctx)); + $lines[] = ''; + $lines[] = '## 문서'; + $lines[] = ''; + $lines[] = self::wrap('docs-index', $this->renderBlock('docs-index', $ctx)); + $lines[] = ''; + $lines[] = '## 트러블슈팅'; + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_TROUBLESHOOTING.' — 운영 중 자주 만나는 증상 → 원인 → 조치를 표로 적습니다.', + '', + '| 증상 | 원인 | 조치 |', + '|---|---|---|', + '| - | - | - |', + ]); + $lines[] = ''; + $lines[] = '## 변경 이력'; + $lines[] = ''; + $lines[] = '[CHANGELOG.md](CHANGELOG.md)'; + $lines[] = ''; + $lines[] = '## 라이선스'; + $lines[] = ''; + $lines[] = (string) ($record['manifest']['license'] ?? 'MIT'); + $lines[] = ''; + + return implode("\n", $lines); + } + + /** + * `docs/README.md` 골격을 만듭니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string 문서 전문 + */ + private function skeletonDocsReadme(array $ctx): string + { + $record = $ctx['record']; + + $lines = []; + $lines[] = "# {$record['name']} 개발자 문서"; + $lines[] = ''; + $lines[] = "> {$record['relPath']} · ".ExtensionInventory::typeLabel($record['type']); + $lines[] = ''; + $lines[] = self::wrap('stats', $this->renderBlock('stats', $ctx)); + $lines[] = ''; + $lines[] = '## 문서 목차'; + $lines[] = ''; + $lines[] = self::wrap('doc-toc', $this->renderBlock('doc-toc', $ctx)); + $lines[] = ''; + + return implode("\n", $lines); + } + + /** + * 섹션 기반 문서 골격을 만듭니다. + * + * @param string $doc 문서 상대 경로 + * @param array $ctx 수집 컨텍스트 + * @param string $purpose 문서 용도 한 줄 + * @return string 문서 전문 + */ + private function skeletonSectioned(string $doc, array $ctx, string $purpose): string + { + $meta = self::DOCUMENTS[$doc]; + $record = $ctx['record']; + $overrides = $meta['sectionOverrides'][$record['type']] ?? []; + + $lines = []; + $lines[] = '# '.$record['name'].' — '.$this->documentTitle($doc); + $lines[] = ''; + $lines[] = '> '.$purpose.' · 진입점: [AGENTS.md](../AGENTS.md)'; + $lines[] = ''; + + // `docs/architecture.md` 만 절 수와 블록 수가 다르다(서술 2 + 블록 1). + if (! self::pairsSectionsWithBlocks($doc)) { + foreach (self::sectionsFor($doc, $record['type']) as $section) { + $lines[] = '## '.$section; + $lines[] = ''; + + if ($section === '디렉토리') { + $lines[] = self::wrap('directory-map', $this->renderBlock('directory-map', $ctx)); + } else { + $lines[] = $this->intentBlock([ + ($section === '설계 의도' ? self::TODO_INTENT : self::TODO_FLOW).' — '.$section.' 를 서술합니다.', + ]); + } + + $lines[] = ''; + } + + return implode(' +', $lines); + } + + // 절 ↔ 블록은 배열의 **키**가 짝을 정한다. 순번(`$blocks[$i]`)으로 짝지으면 절을 + // 하나 끼우는 순간 그 뒤 블록이 전부 엉뚱한 헤딩 밑으로 들어가는데, 헤딩 존재와 + // 블록 존재를 각각만 보는 게이트는 그 어긋남을 잡지 못한다. + foreach ($meta['blocks'] as $section => $blockKey) { + $lines[] = '## '.($overrides[$section] ?? $section); + $lines[] = ''; + $lines[] = self::wrap($blockKey, $this->renderBlock($blockKey, $ctx)); + $lines[] = ''; + $lines[] = $this->intentBlock([ + self::TODO_INTENT.' — 위 표가 답하지 않는 것(왜 이렇게 설계했는가 · 어느 것을 잡아야 하는가)을 적습니다.', + ]); + $lines[] = ''; + } + + return implode(' +', $lines); + } + + /** + * 문서 제목을 반환합니다. + * + * @param string $doc 문서 상대 경로 + * @return string 제목 + */ + private function documentTitle(string $doc): string + { + return match ($doc) { + 'docs/architecture.md' => '아키텍처', + 'docs/extension-points.md' => '확장점', + 'docs/data-model.md' => '데이터 모델', + 'docs/settings.md' => '설정·권한·라우트', + 'docs/frontend.md' => '프론트엔드', + 'docs/components.md' => '컴포넌트', + 'docs/layouts.md' => '레이아웃', + 'docs/handlers.md' => '핸들러', + default => basename($doc, '.md'), + }; + } + + // ----------------------------------------------------------------------- + // 마크다운 유틸 + // ----------------------------------------------------------------------- + + /** + * 사람 영역(`@intent`) 블록을 만듭니다. + * + * @param array $lines 본문 줄 + * @return string 마크다운 + */ + private function intentBlock(array $lines): string + { + return "\n".implode("\n", $lines)."\n"; + } + + /** + * 마크다운 표를 만듭니다. + * + * @param array $headers 헤더 + * @param array> $rows 행 + * @return string 마크다운 표 (행이 없으면 안내 문구) + */ + private function table(array $headers, array $rows): string + { + if ($rows === []) { + return $this->none('해당 항목이 없습니다.'); + } + + $lines = []; + $lines[] = '| '.implode(' | ', $headers).' |'; + $lines[] = '|'.str_repeat('---|', count($headers)); + + foreach ($rows as $row) { + $cells = []; + for ($i = 0; $i < count($headers); $i++) { + $cells[] = $this->cell($row[$i] ?? '-'); + } + $lines[] = '| '.implode(' | ', $cells).' |'; + } + + return implode("\n", $lines); + } + + /** + * 표 셀 값을 안전하게 만듭니다 (파이프·개행 이스케이프). + * + * @param string $value 값 + * @return string 셀 문자열 + */ + private function cell(string $value): string + { + $value = str_replace(["\r\n", "\r", "\n"], ' ', $value); + $value = str_replace('|', '\\|', $value); + + return trim($value) === '' ? '-' : trim($value); + } + + /** + * 인라인 코드로 감쌉니다. + * + * @param string $value 값 + * @return string 마크다운 + */ + private function code(string $value): string + { + return $value === '' ? '-' : '`'.$value.'`'; + } + + /** + * 항목 없음 안내를 만듭니다. + * + * @param string $message 안내 문구 + * @return string 마크다운 + */ + private function none(string $message): string + { + return '_'.$message.'_'; + } + + /** + * 개별 getter 수집 실패를 알리는 문장을 만듭니다. + * + * `available` 은 **진입 클래스**를 읽었는지만 말합니다. 클래스를 읽고도 개별 getter 가 + * 던지면(`errors`) 그 항목만 빈 값이 되는데, 렌더러가 그 빈 값을 그대로 "선언된 권한이 + * 없습니다" · "훅을 발행하지 않습니다" 로 서술하면 **읽지 못한 것이 없는 것으로 굳는다**. + * 경로 주입 실패(`__path_injection`)는 경로 기반 getter 전부를 동시에 비우므로 특히 그렇다. + * + * 표면을 통째로 못 읽은 경우(`surfaceUnavailable`)와 달리 여기서는 본문을 **대체하지 + * 않고 덧붙인다** — 나머지 getter 가 돌려준 실측을 버릴 이유가 없다. + * + * @param array $ctx 수집 컨텍스트 + * @return string|null 통지 문장, 실패가 없으면 null + */ + private function surfaceErrorsNotice(array $ctx): ?string + { + if (($ctx['record']['type'] ?? null) === 'template') { + return null; + } + + $errors = $ctx['surface']['errors'] ?? []; + if (! is_array($errors) || $errors === []) { + return null; + } + + $names = array_map(static fn (string $g): string => '`'.$g.'`', array_keys($errors)); + + return $this->none(sprintf( + '아래 표면을 읽지 못했습니다 (%s). 이 절의 "없음" 은 사실이 아닐 수 있습니다 — 항목이 없다는 뜻이 아니라 %s.', + implode(' · ', $names), + self::SURFACE_NOTICE_MARKER, + )); + } + + /** + * 선언형 표면을 읽지 못한 경우의 안내를 만듭니다 (읽었으면 null). + * + * 수집기는 진입 클래스 로드·인스턴스화 실패를 `available=false` + `reason` 으로 올립니다. + * 그 신호를 읽지 않으면 "권한이 없습니다" · "라우트 파일이 없습니다" 처럼 **사실이 아닌 + * 문장**이 문서에 박힙니다 — 콘솔 경고는 사라지고 커밋된 문서만 남으므로, 읽는 사람에게는 + * 그것이 확장의 사실로 보입니다. "없음" 과 "확인하지 못함" 은 구분해서 보고합니다. + * + * 템플릿은 선언형 표면 자체를 갖지 않으므로(`available=false` 가 정상) 대상이 아닙니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string|null 확인 불가 안내, 정상 수집이면 null + */ + private function surfaceUnavailable(array $ctx): ?string + { + if (($ctx['surface']['available'] ?? false) === true) { + return null; + } + + if (($ctx['record']['type'] ?? null) === 'template') { + return null; + } + + $reason = $ctx['surface']['reason'] ?? null; + + return $this->none(sprintf( + '이 항목을 확인하지 못했습니다 (%s). 항목이 없다는 뜻이 아니라 %s.', + is_string($reason) && $reason !== '' ? $reason : '사유 미상', + self::SURFACE_NOTICE_MARKER, + )); + } + + /** + * 스칼라 값을 표시용 문자열로 만듭니다. + * + * @param mixed $value 값 + * @return string 표시 문자열 + */ + private function scalar(mixed $value): string + { + if ($value === null) { + return '-'; + } + if (is_bool($value)) { + return $this->code($value ? 'true' : 'false'); + } + if (is_scalar($value)) { + return $this->code((string) $value); + } + + return $this->code((string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + /** + * 값을 인라인 코드 라벨로 만듭니다 (배열/객체도 안전하게 처리). + * + * @param mixed $value 값 + * @return string 마크다운 + */ + private function scalarLabel(mixed $value): string + { + if ($value === null || $value === '') { + return '-'; + } + if (is_scalar($value)) { + return $this->code((string) $value); + } + + return $this->code((string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + /** + * HTML 속성용으로 문자열을 이스케이프합니다. + * + * @param string $value 값 + * @return string 이스케이프된 값 + */ + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * 배열/문자열의 항목 수를 셉니다. + * + * @param mixed $value 값 + * @return int 항목 수 + */ + private function countOf(mixed $value): int + { + return is_array($value) ? count($value) : 0; + } + + /** + * FQCN 의 짧은 이름을 반환합니다. + * + * @param string $fqcn 클래스명 + * @return string 짧은 이름 + */ + private function shortName(string $fqcn): string + { + $parts = explode('\\', trim($fqcn, '\\')); + + return end($parts) ?: $fqcn; + } + + /** + * 절대 경로를 확장 루트 기준 상대 경로로 바꿉니다. + * + * @param array $ctx 수집 컨텍스트 + * @param string $path 경로 + * @return string 상대 경로 + */ + private function relativeToExtension(array $ctx, string $path): string + { + $base = rtrim((string) $ctx['record']['path'], '/\\').DIRECTORY_SEPARATOR; + $normalized = str_replace('\\', '/', $path); + $normalizedBase = str_replace('\\', '/', $base); + + return str_starts_with($normalized, $normalizedBase) + ? substr($normalized, strlen($normalizedBase)) + : $normalized; + } + + /** + * 문서 링크를 만듭니다 (AGENTS.md 기준 상대 경로). + * + * @param array $ctx 수집 컨텍스트 + * @param string $doc 문서 상대 경로 + * @param string $anchor 섹션 제목 + * @return string 마크다운 링크 + */ + private function docLink(array $ctx, string $doc, string $anchor): string + { + $slug = strtolower(str_replace([' ', '.', '(', ')'], ['-', '', '', ''], $anchor)); + + return "[{$anchor}]({$doc}#{$slug})"; + } + + /** + * composer.json 의 PHP 제약을 읽습니다. + * + * @param array $ctx 수집 컨텍스트 + * @return string|null PHP 제약 (없으면 null) + */ + private function composerPhp(array $ctx): ?string + { + $path = $ctx['record']['path'].DIRECTORY_SEPARATOR.'composer.json'; + if (! is_file($path)) { + return null; + } + + $composer = json_decode((string) file_get_contents($path), true); + $php = $composer['require']['php'] ?? null; + + return is_string($php) ? $php : null; + } + + /** + * 설정 스키마를 점 표기 키로 평탄화합니다. + * + * @param array $schema 스키마 + * @param string $prefix 키 접두 + * @return array> 평탄화된 키 => 메타 + */ + private function flattenSchema(array $schema, string $prefix = ''): array + { + $flat = []; + + foreach ($schema as $key => $value) { + $path = $prefix === '' ? (string) $key : $prefix.'.'.$key; + + if (is_array($value) && isset($value['type'])) { + $flat[$path] = $value; + + continue; + } + + if (is_array($value) && $value !== [] && array_keys($value) !== range(0, count($value) - 1)) { + $flat += $this->flattenSchema($value, $path); + + continue; + } + + $flat[$path] = ['type' => gettype($value), 'default' => $value]; + } + + return $flat; + } +} diff --git a/app/Support/ExtensionDoc/ExtensionInventory.php b/app/Support/ExtensionDoc/ExtensionInventory.php new file mode 100644 index 00000000..731669d9 --- /dev/null +++ b/app/Support/ExtensionDoc/ExtensionInventory.php @@ -0,0 +1,322 @@ + + */ + 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 유형 목록 + */ + 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> 확장 레코드 목록 (유형 → 식별자 정렬) + */ + 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|null 확장 레코드 (없으면 null) + */ + public function find(string $type, string $id): ?array + { + $records = $this->collect("{$type}:{$id}"); + + return $records[0] ?? null; + } + + /** + * @var array manifest 를 읽지 못한 디렉토리 + */ + private array $malformed = []; + + /** + * 직전 `collect()` 에서 manifest 파싱에 실패한 디렉토리 목록을 반환합니다. + * + * 호출자가 이 목록을 보고해야 "확장이 없다" 와 "읽지 못했다" 가 구분됩니다. + * + * @return array 실패 목록 + */ + public function malformed(): array + { + return $this->malformed; + } + + /** + * 확장 레코드를 조립합니다. + * + * manifest 가 없거나 JSON 파싱에 실패한 디렉토리는 확장이 아니므로 제외합니다 + * (`_backup_*` · 업데이트 중 임시 디렉토리 등). + * + * @param string $type 확장 유형 + * @param string $id 확장 식별자 + * @param string $dirPath 확장 절대 경로 + * @return array|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 $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 ''; + } +} diff --git a/app/Support/ExtensionDoc/FrontendInventory.php b/app/Support/ExtensionDoc/FrontendInventory.php new file mode 100644 index 00000000..1435e93b --- /dev/null +++ b/app/Support/ExtensionDoc/FrontendInventory.php @@ -0,0 +1,531 @@ + $record ExtensionInventory 레코드 + * @return array 프론트 인벤토리 + */ + 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 $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 $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 $record 확장 레코드 + * @return string 레이아웃 확장 루트 + */ + private function layoutExtensionRoot(array $record): string + { + return $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'extensions' : 'resources/extensions'; + } + + /** + * 레이아웃 JSON 을 수집합니다. + * + * @param array $record 확장 레코드 + * @return array> 레이아웃 목록 + */ + 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> $layouts 레이아웃 목록 + * @return array 그룹 => 개수 + */ + 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 $record 확장 레코드 + * @return array{namespace: string|null, names: array, 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 $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 $record 확장 레코드 + * @return array{total: int, byCategory: array, 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 $record 확장 레코드 + * @return array 산출물 상대 경로 + */ + 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 $record 확장 레코드 + * @return array `{라이브러리}/{버전}` 목록 + */ + 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 $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 $record 확장 레코드 + * @param string $sub 확장 루트 기준 하위 경로 + * @return array 상대 경로 목록 (정렬) + */ + 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 키 목록 + */ + private function objectKeys(string $content, string $name): array + { + // 타입 주석이 붙은 선언(`handlerMap: Record 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 핸들러 이름 목록 + */ + 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 $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); + } +} diff --git a/app/Support/ExtensionDoc/HookInventory.php b/app/Support/ExtensionDoc/HookInventory.php new file mode 100644 index 00000000..9e81679d --- /dev/null +++ b/app/Support/ExtensionDoc/HookInventory.php @@ -0,0 +1,527 @@ + + */ + 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 $record ExtensionInventory 레코드 + * @param array $declared 확장이 `getHooks()` 로 선언한 발행 훅 목록 + * @return array{published: array>, publishedSites: int, publishedDynamic: int, publishedUndeclared: int, subscribed: array>, listeners: array>} + */ + 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 $record 확장 레코드 + * @param array $declared `getHooks()` 선언 목록 + * @return array{hooks: array>, 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 $declared 선언 목록 + * @return array> 훅 이름 → 항목 + */ + 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 $record 확장 레코드 + * @return array> 리스너 목록 + */ + 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> 구독 훅 목록 + */ + 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 항목 목록 (빈 항목 제외) + */ + 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 $record 확장 레코드 + * @return \Generator 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 $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); + } +} diff --git a/app/Support/ExtensionDoc/TestPathCollector.php b/app/Support/ExtensionDoc/TestPathCollector.php new file mode 100644 index 00000000..23aeb672 --- /dev/null +++ b/app/Support/ExtensionDoc/TestPathCollector.php @@ -0,0 +1,241 @@ + $record ExtensionInventory 레코드 + * @return array 테스트 인벤토리 + */ + 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 $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 $record 확장 레코드 + * @return array{config: string|null, dirs: array, 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 $record 확장 레코드 + * @return array 매니페스트 상대 경로 + */ + 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 $record 확장 레코드 + * @param array{count: int, dirs: array} $phpunit PHPUnit 집계 + * @param array{config: string|null, dirs: array, files: int} $vitest Vitest 집계 + * @param array{count: int, dirs: array} $playwright Playwright 집계 + * @return array 실행 명령 목록 + */ + 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 $record 확장 레코드 + * @param string $sub 확장 루트 기준 하위 경로 + * @param string $extension 대상 확장자 + * @param array $excludeDirs 제외할 1단계 하위 디렉토리명 + * @return array{count: int, dirs: array, 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]; + } +} diff --git a/docs/README.md b/docs/README.md index 46c7cc68..26b47788 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | diff --git a/docs/extension/README.md b/docs/extension/README.md index 14851a48..f59b169d 100644 --- a/docs/extension/README.md +++ b/docs/extension/README.md @@ -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 기재 의무 | + --- ## 확장 타입별 네이밍 규칙 diff --git a/docs/extension/extension-documentation.md b/docs/extension/extension-documentation.md new file mode 100644 index 00000000..c3fe84cf --- /dev/null +++ b/docs/extension/extension-documentation.md @@ -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 + +| 훅 이름 | 유형 | 발행 위치 | +| ... | + + + +이 훅은 ... (사람이 쓰는 영역 — 생성기가 건드리지 않는다) + +``` + +블록 밖 전부가 사람 영역이다. 계약은 세 가지다. + +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 레퍼런스 문서 규정 diff --git a/resources/views/dev-dashboard.blade.php b/resources/views/dev-dashboard.blade.php index 9b0adf6e..e1181a45 100644 --- a/resources/views/dev-dashboard.blade.php +++ b/resources/views/dev-dashboard.blade.php @@ -1052,6 +1052,28 @@ if (isset($_GET['ajax_action'])) { + +
+
+ 📗 + 확장 개발자 문서 +
+
+ + + +
+
+
@@ -1582,6 +1604,10 @@ if (isset($_GET['ajax_action'])) { */ const COMMAND_TIMEOUTS = { 'security:audit-dependencies': 300000, + // 확장 20개의 진입 클래스를 실제로 부팅해 선언형 getter 40종을 호출한다. + // 기본 60초를 넘기면 화면은 타임아웃을 띄우는데 서버는 계속 문서를 쓰므로, + // "실패했다" 와 "성공했는데 화면이 포기했다" 가 구분되지 않는다. + 'ext:docgen': 300000, }; /** diff --git a/tests/Feature/Documentation/ExtensionDocContractTest.php b/tests/Feature/Documentation/ExtensionDocContractTest.php new file mode 100644 index 00000000..1edde8fa --- /dev/null +++ b/tests/Feature/Documentation/ExtensionDocContractTest.php @@ -0,0 +1,945 @@ + + */ + 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\n{$human['intent']}\n\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 $record 확장 레코드 + * @param ExtensionInventory $inventory 인벤토리 (역방향 의존 스캔에 사용) + * @return array 수집 컨텍스트 + */ + 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), + ]; + } +}