# Local Review — 마일스톤 5b (r3) 미커밋 변경

**Reviewed**: 2026-08-14
**Branch**: `feat/index-code`
**Mode**: Local Review (advisory · 커밋 전 · receipt 체인 밖)
**Decision**: **REQUEST CHANGES** — HIGH 3건

## Summary

산출물 자체(`tests.jsonl` 58행)와 런타임 로직은 건전하다. 문제는 **검증 증거**다 — 계획
Acceptance 가 "이 검사가 살아 있다는 유일한 증거" 로 지정한 픽스처 셋이 검사 대상 코드를 부르지
않고 그 로직의 **사본**을 시험한다. 검사를 통째로 지워도 그 픽스처들은 초록으로 남는다.

## 검토 범위

| File | Action | 규모 |
|---|---|---|
| `docs/_meta/verify/build-tests.mjs` | CREATED (untracked) | 1,621행 |
| `docs/_meta/verify/check-tests-crosscheck.mjs` | CREATED (untracked) | 559행 |
| `docs/_meta/index/tests.jsonl` | CREATED (staged) | meta 1 + 행 58 · 182 KB |
| `docs/_meta/findings/11-index-code.md` | UPDATED | +64행 |

## Findings

### CRITICAL

없음.

### HIGH

**H1 · `check-tests-crosscheck.mjs:483-508` — 위조 음성 픽스처가 `crosscheck()` 를 부르지 않는다**

C-15 · C-16 · C-19 는 셋 다 `crosscheck()` 안의 로직을 selftest 본문에 **다시 적고** 그 사본에
단언한다. `crosscheck` 는 export 돼 있는데 selftest 어디서도 호출되지 않는다.

```js
// C-15 (:483-488) — 3자 대조를 부르지 않고 sameSet 을 직접 호출한다
const reportOnly = { test_files: { r: ["a.spec.ts"] } };
const rescanOnly = { test_files: { r: ["a.spec.ts", "b.spec.ts"] } };
if (!sameSet(reportOnly.test_files.r, rescanOnly.test_files.r)) e1.push("mismatch");

// C-16 (:491-497) — `--file` 전파 분기를 fake 객체로 재현한다
const fake = { status: 1, parsed: { ok: false, errors: [...] } };
if (fake.status !== 0 || fake.parsed.ok !== true) { ... }

// C-19 (:504-508) — 재집계 대조 루프를 재현한다
for (const k of Object.keys(dist)) if (dist[k] !== (recount[k] || 0)) e3.push("mismatch");
```

`crosscheck()` 의 `:332-351`(3자 경로 집합) · `:199-204`(`--file` 전파) · `:256-265`(분포 재집계)를
전부 삭제해도 세 픽스처는 PASS 한다. 파일 머리말(`:416-419`)이 "여기서는 `crosscheck()` 의 대조
논리를 직접 태운다" 고 적었으나 사실이 아니다.

계획 Acceptance: *"위조 음성 픽스처(`--selftest`)가 없으면 FAIL — 검사가 죽어 있어도 정상 입력은
통과한다."* 현재 형태는 이 조항을 만족하지 않는다.

**Fix** — 순수 대조부를 export 함수로 뽑고 양쪽이 같은 것을 부른다. 자식 프로세스는 여전히
`crosscheck()` 가 자기가 띄우므로 "독립 재스캔을 인자로 받으면 FAIL" 규칙을 깨지 않는다:

```js
export function compareSources({ report, rescan, vocab, meta, rows }) { /* :210-407 이동 */ }

export function crosscheck({ reposRoot, reportPath, jsonlPath }) {
  // ... --file / --rescan-candidates / --print-vocab-json 자식 호출은 여기 유지
  errors.push(...compareSources({ report, rescan, vocab, meta, rows }));
}
// selftest 는 합성 report/rescan/vocab/meta/rows 로 compareSources 를 직접 태운다
```

---

**H2 · `check-tests-crosscheck.mjs:87` — 자식의 비영 종료가 두 자리에서 삼켜진다**

```js
if (r.status !== 0 && !json) bail(`${where}: 비영 종료 (${r.status})`);
```

