diff --git a/AGENTS.md b/AGENTS.md index fa6b75dd..a5f106e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,23 @@ | [e2e-testing.md](docs/testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 | - | +### 확장 API 레퍼런스 (9개 확장, 자동 스캔) + +> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음). + +| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | +|------|------|--------------|----------------| +| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 2 | +| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 | +| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 231 | +| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 | +| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | +| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | +| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | +| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | +| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | + + --- diff --git a/app/Console/Commands/ApiDocgenCommand.php b/app/Console/Commands/ApiDocgenCommand.php index 3e68c1ff..977af410 100644 --- a/app/Console/Commands/ApiDocgenCommand.php +++ b/app/Console/Commands/ApiDocgenCommand.php @@ -128,11 +128,26 @@ class ApiDocgenCommand extends Command $checkFindings = []; $examplesOnly = (bool) $this->option('examples-only'); + // 대상(코어/확장)별 README 목차 항목 누적: readmeKey => ['label' => ..., 'entries' => [...]] + $readmeIndex = []; + foreach ($grouped as $file => $items) { $sections = []; $sectionKeys = []; $exampleBlocks = []; + // 목차용 도메인 파일 항목 (소유별). 예시-only/check 모드에서도 목차는 최신화한다. + $owner = $items[0]['owner']; + $readmeKey = $this->readmeFile($owner); + $readmeIndex[$readmeKey]['label'] = $owner['type'] === 'core' + ? '코어' + : ($owner['type'] === 'module' ? '모듈' : '플러그인')." `{$owner['id']}`"; + $readmeIndex[$readmeKey]['entries'][] = [ + 'domain' => $items[0]['domain_group'], + 'file' => basename($file), + 'count' => count($items), + ]; + foreach ($items as $route) { $request = $introspector->introspect($route['controller'], $route['controller_method']); [$schema, $probeMeta] = $this->probeEndpoint($probe, $inferrer, $route, $probed, $sampleMap, $request); @@ -210,6 +225,17 @@ class ApiDocgenCommand extends Command $probe->cleanup(); + // 대상별 API 문서 목차(README.md) 생성/갱신 — 확장 API 레퍼런스 발견성의 규약 진입점. + // check/dry-run 은 파일을 쓰지 않으므로 제외. examples-only 는 목차도 최신화한다. + if (! $this->option('check')) { + foreach ($readmeIndex as $readmePath => $meta) { + $existing = File::exists($readmePath) ? File::get($readmePath) : null; + $content = $scaffolder->readmeIndex($meta['label'], $meta['entries'], $existing); + File::ensureDirectoryExists(dirname($readmePath)); + File::put($readmePath, $content); + } + } + if ($this->option('check')) { if ($checkFindings !== []) { $this->warn('실측 제외/문서 누락 '.count($checkFindings).'건:'); @@ -790,6 +816,23 @@ class ApiDocgenCommand extends Command return base_path("{$base}/_bundled/{$owner['id']}/docs/api/{$domain}.md"); } + /** + * 소유 대상의 API 문서 목차(README.md) 경로를 반환합니다. + * + * @param array{type: string, id: string|null} $owner 소유 메타 + * @return string README.md 절대 경로 + */ + private function readmeFile(array $owner): string + { + if ($owner['type'] === 'core') { + return base_path('docs/backend/api/README.md'); + } + + $base = $owner['type'] === 'module' ? 'modules' : 'plugins'; + + return base_path("{$base}/_bundled/{$owner['id']}/docs/api/README.md"); + } + /** * 문서 헤더(제목 + TL;DR)를 생성합니다. * diff --git a/app/Support/ApiDoc/ApiDocScaffolder.php b/app/Support/ApiDoc/ApiDocScaffolder.php index fd6863dc..5dba71b0 100644 --- a/app/Support/ApiDoc/ApiDocScaffolder.php +++ b/app/Support/ApiDoc/ApiDocScaffolder.php @@ -116,6 +116,55 @@ class ApiDocScaffolder return implode("\n", $lines)."\n"; } + /** + * 대상(코어/확장)의 API 문서 디렉토리 목차(README.md)를 생성합니다. + * + * 도메인 파일별 링크 + 엔드포인트 수를 표로 나열해, 처음 진입한 개발자/AI 가 이 대상의 + * API 레퍼런스 전모를 한눈에 파악하도록 한다. 표는 `@generated` 블록 안에 두어 재생성 시 + * 갱신되며, 블록 밖 사람 서술(개요·주의사항)은 보존한다. 확장 API 문서의 발견성은 + * 코어 인덱스 생성기가 이 README 를 패턴 스캔(확장명 하드코딩 없이)해 확보한다. + * + * @param string $ownerLabel 소유 라벨 (예: '코어', '모듈 `sirsoft-ecommerce`') + * @param array $entries 도메인 파일 목록 + * @param string|null $existing 기존 README (사람 서술 보존용, 없으면 null) + * @return string README 마크다운 + */ + public function readmeIndex(string $ownerLabel, array $entries, ?string $existing = null): string + { + usort($entries, fn ($a, $b) => strcmp($a['domain'], $b['domain'])); + $total = array_sum(array_column($entries, 'count')); + + $rows = ['| 문서 | 도메인 | 엔드포인트 |', '| --- | --- | --- |']; + foreach ($entries as $e) { + $rows[] = "| [{$e['file']}]({$e['file']}) | `{$e['domain']}` | {$e['count']} |"; + } + + $genKey = 'api-readme-index'; + $lines = []; + $lines[] = '# API 레퍼런스 문서 목차'; + $lines[] = ''; + $lines[] = "> **소유**: {$ownerLabel} · **생성**: `php artisan api:docgen` (실측 기반)."; + $lines[] = '> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.'; + $lines[] = ''; + $lines[] = self::GEN_START.$genKey.' -->'; + $lines[] = '- **문서 수**: '.count($entries)." · **엔드포인트 수**: {$total}"; + $lines[] = ''; + $lines[] = implode("\n", $rows); + $lines[] = ''; + $lines[] = self::GEN_END; + $lines[] = ''; + + $generated = implode("\n", $lines)."\n"; + + // 블록 밖 사람 서술(개요 등)이 있으면 보존한다. + $prose = $existing !== null ? $this->extractHumanProse($existing, $genKey) : null; + if ($prose !== null && $prose !== '') { + $generated .= "\n".$prose."\n"; + } + + return $generated; + } + /** * 인증/권한 라인을 구성합니다. * diff --git a/docs/backend/api-documentation.md b/docs/backend/api-documentation.md index ae23b398..d8b24869 100644 --- a/docs/backend/api-documentation.md +++ b/docs/backend/api-documentation.md @@ -51,6 +51,25 @@ G7 의 REST API 는 라우트 `->name()` 규약은 있으나 엔드포인트별 도메인 그룹핑은 URI/라우트명 prefix 기준(`api.admin.users.*` → `users.md`)으로 커맨드가 자동 분류한다. +### 문서 목차와 발견성 (README.md 규약) + +각 대상(코어·확장)의 API 문서 디렉토리에는 `README.md` 목차가 있어야 한다. `api:docgen` 이 도메인 파일 +목록·엔드포인트 수를 담아 자동 생성한다(`@generated` 블록, 재생성 멱등, 블록 밖 사람 개요는 보존). + +- 코어: `docs/backend/api/README.md` +- 확장: `{modules|plugins}/_bundled/{id}/docs/api/README.md` + +확장 API 문서의 발견성은 이 목차를 통해 확보한다. 코어 인덱스 생성기(`generate-docs-index.cjs`)는 +확장명을 하드코딩하지 않고 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 **패턴 스캔**해, +CLAUDE.md·AGENTS.md·docs-index 의 "확장 API 레퍼런스" 표에 자동 편입한다(동적 로딩 원칙 — 코어는 규약과 +스캔 패턴만, 확장 이름은 파일 시스템에서 발견). 처음 진입하는 개발자/AI 의 도달 경로: + +```text +CLAUDE.md "확장 API 레퍼런스" 표 (자동 스캔) + → {확장}/docs/api/README.md (확장 소유 목차) + → {도메인}.md (엔드포인트별 파라미터·응답·예시) +``` + --- ## 표준 문서 포맷 diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md new file mode 100644 index 00000000..91339cb9 --- /dev/null +++ b/docs/backend/api/README.md @@ -0,0 +1,48 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 코어 · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 35 · **엔드포인트 수**: 291 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [activity-logs.md](activity-logs.md) | `activity-logs` | 3 | +| [attachment.md](attachment.md) | `attachment` | 1 | +| [attachments.md](attachments.md) | `attachments` | 4 | +| [auth.md](auth.md) | `auth` | 15 | +| [avatar.md](avatar.md) | `avatar` | 2 | +| [broadcasting.md](broadcasting.md) | `broadcasting` | 1 | +| [changelog.md](changelog.md) | `changelog` | 1 | +| [core-update.md](core-update.md) | `core-update` | 2 | +| [dashboard.md](dashboard.md) | `dashboard` | 5 | +| [extensions.md](extensions.md) | `extensions` | 3 | +| [identity.md](identity.md) | `identity` | 27 | +| [language-packs.md](language-packs.md) | `language-packs` | 15 | +| [layouts.md](layouts.md) | `layouts` | 2 | +| [license.md](license.md) | `license` | 1 | +| [locales.md](locales.md) | `locales` | 1 | +| [me.md](me.md) | `me` | 3 | +| [menus.md](menus.md) | `menus` | 10 | +| [modules.md](modules.md) | `modules` | 25 | +| [notification-channels.md](notification-channels.md) | `notification-channels` | 1 | +| [notification-definitions.md](notification-definitions.md) | `notification-definitions` | 5 | +| [notification-logs.md](notification-logs.md) | `notification-logs` | 3 | +| [notification-templates.md](notification-templates.md) | `notification-templates` | 4 | +| [notifications.md](notifications.md) | `notifications` | 14 | +| [password.md](password.md) | `password` | 1 | +| [permissions.md](permissions.md) | `permissions` | 1 | +| [plugins.md](plugins.md) | `plugins` | 27 | +| [profile.md](profile.md) | `profile` | 4 | +| [roles.md](roles.md) | `roles` | 7 | +| [schedules.md](schedules.md) | `schedules` | 12 | +| [search.md](search.md) | `search` | 1 | +| [seo.md](seo.md) | `seo` | 5 | +| [settings.md](settings.md) | `settings` | 15 | +| [templates.md](templates.md) | `templates` | 57 | +| [users.md](users.md) | `users` | 12 | +| [verify-password.md](verify-password.md) | `verify-password` | 1 | + + + diff --git a/modules/_bundled/gnuboard7-hello_module/docs/api/README.md b/modules/_bundled/gnuboard7-hello_module/docs/api/README.md new file mode 100644 index 00000000..2bf8b841 --- /dev/null +++ b/modules/_bundled/gnuboard7-hello_module/docs/api/README.md @@ -0,0 +1,14 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 모듈 `gnuboard7-hello_module` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 1 · **엔드포인트 수**: 2 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [memos.md](memos.md) | `memos` | 2 | + + + diff --git a/modules/_bundled/sirsoft-board/docs/api/README.md b/modules/_bundled/sirsoft-board/docs/api/README.md new file mode 100644 index 00000000..bdead860 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/README.md @@ -0,0 +1,23 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 모듈 `sirsoft-board` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 10 · **엔드포인트 수**: 80 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [activity-stats.md](activity-stats.md) | `activity-stats` | 1 | +| [board.md](board.md) | `board` | 18 | +| [board-activities.md](board-activities.md) | `board-activities` | 1 | +| [board-types.md](board-types.md) | `board-types` | 4 | +| [boards.md](boards.md) | `boards` | 37 | +| [dashboard.md](dashboard.md) | `dashboard` | 4 | +| [my-comments.md](my-comments.md) | `my-comments` | 1 | +| [reports.md](reports.md) | `reports` | 7 | +| [settings.md](settings.md) | `settings` | 5 | +| [users.md](users.md) | `users` | 2 | + + + diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/README.md b/modules/_bundled/sirsoft-ecommerce/docs/api/README.md new file mode 100644 index 00000000..593b69e1 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/README.md @@ -0,0 +1,46 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 모듈 `sirsoft-ecommerce` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 33 · **엔드포인트 수**: 231 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [addresses.md](addresses.md) | `addresses` | 6 | +| [brands.md](brands.md) | `brands` | 6 | +| [cart.md](cart.md) | `cart` | 11 | +| [categories.md](categories.md) | `categories` | 14 | +| [category-image.md](category-image.md) | `category-image` | 1 | +| [checkout.md](checkout.md) | `checkout` | 5 | +| [claim-reasons.md](claim-reasons.md) | `claim-reasons` | 8 | +| [coupons.md](coupons.md) | `coupons` | 4 | +| [currency.md](currency.md) | `currency` | 2 | +| [dashboard.md](dashboard.md) | `dashboard` | 4 | +| [extra-fee-templates.md](extra-fee-templates.md) | `extra-fee-templates` | 10 | +| [guest.md](guest.md) | `guest` | 5 | +| [inquiries.md](inquiries.md) | `inquiries` | 10 | +| [mileage.md](mileage.md) | `mileage` | 3 | +| [mileage-transactions.md](mileage-transactions.md) | `mileage-transactions` | 5 | +| [options.md](options.md) | `options` | 3 | +| [orders.md](orders.md) | `orders` | 22 | +| [payments.md](payments.md) | `payments` | 1 | +| [presets.md](presets.md) | `presets` | 4 | +| [product-common-infos.md](product-common-infos.md) | `product-common-infos` | 6 | +| [product-image.md](product-image.md) | `product-image` | 1 | +| [product-labels.md](product-labels.md) | `product-labels` | 6 | +| [product-notice-templates.md](product-notice-templates.md) | `product-notice-templates` | 7 | +| [products.md](products.md) | `products` | 30 | +| [promotion-coupons.md](promotion-coupons.md) | `promotion-coupons` | 9 | +| [review-image.md](review-image.md) | `review-image` | 1 | +| [reviews.md](reviews.md) | `reviews` | 12 | +| [settings.md](settings.md) | `settings` | 10 | +| [shipping-carriers.md](shipping-carriers.md) | `shipping-carriers` | 7 | +| [shipping-country.md](shipping-country.md) | `shipping-country` | 2 | +| [shipping-policies.md](shipping-policies.md) | `shipping-policies` | 11 | +| [users.md](users.md) | `users` | 2 | +| [wishlist.md](wishlist.md) | `wishlist` | 3 | + + + diff --git a/modules/_bundled/sirsoft-page/docs/api/README.md b/modules/_bundled/sirsoft-page/docs/api/README.md new file mode 100644 index 00000000..6ad1ecf6 --- /dev/null +++ b/modules/_bundled/sirsoft-page/docs/api/README.md @@ -0,0 +1,15 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 모듈 `sirsoft-page` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 2 · **엔드포인트 수**: 17 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [attachments.md](attachments.md) | `attachments` | 3 | +| [pages.md](pages.md) | `pages` | 14 | + + + diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md new file mode 100644 index 00000000..19f7e45f --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md @@ -0,0 +1,15 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-ckeditor5` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 2 · **엔드포인트 수**: 2 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [images.md](images.md) | `images` | 1 | +| [upload.md](upload.md) | `upload` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-gdpr/docs/api/README.md b/plugins/_bundled/sirsoft-gdpr/docs/api/README.md new file mode 100644 index 00000000..cf0f8d6c --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/docs/api/README.md @@ -0,0 +1,17 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-gdpr` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 4 · **엔드포인트 수**: 15 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [consent.md](consent.md) | `consent` | 7 | +| [consent-log.md](consent-log.md) | `consent-log` | 1 | +| [policy-versions.md](policy-versions.md) | `policy-versions` | 4 | +| [settings.md](settings.md) | `settings` | 3 | + + + diff --git a/plugins/_bundled/sirsoft-marketing/docs/api/README.md b/plugins/_bundled/sirsoft-marketing/docs/api/README.md new file mode 100644 index 00000000..73920976 --- /dev/null +++ b/plugins/_bundled/sirsoft-marketing/docs/api/README.md @@ -0,0 +1,15 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-marketing` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 2 · **엔드포인트 수**: 2 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [channels.md](channels.md) | `channels` | 1 | +| [settings.md](settings.md) | `settings` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md new file mode 100644 index 00000000..067f7063 --- /dev/null +++ b/plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md @@ -0,0 +1,18 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-pay_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 5 · **엔드포인트 수**: 22 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [cbt.md](cbt.md) | `cbt` | 2 | +| [orders.md](orders.md) | `orders` | 13 | +| [payment.md](payment.md) | `payment` | 5 | +| [transaction.md](transaction.md) | `transaction` | 1 | +| [vbank.md](vbank.md) | `vbank` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md new file mode 100644 index 00000000..41baa7dd --- /dev/null +++ b/plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md @@ -0,0 +1,14 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-verification_kginicis` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 1 · **엔드포인트 수**: 1 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [identity.md](identity.md) | `identity` | 1 | + + + diff --git a/tests/Unit/Support/ApiDoc/ApiDocPipelineTest.php b/tests/Unit/Support/ApiDoc/ApiDocPipelineTest.php index 0e370730..eca69af5 100644 --- a/tests/Unit/Support/ApiDoc/ApiDocPipelineTest.php +++ b/tests/Unit/Support/ApiDoc/ApiDocPipelineTest.php @@ -840,4 +840,52 @@ MD; $this->assertStringNotContainsString('| identifier | query |', $section); $this->assertSame(1, substr_count($section, '| path | path |')); } + + #[Test] + public function readme_목차는_도메인_파일과_엔드포인트_수를_표로_나열한다(): void + { + $scaffolder = new ApiDocScaffolder; + + $entries = [ + ['domain' => 'products', 'file' => 'products.md', 'count' => 30], + ['domain' => 'orders', 'file' => 'orders.md', 'count' => 22], + ]; + + $readme = $scaffolder->readmeIndex('모듈 `sirsoft-ecommerce`', $entries); + + // 소유 라벨·집계·도메인 링크가 표에 나타난다. + $this->assertStringContainsString('# API 레퍼런스 문서 목차', $readme); + $this->assertStringContainsString('모듈 `sirsoft-ecommerce`', $readme); + $this->assertStringContainsString('**문서 수**: 2 · **엔드포인트 수**: 52', $readme); + $this->assertStringContainsString('| [products.md](products.md) | `products` | 30 |', $readme); + $this->assertStringContainsString('| [orders.md](orders.md) | `orders` | 22 |', $readme); + // 도메인 알파벳 정렬 — orders 가 products 보다 먼저. + $this->assertLessThan(strpos($readme, 'products.md'), strpos($readme, 'orders.md')); + } + + #[Test] + public function readme_재생성은_generated_블록만_갱신하고_사람_서술을_보존한다(): void + { + $scaffolder = new ApiDocScaffolder; + + $entries = [['domain' => 'pages', 'file' => 'pages.md', 'count' => 5]]; + $first = $scaffolder->readmeIndex('모듈 `sirsoft-page`', $entries); + + // @generated 블록 밖에 사람 개요 서술을 덧붙인다. + $human = $first."\n## 개요\n\n이 모듈은 정적 페이지를 관리합니다. (사람 서술)\n"; + + // 엔드포인트 수가 바뀐 재생성. + $entriesUpdated = [['domain' => 'pages', 'file' => 'pages.md', 'count' => 6]]; + $regen = $scaffolder->readmeIndex('모듈 `sirsoft-page`', $entriesUpdated, $human); + + // 표는 갱신되고(6), 사람 서술은 보존된다. + $this->assertStringContainsString('엔드포인트 수**: 6', $regen); + $this->assertStringContainsString('이 모듈은 정적 페이지를 관리합니다. (사람 서술)', $regen); + // 재재생성해도 사람 서술 중복 없이 멱등. + $regen2 = $scaffolder->readmeIndex('모듈 `sirsoft-page`', $entriesUpdated, $regen); + $this->assertSame( + substr_count($regen, '(사람 서술)'), + substr_count($regen2, '(사람 서술)') + ); + } }