refactor: v2.2.195-201 — slashRouter god-file 해체 (–95%) + 인프라 5개 추출

아키텍처 감사 결과 HIGH 2건 + MED 2건 + LOW 1건 — 7 라운드 정리 시리즈.
기능 변경 없음, 순수 구조 정리.

**slashRouter.ts: 4,174 → 201줄 (–3,973, –95%)**
**agent.ts: 1,617 → 1,551줄 (–66, –4%)**

v2.2.195: eventSourcedStore + SystemPromptBlock registry
  - createEventStore<E>(opts) — 4 store (customers/hire/runway/feedback) I/O 240줄 중복 제거
  - _turnCtx 5 named string field → 1 Map<string, string> (새 verification block 추가 25곳→1곳)
  - buildAstraModeSystemPrompt: 5 ternary gate + 5 위치 → 1 for-loop join

v2.2.196: trackers cluster split
  - src/features/teamops/handlers/_shared.ts (fmtKrw/parseAmount/daysUntil/parseTaskOwner/stageEmoji/STAGE_ORDER/TERMINAL_STAGES)
  - src/features/teamops/handlers/trackers.ts (runway/customers/hire)
  - src/features/teamops/handlers/index.ts (barrel)
  - extension.ts 에 side-effect import (순환 import 회피)

v2.2.197: mtimeFileCache + PostAnswerHook registry
  - src/lib/mtimeFileCache.ts — createMtimeFileCache<T>(name, parse) (terminologyBlock + termValidator 2-cache invariant 자동화)
  - src/agent/postAnswerHooks/{types,index}.ts — Devil/SelfCheck/TermValidator 3 _maybeX method → 1 runPostAnswerHooks(ctx) loop
  - agent.ts –66줄

v2.2.198: dashboards cluster split
  - src/features/teamops/handlers/dashboards.ts (morning/evening/cohort/weekly)

v2.2.199: coordination + communication clusters split
  - src/features/teamops/handlers/coordination.ts (task/decisions/onesie/blocked/standup)
  - src/features/teamops/handlers/communication.ts (draft/feedback)
  - callLmSynthesis export 노출 (communication 이 사용)
  - 옛 parseTaskOwner local 정의 삭제 (_shared.ts 사용)

v2.2.200: system cluster split
  - src/features/system/handlers.ts (memory/glossary/help)

v2.2.201: datacollect cluster split + LLM 인프라 추출
  - src/features/datacollect/handlers.ts (research/benchmark/youtube/blog/wikify/meet)
  - src/features/datacollect/llm.ts (callLmSynthesis + repairKoreanGlitches + bridgeErrorRemedy)
  - slashRouter import 4개로 축소: vscode/logInfo/getBridgeBaseUrl/bridgeErrorRemedy

**최종 slashRouter (201줄):**
- REGISTRY Map + registerSlashCommand/listSlashCommands/isSlashCommand
- handleSlashCommand (dispatcher + 에러 처리)
- Webview interface + chunk helper
- getRecentSlashCommands ring buffer (actionability scoring 용)