`json === true` 면 status 검사가 통째로 꺼진다. 호출자 셋 중 status 를 실제로 보는 것은 `--file`
하나뿐이다:

| 호출 | 위치 | status 검사 |
|---|---|---|
| `--file` | `:198` → `:199` 가 `fileGate.status` 를 본다 | 있음 |
| `--rescan-candidates` | `:207` — `.parsed` 만 꺼내 쓴다 | **없음** |
| `--print-vocab-json` | `:208` — 동일 | **없음** |

`--rescan-candidates` 가 비영으로 죽으면서 유효한 JSON 을 뱉으면(부분 결과 · 저장소 하나 누락)
게이트는 그 값을 정상 입력으로 받는다. 파일 머리말(`:24-25`)과 계획 Acceptance 가 둘 다
"비영 종료·시그널·stdout 파싱 실패·`ok:false` 중 하나라도 삼키면 FAIL" 이라고 적는다.

C-17 은 `{json:false}` 경로를, C-18 은 파싱 실패 경로를 잡는다 — **비어 있는 것은 정확히
`json:true` + 비영 종료 + 유효 JSON** 조합이다.

**Fix** — 기본은 항상 status 검사, `--file` 만 명시적으로 예외:

```js
function runVerifier(args, { json = true, allowNonZero = false } = {}) {
  ...
  if (r.status !== 0 && !allowNonZero) bail(`${where}: 비영 종료 (${r.status})\n${...}`);
```
호출부는 `runVerifier(["--file", ...], { allowNonZero: true })` 하나만 바뀐다. 픽스처도 한 줄:
비영 종료 + 유효 JSON 을 내는 자식으로 `bail` 을 확인.

---

**H3 · `build-tests.mjs:1534-1544` — 0-f 강등 대칭 픽스처가 clamp 식의 사본을 시험한다**

selftest 안 `clampOf` 가 생산 경로(`:1320-1321`)의 두 줄을 그대로 베낀다:

```js
// :1320-1321 (생산)
const anchorFile = String(invocation.id).slice(String(invocation.id).indexOf("/") + 1).replace(/:\d+$/, "");
const clamp = s.reusableTargets.has(anchorFile) && classifyHop(invocation.command || "").kind !== "unresolved";

// :1536-1537 (selftest 사본)
const anchor = inv.id.slice(inv.id.indexOf("/") + 1).replace(/:\d+$/, "");
const clamp = T2.has(anchor) && classifyHop(inv.command || "").kind !== "unresolved";
```

B-17a~d 는 계획 Acceptance 가 **명시적으로 요구한** 네 갈래(`강등 대칭 픽스처 네 갈래가 없으면
FAIL`)인데, 생산 경로의 clamp 를 지워도 넷 다 PASS 한다.

**Fix** — clamp 를 export 함수로 뽑고 `build()` 와 selftest 가 같은 것을 부른다:

```js
export function clampSelect(invocation, reusableTargets, verdict) {
  const anchorFile = String(invocation.id).slice(String(invocation.id).indexOf("/") + 1).replace(/:\d+$/, "");
  const clamped = reusableTargets.has(anchorFile) && classifyHop(invocation.command || "").kind !== "unresolved";
  return clamped ? "unresolved" : verdict;
}
```

### MEDIUM

**M1 · `build-tests.mjs:287` — `git ls-files` 가 비ASCII 경로를 C-인용으로 낸다**

`core.quotePath` 기본값이 `true` 라 `git ls-files` 는 한글 경로를
`"front/public/pdf/\353\263\264\355\227\230…"` 형태로 낸다. **실측으로 이미 존재한다** — site 4개 ·
admin 31개.

오늘 영향 0(그 35개 중 `.ts`/`.js` 는 0건). 하나라도 생기면:
- `outside_allowlist` 축: 인용 문자열이 `"` 로 끝나 `SOURCE_EXT_RE`(`:99`)에 안 걸려 **조용히
  빠진다** → 3자 대조가 report↔rescan 불일치로 잡긴 하지만 원인이 드러나지 않는 메시지가 나온다.
- 테스트 파일 축: 인용 문자열이 `readBlobs`(`:299`)로 가서 `cat-file` 이 `missing` 을 내고 중단.
  fail-closed 지만 메시지가 원인을 가리키지 않는다.

