- name
- config-menu-offers-options-nothing-implements
- description
- 사용자가 고르는 선택지 목록(enum·레지스트리·카탈로그·allowlist)을 폼/검증기/LLM 프롬프트가 "고를 수 있는 메뉴"로 읽을 때, 그 목록의 항목이 **실제로 구현돼 있는지는 아무도 검사하지 않는다.** 사용자가 유령 항목을 고르면 에러도 없이 그냥 아무 일도 안 일어난다 — 설정은 저장되고, 화면에도 보이고, 영원히 발화하지 않는다. 트리거 - 설정 드롭다운/룰 작성/라우팅 대상/웹훅 이벤트/기능 플래그 목록, "설정했는데 왜 동작 안 하지", 스펙만 있고 호출자가 없는 상수, 카탈로그를 LLM 프롬프트에 메뉴로 주입.
# 메뉴에 있는데 주방에 없다
## Problem
BSVibe #766. 형님이 라우팅 룰을 하나 썼다:
```
design (plan) → opus caller_id = workflow.agent_loop.plan
```
**두 달간 조용히 아무 일도 안 했다.** 에러 없음, 경고 없음, 로그 없음.
원인은 룰이 아니라 **레지스트리**였다. `caller_registry` 는 자기 문서에 계약을 적어두고 있었다:
> A *caller* is any code site that **invokes an LLM** through the dispatch mechanism.
그런데 실제 목록에는 두 종류가 섞여 있었다:
1. **설정할 줄 아는** 항목 — 스펙·타임아웃·required_methods 가 다 적힌 것
2. **런타임이 진짜 호출하는** 항목
그리고 **사용자 입력 표면 셋 다** (1)을 (2)의 증거로 취급했다:
| 표면 | 하는 일 |
|---|---|
| REST 검증기 | `if value in KNOWN_CALLERS: return value` |
| NL 컴파일러 | 같은 목록을 **LLM 프롬프트에 "고를 수 있는 메뉴"로 주입** |
| PWA 룰 폼 | `/callers` 응답을 드롭다운으로 렌더 |
∴ **목록 등재 = 사용자에게 하는 약속** — *"이걸 고르면 발화한다."*
**10개 중 2개가 그 약속을 어기고 있었다.**
## 왜 아무도 못 잡는가
- **타입 체커**: 상수는 존재한다. 문자열도 유효하다. mypy 는 만족한다.
- **유닛테스트**: 오히려 **유령 항목을 샘플로 쓴다.** 이번 경우 룰 작성 테스트 7개 파일 33곳이
`workflow.agent_loop.plan` 을 "유효한 caller 예시"로 사용했다 — 즉 테스트가 그 유령을
**정상이라고 증언하고 있었다.**
- **검증기**: 목록 대조는 통과한다. 목록이 거짓말인 게 문제다.
- **런타임**: 매칭이 안 될 뿐이므로 **예외가 안 난다.** fail-silent 가 기본값이다.
- **git blame**: 이 항목을 건드린 커밋 3개가 전부 **레지스트리 내부**였다(선언·타임아웃·플래그).
**배선 커밋은 존재한 적이 없다.** 아무도 "이거 누가 부르지?"를 묻지 않았다.
## Solution
### 1. 고아를 센다 (grep 말고 AST)
주석·docstring 언급이 배선으로 오인되면 안 된다 — *설명은 있는데 호출이 없는 것*이 정확히
이 결함의 모양이다.
```python
def _dispatched() -> set[str]:
"""실제로 ``caller_id=`` 인자로 넘어가는 값만."""
dispatched = set()
for path in BACKEND.rglob("*.py"):
if path == REGISTRY: # 선언 파일 자신은 제외
continue
for node in ast.walk(ast.parse(path.read_text())):
if isinstance(node, ast.Call):
for kw in node.keywords:
if kw.arg == "caller_id":
... # 리터럴이면 값, Name 이면 상수 테이블에서 해석
return dispatched
def test_every_declared_option_is_implemented():
orphans = sorted(set(KNOWN_CALLERS) - _dispatched())
assert not orphans, f"{orphans} 는 메뉴에 있는데 아무도 호출하지 않는다"
```
### 2. ⚠️ 양성 대조를 반드시 같이 넣어라
스캐너가 망가지면 "고아 없음"으로 **조용히 통과**한다 — 게이트가 스스로 무장해제된다.
```python
def test_the_scanner_actually_finds_wiring():
d = _dispatched()
assert "workflow.agent_loop.act" in d # 확실히 배선된 것
assert len(d) >= 5, f"스캐너가 {d} 만 찾음 — 레지스트리가 아니라 스캐너가 고장"
```
### 3. 면제 목록을 만들지 마라
`ALLOWED_ORPHANS = {...}` 는 **이 테스트가 금지하려는 상태 그 자체**다.
선택지는 둘뿐이다 — **배선하거나, 지우거나.**
### 4. 고아마다 둘 중 하나로 판정
| 상태 | 처방 |
|---|---|
| 스펙·설명·튜닝값이 **특정 호출 지점을 지목**한다 | **배선하라.** 없는 기능을 만드는 게 아니라 끊긴 링크를 잇는 것 |
| 그 개념이 아키텍처에서 **사라졌다** | **지워라.** 죽은 코드 |
BSVibe 실제 판정: `knowledge.query` 는 설명이 *"frame 이 knowledge_only 로 분류했을 때"* 라고
그 자리를 정확히 지목하고 90 초 타임아웃까지 *"사용자가 기다린다"* 로 튜닝돼 있었다 → **배선**.
(그 경로는 엉뚱하게 300 초짜리 `frame` 을 쓰고 있었다 — **유령 항목이 진짜 성능 결함을 숨기고 있었다.**)
`agent_loop.plan` 은 plan 턴 자체가 아키텍처에서 사라짐 → **삭제**.
### 5. 지울 때 딸려오는 것들을 세라
사용자 표면에 노출된 목록이므로 **번역 키·라벨 맵·샘플 픽스처**가 같이 산다:
```
backend 레지스트리 상수 + 스펙 + __all__ + 모듈 docstring
tests "유효한 예시"로 쓰인 곳 (BSVibe: 7파일 33곳)
frontend 라벨 맵 + i18n 메시지 키 (locale 마다!) + 컴포넌트 테스트
```
## Key Insights
- **사용자가 고를 수 있는 목록은 계약이다.** "설정 가능하다"와 "동작한다"는 다른 명제인데,
폼·검증기·프롬프트는 전자를 후자로 읽는다. 그 간극을 검사하는 코드는 대개 **존재하지 않는다.**
- **fail-silent 가 기본값이라 사용자만 피해를 본다.** 매칭이 안 되면 그냥 매칭이 안 될 뿐이다.
시스템은 건강하고, 설정은 저장돼 있고, 화면에도 보인다. **틀렸다는 신호가 어디에도 없다.**
- **유닛테스트가 유령의 알리바이가 된다.** 유효한 샘플이 필요하니 목록에서 하나 집어오는데,
하필 고아를 집으면 그때부터 테스트 스위트가 그 고아를 정상이라고 증언한다.
- **"이 상수를 누가 부르지?"를 선언 시점에 한 번만 물었어도** 두 달을 아꼈다.
`git log -S <상수>` 로 배선 커밋이 있었는지 보면 5초다.
## Red Flags
- 상수/스펙은 풍부한데(타임아웃·설명·플래그) **호출자를 못 찾겠다**
- 사용자가 *"설정했는데 아무 일도 안 일어난다"* 고 한다 — 에러 없이
- 카탈로그를 **LLM 프롬프트에 메뉴로 주입**한다(모델이 유령을 고르면 그대로 저장된다)
- 목록 항목이 **테스트에서만** 쓰인다
- `git log -S <상수>` 결과가 **선언 파일 내부 커밋뿐**이다
- 스펙 설명이 특정 호출 지점을 **말로 지목**하는데 그 지점 코드엔 그 상수가 없다
Auf GitHub ansehen