botFacingLayoutFiles() as $file) { $decoded = json_decode((string) file_get_contents($file), true); if (! is_array($decoded)) { continue; } foreach ($this->collectNodeKeys($decoded) as $key) { if (! in_array($key, $known, true) && ! $this->isCommentKey($key)) { $unknown[$key][] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file); } } } $this->assertSame([], $unknown, sprintf( "SEO 렌더러 판정 목록에 없는 노드 키가 봇 대상 화면에 있습니다: %s\n". '→ SEO 렌더러가 이 키를 해석해야 하는지 판단한 뒤, SeoNodeKeyParityTest 의 목록과 '. 'docs/backend/seo-system.md 의 지원 노드 키 표를 함께 갱신하세요.', json_encode(array_map(fn ($files) => array_slice(array_unique($files), 0, 3), $unknown), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) )); } /** * `_comment` 접두사 주석 키인지 판정합니다. * * 저장소 관례상 주석 키는 `_comment` 단독 외에 `_comment_id` 처럼 설명 대상을 * 접미사로 붙인 변형이 통용된다. React(DynamicRenderer)와 SEO(ComponentHtmlMapper) * 모두 알 수 없는 최상위 키를 무시하므로 렌더 패리티에 영향이 없다. * * @param string $key 노드 최상위 키 * @return bool 주석 키 여부 */ private function isCommentKey(string $key): bool { return str_starts_with($key, '_comment'); } /** * 문서 루트가 곧 노드인 partial 파일도 실제로 스캔됩니다. * * 수집 진입점이 `components`/`slots` 뿐이면 봇 대상 자산의 대부분(partial)이 통째로 * 스킵되어 위 분류 단언이 항상 통과한다 — 이슈 #86 이 터진 `_reply_section.json` 이 * 바로 그 형태이므로, 감시망이 비어 있는 채로 초록이 되는 상황을 여기서 차단한다. * * @effects node_rooted_partials_are_scanned */ public function test_scan_covers_node_rooted_partial_documents(): void { $file = base_path('templates/_bundled/sirsoft-basic/layouts/partials/board/show/_reply_section.json'); $this->assertFileExists($file); $document = json_decode((string) file_get_contents($file), true); $this->assertIsArray($document); $this->assertArrayNotHasKey('components', $document, '전제 변경: 이 파일이 더 이상 노드 루트 형태가 아닙니다.'); $keys = $this->collectNodeKeys($document); $this->assertNotEmpty($keys, '노드 루트 partial 에서 키가 수집되지 않았습니다 — 수집 진입점 누락.'); $this->assertContains('children', $keys); $this->assertNotContains('meta', $keys, '문서 메타 키가 노드 키로 잘못 수집되었습니다.'); // 자손까지 내려갔는지 (루트 키만 모으고 끝나면 안 된다) $this->assertContains('iteration', $keys, '자손 노드까지 재귀하지 않았습니다.'); } /** * 스캔 대상 파일 중 노드 루트 partial 이 실제로 다수를 차지하며 모두 수집됩니다. * * @effects node_rooted_partials_are_scanned */ public function test_node_rooted_partials_are_not_silently_skipped(): void { $nodeRooted = 0; $collected = 0; foreach ($this->botFacingLayoutFiles() as $file) { $document = json_decode((string) file_get_contents($file), true); if (! is_array($document) || isset($document['components']) || isset($document['slots'])) { continue; } if (! isset($document['type']) && ! isset($document['name'])) { continue; } $nodeRooted++; if ($this->collectNodeKeys($document) !== []) { $collected++; } } $this->assertGreaterThan(0, $nodeRooted, '노드 루트 partial 탐색 자체가 실패했습니다.'); $this->assertSame($nodeRooted, $collected, sprintf( '노드 루트 partial %d 개 중 %d 개에서만 키가 수집되었습니다.', $nodeRooted, $collected )); } /** * 지원/무시 목록이 서로 겹치지 않습니다 (분류 모호성 차단). * * @effects key_classification_is_unambiguous */ public function test_supported_and_ignored_key_lists_are_disjoint(): void { $this->assertSame( [], array_values(array_intersect(self::SUPPORTED_KEYS, self::INTENTIONALLY_IGNORED_KEYS)), '같은 키가 지원 목록과 무시 목록에 동시에 있습니다.' ); } /** * 봇 렌더링 대상이 되는 레이아웃/확장 자산 파일 목록을 반환합니다. * * 관리자 템플릿과 admin 레이아웃은 인증이 필요해 봇이 접근할 수 없으므로 제외합니다. * * @return array 파일 절대 경로 목록 */ private function botFacingLayoutFiles(): array { $roots = [ base_path('templates/_bundled/sirsoft-basic/layouts'), base_path('templates/_bundled/sirsoft-basic/extensions'), base_path('plugins/_bundled/sirsoft-ckeditor5/resources/extensions'), base_path('plugins/_bundled/sirsoft-daum_postcode/resources/extensions'), base_path('plugins/_bundled/sirsoft-gdpr/resources/extensions'), ]; $files = []; foreach ($roots as $root) { if (! is_dir($root)) { continue; } $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root)); foreach ($iterator as $entry) { if (! $entry->isFile() || $entry->getExtension() !== 'json') { continue; } $path = $entry->getPathname(); if (str_contains(str_replace('\\', '/', $path), '/admin/')) { continue; } $files[] = $path; } } return $files; } /** * 문서(레이아웃/확장 자산) 최상위에만 나타나는 키. * * partial 파일은 문서 루트 자체가 컴포넌트 노드라(§collectNodeKeys) 루트 키를 노드 키로 * 수집하는데, 그중 이 목록의 키는 노드가 아니라 문서 메타이므로 제외한다. */ private const DOCUMENT_ONLY_KEYS = [ 'meta', 'version', 'extends', 'layout_name', 'data_sources', 'computed', 'permissions', 'init_actions', 'modals', 'states', 'defines', 'error_handling', 'globalHeaders', 'scripts', 'priority', 'extension_point', 'target_layout', 'target_id', 'position', 'mode', 'injections', ]; /** * 레이아웃/확장 자산에서 컴포넌트 노드의 최상위 키를 수집합니다. * * 진입점은 셋이다: * 1. `components` / `slots.*` — 일반 레이아웃 문서, 확장 자산(`extension_point` 형) * 2. `injections[].components` — 주입 목록 형 확장 자산 * 3. 위 어느 것도 없고 `type`/`name` 이 있으면 **문서 루트 자체가 컴포넌트 노드** (partial) * * 3번을 빠뜨리면 봇 대상 자산의 대부분(partial)이 통째로 감시망 밖에 남는다 — 이슈 #86 이 * 실제로 터진 `partials/board/show/_reply_section.json` 이 정확히 이 형태다. * * 각 노드에서 `children` / `default` / `responsive.*.children` / `itemTemplate` 로 재귀한다. * * @param array $document 디코드된 레이아웃/확장 자산 * @return array 사용된 노드 키 목록 (중복 제거) */ private function collectNodeKeys(array $document): array { $keys = []; $hasComponentEntryPoint = false; foreach ($document['components'] ?? [] as $node) { $hasComponentEntryPoint = true; $this->collectFromNode($node, $keys); } $slots = $document['slots'] ?? []; if (is_array($slots)) { foreach ($slots as $slotNodes) { if (! is_array($slotNodes)) { continue; } $hasComponentEntryPoint = true; foreach ($slotNodes as $node) { $this->collectFromNode($node, $keys); } } } $injections = $document['injections'] ?? []; if (is_array($injections)) { foreach ($injections as $injection) { foreach ($injection['components'] ?? [] as $node) { $hasComponentEntryPoint = true; $this->collectFromNode($node, $keys); } } } // partial: 문서 루트가 곧 컴포넌트 노드 if (! $hasComponentEntryPoint && (isset($document['type']) || isset($document['name']))) { // 루트에서만 문서 메타 키를 제외하고, 자손은 일반 노드와 동일하게 수집한다 foreach (array_keys($document) as $key) { if (! in_array((string) $key, self::DOCUMENT_ONLY_KEYS, true)) { $keys[] = (string) $key; } } $this->collectDescendantKeys($document, $keys); } return array_values(array_unique($keys)); } /** * 단일 노드와 그 자손의 최상위 키를 수집합니다. * * @param mixed $node 노드 * @param array $keys 수집 결과 (참조) */ private function collectFromNode(mixed $node, array &$keys): void { if (! is_array($node) || array_is_list($node)) { return; } foreach (array_keys($node) as $key) { $keys[] = (string) $key; } $this->collectDescendantKeys($node, $keys); } /** * 노드의 자손(children / default / responsive.*.children / itemTemplate) 키를 수집합니다. * * @param array $node 노드 * @param array $keys 수집 결과 (참조) */ private function collectDescendantKeys(array $node, array &$keys): void { foreach (['children', 'default'] as $arrayKey) { foreach ($node[$arrayKey] ?? [] as $child) { $this->collectFromNode($child, $keys); } } if (isset($node['responsive']) && is_array($node['responsive'])) { foreach ($node['responsive'] as $override) { foreach ($override['children'] ?? [] as $child) { $this->collectFromNode($child, $keys); } } } if (isset($node['itemTemplate'])) { $this->collectFromNode($node['itemTemplate'], $keys); } } }