Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions docs/PLAN-0.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# 0.4.0 계획서 — 코드베이스 인덱싱 (프리뷰)

작성일: 2026-08-07 · 기준: `develop`(0.3.0 직후) · 상태: 초안

---

## 1. 왜 이것인가

에이전트가 저장소를 보는 눈은 `list_files` 와 `search_files` 둘뿐이고, 둘 다 **매번 디스크를 훑어서** 답한다. 0.3.0 에서 그 구조에서 나온 결함을 세 건 고쳤다.

| 무엇 | 증상 |
|---|---|
| 깊이 상한 8 | 8단계보다 깊은 파일이 트리·검색·치환 **어디에도** 없었다 |
| 점 폴더 일괄 제외 | `.vscode/`·`.claude/` 안이 통째로 안 보였다 |
| 잘렸다고 말 안 함 | 상한에 걸린 목록을 "전부" 로 내밀어, 있는 파일을 "없다" 로 답했다 |

셋 다 막았지만 **구조는 그대로다.** 매번 훑는 방식에는 상한이 필요하고, 상한이 있으면 "안 찾아본 것" 과 "없는 것" 이 계속 같은 답으로 나온다. 그리고 이름으로만 찾으므로 "인증을 어디서 하지" 같은 질문에는 애초에 답할 수 없다.

## 2. 이미 있는 것

새로 만들기 전에 무엇이 있는지부터. **인덱스의 절반은 이미 돌고 있다.**

- **TypeScript** — Monaco 의 TS 워커가 프로젝트를 통째로 들고 있다. `projectModels.preload` 가 최대 500개 모델을 미리 세워 두므로, 안 연 파일도 워커가 안다.
- **LSP** — `lspClient.workspaceSymbols(query)` 가 살아 있는 모든 세션에 `workspace/symbol` 을 던져 합친다. `Ctrl+T` 가 이걸 쓴다(App.tsx:5409).
- 즉 **심볼 질의 통로는 이미 있고**, 에이전트만 그걸 못 쓴다. `search_files` 는 텍스트 grep 이라 심볼을 모른다.

이 사실이 이 판의 첫 단계를 정한다 — 인덱스를 처음부터 짓기 전에, **있는 것을 에이전트에게 연결**하는 것이 먼저다.

## 3. 단계

### 1단계 — 심볼 질의를 에이전트에게 준다 *(끝남, 2026-08-07)*

`find_symbol` 도구를 붙인다. 안은 `lspClient.workspaceSymbols` 그대로다.

- 모델이 "이 함수 어디 있어" 를 grep 대신 심볼로 묻는다.
- 얻는 것: 정확도(주석·문자열 오탐 없음), 컨테이너 정보, 종류(class/function/…).
- **한계를 그대로 말한다** — LSP 세션이 없는 언어는 답이 없다. 그때 "없다" 가 아니라 "이 언어는 심볼 색인이 없다" 로 답해야 한다. 0.3.0 내내 지킨 선이 여기도 그대로다.

이 단계만으로 인덱싱의 값어치를 실측할 수 있다. 2단계를 지을지 말지를 여기서 나온 결과로 정한다.

#### 만들면서 알게 된 것

- **`Ctrl+T` 는 TypeScript 프로젝트에서 늘 빈 목록이었다.** `lspClient` 만 물어보는데 TS/JS 는 LSP 세션이 아니라 Monaco 내장 워커가 맡는다. 이 저장소 자체가 그랬다.
- **`getNavigateToItems` 는 Monaco 워커가 노출하지 않는다.** `Missing requestHandler or method` 가 난다. 대신 파일별 `getNavigationTree` 는 주므로, 그것을 모아 워크스페이스 심볼을 만든다.
- TS 트리는 **import 로 끌어온 이름(`alias`)** 과 **`describe("이름", …)` 블록**도 심볼로 준다. 둘 다 "정의가 어디" 의 답이 아니라, 앞은 빼고 뒤는 뒤로 민다.

#### 실측 (이 저장소, 모델 207개)

| 이름 | 심볼 | ms | grep | ms | 심볼의 첫 결과 |
|---|---:|---:|---:|---:|---|
| `applyProposal` | 5 | 200 | 27 | 36 | `engine/editApply.ts:41` |
| `flattenNavTree` | 2 | 171 | 25 | 36 | `engine/navTree.ts:38` |
| `writeAtomic` | 1 | 283 | 4 | 43 | `electron/checkpoints.cjs:66` |
| `notifyActiveEditor` | 1 | 150 | 3 | 31 | `ext/extHost.ts:90` |
| `planFileOps` | 6 | 155 | 33 | 29 | `ext/fileOps.ts:55` |
| `makeTouchSet` | 4 | 172 | 13 | 26 | `electron/touchSet.cjs:39` |