**미래 부담 감소 metrics:**
- 새 슬래시 명령: god-file 끝에 함수 + register → 1 파일 + 1 register call
- 새 verification block: 5곳 편집 → 1 set call
- 새 event store: 60줄 boilerplate → createEventStore 한 줄
- 새 post-answer hook: 3 step → 1 push
- 새 mtime cache: Map + invariant 관리 → createMtimeFileCache 한 줄

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 11:55:22 +09:00
parent 15a34e0889
commit 7bec20620a
40 changed files with 4784 additions and 4545 deletions
+58 -77
View File
@@ -21,6 +21,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { createMtimeFileCache } from '../lib/mtimeFileCache';
const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md';
@@ -30,15 +31,11 @@ interface ForbiddenEntry {
source: 'x-marker' | 'banned-section';
}
interface ValidatorCache {
mtime: number;
entries: ForbiddenEntry[];
}
const _cache = new Map<string, ValidatorCache>();
/** Parsed forbidden entries 캐시 — mtime 기반, 파일 편집 시 자동 재read+parse. */
const _parsedCache = createMtimeFileCache<ForbiddenEntry[]>('term-validator', (raw) => parseGlossaryRaw(raw));
export function clearTermValidatorCache(): void {
_cache.clear();
_parsedCache.clear();
}
function getGlossaryFilePath(relPath: string): string | null {
@@ -62,80 +59,64 @@ function isValidForbiddenToken(token: string): boolean {
}
/**
* Glossary markdown 파싱. mtime 캐시.
* Glossary markdown raw → forbidden entries. mtime 캐시는 mtimeFileCache 가 담당.
*/
function parseGlossary(filePath: string): ForbiddenEntry[] {
try {
if (!fs.existsSync(filePath)) return [];
const st = fs.statSync(filePath);
const cached = _cache.get(filePath);
if (cached && cached.mtime === st.mtimeMs) return cached.entries;
function parseGlossaryRaw(content: string): ForbiddenEntry[] {
const entries: ForbiddenEntry[] = [];
const content = fs.readFileSync(filePath, 'utf-8');
const entries: ForbiddenEntry[] = [];
// ─── Pattern 1: **Canonical** (X: typo1, typo2, ...) ───
// `**ASTRA** (X: astra, Astra 외)` — Astra 외 같은 description 은 후보 필터로 제거.
const xPattern = /\*\*([^*]+)\*\*\s*\(X:\s*([^)]+)\)/g;
let m: RegExpExecArray | null;
while ((m = xPattern.exec(content)) !== null) {
const canonical = m[1].trim();
const variants = m[2].split(',').map((v) => v.trim());
for (const v of variants) {
if (isValidForbiddenToken(v)) {
entries.push({ forbidden: v, suggested: canonical, source: 'x-marker' });
}
// ─── Pattern 1: **Canonical** (X: typo1, typo2, ...) ───
// `**ASTRA** (X: astra, Astra 외)` — Astra 외 같은 description 은 후보 필터로 제거.
const xPattern = /\*\*([^*]+)\*\*\s*\(X:\s*([^)]+)\)/g;
let m: RegExpExecArray | null;
while ((m = xPattern.exec(content)) !== null) {
const canonical = m[1].trim();
const variants = m[2].split(',').map((v) => v.trim());
for (const v of variants) {
if (isValidForbiddenToken(v)) {
entries.push({ forbidden: v, suggested: canonical, source: 'x-marker' });
}
}
// ─── Pattern 2: 금지 섹션 내 `- ❌ "..."` 또는 `- ❌ ...` ───
// H2/H3 제목에 "금지" 또는 "비추" 포함된 섹션 본문만 스캔.
const sectionRegex = /^(#{2,3})\s+(.+)$/gm;
const sections: { headerEnd: number; nextHeaderStart: number; title: string }[] = [];
let sm: RegExpExecArray | null;
let lastIdx = 0;
const matches: { idx: number; level: number; title: string }[] = [];
while ((sm = sectionRegex.exec(content)) !== null) {
matches.push({ idx: sm.index + sm[0].length, level: sm[1].length, title: sm[2].trim() });
}
for (let i = 0; i < matches.length; i++) {
const cur = matches[i];
const next = matches[i + 1];
sections.push({
headerEnd: cur.idx,
nextHeaderStart: next ? next.idx : content.length,
title: cur.title,
});
}
for (const sec of sections) {
if (!/금지|비추|forbidden|avoid|don'?t/i.test(sec.title)) continue;
const body = content.slice(sec.headerEnd, sec.nextHeaderStart);
const itemRe = /^-\s*❌\s*(?:"([^"]+)"|'([^']+)'|([^\n—]+?))(?:\s*[—–-].*)?$/gm;
let im: RegExpExecArray | null;
while ((im = itemRe.exec(body)) !== null) {
const phrase = (im[1] || im[2] || im[3] || '').trim();
if (isValidForbiddenToken(phrase)) {
entries.push({ forbidden: phrase, source: 'banned-section' });
}
}
}
// Dedup
const seen = new Set<string>();
const deduped = entries.filter((e) => {
const k = `${e.source}::${e.forbidden.toLowerCase()}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
_cache.set(filePath, { mtime: st.mtimeMs, entries: deduped });
lastIdx = 0; // suppress lint unused
void lastIdx;
return deduped;
} catch {
return [];
}
// ─── Pattern 2: 금지 섹션 내 `- ❌ "..."` 또는 `- ❌ ...` ───
// H2/H3 제목에 "금지" 또는 "비추" 포함된 섹션 본문만 스캔.
const sectionRegex = /^(#{2,3})\s+(.+)$/gm;
const sections: { headerEnd: number; nextHeaderStart: number; title: string }[] = [];
const matches: { idx: number; level: number; title: string }[] = [];
let sm: RegExpExecArray | null;
while ((sm = sectionRegex.exec(content)) !== null) {
matches.push({ idx: sm.index + sm[0].length, level: sm[1].length, title: sm[2].trim() });
}
for (let i = 0; i < matches.length; i++) {
const cur = matches[i];
const next = matches[i + 1];
sections.push({
headerEnd: cur.idx,
nextHeaderStart: next ? next.idx : content.length,
title: cur.title,
});
}
for (const sec of sections) {
if (!/금지|비추|forbidden|avoid|don'?t/i.test(sec.title)) continue;
const body = content.slice(sec.headerEnd, sec.nextHeaderStart);
const itemRe = /^-\s*❌\s*(?:"([^"]+)"|'([^']+)'|([^\n—]+?))(?:\s*[—–-].*)?$/gm;
let im: RegExpExecArray | null;
while ((im = itemRe.exec(body)) !== null) {
const phrase = (im[1] || im[2] || im[3] || '').trim();
if (isValidForbiddenToken(phrase)) {
entries.push({ forbidden: phrase, source: 'banned-section' });
}
}
}
// Dedup
const seen = new Set<string>();
return entries.filter((e) => {
const k = `${e.source}::${e.forbidden.toLowerCase()}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
export interface TermViolation {
@@ -181,7 +162,7 @@ export function validateTermUsage(
if (!fp || !fs.existsSync(fp)) {
return { ran: false, dictionarySize: 0, violations: [], totalViolations: 0 };
}
const entries = parseGlossary(fp);
const entries = _parsedCache.read(fp) ?? [];
if (entries.length === 0) {
return { ran: true, dictionarySize: 0, violations: [], totalViolations: 0 };
}