**Fix** — `git()` 헬퍼(`:143-156`)에서 인용을 끈다. 한 줄:
```js
return execFileSync("git", ["-C", cwd, "-c", "core.quotePath=false", ...args], { ... });
```

**M2 · `docs/_meta/findings/11-index-code.md:150` — 빈 줄이 §1 표를 쪼갠다**

행 53 과 행 54 사이에 빈 줄이 있어 발견 54·55 가 **헤더 없는 별도 표**로 렌더된다. 계획
Acceptance 는 §1 을 5열 표 하나로 건다. 빈 줄 삭제로 끝난다.

부수: `:161` 의 "발견 41~53 은 마일스톤 5b 소산이다" 도 54·55 를 빠뜨린다.

**M3 · `build-tests.mjs:1267` — 0-f clamp 가 `invocation_discovery` 를 움직인다**

```js
invocation_discovery: mine.some((x) => x === "run_in_ci") ? "run_in_ci" : "undetermined",
```
`mine` 은 행 `ci_status` 배열이고 그 값은 clamp 의 결과다. 계획 0-f: *"저장소 `invocation_discovery`
도 그대로다(호출 열거 자체는 성공했다)"* · Acceptance: *"클램프가 … `invocation_discovery` 를
바꾸면 FAIL"*.

**오늘도, 결정 C 혼합 경로에서도 해가 없다** — 비-`T` 파일의 양성 하나가 남아 있으면 `some(...)`
이 `run_in_ci` 를 유지한다. 검증기(`verify-tests.mjs:948-950`)도 이 결과를 받는다. 드러나는 것은
저장소의 러너 호출이 **전부** `T` 소속인 경우뿐이고, 그때도 과소 확정 방향이다.

**Fix** — 열거 성공 여부에서 파생시키거나, 계획/findings 에 이 편차를 명시적으로 기록. 어느 쪽도
1줄 수준이지만 **둘 중 하나는 해야 한다** — 지금은 Acceptance 조항이 조용히 어긋난 상태다.

**M4 · `outside_allowlist` 필드의 97%가 벤더 번들이다**

| repo | files | exclude_glob 대상 |
|---|---|---|
| admin | 245 | **238** (`_statics/` 234 · `pdfjs-dist/` 4) |
| site | 9 | 4 (`pdfjs-dist/`) |
| server | 1 | 0 |

`candidates_outside_allowlist.insuniverse-admin: 2354` 도 같은 출처다. 발견 52 · `gap` 16 이
사실은 적었지만 **규모를 적지 않아서**, 소비자는 2,354 를 실제 신호로 읽는다. 열거 동형은
유지해야 하므로(발견 52) 처방은 문면 쪽이다 — `gap` 16 에 "245 중 238" 을 넣거나 저장소별 벤더
계수를 별도 필드로.

**M5 · 잔여 항목이 소스 행 텍스트를 산출물에 싣는다 — 구현 노트의 서술과 어긋난다**

`scanSkipEvidence` 가 `expression: lineOf(m.index).trim()`(`:716,746,766`) ·
`callee: lineOf(at).trim().slice(0, 60)`(`:756,761`) 로 **소스 행 원문**을 잔여에 담고, 그것이
`:1197-1202` 를 통해 `tests.jsonl` `skip_scan_residue[]` 로 나간다.

`.claude/notes/…-m5b-r3.md:90` 은 *"산출물에 저장소 소스 **값**을 싣지 않는다 — 경로·좌표·어휘
값만"* 이라고 적는다. 오늘 잔여 0건이라 실제로 나간 것은 없지만 **서술이 틀렸다**.

비밀 노출 위험은 낮다(`.env` 하드 제외 · 대상이 테스트 소스). 그래도 보안 서술은 정정하거나
필드를 어휘 값으로 좁힐 것.

### LOW

