번역 구조 일치 검사 — 무엇을 재고, 무엇을 실패시키는가¶
Status: IMPLEMENTED —
scripts/check-translation-structure.py,scripts/docs_tree.py의 공유 함수 넷(스테일 검사는 그 위로 이관되었다),.github/workflows/build.yml의translations잡(개명 완료) 두 번째 스텝. 출처였던 T-1 은 닫혔다.구현하면서 빈칸 하나가 드러나 여기서 메웠다 — §4.1 의 "고아 이유"(
structure_exempt없이structure_exempt_reason만 있는 줄). 그 밖에 설계와 구현이 갈린 곳은 없고, 프로브 29개 + 새 프로브 하나가 전부 기대대로 돌았다(§6).측정 기준일: 2026-09-06, 32쌍 전부. 이 문서의 모든 수치는 그날 워크트리에서 직접 잰 것이고, 재지 않은 것은 §8 에 재지 않았다고 적었다.
이 문서가 design/ 에 있는 이유¶
../README.md §1 은 이 디렉토리의 축을
../../overview/architecture.md 의 서브시스템으로 정한다.
문서 도구는 그 목록에 없다. 그런데 이 문서의 내용은 결정과 기각한 대안이고,
../../project/documentation-guide.md §1 은 그것을 design/
으로 보낸다. 두 규칙이 서로 다른 곳을 가리키므로 고른 근거를 적어 둔다.
project/에 두지 않았다. 그 디렉토리는 따라야 하는 규칙을 담고(api-stability.md,documentation-guide.md,translation-glossary.md), 이 문서는 규칙이 아니라 그 규칙을 강제하는 장치의 근거다. 규칙 자체는 이미documentation-guide.md§5 와CLAUDE.md에 있고 이 문서는 그것을 옮기지 않는다design/의 도메인 축이 이미 정확한 거울이 아니다 —design/integration/과design/filesystem/에는 대응하는features/디렉토리가 없다design/은 번역 대상이 아니므로(documentation-guide.md§5.1).en.md를 만들지 않는다.project/는 "아직 아님" 이라 언젠가 쌍이 하나 더 늘어난다 — 검사 설계 문서가 그 검사의 대상 개수를 늘리는 것은 피할 이유가 된다
0. 결론 먼저¶
-
여섯 축은 유지한다. 재현했고 32쌍 전부 일치한다. 발명할 것은 없었다. 그러나 처분은 갈린다 — 다섯은 하드 실패이고, 여섯째(
펜스 안 # 줄 수)는 조언이다. 그 축은 자기가 내세운 근거를 지키지 못하고, 근거대로 넓히면 오늘 3쌍이 깨진다(§1.3). -
축 이름만으로는 부족하다 — 여섯 축 전부에 패턴을 붙인다. 산문 정의 하나(
list-items)가 글자대로 읽으면**굵은 글씨**로 시작하는 줄을 리스트 항목으로 세어 오늘 17/32 를 깨뜨린다. 나머지 다섯은 16가지 읽기 전부에서 같은 답을 내지만 그것은 관측이지 명세가 아니므로, 여섯 전부에 패턴을 적었다(§1.1). -
실패냐 경고냐는 축이 아니라 쌍의 상태가 정한다. 쌍이 FRESH 면 하드, STALE 이면 조언이다. 이것은 취향이 아니라 옆 스크립트의 결정문을 이 축에 적용한 결과이고, 적용하지 않으면
check-translation-staleness.py가 내린 결정이 옆문으로 뒤집힌다(§3.4). -
예외는 번역본 frontmatter 에 축 단위로 선언하고, 인코딩까지 못박는다. 베이스라인 파일은 쓰지 않는다 — 두 선례(
BASELINE_TOP_LEVEL_CYCLES,gradle/coverage-baselines.properties)는 둘 다 자연스러운 거처가 없는 값을 위한 것이고, 구조 예외에는 거처가 있다(§4.2). 대신 그 선례에서 자기무효화를 물려받는다(§4.3). 그 frontmatter 를 읽는 파서가 둘(검사의 줄 정규식, mkdocs 의 YAML)이므로 표기를 정하지 않으면 목록이 조용히 잘리거나 발행된 사이트에서 frontmatter 가 통째로 사라진다 — 둘 다 실측했고, 검사가 표기를 강제한다(§4.1). -
선언의 결함은 쌍의 상태를 보지 않는다. §4 의 exit 1 들과 §3.3 의 "STALE 은 실패시키지 않는다" 가 충돌하는 것을 가르는 기준은 그 상태를 정본 편집으로 만들 수 있는가다(§4.5). 정본을 고쳐서 만들 수 없는 상태는
stale결정의 사정거리 밖이다. -
새 스크립트를 만들고
translations잡(옛translation-staleness)의 두 번째 스텝으로 붙인다. 얹지 않는 이유는 그 스크립트의 exit 계약이 두 발견에 대해 쓰인 설명이기 때문이고, 세 번째 발견을 그 아래로 밀어 넣는 것은 T-1 §0 이 진단한 실패(설명이 덮는 범위보다 값의 범위가 넓다) 그 자체다(§5.1). -
오늘 잡을 결함은 0건이다. 공허 통과가 아님은 프로브로 보인다 — 축 프로브 14개는 이 설계를 쓰면서 실제로 돌렸고(§6.1), 등급·예외를 겨누는 게이트 프로브 18개(G1~G14, 프라임 넷)는 검사가 있어야 돌아가므로 기대만 적었다(§6.2).
1. 진단 — T-1 의 실측을 다시 쟀다¶
1.1 여섯 축 · 32쌍 · 불일치 0 (재현됨)¶
측정에 쓴 정의를 먼저 적는다. 항목이 이름만 적은 자리에서 해석이 갈릴 수 있기 때문이다. 산문만으로는 그 목적을 이루지 못한다 — 초안은 산문만 적었고, 여섯 중 하나가 글자대로 읽으면 32쌍 중 17쌍을 깨뜨렸다(아래). 그래서 표 아래에 패턴을 함께 둔다.
| 축 | 무엇을 세었나 | 갈릴 수 있는 자리를 어떻게 닫았나 |
|---|---|---|
| 제목 | ATX 제목의 개수와 # 깊이의 열(순서 포함). 펜스 안은 제외 |
# 뒤에 공백을 요구한다 |
| 코드 펜스 | 여는 펜스의 개수와 언어의 열. ``` 와 ~~~ 를 각각 자기 마커로만 닫는다 |
선행 공백을 허용한다(들여쓴 펜스가 12건 있다, §1.3 (f)). 언어는 info string 의 첫 토큰 |
| 표 행 | 펜스 밖의 표 행 (구분선·헤더 포함) | 선행 공백을 허용한다 |
| 리스트 항목 | 펜스 밖의 리스트 항목. 들여쓰기 깊이는 묻지 않는다 | 마커 뒤에 공백을 요구한다 — 아래 |
| 인용 블록 | > 줄의 연속 구간 수. 줄 수가 아니다 |
> 가 아닌 줄은 빈 줄이든 아니든 구간을 끊는다 |
펜스 안 # 줄 |
펜스 안에서 # 로 시작하는 줄의 수 |
선행 공백을 허용한다 |
산문만으로는 그 자리들이 닫히지 않는다는 것이 아래 패턴을 함께 적는 이유다. 전부 frontmatter 를 벗기고 펜스 안/밖을 가른 뒤 한 줄씩 대는 것이며, 이 문서의 모든 수치가 이것으로 나왔다.
headings ^(#{1,6})\s+\S # '#' 뒤 공백 필수
fences ^\s*(```|~~~) # 여는 펜스만 센다. 언어 = info string 의 첫 토큰
table-rows ^\s*\|
list-items ^\s*([-*+]|\d+[.)])\s # 마커 뒤 공백 필수 -- 없으면 17/32 가 깨진다
quote-blocks ^\s*> # 의 연속 구간 수
fence-hash-lines ^\s*# # 펜스 '안' 에서만
frontmatter 는 모든 축에서 벗겨낸 뒤 잰다. 벗기지 않으면 닫는 --- 가 setext 제목으로 읽히고,
그 오류는 번역본 32개 전부에서 발화한다(§1.3 (a)).
결과: 32쌍, 불일치 0. 항목의 실측이 그대로 재현된다. 제목 축은 개수뿐 아니라 레벨 열까지 전부 일치했다.
정규식 열이 필요한 이유 — 리스트 축은 읽기가 갈리고, 틀린 읽기가 17쌍을 깨뜨린다¶
초안의 리스트 행은 "-/*/+/N./N) 로 시작하는 줄. 들여쓰기 무관" 이었다. 마커 뒤 공백
요구가 없다. 글자대로 — 위 패턴에서 끝의 \s 를 지운 것 — 을 쓰면 본문의 **굵은 글씨** 로 시작하는 줄이
전부 리스트 항목이 된다(* 마커 + 뒤따르는 *). 두 언어가 굵은 글씨로 문단을 여는 빈도가 다르므로
하드 축 하나가 오늘 32쌍 중 17쌍에서 빨개진다.
| 쌍 | 글자대로 (정본/번역) | 마커 뒤 공백 요구 |
|---|---|---|
subagent-development-guide |
70 / 73 (Δ3) | 52 / 52 |
scope-model |
31 / 34 (Δ3) | 20 / 20 |
workflow-cli-guide |
43 / 45 (Δ2) | 27 / 27 |
parallel-tool-execution-guide |
51 / 49 (Δ2) | 39 / 39 |
aimon-core-integration-via-cli-reference |
147 / 145 (Δ2) | 119 / 119 |
| 깨지는 쌍 | 17 / 32 | 0 / 32 |
이 문서가 이 결함을 그냥 넘길 수 없는 이유는 자기가 적어 둔 세 문장 때문이다. 이 절의 첫 문장이
정의를 적는 이유를 "해석이 갈릴 수 있어서" 라고 밝히고 여섯 행 중 다섯은 실제로 갈림을 막는 단서를
달았는데(ATX, "연속 구간 수. 줄 수가 아니다", "구분선·헤더 포함") 리스트 행에만 없었다.
게다가 같은 줄의 "들여쓰기 무관" 과 §1.3 (f) 의 "마크다운 파서가 아니라 정규식으로 돌 것" 이
구현자에게 파서 규칙을 가정하지 말라고 읽히므로, 글자대로 읽는 쪽이 오히려 성실한 독해다.
그리고 그 사각지대가 §1.3 (f) 의 인구조사에 빠져 있었다 — 그 표는 "파서였다면 달랐을 자리를 먼저
세었다" 며 완전한 목록을 자처하는데, 이 코퍼스에서 가장 큰 파서–정규식 차이가 거기 없었다. 그 행을
추가했다.
나머지 다섯 축도 같은 눈으로 다시 쟀다. 각 정의가 허용하는 읽기를 전부 구현해 32쌍에 돌렸다 —
제목 4가지(공백 요구 유무 × 선행 공백 허용 폭), 표 행 3가지(열 0칸 / 선행 공백 / 인용 제외),
펜스 4가지(개수 vs 언어 열 × 들여쓰기 허용 여부), 인용 3가지(구간을 끊는 것: 아무 비-> 줄 / 빈 줄만 /
펜스도 끊는다), 펜스 안 # 2가지. 16가지 전부 0/32 불일치이고, 읽기가 답을 바꾸는 축은
list-items 하나뿐이었다.
IMPORTANT: 그렇다고 나머지 다섯의 단서를 생략하지 않는다. "오늘 어느 읽기로도 같다" 는 관측이지 명세가 아니다. 리스트 축도 어제까지는 그 관측 안에 있었다 — 코퍼스가 우연히 관대했던 축은 다음 문서 하나에 깨진다. 그래서 여섯 축 전부에 패턴을 붙였다.
1.2 함정 셋도 재현했다 — 그리고 한 숫자의 범위가 암묵적이었다¶
| 함정 | 항목이 적은 것 | 재측정 |
|---|---|---|
| 줄 수는 쓸 수 없다 | parallel-tool-execution-guide 가 16% 짧다 |
정확히 재현. 328 → 275 줄, −16.2%. 그리고 32쌍 전부가 줄 수에서 어긋난다(+8.3% ~ −16.2%) — 이 축은 하나도 못 쓴다 |
| 인용은 블록으로 센다 | 줄로 세면 19쌍 중 7쌍이 거짓 양성 | 결론 재현, 범위가 암묵적이었다. 32쌍 전부에 대고 줄로 세면 10쌍이 걸린다. 그 "19" 는 전체가 아니라 PR #33 이 source_commit 을 다시 겨눈 그 파일들이었고, 그 집합으로 좁히면 정확히 7쌍이다. 블록으로 세면 0쌍 |
| 인라인 코드 스팬 정규식은 줄을 넘어야 한다 | 줄 단위로 짜면 두 줄에 걸친 스팬을 놓친다 | 재현. 코퍼스 전체에서 그런 스팬은 한 곳 — builtin-agent-skill-guide 의 `[클래스패스 번들(폴백) < …]`. 한 곳이면 충분하다: 그 한 곳이 "번역본에만 있는 코드" 라는 거짓 신호를 낸다 |
두 번째 줄이 백로그 규칙 여섯의 사례다. 인용은 정확했고 결론도 옳았는데, 그 숫자가 어느 모집단에서 나왔는지가 항목에 적혀 있지 않았다. 세는 법을 함께 적지 않은 숫자는 다음 사람이 다시 센다.
1.3 항목이 적지 않은 것 — 이 설계가 처분을 정하려면 필요한 여섯 가지¶
(a) frontmatter 를 벗기지 않으면 32건 전부가 거짓 제목을 얻는다. 번역본의 frontmatter 를 닫는
--- 는 바로 위에 source_commit: <sha> 라는 비어 있지 않은 줄이 있으므로 setext h2 의 모양을
정확히 갖춘다. 벗긴 뒤 다시 세면 코퍼스 전체에서 진짜 setext 제목은 0건이다.
(b) 여섯째 축은 거의 아무것도 세지 않는다. 32쌍 중 29쌍이 0 대 0이고, 값이 있는 것은 세 쌍
(builtin-agent-skill-guide 2, architecture 1, memory-usage-guide 1)뿐이다.
© 그리고 그 축은 자기가 내세운 근거를 지키지 못한다. 항목이 그 축을 둔 이유는
"코드 펜스 안의 주석도 번역 대상" 이기 때문인데, 정본 문서 전체(docs/** 와 CONTRIBUTING.md,
번역본 제외)에서 펜스 안 주석 마커의 분포는 이렇다.
| 마커 | 건수 |
|---|---|
// |
438 |
/* · * |
128 |
# |
21 |
<!-- |
1 |
즉 그 축은 자기 근거의 21 / 588 = 3.6% 를 덮는다. 그러면 넓히면 되는가 — 안 된다. 넓히면 오늘 3쌍이 깨진다.
| 쌍 | 정본의 펜스 주석 줄 | 번역의 펜스 주석 줄 | 원인 |
|---|---|---|---|
tool-development-guide |
98 | 99 | 한글 주석 2줄이 영어 3줄로 접힘 |
aimon-core-integration-via-cli-reference |
79 | 85 | 같음, 다섯 블록에서 |
embedding-agent-in-application |
37 | 38 | 같음 |
원인은 함정 1과 같다 — 한글은 칸당 정보가 많아 같은 문장이 영어에서 한 줄 더 접힌다. 펜스 밖에서 줄 수를 못 쓰는 이유가 펜스 안에서도 그대로 성립한다. 펜스 안 전체 줄 수로 재면 5쌍이 깨진다.
(d) 축 넷은 오늘 공짜로 더 좁힐 수 있다. 네 가지 강화판이 전부 32/32 일치한다.
| 강화 | 무엇이 더 잡히나 | 오늘 결과 |
|---|---|---|
| 펜스 언어 열 (개수가 아니라 순서 있는 목록) | ```java → ```text 처럼 개수가 안 변하는 변조 |
32/32 일치 |
| 표별 행 수 벡터 (전역 합계가 아니라) | 표 A 의 행을 표 B 로 옮기는 변조 (합계 불변) | 32/32 일치 |
| 절별 벡터 (제목으로 버킷) | 어느 절이 어긋났는지 위치 | 32/32 일치 |
| 리스트 들여쓰기 열 | 중첩 깊이가 무너지는 변조 | 32/32 일치 |
(e) "번역되지 않는 것" 대조는 게이트가 될 수 없다. 인라인 코드 스팬 multiset 을 그대로 비교하면 24/32 쌍이 비대칭이고 279건이 남는다. 기계적으로 변명할 수 있는 것을 빼면 이렇게 된다.
단위를 먼저 못박는다 — 아래 숫자는 스팬 항목이고, 재겨냥은 한 사건이 정본 쪽 하나와 번역 쪽 하나를 함께 만들므로 사건 115 = 항목 230 이다. 섞으면 합이 안 맞는다.
| 변명 | 사건 | 스팬 항목 |
|---|---|---|
.md → .en.md 링크 재겨냥 (documentation-guide.md §5.4 가 시키는 것) |
115 | 230 |
백틱 안 한글이 번역된 것 (<해시> → <hash>) |
— | 16 |
| 남는 것 | — | 33 (14쌍) |
| 합 | 279 ✓ |
남은 33건의 표본은 전부 정당했다 — `merged.id == winner` 한 스팬이 `merged.id` 와
`winner` 두 스팬이 된 것, `never throw` 라는 영어 구절이 한국어 본문에서는 백틱을 달고
영어 본문에서는 안 다는 것. 정당한데 기계가 정당하다고 말할 방법이 없다. 그러므로 이 층은
게이트가 아니라 손으로 돌리는 감사 모드다(§2.3).
(f) regex 사각지대 인구조사. 이 검사는 마크다운 파서가 아니라 정규식으로 돌 것이므로, 파서였다면 달랐을 자리를 먼저 세었다(32쌍 = 64파일).
| 모양 | 건수 | 처분 |
|---|---|---|
| setext 제목 | 0 (frontmatter 를 벗긴 뒤) | 지원하지 않는다. §8 에 남긴다 |
~~~ 펜스 |
0 | 마커는 매칭하되 오늘 데이터는 없다 |
| 백틱 4개 이상 펜스 | 0 | 같음 |
| 인용 블록 안의 표 | 0 | 표 행은 인용 안에서 세지 않는다 |
| 들여쓴 펜스 (리스트 항목 안) | 12 — 한 쌍이 아니라 세 쌍이다: memory-usage-guide 8 · agent-session-guide 2 · workflow-usage-guide 2 |
^\s* 앵커로 처리된다 |
<details> 블록 |
2 | 안쪽은 그냥 마크다운이라 그대로 세어진다 |
손으로 쓴 <a id=…> 앵커 |
2 | 축이 아니다. check-doc-links.py 의 소관 |
**굵은 글씨** 로 문단을 여는 줄 |
328 (정본 164 / 번역 164) | §1.1 의 리스트 패턴이 마커 뒤 공백을 요구하는 것으로 처리된다. 요구하지 않으면 전부 리스트 항목이 되고 17/32 가 깨진다 |
--- 수평선 |
572 | 같은 자리. 양쪽에 대칭이라 혼자서는 불일치를 만들지 않지만, 같은 패턴 하나가 둘 다 처리한다 |
*기울임* 으로 시작하는 줄 (접힌 산문의 이어짐) |
2 | 같음 |
IMPORTANT: 아래 세 행이 나중에 추가되었다. 이 표는 "파서였다면 달랐을 자리를 먼저 세었다" 며
완전한 목록을 자처했는데, 0건짜리 넷(setext · ~~~ · 4백틱 · 인용 안 표)은 세어 두고 이 코퍼스에서
가장 큰 파서–정규식 차이 900건을 세지 않았다. 백로그 규칙 여섯이 겨누는 모양 그대로다 — 인구조사가
결론을 지탱하는데 도구의 사각지대가 목록에 안 보였다.
2. 여섯 축의 처분¶
2.1 정당한 발산을 구분하는 법 — 순서가 있다¶
이 설계의 핵심 질문이고, 답은 축마다 다른 필터를 만드는 것이 아니라 세 단계를 순서대로 밟는 것이다.
1단계 — 정당하게 발산하지 않는 것만 축으로 쓴다. 여섯 축이 통하는 이유는 우연이 아니라
번역자에게 바꿀 재량이 없는 것만 세기 때문이다. 그러려면 재량이 어디에 있는지를 세어야 하고,
세는 법은 이것이다 — 규칙 문서 넷(CLAUDE.md · CONTRIBUTING.md · documentation-guide.md ·
translation-glossary.md)에서 번역자에게 무언가를 시키는 항목을 뽑고, 각각이 어느 구조를 건드리는지
본다. 넷이다.
| # | 규칙 문서가 시키는 것 | 어디 | 무엇을 건드리나 | 하드 축에 보이나 |
|---|---|---|---|---|
| 1 | ASCII 다이어그램을 고치지 말고 다시 그려라 | CLAUDE.md "ASCII 다이어그램" 줄, CONTRIBUTING.md |
펜스 안의 줄 | 아니다 — 펜스는 개수와 언어만 센다 (프로브 11) |
| 2 | 제목을 옮겼으면 #링크 를 다시 겨눠라 |
CLAUDE.md "제목을 번역하면 앵커가" 줄, documentation-guide.md §5.4 |
인라인 링크 문자열 | 아니다 (프로브 13) |
| 3 | 코드 블록 안의 주석은 번역해라 | CLAUDE.md "식별자는 번역하지 않는다" 줄의 꼬리, CONTRIBUTING.md, translation-glossary.md §1 의 소제목 |
펜스 안의 줄 — 번역된 주석은 접힌다 | 아니다 — 그러나 이것이 §1.3 © 가 재는 것이고, 여섯째 축을 조언으로 강등시킨 원인이다 |
| 4 | 번역 파일에는 frontmatter 를 붙여라 | documentation-guide.md §5.3 |
파일 앞머리 네 줄 | 아니다 — 벗겨내기 때문이다. 이것이 §1.3 (a) 가 재는 것이고, 안 벗기면 32쌍 전부가 거짓 제목을 얻는다 |
넷 중 둘이 구조를 실제로 바꾸고, 이 설계는 그 둘 때문에 이미 한 번씩 양보했다 — 3번 때문에 축 하나를 강등했고(§2.2), 4번 때문에 모든 축 앞에 전처리 한 단계를 두었다(§1.1). 즉 1단계는 "축이 운 좋게 안전하더라"가 아니라 "세어 보니 넷이고, 둘은 설계로 흡수하고 둘은 축이 애초에 안 본다" 다. 반대로 표에서 행을 빼라거나 절을 합치라고 시키는 규칙은 넷 중에 없다.
초안은 이 인구조사를 "둘" 로 적었다(1·2번만). 처분은 그대로지만 전제가 틀렸고, 그 전제가 틀린 방식이 나빴다 — 빠진 둘이 하필 이 문서 자신이 §1.3 에서 재고 있던 것들이었다. 백로그 규칙 여섯 그대로다: "'N건' 도 도구가 만들어 낸 숫자이며, 그 N 이 결론을 지탱하고 있다면 도구의 사각지대가 곧 결론의 사각지대다." 일곱째 축을 더하려는 사람은 이 표를 근거로 삼게 되므로, 세는 법을 함께 적는다.
즉 정당한 발산은 걸러내는 것이 아니라 축을 고를 때 설계에서 빼는 것이고, 함정 1(줄 수)은 그것을 빼지 못한 축이다.
| 축 | 이 축을 정당하게 바꾸는 번역 행위가 있는가 | 근거 |
|---|---|---|
| 제목 (개수 + 레벨 열) | 없다 | 두 규칙 문서가 개수를 맞추라고 명시한다. 레벨이 바뀌면 목차와 앵커가 갈린다 |
| 펜스 (개수 + 언어 열) | 없다 | "다시 그려라" 가 겨누는 것은 펜스 안이다. 개수와 언어는 그 명령의 대상이 아니다 |
| 표 행 (표별 벡터) | 없다 | 행 하나가 사실 하나다. 합치거나 나눌 재량이 없다 |
| 리스트 항목 | 없다 | 같다 |
| 인용 블록 | 없다 (> 줄에는 있다) |
접힘이 줄 수를 바꾸지만 블록 수는 바꾸지 않는다 — 10쌍 대 0쌍(§1.2) |
펜스 안 # 줄 |
있다 | 주석은 번역 대상이고 번역된 주석은 접힌다. # 만 세는 지금 형태는 오늘 32/32 일치하지만 그것은 데이터가 없어서다(29쌍이 0 대 0) — 근거대로 // 까지 넓히면 3쌍이 깨진다(§1.3 (b)©) |
2단계 — 1단계를 통과하지 못한 축은 예외를 주는 대신 강등한다. 조언은 아무것도 막지 않지만 아무 거짓말도 하지 않는다. 예외 목록이 붙은 하드 축보다 낫다 — 예외는 왜 있는지 잊히고, 강등은 잊힐 것이 없다.
3단계 — 1·2 를 통과한 다섯 축에만 예외 장치를 준다. 오늘 필요한 예외는 0건이고, 장치가 필요한 이유는 §4.1 에 적는다.
2.2 등급표¶
| 축 | 등급 | 강화판 | 비고 |
|---|---|---|---|
headings — 제목 개수 + 레벨 열 |
하드 | — | 정렬 축이다. §2.3 |
fences — 펜스 개수 + 언어 열 |
하드 | 언어 열 | 펜스 안은 보지 않는다 |
table-rows — 표별 행 수 벡터 |
하드 | 표별 벡터 | 전역 합계는 폴백 |
list-items — 리스트 항목 수 |
하드 | 들여쓰기 열 | |
quote-blocks — 인용 블록 수 |
하드 | — | 줄 수는 절대 쓰지 않는다 |
fence-hash-lines — 펜스 안 # 줄 수 |
조언 | 넓히지 않는다 | §1.3 (b)© |
2.3 제목은 정렬 축이다 — 검사는 2패스다¶
위치 축(표별 벡터, 절별 벡터, 들여쓰기 열)은 두 문서의 제목이 정렬될 때만 의미가 있다. 절 하나가 통째로 빠지면 그 뒤의 모든 절이 한 칸씩 밀리고, 위치 축은 전부 빨갛게 된다 — 진짜 발견 하나가 40개의 잡음에 묻힌다.
그래서 순서를 계약으로 적는다.
- 패스 1 — 전역 개수 다섯 축. 제목이 여기서 어긋나면 거기서 멈춘다
- 패스 2 — 위치 벡터. 패스 1 의 제목 축이 통과했을 때만 돈다
이것이 §4.4 에서 "제목 축을 면제하면 무슨 일이 나는가" 의 답이기도 하다.
분절 규칙 — 벡터는 새 계수기가 아니라 §1.1 축의 재배치다¶
IMPORTANT: 위치 벡터는 §1.1 의 축을 그대로 쓴다. 세는 것도 같고 패턴도 같고, 달라지는 것은 그 결과를 어느 통에 넣는가뿐이다. 이 문장이 규칙인 이유는 지키지 않으면 축의 함정이 벡터 안에서 되살아나기 때문이다 — 절별 벡터를 쓰면서 인용을 블록이 아니라 줄로 세어 보았더니 §1.2 가 기록한 바로 그 수(10/32)가 그대로 돌아왔다. 벡터가 전역 축보다 촘촘하다고 해서 함정에 덜 걸리는 것이 아니다.
세 벡터의 분절은 이렇게 정한다. 셋 다 오늘 32쌍에서 0 불일치이며, 괄호 안은 함께 재어 본 대안이다(전부 같은 답을 낸다 — §1.1 의 마지막 IMPORTANT 와 같은 이유로 그래도 골라 적는다).
| 벡터 | 분절 | 대안도 재어 봤다 |
|---|---|---|
| 표별 행 수 | 연속한 표 행이 한 표다. 표 행이 아닌 줄은 무엇이든 표를 닫는다(빈 줄·제목·펜스·산문) | "빈 줄과 제목만 닫는다" 로도 재었다 — 0/32 로 같다 |
| 절별 | 모든 레벨의 ATX 제목이 새 통을 연다. ## 만 · ##+### 로 버킷하지 않는다 |
셋 다 0/32. 모든 레벨을 고른 이유는 ### 하나가 빠진 것을 그 절 안에서 가리키기 위해서다 — 상위 레벨로만 버킷하면 그 손실이 더 큰 통 안에 묻힌다 |
| 리스트 들여쓰기 열 | 항목마다 선행 공백의 칸 수를 문서 순서대로. 정규화하지 않는다 | 반칸 정규화 · 0/1 이진으로도 재었다 — 0/32 로 같다. 코퍼스에 탭은 0건이므로 탭 환산 규칙은 두지 않는다 |
각 통에 담기는 것은 그 절 안의 표 행 수 · 리스트 항목 수 · 인용 블록 수 · 펜스 수이며, 넷 다 §1.1 의 패턴 그대로다.
2.4 강등한 둘¶
fence-hash-lines— 항상 보고하고 절대 실패시키지 않는다. 넓히지 않는 이유는 §1.3 © 다. 왜 지우지 않는가: 지우는 쪽 근거가 실제로 더 무겁다(29/32 쌍이 0 대 0, 자기 근거의 3.6% 만 덮음, 넓히면 3쌍이 깨짐, 29/32 쌍에서 프로브조차 no-op). 남기는 근거는 하나뿐이다 — 비용이 0 이다. 하드 축이 아니므로 아무것도 막지 않고, 면제 대상이 아니므로 예외를 쌓지 않으며, §2.1 1단계 표의 3번(펜스 안 주석은 번역 대상)이 유일하게 남긴 관측 지점이라 지우면 그 지시가 구조에 미치는 영향을 보는 창이 하나도 없어진다. 남기는 값이 0 에 가깝고 비용도 0 이므로 판단이지 논증이 아니며, 다음 사람이 지우기로 해도 이 문단이 다시 세지 않게 해 준다- "번역되지 않는 것" 대조 — 기본 출력에 넣지 않는다. 14/32 쌍에서 정당한 잡음을 매번 뱉는
출력은 읽히지 않는 출력이고, 읽히지 않는 출력은 T-1 §0 이 진단한 "초록 잡의 콘솔" 과 같은
물건이다. 손으로 번역 감사를 할 때 켜는
--drift모드로 두기로 했으나 구현하지 않았다 — 출력 형태를 정하지 못했고(§8-8), 게이트가 아니므로 CI 스텝에도 없다. 열린 항목으로 등록되어 있다
3. 실패인가 경고인가¶
3.1 옆 스크립트가 이미 답한 것, 그리고 그 답을 가른 기준¶
check-translation-staleness.py 의 헤더가 두 갈래를 이렇게 갈랐다.
| 발견 | exit | 이유 (그 파일의 문장) |
|---|---|---|
stale |
0 | "a translation backlog that blocks edits to the canonical makes the canonical go stale instead — the worse of the two failure modes" |
unresolvable |
1 | "failing on unresolvable does not pressure anyone to skip a translation, it asks for a resolvable SHA, which is one line and belongs to whoever wrote the file" |
기준을 한 문장으로 줄이면 이렇다 — 실패가 요구하는 것이 번역 노동인가, 아니면 그 파일의 한 줄인가.
3.2 이 기준을 그대로 적용하면 답이 갈라진다 — 구조 불일치는 한 사건이 아니기 때문이다¶
구조 불일치를 고치는 것은 확실히 번역 노동이다. 표 행 하나를 채우려면 그 행을 번역해야 한다. 기준을
글자대로 읽으면 stale 쪽이고 exit 0 이다. 그런데 그 적용이 답을 하나로 만들지 못한다.
구조 불일치가 생기는 경로가 둘이고, 두 경로에서 노동을 요구받는 사람이 다르기 때문이다.
| 경로 | 무슨 일이 있었나 | 누가 무엇을 요구받나 |
|---|---|---|
| A 정본이 움직였다 | 정본에 행이 하나 늘고 번역본은 그대로 | 정본을 고친 사람이 번역 노동을 요구받는다. 정확히 stale 이 막으려는 압력이고, 스테일 검사가 이미 이것을 보고하고 있다 |
| B 번역이 잘못 쓰였다 | 두 파일이 같은 시점에 있는데 내용이 어긋난다 | 그 번역을 지금 쓰고 있는 사람이 자기가 하던 일을 끝내라는 요구를 받는다. 새 노동이 아니다 |
T-1 이 겨누는 것은 B 다 — "정본과 같은 시점에 있으면서 내용이 어긋난 번역은 두 검사 모두 초록으로 통과한다." 그리고 T-1 이 재검토 트리거로 고른 사건("번역본을 새로 만들거나, 정본을 고치면서 번역본을 따라 고칠 때 … 그 순간 사람은 이미 두 파일을 나란히 놓고 있다")이 정확히 B 의 순간이다.
3.3 그러므로 게이트는 축이 아니라 쌍의 상태다¶
상태의 분할은 §5.2 의 pair_state() 가 돌려주는 것과 정확히 같아야 한다. 초안은 표를
얕은 클론 축으로 갈라 놓고 pair_state() 는 document|history 축으로 갈라, 두 분할이 겹치지 않는
칸 하나를 비워 두었다 — 전체 클론에서의 UNRESOLVABLE(history). 아래 표는 pair_state() 의 분할을
그대로 쓴다.
pair_state() |
구조 불일치의 처분 | 왜 |
|---|---|---|
| FRESH — 정본과 같은 시점 | exit 1 | 경로 B. 번역이 스스로 최신이라고 말하는데 구조가 아니라고 말한다. 요구하는 것은 새 노동이 아니라 하던 일의 완결이다 |
| STALE — 정본이 움직였다 | 보고, exit 0 | 경로 A. stale 결정 그대로. 그리고 같은 잡의 스테일 절반이 이미 그 쌍을 보고하고 있다 |
| UNRESOLVABLE (document) — frontmatter 없음 · 정본 경로가 없음 · 정본을 diff 할 수 없음 | 보고, exit 0 | 비교할 상대를 못 찾는다. canonical_of() 가 translated_from 에서 나오므로 앞의 두 갈래에서는 정본이 무엇인지조차 모른다 — 처분이 관대해서가 아니라 잴 것이 없다 |
UNRESOLVABLE (history), 전체 클론 — source_commit 이 이 이력에 없거나 HEAD 의 조상이 아니다 |
보고, exit 0 | 이 저장소가 2026-09-06 직전에 실제로 있던 상태다 — 스쿼시가 32쌍 중 19쌍을 한 번에 무효화했고 CI 는 얕은 클론이 아니었다. 구조 비교는 가능하지만(파일 둘만 있으면 된다) 신선도를 말할 수 없으므로 하드로 올릴 근거가 없다. 그리고 스테일 검사가 이미 그 쌍 때문에 exit 1 을 낸다 |
| UNRESOLVABLE (history), 얕은 클론 | 보고, exit 0 + 그 사실 명시 | 같은 이유에 하나 더 — 그 상태는 클론의 깊이 탓이지 파일 탓이 아니다. CI 는 fetch-depth: 0 이므로 여기 오지 않는다 |
넷째 행이 초안에 없었다. 그것이 이 문서가 T-1 §0 에서 인용한 결함의 모양 그대로다 — "설명이 덮는
범위가 값의 범위보다 좁았다." 지적하면서 자기 표에서 반복했으므로, 여기서는 표의 분할을 값의 분할
(pair_state())에 못박아 두 번 갈라지지 않게 한다.
오늘 32쌍이 전부 FRESH 다(실행 확인). 즉 이 검사는 조언 모드로 시작하는 것이 아니라 첫날부터 코퍼스 전체에 하드로 걸린 채 통과한다.
3.4 항상 하드로 두면 옆 결정이 옆문으로 뒤집힌다¶
이 절이 §3.3 을 필수로 만드는 논거다. 등급을 축에만 매고 상태를 보지 않으면 이렇게 된다.
정본
foo.md에 표 행 하나를 더한다. 번역본은 건드리지 않는다. → 스테일 검사: STALE, exit 0 (보고만) → 구조 검사(항상 하드): exit 1
정본을 고친 사람이 번역 밀림 때문에 빨간 빌드를 받는다. 이것은 stale 결정이 없애려고 만든 바로
그 상태이고, 그 결정이 명시적으로 "the worse of the two failure modes" 라고 부른 것이다. 결정을
반박하지 않은 채 다른 스크립트로 같은 압력을 만드는 것은 T-1 §0 이 진단한 모양 — 결정이 닿지 않는
자리에 그 결정을 적용한 것의 거울상이다. 여기서는 결정이 닿는 자리에 그 결정을 적용하지 않는 쪽이다.
3.5 남는 구멍 — 일부러 스테일로 두면 조언으로 떨어진다¶
정직하게 적는다. §3.3 의 게이트는 회피 가능하다: 구조를 맞추기 싫으면 번역본을 갱신하지 않고 두면 된다. 그러면 쌍이 STALE 이 되고 구조 발견은 조언으로 떨어진다.
받아들이는 이유는 셋이고, 첫째는 초안보다 약하게 적는다.
- 그 상태는 기록에는 남는다. 같은 잡의 스테일 절반이 그 쌍을 STALE 로 보고한다. 초안은 이것을 "감춰지지 않는다" 라고 적었는데 그 문장은 T-1 §0 이 측정으로 반증한 것이다 — 애노테이션 19건 중 API 가 돌려준 것은 10건이고, 그 파일들은 어느 PR diff 에도 없어 리뷰 화면에 안 뜨며, "초록인 잡의 콘솔을 열 이유는 아무에게도 없다." §3.6 이 조언 출력에 대해 그 사실을 정면으로 인정하면서 여기서 같은 신호를 완화책으로 쓰면 두 절이 같은 것을 다르게 평가하게 된다. 정확히 말하면 이렇다 — STALE 보고는 영구적이고 조언보다 강하지만, 읽히리라는 보장은 없다
- 그 회피를 막는 방법이 §3.4 를 어기는 것뿐은 아니다. 직접 신호가 하나 있다 — 경로 B 의 정의가
"그 번역을 지금 쓰고 있는 사람" 이므로, PR diff 에 그 번역본이 들어 있는지를 보면 프록시
없이 판정된다. 그런데도 쌍의 상태라는 프록시를 고른 이유는 둘이다. diff 기반 판정은 base ref 를
요구하고(PR·push·머지 큐에서 각각 다르다) 쌍의 상태는 트리만 보면 나오는 성질이라 어떤
체크아웃에서도 같은 답을 준다. 그리고 이 저장소는 문서 검사를 변경 경로에 매는 것을 이미 거절했다 —
build.yml의docs-links잡 주석이 "No path filter, and none is wanted" 다 - 번역을 미룰 자유는 이 저장소가 이미 명시적으로 고른 것이다. 그 자유를 다른 검사로 회수하는 것은 설계가 아니라 우회다
셋을 합치면 결론은 "구멍이 없다" 가 아니라 "구멍은 있고, 막는 값보다 막는 대가가 크다" 다.
3.6 조언 출력이 초록 잡에서 보이지 않는다는 문제¶
T-1 §0 의 발견이 이 설계에 그대로 겨눠진다 — "초록인 잡의 콘솔을 열 이유는 아무에게도 없다", 그리고 check-runs API 는 애노테이션 19건 중 10건만 돌려준다. 그렇다면 조언 출력은 무슨 소용인가.
두 종류를 갈라야 한다.
- 하드 축의 조언(= STALE 쌍의 구조 발견) — 혼자 서 있는 신호가 아니다. 같은 잡이 그 쌍을 이미 STALE 로 보고하고 있고, 이 발견은 그 보고에 붙는 세부다. T-1 §0 의 상황(그 발견이 유일한 기록이었다)과 다르다
fence-hash-lines의 조언 — FRESH 쌍에서도 날 수 있으므로 초록 잡의 외로운 신호가 맞다. 그것을 인정하고 그대로 둔다. 이 축은 게이트가 아니라 이미 그 파일을 보고 있는 사람에게 주는 힌트이고, 게이트로 올리려면 별도의 근거가 필요한데 오늘 코퍼스는 그 근거를 댈 수 없다 (29/32 쌍이 0 대 0)
애노테이션은 하드 발견만 ::error 로 낸다. 조언은 콘솔에만 적는다 — 스테일 검사가 애노테이션 목록의
잘림을 이유로 두 발견을 다른 버킷에 넣은 것과 같은 판단이며, 여기서는 아예 목록을 다투지 않는다.
4. 예외를 어떻게 표현하는가¶
4.1 번역본 frontmatter 에 축 단위로 선언한다 — 인코딩까지 정해야 한다¶
이 frontmatter 를 읽는 파서는 둘이다. 검사 쪽은 §5.2 가 재사용하겠다고 한
frontmatter() 즉 줄 정규식(^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$)이고, 사이트 쪽은
mkdocs 의 mkdocs.utils.meta.get_data() 즉 YAML 이다. 두 계약이 다르므로 스키마만 정하고
인코딩을 안 정하면 양쪽 모두에서 안전하지 않다. 실측 둘.
(a) 관용적 YAML 목록은 검사 쪽에서 조용히 잘린다.
| 표기 | mkdocs YAML | frontmatter() 정규식 |
|---|---|---|
블록 목록 (- list-items / - fences) |
['list-items', 'fences'] |
'- list-items' — 둘째 축이 사라지고 첫째도 어떤 축 id 와도 안 맞는다 |
flow ([list-items, fences]) |
['list-items', 'fences'] |
'[list-items, fences]' (문자열) |
쉼표 문자열 (list-items, fences) |
'list-items, fences' |
'list-items, fences' |
두 파서가 같은 답을 내는 것은 마지막 형태뿐이다.
(b) 이유에 흔한 문자가 들어가면 사이트에서 frontmatter 가 통째로 사라진다. get_data() 는
except Exception: pass 로 YAML 오류를 삼키고 data = {} 를 돌려주면서 본문을 벗기지 않은 채
반환한다(소스 확인). 그러면 그 페이지는 raw frontmatter 를 본문으로 발행하고,
mkdocs build --strict 는 exit 0 이다(strict 는 warning 만 승격하는데 경고 자체가 없다).
실제로 쓸 법한 이유 여섯으로 재니 셋이 그렇게 된다.
| 이유 | mkdocs | 정규식 |
|---|---|---|
원문의 3항 목록이 영어에서 관용적으로 2항이 된다 |
ok | ok |
표 제목: 영어에서는 두 행으로 갈린다 |
소실 | ok |
`#링크` 를 재겨냥하면서 항목이 하나 줄었다 |
소실 | ok |
[a](b.md) 링크가 두 개로 갈린다 |
소실 | ok |
CLAUDE.md 의 규칙 - 구조를 정확히 맞춘다 - 의 예외 |
ok | ok |
영어에서 *강조* 가 항목을 하나 흡수한다 |
ok | ok |
세 번째가 특히 나쁘다. translation-glossary.md §1 이 식별자를 백틱에 두라고 요구하므로 가장
자연스러운 이유 형태가 사이트를 깨뜨린다.
정확히 무엇이 발행되는가 — "setext 제목이 된다" 는 우리 파일에서는 참이 아니다. 새는 것이
---로 끝나므로 닫는 줄이 setexth2가 될 것 같지만, Python-Markdown 으로 렌더해 보면 우리 frontmatter(키 줄 2개 이상)는<hr>+ raw YAML 한 문단 +<hr>이 된다. setexth2는 키 줄이 정확히 하나일 때만 나오고,translated_from과source_commit이 둘 다 필수이므로 그 형태는 나올 수 없다. 그리고 이 검사 자신은 영향을 받지 않는다 — §1.1 의 전처리는 YAML 이 아니라 텍스트 정규식으로 벗기므로 YAML 이 깨져도 정상 동작한다. 즉 이것은 §1.3 (a) 가 잡은 실패의 재발이 아니라 검사와 사이트가 같은 파일을 다르게 읽는 것이고, 나쁜 이유는 그쪽이다: 검사는 초록인데 발행된 페이지에 메타데이터가 노출된다.
그래서 인코딩을 못박고, 검사가 그것을 강제한다¶
---
translated_from: docs/features/tool/tool-development-guide.md
source_commit: eec9ccd
structure_exempt: list-items, fences
structure_exempt_reason: "표 제목: 영어에서는 두 행으로 갈린다"
---
structure_exempt는 쉼표로 구분한 축 id 문자열이다. 목록 표기(블록·flow)를 쓰지 않는다 — 위 표에서 두 파서가 합의하는 유일한 형태다. 검사는^[a-z][a-z-]*(?:,\s*[a-z][a-z-]*)*$를 요구하고, 안 맞으면 exit 1structure_exempt_reason은 큰따옴표로 감싼다. 감싸면 콜론·백틱·대괄호가 전부 안전해진다 (위 여섯 중 소실되던 셋이 전부 통과하는 것을 확인했다). 검사는^"[^"\\]+"$를 요구하고, 안 맞으면 exit 1 — 큰따옴표와 역슬래시를 값에서 금지하는 것은 그 둘이 감싼 뒤에도 YAML 을 깨뜨리는 유일한 문자이기 때문이다(실측)- 이유가 아예 없어도 exit 1. 스테일 검사가
unresolvable에 대해 쓴 것과 같은 형태의 요구다 — 번역 노동이 아니라 그 파일의 한 줄이고, 그 줄은 예외를 단 사람의 손 안에 있다 - 반대 방향도 exit 1 —
structure_exempt없이structure_exempt_reason만 있는 줄 (구현 시점에 메운 빈칸. 초안은 한 방향만 정했다). 이것은 무해하다 — 아무것도 면제되지 않으므로 다섯 축이 전부 돌고 검사는 더 엄격해질 뿐이다. 그래도 실패시키는 이유는 셋이다. - 그것이 바로 위 불릿이 조언 축 면제를 거절한 그 모양이다 — 아무것도 끄지 않으면서 껐다고 읽히는 줄. 한 방향을 그 근거로 막고 반대 방향을 통과시키면 근거가 반쪽만 적용된다
- 그 상태가 만들어지는 경로는 대개 둘인데 둘 다 나쁘다:
structure_exempt를 지우면서 이유를 남긴 것(다음 사람은 면제가 살아 있다고 믿는다), 또는 키 이름을 잘못 적은 것(면제가 걸린 줄 알지만 안 걸렸다) - §4.5 의 기준을 그대로 적용하면 선언의 결함이다. 정본을 고쳐서 만들 수도 없앨 수도 없으므로 쌍의 상태를 보지 않는다
- 모양이 맞아도 어휘가 틀리면 exit 1. 위 정규식은 모양만 보므로
list-item(오타)이나fence-hash-lines(§2.2 가 조언으로 둔 축)도 통과한다. 둘 다 실패시킨다. - 오타를 조용히 무시하면 예외를 단 사람은 축이 꺼진 줄 알고, 다음 실패는 설명 없이 온다
- 조언 축의 면제는 아무것도 끄지 않는 줄이다. 그것이 커버리지 파일이 "a rule that always passes … reads as 'verified' … and verifies nothing" 이라고 거절한 그 모양이고, §4.2 가 축 단위 면제의 근거로 이미 인용한 문장이다
- 그러므로 검사는 값을 §2.2 의 하드 축 다섯 이름에 대조한다. 그 밖의 이름은 전부 exit 1
- 검사 순서가 정해져 있다: 모양 → 어휘 → 자기무효화(§4.3). 앞의 둘에서 떨어진 줄은 뒤로 가지 않으므로, §4.3 이 죽은 줄에 발화하는 일이 없다 — 존재하지 않는 축에 대해 "이제 일치한다" 를 물을 수는 없다
- 위 네 규칙은 전부 쌍의 상태와 무관하게 발화한다 — 근거는 §4.5
- 축 하나를 끄면 나머지 넷은 그대로 돈다
- 조언 축(
fence-hash-lines)은 면제 대상이 아니다. 실패시키지 않는 것에는 끌 것이 없다. 이것이 §2.1 2단계(강등)가 예외 장치보다 먼저 오는 이유의 실물이다
검사가 YAML 파서를 쓰지 않는 것도 결정이다. 그러면 (a) 가 통째로 사라지지만, 두 문서 잡은
actions/checkout 다음에 곧바로 python3 scripts/… 를 부를 뿐 setup-python 도 pip install 도
하지 않는다. import yaml 은 러너 이미지의 기본 패키지에 기대는 선언되지 않은 의존성이 된다.
위 두 규칙은 정규식만으로 검사되므로 그 질문을 열지 않는다.
남는 사각지대도 적는다. 이 규칙 둘은 이 설계가 더하는 키 둘만 지킨다. 다른 키에 YAML 을 깨뜨리는 값이 들어가면 사이트는 여전히 조용히 깨지고 아무것도 잡지 못한다. 오늘 32쌍의 frontmatter 는 전부 mkdocs 가 읽어내므로(확인함) 기존 결함은 없고, 전면적인 YAML 유효성 게이트는 위 문단의 의존성 질문을 여는 별개 변경이다.
4.2 왜 베이스라인 파일이 아닌가 — 두 선례를 읽고¶
| 선례 | 무엇을 동결하나 | 왜 그 모양인가 |
|---|---|---|
PackageDependencyArchitectureTest.BASELINE_TOP_LEVEL_CYCLES |
상호 의존하는 패키지 쌍 13개 | 쌍은 어느 한 파일의 것이 아니다. 거처가 없으므로 목록이 거처가 된다 |
gradle/coverage-baselines.properties |
모듈별 커버리지 하한 | 파일 자신의 주석이 이유를 적는다 — "the whole frozen set is visible on one page rather than scattered across the twenty build files that carry a floor" |
둘 다 자연스러운 거처가 없거나, 흩어지면 한 페이지로 볼 수 없게 되는 값이다. 구조 예외는 다르다 —
어긋난 그 번역본이 예외의 자연스러운 거처이고, 그 파일은 이미 이 도구 사슬이 읽는 frontmatter 를
갖고 있다(translated_from, source_commit). 새 파일도 새 파서도 필요 없다.
그리고 이유가 발견에서 멀어지는 대가를 이 저장소는 이미 치렀다 —
architecture-review-open-items.md 가 기록한 "낡아 있던 산문 일곱" 이 그것이고, 네 번 세어 네 번 다
적게 셌다. 예외의 이유는 예외 옆에 있어야 한다.
커버리지 파일의 규율 하나는 그대로 가져온다 — "A module with no entry gets no rule rather than a floor of zero. Zero would be a rule that always passes, which reads as 'verified' in the task list and verifies nothing." 축 단위 면제가 그 문장이다. 파일 통째 면제를 만들지 않는 이유이기도 하다.
4.3 베이스라인의 위험 — 그리고 무엇을 물려받고 무엇을 못 물려받는가¶
동결은 "늘지 않는 것" 만 보장하고 "줄어드는 것" 은 시키지 않는다. 쌓인 예외는 아무도 다시 읽지 않고, 읽지 않는 예외는 결국 검사가 무엇을 검사하고 있는지 아무도 모르는 상태가 된다.
BASELINE_TOP_LEVEL_CYCLES 는 그 위험에 대한 답을 이미 갖고 있고, 그 답은 실패 메시지에 적혀 있다 —
"The cycle was broken — delete the entry so the baseline keeps saying something true. A baseline
nobody shrinks is a baseline nobody reads." 그 테스트는 새 순환뿐 아니라 더 이상 참이 아닌
항목에도 실패한다.
그것을 그대로 물려받는다: 축이 다시 일치하는데 예외가 선언되어 있으면 FRESH 쌍에서 exit 1.
예외는 자기무효화한다. FRESH 쌍에서 라는 한정이 필수다 — 이 규칙의 방아쇠는 "축이 일치한다"
이고 그것은 두 파일에 함께 달려 있으므로 정본만 고쳐도 뒤집힌다(예: 정본에서 리스트 항목 하나를
빼면 list-items 면제가 갑자기 불필요해진다). 한정 없이 두면 정본을 고친 사람이 남의 예외 때문에
빨간 빌드를 받고, 그것이 §3.4 가 없앤 바로 그 압력이다. STALE 에서는 보고만 한다. 근거의 전체 모양은
§4.5.
물려받지 못하는 것도 적는다.
| 물려받는가 | |
|---|---|
| 자기무효화 (낡은 예외가 실패한다) | 그렇다 — §4.1 의 계약 |
| 한 페이지에서 전체를 보는 것 | 아니다. 32개 파일에 흩어진다 |
두 번째가 frontmatter 를 고른 대가다. 완화책은 둘이고 둘 다 부분적이다 — 검사가 매 실행마다
요약 줄에 예외 총계를 찍고, grep -rn "structure_exempt" docs/ 가 한 번의 명령이다. 그러나
증가를 막는 것은 없다. 초록 잡의 요약 줄은 §3.6 의 문제를 그대로 받는다.
그래서 숫자를 강제하는 대신 재검토 트리거로 둔다: 같은 축에 예외가 셋 이상 쌓이면, 틀린 것은 그 파일들이 아니라 그 축이다. 그때 할 일은 예외를 하나 더 다는 것이 아니라 축을 §2.1 2단계로 강등하는 것이다. 이 문장은 T-1 을 닫을 때 백로그 항목으로 옮긴다.
4.4 제목 축을 면제하면 무슨 일이 나는가¶
면제할 수 있다. 다만 §2.3 의 2패스 계약에 따라 위치 벡터 전체가 함께 꺼지고 전역 개수만 남는다.
검사는 그 사실을 출력에 적는다. 값이 비싼 면제이고, 비싼 것이 맞다 — 제목이 갈리면 앵커가 갈리고
(translation-glossary.md §6, documentation-guide.md §4), 목차가 갈리고, 위치로 말할 수 있는
것이 없어진다.
4.5 §4 의 exit 1 들과 §3.3 의 "STALE 은 실패시키지 않는다" 는 어떻게 공존하는가¶
초안은 §4 에 exit 1 을 둘 두고 §3.3·§7 에는 "STALE 쌍을 실패시키지 말 것" 을 한정 없이 적었다.
같은 쌍에서 둘이 반대를 지시한다 — STALE 인 쌍에 이유 없는 structure_exempt 가 달려 있으면
어느 쪽인가. 구현자가 어느 쪽을 골라도 문서의 다른 절과 어긋난다.
가르는 기준은 §3.1 의 것을 한 겹 더 밀면 나온다 — 그 상태를 만들 수 있는 사람이 누구인가.
| 규칙 | 그 상태를 만들 수 있는 것 | 쌍의 상태를 보는가 | 처분 |
|---|---|---|---|
structure_exempt 표기가 틀림 (§4.1) |
frontmatter 를 쓴 사람뿐. 정본을 고쳐서 만들 수 없다 | 아니다 | 항상 exit 1 |
structure_exempt_reason 이 없거나 표기가 틀림 (§4.1) |
같음 | 아니다 | 항상 exit 1 |
structure_exempt_reason 만 있고 structure_exempt 가 없음 (§4.1) |
같음 | 아니다 | 항상 exit 1 |
| 예외가 무효가 됨 — 축이 다시 일치 (§4.3) | 정본만 고쳐도 만들어진다 | 본다 | FRESH → exit 1, STALE → 보고 |
| 구조 불일치 자체 (§3.3) | 정본만 고쳐도 만들어진다 | 본다 | 표 그대로 |
위 둘은 구조 불일치가 아니라 선언의 결함이고, unresolvable 과 같은 부류다 — 요구하는 것이
번역 노동이 아니라 그 파일의 한 줄이며, 정본을 고치는 사람은 그 상태를 만들 수도 없앨 수도 없다.
그러므로 stale 결정의 사정거리 밖이다. 아래 둘은 안이다.
§7 의 금지도 그에 맞춰 한정된다 — "STALE 쌍의 구조 불일치를 실패시키지 말 것". 프로브도 갈라야 한다(§6.2 의 G5·G5′·G6·G6′).
5. 어디에 붙이는가¶
5.1 새 스크립트 — check-translation-staleness.py 에 얹지 않는다¶
얹지 않는 이유는 파일 길이가 아니라 그 파일의 exit 계약이 산문으로 쓰인 결정이라는 것이다. 헤더 주석 일곱 문단이 정확히 두 발견에 대해 쓰였고, T-1 §0 이 진단한 결함은 "그 설명이 덮는 범위가 값의 범위보다 좁았다" 였다. 세 번째 발견을 같은 exit code 아래로 밀어 넣는 것은 같은 결함을 의도적으로 다시 만드는 일이다.
그리고 두 검사가 답하는 질문이 다르다 — 하나는 최신인가, 하나는 온전한가. 전자는 이력에 대한 질문이고 후자는 파일 두 개에 대한 질문이다.
5.2 공유는 docs_tree.py 로 — 그 모듈이 존재하는 이유 그대로¶
구조 검사는 스테일 검사가 이미 가진 것 셋을 필요로 한다: 쌍 열거, frontmatter 읽기, 그리고 §3.3 때문에 신선도 판정.
docs_tree.py 의 docstring 이 자기 존재 이유를 적어 두었다 — "Each script carrying its own copy is
how the copies drift (.venv was in one skip set and missing from the other two), so the answers
live here and the scripts import them." 두 스크립트가 "이 쌍이 최신인가" 를 각자 쓴 git 호출로
계산하면, 하나는 얕은 클론을 변명하고 하나는 안 하는 상태가 만들어진다 — 스테일 스크립트 헤더가
네 문단을 들여 정확히 그 실수를 설명하고 있다.
| 옮길 것 | 시그니처 | 지금 어디 |
|---|---|---|
translations() — 번역 파일 열거 |
() -> list[Path] |
스테일 스크립트 (그대로) |
frontmatter() — --- 블록 파싱 |
(path) -> dict[str, str] |
같음 (그대로) |
canonical_of() — 선언된 정본 (이름 유추가 아니라 선언을 쓴다) |
(meta) -> Path \| None — 경로가 아니라 frontmatter() 가 돌려준 dict 를 받는다 |
없음 (신규) |
pair_state() — §3.3 의 표가 쓰는 분할 그대로 |
(translation, meta) -> FRESH \| STALE \| UNRESOLVABLE(document) \| UNRESOLVABLE(history) |
스테일 스크립트의 main() 안에 인라인 |
canonical_of() 가 경로가 아니라 meta 를 받는 것이 유일하게 새로 정할 것이었고, meta 쪽을
고른 이유는 둘이다. 두 소비자 모두 이미 meta 를 손에 들고 있다 — 스테일 검사는 같은 dict 에서
source_commit 을 꺼내고, 구조 검사는 structure_exempt 를 꺼낸다 — 그러므로 경로를 받으면
frontmatter 를 두 번 읽는다. 그리고 meta 를 요구하면 선언을 읽지 않고는 이 함수를 부를 수 없으므로,
"이름 유추가 아니라 선언을 쓴다" 는 결정이 문서가 아니라 시그니처에 박힌다.
얕은 클론 여부는 pair_state() 가 나르지 않는다. §3.3 의 다섯째 행이 "그 사유 명시" 를 요구하고
G9 가 그것을 프로브하지만, 그 판정은 쌍마다 다른 값이 아니라 저장소 하나의 성질이다. 그래서
호출자가 git rev-parse --is-shallow-repository 를 실행당 한 번 묻고 출력에 반영한다 — 옆
스크립트가 이미 main() 에서 정확히 그렇게 하고 있으므로 새 관례가 아니다. pair_state() 를
다섯 값으로 넓히면 같은 답이 쌍마다 32번 복제된다.
같은 커밋에서 그 모듈의 docstring 도 고친다. 지금은 "Three scripts walk the same markdown tree … each needs the same three answers" 라고 못 박고 있는데, 넷째 소비자와 답 둘이 붙으면 두 숫자가 다 거짓이 된다. 그 문장이 이 모듈이 존재하는 이유를 적은 문장이므로 특히 그렇다.
대가를 적는다. docs_tree.py 가 subprocess 를 갖게 되어 순수 텍스트 모듈이 아니게 된다.
받아들이는 이유는 위의 드리프트가 이 모듈이 막으려고 만들어진 바로 그것이기 때문이고, 대안
(스테일 스크립트를 import 가능한 이름으로 개명)은 갓 병합된 파일에 그 사유만으로 손대는 일이다.
canonical_of() 가 이름 유추가 아니라 translated_from 을 쓰는 것도 결정이다. 오늘 32건 전부에서
둘이 일치한다 — 그러나 일치는 검증이 아니라 복제이고(백로그 규칙 일곱), 선언된 쪽이 스테일 검사가
이미 신뢰하는 값이다.
5.3 CI — translations 잡(옛 translation-staleness)의 두 번째 스텝¶
새 잡을 만들지 않는다.
- 구조 검사는 §3.3 때문에 이력이 필요하다.
docs-links잡은 얕게 체크아웃하며 그럴 이유가 없다. 거기 붙이면 링크 검사에fetch-depth: 0을 강요하게 된다 translation-staleness는 이미fetch-depth: 0이고, 같은 쌍 집합과 같은 frontmatter 를 읽는다. 두 발견이 나란히 읽히는 것이 맞다- 이 저장소가 이미 같은 판단을 한다 —
playwrightTest는 별도 잡이 아니라 스텝이고, 그 이유가 워크플로 주석에 있다("몇 분 도는 계층에는 값하지만 컴파일 위에 1분 얹는 계층에는 아니다"). 두 번째 러너와 두 번째 체크아웃이 몇 초짜리 검사를 위한 비용이다
스텝 이름은 기존 스텝("Report stale translations, fail on unresolvable ones")과 같은 형태로 무엇을 보고하고 무엇에 실패하는지를 적는다.
- name: Compare structure, fail on a mismatch in a current translation
run: |
python3 scripts/check-translation-structure.py --self-test
python3 scripts/check-translation-structure.py --github
"in a current translation" 이 §3.3 의 게이트를 이름에 싣는다 — 실패가 FRESH 쌍에서만 온다는 것이 잡 목록에서 읽혀야, 정본만 고친 사람이 이 스텝을 보고 자기 차례라고 오해하지 않는다.
줄이 둘인 것은 구현이 더한 것이고 이유가 있다. 두 번째 줄은 코퍼스를 검사하고 첫 번째 줄은 검사를 검사한다(§6.2 의 마지막 문단). 오늘 잡을 결함이 0건인 검사에서 후자가 없으면, 축이 조용히 측정을 멈춘 상태와 측정하고 아무것도 못 찾은 상태가 출력에서 구분되지 않는다.
잡 이름은 translations 로 바꾼다. translation-staleness 라는 이름은 그 잡이 하는 일의 절반만
말하게 된다. 대가는 0 이다 — 확인했다: gh api repos/kangwoo/aimon-core/branches/main/protection
은 404 Branch not protected 이고 …/rulesets 는 [] 다. required check 가 아니므로 개명이
저장소 밖의 설정을 건드리지 않는다.
5.4 번역 대상 표 밖의 .en.md 는 어떻게 하는가¶
먼저 정정 하나. 그 표는 docs/README.md 가 아니라
../../project/documentation-guide.md §5.1 에 있다.
docs/README.md 의 번역 문단은 그쪽을 가리키기만 한다.
검사는 표를 보지 않는다. 파일이 있으면 검사한다.
- 표가 정하는 것은 번역이 기대되는 곳이지, 존재하는 번역이 어긋나도 되는 곳이 아니다.
표 밖의
.en.md도 사람이 읽는다 - 스테일 검사가 이미 그렇게 돈다 — 표가 아니라 저장소 전체를 훑는다. 두 검사가 서로 다른 모집단을 갖는 것이 §5.2 가 막으려는 드리프트다
- 오늘 32쌍은 전부 표 안에 있다(
docs/README.md·overview/·getting-started/·features/· 루트CONTRIBUTING.ko.md— 마지막은documentation-guide.md§5.2 의 역방향 규칙). 표 밖 번역은 0건이므로 이 규칙은 오늘 아무것도 바꾸지 않는다. 장래를 위한 것이다 - 표를 강제하지는 않는다. "대상 아님 디렉토리에 번역이 생겼다" 는 구조 문제가 아니라
documentation-guide.md의 문제이고, 검사 하나에 두 질문을 넣지 않는다
그리고 이것이 T-1 의 두 번째 트리거와 만나는 자리다 — project/ · references/ · migration/ 이
대상으로 승격되면 쌍이 32개보다 늘고, 검사는 승격을 기다리지 않고 그날 생긴 파일부터 본다.
6. 프로브 — 공허 통과가 아님을 어떻게 보이나¶
T-1 이 IMPORTANT 로 적어 둔 대로 오늘 잡을 결함은 0건이다. 그러므로 코퍼스는 이 검사가 무언가를
잡는다는 증거를 댈 수 없고, 증거는 프로브에서 나와야 한다. 형식은
architecture-review-open-items.md 가 everyTestTagIsGated 에 쓴 것을 따른다 — 양성 프로브와
오탐 프로브를 함께 돌리고, 넓힐 때마다 아래쪽 절반을 다시 돌린다.
6.1 축 프로브 — 14개를 실제로 돌렸다 (그리고 구현 뒤 다시 돌렸다)¶
축 정의를 구현한 측정 스크립트에 변조본을 먹여 확인했다. 기준 쌍은
tool-development-guide (# 축 프로브만 그 축에 데이터가 있는 세 쌍에서).
구현이 생긴 뒤 같은 열넷을 scripts/check-translation-structure.py 자체에 다시 먹였고, 결과가
줄 하나까지 같았다 — 임시 저장소에 그 쌍을 넣고 변조본으로 검사를 돌리는 방식이라, 아래 "발화한 축"
은 이제 설계 시점의 측정이 아니라 검사가 실제로 출력한 축 이름이다.
| # | 프로브 | 기대 | 결과 | 발화한 축 |
|---|---|---|---|---|
| 1 | 표 행 하나 삭제 | 실패 | 실패 | table-rows |
| 2 | ### 절 하나 통째 삭제 |
실패 | 실패 | headings · table-rows |
| 3 | ## 를 ### 로 강등 (개수 불변) |
실패 | 실패 | headings(레벨 열) |
| 4 | 리스트 항목 하나 삭제 | 실패 | 실패 | list-items |
| 5 | 코드 펜스 하나 통째 삭제 | 실패 | 실패 | fences |
| 6 | 펜스 언어 변경 (개수 불변) | 실패 | 실패 | fences(언어 열) |
| 7 | 인용 블록 하나를 둘로 쪼갬 (> 줄 수 불변) |
실패 | 실패 | quote-blocks |
| 8 | 표 A 의 행을 표 B 로 옮김 (총합 불변: 42 → 42) | 실패 | 실패 | table-rows(표별 벡터) |
| 9 | 펜스 안 # 주석 한 줄 삭제 (기준 쌍이 아니라 세 쌍에서) |
조언 | 조언 | fence-hash-lines |
| 10 | 산문 전체를 60칸으로 다시 접음 | 통과 | 통과 | — |
| 11 | ASCII 다이어그램 다시 그림 (펜스 안 줄 수 변화) | 통과 | 통과 | — |
| 12 | 펜스 안 // 주석이 한 줄 더 접힘 |
통과 | 통과 | — |
| 13 | 링크를 .md → .en.md 로 재겨냥 |
통과 | 통과 | — |
| 14 | 백틱 안 한글 자리표시자 → 영어 | 통과 | 통과 | — |
3·6·7·8 이 강화판이 값한다는 증거다. 넷 다 원래 축(개수·합계)만 보면 통과하고 강화판에서만 걸린다.
11·12·13 이 §2.1 1단계의 증거다. 셋 다 규칙 문서가 번역자에게 시키는 행위이고(각각 1번·3번·2번), 셋 다 하드 축을 하나도 건드리지 않는다. §2.1 의 넷째(frontmatter)에는 프로브가 없고 필요하지도 않다 — 32쌍 전부가 이미 그 상태이므로(정본 0/32, 번역본 32/32 가 frontmatter 를 갖는다) §1.1 의 32/32 일치 자체가 그 확인이다.
9 는 다른 것을 증명한다. 기준 쌍에서 돌렸을 때 이 프로브는 아무것도 발화시키지 못했다 —
그 문서의 펜스에 # 줄이 0개라 변조가 no-op 이었기 때문이다. 데이터가 있는 세 쌍
(builtin-agent-skill-guide 2 → 1, architecture 1 → 0, memory-usage-guide 1 → 0)에서 다시
돌려야 발화했다. 즉 이 축은 32쌍 중 29쌍에서 프로브조차 할 수 없다. §2.2 가 그것을 조언으로
둔 근거가 프로브에서 한 번 더 나온 셈이다.
6.2 게이트 프로브 — 구현과 함께 전부 돌렸다¶
축이 아니라 §3.3 의 등급 결정과 §4 의 예외를 겨눈다. 초안에서는 기대만 적혀 있었고, 구현 시점에
열여덟 줄 전부를 돌렸다(서브케이스를 펼치면 실행 스무 회 남짓 + 사이트 프로브 2회). 커밋을
만들어야 하는 것들(G2·G3·G4·G6′·G13·G14)은 임시 저장소나 이 저장소의 클론에 실제 커밋으로 상태를
만들었고, G9 는 그것을 --depth 1 로 클론해서 돌렸다.
| # | 프로브 | 기대 | 결과 | 무엇을 지키나 |
|---|---|---|---|---|
| G1 | FRESH 쌍의 번역본에서 표 행 하나 삭제 | exit 1 | exit 1 | §3.3 — T-1 이 겨눈 상태 |
| G2 | 정본에만 행을 더한다 (쌍이 STALE 이 된다) | exit 0 + 보고 | exit 0, behind 로 보고 |
§3.4 — stale 결정이 옆문으로 뒤집히지 않는다 |
| G3 | G2 상태에서 번역본의 source_commit 만 올린다 (번역 없이) |
exit 1 | exit 1 | 스쿼시 시나리오. FRESH 로 돌아오는 순간 하드가 걸린다 |
| G4 | 정본과 번역본을 한 커밋에서 함께 올바르게 고친다 | exit 0 | exit 0 | 정상 갱신이 막히지 않는다 |
| G5 | FRESH 쌍에 이유 없이 structure_exempt 선언 |
exit 1 | exit 1 | §4.1 |
| G5′ | STALE 쌍에 같은 것 | exit 1 (같다) | exit 1 | §4.5 — 선언의 결함은 쌍의 상태를 보지 않는다 |
| G5″ | structure_exempt: [a, b] (flow) · 블록 목록 · 따옴표 없는 이유 |
exit 1 (셋 다) | exit 1 ×3 | §4.1 인코딩 강제 |
| G5‴ | structure_exempt: list-item (오타) · structure_exempt: fence-hash-lines (조언 축) |
exit 1 (둘 다) | exit 1 ×2 | §4.1 어휘 강제 — 모양은 맞고 이름이 틀린 자리 |
| G6 | FRESH 쌍에서 축이 다시 일치하는데 예외가 남아 있다 | exit 1 | exit 1 | §4.3 자기무효화 |
| G6′ | STALE 쌍에서 같은 것 | exit 0 + 보고 | exit 0, 보고됨 | §4.5 — 정본만 고쳐도 만들어지는 상태다 |
| G7 | list-items 를 면제한 파일에서 표 행 하나 삭제 |
exit 1 | exit 1, table-rows 가 발화 |
축 단위 면제가 나머지를 끄지 않는다 |
| G8 | headings 면제 파일에서 표 A→B 행 이동 |
exit 0 + 위치 축이 꺼졌다는 출력 | exit 0 + 그 문장 | §4.4 |
| G9 | 얕은 클론에서 전체를 돌린다 | exit 0 + 그 사유 명시 | exit 0 + 그 문장 | §3.3 다섯째 행 |
| G10 | source_commit 을 이 이력에 없는 SHA 로 바꾸고 전체 클론에서 돌린다 |
exit 0 + 보고 | exit 0, 보고됨 | §3.3 넷째 행 — 이 저장소가 실제로 있던 상태 |
| G11 | 예외를 단 파일을 mkdocs build --strict 로 렌더하고 발행된 페이지에 frontmatter 가 안 보이는지 본다 |
frontmatter 노출 없음 | 노출 없음. 그리고 금지된 형태(따옴표 없는 이유)로 다시 렌더하니 노출됨 — --strict 는 여전히 exit 0 |
§4.1 (b) — 검사만 초록이고 사이트가 깨지는 것을 막는다 |
| G12 | structure_exempt_reason 만 있고 structure_exempt 가 없다 (구현 시점에 추가) |
exit 1 | exit 1 | §4.1 의 고아 이유 — 아무것도 끄지 않으면서 껐다고 읽히는 줄 |
| G13 | 정본만 고쳐 STALE 로 만든 뒤 CI 스텝 두 줄을 통째로 bash -e 로 돌린다 |
exit 0 | exit 0 | §3.4 — 검사 하나가 아니라 스텝이 초록이어야 한다 |
| G14 | 정당한 structure_exempt 를 단 뒤 같은 것 |
exit 0 | exit 0 | §4 의 탈출구가 스텝을 빨갛게 만들지 않는다 |
IMPORTANT: G13·G14 는 구현 리뷰가 만든 프로브다. 그 전까지 모든 프로브가 check-translation-structure.py
하나만 돌렸는데, 잡을 빨갛게 만든 것은 그 위에 있던 --self-test 줄이었다. run: | 두 줄은
Actions 기본 bash -e {0} 으로 도므로 첫 줄이 실패하면 본 검사는 실행조차 되지 않는다.
게이트를 프로브할 때 재는 단위는 검사가 아니라 CI 가 실제로 돌리는 것이다.
쌍으로 묶인 것들이 서로를 지킨다. G2/G3 은 게이트가 어느 쪽으로 무너져도 잡고(둘 중 하나만
보면 못 본다), G5/G5′ 와 G6/G6′ 은 §4.5 의 갈림이 실제로 갈리는지를 보고, G5″/G5‴ 는 §4.1 의 두
층(모양과 어휘)이 각각 서는지를 보며, G9/G10 은 §3.3 의 두 history 행이 서로 다른 이유로 같은
처분에 이르는 것을 확인한다. G11 만 성격이 다르다 — 검사가 아니라 사이트를 보는 유일한
프로브이고, §4.1 (b) 의 실패가 검사만으로는 안 보이기 때문이다.
축 프로브 쪽에도 하나가 빠져 있었다. §6.1 의 14개는 전부 §1.1 의 패턴을 이미 고른 뒤의
변조를 겨눈다 — 패턴 자체가 틀린 경우는 프로브가 아니라 §1.1 의 16가지 읽기 측정이 잡았다.
그 측정이 이제 check-translation-structure.py --self-test 로 굳어 있고 CI 스텝의 첫 줄에서 돈다.
네 가지 틀린 읽기를 코퍼스에 대고 돌린다 — 리스트 패턴의 \s 제거, 인용을 줄로 세기, 펜스 주석을
// 까지 넓히기, frontmatter 를 안 벗기기.
다만 "고정 기대값" 은 고르지 않았다. 초안은 17/32 를 못박으라고 적었는데, 그러면 번역 쌍이
하나 늘 때마다 그 숫자가 움직여 일어나지도 않은 회귀를 이름으로 지목하며 실패한다.
gradle/coverage-baselines.properties 가 자기 여유값을 정하며 적어 둔 문장이 그 대가다 —
"a rule that flakes gets excluded, and an excluded rule is worse than none." 그래서 강제하는 것은
"틀린 읽기가 여전히 무언가를 깨뜨린다" 이고, 실제 측정치는 설계가 기록한 값 옆에 찍어서
드리프트가 실패 없이 보이게 한다. 오늘 네 줄 전부 기록값과 같다(17 · 10 · 3 · 32).
IMPORTANT: 초안은 다섯째 읽기로 "지정된 패턴이 0쌍을 깨뜨리는지" 도 요구했다. 그것은 빼야 했다 —
구현 리뷰가 그 자리에서 이 설계의 게이트를 정면으로 뒤집는 것을 잡았다. 그 계수는 예외도 쌍의 상태도
보지 않으므로, 정본만 고치고 번역을 나중에 따라가게 하는 순간(§3.4 가 지키기로 한 바로 그 흐름) 1이
되고, 첫 번째 정당한 structure_exempt(§4 의 탈출구 전체)에서도 1이 된다. 그리고 CI 스텝은
bash -e 라 본 검사가 아예 실행되지 않는다 — §3.4·§7 이 없앤 압력이 그것을 강제하는 검사보다
한 줄 위에서 되살아난 것이다. 두 시나리오 다 실제로 재현했다.
같은 문단이 고정 기대값을 거절한 논거가 그 자리에도 그대로 적용된다는 것이 진단이다 — 움직이는 숫자에 하드 기대값을 박은 곳이 정확히 거기였다. 처분은 축이 아니라 비교의 모양을 바꾸는 것이다: 각 케이스는 이제 절대 개수가 아니라 차이를 센다 — "틀린 읽기가, 지정된 읽기는 문제 삼지 않는 쌍을, 하나라도 가르는가." 낡은 쌍도 면제된 쌍도 양쪽 읽기에서 모두 어긋나므로 상쇄되어 사라진다.
그 형태에는 덤이 하나 있다. 각 케이스가 자기 패턴을 직접 지킨다 — 프로덕션 패턴을 그 틀린 읽기로
넓히면 두 읽기가 같아져 차이가 0이 되고 그 케이스가 실패한다. 넷 중 셋을 실제로 변조해 확인했고,
넷째(frontmatter 벗기기)는 지키지 못한다: 오늘의 여섯 패턴은 --- 를 아예 보지 않으므로 벗기든 말든
결과가 같다. 그 케이스는 그래서 패턴의 가드가 아니라 코퍼스에 대한 진술이며(번역본만 frontmatter 를
갖는다, 32 대 0), 그 사실을 코드 주석과 §8 에 적었다.
"지정된 읽기가 지금 몇 쌍을 가르는가" 는 실패 없이 보고만 한다. 그 숫자를 게이트하는 것은 본 검사이고, 본 검사만이 예외와 쌍의 상태를 손에 들고 있다.
7. 하지 말 것¶
- 줄 수를 축으로 삼지 말 것. 펜스 밖이든 안이든. 32/32 와 5/32 가 각각 그 답이다(§1.2, §1.3 ©)
- 인용을
>줄로 세지 말 것. 10/32 대 0/32 - 인라인 코드 스팬 정규식을 줄 단위로 짜지 말 것. 코퍼스에 한 곳이 있고 한 곳이면 충분하다
- frontmatter 를 벗기기 전에 구조를 세지 말 것. 닫는
---가 setext 제목이 된다 — 32건 전부 - 리스트 마커 뒤의 공백 요구를 빼지 말 것.
**굵은 글씨**로 문단을 여는 줄 328개가 리스트 항목이 되어 17/32 가 깨진다(§1.1). 축 정의를 산문으로만 적는 것도 같은 금지다 — 그 정의가 갈릴 수 있으면 §1.1 의 패턴 블록에 한 줄을 더한다 - 위치 벡터에 새 계수기를 만들지 말 것. §1.1 의 축을 통에 나눠 담는 것뿐이다. 다르게 세면 그 축의 함정이 벡터 안에서 되살아난다 — 인용을 줄로 세어 보니 §1.2 의 10/32 가 그대로 돌아왔다(§2.3)
fence-hash-lines를//·/*로 넓히지 말 것. 오늘 3쌍이 깨진다. 넓히고 싶으면 먼저 그 3쌍을 설명해야 하고, 설명은 "영어 주석이 한 줄 더 접힌다" 이므로 축이 틀린 것이다- "번역되지 않는 것" 대조를 게이트로 올리지 말 것. 기계적 변명 둘을 뺀 뒤에도 14/32 쌍에 정당한 잔여가 남는다
- STALE 쌍의 구조 불일치를 실패시키지 말 것. §3.4. 한정이 붙는 이유는 §4.5 다 — 선언의 결함(§4.1)은 정본을 고쳐서 만들 수 없으므로 이 금지의 사정거리 밖이고, 쌍의 상태와 무관하게 실패한다
- 파일 통째 면제를 만들지 말 것. 축 단위여야 나머지 넷이 계속 돈다 (제목 축만 예외적으로 위치 벡터를 함께 끈다 — §4.4)
- 예외를 YAML 목록(블록·flow)으로 적지 말 것. 검사의 줄 정규식이 첫 항목만, 그것도
-를 달고 읽는다. 쉼표 문자열이 두 파서가 합의하는 유일한 형태다(§4.1 (a)) - 이유를 따옴표 없이 적지 말 것. 콜론·백틱·대괄호가 들어가면 mkdocs 가 frontmatter 를 통째로 버리고 검사는 초록인 채 사이트가 그것을 발행한다(§4.1 (b))
- 검사에
import yaml을 들이지 말 것. 두 문서 잡은pip install을 하지 않으므로 선언되지 않은 의존성이 된다(§4.1 마지막 문단) docs/project/documentation-guide.md§5.1 의 표를 검사의 모집단으로 쓰지 말 것. 표는 번역이 기대되는 곳을 정하지, 존재하는 번역이 어긋나도 되는 곳을 정하지 않는다
8. 아직 확인되지 않은 것¶
실측하지 않은 것은 실측하지 않았다고 적는다.
게이트 프로브를 돌리지 않았다.— 해소됨. 구현과 함께 열여덟 줄(G12~G14 포함) 전부를 돌렸고 전부 기대대로였다. 표는 §6.2 에 채워져 있다— 해소됨. 워크트리에서 안 보인다는 것은 사실이었지만translation-staleness가 브랜치 보호의 required check 인지 확인하지 못했다.gh로는 한 번의 호출이었다: branch protection 은404 Branch not protected, rulesets 는[]. 개명 비용 0(§5.3). "워크트리에서 안 보인다" 를 미확인의 근거로 쓴 것이 틀렸다 — 이 저장소는 릴리스 스크립트부터gh를 쓴다— 쟀다. 두 검사가 각각 약 4초이고(32쌍 × git 4회가 거의 전부다) 잡 전체가 8초대다.docs_tree.py에subprocess를 들이는 대가를 실측하지 않았다.--self-test는 git 을 안 부르므로 0.4초다. "무시할 만하다" 는 가정이 맞았고, 공유하지 않았다면 같은 8초가 들면서 두 계산이 갈릴 수 있었다는 것이 §5.2 의 논거였다-
frontmatter 를 벗기는 전처리에는 가드가 없다.
--self-test의 네 케이스 중 셋은 프로덕션 패턴을 그 틀린 읽기로 넓히면 실패하지만(확인함), 넷째는 못 한다 — 오늘의 여섯 패턴 중---를 보는 것이 하나도 없어서 벗기든 말든 결과가 같기 때문이다. 즉 그 한 줄이 사라져도 아무것도 빨개지지 않고,---를 보는 일곱째 축이 생기는 순간 32쌍이 한꺼번에 깨진다. 지금 가드를 만들면 오늘은 무조건 통과하는 규칙이 되므로(§4.2 가 인용한 "always passes … verifies nothing") 두지 않았다. 일곱째 축을 더하는 사람이 함께 세울 자리다 -
setext 제목을 지원하지 않기로 했는데, 그 결정의 대가는 코퍼스가 0건이라는 사실에만 기대고 있다. 누군가 setext 제목을 쓰면 그 제목은
headings축에 세어지지 않고, 정본에만 있으면 조용히 통과한다. 막는 장치를 두지 않았다 — 두려면 "setext 제목이 발견되면 실패" 라는 별개 검사가 필요하고, 그것은 구조 일치가 아니라 문서 문법의 문제다 project/·references/·migration/이 승격됐을 때 이 축들이 그대로 성립하는지 모른다. 그 디렉토리에는 아직 번역이 없으므로 잴 대상이 없다.migration/rename-maps.md처럼 표가 본문의 대부분인 문서에서table-rows표별 벡터가 어떻게 행동하는지는 그때 재야 한다- 예외가 실제로 필요해지는 경우를 하나도 보지 못했다. §4 의 장치 전체가 "언젠가 필요할 것" 위에 서 있다. 오늘 예외는 0건이고, 백로그 규칙 여섯의 표현대로 0이면 그것은 근거가 아니라 관측이다. 장치를 만드는 근거는 관측이 아니라 §4.3 의 논거(escape 없는 게이트는 처음 틀렸을 때 삭제된다)다
- frontmatter 의 나머지 키에 대한 YAML 유효성은 아무것도 지키지 않는다. §4.1 의 표기 강제는
이 설계가 더하는 키 둘만 덮는다.
translated_from이나 장래의 다른 키에 YAML 을 깨뜨리는 값이 들어가면 사이트는 조용히 깨지고 검사는 초록이다. 오늘 32쌍은 전부 mkdocs 가 읽어낸다(확인함) 므로 기존 결함은 없지만, 전면 게이트는import yaml의 의존성 질문(§4.1 마지막 문단)을 여는 별개 변경이다 --drift감사 모드의 출력 형태를 정하지 않았다. §1.3 (e) 의 분류(링크 재겨냥 / 백틱 안 한글 / 나머지)가 그대로 쓸 만한지, 세 번째 통을 사람이 읽을 만한 크기로 줄일 수 있는지 재지 않았다
참조 파일 지도¶
| 무엇 | 어디 |
|---|---|
| 이 항목의 출처와 실측 | docs/backlog/translation-tooling-open-items.md (T-1) |
| 백로그 규칙 일곱 | docs/backlog/README.md |
| 옆 검사와 그 exit 결정문 | scripts/check-translation-staleness.py (헤더 docstring) |
| 링크·앵커 검사 | scripts/check-doc-links.py |
| 세 스크립트가 공유하는 답 | scripts/docs_tree.py |
| CI 잡 둘 | .github/workflows/build.yml (docs-links, translations) |
| 이 설계의 구현 | scripts/check-translation-structure.py (모듈 docstring 이 축·게이트·예외를 요약한다), scripts/docs_tree.py 의 공유 함수 넷 |
| 번역 규칙 정본 | docs/project/documentation-guide.md §5, docs/project/translation-glossary.md, CLAUDE.md, CONTRIBUTING.md |
| 베이스라인 선례 (열거형·자기무효화) | modules/aimon-core/src/test/java/at/aimon/core/architecture/PackageDependencyArchitectureTest.java |
| 베이스라인 선례 (수치·데이터 파일) | gradle/coverage-baselines.properties, buildSrc/src/main/kotlin/aimon.java-conventions.gradle.kts |
| 프로브 표의 형식 선례 | docs/backlog/architecture-review-open-items.md |
| 사이트 쪽 frontmatter 파서 (§4.1 의 두 실측) | mkdocs.utils.meta.get_data() — yaml.load + except Exception: pass |
두 문서 잡이 pip install 을 하지 않는다는 사실 |
.github/workflows/build.yml (docs-links · translations 의 steps) |
관련 문서¶
../../backlog/translation-tooling-open-items.md— T-1. 이 설계가 답하는 항목../../project/documentation-guide.md— 번역 규칙 정본. §5.1 이 대상 디렉토리 표../../project/translation-glossary.md— 용어 표기. §1 이 "번역하지 않는 것"../README.md— 설계 문서 색인과 규약