Files
Gnuboard7/app/Support/ExtensionDoc/FrontendInventory.php
T
HeuJung 11175d35d6 feat(core,docs): 확장별 개발자 문서 생성기와 검사 하네스 구축
번들 확장 20개 중 에이전트·확장개발자가 읽을 수 있는 문서를 가진 확장이 0개였다.
`docs/api/**` 는 "엔드포인트가 무엇을 받고 무엇을 돌려주는가" 만 답하고, 확장을 고치려는
쪽이 실제로 묻는 것(왜 이렇게 설계됐는가 / 어디를 확장해야 하는가 / 무엇을 건드리면 안
되는가)은 어디에도 없었다. 그 결과 확장이 발행하는 훅은 확장점인데도 사실상 비공개였다.

이 커밋은 인프라만 담는다. 문서 집필은 확장별로 순차 진행한다.

- `php artisan ext:docgen` — 진입 클래스의 선언형 getter 를 실제로 호출하고 소스를 스캔해
 훅·라우트·권한·메뉴·설정·모델·레이아웃·핸들러·테스트 경로·의존 관계를 실측한다.
 `_bundled` 를 읽는다 (활성 디렉토리에 같은 FQCN 이 로드돼 있으면 eval-rename 으로 우회).
- 자동 생성 블록은 **안쪽만** 교체한다. 사람 서술이 소실될 경로를 만들지 않으려고 파괴적
 재생성 플래그를 두지 않았고, 문서에 없는 블록 키는 주입하지 않고 누락으로 보고한다.
- 필수 문서·섹션·블록 목록은 `ExtensionDocScaffolder::DOCUMENTS` 단일 SSoT 다. 검사
 스크립트는 이를 복제하지 않고 `--check --json` 을 소비하며, PHP 를 못 돌리면 "이상 0건"
 이 아니라 "점검 불가" 로 구분 보고한다.
- 두 audit 룰은 도입 시점 전수가 공허 통과하므로(대상 문서 0건) 픽스처 테스트가 판정식을
 잠근다. 스캐너·mermaid·검사 스크립트 축도 같은 이유로 픽스처를 함께 둔다.

동반 수정: 훅이 넘기는 `file_path` 는 항상 절대경로인데 `FILE_RULES` 다수가 루트로
앵커해 있어 그 규칙들이 조용히 죽어 있었다(추적 파일 440개가 절대경로에서 0건 매치).
`toRepoRelative` 로 정규화하고 세 경로 형태의 판정 일치를 테스트로 고정했다.
문서 인덱스 생성기의 CRLF 문서 제목·TL;DR 추출 누락도 같은 성격이라 함께 고쳤다.
2026-08-31 07:58:44 +09:00

532 lines
18 KiB
PHP

