pipeline: make tech-log-tree.json the one decomposition contract and enforce it

리뷰 두 건을 반영했다.

계약
- tech-log-tree.json 하나가 분해 계약이자 색인이다. 사람이 읽는 트리·Node Specification·
  후보 대장은 없어졌고, 문서에 남아 있던 그 개념을 걷어냈다
- candidateScope — 후보를 찾는 SSOT 범위. 접어 넣은 제2부·제3부는 근거이지 후보가 아니다
- sourceRepository — 분석한 저장소의 경로·리비전·판단 근거. 리비전을 모르면 null 로 두고
  지어내지 않는다. 갈래가 여럿이면 revisions
- 검사기: 계약 미채택·PENDING·PROMOTE↔글감 양방향·candidateScope·sourceRepository 를
  error/warn 으로 센다. 옛 스키마도 검사를 피하지 못한다. 테스트 22 → 31

기록 쓰기
- 템플릿 5종에 source·sourceRevision·topicName, Question 에 닫는 조건, 본문 없는 종류에서
  assets 제거. 고정 절 개수 삭제
- check_evidence.mjs — 인용한 코드가 SSOT 에 있는지, 앵커가 SSOT 를 가리키는지, 제목이
  계약과 같은지, 리비전이 저장소에 있는지. 게시된 기록에서 SSOT 와 다른 URL 을 잡았다

문체
- 문체 규칙의 정본을 ai-tells.md 로. explaining.md 의 질문체 제목·절 끝 대조 반복·그림 예고
  규칙을 삭제해 충돌을 없앴다. 첫 절 「설명 뒤에 평가를 붙이지 않는다」에 지우는 사례 네 유형
