2026년 8월 29일 · 5분 읽기
because 안의 cause: 부분 일치가 통과시킨 목록
열 개 이름을 요구하는 검사가 이름별 부분 문자열 대조였다. 여덟 개만 열거한 문서도 10/10으로 통과했고, 경계를 붙인 한 문장이 그것을 막는다.
같은 이름 열 개가 저장소의 여러 면에 적혀 있다. 문서, 템플릿 파일 이름, 검사기 어휘, 선택 표. 그 목록이 조용히 갈라지는 것을 막으려고 검사를 하나 두었는데, 그 검사가 이름을 하나씩 부분 문자열로 찾고 있었다. 그래서 목록에서 이름을 지워도 검사는 통과했다.
목록 하나가 네 면에 흩어져 있다
정본은 테스트 상수 하나다. 나머지 면은 모두 그 상수에 답해야 한다.
// plugins/justsend/tests/hook.test.ts:17-19
const STORIES = ["flow", "pipeline", "state", "structure", "sequence",
"comparison", "loop", "timeline", "swimlane", "cause"];배포된 검사는 이 상수를 지시면 한 곳과만 대조했다. 템플릿 열 장, 검사기 어휘, 문서의 선택 표는 정본과 비교되지 않았다. 한 면만 고쳐도 통과하는 구조였다.
부분 일치는 삭제를 놓친다
문제는 매처의 의미에 있었다. 문서가 그것을 그대로 적어 둔다.
.toContaincan also check whether a string is a substring of another string.
문자열에 대해서는 부분 문자열 판정이다. 그래서 state는 statement 안에서 걸리고, cause는 because 안에서 걸린다. 열거 목록에 그 이름이 있는지와 무관하게 통과한다.
같은 규칙으로 재현하면 이렇게 나온다.
$ node -e "여덟 개만 열거한 지시면이 per-name 검사를 통과한다"
열거된 이름: 8개
per-name includes 통과: 10/10
state 통과 이유: statement 안의 state
cause 통과 이유: because 안의 cause
bounded 문장 통과: false
여덟 개만 열거한 문서가 열 개 요구를 전부 통과한다. 검사가 있다는 사실이 목록이 맞다는 뜻이 아니었다.
반대 방향도 열려 있었다
이름을 지우는 것만 문제가 아니다. 열한 번째 이름을 목록 안에 끼워 넣어도 이름별 대조는 아무 말을 하지 않는다. 요구가 "이 열 개가 있는가"뿐이라 "이 열 개만 있는가"를 묻지 않기 때문이다. 어휘가 늘어나는 방향의 이탈은 그대로 통과했다.
경계를 붙인 한 문장
고친 방향은 대조 단위를 이름에서 문장으로 올린 것이다. 정본 목록에서 문장을 만들고, 구분자까지 그대로 요구한다.
// plugins/justsend/tests/hook.test.ts:380-387
for (const surface of policySurfaces) {
expect(surface).toContain("templates/hero");
expect(surface).toContain("data-type");
// One bounded sentence, identical on all three surfaces, built here from the
// canonical list. Per-name `toContain` was the weaker claim it replaces:
// `cause` matched "because" and `state` matched "statement", so a name
// deleted from the list still passed, and an eleventh name inserted into it
// was never rejected. Requiring the run with its delimiters catches both.
expect(surface).toContain(`(ten are drawn: ${STORIES.join(" ")})`);
}이제 통과 조건은 "열 개 이름이 이 순서로, 이 구분자로, 이 괄호 안에 있다"다. 이름을 지우면 문장이 달라지고, 열한 번째를 넣어도 문장이 달라진다. 두 방향 모두 걸린다.
검사기는 선언한 유형과 그림을 함께 본다
목록만 맞추는 것으로는 부족하다. 그림 하나가 자기 유형을 선언하고, 검사기가 그 선언과 실제 그림을 대조한다. 목록에 없는 이름을 선언하면 거절되고, 선언한 유형이 그림과 어긋나면 그것도 거절된다.
// plugins/justsend/tests/hook.test.ts:496-501
expect(run(unknown).exitCode).toBe(1);
expect(run(unknown).stderr.toString()).toContain("is not one of");
const crossed = run(fixture, "loop");
expect(crossed.exitCode).toBe(1);
expect(crossed.stderr.toString()).toContain("contradicts the page");두 문구가 서로 다른 실패를 가리킨다. is not one of는 어휘 밖의 이름이고, contradicts the page는 선언과 그림이 어긋난 경우다. 후자가 없으면 유형 이름은 장식이 된다 — 아무 그림에나 아무 이름을 붙일 수 있기 때문이다.
면마다 하나씩 물어야 한다
문장 하나로 지시면을 묶은 다음, 나머지 면도 각각 정본에 답하게 했다.
| 면 | 무엇을 요구하나 |
|---|---|
| 지시면 3곳 | 경계 붙인 같은 문장을 그대로 담는다 |
| 템플릿 10장 | 파일 이름이 정본 집합과 같고 각 파일이 자기 유형을 선언한다 |
| 검사기 어휘 | 정본과 같은 집합이며 열한 번째를 거절한다 |
| 문서 선택 표 | 행이 정본 집합과 같고 접힌 유형은 행을 갖지 않는다 |
템플릿 검사에는 조건이 하나 더 있다. 열 장이 서로 다른 그림이어야 한다는 것이다. 라벨만 바꾼 같은 그림 열 장은 목록을 맞추면서도 아무 문제를 고치지 않는다.
// plugins/justsend/tests/hook.test.ts:444-455
expect(readdirSync(templates).sort()).toEqual(STORIES.map((s) => `${s}.html`).sort());
...
// Ten templates that were one template with new labels would not fix anything.
expect(bodies.size).toBe(STORIES.length);왜 목록은 흩어지는가
목록이 한 곳에만 있으면 이 글은 필요하지 않다. 흩어지는 이유는 목록이 서로 다른 소비자에게 서로 다른 형태로 필요하기 때문이다. 지시면은 사람이 읽는 문장을 요구하고, 템플릿은 파일 이름을 요구하고, 검사기는 집합을 요구하고, 문서 표는 행을 요구한다. 같은 열 개를 네 형태로 적는 순간 갈라질 자리가 넷이 된다.
그래서 정본을 코드 상수로 두고, 네 형태를 그 상수에서 만들거나 그 상수와 대조한다. 지시면의 요구가 이름 10개에서 문장 1개로 바뀐 것도 같은 이유다 — 10 → 1로 줄인 것이 아니라, 열 개를 한 덩어리로 묶어 부분 일치가 파고들 틈을 없앤 것이다.
이 교체는 0.14.1 → 0.14.2에서 이뤄졌다. 버전이 올라간 것 자체가 근거는 아니지만, 같은 이름 목록을 두 판 사이에 비교할 수 있게 해 준다.
대가
경계 붙인 문장은 문구를 고정한다. 목록을 바꾸려면 세 지시면의 같은 문장을 함께 고쳐야 하고, 그 전까지 스위트는 빨간불이다. 비용이지만 의도한 비용이다. 목록을 바꾸는 일이 세 면을 함께 바꾸는 일이라는 사실을 검사가 강제한다.
교체한 대조를 포함해 스위트는 두 파일에서 116건이 통과하고 3.60 s가 든다. 이 수치가 증명하는 것은 이탈 경로 두 개가 닫혔다는 것뿐이고, 열 개 이름이 옳은 분류인지는 아무 검사도 판정하지 않는다.
$ bun test plugins/justsend/tests/
116 pass
0 fail
454 expect() calls
Ran 116 tests across 2 files. [3.60s]
목록을 지키는 검사에서 배운 것은 하나다. 요구가 약하면 검사는 요구가 아니라 알리바이가 된다. 검사가 있다는 사실이 사람을 안심시키고, 그 안심이 목록을 다시 읽지 않게 만든다. 부분 일치로 열 개를 확인하던 판에서 문서 세 곳 중 하나만 대조되고 있었다는 사실을 아무도 몰랐던 이유가 그것이다.
대조를 문장으로 올린 뒤에는 반대의 성질이 생겼다. 목록을 만지면 즉시 빨간불이 뜨고, 빨간불을 끄는 유일한 길이 네 면을 함께 고치는 것이다. 검사가 알리바이가 아니라 절차가 된다. 한 가지는 이 검사가 하지 못한다. 이름 열 개가 독자에게 옳은 분류인지는 어떤 대조도 판정하지 않는다. 목록이 갈라지지 않게 지키는 일과 목록이 좋은지 판단하는 일은 다른 일이고, 뒤쪽은 사람이 그림 열 장을 나란히 놓고 보는 것으로만 결정된다.