From fd6e23088970307c2f0f878b99afffc56f9381e0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 5 Dec 2025 11:02:35 +0900 Subject: [PATCH] perf(comment-checker): add LSP-style background language warming - Warmup common languages (python, typescript, javascript, tsx, go, rust, java) on plugin init - Non-blocking background initialization using Promise.then() pattern - First parse call uses pre-cached language - zero user wait time - Refactor parser manager with ManagedLanguage interface for better state tracking --- local-ignore/comment-checker-ts-plan.md | 162 ++++++++++++++++++++++ local-ignore/push-and-release.sh | 12 ++ src/hooks/comment-checker/detector.ts | 173 ++++++++++++++++++------ src/hooks/comment-checker/index.ts | 5 +- 4 files changed, 313 insertions(+), 39 deletions(-) create mode 100644 local-ignore/comment-checker-ts-plan.md create mode 100755 local-ignore/push-and-release.sh diff --git a/local-ignore/comment-checker-ts-plan.md b/local-ignore/comment-checker-ts-plan.md new file mode 100644 index 0000000..c0f648f --- /dev/null +++ b/local-ignore/comment-checker-ts-plan.md @@ -0,0 +1,162 @@ +# Comment-Checker TypeScript Port 구현 계획 + +## 1. 아키텍처 개요 + +### 1.1 핵심 도전 과제 + +**OpenCode Hook의 제약사항:** +- `tool.execute.before`: `output.args`에서 파일 경로/내용 접근 가능 +- `tool.execute.after`: `tool_input`이 **제공되지 않음** (Claude Code와의 핵심 차이점) +- **해결책**: Before hook에서 데이터를 캡처하여 callID로 키잉된 Map에 저장, After hook에서 조회 + +### 1.2 디렉토리 구조 + +``` +src/hooks/comment-checker/ +├── index.ts # Hook factory, 메인 엔트리포인트 +├── types.ts # 모든 타입 정의 +├── constants.ts # 언어 레지스트리, 쿼리 템플릿, 디렉티브 목록 +├── detector.ts # CommentDetector - web-tree-sitter 기반 코멘트 감지 +├── filters/ +│ ├── index.ts # 필터 barrel export +│ ├── bdd.ts # BDD 패턴 필터 +│ ├── directive.ts # 린터/타입체커 디렉티브 필터 +│ ├── docstring.ts # 독스트링 필터 +│ └── shebang.ts # Shebang 필터 +├── output/ +│ ├── index.ts # 출력 barrel export +│ ├── formatter.ts # FormatHookMessage +│ └── xml-builder.ts # BuildCommentsXML +└── utils.ts # 유틸리티 함수 +``` + +### 1.3 데이터 흐름 + +``` +[write/edit 도구 실행] + │ + ▼ +┌──────────────────────┐ +│ tool.execute.before │ +│ - 파일 경로 캡처 │ +│ - pendingCalls Map │ +│ 에 저장 │ +└──────────┬───────────┘ + │ + ▼ + [도구 실제 실행] + │ + ▼ +┌──────────────────────┐ +│ tool.execute.after │ +│ - pendingCalls에서 │ +│ 데이터 조회 │ +│ - 파일 읽기 │ +│ - 코멘트 감지 │ +│ - 필터 적용 │ +│ - 메시지 주입 │ +└──────────────────────┘ +``` + +--- + +## 2. 구현 순서 + +### Phase 1: 기반 구조 +1. `src/hooks/comment-checker/` 디렉토리 생성 +2. `types.ts` - 모든 타입 정의 +3. `constants.ts` - 언어 레지스트리, 디렉티브 패턴 + +### Phase 2: 필터 구현 +4. `filters/bdd.ts` - BDD 패턴 필터 +5. `filters/directive.ts` - 디렉티브 필터 +6. `filters/docstring.ts` - 독스트링 필터 +7. `filters/shebang.ts` - Shebang 필터 +8. `filters/index.ts` - 필터 조합 + +### Phase 3: 코어 로직 +9. `detector.ts` - web-tree-sitter 기반 코멘트 감지 +10. `output/xml-builder.ts` - XML 출력 +11. `output/formatter.ts` - 메시지 포매팅 + +### Phase 4: Hook 통합 +12. `index.ts` - Hook factory 및 상태 관리 +13. `src/hooks/index.ts` 업데이트 - export 추가 + +### Phase 5: 의존성 및 빌드 +14. `package.json` 업데이트 - web-tree-sitter 추가 +15. typecheck 및 build 검증 + +--- + +## 3. 핵심 구현 사항 + +### 3.1 언어 레지스트리 (38개 언어) + +```typescript +const LANGUAGE_REGISTRY: Record = { + python: { extensions: [".py"], commentQuery: "(comment) @comment", docstringQuery: "..." }, + javascript: { extensions: [".js", ".jsx"], commentQuery: "(comment) @comment" }, + typescript: { extensions: [".ts"], commentQuery: "(comment) @comment" }, + tsx: { extensions: [".tsx"], commentQuery: "(comment) @comment" }, + go: { extensions: [".go"], commentQuery: "(comment) @comment" }, + rust: { extensions: [".rs"], commentQuery: "(line_comment) @comment (block_comment) @comment" }, + // ... 38개 전체 +} +``` + +### 3.2 필터 로직 + +**BDD 필터**: `given, when, then, arrange, act, assert` +**Directive 필터**: `noqa, pyright:, eslint-disable, @ts-ignore` 등 30+ +**Docstring 필터**: `IsDocstring || starts with /**` +**Shebang 필터**: `starts with #!` + +### 3.3 출력 형식 (Go 버전과 100% 동일) + +``` +COMMENT/DOCSTRING DETECTED - IMMEDIATE ACTION REQUIRED + +Your recent changes contain comments or docstrings, which triggered this hook. +You need to take immediate action. You must follow the conditions below. +(Listed in priority order - you must always act according to this priority order) + +CRITICAL WARNING: This hook message MUST NEVER be ignored... + + + // comment text + +``` + +--- + +## 4. 생성할 파일 목록 + +1. `src/hooks/comment-checker/types.ts` +2. `src/hooks/comment-checker/constants.ts` +3. `src/hooks/comment-checker/filters/bdd.ts` +4. `src/hooks/comment-checker/filters/directive.ts` +5. `src/hooks/comment-checker/filters/docstring.ts` +6. `src/hooks/comment-checker/filters/shebang.ts` +7. `src/hooks/comment-checker/filters/index.ts` +8. `src/hooks/comment-checker/output/xml-builder.ts` +9. `src/hooks/comment-checker/output/formatter.ts` +10. `src/hooks/comment-checker/output/index.ts` +11. `src/hooks/comment-checker/detector.ts` +12. `src/hooks/comment-checker/index.ts` + +## 5. 수정할 파일 목록 + +1. `src/hooks/index.ts` - export 추가 +2. `package.json` - web-tree-sitter 의존성 + +--- + +## 6. Definition of Done + +- [ ] write/edit 도구 실행 시 코멘트 감지 동작 +- [ ] 4개 필터 모두 정상 작동 +- [ ] 최소 5개 언어 지원 (Python, JS, TS, TSX, Go) +- [ ] Go 버전과 동일한 출력 형식 +- [ ] typecheck 통과 +- [ ] build 성공 diff --git a/local-ignore/push-and-release.sh b/local-ignore/push-and-release.sh new file mode 100755 index 0000000..170f3b5 --- /dev/null +++ b/local-ignore/push-and-release.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e +cd /Users/yeongyu/local-workspaces/oh-my-opencode + +echo "=== Pushing to origin ===" +git push -f origin master + +echo "=== Triggering workflow ===" +gh workflow run publish.yml --repo code-yeongyu/oh-my-opencode --ref master -f bump=patch -f version=$1 + +echo "=== Done! ===" +echo "Usage: ./local-ignore/push-and-release.sh 0.1.6" diff --git a/src/hooks/comment-checker/detector.ts b/src/hooks/comment-checker/detector.ts index f03dc2c..48957b7 100644 --- a/src/hooks/comment-checker/detector.ts +++ b/src/hooks/comment-checker/detector.ts @@ -17,73 +17,170 @@ function debugLog(...args: unknown[]) { } // ============================================================================= -// Parser caching for performance +// Parser Manager (LSP-style background initialization) // ============================================================================= +interface ManagedLanguage { + language: unknown + initPromise?: Promise + isInitializing: boolean + lastUsedAt: number +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any let parserClass: any = null -let parserInitialized = false -const languageCache = new Map() +let parserInitPromise: Promise | null = null +const languageCache = new Map() -async function getParser() { - if (!parserClass) { - debugLog("importing web-tree-sitter (first time)...") - parserClass = (await import("web-tree-sitter")).default +const LANGUAGE_NAME_MAP: Record = { + golang: "go", + csharp: "c_sharp", + cpp: "cpp", +} + +const COMMON_LANGUAGES = [ + "python", + "typescript", + "javascript", + "tsx", + "go", + "rust", + "java", +] + +async function initParserClass(): Promise { + if (parserClass) return + + if (parserInitPromise) { + await parserInitPromise + return } - if (!parserInitialized) { - debugLog("initializing Parser (first time)...") + parserInitPromise = (async () => { + debugLog("importing web-tree-sitter...") + parserClass = (await import("web-tree-sitter")).default const treeSitterWasmPath = require.resolve("web-tree-sitter/tree-sitter.wasm") debugLog("wasm path:", treeSitterWasmPath) await parserClass.init({ locateFile: () => treeSitterWasmPath, }) - parserInitialized = true - debugLog("Parser initialized") - } + debugLog("Parser class initialized") + })() + await parserInitPromise +} + +async function getParser() { + await initParserClass() return new parserClass() } -async function getLanguage(langName: string) { - if (languageCache.has(langName)) { +async function loadLanguageWasm(langName: string): Promise { + const mappedLang = LANGUAGE_NAME_MAP[langName] || langName + + try { + const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${langName}.wasm`) + return wasmModule.default + } catch { + if (mappedLang !== langName) { + try { + const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${mappedLang}.wasm`) + return wasmModule.default + } catch { + return null + } + } + return null + } +} + +async function getLanguage(langName: string): Promise { + const cached = languageCache.get(langName) + + if (cached) { + if (cached.initPromise) { + await cached.initPromise + } + cached.lastUsedAt = Date.now() debugLog("using cached language:", langName) - return languageCache.get(langName) + return cached.language } debugLog("loading language wasm:", langName) - let wasmPath: string - try { - const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${langName}.wasm`) - wasmPath = wasmModule.default - } catch { - const languageMap: Record = { - golang: "go", - csharp: "c_sharp", - cpp: "cpp", - } - const mappedLang = languageMap[langName] || langName - try { - const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${mappedLang}.wasm`) - wasmPath = wasmModule.default - } catch (err) { - debugLog("failed to load language wasm:", langName, err) + const initPromise = (async () => { + await initParserClass() + const wasmPath = await loadLanguageWasm(langName) + if (!wasmPath) { + debugLog("failed to load language wasm:", langName) return null } + return await parserClass!.Language.load(wasmPath) + })() + + languageCache.set(langName, { + language: null as unknown, + initPromise, + isInitializing: true, + lastUsedAt: Date.now(), + }) + + const language = await initPromise + const managed = languageCache.get(langName) + if (managed) { + managed.language = language + managed.initPromise = undefined + managed.isInitializing = false } - if (!parserClass) { - await getParser() // ensure parserClass is initialized - } - - const language = await parserClass!.Language.load(wasmPath) - languageCache.set(langName, language) debugLog("language loaded and cached:", langName) - return language } +function warmupLanguage(langName: string): void { + if (languageCache.has(langName)) return + + debugLog("warming up language (background):", langName) + + const initPromise = (async () => { + await initParserClass() + const wasmPath = await loadLanguageWasm(langName) + if (!wasmPath) return null + return await parserClass!.Language.load(wasmPath) + })() + + languageCache.set(langName, { + language: null as unknown, + initPromise, + isInitializing: true, + lastUsedAt: Date.now(), + }) + + initPromise.then((language) => { + const managed = languageCache.get(langName) + if (managed) { + managed.language = language + managed.initPromise = undefined + managed.isInitializing = false + debugLog("warmup complete:", langName) + } + }).catch((err) => { + debugLog("warmup failed:", langName, err) + languageCache.delete(langName) + }) +} + +export function warmupCommonLanguages(): void { + debugLog("starting background warmup for common languages...") + initParserClass().then(() => { + for (const lang of COMMON_LANGUAGES) { + warmupLanguage(lang) + } + }).catch((err) => { + debugLog("warmup initialization failed:", err) + }) +} + // ============================================================================= // Public API // ============================================================================= diff --git a/src/hooks/comment-checker/index.ts b/src/hooks/comment-checker/index.ts index 55ce05d..488666d 100644 --- a/src/hooks/comment-checker/index.ts +++ b/src/hooks/comment-checker/index.ts @@ -1,5 +1,5 @@ import type { PendingCall, FileComments } from "./types" -import { detectComments, isSupportedFile } from "./detector" +import { detectComments, isSupportedFile, warmupCommonLanguages } from "./detector" import { applyFilters } from "./filters" import { formatHookMessage } from "./output" @@ -32,6 +32,9 @@ setInterval(cleanupOldPendingCalls, 10_000) export function createCommentCheckerHooks() { debugLog("createCommentCheckerHooks called") + // Background warmup - LSP style (non-blocking) + warmupCommonLanguages() + return { "tool.execute.before": async ( input: { tool: string; sessionID: string; callID: string },