Files
Gnuboard7/app/Console/Commands/Extension/ExtDocgenCommand.php
T
HeuJung 3a0aba85bc feat(core,extensions): 확장 문서 호칭 통일과 레이아웃 편집기 문서 축 신설
확장 문서의 제품 호칭을 「그누보드7」로 통일했다. 가 지적한 두 문구는
개별 오타가 아니라 생성기가 찍는 정형 문구여서, 산출물이 아니라 방출 지점
세 곳을 먼저 고쳤다 — 그러지 않으면 21번째 확장부터 다시 샌다. 정리 범위는
확장 문서와 그 생성기까지이며, 코어 docs 와 언어팩 표시명은 의도적으로 남긴다.

레이아웃 편집기 대응 문서가 없던 문제는 확장마다 docs/editor-spec.md 를
두어 닫았다. 실측은 EditorSpecCollector 가 유지하며, 합본에 런타임과 같은
EditorSpecAssembler 를 써서 문서가 말하는 스펙과 편집기가 읽는 스펙이
갈라질 경로를 두지 않았다. 스펙을 두지 않은 확장에도 문서를 둔다 — 미보유가
정상일 수 있고, 그 정상 여부를 적을 자리가 없으면 다음 사람이 부재를 누락으로
오해하거나 필요한 시점을 놓친다.

초안이 낸 수치 다섯 건이 틀렸고 전부 오류 없이 "사실" 로 실릴 값이었다.
블록 최상위 키를 세어 팔레트가 79 대신 3 이 되던 것, `_` 접두 일괄 배제가
실제 항목을 삼키던 것 등을 정정했다.

게이트를 인위적으로 깨뜨려 점검한 결과 사각 하나가 드러났다 — 채워 넣으라는
표시만 지우고 서술을 쓰지 않으면 검사를 통과해, 빈 문서가 완비로 집계됐다.
미채움을 두 축으로 만들어 닫았고 되돌림으로 검산했다. 편집기 스펙 편집 시
규정이 주입되지 않던 것과 스캐폴딩 안내가 새 문서를 빠뜨리던 것도 함께 고쳤다.
2026-09-01 12:02:55 +09:00