**grep 이 5.5배 많이 준다(105 대 19).** 심볼이 4~6배 느리지만 150~280ms 라 라운드 하나에서는 차이가 없다. 여섯 개 모두 첫 결과가 진짜 정의였다.

### 2단계 — 우리 인덱스 *(짓지 않기로)*

1단계 실측을 보고 접는다. 이유는 셋이다.

- **정확도 문제는 이미 풀렸다.** grep 대비 5.5배 좁고 첫 결과가 정의다.
- **TS/JS 는 Monaco 워커가, 나머지는 LSP 가 이미 덮는다.** 우리가 표를 또 만들면 같은 것을 두 벌 들고 갱신 문제만 새로 떠안는다.
- 남는 구멍은 **LSP 도 TS 도 없는 언어**인데, 그건 인덱스를 짓는 문제가 아니라 **그 언어 서버를 붙이는 문제**다. 값도 그쪽이 크다(정의·참조·진단이 한꺼번에 온다).

그래서 4절의 갱신 설계도 이번 판에서는 필요 없어진다 — 색인을 우리가 안 들고 있으면 낡을 것도 없다. **워커와 LSP 가 모델을 따라가고, 모델은 이미 앱이 관리한다.** 아래 4절은 나중에 정말 우리 표를 만들게 될 때를 위해 남긴다.

### 3단계 — 의미 검색은 하지 않는다(이번 판에서는)

임베딩은 모델·저장소·비용·프라이버시(로컬 임베딩이냐)가 전부 딸려 온다. 심볼 인덱스만으로 어디까지 되는지 본 뒤에 판단한다. **이 판의 범위 밖으로 명시한다.**

---

## 4. 진짜 문제는 갱신이다

인덱스는 낡는 순간 거짓말을 시작하고, **그건 지금 상태보다 나쁘다.** 지금은 느려도 늘 사실을 답한다.

0.3.0 에서 확인한 것 하나가 여기 그대로 걸린다 — **워처는 이름을 다 주지 못한다.** 파일 2500개를 한 번에 만들었더니 `fs.watch` 가 180개만 알려 줬다(윈도우 recursive 워처의 커널 버퍼가 넘친다). 즉 **"워처 알림으로 증분 갱신" 은 그 자체로 못 믿는다.**

선택지와 값:

| 방법 | 정확도 | 비용 | 비고 |
|---|---|---|---|
| 워처 증분만 | 낮음 | 싸다 | 위 이유로 단독 불가 |
| 트리 비교(readTree 전후) | 만들어짐·지워짐은 정확 | 중간 | **내용 변경은 못 잡는다** |
| 크기+mtime 비교 | 내용 변경까지 근사 | 중간 | mtime 이 같고 내용이 다른 경우가 드물게 있다 |
| 내용 해시 | 정확 | 비싸다 | 전체 재해싱을 언제 할지가 문제 |

**초안: 셋을 겹친다.** 워처를 1차 신호로 쓰되 믿지 않고, 트리 비교로 생성·삭제를 확정하고, 열린 파일과 최근 변경분만 mtime·크기로 검증한다. 그리고 **인덱스가 언제 만들어졌는지와 무엇을 못 봤는지를 화면에 남긴다.**

## 5. 프리뷰인 이유

기본은 꺼 둔다. 켠 사람에게는:

- 인덱스가 마지막으로 갱신된 시각
- 이번 색인에서 건너뛴 것(깊이·크기·인코딩·언어 미지원)
- 낡았을 가능성이 있으면 그렇다고

**모르는 것을 아는 척하지 않는다** — 0.3.0 을 관통한 규칙이고, 인덱스에는 더 중요하다. 인덱스가 틀리면 에이전트가 조용히 잘못된 답을 만들고, 그건 찾을 단서가 없다.

## 6. 이번 판에 안 하는 것

- 임베딩·의미 검색 (위 3단계)
- 참조 그래프 — 1단계 결과를 보고 정한다
- 인덱스를 디스크에 저장 — 먼저 메모리로 만들고, 다시 켤 때 다시 짓는 비용을 재 본 뒤에 정한다

## 7. 남은 것

