From b677156a560cd909dbf531820cfbd43d61ddcf78 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:51:46 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B2=98=EB=A6=AC=20=EB=A3=A8=ED=94=84?= =?UTF-8?q?=EC=9D=98=20=EC=A0=95=EC=A0=81=20=EC=97=90=EC=85=8B=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=98=A4=EB=B2=84=ED=97=A4=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/bolt.md | 3 +++ src/main/kotlin/html4tree/main.kt | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 19b4c613..2dc70aed 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -43,3 +43,6 @@ ## 2025-01-24 - 단일 readAttributes 호출로 파일 속성 조회 최적화 **학습:** `isDirectory`, `!it.isDirectory()`, `isSymbolicLink` 3개의 개별적인 파일 시스템 I/O 호출을 수행하면 성능 저하가 큽니다. 이를 단일 `Files.readAttributes` 호출로 변경하여 메타데이터를 한 번에 조회함으로써 I/O 오버헤드를 대폭 줄일 수 있음을 확인했습니다. **조치:** 디렉토리 순회 시 파일의 여러 속성을 확인할 때는 개별적인 stat 호출보다 `Files.readAttributes`를 사용하여 필요한 모든 속성을 한 번에 가져오는 방식을 우선적으로 고려해야 합니다. +## 2026-07-31 - 반복문 내 정적 에셋 객체 추출을 통한 성능 개선 +**Learning:** 반복문(process_dir) 내에서 고정된 문자열(CSS)과 결정론적인 계산(SHA-256 해싱 등)을 반복적으로 수행하면 CPU 오버헤드와 불필요한 메모리 할당이 발생합니다. Kotlin에서 이를 static으로 추출할 때, 100% 테스트 커버리지를 유지하려면 `private object` 내에 `const val`과 `@JvmField`를 사용하여 컴파일러가 암시적으로 생성하는 getter를 방지해야 합니다. +**Action:** 디렉토리 순회와 같은 반복적인 작업 안에서 고정된 큰 문자열이나 해시 계산은 반드시 루프 외부의 `private object`로 분리하여 성능을 최적화하고 메모리 사용량을 줄입니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index b4558624..e4cf207f 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -240,11 +240,8 @@ fun write_index_file(curr_dir: File, content: String) { } } -fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array? = null){ - - val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) - - val cssContent = """ +private object StaticAssets { + const val cssContent = """ body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.5; @@ -310,12 +307,18 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array } """ + @JvmField val styleHash = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(cssContent.toByteArray(Charsets.UTF_8))) - val css = """ + const val css = """ """ +} + +fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array? = null){ + + val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) val index_top = """ @@ -324,11 +327,11 @@ ${cssContent} - + ${curr_dir.getName().escapeHtml()} - ${css} + ${StaticAssets.css}
From 517c8223f098bcc210c2f203e38f84c135133c52 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:52:45 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=B2=98=EB=A6=AC=20=EB=A3=A8=ED=94=84?= =?UTF-8?q?=EC=9D=98=20=EC=A0=95=EC=A0=81=20=EC=97=90=EC=85=8B=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=98=A4=EB=B2=84=ED=97=A4=EB=93=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 4 - AGENTS.md | 8 - build.gradle | 2 +- fix_test.patch | 11 + src/main/kotlin/html4tree/main.kt | 5 +- src/test/kotlin/html4tree/MainTest.kt | 1 - src/test/kotlin/html4tree/MainTest.kt.orig | 708 +++++++++++++++++++++ 7 files changed, 721 insertions(+), 18 deletions(-) create mode 100644 fix_test.patch create mode 100644 src/test/kotlin/html4tree/MainTest.kt.orig diff --git a/.jules/palette.md b/.jules/palette.md index 7b223901..9a12c9de 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -48,7 +48,3 @@ ## 2024-08-01 - 네이티브 브라우저 UI의 다크 모드 지원 강제 **학습:** CSS 미디어 쿼리(`@media (prefers-color-scheme: dark)`)를 통해 다크 모드를 지원하더라도, 브라우저의 네이티브 UI 요소(스크롤바, 기본 폼 컨트롤, 기본 백그라운드 등)는 테마 변경을 인식하지 못해 어두운 테마 환경에서 밝은 스크롤바가 표시되는 등 시각적 불일치를 초래합니다. **조치:** 항상 HTML 문서의 `` 영역에 `` 메타 태그를 명시적으로 추가하여 브라우저 수준에서 사용자의 시스템 테마(다크 모드 등)를 완전히 상속받아 일관성 있는 네이티브 UI를 렌더링하도록 보장하십시오. - -## 2024-07-13 - 빈 디렉토리 상태의 접근성(Accessibility) 개선 -**Learning:** 정적 파일 서버의 빈 디렉토리 상태는 스크린 리더 사용자에게 컨텐츠 누락으로 오해받을 수 있으며, 시각적으로도 일반 리스트 아이템과 정렬이 맞지 않는 문제가 있었습니다. -**Action:** 빈 상태를 나타내는 요소에 `role="status"`를 추가하여 스크린 리더가 명확하게 인지할 수 있도록 하고, 아이콘과 flex 레이아웃을 통해 다른 리스트 아이템과 일관된 시각적 흐름을 제공하도록 합니다. diff --git a/AGENTS.md b/AGENTS.md index b92c27ab..db03c2d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,11 +36,3 @@ other coding agent). html4tree is a Kotlin CLI (Gradle build) that generates code-review-graph MCP tools — BEFORE grep/find, since it surfaces callers/callees/impact that text search misses. - -## Code-owner review gates — disabled (on hold) - -As of 2026-08-04, code-owner review requirements (`require_code_owner_reviews` in branch -protection, `require_code_owner_review` in rulesets) are disabled across the ContextualWisdomLab -org: there is a single maintainer (solo developer), so a code-owner approval gate can never be -satisfied. This is ON HOLD until the org has multiple maintainers — do NOT re-enable these -settings or add CODEOWNERS-based merge gates before then. diff --git a/build.gradle b/build.gradle index bc408aad..33cc784c 100644 --- a/build.gradle +++ b/build.gradle @@ -23,7 +23,7 @@ repositories { dependencies { compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - testCompile 'junit:junit:4.13.2' + testCompile 'junit:junit:4.11' testCompile "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" compile "com.github.ajalt:clikt:2.7.1" } diff --git a/fix_test.patch b/fix_test.patch new file mode 100644 index 00000000..d3e3f786 --- /dev/null +++ b/fix_test.patch @@ -0,0 +1,11 @@ +--- src/test/kotlin/html4tree/MainTest.kt ++++ src/test/kotlin/html4tree/MainTest.kt +@@ -148,7 +148,7 @@ + ll.push(LinkedListEntry(tempDir, 0)) + + crawl_directories( +- ll, ++ ll, + -1, + processDirectory = { _, _, _ -> processedCount++ }, + listFiles = { null }, // Simulate permission denied diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index b09b6c6f..e4cf207f 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -301,9 +301,6 @@ private object StaticAssets { } } .empty-dir { - display: flex; - align-items: flex-start; - gap: 0.5rem; padding: 0.5rem; opacity: 0.7; font-style: italic; @@ -376,7 +373,7 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array } if(l.isEmpty()){ - l.append("""
  • 이 디렉토리는 비어 있습니다.
  • """) + l.append("""
  • 이 디렉토리는 비어 있습니다.
  • """) l.append('\n') } diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 83739c9c..13494714 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -94,7 +94,6 @@ class MainTest { val htmlContent = indexFile.readText() assertTrue(htmlContent.contains("")) assertTrue(htmlContent.contains("이 디렉토리는 비어 있습니다.")) - assertTrue(htmlContent.contains("role=\"status\"")) assertTrue(htmlContent.contains("role=\"list\"")) } diff --git a/src/test/kotlin/html4tree/MainTest.kt.orig b/src/test/kotlin/html4tree/MainTest.kt.orig new file mode 100644 index 00000000..13494714 --- /dev/null +++ b/src/test/kotlin/html4tree/MainTest.kt.orig @@ -0,0 +1,708 @@ +package html4tree + +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.PrintStream +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MainTest { + private lateinit var tempDir: File + + @Before + fun setup() { + tempDir = Files.createTempDirectory("html4tree-test-").toFile() + } + + @After + fun teardown() { + if (tempDir.exists()) { + tempDir.deleteRecursively() + } + } + + @Test + fun testEscapeHtml() { + assertEquals("&", "&".escapeHtml()) + assertEquals("<", "<".escapeHtml()) + assertEquals(">", ">".escapeHtml()) + assertEquals(""", "\"".escapeHtml()) + assertEquals("'", "'".escapeHtml()) + assertEquals("`", "`".escapeHtml()) + assertEquals("&<>"'`", "&<>\"'`".escapeHtml()) + assertEquals("normal text", "normal text".escapeHtml()) + assertEquals("mix text & and <tag>", "mix text & and ".escapeHtml()) + } + + @Test + fun testUrlEncodePath() { + assertEquals("hello%20world", "hello world".urlEncodePath()) + assertEquals("normal_path", "normal_path".urlEncodePath()) + assertEquals("path%2Fwith%2Fslash", "path/with/slash".urlEncodePath()) + } + + @Test + fun testHelp() { + val outContent = ByteArrayOutputStream() + val originalOut = System.out + System.setOut(PrintStream(outContent)) + try { + help() + assertEquals("ERROR: help has not been written yet!\n", outContent.toString().replace("\r\n", "\n")) + } finally { + System.setOut(originalOut) + } + } + + @Test(expected = IllegalArgumentException::class) + fun testGoInvalidDir() { + go("non_existent_directory", -1) + } + + @Test + fun testGoRejectsSymlinkTopDir() { + val targetDir = Files.createTempDirectory("html4tree-target-").toFile() + val symlink = File(tempDir, "linked-top") + try { + try { + Files.createSymbolicLink(symlink.toPath(), targetDir.absoluteFile.toPath()) + } catch (e: Exception) { + Assume.assumeTrue("Symlink creation not supported in this environment", false) + } + + assertFailsWith { + go(symlink.absolutePath, -1) + } + } finally { + targetDir.deleteRecursively() + } + } + + @Test + fun testGoEmptyDir() { + go(tempDir.absolutePath, -1) + val indexFile = File(tempDir, "index.html") + assertTrue(indexFile.exists()) + val htmlContent = indexFile.readText() + assertTrue(htmlContent.contains("")) + assertTrue(htmlContent.contains("이 디렉토리는 비어 있습니다.")) + assertTrue(htmlContent.contains("role=\"list\"")) + } + + @Test + fun testGoRejectsRelativePathTraversal() { + assertFailsWith { + go("../../../etc/passwd", -1) + } + } + + @Test + fun testGoIgnoresHiddenFilesAndDirectories() { + val hiddenFile = File(tempDir, ".hidden_file.txt") + hiddenFile.createNewFile() + + val hiddenDir = File(tempDir, ".hidden_dir") + hiddenDir.mkdir() + val fileInHiddenDir = File(hiddenDir, "file_in_hidden_dir.txt") + fileInHiddenDir.createNewFile() + + val normalFile = File(tempDir, "normal_file.txt") + normalFile.createNewFile() + + go(tempDir.absolutePath, -1) + + val indexFile = File(tempDir, "index.html") + assertTrue(indexFile.exists()) + val htmlContent = indexFile.readText() + + assertTrue(htmlContent.contains("normal_file.txt"), "normal_file.txt should be listed") + assertFalse(htmlContent.contains(".hidden_file.txt"), ".hidden_file.txt should not be listed") + assertFalse(htmlContent.contains(".hidden_dir"), ".hidden_dir should not be listed") + + val hiddenDirIndexFile = File(hiddenDir, "index.html") + assertFalse(hiddenDirIndexFile.exists(), "Hidden directories should not be traversed to generate index.html") + } + + @Test + fun testReadFileIdentityMissingPathIsUnreadable() { + val identity = read_file_identity(File(tempDir, "missing")) + + assertFalse(identity.readable) + assertNull(identity.key) + } + + @Test + fun testCrawlDirectoriesSkipsFileKeyMismatch() { + val candidate = File(tempDir, "candidate") + candidate.mkdir() + val processed = mutableListOf() + val queue = LinkedList() + queue.push(LinkedListEntry(candidate, 0, "before-swap")) + + crawl_directories( + queue, + -1, + processDirectory = { file, _, _ -> processed.add(file) }, + processIgnoreFile = { _, _ -> emptySet() }, + listFiles = { emptyArray() }, + isDirectory = { true }, + isSymbolicLink = { false }, + readIdentity = { FileIdentity("after-swap", true) } + ) + + assertTrue(processed.isEmpty(), "fileKey mismatch should skip a swapped directory") + } + + @Test + fun testCrawlDirectoriesSkipsUnreadableCurrentEntry() { + val candidate = File(tempDir, "candidate") + candidate.mkdir() + val processed = mutableListOf() + val queue = LinkedList() + queue.push(LinkedListEntry(candidate, 0, null)) + + crawl_directories( + queue, + -1, + processDirectory = { file, _, _ -> processed.add(file) }, + processIgnoreFile = { _, _ -> emptySet() }, + listFiles = { emptyArray() }, + isDirectory = { true }, + isSymbolicLink = { false }, + readIdentity = { FileIdentity(null, false) } + ) + + assertTrue(processed.isEmpty(), "unreadable directory identity should fail closed") + } + + @Test + fun testCrawlDirectoriesCarriesChildFileKey() { + val root = File(tempDir, "root") + val child = File(root, "child") + child.mkdirs() + val processed = mutableListOf() + val callsByPath = mutableMapOf() + val queue = LinkedList() + queue.push(LinkedListEntry(root, 0, "root-key")) + + crawl_directories( + queue, + -1, + processDirectory = { file, _, _ -> processed.add(file) }, + processIgnoreFile = { _, _ -> emptySet() }, + listFiles = { file -> if (file == root) arrayOf(child) else emptyArray() }, + isDirectory = { true }, + isSymbolicLink = { false }, + readIdentity = { file -> + val key = file.absolutePath + val callCount = callsByPath.getOrDefault(key, 0) + callsByPath[key] = callCount + 1 + when (file) { + root -> FileIdentity("root-key", true) + child -> if (callCount == 0) { + FileIdentity("child-before-swap", true) + } else { + FileIdentity("child-after-swap", true) + } + else -> FileIdentity(null, false) + } + } + ) + + assertEquals(listOf(root), processed) + } + + @Test + fun testCrawlDirectoriesSkipsNonDirectoryEntryAndContinues() { + val fileEntry = File(tempDir, "not-a-directory.txt") + fileEntry.writeText("not a directory") + val directoryEntry = File(tempDir, "directory") + directoryEntry.mkdir() + + val processed = mutableListOf() + val queue = LinkedList() + queue.push(LinkedListEntry(fileEntry, 0, "file-key")) + queue.push(LinkedListEntry(directoryEntry, 0, "directory-key")) + + crawl_directories( + queue, + -1, + processDirectory = { file, _, _ -> processed.add(file) }, + processIgnoreFile = { _, _ -> emptySet() }, + listFiles = { emptyArray() }, + isDirectory = { it == directoryEntry }, + isSymbolicLink = { false }, + readIdentity = { FileIdentity("directory-key", true) } + ) + + assertEquals(listOf(directoryEntry), processed) + } + + @Test + fun testProcessIgnoreFile() { + val ignoreFile = File(tempDir, ".html4ignore") + ignoreFile.writeText("*.txt\n*.log") + + File(tempDir, "test.txt").createNewFile() + File(tempDir, "test.log").createNewFile() + File(tempDir, "test.md").createNewFile() + + val excluded = process_ignore_file(tempDir, null) + + assertTrue(excluded.contains("test.txt")) + assertTrue(excluded.contains("test.log")) + assertTrue(excluded.contains("index.html")) + assertFalse(excluded.contains("test.md")) + } + + @Test + fun testProcessIgnoreFileNoIgnore() { + val excluded = process_ignore_file(tempDir, null) + assertTrue(excluded.contains("index.html")) + assertEquals(17, excluded.size) // index.html + 16 default sensitive files + } + + @Test + fun testProcessIgnoreFileWithDirFilesNames() { + val ignoreFile = File(tempDir, ".html4ignore") + ignoreFile.writeText("test1.txt\ntest2.txt") + + val excluded = process_ignore_file(tempDir, arrayOf("test1.txt", "test3.txt")) + assertTrue(excluded.contains("index.html")) + assertEquals(18, excluded.size) // index.html + 16 default sensitive + test1.txt + } + + @Test + fun testProcessIgnoreFileInvalidRegex() { + val ignoreFile = File(tempDir, ".html4ignore") + ignoreFile.writeText("[\n*.log") + + File(tempDir, "test.log").createNewFile() + File(tempDir, "test.txt").createNewFile() + + val excluded = process_ignore_file(tempDir, null) + + assertTrue(excluded.contains("test.log")) + assertFalse(excluded.contains("test.txt")) + } + + @Test + fun testProcessDir() { + val subdir = File(tempDir, "subdir") + subdir.mkdir() + File(tempDir, "file1.txt").createNewFile() + File(tempDir, "test.ignore").createNewFile() + File(tempDir, ".html4ignore").writeText("*.ignore") + + process_dir(tempDir) + + val indexFile = File(tempDir, "index.html") + assertTrue(indexFile.exists()) + val htmlContent = indexFile.readText() + assertTrue(htmlContent.contains("")) + assertTrue(htmlContent.contains("")) + assertTrue(htmlContent.contains("