<?php
namespace App\Support\ExtensionDoc;
use Illuminate\Support\Facades\File;
/**
* 확장 프론트엔드 진입점 수집기
*
* 레이아웃 JSON · 액션 핸들러 · 전역 재등록 진입점 · 컴포넌트 · 빌드 산출물을 수집합니다.
*
* 레이아웃 경로는 유형마다 다릅니다 — 모듈/플러그인은 `resources/layouts/`, 템플릿은
* `layouts/` 가 루트입니다. 유형별 분기를 이 수집기 한 곳에 두어, 소비자(스캐폴더·검사
* 스크립트)가 경로 규약을 각자 알 필요가 없게 합니다.
*/
class FrontendInventory
{
/**
* 확장의 프론트엔드 표면을 수집합니다.
*
* @param array<string, mixed> $record ExtensionInventory 레코드
* @return array<string, mixed> 프론트 인벤토리
*/
public function collect(array $record): array
{
$layouts = $this->collectLayouts($record);
return [
'layoutRoot' => $this->layoutRoot($record),
'layouts' => $layouts,
'layoutGroups' => $this->groupLayouts($layouts),
'layoutExtensions' => $this->collectJsonFiles($record, $this->layoutExtensionRoot($record)),
'handlers' => $this->collectHandlers($record),
'entryPoints' => $this->collectEntryPoints($record),
'components' => $this->collectComponents($record),
'builtAssets' => $this->collectBuiltAssets($record),
'vendoredAssets' => $this->collectVendoredAssets($record),
'customDir' => is_dir($record['path'].DIRECTORY_SEPARATOR.'custom'),
'editorSpec' => $this->collectEditorSpec($record),
'routesJson' => is_file($record['path'].DIRECTORY_SEPARATOR.'routes.json') ? 'routes.json' : null,
'routeCount' => $this->countDeclaredRoutes($record),
];
}
/**
* `routes.json` 에 선언된 주소 수를 셉니다 (셀 수 없으면 null).
*
* 템플릿은 선언형 표면(`getRoutes()`)을 갖지 않으므로 집계 배지의 "라우트 수" 가
* 구조적으로 항상 0 이 됩니다 — 주소를 `routes.json` 에 적기 때문입니다. 0 은 사실이
* 아닌데 템플릿에는 "확인하지 못함" 안내도 붙지 않아(선언형 표면 부재가 정상이라)
* 단서 없이 사실처럼 읽힙니다. 실측은 `sirsoft-basic` 40 · `sirsoft-admin_basic` 29.
*
* "라우트 수 = 주소 개수" 규율은 모듈·플러그인과 같습니다.
*
* 읽지 못한 것과 없는 것은 구분합니다 — 파일이 없으면 `null`, 파일이 깨졌어도 `null`
* 입니다. 0 을 돌려주면 "주소가 없다" 는 사실 주장이 됩니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return int|null 주소 수, 셀 수 없으면 null
*/
private function countDeclaredRoutes(array $record): ?int
{
$path = $record['path'].DIRECTORY_SEPARATOR.'routes.json';
if (! is_file($path)) {
return null;
}
$data = json_decode((string) @file_get_contents($path), true);
if (! is_array($data) || ! isset($data['routes']) || ! is_array($data['routes'])) {
return null;
}
return count(array_filter(
$data['routes'],
static fn (mixed $row): bool => is_array($row) && isset($row['path']),
));
}
/**
* 유형별 레이아웃 루트(확장 루트 기준 상대 경로)를 반환합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return string 레이아웃 루트
*/
private function layoutRoot(array $record): string
{
return $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'layouts' : 'resources/layouts';
}
/**
* 유형별 레이아웃 확장 조각 루트(확장 루트 기준 상대 경로)를 반환합니다.
*
* 레이아웃과 같은 규율이다 — 템플릿은 확장 루트 직속, 모듈·플러그인은 `resources/`
* 아래. 형제인 `layoutRoot()` 만 유형을 갈랐던 탓에 템플릿의 조각이 항상 빈 목록으로
* 수집돼(`sirsoft-basic` 실측 1건) 문서에 "없음" 으로 실렸다.
*
* @param array<string, mixed> $record 확장 레코드
* @return string 레이아웃 확장 루트
*/
private function layoutExtensionRoot(array $record): string
{
return $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'extensions' : 'resources/extensions';
}
/**
* 레이아웃 JSON 을 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, array<string, mixed>> 레이아웃 목록
*/
private function collectLayouts(array $record): array
{
$layouts = [];
foreach ($this->collectJsonFiles($record, $this->layoutRoot($record)) as $rel) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
$root = $this->layoutRoot($record).'/';
$inner = str_starts_with($rel, $root) ? substr($rel, strlen($root)) : $rel;
$segments = explode('/', $inner);
$layouts[] = [
'relFile' => $rel,
'name' => basename($inner, '.json'),
'group' => count($segments) > 1 ? $segments[0] : '(root)',
'partial' => str_starts_with(basename($inner), '_'),
'extends' => $this->readJsonString($abs, 'extends'),
];
}
return $layouts;
}
/**
* 레이아웃을 그룹(admin/user 등)별로 집계합니다.
*
* @param array<int, array<string, mixed>> $layouts 레이아웃 목록
* @return array<string, int> 그룹 => 개수
*/
private function groupLayouts(array $layouts): array
{
$groups = [];
foreach ($layouts as $layout) {
$groups[$layout['group']] = ($groups[$layout['group']] ?? 0) + 1;
}
ksort($groups);
return $groups;
}
/**
* 액션 핸들러 이름을 수집합니다.
*
* `handlerMap` 객체의 최상위 키가 핸들러 이름이며, 엔트리포인트가
* `{identifier}.{name}` 으로 네임스페이스를 붙여 등록합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{namespace: string|null, names: array<int, string>, source: string|null}
*/
private function collectHandlers(array $record): array
{
$candidates = [
'resources/js/handlers/index.ts',
'src/handlers/index.ts',
'resources/js/index.ts',
'src/index.ts',
];
foreach ($candidates as $rel) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (! is_file($abs)) {
continue;
}
$content = (string) file_get_contents($abs);
$names = [];
// 핸들러 맵 이름은 확장마다 다르다 (`handlerMap` / `handlers` + alias 재수출).
// 앞선 후보가 alias 대입(`handlerMap = handlers;`)이면 객체 리터럴이 아니므로 비고,
// 다음 후보에서 실제 리터럴을 찾는다.
foreach (['handlerMap', 'handlers'] as $objectName) {
$names = $this->objectKeys($content, $objectName);
if ($names !== []) {
break;
}
}
if ($names === []) {
$names = $this->literalHandlerNames($content);
}
if ($names !== []) {
return [
'namespace' => $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? null : $record['id'],
'names' => $names,
'source' => $rel,
];
}
}
return ['namespace' => null, 'names' => [], 'source' => null];
}
/**
* 전역 재등록 진입점(`window.__[Name]`)과 초기화 함수를 수집합니다.
*
* 로케일 전환 후 액션이 무반응이 되는 결함을 막는 계약이므로, 노출 여부 자체가
* 문서에 드러나야 합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{global: string|null, initFunction: string|null, source: string|null}
*/
private function collectEntryPoints(array $record): array
{
$candidates = ['resources/js/index.ts', 'src/index.ts', 'src/index.tsx'];
foreach ($candidates as $rel) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (! is_file($abs)) {
continue;
}
$content = (string) file_get_contents($abs);
$global = null;
if (preg_match('/window[^;\n]*\)\.\s*(__\w+)\s*=/', $content, $m)) {
$global = $m[1];
}
$init = null;
foreach (['initModule', 'initPlugin', 'initTemplate'] as $fn) {
if (preg_match('/function\s+'.$fn.'\s*\(/', $content)) {
$init = $fn;
break;
}
}
if ($global !== null || $init !== null) {
return ['global' => $global, 'initFunction' => $init, 'source' => $rel];
}
}
return ['global' => null, 'initFunction' => null, 'source' => null];
}
/**
* 템플릿이 제공하는 컴포넌트를 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{total: int, byCategory: array<string, int>, root: string|null}
*/
private function collectComponents(array $record): array
{
$root = 'src/components';
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $root);
if (! is_dir($dir)) {
return ['total' => 0, 'byCategory' => [], 'root' => null];
}
$byCategory = [];
$total = 0;
foreach (File::allFiles($dir) as $file) {
if ($file->getExtension() !== 'tsx') {
continue;
}
if (str_contains(str_replace('\\', '/', $file->getPathname()), '/__tests__/')) {
continue;
}
$relative = str_replace('\\', '/', $file->getRelativePath());
$category = $relative === '' ? '(root)' : explode('/', $relative)[0];
$byCategory[$category] = ($byCategory[$category] ?? 0) + 1;
$total++;
}
ksort($byCategory);
return ['total' => $total, 'byCategory' => $byCategory, 'root' => $root];
}
/**
* 커밋된 빌드 산출물을 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, string> 산출물 상대 경로
*/
private function collectBuiltAssets(array $record): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.'dist';
if (! is_dir($dir)) {
return [];
}
$files = [];
foreach (File::allFiles($dir) as $file) {
if (! in_array($file->getExtension(), ['js', 'css'], true)) {
continue;
}
$rel = $this->relative($record, $file->getPathname());
if (str_starts_with($rel, 'dist/vendor/')) {
continue;
}
$files[] = $rel;
}
sort($files);
return $files;
}
/**
* 동봉(self-hosted) 제3자 자산을 수집합니다.
*
* 외부 CDN 대신 확장이 자기 서버에서 제공하는 구동 자산이며, 버전이 디렉토리명에
* 드러나므로 문서에 그대로 노출합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array<int, string> `{라이브러리}/{버전}` 목록
*/
private function collectVendoredAssets(array $record): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.'dist'.DIRECTORY_SEPARATOR.'vendor';
if (! is_dir($dir)) {
return [];
}
$entries = [];
foreach (File::directories($dir) as $libPath) {
$lib = basename($libPath);
$versions = array_map('basename', File::directories($libPath));
if ($versions === []) {
$entries[] = $lib;
continue;
}
foreach ($versions as $version) {
$entries[] = $lib.'/'.$version;
}
}
sort($entries);
return $entries;
}
/**
* 편집기 스펙(단일 파일 / 분할) 보유 형태를 판정합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @return array{manifest: bool, split: int}
*/
private function collectEditorSpec(array $record): array
{
$manifest = is_file($record['path'].DIRECTORY_SEPARATOR.'editor-spec.json');
$splitDir = $record['path'].DIRECTORY_SEPARATOR.'editor-spec';
$split = 0;
if (is_dir($splitDir)) {
foreach (File::allFiles($splitDir) as $file) {
if ($file->getExtension() === 'json') {
$split++;
}
}
}
return ['manifest' => $manifest, 'split' => $split];
}
/**
* 하위 디렉토리의 JSON 파일 상대 경로를 수집합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param string $sub 확장 루트 기준 하위 경로
* @return array<int, string> 상대 경로 목록 (정렬)
*/
private function collectJsonFiles(array $record, string $sub): array
{
$dir = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sub);
if (! is_dir($dir)) {
return [];
}
$files = [];
foreach (File::allFiles($dir) as $file) {
if ($file->getExtension() === 'json') {
$files[] = $this->relative($record, $file->getPathname());
}
}
sort($files);
return $files;
}
/**
* JSON 파일에서 최상위 문자열 키 값을 읽습니다.
*
* @param string $absolute 파일 절대 경로
* @param string $key 키
* @return string|null 값 (없거나 문자열이 아니면 null)
*/
private function readJsonString(string $absolute, string $key): ?string
{
$data = json_decode((string) @file_get_contents($absolute), true);
return is_array($data) && isset($data[$key]) && is_string($data[$key]) ? $data[$key] : null;
}
/**
* `export const {name} = { ... }` 객체의 최상위 키를 뽑습니다.
*
* @param string $content TS 소스
* @param string $name 객체 변수명
* @return array<int, string> 키 목록
*/
private function objectKeys(string $content, string $name): array
{
// 타입 주석이 붙은 선언(`handlerMap: Record<string, (...a) => unknown> = {`)까지 잡되,
// alias 대입(`handlerMap = handlers;`)은 잡지 않는다. `[^;{]*` 가 문(statement) 경계를
// 넘지 못하게 하고, `=\s*\{` 로 객체 리터럴 대입만 받는다.
if (! preg_match('/\b'.preg_quote($name, '/').'\b[^;{]*=\s*\{/', $content, $m, PREG_OFFSET_CAPTURE)) {
return [];
}
$open = strpos($content, '{', (int) $m[0][1]);
if ($open === false) {
return [];
}
$depth = 0;
$len = strlen($content);
$close = null;
for ($i = $open; $i < $len; $i++) {
$ch = $content[$i];
if ($ch === '{') {
$depth++;
} elseif ($ch === '}') {
$depth--;
if ($depth === 0) {
$close = $i;
break;
}
}
}
if ($close === null) {
return [];
}
$body = substr($content, $open + 1, $close - $open - 1);
$keys = [];
foreach (explode("\n", $body) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '//') || str_starts_with($line, '*') || str_starts_with($line, '/*')) {
continue;
}
if (preg_match('/^([A-Za-z_$][\w$]*)\s*[,:]/', $line, $km)) {
$keys[] = $km[1];
}
}
return array_values(array_unique($keys));
}
/**
* `registerHandler(...)` 인자에서 핸들러 이름을 뽑습니다.
*
* 두 형태를 모두 봅니다 — 리터럴(`'name'`)과 식별자 보간 템플릿 리터럴
* (`` `${PLUGIN_IDENTIFIER}.name` ``). 후자를 놓치면 핸들러 맵을 쓰지 않고 개별
* 등록하는 확장(gdpr 등)이 "핸들러 0개" 로 잘못 집계됩니다.
*
* @param string $content TS 소스
* @return array<int, string> 핸들러 이름 목록
*/
private function literalHandlerNames(string $content): array
{
$names = [];
if (preg_match_all("/registerHandler\s*\(\s*'([^']+)'/", $content, $m)) {
foreach ($m[1] as $name) {
$names[] = $this->stripIdentifierPrefix($name);
}
}
if (preg_match_all('/registerHandler\s*\(\s*`\$\{[^}]+\}\.([A-Za-z_$][\w$]*)`/', $content, $m)) {
$names = array_merge($names, $m[1]);
}
return array_values(array_unique($names));
}
/**
* `{identifier}.{handler}` 형태의 이름에서 확장 식별자 접두를 떼어냅니다.
*
* 등록 코드는 네임스페이스를 붙인 전체 이름을 쓰지만, 표는 핸들러 이름과 호출 이름을
* 각각 보여주므로 접두를 벗긴 이름이 필요합니다.
*
* @param string $name 등록된 이름
* @return string 접두를 뗀 핸들러 이름
*/
private function stripIdentifierPrefix(string $name): string
{
$pos = strrpos($name, '.');
return $pos === false ? $name : substr($name, $pos + 1);
}
/**
* 확장 루트 기준 상대 경로로 변환합니다.
*
* @param array<string, mixed> $record 확장 레코드
* @param string $absolute 절대 경로
* @return string 상대 경로 (POSIX 구분자)
*/
private function relative(array $record, string $absolute): string
{
$base = rtrim((string) $record['path'], '/\\').DIRECTORY_SEPARATOR;
$rel = str_starts_with($absolute, $base) ? substr($absolute, strlen($base)) : $absolute;
return str_replace('\\', '/', $rel);
}
}