454 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Console\Commands\Extension;
use App\Support\ExtensionDoc\ExtensionDocContext;
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
use App\Support\ExtensionDoc\ExtensionInventory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
/**
* 확장 개발자 문서 생성 커맨드
*
* 번들 확장의 `AGENTS.md` · `README.md` · `docs/**` 를 스캐폴딩하고, 코드에서 실측 가능한
* 부분(훅·라우트·권한·모델·레이아웃·테스트 경로)을 자동 생성 블록 안에 갱신합니다.
*
* 기본 동작이 **블록 안쪽 교체**이므로 사람이 쓴 서술이 소실될 경로가 없습니다.
* 그래서 `--force` 같은 파괴적 플래그를 두지 않고, 신규 파일 생성만 `--init` 으로 분리합니다.
*/
class ExtDocgenCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'ext:docgen
{--scope=all : 범위 (all, module:vendor-id, plugin:vendor-id, template:vendor-id)}
{--init : 문서가 없는 확장에 골격 파일 생성 (기존 파일은 건너뜀)}
{--check : 생성하지 않고 누락·드리프트만 리포트}
{--json : 기계 판독 출력}
{--dry-run : 대상과 실측 집계만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = '번들 확장의 개발자 문서(AGENTS.md/README.md/docs)를 실측 기반으로 생성·갱신합니다';
/**
* @var array<int, array{type: string, id: string, manifest: string, reason: string}> manifest 를 읽지 못해 대상에서 빠진 디렉토리
*/
private array $malformed = [];
/**
* 커맨드를 실행합니다.
*
* @param ExtensionInventory $inventory 번들 확장 인벤토리
* @param ExtensionDocContext $context 수집 컨텍스트 조립기
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @return int 종료 코드
*/
public function handle(
ExtensionInventory $inventory,
ExtensionDocContext $context,
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) {
$ctx = $context->build($record);
$results[] = $this->processExtension($ctx, $scaffolder);
}
return $this->report($results, $scope);
}
/**
* 단일 확장을 처리합니다.
*
* @param array<string, mixed> $ctx 수집 컨텍스트
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @return array<string, mixed> 처리 결과
*/
private function processExtension(array $ctx, ExtensionDocScaffolder $scaffolder): array
{
$record = $ctx['record'];
$stats = ExtensionDocScaffolder::statsOf($ctx);
$result = [
'type' => $record['type'],
'id' => $record['id'],
'relPath' => $record['relPath'],
'version' => $record['version'],
'stats' => $stats,
'surfaceAvailable' => $ctx['surface']['available'],
'surfaceReason' => $ctx['surface']['reason'],
'surfaceErrors' => $ctx['surface']['errors'],
'documents' => [],
'created' => [],
'updated' => [],
'missingDocuments' => [],
'missingSections' => [],
'missingBlocks' => [],
'driftedBlocks' => [],
'orphanBlocks' => [],
'unfilled' => [],
];
if ($this->option('dry-run')) {
$result['documents'] = ExtensionDocScaffolder::documentsForType($record['type']);
return $result;
}
// --init 은 파일을 만든 **뒤에** 블록을 다시 렌더한다.
// `docs-index` · `doc-toc` 블록은 문서 파일의 존재 여부를 읽어 링크와 상태를 채우므로,
// 생성 전에 렌더한 본문은 방금 만든 문서를 전부 "미작성" 으로 표기한다.
if ($this->option('init') && ! $this->option('check')) {
$this->initSkeletons($ctx, $scaffolder, $result);
}
$bodies = $scaffolder->renderBlocks($ctx);
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
if (! is_file($abs)) {
$result['missingDocuments'][] = $doc;
continue;
}
$content = (string) File::get($abs);
// 섹션 골격 검사 — 헤딩 누락은 문서가 형식을 벗어났다는 신호다.
// 유형별로 절 이름이 달라지는 자리가 있으므로 sectionsFor() 판정을 쓴다.
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
$result['missingSections'][] = $doc.' → '.$section;
}
}
// 미채움 마커 잔량
foreach (ExtensionDocScaffolder::todoMarkers() as $marker) {
$count = substr_count($content, $marker);
if ($count > 0) {
$result['unfilled'][] = ['doc' => $doc, 'marker' => $marker, 'count' => $count];
}
}
// 마커를 지우기만 하고 서술을 안 쓴 자리도 미채움이다. 이 축이 없으면
// "TODO 를 삭제한 문서" 가 "채운 문서" 와 같은 모양으로 통과한다.
$emptyIntents = ExtensionDocScaffolder::emptyIntentBlocks($content);
if ($emptyIntents > 0) {
$result['unfilled'][] = [
'doc' => $doc,
'marker' => ExtensionDocScaffolder::EMPTY_INTENT_LABEL,
'count' => $emptyIntents,
];
}
$docBodies = [];
foreach (ExtensionDocScaffolder::blocksFor($doc) as $key) {
if (array_key_exists($key, $bodies)) {
$docBodies[$key] = $bodies[$key];
}
}
$merged = ExtensionDocScaffolder::replaceBlocks($content, $docBodies);
foreach ($merged['missing'] as $key) {
$result['missingBlocks'][] = $doc.' → '.$key;
}
// 문서에 실재하지만 `DOCUMENTS` 가 모르는 블록 — 생성기가 순회 대상으로 삼지
// 않으므로 **영영 갱신되지 않고 누락으로도 보고되지 않는다**. 낡은 실측을 단 채
// `--check` 는 이상 0건을 보고한다. 절을 옮기거나 목록을 재편하면 즉시 생긴다.
foreach (ExtensionDocScaffolder::presentBlockKeys($content) as $key) {
if (! in_array($key, ExtensionDocScaffolder::blocksFor($doc), true)) {
$result['orphanBlocks'][] = $doc.' → '.$key;
}
}
if ($this->option('check')) {
if (! $merged['unchanged']) {
foreach ($merged['replaced'] as $key) {
if ($this->blockDiffers($content, $key, $docBodies[$key])) {
$result['driftedBlocks'][] = $doc.' → '.$key;
}
}
}
continue;
}
if (! $merged['unchanged']) {
File::put($abs, $merged['content']);
// 방금 만든 파일은 '생성' 으로만 보고한다 (같은 실행의 2차 렌더는 생성의 일부).
if (! in_array($doc, $result['created'], true)) {
$result['updated'][] = $doc;
}
}
}
return $result;
}
/**
* 문서가 없는 자리에 골격 파일을 만듭니다 (기존 파일은 건드리지 않습니다).
*
* @param array<string, mixed> $ctx 수집 컨텍스트
* @param ExtensionDocScaffolder $scaffolder 문서 스캐폴더
* @param array<string, mixed> $result 처리 결과 (created 누적)
*/
private function initSkeletons(array $ctx, ExtensionDocScaffolder $scaffolder, array &$result): void
{
$record = $ctx['record'];
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
if (is_file($abs)) {
continue;
}
File::ensureDirectoryExists(dirname($abs));
File::put($abs, $scaffolder->skeleton($doc, $ctx));
$result['created'][] = $doc;
}
}
/**
* 문서에 이미 들어 있는 블록 본문이 새로 렌더한 본문과 다른지 판정합니다.
*
* @param string $content 문서 내용
* @param string $key 블록 키
* @param string $body 새 본문
* @return bool 다르면 true
*/
private function blockDiffers(string $content, string $key, string $body): bool
{
$startPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+START\b.*?-->/s';
$endPattern = '/<!--\s*@generated:'.preg_quote($key, '/').'\s+END\s*-->/s';
if (! preg_match($startPattern, $content, $sm, PREG_OFFSET_CAPTURE)) {
return false;
}
$from = (int) $sm[0][1] + strlen($sm[0][0]);
if (! preg_match($endPattern, $content, $em, PREG_OFFSET_CAPTURE, $from)) {
return false;
}
$existing = trim(substr($content, $from, ((int) $em[0][1]) - $from));
return $existing !== trim($body);
}
/**
* 처리 결과를 출력하고 종료 코드를 결정합니다.
*
* @param array<int, array<string, mixed>> $results 확장별 결과
* @param string $scope 범위
* @return int 종료 코드
*/
private function report(array $results, string $scope): int
{
$issues = 0;
foreach ($results as $result) {
$issues += count($result['missingDocuments'])
+ count($result['missingSections'])
+ count($result['missingBlocks'])
+ count($result['driftedBlocks'])
+ count($result['orphanBlocks']);
}
if ($this->option('json')) {
$this->line((string) json_encode([
'scope' => $scope,
'mode' => $this->mode(),
'extensions' => $results,
'malformed' => $this->malformed,
'issues' => $issues,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
return ($this->option('check') && $issues > 0) ? self::FAILURE : self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->info(count($results).'개 확장 (scope='.$scope.')');
$this->newLine();
foreach ($results as $result) {
$parts = [];
foreach ($result['stats'] as $label => $value) {
// 세지 못한 지표는 `null` 로 온다. 그대로 보간하면 빈 칸이 되어
// "0" 과도 "확인 못함" 과도 구분되지 않는다.
$parts[] = $value === null
? "{$label} ".ExtensionDocScaffolder::STAT_UNMEASURED
: "{$label} {$value}";
}
$this->line(sprintf(' [%s] %s v%s', $result['type'], $result['id'], $result['version']));
$this->line(' '.implode(' · ', $parts));
$this->line(' 문서 '.count($result['documents']).'종: '.implode(', ', $result['documents']));
if (! $result['surfaceAvailable'] && $result['surfaceReason'] !== null) {
$this->line(' 선언형 표면: '.$result['surfaceReason']);
}
if ($result['surfaceErrors'] !== []) {
foreach ($result['surfaceErrors'] as $getter => $message) {
$this->line(" ⚠ {$getter}(): {$message}");
}
}
}
return self::SUCCESS;
}
foreach ($results as $result) {
$label = sprintf('[%s] %s', $result['type'], $result['id']);
if ($result['created'] !== []) {
$this->info("{$label} 생성: ".implode(', ', $result['created']));
}
if ($result['updated'] !== []) {
$this->info("{$label} 갱신: ".implode(', ', $result['updated']));
}
if ($result['missingDocuments'] !== []) {
$this->warn("{$label} 문서 없음: ".implode(', ', $result['missingDocuments']));
}
if ($result['missingBlocks'] !== []) {
$this->warn("{$label} 자동 생성 블록 없음: ".implode(', ', $result['missingBlocks']));
}
if ($result['missingSections'] !== []) {
$this->warn("{$label} 필수 섹션 없음: ".implode(', ', $result['missingSections']));
}
if ($result['driftedBlocks'] !== []) {
$this->warn("{$label} 블록 드리프트(코드 실측과 불일치): ".implode(', ', $result['driftedBlocks']));
}
if ($result['orphanBlocks'] !== []) {
$this->warn("{$label} 갱신 대상이 아닌 자동 생성 블록(고아): ".implode(', ', $result['orphanBlocks']));
}
// 미채움 잔량은 계산만 하고 `--json` 에만 실려 있었다 — 계획이 이 마커를 둔
// 이유가 "잔량 집계" 이므로 사람이 읽는 출력에도 낸다.
if ($result['unfilled'] !== []) {
$total = array_sum(array_column($result['unfilled'], 'count'));
$this->line("{$label} 미채움 마커 {$total}건: ".implode(', ', array_map(
static fn (array $u): string => $u['doc'].' → '.$u['marker'].'×'.$u['count'],
$result['unfilled'],
)));
}
foreach ($result['surfaceErrors'] as $getter => $message) {
$this->warn("{$label} 선언형 표면 수집 실패 {$getter}(): {$message}");
}
}
foreach ($this->malformed as $bad) {
$this->warn(sprintf(
'[%s] %s — %s 를 읽지 못해 검사 대상에서 빠졌습니다 (%s). "확장이 없음" 이 아니라 "읽지 못함" 입니다.',
$bad['type'], $bad['id'], $bad['manifest'], $bad['reason'],
));
}
$this->newLine();
$this->info(sprintf('%d개 확장 처리 (scope=%s, mode=%s) — 이슈 %d건', count($results), $scope, $this->mode(), $issues));
if ($this->option('check') && $issues > 0) {
$this->line('`php artisan ext:docgen --init` 으로 골격을 만들고, `php artisan ext:docgen` 으로 블록을 갱신하세요.');
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* 현재 실행 모드를 반환합니다.
*
* @return string 모드 문자열
*/
private function mode(): string
{
if ($this->option('dry-run')) {
return 'dry-run';
}
if ($this->option('check')) {
return 'check';
}
if ($this->option('init')) {
return 'init';
}
return 'update';
}
}