| # | 위치 | 내용 |
|---|---|---|
| L1 | `build-tests.mjs:990` | `scanTargetPathspec` 을 `scanRepo` 안에서 다시 계산하고 쓰지 않는다 (`build()` 가 `:1305` 에서 자기 것을 만든다). 죽은 코드 |
| L2 | `build-tests.mjs:887,914,953,1049` | `channel` 맵을 저장소마다 만들어 반환하는데 아무도 읽지 않는다 — `buildMeta` 는 `workflows.length`/`dockerfiles.length` 로 채널을 만든다 |
| L3 | `build-tests.mjs:1079` | `resolveRowRunner(repo, path, allSet, files, pkgByPath)` 의 `repo`·`files` 가 본문에서 안 쓰인다 |
| L4 | `build-tests.mjs:1593` · `check-tests-crosscheck.mjs:535` | `flag(n)` 이 다음 argv 를 값으로 무검증 수용. `--repos-root --report` 면 `root === "--report"` 가 된다 |
| L5 | `build-tests.mjs:1586` | `--out`·`--tmp-dir` 이 `KNOWN_FLAGS` 에 있는데 `USAGE` 에 없다 |
| L6 | `check-tests-crosscheck.mjs:479-480,510` | `--selftest-dir` 없이 돌면 `docs/.tmp-crosscheck-selftest` 를 추적 트리 안에 만드는데, 그 디렉터리에 **아무것도 쓰지 않는다**. 죽은 코드 |
| L7 | `check-tests-crosscheck.mjs:188-192` | 끝 개행이 없으면 `pop()` 이 마지막 행을 `docs` 에서 없앤 채 개행 오류만 보고한다 — 이후 검사는 잘린 집합 위에서 돈다 |
| L8 | `check-tests-crosscheck.mjs:163-164` | `compareItemSets` 가 `Array.includes` 로 O(n²). 오늘 0건이라 무해 |

## Validation Results

| Check | Result | Notes |
|---|---|---|
| Syntax (`node --check`) | PASS | 2/2 |
| `build-tests.mjs --selftest` | PASS | 26건 |
| `check-tests-crosscheck.mjs --selftest` | PASS | 19건 (단, H1·H3 참조 — 일부는 사본을 시험한다) |
| 0-0 입력 리비전 게이트 | PASS | 3저장소 HEAD == manifest |
| 3자 대조 (report · 재스캔 · JSONL) | PASS | 지적 0건 |
| **복합 게이트 (`verify-tests --file`)** | **FAIL 3건** | 발견 54·55 — **5a 소유 · 이 리뷰 범위 밖** |
| 마일스톤 1~4 회귀 selftest | PASS | 3/3 |
| Lint / Build / Type check | N/A | 루트 `package.json` 없음 — 무의존 Node ESM |

> 복합 게이트 실패는 이 변경의 결함이 아니다. `verify-tests.mjs` 의 앵커 대소문자(발견 54)와
> Dockerfile `WORKDIR` 실재 검사(발견 55)이고 둘 다 마일스톤 5a 소유다.

## 보안

문제 없음 (M5 의 서술 정정 제외).

- 자식 프로세스: `execFileSync` / `spawnSync` + **배열 argv**, 셸 미경유 · 하드코딩 자격증명 0
- `.env`·`.env.*` 하드 제외(`ENV_FILE_RE`) + `exclude_pathspec` 14 + `exclude_glob` 11 이중 확인
- 저장소 경로는 `--repos-root` 아래로 join, 위반 시 0건 쓰기 + 비영 종료
- `catch { ok = false }` 0건 — 실패는 원인을 실은 채 throw

## Decision

**REQUEST CHANGES** — HIGH 3건.

셋 다 **런타임 동작을 바꾸지 않는다**. 오늘 산출된 `tests.jsonl` 은 이 셋과 무관하게 옳다.
셋이 바꾸는 것은 **회귀 방어**다 — 계획 Acceptance 가 "검사가 살아 있다는 증거" 로 지목한 픽스처가
증거가 아니고, 자식 비영 종료 하나가 소리 없이 지나간다. 5b 는 이미 5a 결함으로 차단돼 있으므로
차단 해소 커밋과 같은 사이클에서 함께 고치는 것이 비용이 가장 싸다.

권장 순서:
1. H1·H2·H3 (export 3개 + `allowNonZero` 1개 · 각 selftest 를 실 경로로 재배선)
2. M1·M2 (각 1줄)
3. M3·M4·M5 (문면 정정 또는 1줄 파생 변경)
4. LOW 는 같이 지나가며 정리
