설계 문서
kimchi-claude 설계 — 자연스러운 한국어로 답하는 클로드 코드 플러그인
작성일: 2026-09-22
문제
클로드 코드가 한국어로 답할 때 한국 개발자가 실제로 쓰지 않는 말이 나온다. 세 갈래로 갈린다.
- 직역 신조어 —
thin contract를 “얇은 계약”으로,ladder를 “레더”로 옮긴다. 단어를 하나씩 보면 한국어인데 뜻이 통하지 않는다. - 판교어와 한영 혼용 — “이 컴포넌트의 state를 업데이트하고 디플로이하시죠”.
- 번역체 문장 구조 — “우리는 캐시를 재생성한다”, “당신의 코드”, “~를 수행하다”.
세 갈래가 서로 반대 방향으로 당긴다는 점이 중요하다. 1번은 영어를 너무 많이 옮겨서 생기고 2번은 너무 적게 옮겨서 생긴다. 따라서 “영어를 줄여라” 나 “영어를 써라” 중 어느 쪽도 해법이 아니다. 용어별로 정착된 표기를 고정하는 것 과 문체를 고정하는 것, 두 축으로 접근해야 한다.
목표와 비목표
목표
- 사용자가 한국어로 물으면 한국 개발자가 실제로 쓰는 말투로 답한다.
- 설치만 하면 켜진다. 사용자도 모델도 무언가를 따로 불러오지 않는다.
- 클로드의 작업 품질은 그대로 유지한다. 판단, 분석, 코드, 도구 사용에 관여하지 않는다.
비목표
- 코드 주석과 소스 파일 안의 문자열은 건드리지 않는다. 오픈소스나 다국어 팀에서 역효과가 난다.
- 영어로 진행하는 세션에는 개입하지 않는다.
- 대화 텍스트를 실시간으로 검사하거나 고쳐 쓰지 않는다. 그럴 수 있는 배선이 없다(아래 “알려진 한계” 참조).
확정된 결정
| 항목 | 결정 |
|---|---|
| 목표 문체 | 실무 동료체. 존댓말, 군더더기 없음. 같이 일하는 개발자가 슬랙으로 설명해 주는 느낌 |
| 적용 범위 | 대화 설명, 계획서·할 일 목록, 커밋 메시지·PR 설명. 코드 주석 제외 |
| 활성 조건 | 사용자가 한국어로 쓸 때만 |
| 적용 방식 | 플러그인 번들 출력 스타일, force-for-plugin: true로 강제 적용 |
| 코딩 지침 | keep-coding-instructions: true (필수) |
| 런타임 | Node ESM, 의존성 없음 |
문체 견본
이 함수가 호출될 때마다 캐시를 새로 만들고 있어서 느렸습니다. 결과를 한 번만 계산하고 재사용하도록 고쳤고, 테스트는 전부 통과합니다.
용어 처리 우선순위
이 순서가 플러그인의 핵심 규칙이다. 2026-09-26에 소유자가 순서를 뒤집었다 — 아래 “설계 중 뒤집은 결정”에 근거를 남겼다.
- 한국 개발자가 실제로 타이핑하는 대로 쓴다. 슬랙, PR, 코드 리뷰에 쓰는 말이 기준이다. 확신이 서지 않으면 원어(로마자)를 그대로 쓴다.
- 한국어로 정착한 말은 한국어로 쓴다. 음차: 배포, 커밋, 브랜치, 머지, 캐시, 빌드, 테스트, 로그, 업데이트, 리팩터링, 인터페이스, 스레드. 기본 명사: 함수, 배열, 값, 호출, 반환, 설정. 한자어 기술 용어: 결합도, 응집도, 의존성, 멱등, 동시성, 병목, 직렬화, 캡슐화, 추상화, 인증, 인가.
- 개발자가 로마자로 쓰는 말은 로마자로 쓴다. state, props, payload, target, directory,
endpoint 같은 명사다. race condition·deadlock·latency·throughput·side effect·regression도
여기 넣는다 — 슬랙·PR에서 개발자가 실제로 영어 원어를 그대로 타이핑하고, 규칙표
(
rules/hanja.md,rules/terms.md)도 이제 원어를쓸 것의 첫 자리로 가르친다. 경쟁 조건·교착 상태·지연 시간·처리량·부수 효과·회귀는 설명이 필요할 때 괄호로 덧붙이는 대안이다 (2026-09-26, 아래 “설계 중 뒤집은 결정”의 뒤이은 항목 참고). function·array·value도 함수·배열·값이 정착 한국어 명사이므로 로마자로 남기지 않는다. 명사 자리에만 쓰고 동사는 한국어를 쓰며, 로마자 동사에 하다를 붙이지 않는다(push합니다는 틀리고푸시합니다가 맞다). 사내 은어(컨펌, 어사인, 팔로업)는 기술 용어가 아니므로 여기 해당하지 않는다. - 순우리말 풀어쓰기는 최후 수단이다. 서술문으로 늘이면 대개 더 어색해진다.
- 은유는 번역 대상이 아니다. 은유를 벗기고 개념의 이름을 쓴다.
4번과 5번이 특히 중요하다. 이 두 줄은 모델의 기본 성향을 거스르는 지시이므로 다른 규칙보다 강하게 써야 한다. 근거는 아래 “설계 중 뒤집은 결정”에 기록해 두었다.
구조
두 층으로 되어 있고 두 층이 정반대 원칙으로 동작한다. 말투는 항상 켜져 있어야 하고, 지식은 필요할 때만 열려야 한다.
kimchi-claude/
├── .claude-plugin/
│ ├── plugin.json
│ └── marketplace.json 저장소를 그대로 마켓플레이스로 쓴다
│
├── output-styles/ 항상 켜져 있는 층
│ └── natural-korean.md 생성물. 시스템 프롬프트에 강제 적용된다
├── rules/ 자료 원본. 사람이 읽고 기계가 파싱한다
│ ├── hanja.md 한자어 기술용어 대응표
│ ├── metaphors.md 영어 은유의 정착 여부 판정
│ ├── terms.md 직역·음차 오류
│ ├── patterns.md 번역체 문장 구조
│ ├── pangyo.md 판교어와 한영 혼용
│ ├── register.md 어투·표기 규칙
│ └── observed.md 실제 출력에서 관찰한 사례. 손으로 유지한다
│
├── skills/ 필요할 때 열리는 층
│ ├── korean-encoding/ CP949, 엑셀 CSV BOM, 전각 문자, 앞자리 0, 초성 검색
│ ├── korean-datetime/ 공휴일·대체공휴일·은행 휴무일, 음력, KST, 영업일
│ ├── korean-identifiers/ 주민번호(검증 불가·저장 금지), 마스킹, 관리자 화면
│ ├── korean-formats/ 주소, 전화번호, 이름에 대한 가정
│ └── natural-korean-writing/ 규칙표 전체를 가리킨다. 글을 검토할 때 펼친다
│
├── hooks/
│ ├── hooks.json
│ ├── guard.mjs 산출물 검사의 단일 진입점. 차단이 경고보다 앞선다
│ ├── session-language.mjs 저장소의 산출물 언어를 세션 시작에 알려 준다
│ └── lib/
│ ├── rules.mjs 마크다운 표 → 규칙 객체. builtins(latin-hada 등)도 여기서 내보낸다
│ ├── checks.mjs `검사` 칸 상수(치환/정규식/프롬프트). rules.mjs·latin-hada.mjs가 함께 본다
│ ├── segment.mjs 검사 제외 구간(코드·경로·예외 표시)
│ ├── detect.mjs 한국어 문서인지 판정
│ ├── lint.mjs 규칙 검사와 자동 교정. 조사 안전장치
│ ├── latin-hada.mjs 로마자 동사에 하다를 붙인 표현("push합니다") 탐지. 경고 전용
│ ├── particle.mjs 영어 낱말 뒤 조사를 발음으로 판정
│ ├── format.mjs 같은 지적·교정을 묶어 세고 N가지만 나열하는 공통 로직
│ ├── pii.mjs 주민등록번호 탐지
│ ├── artifact.mjs 말투 판정. 훅 입출력은 다루지 않는다
│ ├── bash-commit.mjs git commit 명령에서 메시지 구간만 정확한 위치로 추출
│ ├── repo-language.mjs 커밋 이력과 문서로 산출물 언어를 추론
│ └── entrypoint.mjs 직접 실행 판정(공백·한글·심볼릭 링크 경로 포함)과 훅의 stdin 읽기·안전한 종료(runHook)
│
├── scripts/
│ ├── build-style.mjs rules/ → output-styles/ 생성. README 숫자도 써 넣는다
│ ├── build-readme-art.mjs README 그림 생성. 문구는 훅과 린터에서 그 자리에서 뽑는다
│ ├── import-corpus.mjs 조사 결과(JSON) → rules/*.md. 개발용, 일회성
│ ├── score-response.mjs 한국어 글을 채점한다. --fix 로 교정도 한다
│ ├── publish-docs.mjs README·설계 문서·기여 안내·보안 정책 → Pages·위키 스테이징. 상대 링크를 대상별 절대 URL로 고쳐 쓴다
│ ├── action-lint.mjs GitHub Action(action.yml)의 오프라인 검사. 변경 파일·커밋·PR 텍스트를 훅과 같은 규칙으로 검사해 JSON을 낸다
│ ├── build-review.mjs action-lint.mjs의 JSON을 PR 리뷰 페이로드(인라인 코멘트 + 본문)로 바꾼다. 이것도 오프라인이다
│ ├── triage-report.mjs 오탐·놓친 표현 이슈 본문을 현재 규칙으로 재현해 JSON과 이슈 코멘트를 낸다. 이것도 오프라인이다
│ ├── rule-impact.mjs PR의 rules/ 변경이 스타일 본문 예산과 린트 결과에 미치는 영향을 base→head로 계산한다. 이것도 오프라인이다
│ ├── hook-latency.mjs 훅 지연 시간을 base→head로 실제 스폰해 잰다(회귀 감시). 이것도 오프라인이다
│ ├── extract-links.mjs skills·rules·docs·README 등에서 출처 http(s) 링크를 모은다. 오프라인이다
│ ├── link-report.mjs extract-links.mjs 결과와 워크플로의 curl 결과를 합쳐 이슈 본문을 낸다. 이것도 오프라인이다
│ ├── pr-version.mjs PR의 버전·커밋 제목 형식을 확인하고(check), Dependabot PR의 버전을 대신 올린다(bump/needs-bump/is-stale). 이것도 오프라인이다
│ └── lib/
│ ├── markdown.mjs 마크다운 인라인 코드·펜스 블록. build-review.mjs·triage-report.mjs가 같이 쓴다
│ └── cli.mjs 인자 파싱·JSON/텍스트 출력·main() 실패 처리. action-lint.mjs·triage-report.mjs가 같이 쓴다
│
├── assets/ README 그림. 생성물이다. 라이트·다크 두 벌
├── corpus/ 처음 들여온 규칙 자료. 출처 추적용
├── evals/ claude plugin eval 세트
└── tests/ 스킬 예시 코드까지 검증한다
스킬마다 examples/ 에 동작하는 코드가 들어 있고 시험이 그것을 검증한다. 산문만 쓰면
클로드가 다시 짜면서 또 틀린다.
왜 출력 스타일인가
처음 설계는 SessionStart 훅이 규칙을 additionalContext로 주입하는 방식이었다. 조사 결과 플러그인이
출력 스타일을 번들로 내보낼 수 있고, force-for-plugin: true를 달면 사용자가 고르지 않아도 적용된다는
것을 확인했다. 스타일 본문은 대화 컨텍스트가 아니라 시스템 프롬프트에 들어가고 매 요청마다 다시
전송된다.
이것이 훅 주입보다 나은 이유는 세 가지다.
- 컨텍스트 압축으로 사라지지 않는다.
compactmatcher로 재주입할 필요가 없다. - 긴 세션에서 흐려지지 않는다. 상기용 재주입 훅이 필요 없다.
- 모델의 판단이 개입하지 않는다. 스킬처럼 “열어 볼지” 결정하는 단계가 없다.
이 발견으로 훅 셋 중 둘(inject-style, remind-style)과 한글 감지 코드, 세션 언어 상태 파일이
모두 사라졌다.
근거(클로드 코드 2.1.278 바이너리 문자열):
name Style name used in the Output style picker in `/config` and in settings.
description Shown in the Output style picker in `/config`.
keep-coding-instructions If true, the default coding instructions stay in the system prompt
alongside this style.
force-for-plugin
로더 메시지: Using forced plugin output style:, Multiple plugins have forced output styles: ... Using:,
Skipping plugin output-style .
keep-coding-instructions: true가 필수인 이유
기본값은 false다. false면 클로드 코드의 기본 소프트웨어 엔지니어링 지침이 시스템 프롬프트에서 빠진다.
그러면 말투는 좋아지고 작업 실력이 떨어진다. 정확히 피해야 할 결과다. 내장 스타일(Concise, Explanatory)도
모두 keepCodingInstructions: true로 정의되어 있다.
강제 적용의 대가
force-for-plugin: true는 사용자의 출력 스타일 선택을 덮어쓴다. 이 플러그인을 켜면 Concise나
Explanatory를 쓸 수 없다. “설치만 하면 켜진다”가 요구사항이므로 이 대가를 받아들인다. 다른 스타일을
쓰려면 플러그인을 임시로 끄면 된다는 안내를 README에 넣는다.
여러 플러그인이 동시에 강제하면 먼저 로드된 하나만 적용되고 경고가 출력된다.
규칙 자료 형식
rules/*.md는 모두 같은 표 구조를 쓴다. 사람이 읽는 문서와 기계가 읽는 규칙이 같은 파일이라
두 벌이 어긋날 수 없다.
| 원어 | 쓰지 말 것 | 쓸 것 | 이유 | 검사 | 순위 |
|---|---|---|---|---|---|
| thin contract | 얇은 계약 | 낮은 결합도 | 은유 직역, 한국어로 뜻이 안 통함 | 치환 | 핵심 |
| blast radius | 여파가 미치는 범위 | 영향 범위 | 풀어쓰기로 늘어짐 | 치환 | 핵심 |
| your | 당신의 | 이 / 생략 | 영어 your 직역 | 정규식 | 핵심 |
| we | 우리는 ~합니다 | 주어를 빼고 대상을 주어로 | 영어 인칭 주어 직역 | 프롬프트 | 핵심 |
원어— 해당 영어 표현. 문장 구조 규칙처럼 대응하는 원어가 없으면—.쓰지 말 것—~는 “앞뒤에 무엇이 붙든”을 뜻한다.순위—핵심/보통/참고. 스타일 본문 분량을 자르는 기준.
검사 열의 세 값이 세 가지 실제 능력에 대응한다. 이 대응이 설계의 핵심이다.
| 값 | 뜻 | 누가 처리하나 |
|---|---|---|
치환 |
문자열로 잡히고, 쓸 것으로 그대로 바꿔도 뜻이 상하지 않는다 | 린터가 자동 교정까지 한다 |
정규식 |
잡을 수는 있지만 문맥을 봐야 고칠 수 있다 | 린터가 경고만 한다 |
프롬프트 |
문자열로 잡을 수 없다 | 출력 스타일만이 막을 수 있다 |
정규식으로 못 잡는 규칙은 예방밖에 방법이 없으므로 스타일 본문에 우선 넣는다.
toPattern이 지원하는 것은 리터럴과 ~ 물결 와일드카드뿐이라, “임의의 로마자 동사에
하다 계열 어미가 곧바로 붙는다”처럼 낱말 자체가 아니라 형태를 보는 패턴은 표 한 줄로
못 적는다. push합니다, deploy한이 이 경우다(0.15.0의 표기 원칙 — 로마자는 명사 자리에만
쓰고, 정착한 음차 동사는 한글로 쓴다 — 를 헤드리스로 돌려 보니 실제로 나왔다). 이런 패턴은
hooks/lib/latin-hada.mjs가 별도 엔진으로 찾는다. rules/ 안에 두지 않는 이유는
parseTable이 6칸 표만 규칙으로 읽고 그 밖의 줄은 “형식이 깨진 행”으로 세기 때문이다 —
다른 형식의 표를 섞으면 corpus.test.mjs의 skipped === 0 검사가 깨진다. 자연스러운
활용형은 문맥마다 다를 수 있어(예: “업데이트합니다” 대 “업데이트해서”) 이 발견은 언제나
정규식으로 분류되어 경고만 한다.
lint()에는 이 검사가 규칙 객체 하나(LATIN_HADA_RULE)로 얹힌다. bad가 매치마다(동사마다)
달라 규칙표처럼 정적 정규식 하나로 셀 수 없으므로, 이 규칙은 toPattern(rule.bad) 대신
find(masked, normalized) 매처를 들고 있다 — lint()는 규칙에 find가 있으면 그 함수를
불러 이미 다 채운 발견 목록을 그대로 받아들인다. 규칙 자체가 자기 검사 방법을 들고 있으므로
lint()의 규칙 순회 루프는 “규칙표 정규식이냐, 그 밖의 매처냐”를 가르지 않는다.
LATIN_HADA_RULE은 overlapFree: true도 함께 든다 — lint()가 규칙끼리 겹치는 발견을
resolveOverlaps로 해소할 때, 이 표시가 있는 발견은 그 다툼에서 아예 빠져 규칙과 span이
겹쳐도 서로 밀어내지 않고 둘 다 보고한다(같은 표시를 물결표 있는 문장 패턴 규칙도 쓴다 —
isOverlapFree가 두 경우를 한 갈래로 본다). 처음에는 규칙과 한 findings 배열에 섞어 넣고
함께 겹침을 해소했는데, “build할때 확인합니다”에서 latin-hada가 잡은 “build할”이 더 길다는
이유로 겹치는 치환 규칙(“할때”→”할 때”)까지 밀어냈다 — applyFixes가 lint()의 결과를
그대로 후보로 쓰므로, 지적만 사라지는 게 아니라 원래 되던 자동 교정까지 함께 사라졌다(실측,
0.16.0). latin-hada는 절대 자동 교정하지 않으므로(check가 늘 정규식이다) 규칙과의 겹침은
표시상의 문제일 뿐이라 밀어낼 이유가 없다.
rules.mjs의 loadRules()는 규칙표(rules)와 별도로 builtins(LATIN_HADA_RULE을 담은
배열)를 함께 내보낸다. 규칙표 자체만 다루는 소비자(build-style.mjs, README 규칙 수,
corpus.test.mjs의 표 시험, import-corpus.mjs)는 rules만 그대로 쓰면 이 검사가 섞여
들어오지 않는다 — 스타일 본문에 latin-hada 예시를 실을 수도, README 규칙 수에 셀 수도
없으니 옳다. 실제로 검사하는 전체 집합이 필요한 훅 쪽은 hooks/lib/artifact.mjs의
loadToneRules()가 [...rules, ...builtins]로 이어 붙인다. rules.mjs가 latin-hada.mjs를
불러오는데 latin-hada.mjs도 검사 칸 상수(CHECK_REGEX 등)가 필요해 거꾸로 rules.mjs를
불러오면 순환 참조가 되므로, 그 상수들은 의존성이 없는 hooks/lib/checks.mjs로 옮기고
rules.mjs는 기존 이름을 그대로 다시 내보낸다(가져다 쓰던 곳을 고칠 필요가 없다).
동사 목록은 일부러 짧다. import·export·return·delete는 언어 예약어이고
fetch·load·render는 그 자체로 흔히 참조되는 전역 API·생명주기 메서드 이름이다.
“한 모듈이 상대에게서 import하는 심볼 수”처럼 실제로 관찰된 문장에서 import는
표기 위반이 아니라 import 구문 자체를 가리키는 말이었다 — 낱말 하나로는 “구문을
가리키는 용법”과 “번역해야 할 동사 용법”을 가를 수 없어 이 일곱은 뺐다(불변식 5).
check(체크박스의 “체크”와 확인의 뜻이 갈린다)와 pull(“풀하다”라는 정착 음차가
없다, 개발자는 “pull 받아서”처럼 로마자 명사로 남긴다)도 같은 이유로 뺐다. call·
apply·reset도 API 이름으로 쓰이지만 실제 대화에서는 일반 동사 용법이 압도적으로
많아 남겼다 — hooks/lib/latin-hada.mjs의 VERBS 주석에 판단 기준을 적어 뒀다.
왼쪽 경계도 낱말 하나만으로는 못 가르는 경우가 있다. “git push하면”·”npm test하면”·
“unit test하는”처럼 명령 이름이나 영어 합성어의 일부일 때는 실제로 관찰된 문장이라도
빼야 한다 — 로마자 낱말 뒤에 공백이 오고 그 뒤에 동사가 바로 이어지면 잡지 않는다(가변
길이 부정 후방탐색, V8이 지원한다). “재commit해야”처럼 한글이 로마자 동사에 공백 없이
바로 붙는 경우는 이 검사 자체가 알 필요가 없다 — findLatinVerbHada는 항상
maskProtected가 가린 문자열에서 도는데, segment.mjs가 “한글 음절 + 로마자 소문자”
모양을 타겟Id류 혼합 식별자로 보고 이미 통째로 가리기 때문이다.
이유 칸 맨 앞에 [표기] 나 [외래어] 를 붙이면 낱말 경계 검사의 예외를 명시적으로 켠다.
[표기]는 왼쪽 경계(앞에 다른 낱말이 붙어 있어도 잡는지)를 빼고, [외래어]는 왼쪽·오른쪽을
모두 뺀다(외래어는 표기 종류에 포함된다). “해야합니다”(→”해야 합니다”)는 앞에 어떤 동사가
오든 붙여 쓴 것 자체가 틀리고, “데이타”(→”데이터”)는 “메타데이타”처럼 다른 외래어에 붙어
있어도 표기가 틀린 건 마찬가지라 이 예외가 필요하다. 표지는 hooks/lib/rules.mjs의
parseTable이 떼어 rule.kind로 넘기고, hooks/lib/lint.mjs는 그 필드만 본다 — 예전에는
이유 문장에 “표기”·”외래어” 같은 낱말이 있는지를 정규식으로 봤는데, 이유 문장을 사람이 읽기
좋게 다듬다가 낱말이 우연히 빠지거나 들어가면 판정이 조용히 바뀌었다. 0.13.19에서 실제로
그랬다 — 레지스터리는 이유에서 “외래어”가 빠져 분류를 잃었고, “50 %”는 반대로 새 이유의
“띄어도”에 걸려 없던 분류를 얻었다(둘 다 이번 단위에서 원래 분류로 되돌렸다). 판정과 설명을
분리해 이유는 자유롭게 고쳐도 안전하게 했다.
검사 값은 손으로 정하지 않는다. scripts/import-corpus.mjs가 계산한다. 규칙이 수백 개면
사람이 판정하다가 반드시 틀린다.
판정 순서는 이렇다.
- 자료에
lintable: false로 표시됐거나 금칙어가 한 글자면프롬프트. - 금칙어가 40자를 넘으면
프롬프트. 그 문장 하나에서만 걸리므로 린터에 쓸모가 없고, 스타일 본문의 대조 예시로만 값어치가 있다. - 금칙어가 24자를 넘으면
정규식. 잡을 수는 있지만 문장을 통째로 갈아 끼우는 것은 위험하다. - 대체 표현에
/나 “생략” 같은 신호가 있으면정규식. 드롭인이 아니다. - 고친 결과가 같은 규칙에 또 걸리면
정규식. 순환한다. applyFixes로 실제 치환을 해 본다. 되면치환, 안 되면정규식.
2번과 3번의 길이 기준을 쓰는 이유가 있다. 처음에는 “종결어미로 끝나면 예시 문장”이라는 기준을 썼는데 틀렸다. 한국어에서는 구절 단위 패턴도 자연스럽게 종결어미로 끝난다. “픽스했습니다”는 예시 문장이 아니라 쓸 만한 패턴이다.
규칙 자료의 출처
자료는 조사로 모았다. 여섯 분류로 초안을 만들고, 각 초안을 반대편에서 전수 검증하고, 전체를 교차 감사했다. 검증 단계의 최우선 항목은 “대체 표현이 풀어쓰기로 늘어져 어색하지 않은가”였다.
| 단계 | 결과 |
|---|---|
| 초안 | 536개 |
| 전수 검증 | 삭제 6개, 교정 34개. 그중 풀어쓰기 편향으로 고친 것 24개 |
| 교차 감사 | 모순 11건, 위험 항목 12건, 남은 풀어쓰기 편향 17건, 누락 8건 지적 |
| 감사 판정 적용 | 삭제 16개, 교정 18개, 누락 보강 10개 |
| 씨앗 규칙 병합 | 짧고 기계적인 패턴 63개. 조사 자료의 긴 예시 문장을 보완한다 |
| 최종 | 564개 (치환 391, 정규식 71, 프롬프트 102) |
| 낱말 단위 보강 (09-23) | 판교어 29개를 낱말로 추가. 예문 한 줄로 적은 규칙이 부사 하나에 빠져나가던 문제. 모델 문장 20개 적중 3 → 13, 저장소 한글 6만 자에서 오탐 0 |
감사가 잡아낸 것 가운데 특히 값어치가 있던 지적이 셋이다.
- 반대 방향 과교정이 누락되어 있었다. 규칙집이 한자어 밀도를 계속 올리라고 밀기 때문에
모델이 공문서체(
본,상기,~에 대하여,~함)로 튈 위험이 있는데, 그 상한선을 정하는 항목이 하나도 없었다. 보강했다. - 기존 한자어를 금지하고 음차를 강요하는 항목이 섞여 있었다.
배압→백프레셔,상용구→보일러플레이트가 그 예다. 용어 처리 우선순위 1번 위반이고, 이 방향으로 규칙이 늘면캐시→임시 저장소와 대칭인 반대편 사고가 난다. 삭제했다. - 사물 존대가 빠져 있었다.
빌드가 완료되셨습니다유형은 한국어 출력에서 가장 흔한 문법 오류인데 항목이 없었다. 보강했다.
교차 감사가 지적한 모순 11건 중 둘은 감사 판정을 적용한 뒤에도 남아 있었고,
tests/corpus.test.mjs의 “쓸 것이 다른 규칙에 걸리지 않는다” 시험이 그 둘을 잡았다.
테일 레이턴시가 레이턴시 금지 규칙에 걸리는 문제와 플레이키/플래키 표기 분열이었다.
자료가 수백 개로 자라면 이런 순환은 사람 눈으로 못 잡는다.
출력 스타일 본문 설계
scripts/build-style.mjs가 rules/를 읽어 다음 순서로 조립한다. 순서에 의도가 있다.
- 적용 범위 — 가장 먼저 온다. 품질을 지키는 장치이기 때문이다.
- 문체 — 규칙 설명 대신 견본 문장 한 쌍.
- 용어 처리 우선순위 — 위의 다섯 줄.
- 대조 예시 표 —
핵심순위 항목의쓰지 말 것 → 쓸 것쌍. 설명문이 아니라 표.
적용 범위 절에는 다음 문장이 반드시 들어간다.
이 규칙은 사용자에게 보여 주는 문장의 표현에만 적용됩니다. 판단, 분석, 코드 품질, 도구 사용에는 영향을 주지 않습니다.
적용하지 않는 것: 코드, 식별자, 명령어, 파일 경로, 제품·라이브러리 이름, 로그와 오류 메시지 원문, 파일에 쓰는 내용, 코드 주석.
이 규칙 때문에 설명을 줄이거나 분석을 생략하지 마십시오. 담을 내용은 그대로 두고 문장만 다듬는 것입니다.
마지막 문장이 필요한 이유는 “군더더기 없이”를 모델이 “짧게”로 읽을 위험이 있기 때문이다. 표현 규칙이 정보량을 깎는 쪽으로 새는 것을 막아야 한다.
build-style.mjs는 출력 길이 상한을 강제한다. 자료가 300개로 자라도 스타일 본문은 상한까지만 담고
보통·참고 순위부터 잘라낸다. 시스템 프롬프트는 프롬프트 캐시에 올라가므로 비용 부담은 낮지만,
모델의 주의 예산은 여전히 유한하다.
린터 (v0.2)
hooks/guard-artifact.mjs가 커밋 메시지와 한국어 문서 파일을 검사한다. 대상은 .md, .txt
같은 문서 확장자뿐이다. 소스 파일을 검사하면 코드 주석까지 건드리게 되고 그것은 적용 범위 밖이다.
| 환경변수 | 기본값 | 훅 | 동작 |
|---|---|---|---|
| (없음) | — | PostToolUse | 위반을 additionalContext로 알린다. 클로드가 직접 고친다 |
KIMCHI_AUTOFIX=1 |
꺼짐 | PreToolUse | updatedInput으로 치환 규칙을 자동 교정한다. 권한 검사는 그대로 거친다 |
KIMCHI_BLOCK=1 |
꺼짐 | PreToolUse | permissionDecision: deny로 막는다 |
KIMCHI_DISABLE=1 |
꺼짐 | — | 아무것도 하지 않는다 |
기본 동작은 차단도 자동 교정도 아니라 경고다. 커밋을 문체 때문에 막으면 작업 흐름이 끊기고 재시도 턴을 낭비한다. 문체가 작업을 방해하면 그것은 실패다. 자동 교정도 기본으로 끈다. 도구 입력을 조용히 고쳐 쓰는 것은 놀라움이 크고, 잘못된 규칙 하나가 산출물을 망칠 수 있다.
updatedInput은 PreToolUse 훅만 쓸 수 있다. 클로드 코드 2.1.278 바이너리의 훅 출력 명세:
- `hookSpecificOutput` - Event-specific output (must include `hookEventName`):
- `additionalContext` - Text injected into model context
- `permissionDecision` - "allow", "deny", or "ask" (PreToolUse only)
- `updatedInput` - Modified tool input (PreToolUse only)
자동 교정의 조사 문제
한국어에는 치환 고유의 함정이 있다. “얇은 계약을 유지”에서 “얇은 계약”을 “낮은 결합도”로 바꾸면 “낮은 결합도을 유지”가 된다. 앞말의 받침이 사라졌기 때문이다.
그래서 hooks/lib/lint.mjs의 applyFixes는 바꾸기 전에 마지막 음절의 받침 유무가 같은지
확인하고, 다르면서 뒤에 조사가 붙어 있으면 건너뛴다. 한글 음절은 (코드 - 0xAC00) % 28이
0이면 받침이 없으므로 이 판정이 몇 줄로 끝난다. 건너뛴 항목은 경고로 알린다.
자동 교정이 permissionDecision을 싣지 않는 이유
updatedInput을 돌려줄 때 permissionDecision: "allow"를 함께 실었더니 클로드 코드
2.1.282에서 사용자의 권한 프롬프트가 통째로 사라졌다(디버그 로그: “Hook approved tool use for
Bash, bypassing permission prompt”). git commit -m "타겟 추가" && touch x가 승인 없이
그대로 실행됐다 — 값이 "allow"이기만 해도 사전 승인으로 취급되기 때문이다. "ask"도 대안이
아니다. 이미 승인된 도구까지 강제로 다시 묻게 만들어 자동 교정과 무관한 프롬프트를 늘린다.
지금은 updatedInput만 돌려주고 permissionDecision은 아예 넣지 않는다. 그러면 클로드 코드가
고쳐 쓴 입력을 정상 권한 파이프라인에 그대로 넘기고, 원래 그 도구 호출에 필요했던 승인 절차를
그대로 거친다. 자동 교정이 결정할 일은 “무엇을 바꿀지”이지 “실행을 허가할지”가 아니다.
systemMessage는 사용자에게만 보이고 모델에게는 전달되지 않는다는 것도 같은 조사에서 확인했다.
모델이 무엇이 바뀌었는지 모르면 실제로 실행·저장된 내용을 잘못 보고하거나, 되돌리려는 시도로
자동 교정을 무효화할 수 있다. 그래서 hookSpecificOutput.additionalContext에도 같은 교정
목록을 실어 모델에게 직접 알린다. KIMCHI_PII=warn의 개인정보 경고도 같은 이유로
additionalContext를 함께 쓴다 — 경고는 “막지 않는다”는 뜻이지 “모델에게 숨긴다”는 뜻이
아니다.
오탐 방어
hooks/lib/segment.mjs가 검사 대상에서 코드 블록, 인라인 코드, URL, 파일 경로, 환경변수,
명령행 옵션을 제외한다. contract라는 변수명을 “계약”으로 고치라고 하면 재앙이므로 이 부품은
단독으로 테스트한다.
가려낸 구간을 잘라내지 않고 같은 길이의 센티넬로 덮는다. 길이를 보존하면 검사에서 찾은 위치가 원문 위치와 1:1로 맞는다.
자바스크립트 정규식의 \w와 \b가 ASCII만 단어 문자로 보고 한글은 비단어 문자라, 경로와
식별자를 잡는 패턴이 한글 본문을 건드리지 않는다. 경계가 공짜로 생긴다.
한 글자 금칙어(실)에는 문자열 검사를 걸지 않는다. 오탐이 너무 많다. 그런 규칙은 스타일 본문으로만
가르친다.
경로·URL·환경변수만으로는 안 막히는 자리가 저장소 전체 903건 말뭉치 실측에서 더 나왔다. 아래 항목을 추가했다. 이런 추가는 항상 양날이다 — 지나치게 넓게 막으면 이번에는 진짜 위반을 놓친다. 각 항목에 그 균형을 어디서 잡았는지, 무엇을 실측했는지 함께 적는다.
마크다운 링크·이미지 대상. [안내](url)의 (url) 부분은 경로이지 산문이 아니다. 대괄호
안(링크 텍스트·대체 텍스트)은 여전히 산문이라 계속 검사한다 — 대상만 가리면 “타겟 안내”라는
링크 텍스트는 그대로 지적할 수 있다. 대상을 무는 문자 집합에서 (와 ]를 뺀다 — )만 빼면
닫는 괄호가 없는 "](".repeat(40000) 같은 입력에서 ]( 마다 문자열 끝까지 밀었다 되돌리는
이차 비용이 났다(실측 5.7초). 길이도 2000자로 한 번 더 막는다. 참조 정의 줄([label]: url
"title")은 통째로 코드라 줄 전체를 덮는다.
참조식 링크의 라벨. [글 내용][라벨]·[라벨][](축약형)·[라벨](단축형)의 라벨은 문서
어딘가의 정의 줄과 글자 그대로 맞아야 링크가 산다. 라벨이 규칙에 걸려 자동 교정되면(예:
타겟→타깃) 정의 줄은 안 바뀐 채 라벨만 바뀌어 [설정 안내][타깃]과 [타겟]:처럼 죽은 링크가
된다. 문서 안의 정의 라벨을 먼저 모으고(대소문자·공백 정규화), 그 라벨과 글자가 맞는 자리만
가린다. 링크 텍스트([글 내용])는 라벨이 아니므로 계속 검사한다.
정의는 울타리·인라인 코드를 가린 사본에서 찾는다. `[타겟]` 처럼 코드로 인용했거나 펜스
안에 예시로 적은 [타겟]: x를 진짜 정의로 세면, 그 라벨을 쓰는 모든 자리가 부당하게 검사에서
빠진다.
Edit/MultiEdit은 new_string 조각만 본다 — 조각 밖에 있는 정의 줄은 안 보인다. 조각만 보고
판정하면 파일에 [타겟]: 정의가 있어도 그걸 모른 채 조각 안의 라벨을 고쳐 죽은 링크를 만든다.
그래서 hooks/lib/artifact.mjs가 크기 상한(MAX_CONTEXT_FILE_BYTES) 안에서 파일 전체를 읽어
정의 라벨을 모으고, maskProtected의 extraDefs 인자로 얹는다. 파일을 못 읽으면(새 파일,
크기 초과) extraDefs에 null을 넘겨 “정의를 알 수 없음”을 표시한다 — 이때는 두 괄호짜리
참조(][라벨]·[라벨][])를 정의 확인 없이 보수적으로 전부 가린다. 자동 교정이 죽은 링크를
만드는 쪽이 지적 하나를 놓치는 쪽보다 나쁘다. 단축형([라벨])까지 넓히지는 않는다 — 혼자서도
흔한 대괄호 표기라 정의 없이 넓히면 오탐이 너무 커진다.
라벨을 무는 문자 집합 [^\]\n]*도 위험하다. 정의가 하나라도 있으면 이 정규식들이 문서 전체를
훑는데, 닫히지 않은 [가 잔뜩 있는 입력("[".repeat(100000))에서 시작마다 문자열 끝까지
밀었다 되돌려 36초가 걸렸다(실측). [^[\]\n]{0,999}로 대괄호 자체를 문자 집합에서 빼고
길이도 999자로 막는다.
밑줄 혼합 식별자의 방향. content_컨텐츠처럼 밑줄과 한글이 섞인 식별자를 덮던 패턴이
“밑줄 뒤에 한글이 와야 한다”는 고정 순서를 요구했다. 타겟_id처럼 한글이 앞에 오면 놓쳤다.
lookahead로 “밑줄과 한글이 어딘가 함께 있다”만 확인하도록 방향을 없앴다.
한글+로마자 붙임 식별자(타겟Id). 밑줄이 없는 캐멀케이스 혼합은 \p{L}*로 양쪽을 넓게
무는 정규식으로 잡으면 137KB 문서에서 마스킹이 0.27ms에서 13.5ms로 50배 느려진다(실측, 불변식
10). 대신 한글→로마자 경계만 찾고 [\p{L}\p{N}_] 문자 집합으로 양쪽을 선형으로 넓힌다. 시드를
찾을 때마다 넓힌 뒤 lastIndex를 시작 위치에 그대로 두면, “가a”를 5000번 이어 붙인 입력에서
매 시드가 이미 넓혀 둔 구간을 처음부터 다시 훑어 0.5초가 8.8초로 늘어난다 — 넓힌 뒤에는
lastIndex를 그 끝으로 밀어 한 붙임 구간을 한 번만 넓히게 한다. 이것과는 별개로, 시드 자체도
“한글+소문자” 또는 “한글+대문자+소문자”(캐멀케이스 시작)로 좁힌다 — 컨텐츠UI를·타겟API
호출·리팩토링PR을처럼 한글 뒤에 대문자만 이어지는 두문자어는 식별자가 아니라 산문에 섞인
영문 약어라 계속 검사해야 한다. 반대 방향(API가처럼 로마자 뒤에 한글)도 다루지 않는다.
울타리 없는 셸 프롬프트·로그 줄. ` ```bash 로 감싸지 않은 $ npm test나 FATAL: connection
refused 같은 한 줄은 도구 출력이지 산문이 아니다. 줄 시작(공백·인용부호 > ·목록 표시 허용)
기준으로 그 줄만 덮고, 바로 다음 줄의 설명 문장은 그대로 검사한다. 다만 Error:·Warning:·
$ 가 로그 표시가 아니라 한국어 문장의 첫 낱말일 수도 있다 — "Error: 컨텐츠를 불러오지
못하면 다시 시도하세요"는 안내문이지 로그가 아니다. 콜론·공백 뒤 글자 가운데 한글 비율이
30% 이상이면 산문으로 보고 덮지 않고, $ ` 뒤가 숫자로 시작하면(“$ 5를 내면”) 프롬프트가
아니라 값을 나타내는 문장으로 본다.
한 줄짜리 $(...) 명령 치환. 중첩된 괄호까지 정확히 가르려면 bash-commit.mjs 수준의 셸
파서가 필요하다. 문서 산문 안에서는 중첩이 드물어 [^()\n]*로 충분히 막는다 — 이 패턴은
처음부터 (를 문자 집합에서 뺐으므로 ](...)와 같은 이차 비용은 나지 않는다.
울타리 닫힘·ATX 제목 뒤의 들여쓰기. ` ``` ` 다음 줄이나 ## 제목 다음 줄에 4칸 들여쓰기가
오면 마크다운은 그것을 새 코드 블록으로 읽는다. 목록 항목 뒤의 들여쓰기(목록 연속)와는 다른
경우라, 울타리·제목 줄을 만나면 “직전 줄이 목록 항목인가” 판정에 쓰는 lastNonBlankLine을
비워 목록 연속으로 오판하지 않게 한다.
YAML 프런트매터. ---로 감싼 앞머리는 빌드 도구가 읽는 설정이지 산문이 아니다. 다만
title·description·summary·excerpt·subtitle 값은 사람이 읽는 문구라 계속 검사한다.
여는 --- 바로 다음 줄이 빈 줄이 아닌 key: 모양(YAML 키)이어야만 프런트매터로 인정한다 —
그렇지 않으면 ---로 감싼 보통 문단이다. 굵게(*)·제목(#)으로 시작하는 줄은 키로 보지
않는다 — “참고: 컨텐츠를 옮겼습니다.”는 콜론이 있어도 강조한 산문이지 YAML 키가 아니다.
목록(- )·주석(#) 줄은 키가 한 번이라도 나온 뒤에만 값으로 인정한다 — 키 없이 곧장 나오면
“## 변경 사항” 뒤에 목록으로 적은 변경 이력처럼 그냥 마크다운 제목·목록이다.
블록 안에 영문 키가 하나도 없어도 프런트매터로 보지 않는다. 빌드 도구가 읽는 키는 실무에서 거의 항상 영문/숫자다. “참고: 컨텐츠 문서를 옮겼습니다”처럼 키 자리까지 온통 한글이면 YAML이 아니라 “키처럼 보이는 한국어 문장”일 수 있다.
로/으로의 오른쪽 경계. “로”는 조사이기도 하지만 “로그”·”로직”·”로더”·”로컬”·”로드”의
시작이기도 하다. “판박이 코드”(→”보일러플레이트”) 규칙이 “판박이 코드로그를”까지 잡아
“보일러플레이트로그를”로 깨졌다. 뒤가 한글이 아니거나(문장 끝) 흔히 그 뒤에 오는 조사
연속(는/도/만/서/써/부터/의/까지/라도/라면/나/요/선/은/야/밖에/조차/마저/든/인/다가)일 때만
진짜 조사로 인정하고, 아니면 이 자리에서 판정을 끝낸다 — 일반화한 활용 연쇄 판정은 하지
않는다. “하”+”니다”처럼 다른 어간·어미 조합까지 건드리면 손해가 더 크다.
heredoc 종료 표시의 따옴표. git commit -F - <<EOF나 $(cat <<EOF ...)처럼 heredoc으로
커밋 메시지를 받을 때, 종료 표시를 따옴표로 감쌌는지가 본문 안 $(...)·${...}·백틱의
자동 교정 가능 여부를 가른다. 감쌌으면(<<'EOF') 셸이 그 글자를 확장하지 않으므로 고쳐도
안전하지만, 감싸지 않았으면(<<EOF) -m 값과 같은 정책으로 위험한 문자가 없을 때만
교정한다 — 안 그러면 $(grep 리팩토링 a.txt)의 검색어까지 바뀐다.
.txt 이름 목록 배제. .txt 확장자 자체는 남긴다 — 일반 텍스트로 적은 설계 메모·안내문에도
검사해야 할 산문이 있다. 다만 CMakeLists.txt·requirements*.txt·*-requirements.txt·
constraints*.txt·robots.txt처럼 이름 자체가 이미 정해진 빌드 도구용 형식인 파일은 말투
검사에서만 뺀다. 개인정보 검사는 pii.mjs가 파일 종류와 무관하게 모든 파일에 그대로 돈다.
품질 보호 장치
말씀된 “퀄리티는 유지” 요구를 지키는 장치를 한자리에 모았다.
| 장치 | 막는 실패 |
|---|---|
keep-coding-instructions: true |
코딩 지침이 시스템 프롬프트에서 빠지는 것 |
| 스타일 본문 길이 상한 | 주의 예산을 먹어 작업 정확도가 떨어지는 것 |
| 적용 배제 목록 명시 | 코드·식별자·로그를 한국어로 건드리는 것 |
| “정보량 줄이지 말 것” 문장 | 문체 규칙이 설명 부실로 번역되는 것 |
| 경고 기본, 차단 선택 | 문체 때문에 작업 흐름이 끊기는 것 |
| 훅 실패 시 종료 코드 0, 빈 출력 | 훅이 깨져서 세션이 막히는 것 |
KIMCHI_DISABLE=1 킬 스위치 |
문제가 생겼을 때 빠져나갈 길이 없는 것 |
segment.mjs 구간 제외 |
코드 식별자 오탐 |
훅 실패 처리를 특히 강조한다. 이 플러그인은 말투를 다듬는 부가 기능이고 작업에 필요한 부품이 아니다. 어떤 오류가 나도 조용히 아무 일도 하지 않고 넘어가야 한다.
검증
1. 단위 테스트 (node:test, 의존성 없음)
| 대상 | 확인할 것 |
|---|---|
segment.mjs |
코드 블록·인라인 코드·URL·경로 제외. contract가 변수명일 때 안 걸리는지 |
rules.mjs |
마크다운 표 파싱, 깨진 행을 조용히 버리는지 |
lint.mjs |
규칙마다 양성 사례와 음성 사례 한 쌍씩 |
applyFixes |
받침 판정, 조사 안전장치, 뒤에서부터 고쳐 위치가 어긋나지 않는지 |
build-style.mjs |
순위 컷, 출력 길이 상한, 커밋된 생성물이 최신인지 |
guard-artifact.mjs |
heredoc 커밋 메시지 추출, 문서 확장자 필터, 세 환경변수, 망가진 입력 |
build-style.mjs의 최신 여부 확인이 중요하다. rules/를 고치고 스타일 파일을 다시 생성하지 않으면
자료와 제품이 어긋난다. npm test가 --check를 먼저 돌려 그것을 막는다.
2. 규칙표 정합성 (tests/corpus.test.mjs)
규칙이 수백 개로 자라면 사람 눈으로 못 잡는 실패가 생긴다. 코드로 막는다.
| 시험 | 잡으려는 실패 |
|---|---|
| 쓸 것이 다른 규칙에 걸리지 않는다 | A가 권하는 표현이 B의 금칙어인 순환 |
| 치환 규칙은 자기 자신을 다시 잡지 않는다 | 고친 결과를 무한히 다시 지적하는 것 |
| 쓰지 말 것이 중복되지 않는다 | 같은 금칙어에 다른 대체 표현이 붙는 것 |
| 깨끗한 한국어 문장에 아무 규칙도 걸리지 않는다 | 규칙이 너무 공격적이어서 생기는 오탐 |
| 치환 규칙은 실제로 치환할 수 있다 | 치환으로 표시했는데 동작하지 않는 것 |
| 한 글자 금칙어에 문자열 검사를 걸지 않는다 | 오탐 폭발 |
네 번째 시험이 규칙 추가의 브레이크 역할을 한다. 깨끗한 예시 문장 여덟 개 중 하나라도 걸리기 시작하면 새로 넣은 규칙이 너무 공격적이라는 신호다.
3. 출력 스타일 적재 확인 (tests/style-file.test.mjs)
가장 나쁜 실패를 막는 시험이다. 프런트매터에 모르는 키가 있으면 클로드 코드가
Skipping plugin output-style 하고 조용히 건너뛴다. 그러면 플러그인은 설치돼 있는데 아무 일도
하지 않고 아무도 알아채지 못한다.
허용되는 키는 네 개뿐이다: name, description, keep-coding-instructions, force-for-plugin.
시험이 그 밖의 키를 거부하고, keep-coding-instructions: true와 force-for-plugin: true가
켜져 있는지, 그리고 편향을 거스르는 문장들이 본문에 남아 있는지 확인한다.
4. 골든 파일
output-styles/natural-korean.md를 커밋한다. 규칙 한 줄을 고치면 생성물 diff가 리뷰에 드러난다.
이 플러그인은 프롬프트가 제품이므로 프롬프트 변경이 코드 변경처럼 검토되어야 한다.
5. 말투와 품질 평가 (evals/)
claude plugin eval .로 돌린다. --ablation이 기본으로 with-without이라 플러그인을 켠 팔과
끈 팔을 모두 돌려 점수 차이를 보고한다. 우리가 보고 싶은 것이 정확히 그 차이다.
| 사례 | 겨냥하는 것 | 기대 |
|---|---|---|
tone-paraphrase |
풀어쓰기 편향. 영향 범위/멱등/경쟁 조건/결합도를 쓰는지 | 켬 > 기준선 |
tone-metaphor |
은유 직역. “얇은 계약” 류 | 켬 > 기준선 |
tone-pangyo |
한영 혼용 | 켬 > 기준선 |
tone-translationese |
번역체 문장 구조 | 켬 > 기준선 |
tone-notation |
외래어 표기 | 켬 > 기준선 |
english-passthrough |
영어로 물으면 영어로 답하는지 | 켬 = 기준선 |
quality-preserved |
forEach + async 버그를 정확히 짚는지 |
켬 = 기준선 |
마지막 두 사례가 첫 다섯 개만큼 중요하다. 말투가 좋아지고 실력이 떨어지면 실패다.
quality-preserved의 점수가 기준선보다 낮으면 스타일 본문이 작업을 방해하고 있다는 뜻이고,
그때는 MAX_CHARS를 줄이거나 순위를 다시 매겨 본문을 줄여야 한다.
채점기는 regex를 먼저 쓴다. 무료이고 판정이 흔들리지 않는다. 목록으로 잡을 수 없는 것(문장 구조,
기술적 정확성)만 llm 채점기에 맡긴다.
6. 손으로 채점하기 (scripts/score-response.mjs)
자격 증명 없이 말투를 재는 도구다. 답변을 표준 입력으로 넣으면 위반 목록과 한글 1000자당 밀도를
알려 준다. --fix를 주면 치환 규칙을 적용한 결과를 내보낸다.
cat 답변.md | node scripts/score-response.mjs
node scripts/score-response.mjs --json 답변.md # CI 채점용
두 번째 층 — 한국 개발 현실 지식 (v0.3)
이름이 약속한 것과 실물의 간극을 메운다. kimchi 는 “한국어”가 아니라 “한국” 을
가리키는데, v0.2 까지는 한국어 말투만 다루었다.
스킬에 대한 판단이 뒤집힌 이유
말투 규칙에서는 스킬을 거부했다. 매 문장에 적용되어야 하니 모델의 판단에 맡길 수 없었다. 도메인 지식은 반대다. 인코딩 지식은 인코딩을 건드릴 때만 필요하다. 필요할 때만 열리는 것이 오히려 맞다.
그래서 한 저장소 안에 정반대 원칙으로 동작하는 두 층이 생겼다.
| 층 | 전달 방식 | 근거 |
|---|---|---|
| 말투 | 출력 스타일(시스템 프롬프트) | 매 문장에 적용되어야 한다 |
| 지식 | 스킬(필요할 때 열림) | 해당 영역을 건드릴 때만 필요하다 |
이 구분을 흐리면 출력 스타일의 6000자 상한이 즉시 터진다. 실측: 스킬 네 개를 더해도 항상 켜져 있는 비용은 설명 네 줄, 약 331토큰이다.
산문이 아니라 동작하는 코드를 담는다
설명만 쓰면 클로드가 다시 짜면서 또 틀린다. 검증 가능한 알고리즘은 실제 파일로 두고
tests/examples.test.mjs 가 검증한다. 시험이 없으면 스킬은 그냥 주장이다.
설명의 절반은 말리는 일이다
지식 층에서 가장 값어치 있는 내용은 “이렇게 하라”가 아니라 “하지 마라”였다.
- 주민등록번호는 2020년 10월부터 뒷자리가 임의 발급이라 체크섬 검증이 통하지 않는다.
검증하는 코드는 정상 번호를 거부한다. 그래서
resident-number.mjs에 검증 함수를 두지 않았고, 검증으로 읽히는 이름이 생기지 않도록 시험으로 막았다 - 음력 공휴일은 직접 계산하면 반드시 틀린다. 올바른 답은 공공데이터포털에서 받는 것이다
- 한글 정렬을 자모 분해로 직접 하지 않는다.
localeCompare("ko")가 이미 옳다
“이렇게 하라”만 있으면 모델이 틀린 방향으로 부지런해진다. 그래서 모든 스킬에
자주 틀리는 것 절을 두고, 그 절이 있는지 시험한다.
스킬이 조용히 사라지는 실패
출력 스타일과 같은 종류의 실패가 있다. 프런트매터가 첫 줄부터 시작하지 않으면 클로드 코드가
스킬을 건너뛴다. 실제로 겪었다 — 검사 예외 표시를 프런트매터 앞에 넣어 네 스킬이 모두
깨졌다. tests/skills.test.mjs 가 프런트매터 위치, 이름 일치, 설명 품질, 참조하는 예시
파일의 존재를 확인한다.
훅을 하나로 합친 이유 (v0.7)
검사가 둘이다. 성격이 정반대라 판정은 따로 두지만 프로세스는 하나로 합쳤다.
| 개인정보 | 말투 | |
|---|---|---|
| 대상 | 모든 파일 | 한국어 문서와 커밋 메시지 |
| 기본값 | 차단 | 경고 |
| 근거 | 밀어 버린 번호는 되돌릴 수 없다 | 문체가 작업을 막으면 실패다 |
따로 등록하면 도구 호출마다 Node 프로세스가 하나 더 뜬다. 실측 38ms 이고, 기본 설정에서는 말투 훅이 PreToolUse 에서 바로 빠져나오므로 그 38ms 가 순수 낭비다. 합친 뒤 도구 호출당 훅 비용이 110ms 에서 80ms 로 줄었다.
합치면서 결정 우선순위도 명시됐다. 훅 둘이 각자 permissionDecision 을 돌려줄 때 무엇이
이기는지는 클로드 코드 명세에 없다. 지금은 차단이 이긴다. 주민등록번호가 들어 있으면
말투는 따질 일이 아니다. 막지 않는 경고는 말투 결과에 얹어 함께 내보낸다. 훅은 한 번만
답할 수 있기 때문이다.
말투 규칙에 두 번째 경로를 낸 이유 (v0.9)
출력 스타일 본문에는 분량 상한 때문에 규칙 50개 남짓만 들어간다. 나머지 515개 가운데
치환·정규식은 린터가 잡지만 프롬프트 규칙은 아무 일도 하지 않았다.
그래서 natural-korean-writing 스킬을 만들어 규칙표 전체를 가리키게 했다.
이것이 “말투는 스킬로 하면 안 된다”는 앞의 판단과 어긋나지 않는다. 그 판단은 말투를 적용하는 일에 대한 것이었다. 매 문장에 적용되어야 하니 모델 판단에 맡길 수 없다. 반면 “이 문서 말투 좀 다듬어줘” 같은 검토 요청은 정반대다. 그때는 전체 표를 펼쳐 보는 것이 맞고, 평소 대화에서는 열리지 않아야 한다.
중복도 생기지 않는다. 스킬이 rules/*.md 를 가리키기만 하므로 같은 파일이 스타일
생성기의 원본이면서 스킬의 참고 자료다.
이 과정에서 스킬 시험의 전제가 좁았던 것도 드러났다. 모든 스킬이 examples/*.mjs 를
가리켜야 한다고 단정했는데, 참고용 스킬은 rules/ 와 scripts/ 를 가리킨다. 진짜
불변식은 “가리키는 파일이 실제로 있는가” 이므로 그렇게 고쳤다.
지식 층을 채운 방법 (v0.10)
스킬을 만들 때 내용을 짜냈다. 그 목록이 실사용에 비해 얼마나 비어 있는지는 몰랐다. 스킬에 실전 질문을 던지고, 모델이 꺼내는 지식 가운데 스킬에 없는 것을 흡수하는 방법으로 채웠다.
| 던진 질문 | 모델이 꺼낸, 스킬에 없던 것 |
|---|---|
| 이커머스 주문·배송 | 오픈마켓 안심번호(0504). 스킬이 0505 만 받았다 |
| 관공서 CSV 배치 | 엑셀 수식 주입, 전각 숫자, 앞자리 0 소실 |
| 쿠폰 만료와 월 정산 | 근로자의 날은 은행만 휴무, 반쯤 열린 구간, 배치 시각의 시간대 |
| 본인인증과 관리자 화면 | 접속기록 보관 의무, 마스킹은 서버에서, 조합 단위 판단 |
여덟 가지 모두 조용히 실패하는 종류다. 예외를 던지는 결함은 금방 발견되지만 이런 것들은 오래 남는다. 지식 층이 값어치를 내는 자리가 정확히 여기다.
이 방법의 요점은 모델이 아는 것을 스킬로 고정하는 것이다. 모델이 운 좋게 꺼내는 것에 의존하면 다음 사용자는 못 받는다. 스킬에 넣으면 확실히 받는다.
절제도 필요하다. 접속기록 보관 기간은 처리 규모와 정보 종류에 따라 달라지므로 숫자를 박지 않고 고시를 확인하라고 적었다. 음력 공휴일을 받아 쓰라고 한 것과 같은 판단이다. 변하는 값을 스킬에 넣으면 낡는다.
실물 확인이 코드 읽기보다 잘 잡는다
이번 작업에서 찾은 결함은 대부분 실물 확인에서 나왔다. 코드를 읽어서는 하나도 나오지 않았을 것이다.
| 확인 방법 | 찾은 것 |
|---|---|
| 조사 엔진을 저장소 문서에 돌림 | ㄹ 받침 예외 누락. 1로 를 1으로 로 고치려 했다 |
| 흔한 기술 낱말 29개에 돌림 | 24개를 판정하지 못했다. 목록이 작으면 기능이 없는 것과 같다 |
| 글 검토 스킬을 실제로 씀 | 경로가 사용자 세션에서 맞지 않았다. 개발 환경에서만 우연히 맞았다 |
| 지식 스킬에 실전 질문을 던짐 | 위 표의 여덟 가지 |
그리고 손으로 찾은 것은 시험으로 옮겼다. 같은 것을 두 번 손으로 찾지 않는다.
tests/dogfood.test.mjs 가 저장소 문서를 검사하고, tests/improvement.test.mjs 가
플러그인을 켜고 끈 답변의 차이를 지킨다.
출시 순서
- v0.1 — 출력 스타일 +
rules/+build-style.mjs+ 단위 테스트. 훅 없음. - v0.2 —
guard-artifact린터. - v0.3 — 한국 개발 현실 지식 스킬 네 개.
- v0.4 — 주민등록번호 유출 검사 훅.
- v0.5 — 영어 낱말 뒤 조사를 발음으로 판정하는 엔진.
- v0.6 — 저장소의 산출물 언어 추론.
- v0.7 — 훅을 하나로 합쳐 도구 호출당 비용을 줄였다.
- v0.8 — 조사 엔진 낱말 목록 확장, 설계 문서 낡음 검증.
- v0.9 — 규칙표 전체를 여는 스킬, 개선 회귀 시험.
- v0.10 — 실물 확인으로 지식 층을 채움. 안심번호, 엑셀 수식 주입, 전각 문자, 앞자리 0, 은행 휴무일, 접속기록.
- v0.13.10~0.13.18 — 시험 캠페인. 아래 “대규모 시험 캠페인” 참조.
- v0.18.0 — GitHub Action(
uses: SungJun1217/kimchi-claude@v0). PR의 문서·커밋 메시지·제목·본문을 훅과 같은 규칙으로 검사해 리뷰 코멘트나 워크플로 주석으로 알린다. - 다음 — 평가 세트 실행(자격 증명이 있는 환경에서).
v0.1이 제품의 대부분이다. 린터는 커밋과 문서에만 걸리고 대화 말투는 건드리지 못한다. 먼저 스타일만 내놓고 실제로 좋아지는지 확인한 다음 린터를 붙이는 것이 순서상 맞다.
대규모 시험 캠페인 (v0.13.10~0.13.18)
단위 테스트가 늘어도 실제 입력의 다양성은 따로 잰 적이 없었다. 리뷰어 역할과 실행기(tester) 역할을
나누고, 실제 페이로드로 훅을 직접 구동하고 변이(뜻을 아주 조금 바꾼 입력)를 넣어 보는 방식으로
1,400여 개 사례를 훑었다. 다섯 영역에서 결함이 나왔다 — 훅 출력 신뢰성(큰 출력이 파이프에서
잘리는 문제), 자동 교정의 적용 범위(코드·경로까지 건드리던 것), 규칙 매칭의 정확도(낱말 경계·조사
오판, 뜻을 바꾸는 치환), 개인정보 탐지의 강건성(대시·공백 변형, 보이지 않는 문자), 스킬 예제의
품질(마스킹 누출, 저장소 언어 오판). 각 결함은 회귀 시험으로 먼저 재현한 뒤 고쳤고, 단위마다
0.13.10부터 0.13.18까지 순서대로 커밋했다.
이 캠페인이 남긴 교훈은 “손으로 찾은 결함은 시험으로 박아 둔다”이다. 같은 결함을 사람이 두 번 찾게 하지 않으려면, 찾을 때마다 회귀 시험을 같이 커밋해야 한다.
국립국어원 자료를 들인 방법 (v0.13.19~0.14.4)
규칙표 항목을 늘릴 때 국립국어원 자료(외래어 표기 용례, 표준국어대사전, 우리말샘, 온라인가나다,
『한눈에 알아보는 공공언어 바로 쓰기』)로 근거를 대조했다. 조사 결과는
corpus/nikl-2026-09-25.json에 검증 행과 후보 행으로 남겨 뒀다 — 훅이나 스크립트는 실행 시점에
이 파일을 읽지 않는다. 개발 시점 근거 자료일 뿐이다.
| 단위 | 버전 | 내용 |
|---|---|---|
| 1 | 0.13.19 | 기존 규칙 42행을 대조해 근거가 틀린 7행의 이유를 고쳤다. 쓸 것·검사 칸은 그대로였지만, 이유 문장이 낱말 매칭으로 경계 판정을 겸하던 시절이라 레지스터리는 경계 판정을 잃었고 “50 %”는 분류만 바뀌었다(첫 글자가 숫자라 동작은 그대로였다). 6번째 단위에서 발견하고 둘 다 되돌렸다 |
| 2 | 0.14.0 | 외래어 표기 오류 20개 추가 |
| 3 | 0.14.1 | 맞춤법 오기 14개 추가 |
| 4 | 0.14.2 | 띄어쓰기 오류 19개 추가 |
| 5 | 0.14.3 | 공공언어 지침 근거 번역투 문장 구조 8개 추가 |
| 6 | 0.14.4 | 낱말 경계 판정을 이유 문장 매칭에서 명시 표지로 바꾸고, 이 절을 추가 |
저작권 제약
국립국어원 자료마다 이용 조건이 다르다.
- 어문 규범·용례 찾기·온라인가나다·다듬은 말은 저작권법 제24조의2에 따른 공공저작물 자유 이용 대상이다.
- 표준국어대사전·우리말샘은 CC BY-SA 2.0 KR(출처 표시·동일 조건 변경 허락)이다. 출전이 있는 용례 문장은 원저작자 권리가 남는다.
- 『한눈에 알아보는 공공언어 바로 쓰기』(개정판) PDF는 공공누리 제4유형(비상업적 이용만
허락, 변경 금지)이다. 원문을 그대로 옮길 수 없어, 규칙표의 이유 칸과 이 문서 모두
지침의 사실과 원칙만 자기 말로 옮기고 쪽수만 인용한다(예:
rules/register.md의 “관계를 뜻하는 간은 의존명사라 띄어 쓴다” 항목).
다듬은 말을 통째로 들이지 않는 이유
국립국어원 다듬은 말 목록에서 실제로 확인한 예(국립국어원 누리집 개선 > 다듬은 말,
corpus/nikl-2026-09-25.json의 notImported.다듬은말에 근거와 함께 남겼다)를 보면 왜
통째로 옮길 수 없는지가 분명하다. 디버깅은 “벌레잡기”, 로그인은 “회원 들어가기”, 쿠키는
“과자”로 다듬어졌다. 브라우저 쿠키를 “과자”로 바꾸면 뜻 자체가 사라진다. 가장 뚜렷한 사례는
서버(server)다 — 다듬은 말은 “메김 선수”인데, 이는 테니스·배구 같은 스포츠에서 공을 먼저
주는 선수를 가리키는 순화어다. 다듬은 말은 낱말 하나가 아니라 분야(도메인)별로 따로
조어되므로, 개발 문서의 서버(컴퓨터)에 기계적으로 적용할 근거가 애초에 없다.
(참고로 캐시·커밋·리팩터링처럼 개발 현장에 굳은 음차어는 조사 시점에 다듬은 말
목록에서 찾지 못했다 — 캐시는 “현금” 뜻의 항목만 있고, 커밋·리팩터링은 등재 자체가 없었다.
그래도 다듬은 말 목록이 앞으로 이런 말에 순화어를 얹는다면, 이미 정착한 전문 용어를 순화어로
강제하는 것은 용어 처리 우선순위(정착된 한국어 기술 용어·외래어가 있으면 그것을 쓰고,
순우리말 풀어쓰기는 최후 수단이라는 원칙, skills/natural-korean-writing/SKILL.md 참고)를
거스른다 — 방향만 반대일 뿐 cache를 임시 저장소로 강제하는 것과 같은 종류의 실수가 될
것이다.) 그래서 다듬은 말 목록은 근거 조사에만 참고하고, 개발 현장 관행과 맞을 때만(예:
workshop→워크숍, 위 표기 용례와 다듬은 말 목록이 일치한 경우) 규칙표에 올린다.
국립국어원 표기와 실무 관행이 갈리는 낱말
아래 낱말은 국립국어원 표기와 개발 현장 관행이 갈려서 일부러 규칙을 만들지 않았다.
실무 관행 쪽이 이미 넓게 쓰이는데 규칙으로 규범 표기를 강제하면 오탐과 반발만 남긴다
(rules/register.md 머리말에도 같은 목록과 이유를 적어 뒀다 — 앞으로 규칙표를 만지는
사람이 “고쳐야 할 오표기”로 착각해 새로 추가하지 않도록). 근거는
corpus/nikl-2026-09-25.json의 notImported.관행과_다른_표기에 남겼다.
| 국립국어원 표기 | 실무 관행 | 근거 | 비고 |
|---|---|---|---|
| 스냅숏 | 스냅샷 | 용례 찾기, 표준국어대사전(『영상』 뜻) | 실무 관행 쪽으로 굳었다 |
| 칼럼 | 컬럼 | 용례 찾기, 표준국어대사전(신문 칼럼 뜻) | 표제어는 신문 칼럼 뜻이고 데이터베이스 열 뜻이 아니다. 개발자는 데이터베이스 문맥에서 컬럼을 쓴다 |
| 프러시저 | 프로시저 | 표준국어대사전(『정보·통신』 뜻) | 실무 관행 쪽으로 굳었다 |
| 플로 | 플로우 | 용례 찾기 | 실무 관행 쪽으로 굳었다 |
| 리포지터리 | 리포지토리 | 우리말샘 | 0.14.0에서 검토. GitHub 한국어 문서가 리포지토리를 쓴다(단, “저장소”가 더 흔해 병기 권장) |
섬네일(thumbnail)·콘텍스트(context)는 표에 올리지 않았다. 외래어 표기법 원칙(thumbnail의 [θ]는 ㅅ으로, context의 짧은 [ɒ]는 오로 적는다)대로면 이 표기가 나오지만, 조사에서 실제 용례·사전 등재를 확인하지 못했다 — 확인하지 못한 근거로 규칙을 만들지 않는다.
쉘→셸(외래어 표기 규범)도 규칙으로 만들지 않았다. 한 글자 금칙어는 toPattern의 최소
길이(2자) 제한에 걸려 문자열 검사가 애초에 안 되고, 미쉘·쉘(사) 같은 고유명사와도 겹친다
(rules/register.md 머리말 참고).
번역투 근거 보강 (2026-09-27)
딥리서치 라운드 하나로 번역투 규칙의 근거를 보강했다. 새국어생활 2012 봄호(김순영, https://www.korean.go.kr/nkview/nklife/2012_1/22_0105.pdf), 공공언어 바로 쓰기(2022 개정판, https://www.korean.go.kr/front/etcData/etcDataView.do?mn_id=208&etc_seq=699의 ‘일본어 번역 투 삼가기’·’영어 번역 투 삼가기’ 절), 국립국어원 온라인가나다 https://korean.go.kr/front/onlineQna/onlineQnaView.do?mn_id=216&qna_seq=310695(이중 피동 질의응답), Microsoft 한국어 스타일 가이드(4.1.8, 4.1.11), 최유숙의 번역투 논문(어문론집 93권, 2023, KCI)이다. 결과는 두 갈래로 갈렸다.
첫째, 잊혀지다→잊히다, 되어지다→되다(및 활용형)는 새로 규칙표에 올렸다. 되다는
그 자체로 피동이라 ‘-어지다’를 다시 붙일 이유가 없고, 어느 문맥에서도 뜻이 바뀌지 않아
치환이다. 다만 낱말 하나로는 활용형을 다 덮지 못해(예: 잊혀졌다가 잊혀지다 행에
안 걸린다) 자주 쓰는 활용형마다 행을 따로 뒀다 — hooks/lib/lint.mjs의 패턴 엔진은
어간+어미 분석이 아니라 문자열 대조라서, 규칙 자료 쪽에서 활용형을 하나하나 적어 줘야
잡힌다.
둘째, ~에 의해·~로부터·가지다류의 번역투는 문맥마다 옳은 대체 표현이 갈린다는 근거를
보강했다. 가지다(이미 규칙표에 있던 행)는 근거 인용만 추가했다. ~에 의해 결정·
~로부터 받았다/들었다는 이 라운드에서 새로 규칙표에 올린 행이다 — “법에 의하여”는 근거를
뜻하는 정당한 쓰임이고, “위원회에 의해 결정됩니다”를 기계적으로 “위원회로 결정됩니다”로
바꾸면 위원회가 결정한 주체가 아니라 선정된 대상으로 읽혀 뜻이 달라지는 식으로, 행위자가
사람·기관인지 도구·장소인지에 따라 옳은 조사가 갈린다. 국립국어원 스스로도 이런 번역투를
비문이 아니라 “부자연스러울 수 있다”고 본다
(https://korean.go.kr/front/onlineQna/onlineQnaView.do?mn_id=216&qna_seq=317346, 2025-07)
— 그래서 이
갈래는 규칙표에서 늘 정규식(경고)이지 치환이 아니다. 자동 치환은 규칙 하나가 모든 문맥에서
옳을 때만 쓴다는 원칙(불변식 5)과 같은 이유다.
이 라운드에서 살아남지 못한 후보도 있다. ~를 통해를 전부 ~로로 바꾸라는 권고,
가지다를 전부 있다로 바꾸라는 권고는 검증 단계에서 반박됐다(각각 1승 2패, 0승 3패) —
둘 다 문맥에 따라 원문이 더 자연스러운 경우가 있었다. 과공손·문체 혼용(사물 존대 등)과
판교어(컨펌, 어사인) 범위는 이 라운드에서 근거를 찾지 못해 열린 채로 남겨 뒀다.
사물 존대와 판교어 근거 (2026-09-27)
번역투 라운드가 열어 둔 두 갈래(과공손·문체 혼용, 판교어)를 딥리서치 2차 라운드로 다시 팠다.
사물 존대는 표준 언어 예절(2011) 경어법 장이 1차 근거다. “주문하신 커피 나오셨습니다”, “품절이십니다”, “사이즈가 없으십니다”, “포장이세요?”를 사물에 붙인 잘못된 -시-로 명시하고 교정형도 함께 준다. 2026-02 문체부·국립국어원 공공언어 설문에서도 과도한 높임이 개선 필요 1위(93.3%)였다. 다만 같은 자료가 간접 존대(“눈이 크시다”, “걱정이 많으시다”)는 인정하므로, 사물 주어의 -시- 전체를 막는 일반 규칙은 오탐을 낸다(불변식 5). 그래서 “품절이십니다”, “품절이세요”, “사이즈가 없으십니다”처럼 고정된 구절만 치환으로 올리고, 구조가 통째로 바뀌는 “포장이세요? → 포장해 드릴까요?”와 어미 패턴 “-(으)실게요”(국립국어원 [바른 공공언어] 영상: 받으실게요·앉으실게요·걸어 보실게요 모두 틀린 예로 든다)는 정규식(경고)에 둔다. 동사마다 교정형이 달라 하나로 못 정하기 때문이다. “선생님 나오셨습니다”처럼 사람이 주어인 문장은 규칙표에 없어 건드리지 않는다.
판교어 쪽은 국립국어원 다듬은 말에서 컨펌(2012-04-16 국어심의회 확정, 확인·확정)과 킥오프 회의(첫 회의·첫 기획 회의)의 공식 근거를 찾았다. 김형배 연구관의 칼럼(한국교육신문, 2015)은 컨펌받다 → 확인받다, 딜레이되다 → 미뤄지다/연기되다/지연되다/지체되다를 구문별로 권한다. 컨펌받다는 남이 검토한 결과를 받는다는 뜻으로 좁혀져 확인받다로 고쳐도 문맥과 관계없이 뜻이 상하지 않아 치환으로 올렸고, 딜레이되다는 의도적으로 미뤘는지(연기) 저절로 늦어졌는지(지연)가 갈려 정규식으로 남긴다. 어사인·싱크 맞추다·팔로업·리마인드·핸들링· ASAP·얼라인·애자일하게는 이번에도 국립국어원 근거를 찾지 못해 기존 정규식·참고 행 그대로 둔다. 2025-12 다듬은 말이 새로 내놓은 마일스톤·휴리스틱은 GitHub Milestones 등으로 개발 현장에 이미 굳은 명사라 규칙에 넣지 않는다.
괄호 뒤 조사는 괄호 앞말을 따른다 (2026-09-27)
개발 문서는 영어 원어를 괄호로 병기하는 관행이 있다(“비밀 키 암호 기법(secret key
cryptography)”). 그런데 조사 엔진(particle.mjs)은 원래 영어 낱말이나 숫자 바로 뒤에
조사가 붙는 자리만 봤다 — 괄호가 끼면 앞말이 뭔지 모른 채 지나쳤다. NIKL 온라인가나다
(327689, 296978, 302384, 316103)는 이런 자리의 조사가 괄호 안이 아니라 괄호 앞말을
따른다고 답한다. “기법(secret key cryptography)가”는 “기법”의 받침(ㅂ)을 따라 “이”가 맞고,
괄호 안이 영어든 한글이든 상관없다.
findParticleErrors에 닫는 괄호 바로 뒤 조사를 찾는 두 번째 스캔을 더했다. 닫는 괄호에서
여는 괄호를 거슬러 찾고, 그 앞말이 한글이면 종성 코드로, 영어/숫자 낱말이면 기존 발음
사전(finalSoundOf)으로 받침을 판정한다. 판정할 수 없으면(불변식 5) 손대지 않는다 — 괄호
안에 괄호가 또 있어 앞말이 어느 괄호의 것인지 모호한 경우, 빈 괄호, 앞말이 순수 숫자(그 자체가
근거 조사에서 다루지 않은 자리), 앞말이 코드로 가려진 경우, 여는 괄호가 줄 맨 앞이라 앞말이
아예 없는 경우가 그렇다.
lint.mjs가 이미 particle.mjs를 임포트하므로(particleHeads 등), 한글 종성 판정에 쓰는
lint.mjs의 finalConsonantClass를 거꾸로 가져다 쓰면 순환 참조가 생긴다. 상수 네 개(음절
시작·끝 코드, 종성 개수, ㄹ 종성 색인)뿐이라 particle.mjs에도 그대로 둬 회로를 끊었다.
GitHub Action (v0.18.0)
플러그인 훅은 클로드 코드 세션 안에서만 돈다. 하지만 문서·커밋 메시지는 PR 리뷰에서도
지적할 수 있는 대상이다. 저장소 어디서든 uses: SungJun1217/kimchi-claude@v0로 켤 수 있는
복합 액션(action.yml)을 추가했다. v0은 릴리스마다 release.yml이 최신 0.x.y 태그로
강제 이동시키는 태그다 — 아직 1.0.0이 아니라서 관행적인 메이저 태그(@v1)를 못 쓰므로,
v0을 그 자리에 대신 둔다.
왜 오프라인 스크립트 + gh 셸 단계로 나눴는가
불변식 10(훅·스크립트는 네트워크를 열지 않는다)을 액션에도 그대로 지킨다. scripts/action-lint.mjs와
scripts/build-review.mjs는 로컬 파일만 읽고 로컬 JSON만 쓴다 — PR 변경 파일 목록을 모으고,
커밋 메시지·PR 제목/본문을 가져오고, 결과를 리뷰로 올리는 것은 모두 action.yml의 셸 단계가
gh CLI로 한다. 이렇게 나누면 두 가지가 따라온다.
- 검사 로직에는 여전히 의존성이 없다.
gh는 GitHub Actions 러너에 이미 있는 도구이지 이 저장소가 새로 들이는 npm 패키지가 아니다. - 검사 로직을
node scripts/action-lint.mjs로 로컬에서 그대로 재현할 수 있다. 네트워크 호출이 검사 결과에 섞여 있으면 CI 밖에서 디버깅할 수 없다.
두 번째 이유가 실제로 값어치를 냈다 — tests/action-lint.test.mjs가 자식 프로세스로
action-lint.mjs를 직접 구동해서 검증하는데, 네트워크가 섞여 있었다면 이 시험 자체가
자격 증명 없이는 못 돌았을 것이다(evals/의 한계와 같은 문제).
판정 로직을 새로 만들지 않은 이유
action-lint.mjs는 hooks/lib/artifact.mjs의 extractTargets를 파일마다 Write 도구
호출인 것처럼 그대로 불러 쓴다. 문서 확장자 제한, 빌드 도구용 파일 이름 예외
(isToneExemptBasename), 파일 자신의 kimchi-ignore-file 선언, 참조식 링크 라벨 수집까지
훅과 똑같은 판정을 거치게 하려는 것이다. 판정을 여기서 다시 적으면 반드시 둘이 어긋난다 —
이 저장소의 다른 모든 판정 로직이 그렇듯, 두 번째 구현은 두 번째 버그를 낳는다.
suggestion 블록을 치환에만 붙이는 이유
GitHub 리뷰 코멘트의 suggestion 블록은 그 줄 전체를 그대로 대체한다. 치환 규칙은
applyFixes가 실제로 안전하게 갈아 끼운 결과가 있으므로 그 줄을 그대로 제안할 수 있다.
정규식 규칙은 문맥을 봐야 고칠 수 있다고 스스로 표시한 규칙이라 — lint.mjs의
autoFixReplacement가 그런 규칙에는 애초에 대체 문자열을 내주지 않는다 — 제안할 안전한
대체문이 없다. 이 규칙은 지적만 코멘트 본문에 적고 suggestion은 붙이지 않는다.
hooks/lib/artifact.mjs의 자동 교정이 같은 이유로 치환에만 적용되는 것과 같은 경계다.
인라인 코멘트는 diff에 보이는 줄만 달 수 있다
GitHub 리뷰 API는 PR diff에 실제로 나타나는 줄(추가되었거나 문맥으로 보이는 줄)에만 코멘트를
붙일 수 있다. build-review.mjs가 pulls/{n}/files 응답의 patch(unified diff hunk)에서
그 줄 번호 집합을 뽑아, 집합 밖의 지적은 코멘트 대신 리뷰 본문의 “diff 밖에서 찾은 표현”
목록으로 접는다. 지적을 조용히 버리지 않는 것이 핵심이다 — PR이 건드리지 않은 줄이라고
지적 자체가 틀린 것은 아니다.
재실행마다 리뷰를 다시 쌓지 않는 방법 — 지문, 스레드 해소, 리뷰 접기
첫 버전은 재실행마다 이전 kimchi 리뷰의 인라인 코멘트를 지우고 새로 올리는 방식이었다. 동작은 했지만 이력이 사라졌다 — 리뷰어가 “이 지적이 지난 커밋에서도 있었나”를 알 방법이 없어졌다. 보안 검토에서 이 방식이 뒤집혔고, 소유자가 재설계를 승인했다.
지금은 지적 하나하나에 지문(fingerprint)을 매긴다.
sha1(file + "|" + ruleKey + "|" + bad + "|" + lineText.trim())
ruleKey는 실제로 걸린 문구(bad)가 아니라 규칙표의 원본 패턴이다 — 같은 문구를 서로 다른
이유로 잡는 규칙이 있을 수 있어, 어느 규칙이 잡았는지까지 지문에 실어야 재실행마다 같은
지적을 같은 것으로 알아본다. lineText는 그 줄의 원문이다 — 문구가 그대로여도 줄 자체가
바뀌면(주변 문맥이 달라지면) 다른 지적으로 본다.
인라인 코멘트마다 이 지문을 숨은 마커로 심는다(<!-- kimchi-claude-lint:v1 fp=<sha1> -->,
scripts/build-review.mjs의 fingerprint/markerFor). 재실행할 때 planReviewActions가
순수 함수로 판정한다.
- 기존 스레드 중 아직 열려 있는(해결·낡음 표시가 없는) kimchi 스레드에서만 지문을
모은다(
isKimchiThread+!isResolved && !isOutdated, 요구사항 5). 이미 해결되어 화면에서 접힌 스레드의 지문까지 세면, 그 지적이 되돌리기 커밋 등으로 다시 나타나도 “이미 지문이 있다”고 오판해 다시 올리지 않는 버그가 된다 — “해결됨”은 “그 자리는 지금 깨끗하다”는 뜻이지 “다시는 지적하지 않는다”는 뜻이 아니다. - 스레드를 “kimchi 것”으로 인정하는 기준은 첫 코멘트다. 스레드의 첫 코멘트가 봇이
달았고 지문 마커가 있어야 kimchi 스레드다. 사람이 나중에 남긴 답에 우연히 같은
마커 글자가 섞여 있어도(
isKimchiThread는comments[0]만 본다) 그 스레드를 가로채지 않는다. - 이번에 찾은 지적 가운데 그 집합에 없는 지문만 새로 올린다. 전부 이미 있으면 리뷰 자체를
올리지 않는다(“걸리는 것이 없으면 리뷰도 없다”는 원래 방침의 연장이다). 한 줄에
지적이 여럿이고 그중 일부만 새로 생겼을 때가 까다롭다 — 이미 활성 스레드가 있는
지적까지 통째로 다시 올리면 같은 지적이 두 스레드에 중복으로 남는다.
finalizeComment가 그룹(같은 줄의 지적들)에서 이미 올라간 지문만 걸러내 마커·목록에서 빼고,suggestion블록만은 그룹 전체로 계산한다 — 그 줄에 실제로 적용 가능한 최종 모습은 이미 지적된 것까지 포함해야 나오기 때문이다. 새 지적만으로 suggestion을 만들면 같은 줄의 다른(이미 지적됐지만 아직 안 고쳐진) 문제를 놓친 채 “고쳐졌다”고 제안하게 된다. - 이번에 찾은 지적에 더는 나타나지 않는 지문을 가진 기존 kimchi 스레드는 GraphQL
resolveReviewThread로 접는다 — 지우지 않으므로 “이 자리는 이렇게 지적됐다가 이렇게 고쳐졌다”는 이력이 PR에 그대로 남는다. 단, 사람이 답을 남긴 스레드는 자동으로 접지 않는다(hasNonBotReply) — 진행 중인 논의를 봇이 임의로 끝내면 안 된다. - 새 리뷰를 올릴 때는 이전 kimchi 리뷰를(작성자가 봇이고 본문에 리뷰 마커가 있는 것만)
GraphQL
minimizeComment(classifier: OUTDATED)로 접는다 —PullRequestReview가Minimizable인터페이스를 구현한다는 것을 실제로 확인했다. 리뷰 자체는COMMENT이벤트라 GitHub REST API로 지우거나 취소할 수 없지만(dismissals엔드포인트는APPROVED·CHANGES_REQUESTED만 받는다), 접어 두면 PR 화면에서 기본적으로 숨는다.
봇 계정 판정에는 함정이 하나 있다. REST API는 이 봇 로그인을 github-actions[bot]으로
주지만, GraphQL은 같은 계정을 login: "github-actions"(대괄호 없음) __typename: "Bot"로
다르게 준다(실측). 리뷰 스레드·리뷰·PR 코멘트 조회를 전부 GraphQL로 하므로, REST 표기만
보고 판정하면 봇 코멘트를 하나도 못 알아본다 — sticky 코멘트가 매번 새로 생기고,
reviewsToMinimize가 아무것도 못 찾는 식으로 조용히 깨진다. isKimchiBot(author)
하나로 두 표기를 모두 받아, 이 판정을 쓰는 모든 자리(isKimchiThread, hasNonBotReply,
reviewsToMinimize, action.yml의 sticky 코멘트 검색 jq)가 같은 기준을 쓴다.
리뷰 본문은 건수와 sticky 코멘트로의 안내만 담아 짧게 유지한다(buildReviewBody) — 자세한
목록(diff 밖 지적, 커밋 메시지, PR 제목·본문, 주민등록번호)은 마커
<!-- kimchi-claude-lint:summary -->로 찾는 이슈 코멘트 하나에 둔다(buildStickyBody).
이 sticky 코멘트는 findings 유무와 무관하게 매 실행마다 그 자리에서 갱신한다 — 걸리는
것이 없어졌으면 “걸리는 표현이 없습니다”로도 갱신된다. 리뷰처럼 여러 개가 쌓일 일이 없다.
이 판정들(planReviewActions, finalizeComment, reviewsToMinimize, buildLineSuggestion,
buildComments)은 전부 gh 호출 없이 순수 함수로 짜여 있다 — 자격 증명 없이도
tests/action-lint.test.mjs가 합성 데이터로 검증한다.
개인정보가 인라인 코멘트에 실리지 않도록 이중으로 막는다
주민등록번호로 보이는 값이 걸린 문서 줄은 suggestion 블록에 절대 올리지 않는다 —
suggestion은 그 줄 전체를 그대로 되읊는 블록이라, 지적한 용어 하나만 가려서는 부족하다.
action-lint.mjs가 줄을 읽는 시점에 redactText로 이미 가린 사본만 lineText에 담고,
가리기 전/후가 달랐으면 lineHasPii를 켠다. build-review.mjs의 buildLineSuggestion은
이 표시만 보고 그 줄의 제안을 아예 만들지 않는다 — 이미 가려진 문자열을 다시
findResidentNumbers로 검사하면 더는 13자리로 안 보여 통과해 버리므로, “가리기 전에 있었는가”
를 직접 표시해 둬야 한다.
파일 경로 자체에 번호가 섞인 경우(900101-1234567.md)도 같은 함수(safeDisplayPath)로
가린 값만 JSON 출력의 file 필드에 싣는다 — annotations의 file= 속성, 리뷰 본문의 목록
어디에도 원문 경로가 나타나지 않는다. safeDisplayPath는 훅의 pii.mjs가 경로에 쓰는
pathOnly(대시 구분자만 인정하는 좁은 규칙)를 일부러 쓰지 않는다 — 감지에 쓴 규칙과
가리기에 쓴 규칙이 다르면 “걸렸다고는 했는데 정작 안 가려진” 값이 새 나간다(실제로 있었던
결함이다 — 경로 감지는 기본 규칙을 쓰는데 가리기는 pathOnly를 써서, 붙여 쓴 형태·공백
구분 형태가 감지는 되고 가려지지는 않았다). 경로 감지와 가리기를 똑같이 기본 규칙(대시·
공백·구분자 없는 형태까지)으로 맞췄다 — pathOnly는 파일명 관례(날짜+일련번호를 붙여 쓴
리포트 파일 등)의 오탐을 줄이려는 규칙인데, 표시용 값이 그런 파일명에서 과하게 가려지는
대가가 주민등록번호를 그대로 보여주는 대가보다 훨씬 싸다.
경로 자체에 번호가 있던 파일은 인라인 리뷰 코멘트를 아예 만들지 않는다(buildComments의
pathHasPii 분기). 리뷰 코멘트의 path는 PR의 실제 파일 경로와 글자 그대로 같아야
하는데(다르면 GitHub API가 리뷰 전체를 422로 거부한다), 그렇다고 가려지지 않은 실제 경로를
그 자리에 쓰면 safeDisplayPath가 막으려던 유출이 리뷰 코멘트를 통해 그대로 일어난다.
action-lint.mjs는 애초에 실제 경로를 JSON 출력 어디에도 남기지 않는다 — pathHasPii가
거짓일 때만(가릴 것이 없어서) file 값 자체가 실제 경로와 같으므로, build-review.mjs는
그 경우에만 file을 PR files API 조회에 쓰고, 참이면 그 지적을 무조건 sticky 요약의
“diff 밖” 목록으로 돌린다.
마크다운·워크플로 명령 값 이스케이프
파일 이름이나 지적 문구에 백틱이 섞여 있으면 mdCode가 감싸는 백틱 개수를 텍스트 안 최장
연속 백틱보다 하나 더 길게 잡아 스팬이 깨지지 않게 한다(커먼마크 규칙). 코드 스팬 안에서는
@멘션도 알림으로 파싱되지 않으므로, 파일 이름·문구를 전부 mdCode로 감싸는 것만으로
별도의 멘션 무력화 처리 없이 안전해진다. 워크플로 명령(::warning file=,line=,col=::)의
속성 값은 GitHub 문서가 요구하는 다섯 문자(%→%25, \r→%0D, \n→%0A, :→%3A,
,→%2C)를 인코딩한다 — 인코딩하지 않으면 파일 이름에 든 콤마·콜론이 다음 속성과 경계를
깨거나, 줄바꿈이 명령 자체를 두 줄로 쪼갠다.
fail-on-findings는 게시가 다 끝난 뒤에 판정한다
오프라인 검사 단계가 위반을 찾았다고 그 자리에서 실패(종료 코드 1)해 버리면, GitHub
Actions는 그 뒤의 단계를(if:에 always()나 failure()를 명시하지 않는 한) 전부
건너뛴다 — 리뷰 게시, 스레드 해소, sticky 코멘트 갱신이 통째로 스킵되는 버그였다. 지금은
이 단계가 항상 종료 코드 0으로 끝나고, 실제 판정 결과는 스텝 출력(exit-code)에만 남긴다.
맨 마지막에 if: always()로 건 단계가 앞선 모든 게시가 끝난 뒤 그 출력을 보고 fail-on-findings
를 판정한다. 이 판정은 문서·커밋뿐 아니라 PR 제목·본문·주민등록번호까지 모두 센다 — 처음에는
PR 텍스트 위반을 세지 않아 “카운트에는 잡히는데 실패하지는 않는” 불일치가 있었다.
PR 헤드 커밋을 직접 지정해서 읽는다
pull_request 이벤트의 기본 체크아웃은 base 브랜치를 끌어와 만든 merge 커밋이다. 작업
트리를 그냥 읽으면 PR이 손대지 않은 base 쪽 변경까지 섞여 들어와, 훅이 보고하는 줄 번호가
실제 PR 헤드와 어긋날 수 있다. action-lint.mjs는 --head-sha를 받으면 작업 트리 대신
git show <sha>:<path>로 그 커밋 시점의 내용을 직접 읽는다 — 호출부가 어느 ref를
체크아웃했든 결과가 갈리지 않는다. 얕은 클론이 기본이라 그 커밋 객체가 로컬에 없을 수 있어,
action.yml이 먼저 git cat-file -e로 있는지 보고 없으면 git fetch --depth=1로 그
커밋 하나만 받아 둔다. 리뷰 페이로드의 commit_id도 같은 헤드 커밋으로 채운다.
fork PR·Dependabot PR은 annotations로 내려간다
fork에서 온 PR과 Dependabot이 연 PR은 기본 토큰이 읽기 전용이다 — head.repo.full_name이
base 저장소와 다르거나 github.actor가 dependabot[bot]이면 리뷰 게시를 시도하지 않고
워크플로 로그의 ::warning/::error로만 알린다. 이 판정이 빗나가더라도(조직 설정이 특이한
경우 등) 게시 단계들은 403을 만나면 set +e로 감싸 job을 실패시키지 않고 넘어간다 — 리뷰를
못 올리는 것이 job 실패보다는 나은 실패다.
마켓플레이스 등록 (v0.24.3)
action.yml에 branding(아이콘 check-circle, 색 red)을 넣어 GitHub Marketplace 목록에 올릴 수 있게
했다. 등록 자체는 릴리스를 편집할 때 “Publish this Action to the GitHub Marketplace”를 고르는 UI
단계라 워크플로로 자동화하지 않는다 — 처음 한 번은 약관 동의와 2FA가 필요하고, gh release create
에는 마켓플레이스 게시 옵션이 없다. 마켓플레이스에 보이는 버전은 게시를 고른 릴리스뿐이지만,
uses: SungJun1217/kimchi-claude@v0는 마켓플레이스와 상관없이 태그를 따라가므로 사용자에게는
영향이 없다.
마켓플레이스에는 올리지 않기로 했다(v0.24.5). v0.24.4 릴리스에서 게시를 두 번 골랐지만 마켓플레이스
검색 결과는 0건이었다(같은 검색으로 setup-node는 40건이 나온다). 마켓플레이스용 Action 저장소는
워크플로 파일 없이 Action 하나만 담아야 한다는 조건이 있는데, 이 저장소는 플러그인·CI·봇 워크플로를
함께 두므로 맞지 않는 것으로 본다. Action만 따로 떼어 낸 저장소를 두면 등록할 수 있지만, 릴리스마다
파일을 복사하는 동기화와 쓰기 토큰 하나가 더 따라온다. 잃는 것은 마켓플레이스 검색 노출뿐이고
uses: SungJun1217/kimchi-claude@v0는 그대로 동작하므로 그 비용을 지지 않는다. branding은 남겨 둔다
— 나중에 따로 떼어 내면 그대로 쓴다.
오탐·놓친 표현 이슈 자동 확인 (v0.19.0)
false-positive.yml·missed-expression.yml 이슈 양식은 사람이 신고한 문장을 담는다.
신고를 받고 나서 유지자가 매번 손으로 그 문장을 재현해 보는 일이 반복됐다 — .github/workflows/triage.yml이
이슈가 열리거나 수정될 때 그 재현을 대신하고, 결과를 코멘트로 남기고 라벨(reproduced/
not-reproduced)을 붙인다.
설계 원칙은 GitHub Action과 같다. scripts/triage-report.mjs는 오프라인 스크립트로, 이슈
본문 텍스트만 읽어 판정하고 코멘트를 올리거나 라벨을 바꾸는 것은 워크플로의 gh 셸 단계가
한다(불변식 10). 판정 로직도 새로 만들지 않는다 — scripts/action-lint.mjs의
findingsForTarget/findingsForText를 그대로 불러 “커밋 메시지” 신고는 커밋 메시지 경로로,
“문서 파일”·”기타” 신고는 문서 경로로 재현한다.
triage.yml은 main(기본 브랜치, 릴리스된 상태만 남는다)을 명시적으로 체크아웃한다 —
“현재 규칙”은 항상 최신 릴리스 기준이지 develop의 진행 중인 변경이 아니다.
“대화 답변(스타일)”으로 신고된 문장은 아예 린트 대상이 아니다 — hooks/lib/lint.mjs가 보는
것은 커밋 메시지와 문서 파일뿐이고, 대화는 output-styles/natural-korean.md(강제 적용되는
시스템 프롬프트)가 다루는 영역이다. 그래서 이 경로는 린트 결과를 절대 주장하지 않는다.
대신 규칙표에 짝이 되는 프롬프트 검사 규칙이 있는지만 본다 — lint()는 치환·정규식만
검사하므로(SCANNABLE_CHECKS), 프롬프트 규칙은 toPattern으로 직접 시험한다
(promptRuleMatches). 이 경로는 reproduced/not-reproduced 라벨도 건드리지 않는다 —
린트로 확정할 수 없는 것을 확정된 것처럼 라벨로 못박으면 안 된다.
라벨의 뜻은 두 양식에서 서로 뒤집힌다. false-positive는 지금도 걸리면(findings가 있으면)
문제가 남아 있는 것이라 reproduced다. missed-expression은 반대로 지금도 안 잡히면(findings가
없으면) 여전히 놓치고 있는 것이라 reproduced다 — “규칙이 이미 잡는다”는 결과는 오히려
not-reproduced(더는 놓치지 않는다)로 본다. decideLabels가 템플릿별로 이 반전을 명시적으로
가른다.
규칙 출처 링크(rules/<file>.md#L<line>)는 규칙 객체 자체가 아니라 다시 찾아야 한다 —
lint()가 내는 위반에는 어느 규칙표 파일에서 왔는지(source)가 실리지 않는다(ruleKey·
good·check만 있다). ownerRuleOf가 같은 조합으로 loadToneRules()가 읽어 둔 원본 규칙
배열에서 다시 찾고, ruleLocation이 그 파일을 한 번 더 읽어 | bad | 셀이 있는 줄을 찾는다.
표 형식이 달라지거나 파일이 없으면 링크 없이 파일 이름만 보여준다 — 링크를 못 만드는 것이
코멘트 자체를 실패시킬 이유는 아니다.
주민등록번호로 보이는 값은 재현에 쓰기 전에 redactText로 가린다 — 신고자가 실제 값을
그대로 붙여 넣었을 가능성을 배제할 수 없고, 훅의 pii.mjs와 같은 원칙(어떤 산출물도
원문을 되읊지 않는다)을 이슈 코멘트에도 지켜야 한다. 가린 뒤에는 형식만 맞는 값이 되어
13자리 매치가 사라지므로 재현 결과 자체는 달라지지 않는다. PII가 있었으면 needs-info
라벨을 추가하고, 코멘트에 형식만 맞는 가상의 값으로 다시 적어 달라는 안내를 함께 싣는다.
이슈 본문(사용자가 붙여 넣은 문장)은 항상 마크다운 코드 블록·인라인 코드로 감싸 인용한다
— build-review.mjs의 mdCode와 같은 이유로, 코드 스팬·펜스 안에서는 @멘션이 알림으로
파싱되지 않는다. 여러 줄 인용은 내용 안의 최장 backtick 연속보다 하나 더 긴 펜스로 감싼다
(mdBlock, mdCode와 같은 커먼마크 규칙). 펜스는 레이블과 반드시 다른 줄에 둔다 —
레이블: ${mdBlock(text)}처럼 한 줄에 이어 붙이면 펜스가 줄 중간에서 시작해 코드 블록으로
인식되지 않고, 사용자가 넣은 여러 줄 텍스트가 살아 있는 마크다운으로 그대로 렌더링되어
@멘션·이미지·헤딩이 실제로 해석되는 결함이었다(실측 — 보안 검토에서 발견). mdCode(한 줄
인라인 코드)에 줄바꿈이 섞여 들어와도 같은 문제가 난다 — <input> 타입 폼 필드(“고친다면”)는
웹 UI에서 한 줄만 받지만 API로는 방어적으로 줄바꿈이 올 수 있어, inlineCode가 스팬에 넣기
전에 항상 한 줄로 접는다. 인용문·인라인 값 모두 길이를 잘라내고(MAX_QUOTE_CHARS,
2,000자 + “…(생략)”), findings 목록도 20건까지만 나열한다(cappedLines) — 공개 이슈
코멘트가 유출 건수·인용 길이에 비례해 무한정 커지면 안 된다.
이슈 폼 파싱(parseIssueForm)은 GitHub이 폼 필드를 ### 라벨\n\n값으로 렌더링하고,
render: text 항목만 제출 시 ` ``` 펜스로 한 번 더 감싼다는 사실에 기댄다. 펜스 유무와
무관하게, 항목이 아예 빠져도(No response 포함) 견디게 짰다 — 이슈 양식은 사람이 웹
UI로 채우므로 필드 값에 무엇이 들어올지 스크립트가 통제할 수 없다. 본문은 API로 임의
길이를 보낼 수 있어 MAX_BODY_CHARS(65,536자)를 넘는 부분은 아예 읽지 않는다. "### 라벨"
경계를 가르는 정규식도 지연(lazy) 수량자 대신(/^### (.+?)\s$/m은 제목 줄에 공백이 아주
길게 이어지면 되짚기가 늘어 이차 시간이 들었다 — 실측) 탐욕적 문자 클래스(/^### ([^\n]\S)[
\t]*$/m`)를 쓴다 — 신뢰할 수 없는 이슈 본문이 그 모양을 그대로 만들 수 있는 서비스거부
경로였다.
kimchi-allow-rrn은 저장소 관리자가 자기 문서에 형식만 맞는 예시를 남길 때 쓰는 opt-out이다
(hooks/lib/pii.mjs). 신뢰할 수 없는 이슈 제출자가 신고 문장에 그 표시를 붙여 검사를 피하고,
실제 번호가 공개 봇 코멘트에 그대로 실리는 수단으로 쓰면 안 된다 — findResidentNumbers/
redactText에 ignoreAllowLine: true를 새로 받게 하고, triage-report.mjs는 이 옵션을
켜서 부른다. 같은 이유로 action-lint.mjs도 커밋 메시지·PR 제목·본문(fork PR 기여자가
통제하는 자유 텍스트)에는 이 옵션을 켠다 — 다만 파일 경로·내용은 그대로 둔다. 이 저장소
자신의 문서·규칙표가 형식만 맞는 예시에 그 표시를 정당하게 쓰는 관례라(dogfooding), 파일
스캔까지 끄면 저장소 스스로의 PR에서 오탐이 늘어난다.
태그가 아직 없는 커밋의 규칙 출처 링크
규칙 출처 링크의 ref는 package.json의 현재 버전에서 v<version> 태그를 그대로
가정한다. 릴리스 절차(AGENTS.md 참고)는 develop을 main에 병합한 뒤 v<version> 태그를
민다 — 두 동작이 원자적이지 않으므로, 병합 커밋이 main에 먼저 올라가고 태그는 뒤이어
푸시되는 아주 짧은 틈이 있다. 그 틈 사이에 이 워크플로가 돌면(예: 그 시점에 이슈가 열리면)
링크는 태그가 생기기 전까지 404다 — GitHub Action의 알려진 한계 목록과 같은 종류의 대가다.
Claude 봇 워크플로 (v0.20.0)
claude.yml(@claude 멘션)과 claude-code-review.yml(자동 코드 리뷰)은 둘 다 소유자 개인
구독의 OAuth 토큰(CLAUDE_CODE_OAUTH_TOKEN)으로 돈다. 이 저장소는 공개 저장소이므로 신뢰
게이트 없이는 누구나 이슈·PR 코멘트나 PR 하나로 소유자의 쿼터를 소모하는 세션을 띄울 수
있다 — 그래서 두 워크플로 모두 author_association이 OWNER/MEMBER/COLLABORATOR인
경우에만 돈다. pull_request_review_comment·pull_request_review는 코멘트/리뷰 자체의
author_association을 본다 — PR을 누가 열었는지가 아니라 지금 @claude를 부른 사람이
신뢰할 수 있는지가 기준이다.
리뷰 워크플로는 신뢰 게이트를 하나 더 둔다. pull_request.author_association으로 PR을 연
사람 자체를 거른다 — PR 코멘트로 부르는 것과 달리 리뷰는 PR이 열리거나 라벨이 붙는 것만으로
자동으로 돈다, 즉 사람이 그 순간 신뢰할 수 있는지 판단할 여지가 없다. fork PR은
head.repo.full_name != github.repository로 거른다 — 애초에 시크릿을 못 받으므로 돈다 해도
인증에서 실패하지만, 실패한 세션도 워크플로 로그를 남기고 큐를 차지하므로 미리 거른다.
dependabot·github-actions 같은 bot이 연 PR도 거른다 — 리뷰를 받을 대상이 아니고, 이런 PR의
author_association은 대개 NONE이라 위 조건에 이미 걸리지만 의도를 코드에 그대로 남겨 둔다.
리뷰 트리거는 PR을 열거나 밀 때마다(synchronize)가 아니라 ready_for_review(draft 해제)
아니면 claude-review 라벨을 붙였을 때만 돈다. 작업 중인 draft PR에 커밋할 때마다 리뷰
세션이 돌면 아직 리뷰받을 준비가 안 된 코드에 쿼터를 쓰는 셈이다 — 리뷰가 필요한 시점(끝났을
때, 또는 사람이 명시적으로 다시 봐 달라고 라벨을 붙였을 때)에만 돈다. claude-review 라벨은
.github/labels.yml에 정의해 두고 gh label create --force로 만든다(불변식과 같은 이유로
동기화 워크플로는 두지 않는다).
리뷰 워크플로는 --plugin-dir $를 claude_args에 얹어 이 저장소
자신(kimchi-claude 플러그인)을 함께 불러온다 — 리뷰 코멘트가 한국어로 나올 때도 항상 켜진
출력 스타일을 그대로 적용받게 하려는 것이다(불변식 3). 공식 code-review 플러그인
설정(plugin_marketplaces/plugins)은 그대로 두고 claude_args에만 이어 붙인다 — 두
메커니즘이 다르다: 플러그인 두 개는 plugins 입력에 쉼표로 나열하는 것이 아니라, 하나는
마켓플레이스 플러그인으로 다른 하나는 로컬 디렉터리 플러그인으로 각각 다른 입력을 쓴다.
두 워크플로 모두 같은 스레드/PR에서 세션이 겹치지 않게 concurrency 그룹을 이슈·PR
번호로 건다 — 겹치면 쿼터를 이중으로 쓰고 봇 코멘트도 뒤섞인다. @claude 쪽은
cancel-in-progress: false(같은 스레드에 두 번 불러도 앞 세션 답을 잃지 않고 순서대로
처리한다)이고, 리뷰 쪽은 cancel-in-progress: true(라벨을 여러 번 떼었다 붙이면 최신
diff만 리뷰하면 되고, 옛 세션을 계속 돌릴 이유가 없다). timeout-minutes: 30은 어느
쪽이든 한 세션이 통제 불능으로 길어지는 것을 막는 상한이다.
규칙 변경 영향 코멘트 (v0.22.0)
buildBody(rules, maxChars)(build-style.mjs)는 상한(MAX_CHARS, 6000자)을 넘기면 낮은
순위 행부터 잘라낸다 — rules/*.md에 행 하나를 보태는 PR이 그 한 줄만 늘리는 것이 아니라
예산 안에서 다른 행을 조용히 밀어낼 수 있다는 뜻이다. 지금까지는 리뷰어가 diff를 보고
이 부작용을 직접 알아채야 했다. .github/workflows/rule-impact.yml이 rules/**·
scripts/build-style.mjs·hooks/lib/**·output-styles/**를 건드리는 PR마다
scripts/rule-impact.mjs(오프라인)로 base→head 영향을 계산해 sticky 코멘트와 워크플로
요약에 남긴다.
설계 원칙은 다른 봇들과 같다. rule-impact.mjs는 로컬 파일만 읽고 표준 출력(또는 --out
파일)에 마크다운만 쓴다 — PR 코멘트를 올리는 것은 워크플로의 gh 셸 단계가 한다. base와
head는 각각 완전한 저장소 체크아웃이라, 규칙 파싱기·스타일 생성기·린트 엔진을 그 트리
자신의 파일에서 pathToFileURL로 동적으로 불러온다 — 규칙표만이 아니라 파서나 예산 배분
로직 자체를 바꾼 PR이어도 그 변경이 그대로 비교에 반영된다.
규칙 하나는 파일 이름 + 쓰지 말 것으로 식별한다(ruleKey). 이 열쇠로 추가·제거·칸 단위
변경을 가르고, 규칙이 실제로 스타일 본문에 담겼는지는 toRow가 내는 행의 앞부분
(| 원어 | 쓰지 말 것 | 쓸 것 | )이 본문에 부분 문자열로 있는지로 판정한다(styleContainsRule)
— 이유 칸만 줄여 적으므로(shortenWhy) 그 축약 규칙까지 복제할 필요가 없다. base·head
양쪽에 다 있는 규칙 가운데 본문 소속이 바뀐 것만 “본문에서 빠진 행”/”본문에 새로 들어간
행”으로 추려낸다(bodyTransitions) — 추가되거나 제거된 행 자체는 이미 다른 항목에서
보이므로 여기 다시 섞지 않는다. 칸 자체가 바뀐 행(diff.changed)도 이 둘에서 뗀다 —
행 텍스트가 달라지면 소속이 바뀐 원인이 분량 상한인지 칸 변경인지 구분이 안 되므로
“행 자체가 바뀌어 본문에서 빠진/들어간 행”으로 따로 보여준다. 이 구분이 핵심이다 —
어떤 PR도 의도한 적 없는 부작용만 따로 눈에 띄어야 한다. 규칙 diff가 비어 있어도
build-style.mjs 자체가 바뀌어(PREAMBLE 길이 등) 본문 길이나 MAX_CHARS가 달라졌으면
“영향 없음”으로 뭉개지 않고 요약 줄을 그대로 찍는다.
린트 결과 비교는 텍스트가 아니라 규칙 집합만 바꾼다. 처음에는 tests/fixtures/**와
head 트리가 추적하는 한국어 .md 문서 전체를 대상으로 삼았는데, 실측해 보니 구조적으로
0건→0건이었다 — 자료 파일은 dogfood 시험(불변식 8)에서 빠지려고 전부
<!-- kimchi-ignore-file ... -->로 시작하고, 이 표시는 lint()의 isIgnoredFile()도
그대로 걸러 버린다. 저장소가 추적하는 일반 문서는 애초에 dogfood를 통과한 깨끗한 글이라
규칙을 지우거나 넓혀도 대개 0건에서 0건으로 그대로다. 그래서 대상을 두 벌로 바꿨다.
tests/improvement.test.mjs의 readAnswer와 같은 규칙(맨 앞 주석 한 덩어리만 벗긴다)으로
이그노어 표시를 벗긴 뒤, “잡아야 할 표현” 자료(*-without-plugin.md, draft-before.md,
evals/experiments/**/answers/none-*.md·fluent-*.md — 플러그인이 없거나 다른 스타일로
받은 실제 답변)와 “오탐 신호” 자료(*-with-plugin.md·*-forced.md, draft-after.md,
kimchi-*.md·merged-*.md — 플러그인을 켠 상태로 받은 실제 답변)로 갈라 따로 보고한다.
전자에서 적발이 줄어드는 것은 규칙이 실제로 하던 일을 그만뒀다는 뜻이고, 후자에서 적발이
느는 것은 새 규칙이 지나치게 넓어 정상적인 문장까지 잡았다는 신호다 — 부호가 반대인 두
신호를 하나의 총건수로 합치면 서로 상쇄되어 안 보인다. 텍스트는 head 한 벌만 모아 그
위에 base 규칙과 head 규칙을 각각 lint()로 돌린다 — 텍스트까지 base·head로 나누면 규칙
변화와 문서 변화가 뒤섞여 어느 쪽 때문에 건수가 바뀌었는지 알 수 없다.
목록은 20개(MAX_LISTED), 린트 변화가 큰 규칙은 집합별로 10개(MAX_TOP_LINT_RULES)까지만
나열하고 나머지는 “외 N개 더”로 요약한다(hooks/lib/format.mjs의 formatGroupedList, 다른
봇들과 같은 원칙). 전체 마크다운은 60KB를 넘으면 뒤에서부터 잘라내고 마커는 항상 맨 앞에
남긴다 — sticky 코멘트가 규칙 수에 비례해 무한정 커지면 안 된다. 규칙 문구는
scripts/lib/markdown.mjs의 mdCode로 감싸 백틱이 섞여 있어도 코드 스팬이 깨지지 않는다.
base 또는 head 트리가 이 스크립트가 기대하는 모양과 다르면(모듈이 옮겨졌거나, export
이름이 바뀌었거나, loadRules·buildBody의 반환 모양이 바뀐 PR) 실패한다. loadTree는
assertTreeShape로 lint가 함수인지, rules가 배열인지, body가 문자열인지,
included·maxChars가 정수인지를 그 자리에서 확인해 던진다 — lintMod.lint처럼 이름이
바뀐 export는 동적 import 자체는 그냥 성공하고 값만 undefined가 되므로, 이 확인이
없으면 한참 뒤 countLintHits가 lint(...)를 호출할 때에야 TypeError로 터진다. 다만
이 확인이 모든 경우를 다 잡는다는 보장은 없으므로(예: rules 배열 안 규칙 객체 자체가
이상한 모양이면 diffRules·countLintHits 안에서 나중에 터질 수 있다), main()은
loadTree부터 buildMarkdown까지 전체를 하나의 try로 감싼다. 어디서 실패하든 결과는
같다 — 마커가 든 짧은 “트리를 불러오지 못했습니다” 본문을 내고 exit 0으로 끝낸다.
여기서 예외가 그대로 새 나가 워크플로 스텝이 실패하면, 코멘트를 갱신하는 뒷단계가 아예
안 돌아 이전 푸시의 낡은 sticky 코멘트가 최신인 것처럼 남는다. 실패를 감추는 것이 아니라
그 실패 자체를 코멘트 내용으로 보고하는 것이다.
워크플로 자체는 pull_request로만 돈다(pull_request_target은 쓰지 않는다) — 신뢰할 수
없는 head 브랜치의 코드를 쓰기 권한 토큰으로 실행하면 안 되기 때문이다. fork PR은 기본
토큰이 읽기 전용이라 코멘트 게시가 403으로 실패할 수 있으므로, 그 경우 워크플로 요약
($GITHUB_STEP_SUMMARY)에만 남기고 job은 실패시키지 않는다.
출처 링크 점검 (v0.23.0)
불변식 7은 바뀌는 값(공휴일 날짜, 보관 기간)을 하드코딩하지 말고 출처(law.go.kr, 국립국어원 등)를
가리키라고 한다. 그 출처 링크가 죽으면 스킬은 조용히 낡은 안내를 계속한다 — 링크가 살아 있는지는
누가 계속 확인해야 하는 문제다. .github/workflows/link-check.yml이 매주 이 확인을 대신한다.
앞선 두 봇과 같은 분업이다. scripts/extract-links.mjs는 skills/·rules/·docs/·README·
CONTRIBUTING·SECURITY에서 http(s) URL만 오프라인으로 모으고(불변식 10), scripts/link-report.mjs는
워크플로의 curl 결과를 받아 이슈 본문으로 바꾼다. 네트워크를 여는 것은 워크플로의 셸 단계뿐이다.
추출은 자리표시자 호스트(example.com·localhost·127.0.0.1)와 이 저장소 자기 자신을 가리키는
github.com/SungJun1217/kimchi-claude 링크를 뺀다 — 전자는 애초에 살아 있는지 물을 대상이 아니고,
후자는 저장소가 존재하는지와 같은 질문이라 따로 두드릴 이유가 없다. 꺾쇠·따옴표·백틱·별표·파이프,
한글 음절, CJK 문장부호 블록(U+3000-U+303F)과 일반 문장부호 블록(U+2000-U+206F, 엠대시·말줄임표·
가운뎃점·굽은 따옴표 포함)은 URL 문자로 받지 않는다 — [문서](https://example.com/c)와처럼 닫는
괄호 바로 뒤에 공백 없이 조사가 붙는 모양이 실제 문서에 있고(README.md 실측), 규칙표 인용에 실제
링크를 끼워 넣다가 백틱(`https://example.com/x`를 )·별표(**https://example.com/x**)·가운뎃점으로 이어
붙인 두 URL이 하나로 뭉치는 것도 실측했다 — 문장 끝에서 트리밍하는 것만으로는 늦다. 괄호((·))만
예외로 URL 문자로 받는다 — 위키백과류 URL(.../wiki/Foo_(bar))이 실제로 괄호를 포함해서, 못
들어오게 막으면 그 URL 자체가 잘린다. 대신 마크다운 링크의 짝 없는 닫는 괄호((https://example.com/c)와)는
괄호 짝 맞추기로 따로 벗겨낸다.
규칙표 이유 칸에는 URL을 넣지 않는다
rules/patterns.md·rules/register.md의 이유 칸은 훅 메시지·score-response.mjs 점수·
GitHub Action 코멘트·triage 코멘트에 매 호출마다 그대로 실린다(불변식 10 — 훅은 매 도구 호출마다
돈다). 처음에 온라인가나다 인용 옆에 URL을 직접 적어 넣었더니, 한 판정 결과에 URL이 세 개나 실려
매 호출의 바이트 값이 늘었다 — 인용 번호(온라인가나다 310695)만으로 충분한데 링크까지 매번
들고 다닐 이유가 없다. 그래서 이유 칸은 번호만 남기고, 각 규칙 파일 끝에 표 바깥의 ## 출처
절을 두어 번호 → URL 대응을 한 번만 적는다. parseTable은 |로 시작하는 줄만 표로 읽으므로
(hooks/lib/rules.mjs) 이 절은 규칙 개수에 전혀 영향을 주지 않고, extract-links.mjs는 문서
전체를 훑으므로 표 안이든 밖이든 그 URL을 그대로 찾아낸다 — 링크 점검은 그대로 되면서 매 호출
비용만 없앤다.
판정은 셋으로 가른다. “죽음”(dead) — 404·410(응답은 왔지만 “없다”고 말함)과 curl의 DNS·연결
실패(6·7, --retry --retry-connrefused 까지 다 실패). “연성 죽음”(softDead, 아래 절) — 응답은
200인데 본문이 강한 오류 페이지 고정 문구인 경우, 이슈를 열고/유지하는 판단에서는 dead와 같은
무게다. “확인 못 함”(unverified) — 그 밖의 모든 4xx·5xx·타임아웃(28)·그 밖의 curl 오류 코드(예: 47
너무 많은 리다이렉트)·더 넓은(오탐 여지가 있는) 오류 낱말은 전부 여기다 — 국내 공공기관 사이트가
봇을 자주 막는다는 사실을 반영한 것으로, 불변식 6과 같은 정신이다. 워크플로는 dead + suspect(=
softDead 건수)가 0보다 크면 마커(<!-- kimchi-claude-link-check -->)로 기존 이슈를 찾아 열거나
갱신하고, 0이면 열려 있던 이슈를 코멘트로 닫는다 — unverified만 있는 상태는 이슈를 닫는 이유가
된다(확정된 문제가 아니라서다).
연성 404(soft 404)
law.go.kr(국가법령정보센터)이 존재하지 않는 문서에 HTTP 200을 그대로 주면서 본문만 “없다”고
말한다(실측: law.go.kr/법령/존재하지않는법령명2024 → 200, <title>국가법령정보센터 |
오류페이지). 상태 코드만 보면 “정상”으로 잘못 판정한다. 그래서 워크플로는 GET 응답의 <title>
태그를 처음 64KB만 읽어 함께 기록하고(title 이 없거나 응답이 HTML이 아니면 null),
link-report.mjs가 두 층으로 나눠 본다.
- 강한 문구(
looksLikeSoftDeadTitle) — “오류\s페이지”, “페이지를\s찾을\s수\s없습니다”, “존재하지\s않는\s(페이지|법령)”. 정부·기관 오류 페이지에서만 실제로 쓰는 고정 문구로 좁혔다 — “존재하지 않는” 뒤에 “페이지”·”법령”이 와야만 걸리게 해서, “존재하지 않는 키를 참조합니다” 같은 일반 문서 제목과 안 겹치게 한다. 여기 걸리면softDead로, dead와 같은 무게로 이슈를 열고/유지한다 — 리뷰에서 지적된 대로, HTTP 200에 “확인 못 함”만 주면 이슈가 죽은 채로도 열리지도 유지되지도 않는 구멍이 있었다. - 넓은 낱말(
looksLikeUnverifiedTitle) — “not\s*found”,\berror\b(대소문자 무관). 이쪽은 반대로 실제 문서 제목과 흔히 겹친다(예: “Error Handling 가이드”, “404와 Not Found의 차이”) — 걸려도 “확인 못 함”에만 머물고 이슈를 열거나 유지하는 판단에는 관여하지 않는다.
남은 한계: korean.go.kr(etcDataView)의 오류 페이지는 이 문구를 따르지 않는다 — 실측해 보니
존재하지 않는 etc_seq에도 <title>국립국어원(정상 페이지와 같은 제목)을 그대로 주고, “서버
프로그램에서 예상치 못한 오류가 발생하여” 같은 오류 문구는 title이 아니라 본문에만 있다. title만
보는 이번 구현은 이 경우를 못 잡아 “확인 못 함(놓칠 수 있음)”에 머문다 — 본문 전체를 파싱해
판정하면 잡을 수 있지만 비용과 오탐 위험이 늘어난다(불변식 6). law.go.kr 사례만 softDead로
안정적으로 잡힌다.
title은 셸 문자열로 보간하지 않는다 — 응답 본문을 grep·sed로만 다뤄 파일 내용으로 만들고, 그
결과를 jq --arg로 JSON에 넣는다(triage-report.mjs가 이슈 본문을 다루는 것과 같은 원칙 — 응답
본문에 무엇이 들어 있든 명령으로 해석되지 않는다).
HEAD를 버리고 GET 하나로 통일
title을 보려면 본문이 필요하고, 본문을 받으려면 어차피 GET이 필요하다 — 그래서 HEAD로 먼저 아끼려던 이전 시도(0.23.0 개발 중 버전)를 버리고 GET 하나로 통일했다. 그 이전 시도는 law.go.kr이 HEAD에만 404를 주고 GET에는 200을 주는 것(정적 라우팅이 HEAD를 다른 경로로 처리하는 것으로 보인다 — 실측)까지 겹쳐, “403·405면 재시도”에서 “2xx·3xx가 아니면 재시도”로 조건을 넓혀야 했다. GET 하나로 합치면서 이 문제 자체가 없어졌다.
러너 자체의 네트워크 장애
링크가 하나라도 있는데 전부 DNS·연결 실패(curl 종료 코드 6·7)로 나오면, 출처가 다 죽은 게 아니라
GitHub Actions 러너가 이번 실행에서 바깥 네트워크에 못 나갔을 가능성이 높다. --retry-connrefused
(기본 --retry는 연결 자체가 거부된 경우를 재시도하지 않는다)를 더해도 남는 이 경우를
looksLikeRunnerOutage가 감지하면, 워크플로는 이슈를 열거나 갱신하지 않고 ::warning::만 남긴다 —
“전부 끊겼다”는 잘못된 신호로 이슈를 흔들면 안 된다.
links 라벨은 워크플로 안에서 만든다
라벨 생성(POST /repos/{owner}/{repo}/labels)은 GitHub REST 권한 모델에서 “issues” 쓰기 권한으로
충분하다(이슈·PR에 공통으로 붙는 메타데이터라 같은 권한을 쓴다) — 이 워크플로가 이미 갖고 있는
permissions: issues: write 를 늘릴 필요가 없다. 매 실행 앞에서 gh label create links --force를
불러 있으면 색·설명만 덮어쓰고 없으면 새로 만든다 — .github/labels.yml에 정의는 남겨 두되(다른
라벨과 같은 문서화 목적), 이 라벨만은 그 문서를 사람이 손으로 동기화하지 않아도 되게 한다.
라벨 단계의 성공 여부(steps.label.outputs.ok)는 뒤 두 단계가 그 라벨에 기대도 되는지를 가른다.
gh issue create --label links는 그 라벨이 저장소에 없으면(라벨 단계가 실패했거나, 성공 응답을
받은 직후 누가 라벨을 지웠으면) 이슈 생성 자체를 실패시킨다 — “라벨 하나 못 붙임”이 “이슈 자체를
못 만듦”으로 번지면 안 되므로, 라벨을 붙인 시도가 실패하면 라벨 없이 한 번 더 시도한다. 이슈를
찾는 단계도 마찬가지다 — 라벨 단계가 실패했으면 과거에 라벨 없이 만들어진 이슈가 있을 수 있어,
--label links 필터 없이 열린 이슈 전체에서 마커 문자열로 찾는다(라벨이 있을 때는 후보를 좁히는
최적화로만 쓴다).
판정 결과는 실행 요약에 남긴다 (v0.24.1)
문제가 없으면 이슈를 만들지 않으므로, 처음에는 링크마다 어떻게 판정했는지가 어디에도 남지 않았다.
첫 수동 실행에서 20개 모두 무사했지만 확인 못 함으로 둔 링크가 있었는지는 알 수 없었다. 이제
이슈 본문과 같은 보고서와 건수 한 줄을 $GITHUB_STEP_SUMMARY에도 쓴다.
PR 버전 검사·자동 bump (v0.24.0)
AGENTS.md의 규칙(“단위 커밋마다 package.json과 .claude-plugin/plugin.json 버전을 함께
올리고, 제목은 0.x.y — <합니다체 한 문장>“)은 지금까지 사람이 손으로 지켰다.
Dependabot PR은 애초에 이 규칙을 모른다 — GitHub Actions 버전만 올리고 이 저장소의
버전 파일은 건드리지 않는다. scripts/pr-version.mjs와 .github/workflows/pr-version.yml이
이 둘을 대신한다.
version-check는 PR 제목이 아니라 마지막 단위 커밋 제목을 본다
처음 요구사항은 “PR 제목 또는 마지막 단위 커밋 제목”이었다. 둘 중 하나만 고르기로
했다 — 이 저장소는 --no-ff 병합만 쓰고 절대 스쿼시하지 않으므로(AGENTS.md), 이력에
실제로 남는 것은 커밋 제목이지 PR 제목이 아니다. PR 제목은 GitHub UI에서 언제든
자유롭게 고칠 수 있는 텍스트라 커밋 이력과 어긋날 수 있고, 어긋나도 아무 경고가 없다.
반대로 마지막 커밋 제목이 형식에 맞으면 그 PR이 병합된 뒤 이력에 실제로 남는 문장도
형식에 맞는다는 뜻이다 — 확인하려는 것과 확인 대상이 같다.
“마지막 커밋”을 git log -1 HEAD로 읽으면 안 된다는 것은 실제로 시험을 만들다 찾은
버그였다. PR을 열어 두고 develop을 다시 받아 병합하면(merge: develop into
feature/x) HEAD가 병합 커밋이 되고, -1 HEAD는 그 병합 커밋의 제목(“merge: …“)을
그대로 돌려준다 — 형식에 맞을 리 없는 문장이라 정상적인 PR이 매번 걸린다. 그래서
subjectFromRange는 마지막 커밋 하나가 아니라 base..head 범위 전체를
git log --no-merges로 훑어 그 안에서 가장 최근인 병합이 아닌 커밋을 찾는다 — 병합
커밋이 몇 겹으로 쌓여 있어도 그 밑의 실제 단위 커밋에 닿는다.
이 함수는 { subject, error }를 돌려주고, 둘은 뜻이 다르다. 범위 안에 병합이 아닌
커밋이 하나도 없으면(예: 이미 병합된 브랜치를 실수로 다시 병합해 아무것도 새로
안 들여온 경우) { subject: null, error: null }이다 — checkPrVersion은 이것을
“확인할 제목이 없다”로 받아들여 제목 검사 자체를 건너뛰고 버전만 본다. 형식을
확인하고 싶어도 확인할 대상이 없는 것이지, 무언가 잘못됐다는 신호가 아니기
때문이다. 반면 git log 자체가 실패하면(sha를 못 찾음 — 얕은 클론, 잘못된 sha,
repoRoot가 저장소가 아님) { subject: null, error: "<메시지>" }다 — 처음 버전은
이 둘을 구분하지 않고 둘 다 null 하나로 뭉뚱그렸는데, 그러면 “확인 인프라 자체가
고장 났다”가 “형식은 확인했고 문제없다”로 둔갑한다(실측 — checkout을 fetch-depth:
1로 잘못 설정해 sha를 못 찾아도 검사가 조용히 통과했다). runCheck는 error가
있으면 ::warning::을 내고 ok를 강제로 false로 뒤집는다 — 설정이 깨졌는데
검사가 통과하는 것이 아무것도 안 잡는 것보다 나쁘다(불변식 5의 반대 방향 — 판정
자체를 못 했으면 “문제없음”이 아니라 “확인 못 함”이라고 말해야 한다).
develop → main 릴리스 PR(baseRef === "main")도 제목 검사를 건너뛴다 — 여러 단위
커밋과 병합 커밋이 뒤섞여 들어오는 흐름이라 “제목 하나”가 없다.
Dependabot PR은 이 결정만으로는 특별 취급이 안 끝난다 — 아래 “push가 워크플로를 다시 트리거하지 않는 문제”에서 별도로 건너뛴다.
pull_request_target의 보안 모델
dependabot-bump는 pull_request_target로 돈다 — 쓰기 토큰(contents: write)이
필요해서다(브랜치에 커밋을 push한다). 이 이벤트는 PR 헤드가 아니라 base(이 저장소의
develop)를 기준으로 워크플로 파일 자체를 읽는다는 점이 pull_request와 다르다 —
그래서 신뢰할 수 없는 PR 헤드가 워크플로 파일을 직접 고쳐 자기 코드를 쓰기 토큰으로
실행시킬 수 없다. 다만 그 안전장치는 워크플로 파일 자체에만 적용되지, 워크플로가
“PR 헤드에서 실행하는 스크립트”에는 적용되지 않는다 — 실수로 PR 헤드의 scripts/를
체크아웃해 그대로 실행하면 공격자가 그 스크립트 내용을 마음대로 바꿔 쓰기 토큰으로
임의 코드를 실행시킬 수 있다. 그래서 이 job은:
- base(이 저장소, develop)를
base/에, PR 헤드를head/에 각각 다른 경로로 체크아웃한다. head/는 끝까지 데이터로만 다룬다 —npm install도,node head/...로 그 안의 스크립트를 실행하는 일도 없다. 실행하는 스크립트는 오직base/scripts/pr-version.mjs하나이고, 이 스크립트가head/의 두 JSON 파일을 인자로 받아 값만 읽고 쓴다(bumpVersionInText는 정규식으로"version"필드 문자열만 바꾸지,head/의 어떤 코드도 실행하지 않는다).- 이 job이 도는 조건 자체도 좁힌다 — 작성자가
dependabot[bot]이고 PR 헤드가 이 저장소 안(head.repo.full_name == github.repository)일 때만 돈다. Dependabot이 만드는 브랜치는 항상 이 저장소 안에 있으므로(fork가 아니다) 이 조건은 “진짜 Dependabot PR인가”를 가르는 데 충분하다.
이 저장소가 신뢰하는 것은 딱 하나 — Dependabot이 낸 PR 제목뿐이다. 그 제목도
셸 문자열로 보간하지 않고 파일로 떠서(triage.yml과 같은 원칙) pr-version.mjs에
넘기고, 파서가 못 알아본 모양이면(parseDependabotTitle의 unknown) 안전한 일반
문장으로 물러난다 — 신뢰할 수 없는 제목 텍스트로 임의의 한국어 문장을 합성하게
두지 않는다.
$ 표현식을 run: 안에 직접 박아 넣지 않는다
첫 버전은 이 원칙을 커밋 제목에는 지켰지만(위 문단), 버전 문자열이나 계산된 커밋
제목처럼 “우리 스스로 계산한 값”에는 지키지 않았다 — --commit-subject
"$"처럼 GitHub Actions가 워크플로 YAML을 렌더링하는
단계에서 그 값을 셸 명령 텍스트에 그대로 이어 붙였다. 이건 명령 주입이다: 그 값이
실제로는 PR 헤드가 통제하는 파일(예: package.json의 version 필드, 혹은 PR
헤드 커밋 이력)에서 나온 것이라, 값 안에 백틱이나 $(...)나 따옴표가 섞여 있으면
셸이 그것을 명령으로 해석한다 — “우리가 계산했다”는 사실이 “신뢰할 수 있다”는 뜻은
아니다. 계산에 쓴 원재료(버전 문자열, 커밋 제목)가 이미 PR 헤드 것이기 때문이다.
고친 방법은 두 가지를 함께 쓴다.
- 셸에 넘길 값은 반드시
env:로 선언하고"$VAR"로만 참조한다.$표현식은 YAML을 실제 셸 스크립트로 렌더링하기 전에 텍스트를 그대로 바꿔치기하지만,env:로 선언한 환경 변수는 셸이 실행 시점에 그 자체를 한 값으로 넘겨받을 뿐 다시 셸 문법으로 해석하지 않는다 — 값 안에 무엇이 들었든 안전하다. - 애초에 셸을 거치지 않는다. 커밋 제목은
--commit-subject로 셸을 거쳐 넘기는 대신--base-sha/--head-sha만 넘기고,pr-version.mjs가execFileSync(인자를 배열로 넘겨 셸을 아예 안 띄운다)로 직접git log를 불러 스스로 읽는다.needs-bump판정도 처음에는node -e "...${BASE_PKG}..."처럼 셸 변수를 JS 소스 문자열에 그대로 붙여 넣었다 — 이건 명령 주입일 뿐 아니라 문자열 안에 따옴표가 섞이면 JS 구문 자체가 깨진다(이중 위험).needs-bump서브커맨드를 따로 만들어env로만 값을 받게 고쳤다.
bump는 head가 아니라 base에서 다시 계산한다
처음 버전은 bumpVersionFiles가 head 쪽 두 파일의 버전을 그대로 믿고 거기서 다음
버전을 계산했다 — 두 파일이 이미 같아야 한다는 조건만 걸었다. 문제는 head 쪽 파일이
“다시 손볼 이유가 있는” 바로 그 상태라는 점이다: 두 파일이 어긋나 있거나(누가 실수로
하나만 손댔다), head가 base보다 뒤처져 있으면(리베이스가 우리 bump 커밋을 지웠다)
head 자체가 신뢰할 수 없는 시작점이다. head를 그대로 믿으면 이미 틀어진 값 위에
+1을 더해 계속 틀어진 채로 두거나, 어느 파일이 “진짜” 버전인지 추측해야 한다.
그래서 bumpVersionFiles는(워크플로가 호출하는 경로에서는 언제나) baseVersion을
받아 head가 이미 base를 앞서 있고 두 파일이 같을 때만 아무것도 안 하고(skipped:
true), 그 밖의 모든 경우(어긋나 있거나, 못 앞섰거나)는 head를 참고하지 않고
base + 1을 정답으로 놓고 두 파일 모두 그 값으로 덮어쓴다. 무엇이 틀렸는지 진단하지
않는다 — base는 이미 검증된 값(직전 병합 시점의 develop)이니 거기서 다시 세는 편이
head의 어느 조각을 신뢰할지 고민하는 것보다 항상 안전하다. baseVersion을 생략하면
(워크플로 밖에서 손으로 부르는 경우) 예전처럼 head 두 파일이 같아야 한다고 엄격하게
요구한다 — 그 경로는 애초에 base라는 비교 대상이 없기 때문이다.
Dependabot 제목은 conventional-commits 접두사가 붙은 모양으로 온다
parseDependabotTitle의 첫 버전은 Bump ...(대문자 B로 시작)만 알았다 — Dependabot
문서·예시가 그렇게 생겼기 때문이다. 그런데 이 저장소 자신의 이력(git log
--author=dependabot)에는 ci: bump the actions group with 3 updates처럼 소문자
bump에 ci: 접두사가 붙은 제목이 실제로 있다 — dependabot.yml의
commit-message.prefix: "ci" 설정이 conventional-commits 스타일 접두사를 앞에
붙이기 때문이다. 대문자만 받으면 이 저장소가 실제로 만드는 모양을 정작 못 알아보는
꼴이라, [\w-]+(?:\([^)]*\))?!?:\s* 형태의 접두사(스코프·breaking 표시 포함)를
선택적으로 허용하고 Bump/bump 둘 다 받는다.
그룹 제목은 관형격을 두 번 겹치지 않는다
그룹 업데이트(“actions 그룹에 3개가 걸렸다”)의 문장은 처음에 “GitHub Actions의 actions 그룹을 묶어 3건 올렸습니다”였다가 “GitHub Actions의 actions 그룹의 액션 3개를 올렸습니다”로 한 번 고쳤는데, 뒤엣것도 “의”가 “GitHub Actions의”와 “actions 그룹의”로 두 번 겹친다 — 한국어에서 관형격 조사 “의”가 이어 붙으면 사이 관계가 헷갈린다(“GitHub Actions의 [actions 그룹의 액션]”인지 “[GitHub Actions의 actions] 그룹의 액션”인지). 지금은 “actions 그룹의 GitHub Actions 3개 버전을 올렸습니다”로 쓴다 — “GitHub Actions”를 그룹 이름 뒤로 옮겨 “무엇을 올렸는지”를 밝히는 말로 바꾸면 “의”가 한 번만 남는다.
Dependabot이 리베이스를 멈추는 대가
Dependabot은 자기 PR 브랜치에 다른 사람(또는 봇)이 커밋을 push하면, 그 이후로는
그 PR을 더 이상 자동으로 리베이스하지 않는다(GitHub 문서에 명시된 동작). 이 워크플로가
정확히 그 일을 한다 — github-actions[bot] 이름으로 버전 bump 커밋을 push한다.
즉 이 PR은 그 순간부터 Dependabot의 자동 리베이스 대상에서 빠진다. base(develop)가
그 뒤로도 계속 앞서가면 이 PR은 낡아 충돌할 수 있는데, 이때 @dependabot rebase는
쓸 수 없다 — 방금 말한 바로 그 이유(다른 사람이 커밋을 얹었다)로 Dependabot이
거절한다. @dependabot recreate를 대신 써야 한다(사람이 직접 달든, 아래
nudge-stale-dependabot-prs job이 자동으로 달든 — 다음 절 참고). 자동 병합·자동
갱신을 완전히 유지하는 방법(예: 봇 커밋 없이 라벨만 다는 등)도 있었지만, 그러면
애초에 버전을 자동으로 못 올린다 — “버전이 항상 맞다”와 “리베이스가 항상 자동이다”는
동시에 가질 수 없어 앞의 것을 골랐다.
봇 토큰으로 실제 필수 검사를 받는다
첫 버전은 GITHUB_TOKEN으로 push했다. GITHUB_TOKEN으로 한 push는 새 workflow
run을 만들지 않는다(GitHub이 재귀 실행을 막으려고 의도한 동작) — dependabot-bump가
push한 커밋에는 그래서 아무 체크런도 안 붙었다. 그 자리를 gh workflow run
test.yml --ref <branch> 디스패치로 메우려 했으나(test.yml의 workflow_dispatch:),
더미 PR #15(2026-09-27, 이미 닫음)로 직접 실측하니 이 전제 자체가 틀렸다.
디스패치로 도는 체크런은 그 커밋 SHA에 실제로 붙긴 했지만(test (22)/test
(24)/test-macos가 그 SHA를 대상으로 성공했다), PR의 statusCheckRollup에는
전혀 잡히지 않았고 gh pr checks --required는 “No required checks reported”를
냈다 — mergeStateStatus는 계속 BLOCKED로 남았다. 브랜치 보호는 체크런이 SHA에
붙어 있는 것만으로는 부족하고, 그 체크런이 그 PR을 향한 pull_request(또는
check_suite) 이벤트에서 난 것이어야 PR의 필수 검사 목록에 실제로 반영된다 —
workflow_dispatch로 별도로 돌린 실행은 같은 SHA를 대상으로 해도 다른 이벤트라
PR과 묶이지 않는다.
그래서 디스패치 대신 push 자체가 pull_request의 synchronize를 내게 만든다.
GITHUB_TOKEN이 아니라 secrets.KIMCHI_BOT_TOKEN(이 저장소 하나에만 권한을 준
fine-grained PAT — 아래 “설정” 참고)으로 push하면, 그 push는 재귀 실행 방지 예외에
걸리지 않는 진짜(사람 계정의) push라 GitHub이 정상적으로 pull_request:
synchronize를 낸다. test.yml도 pr-version.yml의 version-check도 그 이벤트로
다시 돌아 그 커밋 SHA에 필수 검사로 붙는다 — 별도의 디스패치 단계 자체가 필요 없어져
gh workflow run test.yml --ref <branch> 단계와 그 단계가 필요로 했던 actions:
write 권한을 지웠다. test.yml의 workflow_dispatch: 트리거는 그대로 남겨
뒀다(수동으로 아무 브랜치나 다시 돌려 보고 싶을 때 쓰는 것으로, 남겨 둬도 해가
없다) — 다만 그 트리거를 이 bump 흐름이 쓴다는 낡은 설명은 지웠다.
시크릿이 비어 있으면(PAT를 아직 등록하지 않은 저장소를 이 워크플로 그대로 fork해
쓰는 경우 등) push를 시도하지 않는다 — “커밋하고 push한다” 단계 첫머리에서
::error::를 내고 exit 1로 job 자체를 실패시킨다. 조용히 bump를 건너뛰고
초록불로 끝나는 편이 더 나빠 보인다 — Dependabot PR의 버전이 하나도 안 올라간 채
“이 job은 문제없다”는 신호를 내면, 그 PR은 version-check의 스킵 조건(head 커밋이
아직 dependabot[bot] 본인 것)에도 걸려 있어 두 job 모두 초록인데 버전은 하나도
안 맞는 상태로 남는다 — 눈에 아예 안 띄는 실패다.
토큰을 두 체크아웃(base/, head/) 어디에도 심지 않는다 — 둘 다
persist-credentials: false를 준다. 처음 버전은 이걸 빠뜨렸고, 로컬 재현으로
실제 버그를 찾았다: actions/checkout은 기본값(persist-credentials: true)이면
체크아웃 직후 GITHUB_TOKEN을 그 작업 디렉터리의 로컬 git 설정에
http.https://github.com/.extraheader로 심어 둔다. 이 extraheader는 git이 그
호스트로 보내는 모든 요청에 항상 실리고, 나중에 git remote set-url로 URL에
다른 자격 증명(예: https://x-access-token:$BOT_TOKEN@...)을 얹어도 그 URL
자격 증명보다 우선한다 — git은 403을 만나도 URL 쪽으로 넘어가 재시도하지 않는다.
그래서 처음 버전(git remote set-url 방식)은 실제로는 여전히 GITHUB_TOKEN으로
push를 시도했다 — 읽기 전용 토큰이라 403이 났고, 설령 쓰기 권한을 줬더라도
GITHUB_TOKEN이 한 push는 이 절 첫머리에서 설명한 재귀 실행 방지에 걸려
synchronize를 못 냈을 것이다. 어느 쪽으로도 이 워크플로의 목적(진짜
synchronize를 내는 것) 자체가 성립하지 않았다.
고친 방법은 두 체크아웃에 persist-credentials: false를 줘 애초에 GITHUB_TOKEN을
로컬 설정에 안 남기고, push 명령 하나에만 git -c http.https://github.com/
.extraheader="AUTHORIZATION: basic <값>"로 자격 증명을 실어 보낸다 — 이 -c는
그 한 번의 git 프로세스에만 적용되고 .git/config 파일에는 전혀 안 남는다(remote
URL에 자격 증명을 넣는 방식은 그 자체로 파일에 시크릿을 평문으로 남긴다는 점에서도
더 나쁘다). <값>은 x-access-token:$BOT_TOKEN을 base64로 인코딩한
파생값이다(git의 HTTP Basic 인증 규약) — GitHub Actions의 자동 마스킹은 시크릿
원문 문자열만 가리므로, 이 파생값은 ::add-mask::로 따로 가린다. 시크릿은 다른
값과 같은 원칙대로 env:로만 받아 "$BOT_TOKEN"으로 참조하지, $을 run: 문자열에 직접 잇지 않는다.
설정 — KIMCHI_BOT_TOKEN
유지자가 한 번 준비해 둬야 하는 것은 다음과 같다.
- GitHub 개인 계정(이 저장소에 쓰기 권한이 있는 계정) 설정에서 fine-grained personal access token을 새로 만든다.
- Repository access를 “Only select repositories”로 좁히고
SungJun1217/kimchi-claude하나만 선택한다 — 다른 저장소에는 이 토큰이 아무 권한도 없다. - Permissions는 딱 두 가지만 준다 — Contents: Read and write(브랜치에 커밋을 push하는 데 필요하다), Pull requests: Read and write(재생성 요청 코멘트를 사람 계정으로 달려면 필요하다 — 아래 “재생성 요청” 참고). 그 밖의 권한(Actions, Issues, Workflows 등)은 필요 없다.
- 만료(expiration)를 설정한다 — 무기한 토큰은 만들지 않는다. 만료가 오면 재발급하고 아래 5번을 다시 한다.
- 저장소 Settings → Secrets and variables → Actions → New repository secret에
이름
KIMCHI_BOT_TOKEN, 값에 방금 만든 토큰을 넣는다. 이걸로 끝이다 — 아래 절에서 설명하듯, 이 저장소 구성에서는 Dependabot 시크릿에 따로 등록할 필요가 없다(오히려 등록하면 노출 범위만 넓어진다).
pull_request_target과 Dependabot 액터 — 저장소 시크릿은 그대로 온다
GitHub 문서가 명시하는 규칙은 이렇다 — Dependabot이 직접 낸 이벤트
(pull_request, push 등, Dependabot이 자기 브랜치에 커밋하는 그 이벤트 자체)에서는
GITHUB_TOKEN이 읽기 전용으로 내려오고 저장소 Actions 시크릿은 아예 전달되지
않는다(대신 별도 저장소인 Dependabot 시크릿만 전달된다) — 시크릿이 Dependabot이
통제하는 의존성 매니페스트를 거쳐 유출되는 것을 막으려는 조치다. 이 제한은
pull_request_target에는 적용되지 않는다 — base 브랜치 자체가 Dependabot이
만든 것이 아닌 한. dependabot-bump의 base는 develop이고, develop은 사람이
만든 브랜치라 Dependabot이 만든 브랜치가 아니다. 그러므로 pull_request_target으로
도는 이 job에는 저장소 Actions 시크릿(KIMCHI_BOT_TOKEN 포함)이 정상적으로
전달된다 — github.token을 지금까지 문제없이 써 온 이력도 이 규칙과 일치한다.
이 규칙을 안다고 해서 Dependabot 시크릿에도 같은 값을 등록해 둘 이유는 없다 —
오히려 하지 않는 편이 낫다. Dependabot 시크릿은 Dependabot이 직접 트리거하는
이벤트(pull_request 등 — test.yml이 Dependabot PR마다 npm test를 돌리는
그 이벤트가 정확히 여기 해당한다)에도 전달된다. 그 이벤트들은 이 저장소 안 어느
워크플로도 KIMCHI_BOT_TOKEN을 참조하지 않으니 지금 당장 새는 경로는 없지만,
쓰지 않는 곳에까지 시크릿의 노출 범위를 넓힐 이유가 없다(최소 권한 원칙) — 필요한
pull_request_target 쪽에서 이미 정상적으로 오므로, Actions 시크릿 하나로 충분하다.
version-check가 Dependabot PR을 건너뛰는 조건을 좁혔다
이전 버전은 “PR 작성자가 dependabot[bot]이면 통째로 건너뛴다”였다. 이유는
간단했다 — dependabot-bump의 push가 GITHUB_TOKEN으로 하는 push라 애초에
새 synchronize 이벤트 자체를 안 만들었기 때문이다. version-check는
Dependabot PR에서 한 번 빨간불이 켜지면 그 이후 누가 그 브랜치에 직접 push하지
않는 한 영영 다시 안 돌았다 — “버전이 맞다”는 사실을 검사가 확인해 준 적이 없는데도
PR 목록에는 계속 실패로 남았다.
dependabot-bump가 KIMCHI_BOT_TOKEN으로 push하도록 바꾸면서 이 전제가 사라졌다
— push가 진짜 synchronize를 내므로, bump 커밋이 올라간 뒤에는 version-check가
자연스럽게 다시 돈다. 그런데 “PR 작성자”는 병합될 때까지 항상 dependabot[bot]로
고정돼 있어, 그 값만으로는 “이미 bump된 뒤의 재실행”과 “아직 bump되지 않은 최초
실행”을 가를 수 없다 — 작성자 하나만 보면 여전히 통째로 건너뛰거나 통째로 안
건너뛰거나 둘 중 하나만 고를 수 있는데, 필요한 것은 그 사이의 상태 전환이다.
그래서 판정 기준을 head 커밋 하나의 작성자로 옮겼다. bump 전에는 head 커밋이
Dependabot이 만든 커밋 그대로라 작성자가 dependabot[bot]이고, dependabot-bump가
push한 뒤에는 head 커밋이 그 bump 커밋(작성자 github-actions[bot])으로 바뀐다.
version-check는 체크아웃 직후 git log -1 --format='%an' <head sha>로 이 값을
직접 읽는다 — 이미 fetch-depth: 0으로 전체 이력을 받아 둔 그 자리에서 읽으므로
추가 API 호출이 없다. PR 작성자가 dependabot[bot]이면서 동시에 head 커밋
작성자도 아직 dependabot[bot]일 때만 건너뛰고, 그 밖의 모든 경우(작성자가 애초에
Dependabot이 아니거나, bump 커밋이 이미 올라갔거나)는 평소대로 전체를 확인한다.
무한 루프 걱정은 없다 — dependabot-bump의 “다시 올릴 것이 있는지 본다” 단계가
이미 멱등성을 보장한다(head가 base를 이미 앞서 있고 두 파일이 같으면 needs-bump가
false). bump 커밋이 올라간 뒤 그 push가 낸 synchronize에서 dependabot-bump가
다시 돌아도, 이번엔 손볼 것이 없다고 판단해 아무것도 push하지 않는다 — 그래서
version-check가 다시 도는 것도 딱 한 번뿐이다.
Dependabot PR의 버전이 실제로 맞는지는 여전히 전적으로 dependabot-bump의 몫이다
— 그 job은 시크릿이 없거나 bumpVersionFiles가 예외를 던지면(두 파일 버전이 심하게
어긋나 있는 등) job 자체가 빨간 X로 실패한다. 확인하는 job과 고치는 job을 하나로
합쳐 “확인했지만 못 고쳤다”는 상태를 만들지 않는다는 원칙은 그대로다 — 다만 이제는
고친 뒤에 확인하는 job도 실제로 한 번 더 돌아 그 사실을 스스로 검증한다.
develop이 앞서가면 열려 있는 Dependabot PR을 깨워야 한다
dependabot-bump는 PR 하나가 열리거나 새 커밋을 받을 때(opened/synchronize/
reopened)만 돈다. 그 사이에 다른 PR이 먼저 병합돼 develop의 버전이 더
올라가면, 이미 bump까지 마친 Dependabot PR이라도 그 시점부터는 develop보다
뒤처진다 — 아무도 그 PR에 새 이벤트를 안 주므로 아무 job도 다시 돌지 않는다. 두
Dependabot PR이 동시에 열려 있어도 같은 일이 난다: 먼저 병합된 쪽의 bump 커밋이
develop의 새 버전이 되고, 나중 것은 그 직전 버전에 머문 채로 남는다.
이때 그 자리에서 곧바로 다시 bump하면 안 된다 — 병합할 때 두 브랜치가 같은 버전
줄을 서로 다른 값으로 고친 채라 반드시 충돌한다(둘 다 package.json의 같은 줄을
바꿨으니). 진짜 필요한 것은 그 PR을 develop 위로 다시 얹는 것이다.
@dependabot rebase는 못 쓴다. 이 PR에는 이미 dependabot-bump가 만든
github-actions[bot] 커밋이 있다 — Dependabot은 “Dependabot이 아닌 다른 사람(또는
봇)이 고친 PR”이라고 보고 rebase 요청을 거절한다(공식 문서: “we’ll comment to
explain why we can’t rebase”). 대신 @dependabot recreate를 쓴다 — 이건 그
브랜치를 처음부터 다시 만들어 같은 PR에 force-push한다(편집 여부와 무관하게
항상 받아들여진다). 그 force-push가 synchronize를 일으켜 dependabot-bump가
새 base에서 다시 정확한 버전을 계산한다.
nudge-stale-dependabot-prs job이 이 자리를 채운다. develop에 push될 때마다
(다른 단위가 병합될 때마다) 돌아서, dependabot이 develop을 base로 연 열린 PR을
모두 훑는다. 각 PR 헤드의 package.json 버전을(GitHub Contents API로 — PR 헤드를
체크아웃하거나 그 안의 무엇도 실행하지 않는다, pull_request_target 절과 같은
원칙) develop의 새 버전과 비교해(isStaleAgainstDevelop, needsBump와 판정
자체는 같지만 “누가 재생성을 요청할지”와 “이 저장소가 직접 bump할지”는 다른
결정이라 이름을 나눴다) 뒤처진 PR에 @dependabot recreate 코멘트를 단다.
이 명령을 Dependabot이 실제로 받아 처리하는지(공식 문서는 사람이 단 코멘트만
예로 들고, github-actions[bot]처럼 다른 봇이 단 코멘트도 인식하는지는 명시하지
않는다)는 이 저장소 안에서 확인할 방법이 없다 — 실제 Dependabot PR로 검증이
필요한 부분으로 남겨 둔다. 그래서 이 job은 코멘트를 secrets.KIMCHI_BOT_TOKEN이
있으면 그 값으로 단다 — 실제 쓰기 권한이 있는 사람 계정의 코멘트라 github-actions
[bot]보다 인식될 가능성이 높다고 본다. 시크릿이 없으면 github.token으로
물러난다(이 job은 pull_request_target이 아니라 push로 도는 job이라 애초에
액터 제한과 무관하고, PR 헤드를 체크아웃하지도 않으므로 github.token을 쓰는 데
dependabot-bump와 같은 우려는 없다). 어느 토큰으로 달든 코멘트 본문에 사람이
읽을 설명도 함께 적는다 — Dependabot이 명령을 무시하더라도 사람이 같은 명령을
직접 달 수 있게, 코멘트 자체가 “무엇을 해야 하는지”는 항상 전달한다.
같은 develop sha에 대해 같은 PR에 코멘트를 두 번 달지 않는다 — 코멘트 본문에
<!-- kimchi-claude-recreate-nudge:<develop sha> --> 마커를 심어 두고, 새로 달기
전에 그 sha가 이미 마커에 있는 코멘트가 있는지 먼저 본다(gh api --paginate가
코멘트 30개마다 한 번씩 “length” 결과를 따로 내므로, 그 줄들을 awk로 더해 한
숫자로 합친 뒤에 본다 — 안 더하면 코멘트가 31개를 넘는 순간부터 “0”과 절대 같아지지
않아 판정 자체가 무의미해진다). develop이 그 뒤로 또 바뀌면 새 sha라 새 마커로
다시 하나 단다 — 매번 push마다 같은 PR에 코멘트가 쌓이는 스팸을 막으면서도, “지금
이 develop 상태 기준으로는 아직 안 밀렸다”는 사실은 매번 새로 알린다.
이 job의 run: 블록은(다른 모든 run: 블록처럼) 기본 셸 옵션(-e -o pipefail)으로
돈다 — PR 하나를 확인하다 API 호출이 일시적으로 실패하면(네트워크 등) 가드 없이는
그 실패가 곧바로 전체 스크립트를 끝내 남은 PR을 아예 안 본다. is-stale 판정과
기존 코멘트 조회 둘 다 || { ::warning:: 을 남기고 continue; }로 감싸 그 PR
하나만 건너뛰고 루프는 계속 돌게 한다.
훅 지연 시간 비교 (v0.25.0)
불변식 10은 훅이 도구 호출마다 돈다고, 예산은 호출당 ~100ms(그중 ~80ms는 Node 시작
시간)라고 못박는다. 지금까지 이 예산을 지키는 장치는 tests/*.test.mjs의 느슨한
타임아웃뿐이었다 — 타임아웃은 몇 배씩 벌어져야 겨우 걸리므로, 훅이 한 번에 몇 ms씩
서서히 느려지는 회귀(규칙 하나가 늘 때마다, 의존 체인이 한 단계 늘 때마다)는 그
사이를 그대로 통과한다. .github/workflows/hook-latency.yml이 hooks/**·rules/**
(규칙은 훅이 부팅 때 읽으므로)·이 봇 자신(scripts/hook-latency.mjs, 워크플로
파일)을 건드리는 PR마다 scripts/hook-latency.mjs(오프라인)로 base→head 지연
시간을 재서 sticky 코멘트와 워크플로 요약에 남긴다. package.json은 일부러
경로 목록에서 뺐다 — 이 저장소의 모든 단위 커밋이 버전을 올리므로(AGENTS.md)
포함하면 훅과 전혀 무관한 PR마다 base와 head가 사실상 같은 트리인 A/A 비교가
돌아 신호 없는 워크플로 실행만 쌓인다.
다른 봇들과 원칙이 같다 — base/head는 각각 완전한 저장소 체크아웃이고, 이 스크립트는
node <디렉터리>/hooks/guard.mjs·session-language.mjs를 그 트리 자신의 파일로
실제로 스폰한다. rule-impact.mjs가 규칙 파싱기·스타일 생성기를 동적 import로
불러오는 것과 달리 여기서는 프로세스를 통째로 새로 띄운다 — 잴 대상이 함수 하나의
반환값이 아니라 “Node 프로세스 하나가 stdin을 받아 stdout을 내고 죽는” 벽시계
시간(entrypoint.mjs의 안전 타이머까지 포함한 실제 경로) 그 자체이기 때문이다.
대표 페이로드와 체인 갈래
guard.mjs 안에는 실행 비용이 다른 갈래가 최소 셋 있고(toneDynamicModeEnabled
주석의 실측 — 기본 설정 PreToolUse는 PII만 보고 빠져나가 규칙 체인을 아예 안
불러온다), 그 갈래를 하나씩 짚는 대표 페이로드를 골랐다.
- PreToolUse
Bash일반 명령 — 기본 설정(둘 다 꺼짐)의 빠른 경로. PII 검사만 하고 규칙 체인을 안 불러온다. - PreToolUse
Bashgit commit(한국어 메시지,KIMCHI_BLOCK=1) — 차단 모드가 실제로 규칙 체인을 태우는 경로. - PreToolUse
Write5KB/200KB 한국어 문서(KIMCHI_AUTOFIX=1) — 자동 교정 체인(rules → lint → particle → segment) 전체가 도는 경로. 문서 크기를 둘로 갈라 둔 것은 상수 비용(Node 시작, 규칙 로딩)과 입력 길이에 비례하는 비용을 구분해서 보기 위해서다 — 5KB에서만 재고 끝내면 200KB 문서에서만 나타나는 이차 이상의 회귀(예: 검사 제외 구간 계산이 문서 길이에 비선형으로 늘어나는 버그)를 놓친다. - PostToolUse
Edit(작은new_string) — 자동 교정이 아니라 경고만 내는warnAboutTone경로(PostToolUse는 항상 이 갈래를 탄다, hooks.json 참고). - PostToolUse
Write200KB 대용량 문서(기본 모드,KIMCHI_*전부 꺼짐) —checkTone은event !== "PreToolUse"면KIMCHI_AUTOFIX·KIMCHI_BLOCK여부와 무관하게 항상 돈다. 즉 이것이 아무 설정도 켜지 않은 기본 사용자가 대용량 한국어 문서를 쓸 때마다 실제로 내는 비용이다. 위 200KB 페이로드는 자동 교정 모드에서만 재므로, 이 페이로드가 없으면 “설치만 하고 아무것도 안 켠 사용자”의 대용량 경로는 5KB짜리로만(과소하게) 잡힌다. - SessionStart(영어 커밋 이력 저장소) — 아예 다른 훅(
session-language.mjs)의 경로.repo-language.mjs가git log를 실행하는 비용까지 잡는다.
각 문서 페이로드는 rules/terms.md에 실제로 있는 항목(“블라스트 레디우스”, “루즈
커플링”)을 반복해 채운다 — 아무것도 안 걸리는 텍스트로는 체인의 일부(빠른 조기
탈출)만 재고, 실제로 교정을 적용하는 비용은 재지 못한다.
ABBA 순서와 두 문턱
base를 전부 돌리고 head를 나중에 돌리면 그 사이 러너 자체의 드리프트(CPU
스로틀링, 파일시스템 캐시 상태 변화)가 델타에 그대로 새어 든다. 처음에는 반복마다
base 스폰 하나, head 스폰 하나 순서로 번갈아(ABAB…) 돌렸는데, 이 방식은 선형
드리프트는 상쇄해도 “먼저 도는 쪽이 유리해지는” 위치 효과(예: 먼저 스폰된 쪽이
파일시스템 캐시를 데워 둔다)는 항상 base에만(또는 항상 head에만) 고정해서 싣는다.
그래서 4회를 한 묶음으로 순서 자체를 뒤집는 고전적인 ABBA 균형화로 바꿨다
(orderForIteration) — base,head,head,base를 반복하면 두 트리가 “먼저 도는 쪽”도
절반씩 겪는다. 앞쪽 DEFAULT_WARMUP(2)회는 기록에서 버린다 — 첫 스폰 몇 번은
파일시스템 캐시가 덜 데워져 있어 이후 반복보다 눈에 띄게 느리다.
회귀는 비율과 절대값 두 문턱을 모두 넘을 때만 표시한다(isRegression,
REGRESSION_RATIO 20%, REGRESSION_FLOOR_MS 10ms). 비율만 보면 원래 몇 ms인
빠른 경로(예: PII만 보는 PreToolUse Bash)에서 1ms → 1.3ms 같은 잡음도 “30% 증가”로
잡힌다. 절대값만 보면 이미 느린 경로(200KB 문서 자동 교정)에서 10% 늘어난 실제
회귀를 놓친다. 둘을 and로 묶어야 잡음과 실제 회귀가 갈린다 — rule-impact.mjs가
채점 대상을 “잡아야 할 표현”과 “오탐 신호”로 나눠 반대 부호의 신호를 따로 보는 것과
같은 이유로, 여기서도 한 문턱만으로는 서로 다른 두 실패 모드(잡음을 회귀로 오판,
실제 회귀를 잡음으로 묵인) 중 하나를 반드시 저지른다.
base 트리에 스크립트 자체가 없으면(새로 생긴 훅) 그 트리는 건너뛰고 “base에 없음”으로 표시한다 — 비교 불가를 회귀로 세지 않는다.
재확인 — 문턱을 넘은 페이로드만 다시 잰다
DEFAULT_RUNS(8) 남짓한 표본으로 base=head인 트리 138쌍을 비교했을 때 오탐은
0건이었지만, 그 과정에서 공유 인프라의 이웃 부하가 심한 구간에는 개별 회차 편차가
±30%까지 벌어지는 것을 실측했다 — 표본이 적으면 딱 한 번의 측정에서 그 잡음이
20%/10ms 문턱을 넘을 수 있다는 뜻이다. 그래서 1차 측정에서 회귀로 보인 페이로드만
CONFIRM_RUNS(20)회로 다시 재고(confirmIfRegression), 그때도 문턱을 넘을 때만
최종 회귀로 남긴다 — 재현되지 않으면 “정상(재확인에서 사라짐)”으로 되돌린다.
문턱을 넘지 않은 페이로드는 애초에 다시 재지 않는다 — 추가 비용은 회귀로 보이는
것에만 든다. 타임아웃으로 이미 확정된 결과(아래 참고)는 재확인 대상이 아니다 —
무한 루프는 통계로 판단할 문제가 아니라 이미 일어난 사실이다.
스폰이 안 끝나면 그 자체가 회귀다
이 봇이 잡아야 할 회귀 가운데 가장 심각한 것은 정도의 문제(몇 ms 느려짐)가 아니라
종류의 문제(무한 루프, 병리적 정규식 백트래킹으로 훅이 아예 안 끝남)다. 이전
설계는 execFileSync에 타임아웃이 없어, 그런 회귀를 만나면 이 스크립트 자신이
영원히 멈춰(GitHub Actions job 기본 상한 6시간까지) 코멘트도 못 남기고 조용히
실패했다 — 잡아야 할 바로 그 대상 앞에서 이 봇 자신이 멈추는 구조적 결함이었다.
지금은 spawnOnce가 execFileSync에 timeout(SPAWN_TIMEOUT_MS, 15초 — 훅
제한 시간 5초의 3배, 정상적으로 느린 실행까지 오탐으로 죽이지 않을 만큼 여유
있게 잡았다)을 준다. 시간이 지나면 execFileSync가 killSignal(기본 SIGTERM)로
죽이고 err.signal이 채워진 채 던지는데, 이 실패는 예외로 전파하지 않고
{timedOut: true}로 알린다. toResult는 head 쪽에 타임아웃이 하나라도 있으면
잠정적으로 회귀로 표시한다(통계 문턱과 무관하게). base 쪽 타임아웃은 비교 기준
자체가 미덥지 않다는 뜻으로 따로 표시하되 head 회귀로 강제하지는 않는다. job에도
timeout-minutes: 10을 둔다 — 스폰 하나의 타임아웃이 대부분의 경우를 막아도,
다른 원인으로 job 전체가 예상 밖으로 오래 걸리는 경우까지 GitHub Actions
기본 상한에 맡기지 않는다.
head 타임아웃도 재확인한다 — 거짓 “훅 제한 시간” 경보가 가장 비싸다
SPAWN_TIMEOUT_MS(15초)가 넉넉해 보여도, 실제 A/A 재현(develop을 base로, 손대지
않은 작업 트리를 head로 돌린 시험)에서 부하가 심한 순간에 정상 페이로드가 10회
중 2회 15초를 넘긴 사례를 실측했다 — 단독으로 다시 재면 매번 150ms 남짓이었다.
즉 head 타임아웃 한 번은 진짜 무한 루프의 증거가 아니라 공유 인프라의 이웃
부하로도 일어난다. 그런데 이 신호는 이 봇이 낼 수 있는 코멘트 가운데 신뢰를
가장 크게 깎는 것이다 — “훅이 멈췄을 수 있습니다”라는 경보를 걸어 놓고 그것이
거짓이면, 다음번 진짜 무한 루프 경보도 사람들이 무시하게 된다. 통계 회귀에 이미
confirmIfRegression(문턱을 넘은 페이로드만 다시 재는)이 있는 것과 같은 이유로,
타임아웃에도 확정 전 재확인 단계를 뒀다 — 다만 통계가 아니라 “다시 걸리는가”만
보므로 방식이 다르다.
confirmTimeoutIfNeeded는 head에 타임아웃이 하나라도 있으면 head만
TIMEOUT_CONFIRM_RETRIES(3)회 다시 스폰한다(measurePayload의 sides: ["head"]
— base는 이미 문제가 없다고 확인됐으므로 왕복 비용을 더하지 않는다). 진짜 무한
루프나 병리적 정규식 백트래킹은 재시도마다 다시 걸리지만, 우연한 부하는 3번
연속(또는 3번 중 2번) 재현되기 어렵다 — 그래서 TIMEOUT_CONFIRM_MIN_HITS(2)회
이상 다시 타임아웃돼야만 최종 회귀로 확정한다(timeoutConfirmed: true). 그 밑이면
“정상(시간 초과가 재확인에서 사라짐)”으로 되돌리고(timeoutConfirmed: false),
회귀 집계에서도 빠지며, 마크다운 맨 앞의 “시간 제한을 넘은 페이로드” 절에서도
제외한다(buildMarkdown의 timeouts 필터가 timeoutConfirmed !== false를
본다) — 표의 판정 칸에만 조용히 남는다. 최악의 경우 추가 비용은
TIMEOUT_CONFIRM_RETRIES * SPAWN_TIMEOUT_MS ≈ 45초로, 실제로 멈춘 페이로드
하나에만 든다(재현되지 않으면 3회 모두 정상 시간 안에 끝나 훨씬 빨리 끝난다).
통계 재확인(confirmIfRegression)과 타임아웃 재확인(confirmTimeoutIfNeeded)은
서로 배타적이다 — main()은 headTimeouts > 0이면 타임아웃 경로로, 아니고
regression이면 통계 경로로 보낸다. 통계 재확인 함수 자신도 여전히
headTimeouts > 0 || baseTimeouts > 0이면 손대지 않고 그대로 돌려주는 방어를
갖고 있다 — 두 경로가 우연히 같은 결과에 동시에 적용되는 일이 없게 한다.
페이로드 전체의 최악 실행 시간을 예산 안에 묶는다
head가 실제로 멈춰 있으면(무한 루프, 병리적 정규식 백트래킹) 그 사실은 모든
페이로드가 공유한다 — 대표 페이로드 7개 가운데 6개가 hooks/guard.mjs를 부르고
나머지 1개가 hooks/session-language.mjs를 부르는데, 훅 하나가 통째로 멈추면 그
훅을 부르는 페이로드는 전부 매 스폰이 타임아웃된다. stopAfterFirstHeadTimeout
옵션(1차 측정에만 켠다) 없이 계산하면 runs+warmup(기본 10회)마다 매번
SPAWN_TIMEOUT_MS(15초)를 기다리므로, 페이로드 하나에 최대 10 × 15초 = 150초가
들고 7개 전부 멈추면 7 × 150초 ≈ 17.5분이다 — 이 봇의 job timeout-minutes(당시
10분)보다 길다. 즉 이 봇이 가장 잡아야 할 바로 그 회귀(훅이 아예 멈추는 것)가
job 자체를 타임아웃으로 죽여, 코멘트 하나 없이 조용히 실패하게 만드는 구조적
결함이었다.
measurePayload의 stopAfterFirstHeadTimeout은 head가 한 번이라도 타임아웃되면
그 반복에서 곧바로 측정을 그만둔다 — “멈췄는지 아닌지”는 1번만 확인해도 충분하고,
“몇 번 멈추는지”의 확인은 이미 confirmTimeoutIfNeeded가 head만 따로, 정확히
TIMEOUT_CONFIRM_RETRIES(3)회 재는 것으로 넘겨받는다. 이 옵션은 main()의 1차
측정 호출과 confirmIfRegression의 재확인 호출 양쪽에 켠다(후자는 “1차 측정에서는
멈추지 않았는데 재확인 20회 도중 멈추기 시작하는” 드문 경우에 대한 방어선이다).
confirmTimeoutIfNeeded 자신의 head-only 재확인 호출에는 켜지 않는다 — 재현
여부를 세려면 3회를 다 채워야 한다. 그 결과 페이로드 하나의 최악 비용은
SPAWN_TIMEOUT_MS(15초, 최초 감지) + TIMEOUT_CONFIRM_RETRIES * SPAWN_TIMEOUT_MS
(45초, 재확인) ≈ 60초로 줄어든다.
그래도 페이로드 수가 늘어나면(지금 7개, 앞으로 더 늘 수 있다) 60초 × N은 여전히
선형으로 쌓인다. 그래서 스크립트 전체에 SCRIPT_DEADLINE_MS(8분)라는 마지막
방어선을 뒀다 — main()은 페이로드를 시작하기 전에 스크립트 시작 이후 경과
시간을 확인하고, 상한을 넘겼으면 그 페이로드부터는 스폰 자체를 하지 않고
“시간이 모자라 측정하지 못했습니다”라는 행으로 남긴다(skipped: true, error
필드에 그 문구를 실어 기존 “측정하지 못한 페이로드” 배관을 그대로 쓰되
markFor는 ⏳ 시간 부족으로 따로 표시하고, 마크다운에도 “시간이 모자라 건너뛴
페이로드”라는 별도 절을 둔다). 이미 계산된 앞쪽 페이로드의 결과는 그대로
보고서에 남는다 — 시간이 모자랐다는 사실 자체도 하나의 결과다. job의
timeout-minutes도 10분에서 15분으로 올렸다 — SCRIPT_DEADLINE_MS(8분)가 페이로드
스폰을 잘라낸 뒤에도 결과 파일을 쓰고 코멘트를 올릴 시간이 남게 한다.
페이로드 하나의 실패가 나머지 행을 지우면 안 된다
측정 전체를 하나의 try로 감싸면, 페이로드 하나가 예상 밖으로 던졌을 때(스폰
자체의 알 수 없는 실패, 확인 재측정 실패 등) 이미 계산된 다른 페이로드들의 결과까지
함께 버려지고 “측정하지 못했습니다”라는 한 줄만 남는다 — 문제가 하나뿐인데 신호
전체를 잃는다. 그래서 main()은 englishRepoDir 준비·결과 파일 쓰기 같은
스크립트 전체 단위의 실패만 바깥 try로 잡고, 페이로드별 측정(payloads를 도는 for 루프 안)은
각각 자기 try로 감싼다. 한 페이로드가 실패하면 그 행만 error 필드를 달고
마크다운의 “측정하지 못한 페이로드” 절과 표의 ❗ 오류 표시로 남으며, 나머지
페이로드는 평소대로 보고된다.
이 스크립트 자신을 재는 대상과 같은 실패에 노출하지 않는다
isEntrypoint(직접 실행 판정)는 hooks/lib/entrypoint.mjs의 것을 재사용하지
않고 이 파일 안에 최소 구현으로 따로 둔다. 그 함수는 정적 import로 불러오는데,
이 워크플로가 정확히 hooks/**를 건드리는 PR마다 도는 구조상 그 라이브러리
자신의 export가 바뀌거나 깨지는 PR이면 이 파일 맨 위의 정적 import가 main()의
try보다 먼저 스크립트 로딩 자체를 실패시켜 코멘트조차 못 남긴다 — 재는 대상(훅
라이브러리)이 재는 도구 자신을 깨뜨리는 순환 취약점이다. 대신 argv[1]과 이
파일의 실제 경로를 비교하는 몇 줄짜리 자체 구현을 쓴다 — 심볼릭 링크 해석 같은
entrypoint.mjs의 정교함은 여기 필요 없다(이 스크립트는 항상
node scripts/hook-latency.mjs로 직접 실행된다).
왜 경고만 하는가
hook-latency.yml은 job을 절대 실패시키지 않는다. 이 저장소의 다른 검사(dogfood,
규칙표 정합성)는 잘못이 결정론적이고 고치는 방법이 뻔하지만, 지연 시간 회귀는
“몇 ms 늘었다”는 사실 자체가 항상 나쁜 것은 아니다 — 규칙이 하나 늘면 로딩 비용도
따라 늘고, 그것은 종종 의도한 트레이드오프다(불변식 9: 규칙 자료 품질이 제품
품질이다). 게다가 GitHub 러너의 타이밍은 그 자체로 잡음이 크다(공유 인프라의
이웃 부하). 이런 신호를 job 실패로 묶으면 사람이 “일단 재실행”을 반복하게 되고,
그러다 진짜 회귀도 재실행으로 넘겨 버린다. ::warning:: 어노테이션과 sticky
코멘트로만 알리고, 늘어난 지연이 트레이드오프로 받아들일 만한지는 리뷰어가
판단한다. 무한 루프처럼 종류가 다른 회귀(위 “스폰이 안 끝나면” 참고)조차 job을
실패시키지 않고 마크다운 맨 앞에 눈에 띄게 남기는 것으로 처리하는 것도 같은
원칙이다 — 사람이 판단할 여지를 주 워크플로의 초록/빨강 이분법으로 뭉개지 않는다.
같은 이유로 스크립트 자신이 실패해도(임시 저장소를 못 만들거나 결과 파일을 못
쓰는 등) job을 실패시키지 않는다 — rule-impact.mjs와 같은 방어선이다.
실패하면 “훅 지연 시간을 측정하지 못했습니다”라는 짧은 마커 본문을 내고 정상
종료한다. 여기서 예외가 그대로 새 나가 워크플로 스텝이 실패하면 코멘트 갱신
단계가 아예 안 돌아 이전 푸시의 낡은 sticky 코멘트가 최신인 것처럼 남는다 —
실패를 감추는 것이 아니라 그 실패 자체를 코멘트 내용으로 보고하는 것이다.
알려진 한계
- 대화 텍스트는 사후 검사만 가능하다. 어시스턴트가 말하기 직전에 끼어드는 훅이 없다. Stop 훅으로 검사하면 사용자는 “어색한 한국어 → 고친 한국어”를 연달아 보게 된다. 그래서 대화 텍스트에 대해서는 예방(출력 스타일)만 하고 사후 교정은 하지 않는다.
- 린터는 목록에 있는 것만 잡는다. 번역체 문장 구조처럼 목록화되지 않는 문제는 출력 스타일에 의존한다.
- 강제 적용은 다른 출력 스타일을 배제한다. 위의 “강제 적용의 대가” 참조.
- 규칙 자료의 품질이 제품의 품질이다. 배선은 작고 자료가 크다. 잘못된 항목은 도움이 아니라 해가 된다.
- 개선 증거는 회귀 시험으로만 남아 있다.
claude plugin eval을 돌릴 수 없어, 플러그인을 켜고 끈 상태로 같은 질문에 받은 실제 답변을tests/fixtures/에 보존하고 린터로 채점해 차이를tests/improvement.test.mjs로 굳혔다. 비교 한 건뿐이고 표본이 적다. 넓은 측정은 평가 세트가 돌아야 가능하다. - 낱말 경계 검사는 붙여 쓴 외래어 복합어를 못 잡는다. “에러핸들링”·”고객니즈”·
“코드오너십”·”비즈니스임팩트”·”재디플로이”처럼 판교어가 다른 한글 낱말에 그대로
붙어 있으면,
lint.mjs의 왼쪽 경계 검사가 “다른 낱말 속”으로 보고 지적하지 않는다. 보수적인 쪽을 택했다 — 경계를 느슨히 하면 “디커플링”의 “커플링”처럼 무관한 낱말 속에서 오탐이 난다. 띄어 쓴 형태(“에러 핸들링”)는 그대로 잡는다. - 평가는 자격 증명이 있는 환경에서만 돌아간다.
claude plugin eval은 자식claude프로세스를 띄우는데, 그 프로세스가 자격 증명을 물려받지 못하면 모든 사례가 인증 오류로 실패한다. AWS Bedrock 경유 세션 안에서 특히 그렇다. 사례 파일 자체는 적재되고regex채점기도 도는 것을 확인했지만, 실행은 사람이 직접 셸에서 해야 한다. 자격 증명이 필요 없는 검증은npm test에 들어 있다. - GitHub Action은 fork PR·Dependabot PR에서 인라인 코멘트를 못 단다. 둘 다 기본 토큰이
읽기 전용이라
pull-requests: write가 없다 — 이때는 annotations(워크플로 로그의::warning)로만 알린다.COMMENT이벤트로 올린 리뷰 자체는 GitHub REST API로 지우거나 취소할 방법이 없어(dismissals는APPROVED·CHANGES_REQUESTED만 받는다), 재실행마다 쌓이는 리뷰는 지우는 대신 GraphQLminimizeComment로 접는다 — 위 “GitHub Action” 절 “재실행마다 리뷰를 다시 쌓지 않는 방법” 참조. - 리뷰·PR 대화 코멘트 조회는 최근 100개까지만 본다. 리뷰 스레드(
reviewThreads)는gh api graphql --paginate로 전체를 받아 오지만(gh가$endCursor와pageInfo{hasNextPage endCursor}를 보고 커서를 자동으로 채워 반복 호출한다), 리뷰 목록(reviews)과 PR 대화 코멘트(comments)는last:100(가장 최근 100개)만 조회한다 — sticky 요약 코멘트와 가장 최근 kimchi 리뷰는 이 액션이 최근에 직접 올린 것이라 항상 그 범위 안에 있다는 전제다. 리뷰·코멘트가 100개를 훌쩍 넘는 매우 오래 논의된 PR에서 sticky 코멘트보다 최근에 사람이 단 코멘트가 100개 넘게 쌓이면(극단적인 경우), sticky 코멘트를 못 찾아 새 이슈 코멘트를 하나 더 만들 수 있다. 스레드 안의 코멘트도 스레드당first:50까지만 본다 — 한 스레드에 답이 50개 넘게 달리는 것은 거의 없다고 보고 페이지네이션을 넣지 않았다.
설계 중 뒤집은 결정
같은 실수를 반복하지 않기 위해 기록해 둔다.
스킬 → 출력 스타일
처음에는 큰 용어집을 스킬에 두고 모델이 필요할 때 열어 보게 하려 했다. 잘못된 설계다. 말투는 매 문장에 적용되어야 하는 규칙이므로 모델의 판단에 맡길 수 없다. 스킬과 슬래시 커맨드를 모두 없앴다.
“자연스러운 우리말로 풀어쓰기” → “한자어 기술용어 우선”
처음 정한 용어 원칙의 2순위가 “자연스러운 우리말로 풀고 첫 등장에 원어 병기”였다. 이 원칙이 잘못된 결과를 만들어 냈다. 실제로 설계 중에 같은 방향으로 두 번 연속 틀렸다.
| 원문 | 첫 시도 | 두 번째 시도 | 맞는 표현 |
|---|---|---|---|
| thin contract | 얇은 계약 | 모듈끼리 서로 알아야 하는 것을 최소로 줄이세요 | 결합도를 낮추세요 |
| blast radius | — | 여파가 미치는 범위 | 영향 범위 |
두 번째 시도들은 전부 순우리말로 풀어쓴 서술문이다. 문법은 맞지만 어색하다. 한국어 기술 레지스터의 실제 어휘는 한자어 술어이고, 그것이 더 짧고 정확하고 이미 통용된다.
이것은 모델의 체계적 편향으로 보인다. “자연스러운 한국어”라는 지시를 받으면 순우리말 풀어쓰기로 기울고 그 결과가 번역 투로 읽힌다. 따라서 출력 스타일은 이 편향을 명시적으로 거슬러야 한다. 풀어쓰기를 최후 수단으로 못박은 4번 규칙이 그 장치다.
영어 개발 용어를 강제로 한자어·음차로 옮기던 원칙 → 로마자 원어 허용 (2026-09-26)
용어 처리 우선순위 2번이 “정착된 외래어가 있으면 그것을 쓴다”였고, 그 자리에 없는 개발 용어는
4번(순우리말 풀어쓰기) 앞에 “원어 그대로”가 있었는데도 실제로는 프롬프트 규칙이 state
같은 낱말을 억지로 한자어로 옮기라고 가르치고 있었다(이 컴포넌트의 state를 업데이트합니다
→ 이 컴포넌트의 상태를 갱신합니다). function·array·value는 원래도 옳게 한국어(함수·배열·값)를
가르치고 있었으니 건드리지 않는다 — 로마자 허용은 state·props·payload처럼 실제로
정착 한국어 대응이 없는 말에만 해당한다. 소유자가 이 순서를 뒤집었다.
한국 개발자가 실제로 쓰는 문장은 이 컴포넌트의 state를 업데이트하고 배포합니다. build target
directory에 올릴게요. 배포할 때 확인해야 합니다.이지 상태를 갱신하고, 빌드 타깃 디렉터리가
아니다 — 후자는 국립국어원 규범 표기이지 슬랙·PR에서 개발자가 타이핑하는 말이 아니다.
새 순위는 “정착 외래어”와 “원어 그대로”를 각각 독립된 목적어 집합으로 명시한다. 배포·커밋처럼
이미 한국어로 읽고 쓰는 말은 한국어로, state·payload·target처럼 개발자가 로마자로 그대로
타이핑하는 말은 로마자로 남긴다. 두 집합을 하나로 뭉뚱그리면 다시 이번 실수로 되돌아간다.
로마자는 명사 자리에만 허용한다(call합니다, deploy한처럼 동사에 붙이면 여전히 틀리다).
사내 은어(컨펌, 어사인)는 이 허용과 무관하게 애초에 기술 용어가 아니므로 한국어로 쓴다 — 별개의
규칙이다. rules/pangyo.md의 update the component's state·call this function 등 항목을
이 방향으로 뒤집었다.
헤드리스 시험(2026-09-26)에서 측정한 사실이 다음을 고쳤다.
push합니다: 로마자 동사에 하다를 붙이지 않는다는 규칙에 예시가 없어 3회 중 1회가 이렇게 썼다 → 본문에푸시합니다·머지합니다·업데이트합니다예시를 박았다. 이후 7회 중 0회.- 질문의 타겟·디렉토리·릴리즈를 그대로 따라 썼다: 원칙을 조건 없이 줄였을 때 1회 중 1회.
- 질문에 없는데도 디렉토리를 썼다: 질문 조건부 문장(“질문에 …가 적혀 있어도”)일 때 1회 중 1회 — 조건문은 질문에 용어가 아예 없는 경우를 막지 못한다. → target·directory는 무조건 로마자를 기본값으로 쓰고, 한글로 적을 때만 타깃·디렉터리로 고치는 무조건 규칙으로 바꿨다. 이후 헤드리스 4회(따라 쓰기 1회, 먼저 꺼내기 2회, state 1회) 모두 0회.
- Latin 목록에
race condition·deadlock·latency·throughput·side effect·regression이 섞여 있었는데, 이 여섯은rules/hanja.md(경쟁 조건, 교착 상태, 지연 시간, 처리량, 부수 효과, 회귀)와rules/terms.md의 기존 행이 이미 정착 한국어로 가르치고 있어 자기모순이었다 → 이번 단위에서는 목록에서 뺐다. 이 여섯을 로마자로 되돌릴지는 규칙 자료를 다루는 별도 단위에서 판단한다.
여섯 용어를 로마자로 되돌림 — 규칙표를 먼저 맞춘 뒤 (2026-09-26)
위 자기모순을 규칙표 쪽에서 해소했다. rules/terms.md의 음차 경고 행
(레이스 컨디션·데드락·레이턴시·스루풋·사이드 이펙트·리그레션 → 한자어)을 지우고,
rules/hanja.md의 대응 행(경쟁 상황·꽉 막힌 상태 등 풀어쓴 표현 → 한자어)의 쓸 것을
원어를 첫 자리로, 한자어를 괄호 대안으로 바꿨다. 두 파일이 서로 다른 방향을 가르치던
자기모순이 없어졌으니 우선순위 3번의 로마자 목록에 이 여섯을 다시 넣었다. 검사 칸은
치환이던 행(side effect, rules/hanja.md·rules/pangyo.md)만 정규식·프롬프트로
내렸다 — 로마자를 고르는 것은 낱말 선택 판단이라 자동 교정 대상이 아니다(5번 불변식).
조어한 한국어 번역 → 원어 (2026-09-26)
sanity check·escape hatch·source of truth·single source of truth·
graceful degradation·breaking change·dirty flag·rate limit(rules/terms.md)와
sanitization·early return(rules/hanja.md)의 쓸 것이 억지로 조어한 한국어 번역이었다
(예: sanity check → 정상 동작 확인). nullable → nullable(rules/observed.md, “음차보다
원어가 낫다”)과 같은 판단이다 — 개발자는 이 말들을 직역으로 읽지 않고 원어 그대로 쓰거나
음차로 쓴다. 원어를 첫 자리로 바꾸고 조어한 번역은 괄호 대안으로 남겼다.
같은 목록에서 후보로 검토했던 간접 참조(indirection)·락 단위(lock granularity)·
장애 유형(failure mode)·잘림(truncation)·요청 조절(throttling, 이미 스로틀링이
첫 자리)는 그대로 뒀다. OS·네트워크 교과서와 클라우드·SRE 문서에서 흔히 보는 한국어
번역이고, 원어를 그대로 쓰는 사례가 드물어 바꾸면 오히려 1번 원칙(실제로 타이핑하는
대로)에 어긋난다. 공개 API 범위(API surface)도 그대로 뒀는데, 이건 교과서 출처보다는
“API surface” 자체가 한국어 개발자 사이에서 잘 쓰이지 않는 표현이라는 판단이다 — 근거를
표에 적어 둘 만큼 확실하지는 않으니, 실제로 자주 나오는 표현이 관찰되면 뒤집을 수 있다.
은유 층의 발견
실패 유형이 두 층이 아니라 세 층이었다.
- 단어 층 —
블라스트 레디우스같은 음차. 잡기 쉽다. - 문장 구조 층 —
우리는 ~한다같은 번역체. 정규식으로 일부 잡힌다. - 은유 층 —
얇은 계약. 단어도 한국어고 문법도 맞는데 비유가 영어라서 통하지 않는다.
3번이 가장 잡기 어렵다. 앞의 두 층을 모두 통과하기 때문이다. 게다가 은유마다 정착 여부가 다르다.
무거운 작업, 가벼운 라이브러리, 코드 냄새, 일급 함수는 한국어에 뿌리내렸고 얇다, 두껍다,
깊다, 평평하다는 내리지 않았다. 원칙으로 이 구분을 유도할 수 없으므로 rules/metaphors.md에
사례를 판정해 둔다.
프롬프트 규칙 우선 정렬 → 갈래별 예산 배정
스타일 본문에 담을 규칙을 고를 때 프롬프트 규칙을 먼저 담도록 정렬 기준을 넣었다. 근거는 “치환·정규식 규칙은 린터가 대신 잡아 준다”였다. 틀린 전제였다.
린터는 커밋 메시지와 문서 파일만 본다. 대화는 보지 못한다. 그리고 대화가 이 플러그인의 주 무대다. 정렬 기준을 바꾼 결과 본문 39개 중 치환 규칙이 3개로 줄어, 용어 규칙 수백 개가 대화에서 아무 일도 하지 않게 되었다.
지금은 행 예산을 두 갈래로 나눈다. 절반은 프롬프트 규칙(예방밖에 방법이 없는 것), 절반은 치환·정규식 규칙(용어)이 쓴다. 한쪽이 예산을 덜 쓰면 남은 자리를 다른 쪽에 넘긴다. 그리고 한 규칙이 안 들어가도 선택을 멈추지 않는다. 뒤에 오는 짧은 규칙은 아직 들어갈 수 있다.
치환과 정규식 사이에는 우열을 두지 않는다. 둘 다 린터가 잡을 수 있어 서로 앞설 근거가 없고, 순서를 매기면 규칙 파일에 적은 순서만 뒤섞인다.
남는 자리 넘기기를 순위 tier가 끝날 때까지 미뤘던 것
“한쪽이 예산을 덜 쓰면 남은 자리를 넘긴다”를 처음에는 전체 규칙을 다 훑은 뒤 한 번에 했다.
문제는 낮은 순위 규칙이 제 갈래에 자리가 있으면 첫 시도에서 바로 들어가 버린다는 것이다
— 이건 남는 자리 재시도(anyLane)가 아니라 정상적인 첫 시도 admit이라 순위와 무관하게
일어난다. 실측: rules/hanja.md의 핵심 순위 행 몇 개를 고치면서 이유 칸이 조금 길어지자,
같은 핵심 순위인 lazy evaluation(metaphors.md, 치환)이 치환 갈래(termLane) 예산을 넘겨
밀려났다. 그런데 그보다 뒤에 처리된 보통 순위인 profiling(hanja.md, 프롬프트)은
프롬프트 갈래(promptLane) 예산이 아직 남아 있어 첫 시도에서 바로 들어갔고, 그 길이만큼
전체 길이(length)를 먼저 써버렸다. lazy evaluation이 나중에 남는 자리(anyLane)를
재시도할 차례가 왔을 때는 이미 자리가 없었다 — 순위가 낮은 규칙이 자기 갈래에 여유가
있다는 이유만으로, 순위가 높지만 다른 갈래가 꽉 찬 규칙보다 먼저 본문 자리를 차지한 것이다.
지금은 순위 tier(핵심 → 보통 → 참고)를 하나씩 끝내면서, 그 tier 안에서 갈래 예산을 넘긴 규칙을 곧바로 남는 자리에 재시도한다. 다음 tier로 넘어가기 전에 현재 tier의 재시도를 모두 마치므로, 낮은 순위가 높은 순위의 재시도 기회를 가로챌 수 없다.
이 사고가 알려주는 것은 두 갈래 예산 분배 자체가 아니라 본문 길이가 핵심 순위 규칙만으로도
거의 다 찬다는 사실이다(핵심 순위 문장 구조·프롬프트 규칙만 합쳐도 갈래 예산을 훌쩍
넘는다). 그래서 규칙 하나의 이유 문구를 몇 글자 늘리는 것만으로도 다른 파일의 핵심 규칙이
본문에서 빠질 수 있다 — 알파벳순으로 이 파일보다 뒤에 오는 파일(patterns.md,
register.md, terms.md)일수록 더 취약하다. tests/build-style.test.mjs에 본문에 반드시
있어야 하는 핵심 규칙 몇 개(문장 구조·은유 대표 사례)를 못박아 뒀다 — 이 시험이 실패하면
새로 늘어난 글자 수만큼 근처 규칙의 이유를 줄여 자리를 돌려줘야 한다.
조사 안전장치를 한쪽만 막았던 것
isParticleSafe가 매치 뒤 글자만 봤다. 그런데 대체 표현이 를 처리처럼 조사로 시작하면
매치 앞 단어의 받침이 조사를 결정한다. 그래서 파일에 대한 처리를 진행이
파일를 처리로 바뀌었다. 조사는 양쪽에서 온다.
지금은 대체 표현이 조사로 시작하면 자동 교정을 포기한다. 규칙 표에는 앞말의 받침 정보가 없다.
드롭인 판정이 괄호와 쉼표를 놓쳤던 것
정상 종료 (안전 종료)나 결합도, 느슨한 결합은 규칙 표에서는 대안 나열이지만 본문에 그대로
꽂으면 문장이 망가진다. 실제로 커플링을 낮췄습니다가 결합도, 느슨한 결합을 낮췄습니다로
바뀌었고, ~을 할 수 있습니다처럼 물결표가 든 대체 표현은 이 기능은 을 할 수 있습니다를
만들었다.
지금은 대체 표현에 괄호·쉼표·슬래시가 있거나, 가운데 물결표가 있거나, 조사로 시작하면
정규식으로 내려 경고만 한다. 이 판정으로 치환 규칙이 395개에서 294개로 줄었다.
시험 탐침이 조사 인접 상황을 재지 않았던 것
치환 규칙은 실제로 치환할 수 있다 시험이 앞말 ${bad} 뒷말처럼 양쪽을 띄운 문장만
탐침으로 썼다. 그래서 조사가 바로 붙는 상황을 구조적으로 재지 못했다. 생성기도 같은 탐침으로
검사 값을 정했으므로, 생성기와 시험이 같은 맹점을 공유했다. 잘못된 치환 규칙 76개가
시험을 전부 통과한 채로 배포된 이유다.
지금은 ${bad}을, ${bad}를, ${bad}이, ${bad}가 네 가지 탐침을 쓰고, 교정이 적용되면
대체 표현의 받침이 원래 표현과 같은지 확인한다. 데이터 불변식(대체 표현이 조사로 시작하지
않는다, 괄호·쉼표가 없다)도 따로 시험한다.
차단 → 경고
린터의 기본 동작을 차단으로 설계했다가 경고로 내렸다. “클로드의 퀄리티는 유지”라는 제약이 들어오면서 판단이 바뀌었다. 커밋을 문체 때문에 막으면 작업 흐름이 끊기고 재시도 턴을 낭비하며 최악의 경우 원래 하려던 일을 잊는다.
검사 두 값 → 세 값
검사 열을 정규식과 프롬프트 두 값으로 설계했다. 그런데 훅 출력 명세에서 updatedInput을
발견했다. PreToolUse 훅이 도구 입력을 직접 고쳐 쓸 수 있다는 뜻이고, 그러면 린터가 경고만 하는 것이
아니라 결정론적으로 교정할 수 있다.
능력이 셋이 되었으므로 값도 셋이 되어야 했다. 치환(자동 교정 가능), 정규식(경고만),
프롬프트(스타일만). 세 값이 세 능력에 정확히 대응한다.
그런데 치환에는 한국어 고유의 함정이 있었다. 조사다. 위의 “자동 교정의 조사 문제” 참조.
이 함정 때문에 치환 여부를 사람이 손으로 정하면 안 된다는 결론이 나왔고, import-corpus.mjs가
실제 치환을 해 보고 계산하도록 만들었다.
자동 교정·경고에 permissionDecision을 실었던 것
updatedInput이나 KIMCHI_PII=warn 경로 모두 처음에는 permissionDecision: "allow"를
함께 돌려줬다. “이미 검사를 통과했으니 명시적으로 허용해 준다”는 생각이었다. 실제 세션에서
확인해 보니 틀린 생각이었다 — 클로드 코드 2.1.282는 "allow"를 받으면 사용자의 권한 프롬프트
자체를 건너뛴다. 자동 교정을 켠 상태에서 git commit이 사용자 승인 없이 실행됐다.
지금은 두 경로 모두 permissionDecision을 아예 넣지 않는다. 결정은 정상 권한 파이프라인에
맡기고, 훅은 updatedInput(무엇이 바뀌었는지)과 additionalContext(모델에게 알리는 채널)로만
말한다. 자세한 내용은 위 “자동 교정이 permissionDecision을 싣지 않는 이유” 참조.
규칙 자료를 손으로 쓰려던 계획 → 조사로 모으고 검증하기
설계 중에 같은 방향으로 두 번 연속 틀린 뒤, 규칙 자료를 손으로 쓰는 것을 포기했다. 대신 분류별로 초안을 만들고, 각 초안을 반대편에서 검증하고, 전체를 교차 감사하는 과정을 거쳤다. 검증 단계의 최우선 항목은 “쓸 것이 풀어쓰기로 늘어져 어색하지 않은가”였다. 실제로 분류마다 다섯에서 여섯 건이 그 이유로 교정되었다.