- voice 스킬의 「독자 쪽을 본다」를 자료에 오독 기록이 있을 때로 좁히고, 평가만 더한 예시를 교체
- check_prose: 안내 문장을 요구하던 경고 제거, 문장이 끝나지 않은 채 문단이 끝나는 조각 검사 추가

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 12:39:20 +09:00
co-authored by Claude Fable 5.1
parent 73026cada6
commit 9d2a3725c5
54 changed files with 3583 additions and 871 deletions
@@ -30,30 +30,67 @@ Concept 에 담고 `관계`로 가리킨다. `references/record-kinds.md`
## 필수 절차
0. **글감 나누기** — 긴 글(SSOT)에서 옮겨 오는 것이면 먼저 나눈다. 기준`tech-log-tree.json`
형식은 `references/from-ssot-to-records.md`. 나눈 뒤 글을 쓴다.
0. **글감 나누기** — 긴 글(SSOT)에서 옮겨 오는 것이면 먼저 나눈다. 기준
`references/from-ssot-to-records.md`, 계약은 `references/tech-log-tree-contract.md`.
**`tech-log-tree.json` 에 노드가 없는 글은 쓰지 않는다** — 트리에 먼저 올리고, 그 노드의
후보가 `PROMOTE` 이면서 `dispositionReview: CONFIRMED` 인지 확인한 뒤에 쓴다. `PENDING`
사람이 다시 읽지 않았다는 뜻이라, 그 위에 쓴 글은 과분류를 그대로 물려받는다. 계약 밖에서 쓴
기록은 색인에 `unlisted` 로 남는다.
1. **종류 선택** — 위 표. 애매하면 "재현했나"를 묻는다.
2. **칸 채우기** — 칸과 게시 조건은 `references/record-kinds.md`.
3. **본문 작성**(Case·Concept) — **종류마다 무엇을 어떤 순서로 쓰는지는
`references/writing-each-kind.md`.** 문법은 `references/body-syntax.md`, 표·코드·그림은
`references/code-tables-diagrams.md`. **문장은 `references/explaining.md`.**
**문서군 전체의 리듬은 `references/ai-tells.md`.**
그림이 필요하면 손으로 그리지 말고 `technical-visualizer` 스킬로 만든다.
이미 쓴 문장이 AI가 쓴 것처럼 읽히면 `rewriting-technical-prose-naturally` 로 다시 쓴다.
4. **검사** — 둘 다 돌린다. 파서와 문장은 다른 것을 본다.
**문서군 전체의 리듬은 `references/ai-tells.md`.** 이 둘은 첫 초안부터 적용한다 — AI 티를
남겨 두고 나중에 걷어내는 순서가 아니다. **문체를 손보기 전에 문장을 고른다** — 설명이 끝난
뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 먼저 뺀다(`ai-tells.md` 첫 절). 문체 규칙의
정본은 `ai-tells.md` 다.
순서·경계·상태 전이처럼 문장만으로 따라가기 어려운 관계가 있으면 그때
`technical-visualizer` 로 그림을 만든다. 손으로 SVG 를 그리지 않고, 모든 글에 그림을 만들지도
않는다.
4. **검사** — 셋 다 돌린다. 파서와 문장과 증빙은 각각 다른 것을 본다.
- `scripts/check_body.mjs` — Case 본문이 Studio 파서를 통과하는지. 같은 파서를 그대로 부른다.
- `rewriting-technical-prose-naturally/scripts/check_prose.mjs` — 문장 규범. **error 0 이 될 때까지 고친다.**
칸 하나나 한 절만 고쳤으면 `--doc` 없이 부른다. 이어서 `style_profile.mjs` 로 문체 수치를 본다.
- `scripts/check_evidence.mjs <프로젝트> --repo`**인용한 것이 실재하는지.** 본문 코드블록의
각 줄이 SSOT 안에 있는지, `source` 앵커가 SSOT 를 가리키는지, 제목이 계약과 같은지,
`sourceRepository` 의 리비전이 그 저장소에 있는지를 본다.
5. **관계 연결** — Decision은 근거가 **1개 이상** 없으면 게시가 거절된다.
6. **Studio에서 확인** — 넣고 **저장까지만** 한 뒤 미리보기로 읽는다.
절차는 `references/studio-draft-review.md`. **게시하지 않는다.**
7. **게시**고칠 것이 없을 때만. 못 채운 칸은 그 칸 아래에 표시된다.
7. **색인 갱신**기록을 쓰거나 지웠으면 다시 만들고 검사한다.
`python3 scripts/build-tech-log-tree.py <프로젝트>` ·
`python3 scripts/verify-tech-log-tree.py <프로젝트>` — error 0 이어야 한다.
8. **게시** — 고칠 것이 없을 때만. 못 채운 칸은 그 칸 아래에 표시된다.
## 어느 스킬이 무엇을 하나
이 스킬이 첫 초안을 만든다. 나머지는 초안이 나온 뒤에 각각 다른 것을 고친다.
| 스킬 | 하는 일 | 하지 않는 일 |
|---|---|---|
| `deriving-tech-log-root-tree` | 후보에 처분을 매기고 `PROMOTE` 를 글감으로 올린다 | 글을 쓰지 않는다 |
| `writing-tech-log-records` | 종류를 고르고 칸과 본문을 쓴다. `explaining.md`·`ai-tells.md` 를 처음부터 적용한다 | — |
| `technical-visualizer` | 문장으로 따라가기 어려운 관계를 그림으로 만든다 | 모든 글에 그림을 붙이지 않는다 |
| `rewriting-technical-prose-naturally` | 사실과 구조가 이미 맞는 초안의 번역투·반복 문형·과한 대구를 고친다 | 분류가 틀렸거나 근거가 모자란 것은 못 고친다 |
| `writing-as-the-person-who-did-it` | 자료에 남아 있는 선택·비교·어긋남·확인하지 못한 범위를 제자리에 놓는다 | 자료에 없는 「처음에는」·「고민 끝에」를 만들지 않는다 |
Reference·Question·Decision 은 그림을 렌더링할 자리가 없다. 그림이 필요한 내용은 짝이 되는
Case 나 Concept 에 담고 `관계`로 가리킨다.
## 보호 구간
수치, 날짜, 버전, 단위, 코드, 명령어, URL, 인용, 공식 명칭은 원문과 한 글자도 달라지면 안 된다.
측정하지 않은 값을 채우지 않는다 — 검증일은 실제로 확인한 날이다.
**옮겨 적었다고 확인한 것이 아니다.** 앞선 기록에서 코드를 가져오거나 기억으로 경로를 쓰면
SSOT 에 없는 인용이 생긴다. 실제로 그렇게 게시된 기록에 잘못된 redirect URI 가 네 곳 남아 있었고,
realm 설정이 와일드카드라 실행해도 드러나지 않았다. **인용한 줄은 SSOT 에서 찾아 대조한다.**
`check_evidence.mjs` 가 그 대조를 기계로 한 번 더 한다.
SSOT 에 없는데 필요한 인용이라면 순서가 반대다 — `final/document.md` 를 먼저 보강하고,
그것도 저장소에서 확인한 뒤에 한다. `sourceRepository.path` 가 그 저장소를 가리킨다.
## 쓰지 않는 것
- 자료가 뒷받침하지 않는 선택 이유. 썼다는 사실을 왜 골랐는지로 바꾸지 않는다.
@@ -81,5 +118,8 @@ Concept 에 담고 `관계`로 가리킨다. `references/record-kinds.md`
| `![](https://…외부)` | Asset으로 올려 `/api/v1/public/media/…` |
| Decision에 근거 없음 | 관계 1개 이상 연결 |
| 측정 안 한 검증일 | 비워 둔다 |
| 설명 직후에 「~증거다」「~가 아니다」로 평가 | 지운다. 앞 문장이 사실을 말했으면 거기서 끝낸다 |
| 「~로 읽기 쉽다. 그렇지 않다」 | 오해를 지어내지 않는다. 관측부터 적는다 |
| 「먼저 ~를 보고 …」 차례 예고 · 「~를 함께 적는다」 | 지운다. 다음 절이 바로 시작한다 |
작성 후 `references/review-checklist.md`로 대조한다.
@@ -3,6 +3,95 @@
문장 하나하나는 멀쩡한데 문서군 전체가 같은 리듬으로 굴러갈 때 티가 난다. 개별 문장을 일부러
어눌하게 만드는 방향은 역효과다. 아래는 실제 리뷰에서 지적된 것들이다.
**이 문서가 문체 규칙의 정본이다.** `explaining.md` 는 무엇을 더 설명할지를 다루고, 둘이 어긋나면
이쪽을 따른다.
## 설명 뒤에 평가를 붙이지 않는다
가장 많이 지적된 형태다. 기술 설명은 이미 끝났는데 그 뒤에 **그 설명을 평가하거나, 다음을 예고하거나,
독자가 오해할 것이라고 가정하거나, 어떻게 읽고 써야 하는지 지시하는 문장**이 하나 더 붙는다.
문장마다 무엇을 주는지 본다 — 동작, 정의, 근거, 판단에 영향을 주는 조건 가운데 하나여야 한다.
그 넷 중 어느 것도 주지 않고 판정만 하는 문장은 지운다. 특정 단어를 금지하는 방식은 쓰지 않는다.
「중요하다」가 나쁜 것이 아니라 그 문장이 아무것도 더하지 않는 것이 문제다.
**고치는 순서는 내용이 먼저다.** 필요 없는 문장을 남긴 채 표현만 자연스럽게 바꾸면 어색한 문장의
표현만 달라진다. 평가·예고·되풀이를 걷어낸 뒤에 문장을 다듬는다.
네 가지가 반복된다. 전부 「다른 표현으로 고치는 사례」가 아니라 **「통째로 지우는 사례」**다.
### 설명한 것의 중요성을 다시 평가한다
```text
✗ 다른 하나는 앞 단계가 어긋났는데도 나머지를 계속 돌려 마지막에 전체 통과를 남기는 일이다.
그렇게 만든 기록은 경계가 지켜졌다는 근거가 되지 않는다.
○ 다른 하나는 앞 단계가 어긋났는데도 나머지를 계속 돌려 마지막에 전체 통과를 남기는 일이다.
✗ 발행된 SQL에 Limit 노드가 없다는 것 자체가 DB가 페이징을 하지 않았다는 증거다.
○ 발행된 SQL에는 Limit 노드가 없었다.
```
「증거다」「핵심이다」「너무 넓다」「서로를 대신하지 않는다」로 끝나는 꼬리 문장이 이 형태다.
앞 문장이 사실을 말했으면 거기서 끝낸다.
### 독자가 오해할 것이라고 먼저 가정한다
```text
✗ 훑은 행만 보면 keyset이 결과까지 줄인 것으로 짐작하기 쉽다. 커서로 넘긴 두 번째 페이지는
OFFSET의 두 번째 페이지와 같은 20개 식별자를 같은 순서로 반환했다.
○ 커서로 넘긴 두 번째 페이지는 OFFSET의 두 번째 페이지와 같은 20개 식별자를 같은 순서로 반환했다.
✗ Web Storage에 토큰을 쓰지 않으니 JavaScript에서도 토큰이 사라진다고 읽기 쉽다. 그렇지 않다.
액세스·리프레시·ID 토큰은 실행 중 메모리에 있다.
○ 액세스·리프레시·ID 토큰은 실행 중 메모리에 있다.
```
「~로 읽기 쉽다」「~라고 생각하면 안 된다」「~로 보기 쉽다」. 자료에 누군가 실제로 그렇게 읽었다는
기록(버그·정정·문의)이 없으면 독자를 지어낸 것이다. 관측부터 적으면 오해는 생기지 않는다.
### 어떻게 읽고 어떻게 써야 하는지 지시한다
```text
✗ AP2와 AP3의 쿠키는 서버 쪽 상태를 찾는 열쇠이고, AP4의 쿠키는 최소 상태를 담은 값이다.
두 쿠키를 같은 문장으로 설명하면 서버 저장소가 있는 쪽과 없는 쪽이 구분되지 않는다.
○ AP2와 AP3의 쿠키는 서버 쪽 상태를 찾는 열쇠이고, AP4의 쿠키는 최소 상태를 담은 값이다.
✗ 이 두 낱말만 알면 따라올 수 있고, 나머지는 처음 나오는 곳에서 푼다.
✗ 먼저 브라우저가 들고 있는 값부터 보고, 그 값이 Bearer 요청이 되기까지를 따라간다.
✗ native로 내려갔다는 것과 그 범위를 함께 적는다.
```
「~라고 설명하면 ~가 구분되지 않는다」「~를 함께 적는다」「먼저 ~를 보고 다음에 ~를 본다」. 독자에게
필요한 것은 각 쿠키가 무엇을 보관하는지이지 그것을 어떻게 설명해야 하는지가 아니다. 이런 문장은
작성자의 검토 메모다. 차례 예고도 같다 — 다음 절이 바로 시작하면 된다.
### 이미 설명한 것을 다시 말한다
```text
✗ 배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 두 효과는 서로를 대신하지 않는다.
○ 배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다.
✗ 반복이 사라진 것이 아니라 스트림 뒤로 숨었다.
○ 현재 매핑에서는 각 아이템의 getHighlights()에 접근하면서 지연 로딩이 실행된다.
```
같은 대조를 추상어로 한 번 더 하거나, 동작 설명 뒤에 인상적인 문장으로 닫는 것. 동작을 그대로
적으면 독자가 비유를 코드 동작으로 다시 번역하지 않아도 된다.
### SSOT 에 같은 문장이 있어도 옮기지 않는다
`final/document.md` 는 사실과 근거의 기준이지 문장의 기준이 아니다. 원문에 「너무 넓은 성공 기준입니다」가
있어도 기록에 옮길 이유는 없다. 옮기는 것은 수치·조건·동작·판단이고, 평가는 옮기지 않는다.
### 무엇을 남기나
- 코드가 그렇게 동작하는 이유, 측정 조건, 결과를 읽는 데 필요한 예외
- 실제 선택을 바꾼 판단 — 「200 이어도 정상인 이유는 세션이 유효하기 때문이다」처럼 판정에 영향을 주는 것
- 요약·결론·본문 사이의 반복 — Studio 칸 구조상 필요하다. 걷어낼 것은 **한 칸 안에서** 설명 직후에 붙은 문장이다
어미 수·절 수·안내 문장 수 같은 수치는 참고 정보다. `style_profile.mjs` 가 「벗어남」을 내도 그것을
맞추려고 문장을 넣지 않는다. 위 첫 예시는 문장 검사를 error 0 으로 통과한 채로 지적됐다.
## 억지 구어체를 만들지 않는다
AI 티를 지우려고 넣은 질문체와 청유형이 오히려 「AI 문장을 억지로 인간화한 것」으로 읽힌다.
@@ -2,6 +2,12 @@
가장 자주 나오는 지적은 **설명이 짧다**는 것이다. 사실은 맞는데 독자가 따라오지 못한다.
그 반대도 같은 무게로 지적된다 — 설명이 끝난 뒤에 그 설명을 평가하거나, 다음 절을 예고하거나,
독자가 오해할 것이라고 가정하는 문장이 붙는 것. 이 문서는 **무엇을 더 설명하는가**를 다루고,
무엇을 빼는가는 `ai-tells.md` 첫 절이 다룬다. **문체 규칙의 정본은 `ai-tells.md` 다.** 두 문서가
어긋나면 그쪽을 따른다. 여기 규칙은 전부 「그 설명이 없으면 독자가 막히는 자리」에서만 쓴다.
이미 설명된 문단에 더하지 않는다.
## 이름을 댔으면 왜 있는지도 댄다
낯선 클래스·기법·설정 이름을 적고 다음 문장으로 넘어가지 않는다. **왜 그것이 존재하는지**를 한
@@ -13,13 +19,13 @@ XorCsrfTokenRequestAttributeHandler가 token을 XOR와 Base64로 가린다.
쓴다
XorCsrfTokenRequestAttributeHandler가 token을 XOR와 Base64로 가린다.
BREACH 공격을 줄이기 위해서다. 여기서 깊게 다루지는 않는다 — HTTP 응답 압축 크기의
차이로 응답 안의 비밀값을 조금씩 추측하는 공격이고, 그래서 응답에 실리는 값을 매번
다르게 만든다.
BREACH 공격을 줄이기 위해서다. HTTP 응답 압축 크기의 차이로 응답 안의 비밀값을 조금씩
추측하는 공격이고, 그래서 응답에 실리는 값을 매번 다르게 만든다.
```
깊게 안 갈 것이면 **안 간다고 밝히고 한 문장 요약 준다.** 이름만 던지고 넘어가면 독자는 그
자리에서 검색하러 나간다.
깊게 안 갈 것이면 **한 문장 요약 준다.** 「여기서 깊게 다루지는 않는다」 같은 예고는 붙이지 않는다 —
요약이 있으면 그것으로 충분하고, 없으면 독자는 그 자리에서 검색하러 나간다. 이 규칙은 **처음
나오는 낯선 이름**에만 걸린다. 이미 설명한 이름이나 문맥에서 분명한 이름에는 붙이지 않는다.
## 「역할이 다르다」로 끝내지 않는다
@@ -87,27 +93,6 @@ sessionStorage에도 accessToken과 refreshToken이 없었다.
쓴다 identity-header-trust
```
## 제목은 묻고 본문은 답한다
절 제목에 `~해보자` `~하지?` `~일까?`를 쓴다. 그리고 **첫 문장에서 그 질문을 다시 던지고**
답한다.
| 쓰지 않는다 | 쓴다 |
|---|---|
| 브라우저에 남은 것 | 브라우저에 관리 대상 |
| 위조 요청은 어떻게 생겼나 | 위조 요청은 어떻게 생겼을까? |
| 세 겹으로 나눠서 막는다 | 세 겹으로 나눠서 막아보자 |
| 무엇이 서버로 넘어왔나 | 무엇이 서버로 책임이 넘어왔지? |
| upstream이 JWT를 받지 않는다는 뜻 | upstream이 JWT를 받지 않는다? |
```text
## 브라우저에 관리 대상
브라우저에 관리 대상은 그럼 어떤 게 될까?
| 무엇 | 브라우저에 있나..? | JavaScript가 읽나..? |
```
## 굵게를 걷어낸다
`**굵게**`는 거의 쓰지 않는다. 한 절에 하나를 넘기면 강조가 아니라 얼룩이 된다. 강조는
@@ -155,9 +140,6 @@ authorization code와 함께 redirect되고, 그 code를 mediator가 token으로
이 구조에서는 token 관리와 교환의 위치가 브라우저에서 Spring backend로 옮겨지게 된다.
```
같은 대조를 절 끝에서 한 번 더 쓴다 — 「앞선 구조에서는 브라우저가 OIDC client였다면 이
구조에서는 mediator가 OIDC client가 된다」처럼.
## 결론은 문장 끝에 붙인다
한 줄짜리 단정문을 따로 떼어 강조하지 않는다. 앞 문장에서 `그래서` · `그렇기 때문에`
@@ -205,11 +187,6 @@ access token이 필요하고, 그것을 응답 본문으로 받게 된다. 그
Reference의 규칙 제목과 Decision의 결정문도 `~한다`로 끊는다. 그 자리는 기준이다.
## 그림은 한 줄로 예고한다
`전체적인 구조를 보면 다음과 같다` 같은 한 줄을 두고 그림을 넣는다. 문단 사이에 말없이
끼우지 않는다.
## 번역투를 걷어낸다
가장 자주 나오는 지적 두 번째다. 어미는 한국어인데 **문장 구조가 영어**여서 읽기 힘들다.
@@ -409,5 +386,4 @@ Reference의 규칙 제목과 Decision의 결정문도 `~한다`로 끊는다.
- **`~하면 된다`를 쓰지 않는다.** 조언하는 말투이지 기록하는 말투가 아니다.
`정하면 된다``정한다`, `적으면 된다``적는다`, `두면 된다``둔다`
- `A는 B다`보다 `A는 B라는 점이 문제가 된다` — 무엇이 걸리는지까지 말한다
- 무엇을 하자고 이끌 때는 `~해 보자`를 쓴다. 절 제목과 여는 문장에만 쓰고 규칙에는 쓰지 않는다
- 명령조를 줄인다. 검사 항목이 아니라 같이 읽는 사람의 말로 쓴다
@@ -1,8 +1,40 @@
# SSOT에서 글감을 뽑는 기준
`final/`의 긴 글 하나가 정본(SSOT)이다. 거기서 Studio에 올릴 기록을 뽑아낸다. 이 문서는 **무엇을
몇 건으로 나눌지**를 정하는 기준이다. 나눈 결과는 `tech-log-tree.json`에 제목만 먼저 적고, 글은
그다음에 쓴다.
`final/document.md` 하나가 정본(SSOT)이다. 거기서 Studio에 올릴 기록을 뽑아낸다. 이 문서는
**무엇을 몇 건으로 나눌지**를 정하는 기준이다. 나눈 결과는 `tech-log-tree.json` 하나에 적는다 —
분해 계약과 색인이 같은 파일이라 둘이 어긋날 자리가 없다.
## 어느 파일에서 뽑나
| 층 | 하는 일 |
|---|---|
| 코드·설정·실행 증거 | 사실의 근거 |
| `final/document.md` | **글감 범위의 SSOT** — 후보를 발견하는 유일한 입력 |
| `analysis/**/*.md` | final이 이미 채택한 주장을 상세히 확인하는 보조 근거 |
| `tech-log-tree.json` | 사람이 고른 글감. 분해 계약이자 색인이고 이 파일이 정본이다 |
**`analysis/**`를 글감을 찾으려고 열지 않는다.** 이미 final에 있는 주장의 세부를 확인할 때만
연다. 분석에만 있고 final에는 없는 자료를 발견하면 트리에 바로 넣지 말고 `final/document.md`
먼저 보강한다. 그러지 않으면 모듈 문서 61편이 각각 정본 노릇을 하고, 트리는 그 절 수의 합만큼
자란다.
## 후보를 찾는 범위
접어 넣은 `final/document.md`가 전부 후보 자리는 아니다. **제1부(통합 분석)가 후보를 찾는
범위**이고, 제2부(모듈 분석 전문)와 제3부(분석 재료)는 근거다. 제2부의 절 제목을 후보로 읽으면
모듈 분석 편수만큼 글감이 늘어난다 — 접어 넣기 전에 있던 문제가 그대로 돌아온다.
범위는 기억하지 말고 계약에 적는다.
```json
"candidateScope": {
"document": "final/document.md",
"sections": ["§3", "§4", "§5", "§6", "§7", "§8", "§9", "§10", "§11"],
"excluded": ["제2부 — 모듈 분석 전문", "제3부 — 분석 재료"]
}
```
범위 밖의 앵커는 후보가 아니라 근거다. 제1부에서 나온 글감의 `source`로 건다.
## 왜 먼저 나누는가
@@ -10,6 +42,22 @@
Reference의 칸도 반쯤만 맞는 기록이 나온다. 종류를 먼저 정하고 그 종류가 요구하는 것이 SSOT에
있는지 확인해야 한다.
## 고르는 것이지 남김없이 내는 것이 아니다
분석에 빠진 것이 없는지 볼 때는 recall 100%가 맞다. 공개할 글을 정할 때는 아니다. 「분석에서
보존할 가치」와 「독립된 글로 읽을 가치」는 다른 물음이고, 둘을 한 축으로 재면 분석 부산물이
전부 글이 된다.
물음은 하나다.
> **이 기록을 없애고 관련 Case나 Concept의 한 절로 넣어도 이해·결정·재사용성이 그대로라면
> 독립 기록으로 만들지 않는다.**
후보마다 처분을 적는다 — `PROMOTE` · `MERGE_INTO` · `KEEP_IN_SSOT` · `NEEDS_EVIDENCE` ·
`NEEDS_DECISION` · `BLOCKED`. `KEEP_IN_SSOT`은 버린 것이 아니라 분석에 남기고 글로 만들지
않기로 한 것이고, 그것도 정상적인 결과다. 자세한 것은
`.agents/skills/deriving-tech-log-root-tree/references/candidate-disposition.md`.
## 한 건으로 자르는 단위
**절이 아니라 주장이다.** SSOT의 `##` 하나가 기록 하나가 아니다. 다음 넷 중 하나가 한 건이다.
@@ -46,12 +94,20 @@ Reference 하나로 나누고 `관계`로 잇는다.
**같은 관측을 두 건으로 쪼개지 않는다.** 「N+1이 났다」와 「그래서 몇 개가 나갔다」는 한 건이다.
쪼개면 둘 다 반쪽이 된다.
**주제를 먼저 정한다.** Studio는 주제 아래에 다섯 종류를 나눠 보여 준다. 주제가 다르면 같은
프로젝트여도 폴더가 갈린다. 주제 slug는 Studio의 것을 그대로 쓴다.
**Concept은 거꾸로 뽑는다.** Case·Reference·Decision·Question을 먼저 고르고, 그것을 읽는 사람이
미리 알아야 하는 구조가 있을 때만 Concept을 만든다. 메커니즘처럼 보이는 절을 훑어 채우면 어느
기록도 필요로 하지 않는 개념이 쌓인다.
**주제 하나에 독자 질문 하나.** Studio는 주제 아래에 다섯 종류를 나눠 보여 준다. 그 주제의
기록들이 함께 답하는 물음을 한 줄로 적고, 그 물음에 답하지 않는 글감은 다른 주제로 옮긴다.
주제 slug는 Studio의 것을 그대로 쓴다.
## `tech-log-tree.json`
주제 → 종류 → 글감 순서로 담는다. 아직 쓰지 않은 글감은 `file` 없이 제목만 둔다.
**주제·글감·`readiness`·`source`·`classification`·`relations`는 사람이 적는다.** 스크립트가
채우는 것은 기록 파일에서 읽을 수 있는 넷뿐이다 — `file`·`publication`·`status`·`studioId`.
주제 → 종류 → 글감 순서로 담고, 아직 쓰지 않은 글감은 `file` 없이 남는다. `readiness`는 증거가
갖춰진 정도이고 `publication`은 Studio에 올렸는지다 — 둘은 다른 것이라 섞지 않는다.
```json
{
@@ -75,18 +131,23 @@ Reference 하나로 나누고 `관계`로 잇는다.
}
```
기록을 쓰거나 지운 뒤에는 다시 만든다. 스크립트는 기록 파일에서 값을 읽어 채우고, `file`이 없는
글감은 지우지 않는다.
기록을 쓰거나 지운 뒤에는 파생 칸을 다시 채운다. 사람이 적은 칸은 그대로 남는다. 디렉터리를
훑어 주제를 만들지 않으므로, 계약에서 뺀 주제의 폴더가 남아 있어도 되살아나지 않고 `unlisted`
적힌다.
```bash
python3 scripts/build-tech-log-tree.py [프로젝트]
python3 scripts/verify-tech-log-tree.py [프로젝트] # error 0 이어야 한다
```
## 순서
1. SSOT를 끝까지 읽는다. 절 제목만 훑지 않는다 — 수치와 근거가 어디 있는지 알아야 종류를 정한다
2. 위 물음으로 글감을 나누고 주제를 정한
3. `tech-log-tree.json`에 제목만 적는다. 이때 글은 쓰지 않는
4. 글감 하나를 골라 `<주제>/<종류>/`에 기록을 쓴다. 형식은 `writing-each-kind.md`
5. 트리를 다시 만든
6. Studio에 넣고 저장한
2. §3~§8에서 Case, §9에서 Reference, §10에서 Decision, §11에서 Question을 고른
3. 그 넷을 이해하는 데 필요한 Concept만 거꾸로 더한
4. 후보마다 처분을 적고, 사람이 다시 읽은 것만 `dispositionReview: CONFIRMED`로 둔다.
`PROMOTE`이면서 `CONFIRMED`인 것만 글감이 된
5. 주제를 묶고 주제마다 독자 질문을 한 줄 적는
6. 글감 하나를 골라 `<주제>/<종류>/`에 기록을 쓴다. 형식은 `writing-each-kind.md`
7. 색인을 다시 만들고 검사기를 돌린다
8. Studio에 넣고 저장한다
@@ -67,6 +67,10 @@ evidence:
- ../../../final/evidence/explain/highlights-child-plan-A.txt
```
**`assets` 는 본문이 있는 Case 와 Concept 에만 둔다.** 나머지 세 종류는 칸이 평문으로
렌더링돼 그림을 표시할 자리가 없다. 그림이 필요한 내용은 Case 나 Concept 에 담고 `관계`
가리킨다.
`assets` 는 Studio 에 올릴 파일이다. 아직 안 올렸으면 key 가 파일 이름과 같고, 올린 뒤에는
서버가 준 `<이름>-<해시8>` 로 바뀐다. **Studio 에 넣을 때 이 목록을 보고 Asset 을 올리고,
본문의 `:::evidence key` 를 서버가 준 키로 바꾼다.**
@@ -145,6 +149,26 @@ subrequest 가 실어 보내는 것` 같은 것이 여기 온다.
Case 와 헷갈리면 **내가 무엇을 했는지**를 묻는다. 내가 돌려 보고 수치를 얻었으면 Case, 남의 문서와
코드를 읽고 동작을 정리했으면 Concept 이다.
### Concept 이 아닌 것
Concept 은 **어떤 Case 를 이해하려면 먼저 알아야 하는 구조**다. 그 Case 가 없으면 Concept 도
없다. 분석하면서 알게 된 사실을 종류가 마땅치 않아 여기 넣지 않는다.
| 이런 제목 | 실제로는 |
|---|---|
| 호출자가 없다 · 프로덕션에서 실행되지 않는다 | 부재는 Case 의 관측이다 |
| 구현 클래스 51개를 전부 읽었다 · 재현에 쓴 레인 | 분석 범위·방법. SSOT 의 coverage 원장에 남는다 |
| 보류한 항목과 보류한 이유 | 분석 진행 기록. 같은 곳에 남는다 |
| `grep refs=0` 은 시작점이지 결론이 아니다 | 분석 방법론. Reference 로 쓸 수 있으면 Reference 다 |
| (8.4) 문서/구현 드리프트 — … · Confirmed — … | 분석 문서의 절 제목을 그대로 옮긴 것 |
한두 문장으로 Case 안에서 설명되는 것도 Concept 이 아니다. **없애고 Case 의 한 절로 넣어도
이해가 그대로면 독립 기록으로 만들지 않는다.**
Concept 이 되는 것은 이런 것들이다 — `Spring 조립의 세 경로`, `@ConditionalOnBean 의 평가
시점`, `커밋 증거 상태 전이`, `fenced lease 와 CAS`, `gRPC flow control 과 backpressure`.
여러 Case 가 같은 선수 지식을 요구할 때 그것을 한 번만 설명하려고 만드는 자리다.
## Reference — 반복 적용할 기준
| 칸 | 필드 | 비고 |
@@ -23,6 +23,7 @@
- [ ] 종류가 내용과 맞는다 (`record-kinds.md`의 판단 흐름)
- [ ] Question의 사실과 가정이 섞이지 않았다
- [ ] Question이 `OPEN`이면 미지수가 있다
- [ ] Question에 닫는 조건이 있다. 「더 알아본다」로 끝나지 않았다
- [ ] Decision에 근거 기록이 1개 이상 연결됐다
- [ ] Decision의 영향에 감수한 비용이 있다
- [ ] Reference의 예시가 문장이다. 코드를 밀어 넣지 않았다
@@ -53,6 +54,9 @@
- [ ] `여지가 생긴다`·`소지가 있다` 대신 무엇이 어디로 가는지 썼다
- [ ] 읽는 법을 지시하는 문장이 없다. `봐야 한다`·`여기까지다`·`먼저 본다`·`읽으면 안 된다`
- [ ] 빼도 남은 뜻이 그대로인 문장이 없다
- [ ] 설명 직후에 그 설명의 중요성을 평가하는 문장이 없다 (「~증거다」「~핵심이다」「서로를 대신하지 않는다」)
- [ ] 독자가 오해할 것이라고 가정하는 문장이 없다 (「~로 읽기 쉽다」「~라고 생각하면 안 된다」)
- [ ] 다음 절을 예고하거나 「~를 함께 적는다」처럼 작성 방법을 말하는 문장이 없다
- [ ] 규칙 제목이 말한 것을 본문 끝에서 다시 지시하지 않았다
- [ ] `싣는다`·`낸다`·`친다`·`짠다`를 실제 동작으로 풀어 썼다
- [ ] 동사마다 목적어가 있다. `교환이 끝난다`처럼 무엇인지 빠지지 않았다
@@ -74,6 +78,7 @@
- [ ] Reference가 Case를 문장만 바꿔 옮기지 않았다
- [ ] 현재 확인한 것과 운영에서 추가로 필요한 것을 나눴다
- [ ] 테스트를 말할 때 무엇을 단정하는지 적었다
- [ ] 어미·절·안내 문장의 수치는 참고만 했다. 맞추려고 문장을 넣지 않았다
## 본문 (Case)
@@ -101,6 +106,8 @@
- [ ] Project가 필요하면 지정됐고, 그 Project에 slug가 있다
- [ ] 관계의 대상이 실제로 있는 공개 기록이다
- [ ] 코드·표·그림이 필요한 내용을 Reference나 Question에 밀어 넣지 않았다
- [ ] 본문이 없는 세 종류에 `assets`를 선언하지 않았다
- [ ] 이 글감의 후보가 `PROMOTE`이고 `dispositionReview``CONFIRMED`
## 마지막
@@ -1,119 +0,0 @@
# Root Tree Contract
The root tree is the explicit boundary between deep project analysis and Tech Log record generation.
## Required document header
A root tree records:
- `schemaVersion`
- `project`
- `sourceDocument`
- `sourceDocumentSha256`
- `sourceRevision`
- `generatedAt`
The hash/revision prevents a scheduled generator from treating a tree derived from old code as current.
## Required human-readable tree
Each Topic has a title, slug, and four branches:
```text
PROJECT
<project>
TOPIC
<Topic title>
<topic-slug>
├── CASE
├── REFERENCE
├── OPEN QUESTION
└── DECISION
```
Empty branches are allowed. Do not manufacture nodes to fill all four kinds.
## Node source contract
Every candidate includes a specification after the human-readable tree.
### Case
Required:
- `slug`
- `readiness`
- one or more `source` anchors
- `classification` explaining the concrete incident/experiment/diagnosis
- relevant code/evidence when the conclusion depends on them
- `missing-verification`
- `relations`
A Case with `NEEDS_EVIDENCE`, `BLOCKED`, or `REJECTED` is not generated.
### Reference
Required:
- `slug`
- `readiness`
- `source`
- `classification` explaining the reusable criterion
- `scope`
- `exceptions`
- `relations`
A Reference must be useful beyond retelling one Case. If removing the originating project's names leaves no rule, it is probably still a Case.
### Open Question
Required:
- `slug`
- `readiness: OPEN`
- `source`
- `known`
- `unknown`
- `next-verification`
- `decision-criterion`
- `relations`
Do not generate a Question when the detailed analysis already contains a verified answer. Move the material to Case/Reference/Decision as appropriate and update the tree first.
### Decision
Required:
- `slug`
- `readiness`
- `decision-status`
- `source`
- `decision-evidence`
- `grounds`
- `classification`
- `relations`
`decision-status` is one of `PROPOSED`, `ADOPTED`, `SUPERSEDED`, `NOT_DECIDED`. A `NOT_DECIDED` candidate uses `NEEDS_DECISION` and is not generated as a Decision.
## Readiness semantics
| readiness | meaning | generation |
|---|---|---|
| `READY` | grounded enough for the kind | allowed |
| `NEEDS_EVIDENCE` | material assertion still lacks verification | blocked |
| `NEEDS_DECISION` | direction sounds plausible but project has not decided | blocked |
| `OPEN` | legitimate unresolved Question | allowed as Open Question |
| `BLOCKED` | sources are incomplete or contradictory | blocked |
| `REJECTED` | should not become a record | blocked |
## Derivation rules
1. Start from sections and evidence already present in detailed analysis; do not begin by brainstorming titles.
2. Prefer several narrowly grounded Cases over one broad Case that combines unrelated incidents.
3. Extract References only after identifying the invariant/selection criterion that survives outside the incident.
4. Extract Questions from explicit uncertainty, missing verification, operational unknowns, or conflicting constraints.
5. Extract Decisions only from explicit project choice evidence: ADR, commit/history, configuration plus recorded rationale, issue/PR decision, or user-supplied decision record.
6. A node may relate to several siblings, but each record has one primary purpose.
7. If new runtime evidence changes the answer, update detailed analysis and regenerate/review the tree before editing downstream records.
@@ -0,0 +1,172 @@
# Tech Log Tree Contract
`tech-log-tree.json` is the explicit boundary between deep project analysis and Tech Log
record generation. **It is the decomposition contract and the index at once, and it is the
source of truth.** There is one file, so nothing can disagree with it.
A finished `tech-log-studio/` holds `tech-log-tree.json` and the record folders. Nothing
else.
A project whose index predates this contract fails verification with one error until it is
migrated. The per-field checks stay off for such a project — "not written yet" must not read
as "written wrong" — but non-adoption itself is counted, because a warning lets an old index
avoid every check indefinitely.
## Required top level
- `schemaVersion`
- `project`
- `ssot` and `ssotSha256` — the hash prevents treating a tree derived from old material
as current
- `sourceRevision`
- `generatedAt`
- `sourceRepository``path`, `revision`, and `verified`: which checkout the analysis read,
which commit the document describes, and how that was confirmed. Leave `revision` null rather
than inventing one; the verifier warns instead of accepting a made-up label. When the work
is spread over branches rather than one line of commits, use `revisions` — a label to commit
map — and pin every tip the document describes
- `candidateScope` — which part of the SSOT candidates may come from
- `contract` — the decomposition rules, `readinessValues`, `dispositionValues`
- `topics`, `candidates`, `counts`, `unlisted`
## Candidate scope
A folded `final/document.md` carries the integrated analysis, the module analyses, and the
analysis material in one file. Only the first is candidate material.
```json
"candidateScope": {
"document": "final/document.md",
"sections": ["§3", "§4", "§5", "§6", "§7", "§8", "§9", "§10", "§11"],
"excluded": ["제2부 — 모듈 분석 전문", "제3부 — 분석 재료"]
}
```
`document` names the SSOT and must match `ssot`. `sections` names the candidate scope, and
`excluded` names the parts that are evidence rather than candidates. A node may cite an
anchor from an excluded part in `source`; it may not exist because of one.
## Topics
Each Topic has `topic` (its key), `title`, **`readerQuestion`**, and `kinds` with the five
record kinds.
```json
"oauth-oidc-auth-boundary": {
"topic": "oauth-oidc-auth-boundary",
"title": "OAuth 자격증명과 세션의 보관 경계",
"readerQuestion": "자격증명과 세션을 누가 보관하고, 누가 API 요청을 만들며, 보호 자원은 무엇을 신뢰하는가?",
"kinds": { "case": [], "concept": [], "reference": [], "question": [], "decision": [] }
}
```
Every node in the Topic must help answer the reader question. Two Topics do not share a
question; one Topic does not need two. Empty kinds are allowed — do not manufacture nodes
to fill all five.
## Candidates
Everything the analysis found lives in `candidates` with its disposition —
`.agents/skills/deriving-tech-log-root-tree/references/candidate-disposition.md`. Only
`PROMOTE` candidates become nodes under `topics`, and the relation runs both ways: a
`PROMOTE` candidate whose target is not a node, and a node no `PROMOTE` candidate points
at, are both contract errors.
`dispositionReview` records whether a person re-read the candidate under the independence
test. `PENDING` means it reached the tree by recall alone, and a `PENDING` candidate is an
error, not a warning — a record written over an unreviewed tree inherits the
over-classification the disposition step exists to catch. Write records only for nodes
whose candidate is `PROMOTE` and `CONFIRMED`.
## Written by hand, refreshed by script
`readiness` · `source` · `code` · `evidence` · `classification` · `relations` and the rest
of each kind's fields are written by a person. `build-tech-log-tree.py` never touches them.
It refreshes only what it can read from the record files — `file`, `publication`, `status`,
`studioId`, `assets`, `evidenceFiles` — and lists records that have no node in `unlisted`.
### Case
`slug` · `readiness` · `source` · `classification` · `missing-verification` · `relations`,
plus `code`/`evidence` when the conclusion depends on them.
One problem, an observation or reproduction, a diagnosis, a conclusion that closes. Several
observations that answer the same question with the same conclusion are one Case with a
table or sub-sections, not several partial Cases.
### Concept
`slug` · `readiness` · `source` · `basis-version` · `classification` · `relations`.
`basis-version` names what the explanation was written against — `Keycloak 26.7.0 identity
brokering`, `Spring Boot 3.3 auto-configuration`. A Concept without it cannot be known to
be stale.
A Concept exists because a Case, Decision, or Question needs it to be understood. Absence,
call-counts, unwired subsystems, analysis scope, and coverage ledgers are not Concepts.
### Reference
`slug` · `readiness` · `source` · `classification` · `scope` · `exceptions` · `relations`.
A Reference must be useful beyond retelling one Case. If removing the originating
project's names leaves no rule, it is still a Case.
### Open Question
`slug` · `readiness: OPEN` · `source` · `known` · `unknown` · `next-verification` ·
`decision-criterion` · `relations`.
Do not create a Question when the analysis already contains a verified answer. Move the
material to Case/Reference/Decision and update the tree first.
### Decision
`slug` · `readiness` · `decision-status` · `source` · `decision-evidence` · `grounds` ·
`classification` · `relations`.
`decision-status` is `PROPOSED`, `ADOPTED`, `SUPERSEDED`, or `NOT_DECIDED`. A
`NOT_DECIDED` candidate uses `NEEDS_DECISION` and is not written as a Decision.
## Readiness semantics
**`readiness` is about evidence, not about publication.** Whether a record has been
written, and whether it has been saved into Studio, are separate facts that the generated
index carries as `file` and `publication`. A published record with thin evidence is still
`NEEDS_EVIDENCE`.
| readiness | meaning | generation |
|---|---|---|
| `READY` | grounded enough for the kind | allowed |
| `OPEN` | legitimate unresolved Question | allowed as Open Question |
| `NEEDS_EVIDENCE` | material assertion still lacks verification | blocked |
| `NEEDS_DECISION` | direction sounds plausible but the project has not decided | blocked |
| `BLOCKED` | sources are incomplete or contradictory | blocked |
`REJECTED` is not a readiness. Whether a candidate becomes a record at all is a
disposition, and it lives in `candidates`, not on the node.
## Derivation rules
1. Discover candidates from `final/document.md` only. It is the whole analysis, folded in —
there is no `analysis/` folder to search in a finished project.
2. Give every candidate a disposition before writing any node. `KEEP_IN_SSOT` is a normal
outcome, and a decomposition that excludes nothing has not selected anything.
3. Apply the independence test: if folding the record into a related Case or Concept as
one section changes nothing, it is not an independent record.
4. Take Cases, References, Decisions, and Questions first; add Concepts backwards from
what those four require.
5. Prefer several narrowly grounded Cases over one broad Case combining unrelated
incidents — but merge observations that share a question and a conclusion.
6. Extract Decisions only from explicit choice evidence: ADR, commit/history, configuration
plus recorded rationale, issue/PR decision, or a user-supplied decision record.
7. A node may relate to several siblings, but each record has one primary purpose.
8. If new runtime evidence changes the answer, update the analysis and revise the tree
before editing downstream records.
## Generation and verification
```bash
python3 scripts/build-tech-log-tree.py <project> # 파생 칸을 다시 채운다
python3 scripts/verify-tech-log-tree.py <project> # error 0 이어야 한다
```
@@ -7,16 +7,18 @@
Question 4·Decision 2), n+1liner 24건(Case 5·Reference 7·Question 5·Decision 7).
「대개 이렇게 쓴다」는 말은 그 47건이 그렇게 돼 있다는 뜻이다.
`clean-architecture-backend-template` 의 949건은 다른 절 이름으로 쓰여 있었고 이 기준에 맞춰
고쳤다. 올라간 적 없는 초안이 아니라 **올라간 것**이 기준이다.
`clean-architecture-backend-template` 의 949건은 다른 절 이름으로 쓰여 있었고, 지금 이 기준으로
다시 판정하는 중이다. 아직 기준을 만족한 상태가 아니므로 그 949건을 본보기로 삼지 않는다. 올라간
적 없는 초안이 아니라 **올라간 것**이 기준이다.
## 파일 뼈대 — 다섯 종류가 같다
```markdown
---
id · kind · slug · title · topic · project · status · studio
id · kind · slug · title · topic · topicName · project · status · studio
source · sourceRevision
(종류별) basisVersion · decisionStatus · questionStatus · lastVerifiedOn
(있으면) assets · evidence
(있으면) evidence · assets — assets 는 Case 와 Concept 만
---
# 제목
@@ -41,10 +43,13 @@ frontmatter 는 메타데이터, `##` 는 Studio 의 칸, 제목 아래 첫 문
```yaml
source:
- analysis/05-adapter-outbound-persistence-jpa.md#L354
module: adapter-inbound-graphql
- final/document.md#a05-adapter-outbound-persistence-jpa#L354
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
```
`source` 는 SSOT 의 앵커다. 분석을 접어 넣은 프로젝트에는 `analysis/` 가 없으므로 그 경로를 적으면
가리키는 파일이 없다.
**관계 항목은 굵은 제목 한 줄 + 이유 한 줄**이다.
```markdown
@@ -63,7 +68,9 @@ module: adapter-inbound-graphql
| 검증 환경 | 런타임·버전·DB·측정 도구. `이름 : 값`으로 줄을 나눈다 |
| 재현 조건 | 다른 사람이 같은 값을 얻는 순서. 번호를 매긴다 |
**본문은 5~12절, 대개 6절이다.**
본문의 절은 하나의 주장과 그 근거가 이어지는 단위로 나눈다. 47건에서는 대개 여섯 절 안팎이었지만
**절 수는 작성 조건이 아니다.** 숫자를 맞추려고 절을 쪼개거나 붙이면 문서마다 같은 모양이 된다.
아래는 그 47건에서 실제로 반복된 순서다.
- **첫 절은 무대를 세운다.** 잰 코드나 구조를 먼저 보여 준다 — 「측정한 loadFeed 구현」,
「무대 — 페이징 한 줄만 추가」, 「가시성을 얹기 전 — 인덱스로 커서 이후만」,
@@ -75,7 +82,8 @@ module: adapter-inbound-graphql
그 마지막 절은 **본문 안**이다. 칸으로 빼면 Studio 에 그런 칸이 없어 사라진다.
코드블록에는 무엇을 보라는 한 줄을 붙인다. 표 앞이나 뒤에 그 표를 어떻게 읽는지 적는다. 예시는
코드블록에는 라벨로 무엇인지 적는다. 표는 머리글이 무엇을 묻는지 말하게 하고, 그 표를 어떻게 읽는지
설명하는 문장(「이렇게 갈린다」「함께 읽어야 한다」)은 두지 않는다. 예시는
한 규모로 고정한다 — 표가 전체 계열을 이미 보여 준다.
## Concept — 6건
@@ -90,7 +98,8 @@ basisVersion: Keycloak 26.7.0 · oidc-client-ts 3.3.0
basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request
```
**본문은 5~8절, 대개 6절이다.**
절의 개수를 정해 두지 않는다. 설명해야 할 참여자와 단계가 몇 개인지가 정한다. 47건에서 반복된
순서는 이렇다.
- 첫 절은 무엇이 무엇을 주고받는지다 — 「두 개의 OAuth 왕복이 이어진다」,
「Resource Server가 받는 입력」, 「요청 하나가 두 번 평가된다」
@@ -128,6 +137,18 @@ basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request
`다음 검증`은 실행할 수 있는 문장으로 적는다. 「더 알아본다」로는 닫히지 않는다 —
「seed(1000) 뒤 `ANALYZE highlights` 를 돌리고 Plan B 를 다시 잰다」처럼 적는다.
**그 아래 닫는 조건을 한 줄 붙인다.** 어떤 결과가 나오면 이 질문을 닫거나 Decision 으로 넘기는지
적지 않으면 검증을 마쳐도 질문이 그대로 열려 있다. 계약의 `decision-criterion` 이 이 줄이다.
```markdown
## 다음 검증
1. seed(1000) 뒤 `ANALYZE highlights` 를 돌리고 Plan B 를 다시 잰다
닫는 조건 : Plan B 의 실제 행 수가 추정치의 2배 안에 들어오면 닫고, 벗어나면 통계 갱신 주기를
정하는 Decision 으로 넘긴다
```
## Decision — 9건
칸은 **`근거`** · `결정문` · `판단 이유` · `영향`. **다른 넷과 달리 관계 절 이름이 「근거」다.**
@@ -147,11 +168,13 @@ basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request
- 그림은 `technical-visualizer` 로 만든다. 손으로 SVG 를 그리지 않는다
- 그림 안에는 이름만 넣는다. 문장은 `<desc>` 와 옆 문단에 둔다
- 그림과 증거는 frontmatter 의 `assets` · `evidence` 로 잇는다. 같은 파일을 기록 옆에 복사하지 않는다
- `assets` 는 본문이 있는 두 종류만 갖는다. Reference·Question·Decision 은 그림을 렌더링할 자리가
없어서 선언해도 화면에 나오지 않는다
- 본문은 `<!-- body:start -->``<!-- body:end -->` 사이다. 그 밖은 Studio 로 가지 않는다
## 관계를 어디서 가져오나
관계는 **다른 기록을 가리키는 링크**다. 지어내지 않는다. 분해 계약(`root-tree.md`)이 노드마다
관계는 **다른 기록을 가리키는 링크**다. 지어내지 않는다. 분해 계약(`tech-log-tree.json`)이 노드마다
`relations` 를 적어 두면 그것을 그대로 옮긴다.
계약이 관계를 적지 않은 노드는 한 가지 규칙만 쓸 수 있다 — **Reference 의 근거 사건은 같은
@@ -0,0 +1,139 @@
#!/usr/bin/env node
// 기록이 인용한 것이 정말 SSOT 에 있는지 본다.
//
// node check_evidence.mjs <프로젝트>
// node check_evidence.mjs <프로젝트> --repo # 저장소까지 대조 (sourceRepository.path 필요)
//
// 세 가지를 본다.
// 1. 본문 코드블록의 각 줄이 SSOT 안에 있는가
// 2. frontmatter 의 source 앵커가 SSOT 를 가리키는가
// 3. 기록의 title 이 계약(tech-log-tree.json)의 title 과 같은가
//
// 검사기가 못 보던 자리다. `verify-tech-log-tree.py` 는 slug 와 칸의 존재만 보고,
// 인용한 코드가 실재하는지도 제목이 계약과 같은지도 보지 않는다.
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
import { execSync } from "node:child_process";
const [project, ...flags] = process.argv.slice(2);
if (!project) { console.error("usage: check_evidence.mjs <프로젝트> [--repo]"); process.exit(2); }
const withRepo = flags.includes("--repo");
const root = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
const base = join(root, "docs", project);
const treePath = join(base, "tech-log-studio", "tech-log-tree.json");
if (!existsSync(treePath)) { console.error(`${project}: tech-log-tree.json 이 없다`); process.exit(2); }
const tree = JSON.parse(readFileSync(treePath, "utf8"));
const ssotRel = tree.ssot || "final/document.md";
const norm = s => s.replace(/\s+/g, " ").trim();
const ssot = norm(readFileSync(join(base, ssotRel), "utf8"));
// 계약이 말하는 제목
const contractTitle = new Map();
for (const topic of Object.values(tree.topics || {}))
for (const [kind, items] of Object.entries(topic.kinds || {}))
for (const n of items) if (n.slug) contractTitle.set(`${kind}:${n.slug}`, n.title || "");
// ``` 로 열고 닫는 펜스를 짝짓는다. ```java label="…" 도 여는 표시다
function codeBlocks(text) {
const out = []; let inside = false, lang = "", buf = [];
for (const line of text.split("\n")) {
const t = line.trimStart();
if (t.startsWith("```")) {
if (inside) { out.push([lang, buf.join("\n")]); buf = []; inside = false; lang = ""; }
else { inside = true; lang = (t.slice(3).trim().split(/\s+/)[0] || "").toLowerCase(); }
continue;
}
if (inside) buf.push(line);
}
return out;
}
// ```text 는 필자가 짠 요약표·흐름도에 쓰인다. 정렬 공백이 열 구분자라 줄 단위로 대조하면
// 전부 오탐이 된다. 그래서 text 펜스는 줄이 아니라 그 안의 식별자·URL·수치만 본다.
const PROSE_FENCE = new Set(["text", "", "txt", "console", "diff"]);
// 맨몸 영단어(observation, self-report …)는 필자가 붙인 열 이름이라 제외하고,
// 경로·URL·점 있는 식별자처럼 저장소에서 온 것만 본다.
const TOKEN = /(?:https?:\/\/[^\s"'`,)]+|\/[A-Za-z0-9_][A-Za-z0-9_./-]{4,}|[A-Za-z_][A-Za-z0-9_]*(?:[.][A-Za-z0-9_]+)+)/g;
const findings = [];
const studio = join(base, "tech-log-studio");
for (const topicDir of readdirSync(studio)) {
const tp = join(studio, topicDir);
if (!statSync(tp).isDirectory() || topicDir.startsWith("_")) continue;
for (const kind of readdirSync(tp)) {
const kp = join(tp, kind);
if (!statSync(kp).isDirectory()) continue;
for (const file of readdirSync(kp).filter(f => f.endsWith(".md"))) {
const p = join(kp, file);
const text = readFileSync(p, "utf8");
const fm = text.startsWith("---") ? text.slice(4, text.indexOf("\n---", 3)) : "";
const get = k => (fm.match(new RegExp(`^${k}: (.*)$`, "m")) || [, ""])[1].trim();
const slug = get("slug"), title = get("title");
// 1. 인용한 코드가 SSOT 에 있는가
const bodyStart = text.indexOf("<!-- body:start -->");
const body = bodyStart === -1 ? text : text.slice(bodyStart);
for (const [lang, block] of codeBlocks(body)) {
if (PROSE_FENCE.has(lang)) {
for (const tok of block.match(TOKEN) || [])
if (tok.length >= 8 && !ssot.includes(tok))
findings.push([file, "인용한 식별자가 SSOT 에 없다", tok.slice(0, 90)]);
continue;
}
for (const raw of block.split("\n")) {
const t = raw.trim();
if (t.length < 20) continue;
if (/^(\/\/|\*|\/\*\*|#|--|>|\|)/.test(t)) continue;
if (/[가-힣]/.test(t)) continue; // 한글이 섞인 줄은 코드가 아니다
if (!ssot.includes(norm(t)))
findings.push([file, "인용한 코드가 SSOT 에 없다", t.slice(0, 90)]);
}
}
// 2. source 앵커가 SSOT 를 가리키는가
const src = (fm.match(/^source:\n((?:\s+-\s.*\n)+)/m) || [, ""])[1];
const anchors = src.split("\n").map(l => l.replace(/^\s*-\s*/, "").trim()).filter(Boolean);
if (anchors.length && !anchors.some(a => a.includes(ssotRel)))
findings.push([file, "source 가 SSOT 를 가리키지 않는다", anchors.join(" · ").slice(0, 90)]);
// 3. 제목이 계약과 같은가
const key = `${kind}:${slug}`;
if (contractTitle.has(key) && contractTitle.get(key) !== title)
findings.push([file, "제목이 계약과 다르다", `계약 "${contractTitle.get(key)}" ≠ 기록 "${title}"`]);
}
}
}
// 4. (--repo) 저장소가 실재하고 리비전이 맞는가
if (withRepo) {
const repo = tree.sourceRepository || {};
if (!repo.path) findings.push(["tech-log-tree.json", "sourceRepository.path 가 없다", ""]);
else if (!existsSync(repo.path)) findings.push(["tech-log-tree.json", "저장소 경로가 없다", repo.path]);
else {
// 갈래가 여럿이면 revisions 로 적는다. 둘 다 없으면 verify-tech-log-tree.py 가 warn 을 낸다
const revs = repo.revision ? { revision: repo.revision } : (repo.revisions || {});
for (const [label, rev] of Object.entries(revs)) {
try {
execSync(`git -C ${JSON.stringify(repo.path)} cat-file -e ${rev}^{commit}`, { stdio: "ignore" });
} catch {
findings.push(["tech-log-tree.json", "그 리비전이 저장소에 없다", `${label} = ${rev}`]);
}
}
}
}
const grouped = new Map();
for (const [f, rule, detail] of findings) {
if (!grouped.has(rule)) grouped.set(rule, []);
grouped.get(rule).push(`${f}${detail}`);
}
console.log(`\n[${project}] 증빙 대조${withRepo ? " (저장소 포함)" : ""}`);
if (!findings.length) { console.log(" 문제 없음"); process.exit(0); }
for (const [rule, items] of [...grouped].sort((a, b) => b[1].length - a[1].length)) {
console.log(`${String(items.length).padStart(4)} ${rule}`);
for (const it of items.slice(0, 3)) console.log(` · ${it}`);
if (items.length > 3) console.log(` … 외 ${items.length - 3}`);
}
console.log(`\n합계 ${findings.length}`);
process.exit(1);
@@ -3,11 +3,15 @@ id: <Studio 가 준 uuid. 아직 없으면 빈 값>
kind: CASE
slug: <slug>
title: <제목>
topic: <주제 이름>
topic: <topic-slug — 폴더 이름과 같다>
topicName: <화면에 보이는 주제 이름>
project: <프로젝트 이름>
status: 게시 전
studio: "<편집 화면 주소. 아직 없으면 빈 값>"
lastVerifiedOn: <실제로 확인한 날 또는 빈 값>
source:
- final/document.md#<anchor>
sourceRevision: <분석한 리비전>
assets:
- key: <본문의 :::evidence key 와 같은 값>
file: <../../../final/assets/… 상대 경로>
@@ -44,6 +48,6 @@ evidence:
<!-- body:start -->
<rich Case body>
<rich Case body — 절은 주장 하나와 그 근거가 이어지는 단위로 나눈다. 정해진 개수는 없다>
<!-- body:end -->
@@ -3,11 +3,15 @@ id: <Studio 가 준 uuid. 아직 없으면 빈 값>
kind: CONCEPT
slug: <slug>
title: <제목>
topic: <주제 이름>
topic: <topic-slug — 폴더 이름과 같다>
topicName: <화면에 보이는 주제 이름>
project: <프로젝트 이름>
status: 게시 전
studio: "<편집 화면 주소. 아직 없으면 빈 값>"
basisVersion: <무엇을 보고 썼는지. 예 Keycloak 26.7.0 · oidc-client-ts 3.3.0>
source:
- final/document.md#<anchor>
sourceRevision: <분석한 리비전>
assets:
- key: <본문의 :::evidence key 와 같은 값>
file: <../../../final/assets/… 상대 경로>
@@ -34,6 +38,8 @@ evidence:
## <단계마다 실제로 일어나는 일>
<설명할 단계가 몇 개인지가 절의 개수를 정한다. 미리 정해 둔 수에 맞추지 않는다>
## <그 설계가 막지 않는 것>
## <지금 확인한 범위>
@@ -3,18 +3,24 @@ id: <Studio 가 준 uuid. 아직 없으면 빈 값>
kind: PROJECT_DECISION
slug: <slug>
title: <제목>
topic: <주제 이름>
topic: <topic-slug — 폴더 이름과 같다>
topicName: <화면에 보이는 주제 이름>
project: <프로젝트 이름>
status: 게시 전
studio: "<편집 화면 주소. 아직 없으면 빈 값>"
decisionStatus: PROPOSED
assets:
- key: <본문의 :::evidence key 와 같은 값>
file: <../../../final/assets/… 상대 경로>
source:
- final/document.md#<anchor>
sourceRevision: <분석한 리비전>
evidence:
- <../../../final/evidence/raw/… 상대 경로>
---
<!--
본문이 없는 종류라 `assets` 를 두지 않는다. 칸이 평문으로 렌더링되므로 그림과 코드블록은
표시되지 않는다. 그런 자료는 근거로 건 Case 나 Concept 에 담는다.
-->
# <title>
<summary>
@@ -3,18 +3,24 @@ id: <Studio 가 준 uuid. 아직 없으면 빈 값>
kind: QUESTION
slug: <slug>
title: <제목>
topic: <주제 이름>
topic: <topic-slug — 폴더 이름과 같다>
topicName: <화면에 보이는 주제 이름>
project: <프로젝트 이름>
status: 게시 전
studio: "<편집 화면 주소. 아직 없으면 빈 값>"
questionStatus: OPEN
assets:
- key: <본문의 :::evidence key 와 같은 값>
file: <../../../final/assets/… 상대 경로>
source:
- final/document.md#<anchor>
sourceRevision: <분석한 리비전>
evidence:
- <../../../final/evidence/raw/… 상대 경로>
---
<!--
본문이 없는 종류라 `assets` 를 두지 않는다. 칸이 평문으로 렌더링되므로 그림과 코드블록은
표시되지 않는다. 그런 자료는 Case 나 Concept 에 담고 `관계`로 가리킨다.
-->
# <title>
<summary of unresolved issue>
@@ -49,3 +55,5 @@ evidence:
## 다음 검증
1. <next concrete verification>
닫는 조건 : <어떤 결과가 나오면 이 질문을 닫거나 Decision 으로 넘기는가>
@@ -3,17 +3,24 @@ id: <Studio 가 준 uuid. 아직 없으면 빈 값>
kind: REFERENCE
slug: <slug>
title: <제목>
topic: <주제 이름>
topic: <topic-slug — 폴더 이름과 같다>
topicName: <화면에 보이는 주제 이름>
project: <프로젝트 이름>
status: 게시 전
studio: "<편집 화면 주소. 아직 없으면 빈 값>"
assets:
- key: <본문의 :::evidence key 와 같은 값>
file: <../../../final/assets/… 상대 경로>
source:
- final/document.md#<anchor>
sourceRevision: <분석한 리비전>
evidence:
- <../../../final/evidence/raw/… 상대 경로>
---
<!--
본문이 없는 종류라 `assets` 를 두지 않는다. 이 기록의 칸은 평문으로 렌더링되므로 그림도
코드블록도 표시되지 않는다. 그림이 필요한 내용은 Case 나 Concept 에 담고 `관계`로 가리킨다.
`evidence` 는 이 기록이 인용한 측정 자료의 출처이고 화면에는 나오지 않는다.
-->
# <title>
<summary>