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:
@@ -23,27 +23,12 @@ export interface BuildAstraModeSystemPromptInput {
|
||||
/** From this._turnCtx.knowledgeMix — pass null when absent. */
|
||||
knowledgeMix: any;
|
||||
/**
|
||||
* [CONFLICT WARNINGS] 블록 — buildConflictWarningsBlock 산출. 빈 문자열이면 충돌 없음 → 주입 안 함.
|
||||
* v4 정책 텍스트의 "[CONFLICT WARNING] 플래그" 참조를 실제 데이터로 뒷받침.
|
||||
* 동적 시스템 프롬프트 블록 Map (id → 본문). memoryContext 가 채움.
|
||||
* 옛 named param 5개 (conflictWarningsCtx/coveChecklistCtx/intentClarificationCtx/
|
||||
* citationTraceCtx/terminologyCtx) 를 통합. casual 모드는 자동 skip.
|
||||
* 등록 순서대로 [CONTEXT] *밖* 에 join 되어 주입.
|
||||
*/
|
||||
conflictWarningsCtx?: string;
|
||||
/**
|
||||
* [VERIFICATION CHECKLIST] CoVe 블록 — buildCoveChecklistBlock 산출. 답변 *작성 전*
|
||||
* 그라운딩 체크리스트로 모델 self-verify 지시. 빈 문자열이면 비활성.
|
||||
*/
|
||||
coveChecklistCtx?: string;
|
||||
/**
|
||||
* [INTENT CLARIFICATION GUIDANCE] — 모호 질의 감지 시 *역질문 우선* 지시. 모호 아닐 때 빈 문자열.
|
||||
*/
|
||||
intentClarificationCtx?: string;
|
||||
/**
|
||||
* [CITATION TRACE] — 답변 끝에 사용 출처 한 줄 정리 지시. 검색 결과 있을 때 채워짐.
|
||||
*/
|
||||
citationTraceCtx?: string;
|
||||
/**
|
||||
* [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리 + Term Check 지침. 파일 있을 때만.
|
||||
*/
|
||||
terminologyCtx?: string;
|
||||
dynamicBlocks?: Map<string, string>;
|
||||
}
|
||||
|
||||
export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInput): string {
|
||||
@@ -62,11 +47,7 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
isCasualConversation,
|
||||
localPathContext,
|
||||
knowledgeMix,
|
||||
conflictWarningsCtx,
|
||||
coveChecklistCtx,
|
||||
intentClarificationCtx,
|
||||
citationTraceCtx,
|
||||
terminologyCtx,
|
||||
dynamicBlocks,
|
||||
} = input;
|
||||
|
||||
// 기존 Astra 모드 (에이전트 미선택)
|
||||
@@ -105,29 +86,15 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
// priorConclusionCtx 는 modeBridgeCtx 와 같은 위치 (base systemPrompt 직후) — 모델이
|
||||
// 자기 직전 결론을 anchor 로 잡고 사용자의 follow-up 을 그 결론에 대한 정정으로 해석하게.
|
||||
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
|
||||
// [CONFLICT WARNINGS] 는 [CONTEXT] 밖에 — token-truncation 시 보호. v4 정책이
|
||||
// 충돌 처리 *방법* 을 명시하고, 이 블록이 *어느 출처가 충돌* 인지 데이터 제공.
|
||||
// Casual conversation 모드에서는 RAG context 자체를 안 쓰므로 충돌 경고도 무의미 — 생략.
|
||||
const conflictWarningsBlock = (!isCasualConversation && conflictWarningsCtx && conflictWarningsCtx.trim())
|
||||
? '\n\n' + conflictWarningsCtx
|
||||
: '';
|
||||
// [VERIFICATION CHECKLIST] CoVe — 답변 작성 전 self-verify 지시. Conflict 와 마찬가지로
|
||||
// [CONTEXT] 밖, casual 모드 비활성. CoVe 가 강하면 단정적 답변이 줄고 근거 인용 늘어남.
|
||||
const coveBlock = (!isCasualConversation && coveChecklistCtx && coveChecklistCtx.trim())
|
||||
? '\n\n' + coveChecklistCtx
|
||||
: '';
|
||||
// [INTENT CLARIFICATION GUIDANCE] — 모호 차원 감지 시 *역질문 우선*. Casual 모드는 제외.
|
||||
// 위치: 다른 verification block 보다 *앞* — 모호하면 답변 자체를 안 만들어야 하므로.
|
||||
const intentBlock = (!isCasualConversation && intentClarificationCtx && intentClarificationCtx.trim())
|
||||
? '\n\n' + intentClarificationCtx
|
||||
: '';
|
||||
// [CITATION TRACE] — 답변 끝에 출처 한 줄. CoVe 와 함께 동작 — CoVe 가 라벨, Citation 이 정리.
|
||||
const citationBlock = (!isCasualConversation && citationTraceCtx && citationTraceCtx.trim())
|
||||
? '\n\n' + citationTraceCtx
|
||||
: '';
|
||||
// [TERMINOLOGY DICTIONARY] — 사용자 편집 글로서리. casual 모드 비활성 (greeting 에 용어 강제 의미 없음).
|
||||
const terminologyBlock = (!isCasualConversation && terminologyCtx && terminologyCtx.trim())
|
||||
? '\n\n' + terminologyCtx
|
||||
: '';
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}${intentBlock}${terminologyBlock}${conflictWarningsBlock}${coveBlock}${citationBlock}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
// 동적 블록 join — [CONTEXT] *밖* 에 주입돼 token-truncation 시 보호. Casual 모드면
|
||||
// RAG context 자체를 안 쓰므로 동적 블록도 의미 없음 → 일괄 skip.
|
||||
// 등록 순서대로 join (memoryContext 가 메모리 호출 순으로 set — 현재: intent →
|
||||
// terminology → conflict → cove → citation). 빈 본문 entry 는 자동 제외.
|
||||
let dynamicBlocksJoined = '';
|
||||
if (!isCasualConversation && dynamicBlocks && dynamicBlocks.size > 0) {
|
||||
for (const body of dynamicBlocks.values()) {
|
||||
if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
|
||||
}
|
||||
}
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음.
|
||||
*
|
||||
* 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함.
|
||||
*
|
||||
* 현재 등록 순서 (v2.2.197):
|
||||
* 1. devilRebuttal — Devil Agent 반박 카드 (비활성 시 silent skip)
|
||||
* 2. postHocSelfCheck — 답변 검증 LLM 호출 (opt-in, 기본 OFF)
|
||||
* 3. termValidator — 결정론적 글로서리 forbidden 검사 (기본 ON)
|
||||
*/
|
||||
|
||||
import type { PostAnswerHook, PostAnswerHookContext } from './types';
|
||||
import { maybeEmitDevilRebuttal as maybeEmitDevilRebuttalFn } from '../llm/devilRebuttal';
|
||||
import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from '../postHocSelfCheck';
|
||||
import { validateTermUsage, formatTermValidatorFooter } from '../termValidator';
|
||||
import { getConfig } from '../../config';
|
||||
|
||||
const devilRebuttalHook: PostAnswerHook = {
|
||||
id: 'devil-rebuttal',
|
||||
runAsync: true,
|
||||
async run(ctx: PostAnswerHookContext): Promise<void> {
|
||||
await maybeEmitDevilRebuttalFn(
|
||||
{
|
||||
getAbortSignal: ctx.getAbortSignal,
|
||||
callNonStreaming: ctx.callNonStreaming,
|
||||
// agent.ts 에서 vscode.Webview 를 통과시키므로 실런타임 호환. 타입 cast 로 hook 일반화.
|
||||
getWebview: ctx.getWebview as any,
|
||||
},
|
||||
{
|
||||
userPrompt: ctx.userPrompt,
|
||||
assistantAnswer: ctx.assistantAnswer,
|
||||
baseUrl: ctx.baseUrl,
|
||||
modelName: ctx.modelName,
|
||||
contextLength: ctx.contextLength,
|
||||
engine: ctx.engine,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const postHocSelfCheckHook: PostAnswerHook = {
|
||||
id: 'self-check',
|
||||
runAsync: true,
|
||||
async run(ctx: PostAnswerHookContext): Promise<void> {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.selfCheckEnabled) return;
|
||||
if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
|
||||
const model = (cfg.selfCheckModel || '').trim() || cfg.defaultModel;
|
||||
if (!model || !cfg.ollamaUrl) return;
|
||||
|
||||
const result = await postHocSelfCheck(ctx.userPrompt, ctx.assistantAnswer, ctx.selfCheckSources, {
|
||||
ollamaUrl: cfg.ollamaUrl,
|
||||
model,
|
||||
timeoutMs: (cfg.selfCheckTimeoutSec ?? 6) * 1000,
|
||||
excerptLength: DEFAULT_SELF_CHECK_OPTIONS.excerptLength,
|
||||
maxSources: DEFAULT_SELF_CHECK_OPTIONS.maxSources,
|
||||
});
|
||||
const footer = formatSelfCheckFooter(result, model);
|
||||
ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||
},
|
||||
};
|
||||
|
||||
const termValidatorHook: PostAnswerHook = {
|
||||
id: 'term-validator',
|
||||
runAsync: false,
|
||||
run(ctx: PostAnswerHookContext): void {
|
||||
const cfg = getConfig();
|
||||
if (cfg.termValidatorEnabled === false) return;
|
||||
if (!ctx.assistantAnswer || !ctx.assistantAnswer.trim()) return;
|
||||
const result = validateTermUsage(ctx.assistantAnswer, cfg.glossaryPath || '.astra/glossary.md');
|
||||
if (!result.ran || result.dictionarySize === 0) return;
|
||||
const footer = formatTermValidatorFooter(result);
|
||||
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||
},
|
||||
};
|
||||
|
||||
export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
||||
devilRebuttalHook,
|
||||
postHocSelfCheckHook,
|
||||
termValidatorHook,
|
||||
];
|
||||
|
||||
/** 모든 hook 을 안전하게 실행 — 한 hook 의 throw 가 다른 hook 막지 않음. */
|
||||
export function runPostAnswerHooks(ctx: PostAnswerHookContext): void {
|
||||
for (const hook of POST_ANSWER_HOOKS) {
|
||||
try {
|
||||
if (hook.runAsync) {
|
||||
void Promise.resolve(hook.run(ctx)).catch(() => { /* swallow */ });
|
||||
} else {
|
||||
hook.run(ctx);
|
||||
}
|
||||
} catch { /* hook never breaks the turn */ }
|
||||
}
|
||||
}
|
||||
|
||||
export type { PostAnswerHook, PostAnswerHookContext } from './types';
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Post-Answer Hook 인터페이스 — 답변 streaming 완료 후 실행되는 부가 작업.
|
||||
*
|
||||
* 옛 구조: `agent.ts` 의 `_maybeEmitDevilRebuttal`, `_maybePostHocSelfCheck`,
|
||||
* `_maybeRunTermValidator` 3개 private method. 새 hook 추가 시 (1) method 정의
|
||||
* (2) import (3) call site `void this._maybeX(...)` — 3곳 편집.
|
||||
*
|
||||
* 새 구조: 각 hook 이 `PostAnswerHook` 객체로 자기 module 에. agent.ts 는 1 loop.
|
||||
* 새 hook = 1 파일 + index.ts 에 1 push.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hook 이 webview 에 postMessage 만 하면 되므로 vscode.Webview 또는 slashRouter 의
|
||||
* 간이 Webview 둘 다 만족하는 최소 인터페이스로 정의.
|
||||
*/
|
||||
interface PostMessageWebview {
|
||||
postMessage(msg: any): Thenable<boolean> | boolean;
|
||||
}
|
||||
|
||||
export interface PostAnswerHookContext {
|
||||
userPrompt: string;
|
||||
assistantAnswer: string;
|
||||
/** LLM 호출용 — Devil/SelfCheck 가 사용. */
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
contextLength: number;
|
||||
engine: 'lmstudio' | 'ollama';
|
||||
/** Self-check 용 출처 미리보기. memoryContext 가 turnCtx 에 채움. */
|
||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||
/** Devil Agent 가 호출 — non-streaming LLM. */
|
||||
callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
|
||||
/** Abort signal accessor. */
|
||||
getAbortSignal: () => AbortSignal | undefined;
|
||||
/** Webview accessor — hook 결과 streamChunk 송출. vscode.Webview / 간이 Webview 호환. */
|
||||
getWebview: () => PostMessageWebview | undefined;
|
||||
}
|
||||
|
||||
export interface PostAnswerHook {
|
||||
/** 디버그·중복 방지용. */
|
||||
id: string;
|
||||
/**
|
||||
* true → fire-and-forget (async, main turn 영향 없음).
|
||||
* false → 동기 실행 (LLM 호출 없는 결정론적 hook, 예: termValidator).
|
||||
*/
|
||||
runAsync: boolean;
|
||||
/** 실행 본문. throw 해도 다른 hook 영향 없음 (caller 가 try/catch 로 감쌈). */
|
||||
run(ctx: PostAnswerHookContext): void | Promise<void>;
|
||||
}
|
||||
+58
-77
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user