1단계가 끝나고 2단계를 접었으므로, 0.4.0 의 남은 후보는 이렇게 바뀐다.

- **언어 서버를 더 붙인다** — Go·Rust·Java 등. 심볼·정의·참조·진단이 한꺼번에 따라온다. 지금 구멍이 정확히 여기다.
- **참조 찾기(누가 부르나)** — TS 워커의 `getReferencesAtPosition` 이 있는지부터 확인한다. `getNavigateToItems` 처럼 없을 수 있다.
- **의미 검색** — 3단계 그대로 미룬다.
32 changes: 30 additions & 2 deletions ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { ImagePane, MarkdownPane, isImage, mdToHtml } from "./editor/MediaPane";
import monaco, { languageOf, applyTsPaths, revalidateTs } from "./editor/monacoSetup";
import * as projectModels from "./editor/projectModels";
import * as proposalDeco from "./editor/proposalDeco";
import * as symbolIndex from "./editor/symbolIndex";
import { typeEdit, reducedMotion } from "./editor/editAnimator";
import * as lspClient from "./editor/lspClient";
import * as lspConv from "./editor/lspConverters";
Expand Down Expand Up @@ -3467,6 +3468,25 @@ export class App extends React.Component<{ playOpening?: boolean }, S> {
if (!all.length) return (glob ? `(${glob} 에 맞는 파일 없음)` : "(빈 워크스페이스)") + partial;
return shown.join("\n") + (cut ? `\n\n… ${cut}개 더 있음(전체 ${all.length}). glob 으로 좁혀서 다시 부르세요.` : "") + partial;
}
if (call.name === "find_symbol") {
const query = String(call.input?.query ?? "").trim();
this.addTool(toolId, agentId, t("sc2.verbSymbol"), query);
if (!query) {
this.setTool(toolId, { st: "done", note: t("sc2.noteError") });
return "오류: query 가 비었습니다.";
}
const r = await symbolIndex.findSymbols(query, 60);
this.setTool(toolId, { st: "done", note: t("sc2.noteHits", { n: r.hits.length }) });
// 색인이 아예 없는 것과 찾아봤는데 없는 것은 다르다. 섞으면 모델이 "그런 심볼은
// 없다" 고 단정하고 넘어간다.
if (!r.sources.length) {
return "이 워크스페이스에는 심볼 색인이 없습니다(TypeScript 프로젝트가 아니거나 해당 언어 서버가 없음). search_files 로 찾으세요.";
}
if (!r.hits.length) {
return `"${query}" 로 찾은 심볼 없음. 색인은 있습니다(${r.sources.join(", ")}) — 이름이 다르거나 색인이 없는 언어의 파일일 수 있으니 search_files 도 해 보세요.`;
}
return r.hits.map(h => `${h.rel}:${h.line}:${h.column} ${h.container ? h.container + "." : ""}${h.name}`).join(String.fromCharCode(10));
}
if (call.name === "search_files") {
const query = String(call.input?.query ?? "");
this.addTool(toolId, agentId, t("sc2.verbSearch"), query);
Expand Down Expand Up @@ -5414,8 +5434,16 @@ ${(r.output || "").slice(0, 2000)}`;
this.setState({ symLoading: true });
this._symTimer = setTimeout(async () => {
try {
const raw = await lspClient.workspaceSymbols(q);
const results = lspConv.toWorkspaceSymbols(raw).slice(0, 200);
// 예전엔 lspClient 만 물어봤다. TS/JS 는 LSP 세션이 아니라 Monaco 내장 워커가
// 맡으므로, TypeScript 프로젝트에서는 Ctrl+T 가 늘 빈 목록이었다(이 저장소가 그렇다).
const r = await symbolIndex.findSymbols(q, 200);
// uri 자리에는 진짜 파일 uri 를 넣는다 — 아래 jumpToSymbol·경로 표시가 그걸 판다.
const wsRoot = (this.state.workspace?.root ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
const results = r.hits.map(h => ({
name: h.name, container: h.container, kind: h.kind,
uri: monaco.Uri.file(wsRoot + "/" + h.rel).toString(),
range: { startLineNumber: h.line, startColumn: h.column, endLineNumber: h.line, endColumn: h.column },
}));
// 현재 쿼리와 여전히 일치할 때만 반영
if (this.state.symQuery.trim() === q) this.setState({ symResults: results, symLoading: false });
} catch { this.setState({ symResults: [], symLoading: false }); }
Expand Down
15 changes: 15 additions & 0 deletions ide/src/ai/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,21 @@ export const WORKSPACE_TOOLS: ToolDef[] = [
required: ["query"],
},
},
{
name: "find_symbol",
description:
"이름으로 심볼(함수·클래스·타입·변수)의 정의 자리를 찾는다. " +
"search_files 는 텍스트를 찾으므로 주석·문자열·호출부까지 다 걸리지만, 이것은 정의만 준다. " +
"'X 가 어디 정의돼 있지' 류의 질문에는 이쪽이 정확하고 빠르다. " +
"언어에 색인이 없으면 결과 대신 그렇다고 알려 준다 — 그때는 search_files 로 가라.",
input_schema: {
type: "object",
properties: {
query: { type: "string", description: "심볼 이름 또는 그 일부" },
},
required: ["query"],
},
},
{
name: "read_file",
description:
Expand Down
8 changes: 8 additions & 0 deletions ide/src/editor/lspClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ export async function executeCommand(languageId: string, command: string, args:
}

/** 살아있는 모든 세션에 workspace/symbol 질의 후 병합 (Ctrl+T) — 새 서버는 안 띄움 */
/** 살아 있는 세션 수. 0 이면 이 워크스페이스에는 LSP 색인이 없다 —
* "심볼 없음" 과 "찾아볼 곳이 없음" 을 가르는 데 쓴다. */
export function liveSessionCount(): number {
let n = 0;
for (const [, s] of sessions) if (!s.dead) n++;
return n;
}

export async function workspaceSymbols(query: string): Promise<any[]> {
const out: any[] = [];
for (const [, s] of sessions) {
Expand Down
3 changes: 3 additions & 0 deletions ide/src/editor/projectModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ function uriFor(root: string, rel: string): monaco.Uri {
return monaco.Uri.file(base + "/" + rel);
}

/** 지금 열린 워크스페이스 루트. 모델이 없는 uri 를 상대 경로로 뗄 때 필요하다. */
export function currentRootPath(): string | null { return currentRoot; }

export function relFor(uriString: string): string | null {
if (!currentRoot) return null;
for (const [rel, u] of relIndex) if (u === uriString) return rel;
Expand Down
137 changes: 137 additions & 0 deletions ide/src/editor/symbolIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// 워크스페이스 심볼 찾기 — 이름으로 정의 자리를 찾는다.
//
// 두 군데서 온다.
// 1. LSP 세션 (pyright 등) — workspace/symbol
// 2. Monaco 의 TS 워커 — 파일별 navigation tree 를 모아서
//
// 2번이 빠져 있었다. Ctrl+T 는 lspClient 만 물어봤는데 TS/JS 는 LSP 세션이 아니라
// Monaco 내장 워커가 맡는다. 그래서 TypeScript 프로젝트에서 Ctrl+T 가 늘 빈 목록이었다.
// 이 저장소 자체가 그렇다.
//
// 색인이 아예 없어서 못 찾은 것과 찾아봤는데 없는 것은 다르다. 그 둘을 갈라서 낸다 —
// 안 그러면 "그런 심볼 없음" 이 "그 언어는 지원 안 함" 을 덮어 버린다.

import monaco from "./monacoSetup";
import * as lspClient from "./lspClient";
import * as projectModels from "./projectModels";
import { flattenNavTree, isTestPath } from "../engine/navTree";

export interface SymbolHit {
name: string;
/** 클래스명 등 담고 있는 것. 없으면 빈 문자열. */
container: string;
/** LSP SymbolKind 번호. 모르면 0. */
kind: number;
rel: string;
line: number;
column: number;
/** 어디서 왔나 — 한계를 설명할 때 쓴다. */
from: "ts" | "lsp";
}

export interface SymbolAnswer {
hits: SymbolHit[];
/** 실제로 물어본 곳. 비어 있으면 이 워크스페이스에는 심볼 색인이 없다. */
sources: ("ts" | "lsp")[];
/** 모델이 너무 많아 다 훑지 못했다 — "없다" 를 단정하면 안 되는 경우. */
capped: boolean;
}

/** 워커가 주는 파일 이름(모델 uri) → 워크스페이스 상대 경로 */
function relOfUri(uri: string): string | null {
return projectModels.relFor(uri);
}

/** 한 번에 훑을 모델 수. 넘으면 훑다 만 것이므로 그렇다고 말한다. */
export const TS_MODEL_CAP = 400;

async function fromTypescript(query: string, max: number): Promise<{ hits: SymbolHit[]; available: boolean; capped: boolean }> {
const ts: any = (monaco.languages as any).typescript;
if (!ts?.getTypeScriptWorker) return { hits: [], available: false, capped: false };
const models = monaco.editor.getModels()
.filter(m => !m.isDisposed() && /typescript|javascript/.test(m.getLanguageId()) && projectModels.relFor(m.uri.toString()));
if (!models.length) return { hits: [], available: false, capped: false };

// Monaco 의 워커 프록시는 getNavigateToItems 를 노출하지 않는다(실측: "Missing
// requestHandler or method"). 대신 파일별 navigation tree 를 주므로, 그걸 모아
// 워크스페이스 심볼을 만든다. 모델은 preload 가 세워 두어 안 연 파일도 들어온다.
let getWorker: any;
try { getWorker = await ts.getTypeScriptWorker(); } catch { return { hits: [], available: true, capped: false }; }

const q = query;
const scan = models.slice(0, TS_MODEL_CAP);
const out: SymbolHit[] = [];
await Promise.all(scan.map(async (m) => {
if (out.length >= max) return;
const rel = projectModels.relFor(m.uri.toString());
if (!rel) return;
try {
const client = await getWorker(m.uri);
const tree = await client.getNavigationTree(m.uri.toString());
for (const f of flattenNavTree(tree, q)) {
const pos = m.getPositionAt(f.offset);
out.push({ name: f.name, container: f.container, kind: f.kind, rel, line: pos.lineNumber, column: pos.column, from: "ts" });
}
} catch { /* 이 파일은 건너뛴다 — 워커가 아직 모르거나 파싱 실패 */ }
}));
return { hits: out, available: true, capped: models.length > scan.length };
}

async function fromLsp(query: string): Promise<{ hits: SymbolHit[]; available: boolean }> {
const raw = await lspClient.workspaceSymbols(query).catch(() => []);
const hits: SymbolHit[] = [];
for (const s of raw ?? []) {
const loc = s?.location ?? s;
const uri = String(loc?.uri ?? "");
const rel = relOfUri(uri) ?? uriToRel(uri);
if (!rel) continue;
const start = loc?.range?.start ?? { line: 0, character: 0 };
hits.push({
name: String(s?.name ?? ""),
container: String(s?.containerName ?? ""),
kind: Number(s?.kind ?? 0),
rel,
line: Number(start.line ?? 0) + 1,
column: Number(start.character ?? 0) + 1,
from: "lsp",
});
}
return { hits, available: lspClient.liveSessionCount() > 0 };
}

/** LSP 는 파일 uri 를 그대로 준다 — 모델이 없을 수도 있어 경로에서 직접 뗀다. */
function uriToRel(uri: string): string | null {
const root = projectModels.currentRootPath();
if (!root) return null;
let p = String(uri || "");
if (/^file:\/\//i.test(p)) {
try { p = decodeURIComponent(p.replace(/^file:\/\/\/?/i, "")); } catch { /* 망가진 인코딩 */ }
}
const a = p.replace(/\\/g, "/").toLowerCase();
const b = root.replace(/\\/g, "/").toLowerCase();
if (!a.startsWith(b)) return null;
return p.replace(/\\/g, "/").slice(root.length).replace(/^\/+/, "") || null;
}

/** 이름으로 심볼을 찾는다. 두 통로에 모두 물어보고 합친다. */
export async function findSymbols(query: string, max = 100): Promise<SymbolAnswer> {
const q = String(query ?? "").trim();
if (!q) return { hits: [], sources: [], capped: false };
const [ts, lsp] = await Promise.all([fromTypescript(q, max), fromLsp(q)]);
const sources: ("ts" | "lsp")[] = [];
if (ts.available) sources.push("ts");
if (lsp.available) sources.push("lsp");

// 같은 자리를 두 통로가 다 주면 하나만 남긴다.
const seen = new Set<string>();
const hits: SymbolHit[] = [];
for (const h of [...ts.hits, ...lsp.hits]) {
const key = h.rel + ":" + h.line + ":" + h.name;
if (seen.has(key)) continue;
seen.add(key);
hits.push(h);
}
// 테스트 파일은 뒤로. describe("이름", …) 이 심볼로 잡혀 첫 답이 테스트가 되곤 했다.
const ordered = [...hits.filter(h => !isTestPath(h.rel)), ...hits.filter(h => isTestPath(h.rel))];
return { hits: ordered.slice(0, max), sources, capped: ts.capped };
}
Loading
Loading