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
@@ -15,12 +15,31 @@ Produce a highly detailed, source-traceable engineering analysis. This stage dis
2. If an `IN_PROGRESS` project exists, analyze only that project. If none exists, activate the first `PENDING` project in queue order. Never preempt an active project because a new project appeared.
3. For the selected `<분석 대상 저장소>`, check the nearest `AGENTS.md` or equivalent repository instructions.
4. Record Git revision and `git status` when Git is available. Never modify or reset user source as part of analysis.
5. Read `docs/<프로젝트>/state.json` if it exists; otherwise initialize from `docs/_templates`.
5. Read `docs/<프로젝트>/state.json` if it exists; otherwise initialize the working material from `templates/` in this skill. The project folder template (`docs/_templates/`) holds only the finished shape and does not carry it.
6. Map repository/build/module boundaries before choosing a scope.
7. If the repository is large, select one bounded unanalysed module/subsystem and analyze it completely. Do not skim the whole repository and call that detailed analysis.
8. Update `source-index.md`, the bounded analysis file, coverage ledger, evidence, and `state.json`.
9. Capture runtime evidence only where it resolves a material uncertainty or verifies a significant claim.
10. Continue the same project across runs until all intended scopes are complete. Then synthesize `final/document.md` without dropping provenance or limitations, mark its queue entry `COMPLETE`, and clear `activeProject`. Do not start the next project before this completion transition.
10. Continue the same project across runs until all intended scopes are complete.
11. **Fold the analysis into `final/document.md`.** Not a summary of it — the material
itself, with provenance and limitations intact. The test is that every claim a Tech Log
record will cite can be anchored in `final/document.md` alone. Anything that survives
only in `analysis/**` has not been folded in.
12. **Remove the working material.** `analysis/`, `notes/`, `checkpoints/`, `state.json`,
and `source-index.md` exist only while the analysis runs. A finished project folder
holds `final/` and `tech-log-studio/` (and `source/` when the material came from
outside). Then mark the queue entry `COMPLETE` and clear `activeProject`. Do not start
the next project before this completion transition.
`python3 scripts/fold-analysis-into-final.py <project>` performs steps 11 and 12: it moves
the module analyses into part 2 of `final/document.md`, the source index, scope coverage and
process notes into part 3, rewrites every `analysis/NN` anchor to `final/document.md#aNN`,
and removes the working material.
`analysisStatus: COMPLETE` while the working material is still on disk means step 11 was
skipped — the analysis was summarized rather than folded in, and downstream records will
anchor on `analysis/**` instead of the SSOT. `scripts/verify-project-layout.py` and
`scripts/verify-tech-log-tree.py` count that state.
Read `references/queue-contract.md`, `references/analysis-contract.md`, `references/deep-analysis-standard.md`, and `references/evidence-contract.md` before analysis.
@@ -60,7 +60,7 @@ When reanalysis finishes:
- mark the queue entry `COMPLETE`;
- clear `activeProject`.
The 10:00 root-tree stage will see the changed final document and may then update decomposition/readiness. The 11:00 generation stage remains grounded in that updated tree.
The 10:00 decomposition stage will see the changed final document and may then update decomposition/readiness. The 11:00 generation stage remains grounded in that updated tree.
## New projects and ordering
@@ -0,0 +1,11 @@
분석하는 동안에만 있는 작업 재료의 틀이다.
docs/<프로젝트>/state.json 분석 상태 — 어디까지 봤나, 어느 리비전을 봤나
docs/<프로젝트>/source-index.md 분석한 코드의 목록
docs/<프로젝트>/analysis/ 모듈·서브시스템 단위 분석
docs/<프로젝트>/notes/ 분석 중에 남긴 메모
docs/<프로젝트>/checkpoints/ 중간 저장
프로젝트 폴더 틀(docs/_templates/)에는 이것들이 없다. 분석이 끝나면 내용을
final/document.md 로 옮기고 폴더에서 지우기 때문이다. 끝난 프로젝트의 폴더는
final/ 과 tech-log-studio/ 둘이다.
@@ -20,11 +20,11 @@
"status": "NOT_STARTED",
"sourceRevision": null
},
"rootTree": {
"path": "root-tree.md",
"evidenceTasks": [],
"lastRunAt": null,
"techLogTree": {
"path": "tech-log-studio/tech-log-tree.json",
"status": "NOT_STARTED",
"sourceDocumentHash": null
},
"evidenceTasks": [],
"lastRunAt": null
}
}
@@ -1,38 +1,142 @@
---
name: deriving-tech-log-root-tree
description: Use when a completed or substantially completed docs project analysis must be decomposed into grounded Tech Log Topics and candidate Case, Reference, Open Question, and Decision records.
description: Use when a completed or substantially completed docs project analysis must be decomposed into grounded Tech Log Topics and candidate Case, Concept, Reference, Open Question, and Decision records.
---
# Deriving Tech Log Root Tree
## Core rule
**Discover record candidates from evidence already present in the detailed analysis. Do not brainstorm a content calendar.**
**Select what is worth publishing. Do not emit everything the analysis found.**
Recall is the objective function of an analysis-coverage audit. It is not the objective
function of an editorial decomposition. When the two are measured on one axis, every
analysis by-product becomes a record. A decomposition that excludes nothing has not
selected anything.
## SSOT hierarchy
Four layers, and only one of them is the input for finding candidates.
| Layer | Role |
|---|---|
| code · config · execution evidence | ground truth for facts |
| `final/document.md` | **SSOT for the candidate scope** — the only input for discovering candidates |
| `analysis/**/*.md` | supporting detail for a claim `final` already adopted — exists only while the analysis is running |
| `tech-log-tree.json` | the decomposition contract and the index at once, and the source of truth. Written by hand; a script refreshes only the fields it can read back from the record files |
Do not open `analysis/**` to discover candidates. Open it to check the detail of a claim
that is already in `final/document.md`. If the analysis holds material that `final` does
not, **fix `final/document.md` first**, then decompose. Otherwise 61 module documents
become 61 competing SSOTs and the tree grows to their combined section count.
## Candidate scope
A folded `final/document.md` is not uniformly candidate material. Part 1 is the integrated
analysis and it is where candidates come from. Part 2 holds the module analyses that were
folded in, and Part 3 holds the analysis material — both are supporting evidence, and
reading them as candidate material recreates the failure the fold was meant to end: one
candidate per module-analysis heading.
Declare the boundary in the contract so it is checkable rather than remembered.
```json
"candidateScope": {
"document": "final/document.md",
"sections": ["§3", "§4", "§5", "§6", "§7", "§8", "§9", "§10", "§11"],
"excluded": ["제2부 — 모듈 분석 전문", "제3부 — 분석 재료"]
}
```
An anchor outside that scope is a source anchor, not a candidate. Cite it from a node whose
candidate came from Part 1.
## Which files exist, and when
Decomposition happens after the analysis has been folded in, and the folded project has
fewer files than the one that was being analyzed. Read what is actually there.
| Phase | Files | Where candidates come from |
|---|---|---|
| analysis running | `state.json` · `source-index.md` · `analysis/**` · `final/document.md` | `final/document.md` |
| analysis folded in | `final/document.md` only | `final/document.md`, candidate scope |
| decomposition | `final/document.md` · `tech-log-tree.json` | candidate scope |
`state.json` and `source-index.md` say how far the analysis got and which code it covered.
They do not hold candidates, and in a folded project they are gone.
## Required sequence
1. Read project `state.json`, `final/document.md`, and `source-index.md`.
2. Read bounded analysis files when the final document's anchor is not enough to judge classification.
3. Identify coherent Topics from shared engineering problem spaces, not merely folder/module names.
4. Within each Topic, identify concrete incidents first (Case), then reusable rules (Reference), unresolved unknowns (Open Question), and explicit project choices (Decision).
5. Write the human-readable PROJECT/TOPIC tree.
6. Add a Node Specification for every title with source anchors, readiness, relations, and kind-specific metadata.
7. Run `references/decomposition-checklist.md`.
8. Hash the source detailed document and record the project revision so downstream generation can detect staleness.
1. Read the candidate scope of `final/document.md` end to end.
2. Pick representative **Cases** from the confirmed-problem and execution sections
(in the standard layout, §3–§8).
3. Pick **References** from the reusable-criteria section (§9).
4. Pick **Decisions** from the explicit-decision section (§10).
5. Pick **Questions** from the unresolved section (§11).
6. Only now add the **Concepts** those four need in order to be understood. Concept is
derived backwards from the records that require it, never by sweeping headings.
7. Give every candidate a disposition — `references/candidate-disposition.md` — and set
`dispositionReview` to `CONFIRMED` only for the ones a person actually re-read.
8. Group `PROMOTE` candidates into Topics. Write one reader question per Topic.
9. Write every promoted candidate into `tech-log-tree.json` as a node under its Topic,
with the fields its kind requires. There is no second tree to keep in step.
10. Run `references/decomposition-checklist.md`.
11. Record `candidateScope`, the source document hash, and the project revision.
12. `python3 scripts/verify-tech-log-tree.py <project>` — errors must be 0.
Use `.agents/skills/writing-tech-log-records/references/root-tree-contract.md` as the output contract.
Use `.agents/skills/writing-tech-log-records/references/tech-log-tree-contract.md` as the
output contract.
## Topic boundary
A Topic is a stable problem/decision area whose records share terminology, evidence, and relations. It should be broad enough to connect several records when the evidence supports them, but narrow enough that its References and Decisions remain coherent.
**A Topic is one reader question.** Write it under the topic slug:
Do not create one Topic per source file. Do not force unrelated incidents into one Topic because they use the same framework.
```text
TOPIC
OAuth 자격증명과 세션의 보관 경계
oauth-oidc-auth-boundary
독자 질문 — 자격증명과 세션을 누가 보관하고, 누가 API 요청을 만들며, 보호 자원은 무엇을 신뢰하는가?
```
A node that does not help answer that question belongs to another Topic. If a Topic needs
two questions, it is two Topics. If two Topics share one question, they are one Topic.
This is the test that catches both failures at once — splitting one problem space across
`state-machines-and-ownership`, `state-ownership-and-concurrency`, and
`owner-safe-state-machines`, and packing forwarded-header trust, fileserver mapping,
Redis key APIs, and permission normalization into one `admission-budget-and-backpressure`.
Do not create one Topic per source file or module. A directory is not a Topic.
## Classification discipline
- Case title names the concrete engineering problem/verification, not a generic technology lesson.
- Reference title names a reusable criterion/distinction.
- Open Question title states an uncertainty that is still unresolved.
- Decision title states an actual/proposed project direction evidenced in sources.
- **Case** — one problem, an observation or reproduction, a diagnosis, a closed conclusion.
- **Concept** — structure or behavior that must be explained from the beginning before a
Case can be understood. Has a `basis-version`.
- **Reference** — a rule that applies to the next project, with scope and exceptions.
- **Open Question** — no answer yet, the design turns on the answer, and there is a next
verification and a closing criterion.
- **Decision** — the project actually chose a direction, with grounds and an accepted cost.
Branches may be empty. Symmetry is not a quality goal.
The independence test decides all five:
> Delete this record and fold it into a related Case or Concept as one section. If
> understanding, decisions, and reuse are unchanged, it is not an independent record.
Branches may be empty. Symmetry is not a quality goal. Neither is volume — a large
denominator justifies a long `final/document.md`, not a long tree.
## Refreshing the derived fields
There is one file. `tech-log-tree.json` is written by hand, and the build refreshes only
what it can read back from the record files — `file`, `publication`, `status`, `studioId`,
`assets`, `evidenceFiles` — plus `counts`, `ssotSha256`, and the `unlisted` list.
```bash
python3 scripts/build-tech-log-tree.py <project>
```
`readiness`, `source`, `classification`, `relations`, and the rest of each kind's fields
survive a rebuild untouched. The build never reads the directory listing for Topics: a
folder left behind after a node is dropped from the contract shows up in `unlisted`, and it
does not come back as a Topic.
@@ -0,0 +1,99 @@
# 후보의 처분 — 무엇을 독립 기록으로 만들고 무엇을 만들지 않는가
분석에서 나온 항목마다 처분을 하나 적는다. 처분은 `tech-log-tree.json``candidates`
남고, `PROMOTE` 만 같은 파일의 `topics` 로 올라간다.
## 목표 함수
**빠짐없이 방출하는 것이 아니라 고르는 것이다.** 분석 누락을 검증할 때는 recall 100%
가 맞다. 공개할 글을 정할 때는 아니다. 「분석에서 보존할 가치」와 「독립된 글로 읽을
가치」는 다른 물음이고, 둘을 한 축으로 재면 분석 부산물이 전부 글이 된다.
제외가 0 건인 분해는 선별하지 않은 분해다.
## 여섯 가지 처분
| 처분 | 뜻 | 어디로 |
|---|---|---|
| `PROMOTE` | 독립 Tech Log 로 쓴다 | `tech-log-tree.json` 의 노드가 된다 |
| `MERGE_INTO` | 다른 기록의 한 절·표 행으로 흡수한다 | 흡수한 기록의 slug 를 `target` 에 적는다 |
| `KEEP_IN_SSOT` | 중요한 분석 결과지만 독립 기록은 아니다 | `final/document.md``analysis/**` 에 남는다 |
| `NEEDS_EVIDENCE` | 주장에 아직 검증이 없다 | 측정한 뒤에 다시 판정한다 |
| `NEEDS_DECISION` | 방향이 그럴듯하지만 프로젝트가 정하지 않았다 | 정해진 뒤에 다시 판정한다 |
| `BLOCKED` | 원본이 불완전하거나 서로 어긋난다 | 원본을 고친 뒤에 다시 판정한다 |
**`KEEP_IN_SSOT` 은 실패가 아니다.** 정보를 버리지 않으면서 글로 과분류하지 않는
상태다. 분석 범위, 호출자 수, 미배선 사실, 커버리지 원장, 재현에 쓴 레인 같은 것이
여기 온다 — 분석에는 반드시 남아야 하고 공개 기록으로는 읽을 사람이 없다.
`REJECTED` 는 쓰지 않는다. 무엇을 버렸는지가 아니라 무엇이 어디에 남았는지를 적는다.
## 독립성 검사
처분을 정하는 물음은 하나다.
> **이 기록을 없애고 관련 Case 나 Concept 의 한 절로 넣어도 이해·결정·재사용성이
> 그대로라면 독립 기록으로 만들지 않는다.**
그대로면 `MERGE_INTO`. 넣을 자리조차 없으면 `KEEP_IN_SSOT`.
## 종류마다 독립 기록이 되는 조건
| 종류 | 독립 기록이 되는 조건 | 되지 않는 것 |
|---|---|---|
| Case | 하나의 문제 · 관측·재현 · 진단 · 결론이 닫힌다 | 단순 정적 카운트, 문구 수정, 같은 원인의 부분 증상 |
| Concept | 내부 구조나 동작을 처음부터 설명해야 Case 를 이해할 수 있다. 기준 버전이 있다 | 분석 범위, 호출자 수, 미배선 사실, 한두 문장으로 Case 안에 설명되는 것 |
| Reference | 다음 프로젝트에도 적용할 규칙이며 적용 조건과 예외가 있다 | Case 결론을 선언문으로 바꾼 것 |
| Question | 답이 아직 없고, 답에 따라 설계가 달라지며, 다음 검증과 종료 기준이 있다 | 실행하지 않은 테스트 목록, 막연한 "다른 방법은?" |
| Decision | 대안 중 프로젝트가 실제 방향을 정했고 근거와 감수한 비용이 있다 | 기술이 존재한다는 사실, 권장사항, 아직 정하지 않은 방향 |
## Case 를 언제 합치나
**같은 질문에서 나와 같은 결론에 닿는 관측이면 한 Case 다.** 인과 단위·의미 단위·검증
단위가 셋 다 같아야 합친다는 기준은 너무 좁다 — 그 기준에서는 같은 결함의 다섯 증상이
다섯 편이 된다.
관측이 여럿이면 한 Case 안에 표나 하위 절로 넣는다. 표의 행 하나가 될 것을 기록
하나로 만들지 않는다.
## Concept 을 언제 만드나
**Case·Decision·Question 을 먼저 고른 뒤 거꾸로 뽑는다.** "이 Case 를 읽는 사람이 미리
알아야 하는 구조가 있는가"를 묻고, 있으면 그때 Concept 을 만든다. 메커니즘처럼 보이는
절을 훑어 채우면 어느 Case 도 필요로 하지 않는 개념이 쌓인다.
Concept 에는 `basis-version` 이 있어야 한다. 무엇을 보고 쓴 글인지 없으면 언제 낡았는지
읽는 사람이 알 방법이 없다.
제목이 이런 꼴이면 Concept 이 아니다.
```text
호출자가 없다 → 부재는 Case 의 관측이다
프로덕션에서 실행되지 않는다 → 같은 이유
구현 클래스 51개를 전부 읽었다 → 분석 범위. KEEP_IN_SSOT
보류한 항목과 보류한 이유 → 분석 진행 기록. KEEP_IN_SSOT
(8.4) 문서/구현 드리프트 — … → 분석 문서의 절 제목을 그대로 옮긴 것
Confirmed — … → 같은 것. finding 등급이 제목에 남아 있다
```
## 대장에 적는 것
```json
{
"id": "A05-F012",
"kindCandidate": "CASE",
"sourceRefs": ["final/document.md#8-3"],
"summary": "…",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:two-owners-popped-the-evidence-frame",
"reason": "같은 결함의 두 번째 증상이다. 그 Case 의 재현 절에 행으로 들어간다"
}
```
`dispositionReview``CONFIRMED``PENDING` 둘이다. 사람이 위 물음으로 판정했으면
`CONFIRMED`, recall 로 자동 방출된 것이면 `PENDING` 이다. **`PENDING` 이 남아 있는
프로젝트는 글감 선별이 끝나지 않은 것이다.**
`python3 scripts/verify-tech-log-tree.py <프로젝트>` 가 남은 건수를 error 로 센다. 경고가
아니라 error 인 이유는 하나다 — 경고로 두면 재판정하지 않은 트리로 글을 쓰기 시작할 수 있다.
@@ -1,16 +1,28 @@
# Root Tree Decomposition Checklist
# Tech Log Tree Decomposition Checklist
## Selection
- [ ] Every analysis candidate carries a disposition, and `KEEP_IN_SSOT` is used.
- [ ] `dispositionReview: PENDING` is 0 — nothing reached the tree by recall alone.
- [ ] Each `PROMOTE` node passes the independence test.
- [ ] Candidates were discovered from `final/document.md`, not from `analysis/**`.
- [ ] Material found only in `analysis/**` was added to `final/document.md` first.
- [ ] `candidateScope` is declared, and no candidate came from outside it.
## Source integrity
- [ ] The tree records the detailed document hash and project revision/snapshot.
- [ ] The tree records the source document hash, ledger hash, and project revision.
- [ ] Every node has at least one source anchor.
- [ ] Source anchors actually contain the material implied by the title.
- [ ] Runtime-dependent claims name evidence or use `NEEDS_EVIDENCE`.
- [ ] `readiness` states how well evidenced the node is. It does not state whether the
record has been written or published.
## Topic quality
- [ ] Topic is a coherent engineering problem space rather than a directory name.
- [ ] Two Topics do not merely split the same causal chain arbitrarily.
- [ ] Every Topic has one reader question, and every node in it helps answer that question.
- [ ] No two Topics share a reader question.
- [ ] Topic is an engineering problem space rather than a directory name.
- [ ] A large Topic is split when its records no longer share useful relations/criteria.
## Case
@@ -18,6 +30,15 @@
- [ ] There is a specific incident, experiment, failure, diagnosis, or verification sequence.
- [ ] The title can be understood without inventing a historical story.
- [ ] The conclusion is bounded by actual evidence.
- [ ] Observations that answer the same question with the same conclusion are one Case,
as a table or sub-sections — not several partial Cases.
## Concept
- [ ] It was added because a Case, Decision, or Question needs it, not by sweeping headings.
- [ ] `basis-version` names what the explanation was written against.
- [ ] The title names a mechanism, not an absence, a count, or an analysis-scope fact.
- [ ] No analysis section number or finding grade survives in the title.
## Reference
@@ -29,15 +50,25 @@
- [ ] The answer is not already in the analysis.
- [ ] Known/unknown/next verification are separable.
- [ ] The design or a decision actually turns on the answer.
- [ ] Candidate options are included only when sources really considered them.
## Decision
- [ ] A project choice is explicitly recorded or user-supplied.
- [ ] `technology is present` is not being treated as rationale.
- [ ] The accepted cost is stated, not only the benefit.
- [ ] `NEEDS_DECISION` is used if the direction is only a recommendation.
## Duplication
## Duplication and shape
- [ ] No two nodes have the same primary purpose.
- [ ] Relations are used instead of copying one record's entire content into another.
- [ ] Relations are used instead of copying one record into another.
- [ ] Node count is bounded by what a reader would read, not by the analysis denominator.
## Parity
- [ ] Every node traces back to a `PROMOTE` candidate, and every `PROMOTE` candidate has a node.
- [ ] `counts` matches what `build-tech-log-tree.py` produces.
- [ ] `unlisted` is empty — no record file exists outside the contract.
- [ ] `python3 scripts/verify-tech-log-tree.py <project>` reports 0 errors.
@@ -1,410 +0,0 @@
---
schemaVersion: 1
exampleOnly: true
generationAllowed: false
project: backend-clean-architecture
sourceDocument: final/document.md
sourceDocumentSha256: <example-only>
sourceRevision: <example-only>
generatedAt: <example-only>
---
# Root Tree Example
> 이 파일은 **구조 예시**다. 실제 `/shared/codebase/backend-clean-architecture` 분석을 수행해 만든 결과가 아니므로 downstream 문서 생성에 사용하지 않는다. 실제 프로젝트에서는 동일한 형식으로 source anchor와 evidence를 채우고 readiness를 판정한다.
PROJECT
backend-clean-architecture
TOPIC
JPA 피드 조회 성능
jpa-feed-query-performance
├── CASE
│ ├── DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
│ ├── 필드 접근 없이 발생한 EAGER ToOne N+1
│ ├── Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
│ ├── Collection Fetch Join Pagination의 In-memory Paging
│ ├── Projection 이후에도 1,509행을 읽은 Row Over-fetch
│ └── Visibility OR이 Keyset Index를 깨뜨린 문제
├── REFERENCE
│ ├── JPA N+1 정량 진단 기준
│ ├── Fetch Type과 Fetch Strategy 구분
│ ├── Fetch Join · Batch · Projection 선택 기준
│ ├── Top-N-per-group 선택 기준
│ ├── Keyset Pagination 설계 기준
│ ├── Feed Visibility Query Pattern
│ └── PostgreSQL Query Plan 측정 기준
├── OPEN QUESTION
│ ├── Highlight 없는 FeedItem을 허용할 것인가
│ ├── Round Trip과 Row Volume을 독립 측정할 것인가
│ ├── ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
│ ├── feed_visible을 Production CQRS로 승격할 것인가
│ └── 실제 동시 트래픽에서도 이 구조가 안정적인가
└── DECISION
├── Query Plan은 실제 PostgreSQL에서 측정한다
├── Query Strategy는 FeedQueryPort 뒤에서 소유한다
├── Collection Fetch Join과 Pagination을 같이 사용하지 않는다
├── Entity Graph 조회에는 Batch Fetch를 사용한다
├── 화면 조회는 Read Projection을 사용한다
├── Feed Pagination은 Keyset을 사용한다
└── 현재 Read Model은 CQRS-lite로 유지한다
# Node Specifications
## CASE — DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
- slug: `highlight-collection-n-plus-one`
- readiness: `BLOCKED`
- source:
- `final/document.md#컬렉션-n1-정량화`
- code:
- `<actual source path/symbol from analyzed project>`
- evidence:
- `<actual raw/query-plan/test evidence path>`
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
- relations:
- `<related Reference/Question/Decision slug and reason>`
## CASE — 필드 접근 없이 발생한 EAGER ToOne N+1
- slug: `eager-to-one-n-plus-one`
- readiness: `BLOCKED`
- source:
- `final/document.md#user-page-연관-숨은-추가-쿼리-정량화`
- code:
- `<actual source path/symbol from analyzed project>`
- evidence:
- `<actual raw/query-plan/test evidence path>`
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
- relations:
- `<related Reference/Question/Decision slug and reason>`
## CASE — Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
- slug: `fetch-join-multibag-row-explosion`
- readiness: `BLOCKED`
- source:
- `final/document.md#fetch-join을-적용하며-확인한-두-가지-문제`
- code:
- `<actual source path/symbol from analyzed project>`
- evidence:
- `<actual raw/query-plan/test evidence path>`
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
- relations:
- `<related Reference/Question/Decision slug and reason>`
## CASE — Collection Fetch Join Pagination의 In-memory Paging
- slug: `collection-fetch-join-in-memory-pagination`
- readiness: `BLOCKED`
- source:
- `final/document.md#컬렉션-fetch-join-페이징`
- code:
- `<actual source path/symbol from analyzed project>`
- evidence:
- `<actual raw/query-plan/test evidence path>`
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
- relations:
- `<related Reference/Question/Decision slug and reason>`
## CASE — Projection 이후에도 1,509행을 읽은 Row Over-fetch
- slug: `projection-row-over-fetch`
- readiness: `BLOCKED`
- source:
- `final/document.md#dto-프로젝션`
- code:
- `<actual source path/symbol from analyzed project>`
- evidence:
- `<actual raw/query-plan/test evidence path>`
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
- relations:
- `<related Reference/Question/Decision slug and reason>`
## CASE — Visibility OR이 Keyset Index를 깨뜨린 문제
- slug: `visibility-or-breaks-keyset-index`
- readiness: `BLOCKED`
- source:
- `final/document.md#가시성-조건`
- code:
- `<actual source path/symbol from analyzed project>`
- evidence:
- `<actual raw/query-plan/test evidence path>`
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
- relations:
- `<related Reference/Question/Decision slug and reason>`
## REFERENCE — JPA N+1 정량 진단 기준
- slug: `jpa-n-plus-one-quantitative-diagnosis`
- readiness: `BLOCKED`
- source:
- `final/document.md#컬렉션-n1-정량화`
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
- scope: `<actual applicability derived from analysis>`
- exceptions: `<actual exceptions or none>`
- relations:
- `<originating Case/Decision and reason>`
## REFERENCE — Fetch Type과 Fetch Strategy 구분
- slug: `fetch-type-vs-fetch-strategy`
- readiness: `BLOCKED`
- source:
- `final/document.md#최초-구현과-첫-관찰`
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
- scope: `<actual applicability derived from analysis>`
- exceptions: `<actual exceptions or none>`
- relations:
- `<originating Case/Decision and reason>`
## REFERENCE — Fetch Join · Batch · Projection 선택 기준
- slug: `fetch-join-batch-projection-selection`
- readiness: `BLOCKED`
- source:
- `final/document.md#배치-페치`
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
- scope: `<actual applicability derived from analysis>`
- exceptions: `<actual exceptions or none>`
- relations:
- `<originating Case/Decision and reason>`
## REFERENCE — Top-N-per-group 선택 기준
- slug: `top-n-per-group-selection`
- readiness: `BLOCKED`
- source:
- `final/document.md#top-n-per-group`
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
- scope: `<actual applicability derived from analysis>`
- exceptions: `<actual exceptions or none>`
- relations:
- `<originating Case/Decision and reason>`
## REFERENCE — Keyset Pagination 설계 기준
- slug: `keyset-pagination-design`
- readiness: `BLOCKED`
- source:
- `final/document.md#keyset-vs-offset`
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
- scope: `<actual applicability derived from analysis>`
- exceptions: `<actual exceptions or none>`
- relations:
- `<originating Case/Decision and reason>`
## REFERENCE — Feed Visibility Query Pattern
- slug: `feed-visibility-query-pattern`
- readiness: `BLOCKED`
- source:
- `final/document.md#가시성-조건`
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
- scope: `<actual applicability derived from analysis>`
- exceptions: `<actual exceptions or none>`
- relations:
- `<originating Case/Decision and reason>`
## REFERENCE — PostgreSQL Query Plan 측정 기준
- slug: `postgresql-query-plan-measurement`
- readiness: `BLOCKED`
- source:
- `final/document.md#측정-환경과-데이터셋`
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
- scope: `<actual applicability derived from analysis>`
- exceptions: `<actual exceptions or none>`
- relations:
- `<originating Case/Decision and reason>`
## OPEN QUESTION — Highlight 없는 FeedItem을 허용할 것인가
- slug: `allow-feed-item-without-highlight`
- readiness: `BLOCKED`
- source:
- `final/document.md#확인된-문제와-이후-검증할-가설`
- known:
- `<grounded fact from detailed analysis>`
- unknown:
- `<specific unresolved uncertainty>`
- next-verification: `<concrete experiment/measurement/decision input>`
- decision-criterion: `<condition that would close the question>`
- relations:
- `<related Case/Reference/Decision and reason>`
## OPEN QUESTION — Round Trip과 Row Volume을 독립 측정할 것인가
- slug: `measure-round-trip-and-row-volume-separately`
- readiness: `BLOCKED`
- source:
- `final/document.md#측정-환경과-데이터셋`
- known:
- `<grounded fact from detailed analysis>`
- unknown:
- `<specific unresolved uncertainty>`
- next-verification: `<concrete experiment/measurement/decision input>`
- decision-criterion: `<condition that would close the question>`
- relations:
- `<related Case/Reference/Decision and reason>`
## OPEN QUESTION — ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
- slug: `cardinality-estimate-after-analyze`
- readiness: `BLOCKED`
- source:
- `final/document.md#postgresql-query-plan-측정`
- known:
- `<grounded fact from detailed analysis>`
- unknown:
- `<specific unresolved uncertainty>`
- next-verification: `<concrete experiment/measurement/decision input>`
- decision-criterion: `<condition that would close the question>`
- relations:
- `<related Case/Reference/Decision and reason>`
## OPEN QUESTION — feed_visible을 Production CQRS로 승격할 것인가
- slug: `promote-feed-visible-to-production-cqrs`
- readiness: `BLOCKED`
- source:
- `final/document.md#cqrs-lite-읽기-모델`
- known:
- `<grounded fact from detailed analysis>`
- unknown:
- `<specific unresolved uncertainty>`
- next-verification: `<concrete experiment/measurement/decision input>`
- decision-criterion: `<condition that would close the question>`
- relations:
- `<related Case/Reference/Decision and reason>`
## OPEN QUESTION — 실제 동시 트래픽에서도 이 구조가 안정적인가
- slug: `stability-under-concurrent-traffic`
- readiness: `BLOCKED`
- source:
- `final/document.md#다음-단계`
- known:
- `<grounded fact from detailed analysis>`
- unknown:
- `<specific unresolved uncertainty>`
- next-verification: `<concrete experiment/measurement/decision input>`
- decision-criterion: `<condition that would close the question>`
- relations:
- `<related Case/Reference/Decision and reason>`
## DECISION — Query Plan은 실제 PostgreSQL에서 측정한다
- slug: `measure-query-plan-on-postgresql`
- readiness: `NEEDS_DECISION`
- decision-status: `NOT_DECIDED`
- source:
- `final/document.md#측정-환경과-데이터셋`
- decision-evidence:
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
- grounds:
- `<Case or Reference that supports the choice>`
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
- relations:
- `<related nodes and reason>`
## DECISION — Query Strategy는 FeedQueryPort 뒤에서 소유한다
- slug: `query-strategy-behind-feed-query-port`
- readiness: `NEEDS_DECISION`
- decision-status: `NOT_DECIDED`
- source:
- `final/document.md#조회-전략은-포트-뒤-어댑터의-책임`
- decision-evidence:
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
- grounds:
- `<Case or Reference that supports the choice>`
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
- relations:
- `<related nodes and reason>`
## DECISION — Collection Fetch Join과 Pagination을 같이 사용하지 않는다
- slug: `no-collection-fetch-join-with-pagination`
- readiness: `NEEDS_DECISION`
- decision-status: `NOT_DECIDED`
- source:
- `final/document.md#컬렉션-fetch-join-페이징`
- decision-evidence:
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
- grounds:
- `<Case or Reference that supports the choice>`
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
- relations:
- `<related nodes and reason>`
## DECISION — Entity Graph 조회에는 Batch Fetch를 사용한다
- slug: `batch-fetch-for-entity-graph`
- readiness: `NEEDS_DECISION`
- decision-status: `NOT_DECIDED`
- source:
- `final/document.md#배치-페치`
- decision-evidence:
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
- grounds:
- `<Case or Reference that supports the choice>`
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
- relations:
- `<related nodes and reason>`
## DECISION — 화면 조회는 Read Projection을 사용한다
- slug: `read-projection-for-screen-query`
- readiness: `NEEDS_DECISION`
- decision-status: `NOT_DECIDED`
- source:
- `final/document.md#dto-프로젝션`
- decision-evidence:
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
- grounds:
- `<Case or Reference that supports the choice>`
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
- relations:
- `<related nodes and reason>`
## DECISION — Feed Pagination은 Keyset을 사용한다
- slug: `keyset-for-feed-pagination`
- readiness: `NEEDS_DECISION`
- decision-status: `NOT_DECIDED`
- source:
- `final/document.md#keyset-vs-offset`
- decision-evidence:
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
- grounds:
- `<Case or Reference that supports the choice>`
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
- relations:
- `<related nodes and reason>`
## DECISION — 현재 Read Model은 CQRS-lite로 유지한다
- slug: `keep-cqrs-lite-read-model`
- readiness: `NEEDS_DECISION`
- decision-status: `NOT_DECIDED`
- source:
- `final/document.md#cqrs-lite-읽기-모델`
- decision-evidence:
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
- grounds:
- `<Case or Reference that supports the choice>`
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
- relations:
- `<related nodes and reason>`
@@ -0,0 +1,152 @@
# `tech-log-tree.json` 예시
**구조 예시다.** 실제 분석을 수행해 만든 결과가 아니므로 이 값을 그대로 옮겨 쓰지 않는다.
실제 프로젝트에서는 같은 모양에 진짜 source anchor 와 evidence 를 채우고 readiness 를 판정한다.
트리는 이 파일 하나다. 사람이 읽는 트리와 Node Specification 을 따로 쓰고 대조하던 절차는 없다 —
계약과 색인이 같은 파일이라 어긋날 자리가 없다.
```json
{
"schemaVersion": 4,
"project": "n+1liner",
"ssot": "final/document.md",
"ssotSha256": "<sha256>",
"sourceRevision": "<git-revision>",
"generatedAt": "<YYYY-MM-DD>",
"candidateScope": {
"document": "final/document.md",
"sections": ["§3", "§4", "§5", "§6", "§7", "§8", "§9", "§10", "§11"],
"excluded": ["제2부 — 모듈 분석 전문", "제3부 — 분석 재료"]
},
"contract": {
"readinessValues": ["READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"],
"dispositionValues": {
"PROMOTE": "독립 Tech Log 로 쓴다",
"MERGE_INTO": "다른 기록의 한 절로 흡수한다",
"KEEP_IN_SSOT": "분석에는 남기고 독립 기록으로 만들지 않는다",
"NEEDS_EVIDENCE": "주장에 아직 검증이 없다",
"NEEDS_DECISION": "방향이 그럴듯하지만 프로젝트가 정하지 않았다",
"BLOCKED": "원본이 불완전하거나 서로 어긋난다"
}
},
"topics": {
"jpa-feed-query-performance": {
"topic": "jpa-feed-query-performance",
"title": "JPA 피드 조회 성능",
"readerQuestion": "피드 한 화면을 그리는 데 쿼리가 몇 번 나가고, 조회 전략을 바꿀 때 무엇이 함께 바뀌는가?",
"kinds": {
"case": [
{
"title": "필드 접근 없이 발생한 EAGER ToOne N+1",
"kind": "case",
"slug": "eager-to-one-n-plus-one",
"readiness": "READY",
"source": ["final/document.md#user-page-연관-숨은-추가-쿼리-정량화"],
"code": ["FeedQueryRepository.java:loadFeed"],
"evidence": ["evidence/raw/explain/highlights-child-plan-A.txt"],
"classification": "조회 한 번에 나간 쿼리 수를 세어 재현했고 실행계획으로 확인했다",
"missing-verification": "동시 트래픽에서는 재지 않았다",
"relations": ["reference:fetch-type-vs-fetch-strategy"]
}
],
"concept": [
{
"title": "Fetch Type 과 Fetch Strategy 가 갈라지는 자리",
"kind": "concept",
"slug": "fetch-type-and-fetch-strategy",
"readiness": "READY",
"source": ["final/document.md#fetch-type과-fetch-strategy"],
"basis-version": "Hibernate 6.4 · Spring Data JPA 3.2",
"classification": "이 구분을 먼저 알아야 위 Case 의 관측을 읽을 수 있다",
"relations": ["case:eager-to-one-n-plus-one"]
}
],
"reference": [
{
"title": "Fetch Type 과 Fetch Strategy 를 구분한다",
"kind": "reference",
"slug": "fetch-type-vs-fetch-strategy",
"readiness": "READY",
"source": ["final/document.md#fetch-type과-fetch-strategy"],
"classification": "다음 프로젝트에도 적용할 조회 기준이다",
"scope": "JPA 연관을 하나라도 조회하는 모듈",
"exceptions": "단건 조회만 있는 경로에는 걸리지 않는다",
"relations": ["case:eager-to-one-n-plus-one"]
}
],
"question": [
{
"title": "ANALYZE 이후 Cardinality Estimate 는 어떻게 달라지는가",
"kind": "question",
"slug": "cardinality-estimate-after-analyze",
"readiness": "OPEN",
"source": ["final/document.md#query-plan-측정"],
"known": "현재 통계에서 Plan B 의 추정 행 수는 실제의 1/8 이다",
"unknown": "통계를 갱신하면 플래너가 같은 계획을 고르는지",
"next-verification": "seed(1000) 뒤 ANALYZE highlights 를 돌리고 Plan B 를 다시 잰다",
"decision-criterion": "추정치가 실제의 2배 안이면 닫고, 벗어나면 통계 갱신 주기를 정하는 Decision 으로 넘긴다",
"relations": ["case:eager-to-one-n-plus-one"]
}
],
"decision": [
{
"title": "Collection Fetch Join 과 Pagination 을 같이 쓰지 않는다",
"kind": "decision",
"slug": "no-collection-fetch-join-with-pagination",
"readiness": "READY",
"decision-status": "ADOPTED",
"source": ["final/document.md#컬렉션-fetch-join-페이징"],
"decision-evidence": ["case:eager-to-one-n-plus-one"],
"grounds": "메모리 페이징으로 떨어지는 것을 실행계획에서 확인했다",
"classification": "대안을 두고 프로젝트가 실제로 고른 방향이다",
"relations": ["case:eager-to-one-n-plus-one"]
}
]
}
}
},
"candidates": [
{
"id": "F012",
"kindCandidate": "CASE",
"sourceRefs": ["final/document.md#user-page-연관-숨은-추가-쿼리-정량화"],
"summary": "필드 접근 없이 EAGER ToOne 이 추가 쿼리를 냈다",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "case:eager-to-one-n-plus-one",
"reason": "재현·진단·결론이 한 사건 안에서 닫힌다"
},
{
"id": "F013",
"kindCandidate": "CASE",
"sourceRefs": ["final/document.md#컬렉션-n1-정량화"],
"summary": "같은 원인으로 컬렉션 쪽에서도 추가 쿼리가 났다",
"disposition": "MERGE_INTO",
"dispositionReview": "CONFIRMED",
"target": "case:eager-to-one-n-plus-one",
"reason": "같은 결함의 두 번째 증상이다. 그 Case 의 표에 행으로 들어간다"
},
{
"id": "F014",
"kindCandidate": "CONCEPT",
"sourceRefs": ["final/document.md#분석-범위"],
"summary": "이번 분석에서 읽은 리포지터리 메서드는 41개다",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "분석 범위 계수다. 분석에는 남아야 하고 공개 기록으로는 읽을 사람이 없다"
}
],
"counts": { "topics": 1, "nodes": 5, "written": 0, "unwritten": 5, "unlisted": 0, "candidates": 3 },
"unlisted": [],
"history": {}
}
```
## 이 예시가 보여 주는 것
- 후보 셋 중 하나만 글감이 됐다. `MERGE_INTO``KEEP_IN_SSOT` 이 없는 분해는 선별하지 않은 분해다.
- Concept 은 Case 를 먼저 고른 뒤에 그것을 읽는 데 필요해서 더했다.
- Question 에 `decision-criterion` 이 있다. 무엇이 나오면 닫는지를 적지 않으면 검증을 마쳐도 열려 있다.
- 다섯 종류를 억지로 채우지 않아도 된다. 여기서 다섯이 다 있는 것은 실제로 다섯이 있었기 때문이다.
@@ -13,6 +13,16 @@ import { readFileSync } from 'node:fs';
const ERR = 'error', WARN = 'warn';
// 관형형 어미 `-ㄴ`·`-ㄹ` 이 붙은 음절. 「적힌」·「만들」처럼 받침이 ㄴ 이나 ㄹ 인 글자를
// 코드포인트로 만든다. 「두 자리」·「세 자리」 같은 자릿수는 앞 글자에 받침이 없어 빠진다.
const ADNOMINAL = (() => {
const out = [];
for (let cho = 0; cho < 19; cho++)
for (let jung = 0; jung < 21; jung++)
for (const jong of [4, 8]) out.push(String.fromCharCode(0xAC00 + cho * 588 + jung * 28 + jong));
return out.join('');
})();
const RULES = [
{ id: 'idiom-follow', sev: ERR, re: /[를을]\s*(따라갔|따라\s*늘|따라\s*증가|좇았|좇아)/g,
msg: '개수에 `따라가다/좇다`를 붙였습니다. `~에 비례해`, `~가 커지는 만큼`, `~와 같은 수로`, 또는 값을 그대로 적으세요.' },
@@ -47,6 +57,21 @@ const RULES = [
re: /(나머지\s*(둘|셋|하나)[^.\n]{0,20}(대신|자리|메우)|어느\s*하나도[^.\n]{0,25}(대신|자리)|각각\s*다른\s*[가-힣]{1,8}(을|를|에서|에)\s*(맡|쓰이|담당)|그\s*자리를\s*(대신|메우)|서로\s*독립된\s*[가-힣\d]{1,6}\s*곳|만으로는[^.\n]{0,40}(속성|성질)을\s*대신)/g,
msg: '역할을 세어 대칭을 만들었습니다. 그것 하나만 있을 때 무엇이 실제로 통과하는지 적으세요.' },
// 무엇이 어디서 일어나는지를 「자리」로 대신한다. 「적힌 자리가 없다」·「그 자리에서 푼다」·
// 「검사기가 자리다」. 참고 여섯 편에 한 건도 없다(자리 0 · 옆 0 · 칸 0). 곳·부분으로 바꾸거나,
// 애초에 장소가 아니라 순서·동작이면 그것을 적는다.
// 관형형 어미와 지시어 뒤만 본다. 「앞 두 자리」 같은 자릿수는 걸리지 않는다.
{ id: 'spatial-metaphor', sev: ERR,
re: new RegExp(`(?:[${ADNOMINAL}]|는|던|[그이저])\\s*자리`, 'g'),
msg: '`자리`로 설명했습니다. 장소를 뜻하면 `곳`·`부분`으로 바꾸고, 장소가 아니면 거기서 무엇이 일어나는지 동사로 적으세요.' },
// 할 일이나 노출을 「그대로 남아 있다」로 닫는다. 누가 무엇을 해야 하는지 말하지 않고 상태만
// 보고한다. 참고 여섯 편에 한 건도 없다 — 그 글들의 「남아있다」 3건은 의존관계가 실제로 남는
// 것이라 형태가 다르다. 값·쿠키가 진짜 남는 문장은 걸리지 않는다.
{ id: 'leftover-state', sev: ERR,
re: /(그대로\s*남[아는])|((일|것|부분|점|과제|몫)(은|이|도)\s*(아직\s*)?남[아는])|(아직\s*남아\s*있)/g,
msg: '무엇이 「남아 있다」로 닫았습니다. 누가 무엇을 해야 하는지, 또는 무엇을 아직 막지 못하는지로 적으세요.' },
{ id: 'nominalized', sev: ERR, re: /(채워진\s*목록\s*수|준비한\s*SQL\s*문장|획득한[^.\n]{0,10}객체\s*수|[가-힣]+에\s*대한\s*(측정|비교|확인|분석))/g,
msg: '사건을 명사구로 바꿨습니다. 동사로 적으세요.' },
@@ -118,7 +143,7 @@ function positiveChecks(text, lines, docMode, rulesMode) {
// 문서 어디에든 `약어(...)` 형태가 있으면 푼 것으로 본다
if (!new RegExp(a + '\\s*\\(').test(text)) {
out.push({ id: 'unexpanded-acronym', sev: WARN,
msg: `약어 \`${a}\`을(를) 글 어디에서도 풀지 않았습니다. 처음 나오는 자리\`${a}(전체 이름, 우리말 뜻)\`으로 폅니다.` });
msg: `약어 \`${a}\`을(를) 글 어디에서도 풀지 않았습니다. 처음 나오는 \`${a}(전체 이름, 우리말 뜻)\`으로 폅니다.` });
}
}
@@ -156,12 +181,12 @@ function positiveChecks(text, lines, docMode, rulesMode) {
for (const m of alwaysBad) {
out.push({ id: 'naming-instead-of-telling', sev: ERR, index: m.index,
excerpt: text.slice(Math.max(0, m.index - 30), m.index + m[0].length),
msg: '무슨 일이 있었는지 적는 대신 그것이 무엇인지 이름 붙이고 닫았습니다. 그 자리에서 실제로 일어나는 일을 동사로 적으세요.' });
msg: '무슨 일이 있었는지 적는 대신 그것이 무엇인지 이름 붙이고 닫았습니다. 거기서 실제로 일어나는 일을 동사로 적으세요.' });
}
if (!rulesMode && namingEnd.length >= 4) {
out.push({ id: 'naming-instead-of-telling', sev: ERR,
msg: `문장을 「~것이 ~이다」로 닫은 자리가 ${namingEnd.length}입니다(참고 여섯 편은 글 하나에 0~2회). `
+ `분류하지 말고 그 자리에서 무엇이 일어나는지 적으세요.` });
msg: `문장을 「~것이 ~이다」로 닫은 곳이 ${namingEnd.length}군데입니다(참고 여섯 편은 글 하나에 0~2회). `
+ `분류하지 말고 거기서 무엇이 일어나는지 적으세요.` });
}
if (!rulesMode && sentences.length >= 8 && kinds.size <= 1) {
@@ -173,11 +198,27 @@ function positiveChecks(text, lines, docMode, rulesMode) {
// 4. 독자를 데리고 다니는 문장
const steer = /(살펴보|알아보|파보|확인해\s*봅|정리해\s*보|소개해\s*보|다뤄\s*보|짚어\s*보|나중에\s*살펴|딴 길로|먼저[^\n]{0,25}부터|이번에는|공유합니다|공유하고자|다루겠습니다|보겠습니다|하겠습니다)/;
// 강제하지 않는다. 강제했더니 `먼저 ~를 구분해야 합니다` 같은 지도형 문장이 절마다 붙어서
// 문장이 아니라 구조가 기계처럼 읽히게 됐다.
if (!rulesMode && !steer.test(text)) {
out.push({ id: 'no-reader-steering', sev: WARN,
msg: '독자를 안내하는 문장이 없습니다. 필요하면 하나 두되, 없어도 됩니다.' });
// 안내 문장이 없다고 경고하지 않는다. 그 경고가 「필요하면 하나 두라」로 읽혀 평가·안내 문장을
// 보태는 쪽으로 작용했다. 문서는 대상을 설명하지 독자의 읽기를 지시하지 않는다.
// 문장이 끝나지 않은 채 문단이 끝나는 줄 — 지우다 남은 조각이거나 마침표가 빠진 것.
// 연결어미·조사로 끝나고 다음 줄이 비어 있을 때만 잡는다. 문단 안에서 줄을 바꾼 것은
// 다음 줄이 이어지므로 걸리지 않는다. 줄 끝에 인라인 코드가 있었으면(strip 이 공백으로
// 바꿔 둔 자리) 판단할 수 없으니 건너뛴다. 「이름 : 값」 줄도 문장이 아니라 건너뛴다.
const DANGLING = /(때|고|며|면|를|을|는|은|이|가|에서|으로|에|와|과|도|서|아|어|지|니|라서|라|의)$/;
let inFront = lines[0] === '---';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (inFront) { if (i > 0 && line === '---') inFront = false; continue; }
const t = line.trimEnd();
if (!t.trim() || t !== line) continue; // 빈 줄 · 끝에 공백(인라인 코드 자리)
if (/^\s*(#|-|\*|\d+\.|:::|<!--|\||>)/.test(t) || t.includes(' : ')) continue;
const next = (lines[i + 1] ?? '').trim();
if (next !== '') continue; // 문단이 이어진다
if (DANGLING.test(t)) {
out.push({ id: 'dangling-sentence', sev: ERR, line: i + 1,
msg: `문장이 끝나지 않은 채 문단이 끝납니다("…${t.slice(-24)}"). 지우다 남은 조각이거나 마침표가 빠졌습니다.` });
}
}
// 독자에게 사고를 지시하는 문장 — 사건으로 바로 들어가면 될 자리
@@ -0,0 +1,117 @@
---
name: writing-as-the-person-who-did-it
description: Use when a Korean technical document is accurate, well-ordered and well-evidenced but reads like a report produced by nobody — no one chose anything, nothing surprised anyone, and the limits are an inventory instead of an admission.
---
# 일한 사람이 쓴 글로 만들기
## 무엇을 고치는 스킬인가
문장도 맞고 순서도 맞고 근거도 붙어 있는데 **아무도 쓰지 않은 글처럼 읽히는** 상태를 고친다.
증상은 셋이다.
- 무엇을 골랐다는 말은 있는데 **왜 그것을 골랐는지, 무엇과 견주었는지**가 없다
- 확인하지 못한 것이 **목록**으로 있고, 그것이 걸리는 대목에는 없다
- 어긋난 자리가 없다. 처음부터 지금 구조를 알고 있었던 것처럼 읽힌다
고치는 방법은 하나뿐이다. **자료에 남아 있는 사람의 흔적을 찾아서 제자리에 놓는다.**
## 절대 규칙 — 없는 사람을 만들지 않는다
이 스킬을 잘못 쓰면 지어낸 경험이 붙는다. 그것이 아무 목소리도 없는 글보다 나쁘다.
**쓸 수 있는 것은 자료가 기록한 사람의 행동과 판단뿐이다.** 넣은 문장마다 그것이 어느 파일,
어느 칸, 어느 줄에서 왔는지 댈 수 있어야 한다. 못 대면 지어낸 것이다.
| 이런 말이 떠오르면 | 실제로 벌어지는 일 |
|---|---|
| 「처음에는 X를 의심했다」 | 자료에 그런 기록이 없으면 없던 수사 과정을 만든 것이다 |
| 「놀랍게도」·「당황스럽게도」 | 감정을 지어냈다. 관측이 뜻밖이었다는 근거가 자료에 없다 |
| 「고민 끝에 Y를 골랐다」 | 대안을 견주었다는 기록이 없으면 고민도 없었다 |
| 「많은 시행착오를 겪었다」 | 시행착오가 자료에 있으면 그것을 적고, 없으면 이 문장도 없다 |
| 「이 글이 도움이 되길 바란다」 | 기록에는 독자에게 인사하는 자리가 없다. 참고 글의 습관을 옮겨 온 것이다 |
| 「~로 읽기 쉽다. 그렇지 않다」 | 자료에 그런 오독 기록이 없으면 독자를 지어낸 것이다. 관측부터 적는다 |
`../rewriting-technical-prose-naturally/references/protected-content.md`가 함께 걸린다. 수치·버전·
식별자는 이 스킬에서도 한 글자도 바뀌지 않는다.
## 자료 어디에 사람이 남아 있나
기록을 열기 전에 이것부터 찾는다. 대개 **이미 문서 안에 있고 자리가 틀렸을 뿐**이다.
| 어디 | 무엇이 있나 |
|---|---|
| `확인하지 못한 것` 칸 | 재지 않은 것을 아는 사람이 적어 둔 것이다. 목록이 아니라 그 대목에서 할 말이다 |
| `재현 조건` 칸 | 누군가 실제로 이 순서로 돌렸다. 그 순서가 왜 그 순서인지가 대개 빠져 있다 |
| javadoc·README·주석 | 코드를 쓴 사람이 남긴 말. 그대로 옮길 수 있다 |
| 커밋 메시지 | 왜 지웠는지·왜 바꿨는지가 한 줄로 남아 있다 |
| `final/document.md` | 기록으로 뽑으면서 잘라낸 판단이 여기 남아 있는 경우가 많다 |
| 결정 기록의 `판단 이유`·`영향` | 감수한 비용이 적혀 있다 |
찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.
## 옮길 수 있는 다섯 가지
참고 여섯 편에서 뽑았고, 각 항목이 어느 글에서 왔는지는
[references/voice-moves.md](references/voice-moves.md)에 있다. 여섯 편이 다 쓰는 것이 아니다 —
13569 한 편에 몰려 있고 23625는 거의 쓰지 않는다. **글마다 자료가 허락하는 만큼만 쓴다.**
1. **고른 이유를 견준 대상과 함께 적는다** — 무엇을 놓고 무엇을 골랐고 무엇을 감수했나
2. **확인하지 못한 것을 그 대목에서 말한다** — 끝의 목록이 아니라 그 주장 옆에서
3. **어긋난 자리를 남긴다** — 예상과 결과가 달랐던 지점이 자료에 있으면 지우지 않는다
4. **남의 말을 그대로 옮긴다** — javadoc·문서·커밋 메시지·사용자 문의. 요약하지 않는다
5. **자료에 남은 오독을 그 자리에 둔다** — 누군가 실제로 틀리게 읽었다는 기록(버그·정정·문의)이 있을 때만.
독자가 틀릴 것이라고 가정해서 만든 「~로 읽기 쉽다」는 이 항목이 아니라 지어낸 독자다
다섯을 다 쓰지 않는다. 한 기록에 둘이면 충분하고 **하나도 없어도 된다.** 이미 충분히 설명된 문단에
사람의 목소리를 더하려고 문장을 붙이면 그것이 「설명 뒤의 평가」가 된다. 다섯을 채우면 그것이 또 다른 틀이 된다.
## 문체는 옮기지 않는다
참고 글은 합니다체이고 `~는데요`·`~텐데요`·`~거든요`로 말을 부드럽게 한다. 이 저장소의 기록은
한다체다. **부드러움을 흉내 내면 그 회사 목소리가 되고, 기록의 어조가 무너진다.**
한다체에서 사람이 드러나는 자리는 어미가 아니라 **무엇을 말하기로 했는가**다.
```text
없는 목소리 컨트롤러가 직접 내부 토큰을 확인한다.
지어낸 목소리 컨트롤러가 직접 내부 토큰을 확인하는데요, 이게 좀 아쉬운 부분입니다.
평가만 더한 것 컨트롤러가 직접 내부 토큰을 확인한다. 지금 구조에서 이것을 막는 것은 없다.
있는 목소리 컨트롤러가 직접 내부 토큰을 확인한다. /edge/** 전체를 보호하는 filter 는 없어서,
새 엔드포인트가 같은 메서드를 부르지 않으면 그 엔드포인트는 검사를 지나친다.
```
네 번째가 사람이 쓴 글이다. 어미는 그대로이고, 아는 사람만 할 수 있는 말이 하나 붙었다 — 그리고 그
말이 **사실을 더한다**(filter 가 없다, 어느 경로가 검사를 지나치는가). 세 번째처럼 판정만 더하면
(「막는 것은 없다」) 그것은 목소리가 아니라 평가다. 그런 문장은 빼는 쪽이 맞다.
## 절차
1. **자료를 먼저 훑는다.** 위 표의 자리를 다 열고, 쓸 수 있는 흔적을 목록으로 적는다
2. **흔적이 없으면 멈춘다.** 문서에 그렇게 보고한다
3. **흔적마다 놓일 자리를 정한다.** 대개 그 주장이 나오는 문단 바로 뒤다
4. **옮긴다.** 문장을 새로 짓지 말고, 자료의 말을 그 자리에 놓는 것으로 시작한다
5. **검사한다**`scripts/check_voice.mjs`. 이 검사기는 목소리가 **모자란지** 재지 않는다.
**지어낸 목소리**를 잡는다
6. `../rewriting-technical-prose-naturally``check_prose.mjs`를 다시 돌린다
## 검사
```bash
node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs <파일.md>
```
세 가지를 본다.
- **지어낸 경험** — 자료가 뒷받침하지 않으면 error인 표현들
- **참고 글에서 옮겨 온 인사말** — 「도움이 되길 바랍니다」 계열
- **합니다체 부드러움을 한다체에 섞은 자리**
검사기가 조용하다고 목소리가 생긴 것은 아니다. **모자란 것은 사람이 읽어야 안다.**
## 참조
- [references/voice-moves.md](references/voice-moves.md) — 다섯 가지가 여섯 편에서 어떻게 쓰였나
- `../rewriting-technical-prose-naturally/examples/` — 원문 여섯 편
- `../rewriting-technical-prose-naturally/references/article-shape.md` — 밀도. 이 스킬보다 먼저 본다
- `../writing-tech-log-records/references/writing-each-kind.md` — 종류마다 어느 칸이 사람의 자리인가
@@ -0,0 +1,160 @@
# 다섯 가지가 여섯 편에서 어떻게 쓰였나
원문은 `../../rewriting-technical-prose-naturally/examples/`에 있다. 인용은 그 파일에서 그대로
옮긴 것이다.
## 먼저 — 여섯 편이 고르게 쓰지 않는다
세어 보면 이렇다.
| | 1인칭 | 그때 든 생각 | 예상이 빗나감 | 독자에게 말 걸기 | 모순·자조 | 겸양 닫기 |
|---|---|---|---|---|---|---|
| 13569 | 12 | 3 | 5 | 3 | 5 | 1 |
| 17386 | 10 | 5 | 0 | 2 | 1 | 0 |
| 7835 | 8 | 0 | 0 | 3 | 0 | 0 |
| 20161 | 5 | 0 | 0 | 2 | 0 | 0 |
| 22396 | 4 | 0 | 0 | 2 | 0 | 4 |
| 23625 | 1 | 0 | 0 | 0 | 0 | 0 |
**13569 한 편에 몰려 있다.** 23625는 거의 쓰지 않는데도 읽힌다. 그러니까 이것은 채워야 할 칸이
아니라, **자료가 그런 글일 때만 나오는 것**이다.
13569이 그렇게 쓸 수 있었던 이유는 그 글이 「예상보다 빨라져서 문제가 생겼다」는 사건을 실제로
겪었기 때문이다. 겪지 않은 글이 같은 어조를 쓰면 그때부터 거짓이 된다.
---
## 1. 고른 이유를 견준 대상과 함께 적는다
무엇을 골랐다가 아니라, **무엇을 놓고 무엇을 골랐고 무엇을 감수했나**를 적는다.
22396은 상용 내비게이션을 쓰지 않은 이유를 돈으로 적는다.
> 하지만 초당 2만 건에 달하는 경로 계산을 위해 API 호출마다 비용이 발생하는 상용 내비게이션을
> 사용하는 것은 현실적으로 어렵습니다. 게다가 배차를 추천받은 라이더분이 바로 수락하는 경우,
> 한 번 계산한 거리를 오랜 시간 재활용하기도 쉽지 않습니다.
그리고 고르지 **않은** 쪽을 표로 남긴다 — TTL 삭제 대 명시적 삭제. 장점 칸이 채워진 쪽을 버리고
단점이 하나뿐인 쪽을 골랐다는 것이 표에 그대로 보인다.
7835는 저장소를 고르는 대목에서 반대 의견을 먼저 적는다.
> 이벤트는 작은 단위로 저장이 되고, 고속 처리되어야하기 때문에 RDBMS 가 아닌 다른 데이터베이스를
> 선택해야한다고 생각할 수 있습니다.
그리고 왜 그럼에도 RDBMS인지로 이어 간다.
**한다체로 옮길 때** — 어미만 바꾸면 된다. 「~하는 것은 현실적으로 어렵다」·「~라고 생각할 수 있다」.
**쓸 수 없는 경우** — 자료에 대안이 없으면 이 항목은 건너뛴다. 「여러 방법을 검토했다」는 검토한
기록이 없으면 지어낸 문장이다.
---
## 2. 확인하지 못한 것을 그 대목에서 말한다
이 저장소의 기록은 `확인하지 못한 것`을 끝에 목록으로 모아 둔다. 참고 글은 **그 말이 걸리는 자리**
에서 한다.
22396은 압축을 시도한 절 안에서 바로 닫는다.
> 압축률이 높더라도 원본 데이터가 워낙 커서 압축만으로는 대역폭 문제를 해결할 수 없었습니다.
13569은 배포 전 검토 항목을 늘어놓은 뒤 그 자리에서 한계를 인정한다.
> 문제가 없을 것으로 예상하였지만 일들이 항상 마음처럼 굴러가던가요?
**한다체로 옮길 때** — 끝의 목록을 지우라는 말이 아니다. 목록은 두고, **그중 하나를 그 주장이
나오는 문단 뒤에 한 번 더** 놓는다. 읽는 사람이 그 주장을 믿을지 정하는 자리가 거기이기 때문이다.
```text
전 (본문 끝) ## 확인하지 못한 것
role 전달은 확인하지 않았다.
후 (그 대목) 지금 edge 응답은 사용자와 이메일만 전달한다. role을 넘기면 무엇이 달라지는지는
확인하지 않았다.
(본문 끝) ## 확인하지 못한 것 ← 목록은 그대로 둔다
```
---
## 3. 어긋난 자리를 남긴다
**자료에 있을 때만 쓴다.** 없으면 이 항목은 통째로 건너뛴다.
13569의 「빨라도 문제」 절 전체가 이것이다.
> 근데 이거 빨라도 너무 빨라진 것 같습니다.
> '내가 뭘 놓친 게 있나?' 아니면 '코드를 잘못 짰나?' 생각이 들었지만…
그리고 배포 뒤 실제로 문제가 났고, 그 대응이 스스로 모순임을 적는다.
> 근본적으로는 실행 속도가 너무 빨라진 것이 문제이기 때문에
> 모순적이지만 우선 단기적인 대응 방안으로 의도적으로 지연 시간을 설정해 천천히 실행하도록
> 수정하였습니다.
22396은 실패한 시도를 지우지 않는다 — 「시도 1. 데이터 압축」이 실패한 채로 남아 있고, 24MB → 3MB
라는 성공한 압축률까지 적은 다음 그것으로도 모자란다고 닫는다.
**한다체로 옮길 때** — 감탄사와 혼잣말은 옮기지 않는다. 옮기는 것은 **순서**다. 그때 그렇게
생각했다 → 실제로는 이랬다 → 그래서 이렇게 했다.
```text
지어낸 것 놀랍게도 응답은 200이었다.
있는 것 세션 자체는 유효하므로 이 요청이 200으로 처리되는 것은 정상이고, 그래서 이 테스트의
판정 기준은 상태 코드가 아니다.
```
두 번째는 읽는 사람이 200을 보고 「뚫렸다」고 짐작할 것을 알고 쓴 문장이다. 감정을 적지 않고도
사람이 보인다.
---
## 4. 남의 말을 그대로 옮긴다
여섯 편이 가장 자주 쓰는 방법이고, **증거로 뒷받침되므로 지어낼 수 없다.**
13569은 최적화를 말하다 말고 격언 셋을 통째로 옮긴다.
> "우리는 최적화에 대해서 다음 두가지 규칙을 따른다. 첫째. 하지 마라. 둘째. (전문가 한정)
> 아직은 하지 마라."
> M. A. Jackson (1975)
23625는 사용자 문의를 그대로 옮긴다.
> 심각한 문제는 사용자가 "같은 엑셀 파일을 여러 번 받았다"는 문의였습니다.
20161은 Nginx 문서와 Lucene 문서를 원문 그대로 인용문으로 넣는다.
**이 저장소에서 쓸 수 있는 것** — javadoc이 가장 많다. 코드를 쓴 사람이 자기 코드의 위험을 적어
둔 자리가 있으면 그것을 요약하지 말고 그대로 옮긴다.
> `InboxCleanupJob`의 javadoc이 스스로 적는다 — "A single unbounded DELETE over a table that has
> been accumulating for weeks holds locks long enough to block the very reservations the inbox
> exists to serve, so the cleanup would cause the outage it is meant to prevent."
이 한 줄이 있으면 「이 결함은 알려져 있었다」는 말을 따로 할 필요가 없다.
---
## 5. 한 번은 독자 쪽을 본다
읽는 사람이 **틀리게 짐작할 자리**에서 한 문장이다. 인사가 아니다.
20161은 겉보기에 멀쩡한 쿼리를 보여 주고 나서 짚는다.
> 겉으로 보기에는 문제가 없어 보이는 쿼리였지만, 카테고리 필터가 있을 때와 없을 때 검색 API의
> 응답 속도 차이는 매우 큰 편이었습니다.
22396은 강조를 두 번 한다고 말하고 두 번 한다.
> 앞서 언급했지만 중요한 내용이기에 다시 한번 강조드립니다.
> **배달료 책정이나 배민커넥트 앱상에서 활용되는 거리 계산에는 상용 내비게이션이 사용됩니다.**
17386은 글 첫머리에 「누가 읽으면 좋을까」를 두고 전제 지식을 못 박는다.
**한다체로 옮길 때** — 「~라고 생각하기 쉽다」·「겉보기에는 ~이지만」·「여기서 확인할 값은
~가 아니다」. 한 기록에 **한 번**이다. 두 번 넘으면 독자를 끌고 다니는 글이 된다.
**옮기지 않는 것** — 「도움이 되길 바랍니다」·「공유드립니다」·「즐거움을 느끼고 있습니다」.
블로그 글의 맺음말 자리에 있는 인사이고, 기록에는 그 자리가 없다.
@@ -0,0 +1,115 @@
#!/usr/bin/env node
// 지어낸 목소리를 잡는다. 모자란 목소리는 재지 않는다 — 그것은 사람이 읽어야 안다.
//
// node check_voice.mjs [--블로그] <파일.md> [...]
//
// 왜 세지 않는가: 이 저장소에서 「종결어미 종류 수」를 세는 검사를 넣었더니, 그것을 맞추려고
// 없던 물음표 문장과 「~해 보자」가 문서에 끼어들었다. 목소리를 개수로 재면 같은 일이 난다.
// 그래서 이 검사기는 있어야 할 것을 요구하지 않고, 있으면 안 되는 것만 잡는다.
//
// 참고 여섯 편을 그냥 돌리면 떨어진다. 그것은 규칙이 과해서가 아니라 장르가 달라서다 —
// 여섯 편은 합니다체 블로그 글이라 `~는데요`를 쓰고 맺음말에 인사를 둔다. 기록은 한다체이고
// 인사할 자리가 없다. 여섯 편에 돌려 볼 때는 `--블로그`를 붙인다. 그러면 이 두 규칙만 꺼지고
// 지어낸 목소리를 잡는 규칙은 그대로 돈다 — 여섯 편은 그쪽에 한 건도 걸리지 않는다.
import { readFileSync } from "node:fs";
import { basename } from "node:path";
const ERR = "error", WARN = "warn";
const RULES = [
// 1. 수사 과정을 말하는 문구.
//
// 이것만으로는 지어냈는지 알 수 없다. 22396 의 「여러 시행착오를 겪었습니다」는 바로 뒤에
// 실패한 시도 1 을 통째로 싣고, 7835 의 「고민 끝에」는 글 전체가 그 고민이다. 둘 다 정당하다.
// 나쁜 것은 문장만 있고 과정이 없는 경우인데, 그건 문서를 읽어야 안다. 그래서 판단 항목이다.
{ id: "process-claimed", sev: WARN,
re: /(처음에는[^.\n]{0,30}(의심|생각|짐작)|한참[^.\n]{0,15}(헤매|찾|고민)|여러[^.\n]{0,10}(시행착오|삽질)|고민\s*끝에|우여곡절|검토\s*끝에)/g,
msg: "겪은 과정을 말했습니다. 이 문서가 그 과정을 실제로 보여 주면 두고, 문장만 있으면 지우세요." },
{ id: "invented-emotion", sev: ERR,
re: /(놀랍게도|당황스럽|의외로|뜻밖에도|아쉽게도|다행히도|기쁘게도|흥미롭게도|충격적)/g,
msg: "감정을 지어냈습니다. 관측이 뜻밖이었다는 근거가 자료에 있어야 쓸 수 있습니다." },
// 2. 참고 글의 맺음말을 옮겨 온 자리. 기록에는 독자에게 인사하는 칸이 없다.
{ id: "borrowed-greeting", sev: ERR,
re: /(도움이\s*되(길|기를)|되었으면\s*좋겠|공유(드립니다|하고자|합니다)|읽어\s*주셔서|감사합니다|즐거움을\s*느끼|노력하겠습니다|기대합니다)/g,
msg: "블로그 맺음말의 인사입니다. 기록에는 그 자리가 없습니다. 남은 일이나 감수한 것으로 닫으세요." },
// 3. 합니다체의 부드러움을 한다체 문서에 섞은 자리
{ id: "register-mix", sev: ERR,
re: /(는데요|거든요|텐데요|인데요|한데요|잖아요|네요)/g,
msg: "합니다체의 부드러운 어미를 섞었습니다. 참고 글의 어조이지 이 기록의 어조가 아닙니다." },
// 4. 겪지 않은 1인칭. 기록의 주어는 대개 코드와 요청이다.
// 앞 글자가 한글이면 낱말 안이다 — 「브라우저는」의 「저는」을 잡지 않는다
{ id: "unsupported-first-person", sev: WARN,
re: /(?<![가-힣])(저는|저희(는|가|의|도)|제가|우리는)\s/g,
msg: "1인칭입니다. 자료가 그 사람의 행동을 기록했으면 두고, 아니면 무엇이 그렇게 했는지로 바꾸세요." },
// 5. 독자를 끌고 다니는 문장이 여러 번 나오는 것은 참고 글도 하지 않는다 (개수는 아래에서 본다)
{ id: "steering", sev: WARN,
re: /(라고\s*생각하기\s*쉽|겉보기에는|여기서\s*확인할\s*값은|짐작하기\s*쉽|헷갈리기\s*쉽)/g,
msg: "독자 쪽을 보는 문장입니다. 한 기록에 한 번이면 충분합니다." },
];
function strip(src) {
return src
.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, " "))
.replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length))
.replace(/^---\n[\s\S]*?\n---\n/, (m) => m.replace(/[^\n]/g, " "));
}
function lineOf(text, index) {
return text.slice(0, index).split("\n").length;
}
let failed = 0;
const argv = process.argv.slice(2);
// 합니다체 블로그 글에 돌릴 때는 어조·인사 규칙을 끈다. 장르가 다르지 글이 나빠서가 아니다.
const blogMode = argv.includes("--블로그");
const GENRE = new Set(["register-mix", "borrowed-greeting"]);
const files = argv.filter((a) => !a.startsWith("--"));
if (!files.length) {
console.error("쓰는 법: node check_voice.mjs [--블로그] <파일.md>");
process.exit(2);
}
for (const file of files) {
const raw = readFileSync(file, "utf8");
const text = strip(raw);
const found = [];
for (const r of RULES) {
if (blogMode && GENRE.has(r.id)) continue;
for (const m of text.matchAll(r.re)) {
found.push({ ...r, line: lineOf(text, m.index), hit: m[0].trim() });
}
}
// 독자 쪽을 보는 문장은 하나까지가 정상이다
const steering = found.filter((f) => f.id === "steering");
const rest = found.filter((f) => f.id !== "steering");
const shown = steering.length > 1 ? rest.concat(steering) : rest;
const errors = shown.filter((f) => f.sev === ERR).length;
const warns = shown.length - errors;
if (errors) failed = 1;
console.log(
errors
? `FAIL ${basename(file)} — error ${errors}${warns ? ` · 경고 ${warns}` : ""}`
: `OK ${basename(file)}${warns ? ` (경고 ${warns}건)` : ""}`
);
for (const f of shown) {
const mark = f.sev === ERR ? " " : "·";
console.log(` ${mark} ${basename(file)}:${f.line} [${f.id}] "${f.hit}"`);
console.log(` ${f.msg}`);
}
if (steering.length > 1) {
console.log(` · [steering] 독자 쪽을 보는 문장이 ${steering.length}개입니다 — 하나만 남기세요.`);
}
}
console.log(
"\n검사기가 조용해도 목소리가 생긴 것은 아닙니다. 모자란 것은 사람이 읽어야 압니다."
);
process.exit(failed);
@@ -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>
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/writing-as-the-person-who-did-it
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+135 -26
View File
@@ -9,6 +9,7 @@
| SSOT에서 글감을 뽑아 트리를 만든다 | `deriving-tech-log-root-tree` |
| 글감 하나를 기록으로 쓴다 | `writing-tech-log-records` |
| 문장이 AI가 쓴 것처럼 읽히면 다시 쓴다 | `rewriting-technical-prose-naturally` |
| 맞는 말인데 아무도 쓰지 않은 보고서처럼 읽히면 | `writing-as-the-person-who-did-it` |
| 그림을 만든다 | `technical-visualizer` |
| 분석에서 리팩터링 작업 항목을 뽑는다 | `refactoring-from-analysis` |
@@ -18,6 +19,10 @@
```text
원자료
→ SSOT final/document.md 하나. 여기서만 글감을 찾는다
→ 후보 처분 PROMOTE · MERGE_INTO · KEEP_IN_SSOT · NEEDS_EVIDENCE · NEEDS_DECISION
→ 분해 계약 tech-log-tree.json — PROMOTE 이고 CONFIRMED 인 것만 올린다. 주제마다 독자 질문 한 줄
→ 색인 생성 build-tech-log-tree.py · verify-tech-log-tree.py (error 0)
→ 종류 선택 Case · Concept · Reference · Question · Decision
→ 칸 채우기 종류마다 칸이 다르다
→ 본문 작성 Case · Concept. 코드·표·다이어그램·이미지
@@ -66,35 +71,99 @@ SVG로 컴파일한다. 손으로 SVG를 그리지 않는다. **그림 안에는
프로젝트 하나가 폴더 하나다. 프로젝트 문서는 그 폴더 밖에 두지 않는다.
**끝난 프로젝트의 폴더는 둘이고, 각각 정본 파일이 하나다.**
```text
docs/<프로젝트>/
├── source/ 밖에서 가져온 원본. 고치지 않는다
├── state.json 분석 상태 — 어디까지 봤나, 어느 리비전을 봤나
├── source-index.md 분석한 코드의 목록
├── analysis/ 모듈·서브시스템 단위 분석. 큰 저장소는 여기서 누적한다
│ 한 모듈을 끝까지 읽은 결과를 한 편으로 둔다
├── notes/ · checkpoints/ 분석 중에 남긴 메모와 중간 저장
├── final/ SSOT — 이 프로젝트에 대해 아는 것 전부
│ ├── document.md 상세한 글
│ ├── assets/ svg, drawio, 그림
── .techviz/ 그림의 정본 (context, spec, prompt)
│ ├── assets/ 그림. 그림 하나가 폴더 하나다 — <이름>/<이름>.svg 와 편집 형식들
│ └── tech-log-studio/ Studio 에 올릴 표현물. 기록의 assets: file: 이 가리키는 자리
│ ├── .techviz/ 그림의 정본 (context, spec, prompt). <이름>/ 이 assets 와 짝이다
│ └── evidence/ 증거. 원문이 정본이고 그림은 표현물이다
│ ├── raw/ 명령 출력·csv·덤프 원문 — 정본
│ ├── meta/ 그 실행의 command·cwd·executedAt·exitCode·revision
│ ├── rendered/ raw 에서 만든 터미널 SVG (표현물)
│ └── browser/ 브라우저 캡처 (Playwright MCP)
└── tech-log-studio/ Studio 에 올릴 글만
├── root-tree.md 사람이 쓴 분해 계약 — SSOT 의 sha256 을 물고 있
├── candidate-ledger.json · root-tree-source-manifest.json
├── tech-log-tree.json 위에서 파생한 색인. 기록을 고치면 다시 만든다
├── _meta/ 편집·검증 이력
├── tech-log-tree.json 분해 계약이자 색인. 이 프로젝트의 글감 전부
└── <주제 slug>/
├── case/ concept/ reference/ question/ decision/
```
**분해 계약이 정본이고 `tech-log-tree.json` 은 색인이다.** `root-tree.md` 는 글감마다 readiness·
source anchor·classification·missing-verification 을 사람이 적는 자리이고, json 은 기록 파일을
읽어 지금 상태를 비추는 것이다. 둘이 어긋나면 `root-tree.md` 가 맞다.
**분석하거나 반입하는 동안에만 있는 것이 따로 있다.**
```text
├── source/ 밖에서 가져온 원본. 대조가 끝나면 지운다
├── state.json 분석 상태 — 어디까지 봤나, 어느 리비전을 봤나
├── source-index.md 분석한 코드의 목록
├── analysis/ 모듈·서브시스템 단위 분석. 큰 저장소는 여기서 누적한다
└── notes/ · checkpoints/ 분석 중에 남긴 메모와 중간 저장
```
**이것들은 작업 재료다.** 틀도 프로젝트 폴더가 아니라
`.agents/skills/analyzing-codebase-for-tech-log/templates/` 에 있다. 끝나면 두 번 합친다.
```bash
python3 scripts/fold-analysis-into-final.py <프로젝트> # 분석 재료 → final/document.md
python3 scripts/fold-studio-contract-into-index.py <프로젝트> # 분해 재료 → tech-log-tree.json
```
모듈 분석은 `final/document.md` 제2부로, `source-index.md` 와 스코프 커버리지와 분석 과정
노트는 제3부로 옮기고, `analysis/NN` 을 가리키던 앵커를 `final/document.md#aNN` 으로 고친
뒤 폴더를 지운다. 분해 쪽도 같다 — 사람이 쓴 트리와 Node Specification, 후보 대장, 편집
이력이 `tech-log-tree.json` 하나로 들어간다. **옮기는 것이지 요약하는 것이 아니다** — 요약만 하고 근거를 원래 자리에
둔 채로 `COMPLETE` 를 찍으면 기록의 `source``analysis/` 를 가리켜 SSOT 가 둘이 된다.
`verify-project-layout.py` 의 「작업 재료가 남아 있다」와 `verify-tech-log-tree.py`
「근거가 SSOT 밖에만 있다」가 그 상태를 센다.
**배치의 정본은 `docs/_templates/` 다.** 폴더마다 `README.txt` 가 무엇을 담는지 한 줄로 적혀
있고, `python3 scripts/verify-project-layout.py` 가 프로젝트들이 그 모양인지 대조한다.
**`tech-log-tree.json` 이 분해 계약이자 색인이고 정본이다.** 글감마다 readiness·source
anchor·classification·missing-verification·relations 를 사람이 적고, 스크립트는 기록 파일에서
읽을 수 있는 것(`file`·`publication`·`status`)만 다시 채운다. 디렉터리를 훑어 주제를 만들지
않는다 — 폴더가 정본이면 계약에서 뺀 주제가 파일이 남아 있다는 이유만으로 되살아난다.
계약에 없는 기록은 `unlisted` 에 적힌다.
정본은 층으로 나뉜다.
| 층 | 하는 일 |
|---|---|
| 코드·설정·실행 증거 | 사실의 근거 |
| `final/document.md` | **글감 범위의 SSOT** — 후보를 발견하는 유일한 입력 |
| `analysis/**/*.md` | final 이 이미 채택한 주장을 상세히 확인하는 보조 근거. 분석 중에만 있다 |
| `tech-log-tree.json` | 사람이 고른 글감. 분해 계약이자 색인이고 이 파일이 정본이다 |
`analysis/**` 를 글감을 찾으려고 열지 않는다. 분석에만 있는 자료를 발견하면 트리에 바로 넣지 말고
`final/document.md` 를 먼저 보강한다. 그러지 않으면 모듈 문서마다 정본 노릇을 하고 트리는 그 절
수의 합만큼 자란다.
**접어 넣은 final 이 전부 후보 자리는 아니다.** 제1부(통합 분석)가 후보를 찾는 범위이고 제2부와
제3부는 근거다. 범위는 `tech-log-tree.json``candidateScope` 에 적는다 — 적지 않으면 모듈 분석
65편의 절 제목이 다시 글감이 된다.
```json
"candidateScope": {
"document": "final/document.md",
"sections": ["§3", "§4", "§5", "§6", "§7", "§8", "§9", "§10", "§11"],
"excluded": ["제2부 — 모듈 분석 전문", "제3부 — 분석 재료"]
}
```
**분석 후보 전부는 같은 파일의 `candidates` 에 처분과 함께 남고 `PROMOTE` 만 글감이 된다.**
`KEEP_IN_SSOT` 은 버린 것이 아니라 분석에 남기고 독립 기록으로 만들지 않기로 한 것이고, 그것도
정상적인 결과다. 제외가 0 건인 분해는 선별하지 않은 분해다.
계약을 아직 채택하지 않은 프로젝트도 error 다. 칸마다 error 를 내지는 않고 미채택 자체를 한 번
센다 — 경고로 두면 옛 스키마로 남아 있는 한 검사를 피한다.
처분과 글감은 양쪽으로 맞아야 한다 — `PROMOTE` 인데 글감이 없는 것도, 글감인데 그것을 낳은
`PROMOTE` 후보가 없는 것도 error 다. 사람이 다시 읽지 않은 후보(`dispositionReview: PENDING`)도
error 다. 경고로 두면 재판정하지 않은 트리로 글을 쓰기 시작하게 된다.
**readiness 와 게시 여부는 다른 것이다.** readiness 는 증거가 갖춰진 정도이고, 글을 썼는지·Studio 에
올렸는지는 색인의 `file``publication` 이 따로 말한다.
`final/` 이 정본이고 `tech-log-studio/` 는 거기서 뽑아낸 글이다. 증거는 `final/evidence/` 에만
두고 기록에서는 그 파일을 가리킨다. 같은 파일을 양쪽에 두지 않는다.
@@ -104,7 +173,8 @@ source anchor·classification·missing-verification 을 사람이 적는 자리
`README.txt` 에 적는다 — 파일 이름만으로는 6개월 뒤에 못 읽는다.
**SVG 는 정본이 아니다.** 실행한 명령의 원문과 메타데이터가 정본이고 터미널 SVG 는 문서에 넣기
위한 표현물이다. 원문 없이 SVG 만 남기지 않는다.
위한 표현물이다. 원문 없이 SVG 만 남기지 않는다. 그림도 같다 — `.techviz/<이름>/` 없이 남은
SVG 는 다시 만들 수 없고, `verify-project-layout.py` 가 그런 그림을 센다.
```bash
python3 scripts/terminal-evidence/render_terminal.py \
@@ -138,18 +208,35 @@ Redis 20편은 한 글을 나눠 쓴 것이라 `docs/clean-architecture-backend-
다른 프로젝트에서 쓴 문서를 이 저장소로 옮길 때 따르는 순서다.
1. 원본을 `docs/<프로젝트>/source/` 에 그대로 복사한다. 손대지 않는다 — 대조할 것이 필요하다
1. 원본을 `docs/<프로젝트>/source/` 에 그대로 복사한다. 손대지 않는다 — 대조할 것이 필요하다.
**`source/` 도 작업 재료다** — 대조가 끝나 `final/` 이 그 내용을 담으면 지운다
2. 원본과 증거를 `final/` 로 옮긴다. 글은 `final/document.md`, 그림은 `assets/`,
터미널 기록·스크린샷·실행계획은 `evidence/`. **여기까지가 SSOT 다**
3. SSOT 를 읽고 글감을 뽑아 `tech-log-tree.json` 에 적는다. 종류(case·concept·reference·
question·decision)와 주제를 먼저 정하고 제목만 적는다. 아직 글은 쓰지 않는다
4. 트리의 글감 하나를 골라 `<주제>/<종류>/` 아래에 기록을 쓴다. 증거는 `final/evidence/`
3. SSOT 를 읽고 후보마다 처분을 적는다. §3~§8 에서 Case, §9 에서 Reference, §10 에서 Decision,
§11 에서 Question 을 고르고, 그 넷을 이해하는 데 필요한 Concept 만 거꾸로 더한다.
사람이 다시 읽은 후보만 `dispositionReview: CONFIRMED` 로 둔다
4. `PROMOTE` 를 주제로 묶어 `tech-log-tree.json` 에 적는다. 주제마다 독자 질문을 한 줄 적고,
글감마다 종류가 요구하는 칸을 채운다. 아직 글은 쓰지 않는다
5. 트리의 글감 하나를 골라 `<주제>/<종류>/` 아래에 기록을 쓴다. 증거는 `final/evidence/`
파일을 가리킨다
5. 기록을 쓰거나 지웠으면 트리를 다시 만든다 — `python3 scripts/build-tech-log-tree.py`
6. Studio 에 넣고 저장한다
6. 색인을 다시 만들고 검사한다 — `build-tech-log-tree.py` · `verify-tech-log-tree.py`
7. Studio 에 넣고 저장한다
`tech-log-tree.json` 은 손으로 고쳐도 되고 스크립트로 다시 만들어도 된다. 스크립트는 기록
파일에서 제목·slug·상태를 읽어 채우고, 파일이 아직 없는 글감은 지우지 않고 남긴다.
글감의 칸은 손으로 적고, 스크립트는 기록 파일에서 읽는 칸만 다시 채운다. 계약에 없는 기록
디스크에 있으면 `unlisted` 에 적히고 검사기가 error 로 센다.
**분석한 저장소는 `tech-log-tree.json``sourceRepository` 가 가리킨다.** 경로·리비전·그렇게
판단한 근거를 함께 적는다. 리비전을 모르면 `null` 로 두고 지어내지 않는다 — 검사기가 warn 으로 센다. 작업이 한 줄기가 아니라
브랜치로 갈라져 있으면 `revisions` 에 이름과 커밋을 짝지어 적는다.
| 프로젝트 | 저장소 |
|---|---|
| `clean-architecture-backend-template` | `desktop-server-git/clean-architecture-backend-template` @ `21234e38` |
| `n+1liner` | `github-project/ca-tmpl` @ `761384d` — 저장소 HEAD 는 다른 브랜치다 |
| `ca-tmpl` | `github-project/ca-tmpl` — 같은 저장소의 아키텍처 경계 쪽 |
| `keycloak` | `keycloak-pattern` — 패턴 넷이 `develop-keycloak-pattern1`~`4` 브랜치에 나뉘어 있어 `revisions` 로 tip 넷을 적는다 |
| `keycloak-session-store` | `keycloak-pattern` @ `cdac9b8` — 같은 저장소의 실험 26건 |
| `TechLog` | `desktop-server-git/tech-log-frontend` · `tech-log-backend` |
| 런 또는 파일 | 문서 |
|---|---|
@@ -158,11 +245,25 @@ Redis 20편은 한 글을 나눠 쓴 것이라 `docs/clean-architecture-backend-
| `keycloak-session-store` | 세션은 어디에 있는가 — Keycloak 다중 노드 실험 26건의 기록. `keycloak` 이 남긴 열린 질문 네 개에 측정으로 답한다 |
| `n+1liner` | 하이라이트 피드 조회 성능 — N+1 진단과 조회 전략의 진화 |
| `TechLog` | 계약이 먼저인 시스템에서 값이 사라지는 자리들 — TechLog를 만들며 만난 결함의 전수 기록 |
| `clean-architecture-backend-template` | 62개 leaf를 23편으로 읽은 통합 분석 · 기록 949건 |
| `clean-architecture-backend-template` | 62개 leaf를 23편으로 읽은 통합 분석 · 모듈 분석 65편을 제2부에 합쳐 46,437줄 · 기록 949건 (선별 재판정 대기) |
## 검사
게시 전에 둘 다 돌린다. 하나는 파서를, 하나는 문장을 본다.
**분해 계약이 스스로 맞는지.** `tech-log-tree.json` 의 주제·글감·후보와 디스크의 기록이 같은
것을 말하는지 본다. `verify-pipeline.py` 는 스킬과 틀이 제자리에 있는지를 보고,
프로젝트 트리 정합성은 이 검사기가 본다(`--skip-projects` 로 끌 수 있다).
```bash
python3 scripts/verify-tech-log-tree.py <프로젝트> # 글감 계약. error 0 까지 고친다
python3 scripts/verify-project-layout.py <프로젝트> # 폴더 배치. 그림의 정본이 있는지도 본다
python3 scripts/fold-analysis-into-final.py <프로젝트> # 분석 재료 → final/document.md
python3 scripts/fold-studio-contract-into-index.py <프로젝트> # 분해 재료 → tech-log-tree.json
python3 scripts/build-tech-log-tree.py <프로젝트> # 파생 칸(file·publication·status)만 다시 채운다
python3 scripts/verify-pipeline.py # 위 셋을 모든 프로젝트에 돌린다
python3 -m unittest discover -s scripts/tests # 파서·검사기·생성기
```
게시 전에는 아래 둘을 돌린다. 하나는 파서를, 하나는 문장을 본다.
**본문이 Studio 파서를 통과하는지.** Studio가 쓰는 파서를 그대로 부르므로 통과하면 저장도 통과한다.
@@ -171,6 +272,14 @@ node --experimental-transform-types \
.agents/skills/writing-tech-log-records/scripts/check_body.mjs 초안.md
```
**인용한 것이 실재하는지.** 본문 코드블록의 각 줄이 SSOT 안에 있는지, `source` 앵커가 SSOT 를
가리키는지, 제목이 계약과 같은지, `sourceRepository` 의 리비전이 그 저장소에 있는지를 본다.
옮겨 적은 것은 확인한 것이 아니다 — 게시된 기록에 SSOT 와 다른 redirect URI 가 네 곳 있었다.
```bash
node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs <프로젝트> --repo
```
`tech-log-frontend` 체크아웃 경로가 다르면 `--frontend` 또는 `TECH_LOG_FRONTEND`로 알려 준다.
**문장이 규범을 지키는지.** `error` 가 남아 있으면 덜 된 글이다. 칸 하나나 한 절만 고쳤으면
+1
View File
@@ -10,6 +10,7 @@
|---|---|
| `writing-tech-log-records` | Studio에 올릴 기록 한 건을 쓰거나 고칠 때. 종류 선택, 칸 채우기, 본문 작성, 게시 전 대조 |
| `rewriting-technical-prose-naturally` | 이미 쓴 문장이 AI가 쓴 것처럼 읽힐 때 |
| `writing-as-the-person-who-did-it` | 정확한데 아무도 쓰지 않은 보고서처럼 읽힐 때. 자료에 남은 사람의 흔적을 제자리에 놓습니다 |
| `technical-visualizer` | 다이어그램이 필요할 때. 손으로 SVG를 그리지 않습니다 |
`.claude/skills/`는 위 폴더를 가리키는 상대 경로 심링크입니다.
+18 -5
View File
@@ -1,9 +1,22 @@
# <project>
- codebase: `/shared/codebase/<project>`
- analysis owner: `/shared/document-detail/<project>`
- Tech Log output: `/shared/Tech-Log-Document/<project>`
- 분석 대상 코드베이스: `<경로 또는 URL>`
- 이 폴더: SSOT(`final/document.md`)와 Studio 에 올릴 글(`tech-log-studio/`)의 정본
## Current analysis scope
## 끝난 프로젝트의 폴더
`state.json`을 정본으로 사용한다. 대형 코드베이스에서는 한 실행에 한 module/subsystem을 우선한다.
| 폴더 | 무엇 |
|---|---|
| `final/` | SSOT — 이 프로젝트에 대해 아는 것 전부 |
| `tech-log-studio/` | SSOT 에서 뽑아 쓴 글. 정본은 `tech-log-tree.json` |
## 분석·반입하는 동안에만 있는 것
`state.json` · `source-index.md` · `analysis/` · `notes/` · `checkpoints/` · `source/`
**작업 재료다.** 코드베이스를 직접 읽으면 앞의 다섯을 쌓고, 밖에서 문서를 가져오면
`source/` 를 쌓는다. 끝나면 그 내용을 `final/document.md` 로 합친 뒤 지운다. 남아 있으면
합치는 일이 끝나지 않은 것이다.
지금 어디까지 봤는지는 `state.json` 이 정본이다. 큰 저장소에서는 한 번에 한
모듈·서브시스템만 본다.
+2
View File
@@ -0,0 +1,2 @@
그림의 정본 — <이름>/{context.json, spec.json, prompt.md}.
technical-visualizer 스킬이 만든다. 손으로 SVG 를 그리지 않는다.
+2
View File
@@ -0,0 +1,2 @@
그림. 그림 하나가 폴더 하나다 — <이름>/<이름>.svg 와 편집 형식들.
Studio 에 올릴 표현물은 tech-log-studio/ 아래에 flat SVG 로 둔다.
@@ -0,0 +1,2 @@
Studio 에 올릴 표현물. 기록 frontmatter 의 `assets: file:` 이 가리키는 자리다.
그림의 정본은 ../<이름>/ 과 ../../.techviz/<이름>/ 에 있다.
+1
View File
@@ -0,0 +1 @@
Playwright MCP 로 찍은 브라우저 캡처. 무엇을 찍었는지 한 줄을 적는다.
+1
View File
@@ -0,0 +1 @@
그 실행의 command·cwd·executedAt·exitCode·revision. 형식은 evidence.json.
@@ -7,7 +7,7 @@
"cwd": "<working directory or null>",
"exitCode": null,
"rawPath": "evidence/raw/<file>",
"presentationPath": "evidence/terminal|browser|svg/<file>",
"presentationPath": "evidence/rendered|browser/<file>",
"proves": "<bounded claim>",
"doesNotProve": "<important limitation>"
}
+2
View File
@@ -0,0 +1,2 @@
명령 출력·csv·덤프 원문. 여기가 정본이다.
하위 폴더를 자유롭게 둔다(explain/, guards/). 폴더마다 무엇을 담았는지 한 줄을 README.txt 에 적는다.
+2
View File
@@ -0,0 +1,2 @@
raw 에서 만든 터미널 SVG. 표현물이지 정본이 아니다.
scripts/terminal-evidence/render_terminal.py 로 만든다.
-85
View File
@@ -1,85 +0,0 @@
---
schemaVersion: 1
project: <project>
sourceDocument: final/document.md
sourceDocumentSha256: <sha256>
sourceRevision: <git-revision-or-snapshot>
generatedAt: <ISO-8601>
---
# Root Tree
PROJECT
<project>
TOPIC
<Topic title>
<topic-slug>
├── CASE
│ └── <Case title>
├── REFERENCE
│ └── <Reference title>
├── OPEN QUESTION
│ └── <Open Question title>
└── DECISION
└── <Decision title>
# Node Specifications
## CASE — <Case title>
- slug: `<slug>`
- readiness: `READY | NEEDS_EVIDENCE | BLOCKED | REJECTED`
- source:
- `final/document.md#<anchor>`
- code:
- `/shared/codebase/<project>/<path>:<line-or-symbol>`
- evidence:
- `evidence/raw/<file>`
- classification: `<specific incident/experiment/diagnosis that makes this a Case>`
- missing-verification: `<none or concrete missing check>`
- relations:
- `<kind>:<slug> — <reason>`
## REFERENCE — <Reference title>
- slug: `<slug>`
- readiness: `READY | NEEDS_EVIDENCE | BLOCKED | REJECTED`
- source:
- `final/document.md#<anchor>`
- classification: `<reusable criterion rather than a retelling of one Case>`
- scope: `<where the rule applies>`
- exceptions: `<known exceptions or none>`
- relations:
- `<kind>:<slug> — <reason>`
## OPEN QUESTION — <Open Question title>
- slug: `<slug>`
- readiness: `OPEN | BLOCKED | REJECTED`
- source:
- `final/document.md#<anchor>`
- known:
- `<grounded fact>`
- unknown:
- `<unresolved fact>`
- next-verification: `<what would reduce the uncertainty>`
- decision-criterion: `<what would allow this question to close>`
- relations:
- `<kind>:<slug> — <reason>`
## DECISION — <Decision title>
- slug: `<slug>`
- readiness: `READY | NEEDS_DECISION | BLOCKED | REJECTED`
- decision-status: `PROPOSED | ADOPTED | SUPERSEDED | NOT_DECIDED`
- source:
- `final/document.md#<anchor>`
- decision-evidence:
- `<commit/ADR/config/history/user-provided decision record>`
- grounds:
- `<case/reference relation>`
- classification: `<why this is an actual project decision, not advice>`
- relations:
- `<kind>:<slug> — <reason>`
+94
View File
@@ -0,0 +1,94 @@
{
"schemaVersion": 4,
"project": "<project>",
"ssot": "final/document.md",
"sourceRepository": {
"path": "<분석한 저장소의 체크아웃 경로 또는 URL>",
"revision": "<문서가 서술한 상태의 커밋. 모르면 null 로 두고 지어내지 않는다>",
"revisions": {"<갈래가 여럿이면 이름>": "<커밋>"},
"verified": "<이 값이 맞다고 판단한 근거 — 무엇을 어디서 대조했는지>"
},
"ssotSha256": "<sha256>",
"sourceRevision": "<git-revision-or-snapshot>",
"generatedAt": "<YYYY-MM-DD>",
"candidateScope": {
"document": "final/document.md",
"sections": ["§3", "§4", "§5", "§6", "§7", "§8", "§9", "§10", "§11"],
"excluded": ["제2부 — 모듈 분석 전문", "제3부 — 분석 재료"],
"note": "후보를 찾는 범위다. 제2부·제3부는 근거이지 후보 자리가 아니다. excludedAnchorPattern 을 적으면 그 정규식에 걸리는 앵커에서만 나온 글감을 검사기가 error 로 센다"
},
"note": "이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. 노드의 칸(readiness·source·classification·relations…)은 사람이 적고, file·publication·status 는 기록 파일에서 읽어 채운다 — python3 scripts/build-tech-log-tree.py <프로젝트>",
"contract": {
"decomposition": [
"글감을 찾는 입력은 final/document.md 하나다. 거기에 없는 근거는 먼저 SSOT 에 넣는다.",
"후보 전부는 candidates 에 처분과 함께 남고 PROMOTE 만 topics 로 올라간다.",
"없애고 관련 Case 나 Concept 의 한 절로 넣어도 이해·결정·재사용성이 그대로라면 독립 기록으로 만들지 않는다.",
"Topic 은 독자 질문 하나다. 그 물음에 답하지 않는 글감은 다른 Topic 으로 옮긴다.",
"Concept 은 Case·Decision·Question 을 먼저 고른 뒤 그것을 이해하는 데 필요한 것만 거꾸로 더한다."
],
"readinessValues": ["READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"],
"dispositionValues": {
"PROMOTE": "독립 Tech Log 로 쓴다",
"MERGE_INTO": "다른 기록의 한 절로 흡수한다",
"KEEP_IN_SSOT": "분석에는 남기고 독립 기록으로 만들지 않는다 — 정상적인 성공 결과다",
"NEEDS_EVIDENCE": "주장에 아직 검증이 없다",
"NEEDS_DECISION": "방향이 그럴듯하지만 프로젝트가 정하지 않았다",
"BLOCKED": "원본이 불완전하거나 서로 어긋난다"
}
},
"counts": { "topics": 1, "nodes": 2, "written": 0, "unwritten": 2, "unlisted": 0, "candidates": 1 },
"topics": {
"<topic-slug>": {
"topic": "<topic-slug>",
"title": "<Topic 제목>",
"readerQuestion": "<이 Topic 의 기록들이 함께 답하는 물음 하나?>",
"kinds": {
"case": [
{
"title": "<Case 제목>",
"kind": "case",
"slug": "<slug>",
"readiness": "READY",
"source": ["final/document.md#<anchor>"],
"code": ["<path>:<line-or-symbol>"],
"evidence": ["evidence/raw/<file>"],
"classification": "<재현·진단·결론이 닫히는 하나의 사건. 정적 카운트나 문구 수정이 아니다>",
"missing-verification": "<없음 또는 하지 못한 확인>",
"relations": ["concept:<slug>"],
"publication": "미작성"
}
],
"concept": [
{
"title": "<Concept 제목>",
"kind": "concept",
"slug": "<slug>",
"readiness": "READY",
"source": ["final/document.md#<anchor>"],
"basis-version": "<무엇을 보고 썼는지 — 예: Keycloak 26.7.0 identity brokering>",
"classification": "<이 구조를 처음부터 설명해야 어떤 Case 를 이해할 수 있는지>",
"relations": ["case:<slug>"],
"publication": "미작성"
}
],
"reference": [],
"question": [],
"decision": []
}
}
},
"candidates": [
{
"id": "<A05-F012>",
"kindCandidate": "CASE",
"sourceRefs": ["final/document.md#<anchor>"],
"summary": "<한 줄>",
"disposition": "KEEP_IN_SSOT",
"dispositionReview": "CONFIRMED",
"target": null,
"reason": "<왜 독립 기록으로 만들지 않았는지>"
}
],
"unlisted": [],
"history": {}
}
+101 -72
View File
@@ -1,113 +1,142 @@
#!/usr/bin/env python3
"""tech-log-tree.json 을 다시 만든다.
"""`tech-log-tree.json` 의 파생 칸을 다시 채운다.
노드 필드는 document-detail root-tree 계약을 따른다 readiness, source, code,
evidence, classification, missing-verification, relations. 제목만 보고 기록을 만들지
못하게 하려는 것이다.
** 파일이 정본이다.** 주제·글감·readiness·source·classification·relations 사람이
적고, 스크립트는 손대지 않는다. 기록 파일을 읽어 채우는 것은 넷뿐이다.
SSOT(final/document.md)에서 뽑은 글감과 이미 기록 파일에 모은다. 기록 파일이
정본이므로 스크립트는 그것을 읽어 채우고, 아직 글이 없는 글감은 사람이 적은 항목을
그대로 둔다.
file 글감의 기록 디스크에 있으면 상대 경로
publication 게시됨 | 초안 | 미작성
status 기록 frontmatter status
studioId · assets · evidenceFiles
**readiness publication 다른 것이다.** 증거가 갖춰진 정도와 Studio 올렸는지를
섞지 않는다. 계약에 없는 기록이 디스크에 있으면 `unlisted` 적는다 지우지도, 몰래
주제로 만들지도 않는다.
python3 scripts/build-tech-log-tree.py [프로젝트 ...]
"""
from __future__ import annotations
import json, re, sys, glob, os, datetime, hashlib
KINDS = ["case", "concept", "reference", "question", "decision"]
import datetime
import glob
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import techlog # noqa: E402
from techlog import KINDS # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DERIVED = ("file", "publication", "status", "studioId", "assets", "evidenceFiles")
def front_matter(path: str) -> dict:
def front_matter(path: str) -> tuple[dict, str]:
text = open(path, encoding="utf-8").read()
if not text.startswith("---"):
return {}
return {}, text
end = text.find("\n---", 3)
out = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out
return out, text
def build(project: str) -> dict:
def records_on_disk(studio: str) -> dict[tuple[str, str], str]:
found = {}
for path in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md"))):
parts = path.split(os.sep)
topic_dir, kind_dir = parts[-3], parts[-2]
if topic_dir.startswith("_") or kind_dir not in KINDS:
continue
fm, _ = front_matter(path)
slug = fm.get("slug") or os.path.basename(path)[:-3]
found[(kind_dir, slug)] = path
return found
def build(project: str) -> tuple[dict | None, list[str]]:
base = os.path.join(ROOT, "docs", project)
studio = os.path.join(base, "tech-log-studio")
tree_path = os.path.join(studio, "tech-log-tree.json")
previous = {}
if os.path.exists(tree_path):
previous = json.load(open(tree_path, encoding="utf-8"))
index_path = os.path.join(studio, "tech-log-tree.json")
index = techlog.load_index(index_path)
if index is None:
return None, [f"{project}: tech-log-tree.json 이 없다. 글감을 먼저 적는다"]
ssot = "final/document.md" if os.path.exists(os.path.join(base, "final/document.md")) else None
topics = {}
for topic_dir in sorted(d for d in glob.glob(os.path.join(studio, "*"))
if os.path.isdir(d) and not os.path.basename(d).startswith("_")):
topic = os.path.basename(topic_dir)
entry = {"topic": topic, "kinds": {}}
for kind in KINDS:
items = []
for f in sorted(glob.glob(os.path.join(topic_dir, kind, "*.md"))):
fm = front_matter(f)
text = open(f, encoding="utf-8").read()
node = {
"title": fm.get("title", os.path.basename(f)),
"slug": fm.get("slug", ""),
"file": os.path.relpath(f, studio),
"readiness": "READY" if fm.get("id") else "NEEDS_EVIDENCE",
"status": fm.get("status", "미작성"),
"studioId": fm.get("id", ""),
"assets": re.findall(r"^ - key: (\S+)", text, re.M),
"evidence": re.findall(r"^ - (\.\./\S+)", text, re.M),
"relations": re.findall(r"^- \*\*(.+?)\*\*$", text, re.M),
}
# 이미 쓴 글감은 지난 트리의 사람이 적은 칸을 잃지 않는다
for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []):
if old.get("slug") == node["slug"]:
for key in ("classification", "missing-verification", "source", "code"):
if old.get(key):
node[key] = old[key]
items.append(node)
# 아직 글이 없는 글감은 지난 tree 에서 가져와 유지한다
written = {i["slug"] for i in items if i["slug"]}
for old in previous.get("topics", {}).get(topic, {}).get("kinds", {}).get(kind, []):
if not old.get("file") and old.get("slug") not in written:
items.append(old)
entry["kinds"][kind] = items
topics[topic] = entry
disk = records_on_disk(studio)
used: set[tuple[str, str]] = set()
for _, kind, node in techlog.nodes(index):
for key in DERIVED:
node.pop(key, None)
node["publication"] = "미작성"
key = (kind, node.get("slug", ""))
if not node.get("slug") or key not in disk:
continue
path = disk[key]
fm, text = front_matter(path)
studio_id = fm.get("id", "")
node.update({
"file": os.path.relpath(path, studio),
"status": fm.get("status", ""),
"studioId": studio_id,
"publication": "게시됨" if studio_id else "초안",
"assets": re.findall(r"^ - key: (\S+)", text, re.M),
"evidenceFiles": re.findall(r"^ - (\.\./\S+)", text, re.M),
})
used.add(key)
ssot_path = os.path.join(base, ssot) if ssot else None
digest = None
if ssot_path and os.path.exists(ssot_path):
digest = hashlib.sha256(open(ssot_path, "rb").read()).hexdigest()
unlisted = [os.path.relpath(p, studio) for k, p in sorted(disk.items()) if k not in used]
total = sum(1 for _ in techlog.nodes(index))
written = sum(1 for _, _, n in techlog.nodes(index) if n.get("file"))
ssot = index.get("ssot", "final/document.md")
return {
"schemaVersion": 2,
"project": project,
"ssot": ssot,
"ssotSha256": digest,
"generatedAt": datetime.date.today().isoformat(),
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다. "
"ssotSha256 이 지금 final/document.md 와 다르면 SSOT 가 바뀐 뒤 트리를 다시 보지 않은 것이다.",
"readinessValues": ["READY", "NEEDS_EVIDENCE", "BLOCKED", "REJECTED"],
"topics": topics,
index["ssotSha256"] = techlog.sha256_of(os.path.join(base, ssot))
index["generatedAt"] = datetime.date.today().isoformat()
index["counts"] = {
"topics": len(index.get("topics", {})),
"nodes": total,
"written": written,
"unwritten": total - written,
"unlisted": len(unlisted),
"candidates": len(index.get("candidates", [])),
}
index["unlisted"] = unlisted
warnings = []
if unlisted:
warnings.append(f"계약에 없는 기록 {len(unlisted)}건이 디스크에 있다 — "
"글감으로 올리거나 지운다")
return index, warnings
def main(argv: list[str]) -> int:
projects = argv[1:] or [
os.path.basename(os.path.dirname(p))
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
if not os.path.basename(os.path.dirname(p)).startswith("_")
]
failed = 0
for project in sorted(projects):
tree = build(project)
index, messages = build(project)
if index is None:
failed += 1
for line in messages:
print(line)
continue
out = os.path.join(ROOT, "docs", project, "tech-log-studio", "tech-log-tree.json")
with open(out, "w", encoding="utf-8") as fh:
json.dump(tree, fh, ensure_ascii=False, indent=2)
json.dump(index, fh, ensure_ascii=False, indent=2)
fh.write("\n")
n = sum(len(v) for t in tree["topics"].values() for v in t["kinds"].values())
print(f"{os.path.relpath(out, ROOT)} — 주제 {len(tree['topics'])} · 글감 {n}")
return 0
c = index["counts"]
extra = f" · 계약 밖 기록 {c['unlisted']}" if c.get("unlisted") else ""
print(f"{os.path.relpath(out, ROOT)} — 주제 {c['topics']} · 글감 {c['nodes']} "
f"(쓴 것 {c['written']}){extra}")
for line in messages:
print(f" ! {line}")
return 1 if failed else 0
if __name__ == "__main__":
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""분석 작업 재료를 `final/document.md` 로 옮기고 폴더에서 지운다.
`analyzing-codebase-for-tech-log` 11·12 단계다. 분석하는 동안 쌓은
`analysis/` · `source-index.md` · `notes/` · `state.json` 커버리지 작업 재료이고,
분석이 끝나면 내용이 SSOT 안에 있어야 한다. 요약만 하고 근거를 원래 자리에 두면
기록의 `source` `analysis/` 가리켜 SSOT 둘이 된다.
**옮기는 것이지 요약하는 것이 아니다.** 본문을 그대로 싣고 제목 수준만 내려 붙인다.
그다음 `analysis/NN` 가리키던 앵커를 `final/document.md#aNN` 으로 고치고, 작업 재료를
지운다.
python3 scripts/fold-analysis-into-final.py <프로젝트> [--dry-run] [--keep]
`--keep` 옮기기만 하고 지우지 않는다. 되돌리려면 git 으로 돌린다.
"""
from __future__ import annotations
import argparse
import collections
import glob
import json
import os
import re
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import techlog
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 분석 스킬의 틀 파일. 내용이 아니라 빈 서식이라 옮기지 않는다
TEMPLATE_FILES = {"module.md"}
# 가족 문서가 대표하는 하위 폴더 → 그 가족의 번호
FAMILY = {"messaging": "19", "grpc": "20"}
FAMILY_DIR = {v: k for k, v in FAMILY.items()}
# 분석 과정 기록. 글감 선별(_meta)이 아니라 분석 자체의 기록만 문서로 옮긴다
STUDIO_NOTES = ("tech-log-candidate-recall-audit", "tech-log-concept-recall-audit",
"tech-log-reselection-plan")
FENCE = re.compile(r"^\s*(```|~~~)")
def shift_headings(text: str, by: int = 2) -> str:
"""코드펜스 밖의 제목만 수준을 내린다. 펜스 안의 `#` 은 주석이다."""
out = []
fence = None
for line in text.splitlines():
m = FENCE.match(line)
if m:
token = m.group(1)
fence = None if fence == token else (fence or token)
out.append(line)
continue
if fence is None and line.startswith("#"):
level = len(line) - len(line.lstrip("#"))
out.append("#" * min(level + by, 6) + line[level:])
else:
out.append(line)
return "\n".join(out)
def collect(base: str) -> list[dict]:
"""옮길 모듈 문서를 순서대로 모은다."""
analysis = os.path.join(base, "analysis")
items = []
for path in sorted(glob.glob(os.path.join(analysis, "*.md"))):
name = os.path.basename(path)
if name in TEMPLATE_FILES:
continue
m = re.match(r"^(\d+)-?(.*)\.md$", name)
if not m:
continue
number, rest = m.group(1), m.group(2)
items.append({"id": f"a{number}", "title": rest or "project-overview",
"origin": f"analysis/{name}", "path": path})
for family, number in FAMILY.items():
for path in sorted(glob.glob(os.path.join(analysis, family, "*.md"))):
stem = os.path.basename(path)[:-3]
items.append({"id": f"a{number}-{stem}", "title": stem,
"origin": f"analysis/{family}/{stem}.md", "path": path})
return items
def anchor_rules(items: list[dict]) -> list[tuple[re.Pattern, str]]:
"""긴 형태부터 고친다 — `analysis/05-…md` 를 `analysis/05` 보다 먼저."""
# 줄 앵커는 옮기면 뜻을 잃는다. 절 앵커로 낮춘다 — 후보 대장은 `sourceHeading` 을 갖고 있다
rules = [(re.compile(r"(analysis/[\w/.-]+\.md)#L\d+"), r"\1")]
for it in sorted(items, key=lambda x: -len(x["origin"])):
rules.append((re.compile(re.escape(it["origin"])), f"final/document.md#{it['id']}"))
for it in items:
m = re.match(r"^a(\d+)$", it["id"])
if m:
rules.append((re.compile(rf"analysis/{m.group(1)}(?![\w./-])"),
f"final/document.md#{it['id']}"))
rules.append((re.compile(r"`?analysis/module\.md`?"), "분석 틀"))
return rules
def anchors_from_document(document: str) -> list[dict]:
"""이미 옮긴 문서에서 절 목록을 되읽는다. 앵커만 마저 고칠 때 쓴다."""
items = []
for line in open(document, encoding="utf-8"):
m = re.match(r"^## (A\d+(?:-[\w-]+)?)\. (.+)$", line)
if m:
ident, title = m.group(1).lower(), m.group(2).strip()
number = ident[1:3]
origin = (f"analysis/{FAMILY_DIR[number]}/{ident[4:]}.md"
if "-" in ident else f"analysis/{number}-{title}.md")
items.append({"id": ident, "title": title, "origin": origin})
return items
def coverage_table(base: str) -> str:
path = os.path.join(base, "state.json")
if not os.path.exists(path):
return ""
state = json.load(open(path, encoding="utf-8"))
scopes = state.get("scopes", [])
if not scopes:
return ""
rows = ["| 스코프 | 경로 | 상태 | 전량 통독 | 구조만 | 제외 | 옮겨 간 자리 |",
"|---|---|---|---:|---:|---:|---|"]
for s in scopes:
cov = s.get("coverage") or {}
origin = s.get("analysisFile", "")
m = re.search(r"analysis/(\d+)", origin)
where = f"§A{m.group(1)}" if m else ""
rows.append(f"| `{s.get('id','')}` | `{s.get('path','')}` | {s.get('status','')} | "
f"{cov.get('fullRead', 0)} | {cov.get('structuralOnly', 0)} | "
f"{cov.get('excluded', 0)} | {where} |")
revision = state.get("gitRevision") or ""
head = (f"분석한 리비전은 `{revision}` 이다.\n\n" if revision else "")
return head + "\n".join(rows) + "\n"
def build_part(base: str, items: list[dict]) -> str:
out = ["", "---", "", "# 제2부 — 모듈 분석 전문", "",
"제1부는 이 부의 종합이다. 여기 실린 것이 근거이고, 분석하는 동안에는 "
"`analysis/` 아래에 파일로 나뉘어 있었다. 파일이 아니라 이 문서가 정본이므로 "
"그대로 옮겨 왔다 — 제목 수준만 내렸고 본문은 손대지 않았다.", ""]
for it in items:
text = open(it["path"], encoding="utf-8").read().rstrip()
lines = len(text.splitlines())
# 유래 줄에는 `analysis/` 접두어를 쓰지 않는다 — 앵커 치환에 같이 걸린다
origin = it["origin"].split("analysis/", 1)[-1]
out += ["---", "", f"## {it['id'].upper()}. {it['title']}", "",
f"> 분석 중에는 `{origin}` 파일이었다. {lines:,}줄.", "",
shift_headings(text), ""]
out += ["---", "", "# 제3부 — 분석 재료", "",
"분석하는 동안 따로 두었던 목록과 기록이다. 폴더가 아니라 이 문서에 남는다.", ""]
index = os.path.join(base, "source-index.md")
if os.path.exists(index):
out += ["---", "", "## D. 분석한 코드의 목록", "",
"> 분석 중에는 `source-index.md` 였다.", "",
shift_headings(open(index, encoding="utf-8").read().rstrip()), ""]
table = coverage_table(base)
if table:
out += ["---", "", "## E. 스코프별 커버리지", "",
"> 분석 중에는 `state.json` 의 `scopes` 였다. 리프 단위 정본이던 자리다.", "",
table, ""]
notes = [p for p in sorted(glob.glob(os.path.join(base, "notes", "*.md")))
if os.path.basename(p)[:-3] not in STUDIO_NOTES]
if notes:
out += ["---", "", "## F. 분석 과정 기록", "",
"> 분석 중에는 `notes/` 였다. 무엇을 어디까지 어떻게 확인했는지의 기록이다.", ""]
for path in notes:
out += [shift_headings(open(path, encoding="utf-8").read().rstrip(), by=3), ""]
return "\n".join(out)
def rewrite_anchors(base: str, rules: list[tuple[re.Pattern, str]], dry: bool) -> dict:
counts = collections.Counter()
targets = [os.path.join(base, "final", "document.md")]
# 증거 메타데이터도 분석 파일을 근거로 적고 있다
targets += sorted(glob.glob(os.path.join(base, "final", "evidence", "meta", "*.json")))
studio = os.path.join(base, "tech-log-studio")
targets += sorted(glob.glob(os.path.join(studio, "*.md")))
targets += sorted(glob.glob(os.path.join(studio, "*.json")))
targets += [p for p in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md")))
if not os.path.relpath(p, studio).startswith("_")]
# _meta/checkpoints 는 지난 상태를 얼려 둔 것이라 고치지 않는다
targets += [p for p in sorted(glob.glob(os.path.join(studio, "_meta", "**", "*.*"),
recursive=True))
if os.path.splitext(p)[1] in {".md", ".json"}
and "checkpoints" not in os.path.relpath(p, studio).split(os.sep)]
for path in targets:
text = open(path, encoding="utf-8").read()
new = text
for pattern, replacement in rules:
new, n = pattern.subn(replacement, new)
if n:
counts[os.path.relpath(path, base)] += n
if new != text and not dry:
open(path, "w", encoding="utf-8").write(new)
return counts
def main() -> int:
ap = argparse.ArgumentParser(description="분석 작업 재료를 final/document.md 로 옮긴다.")
ap.add_argument("project")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--keep", action="store_true", help="옮기기만 하고 지우지 않는다")
ap.add_argument("--anchors-only", action="store_true",
help="본문은 이미 옮겼고 앵커만 마저 고친다")
args = ap.parse_args()
base = os.path.join(ROOT, "docs", args.project)
document = os.path.join(base, "final", "document.md")
if args.anchors_only:
items = anchors_from_document(document)
counts = rewrite_anchors(base, anchor_rules(items), dry=args.dry_run)
print(f"{args.project}: 앵커 {sum(counts.values()):,}건 · 파일 {len(counts):,}")
for name, n in counts.most_common(8):
print(f" {n:>5} {name}")
return 0
if not os.path.isdir(os.path.join(base, "analysis")):
print(f"{args.project}: analysis/ 가 없다 — 이미 옮겼거나 분석한 적이 없다")
return 0
items = collect(base)
part = build_part(base, items)
before = len(open(document, encoding="utf-8").read().splitlines())
print(f"{args.project}: 모듈 문서 {len(items)}")
print(f" final/document.md {before:,}줄 → {before + len(part.splitlines()):,}")
rules = anchor_rules(items)
counts = rewrite_anchors(base, rules, dry=True)
print(f" 앵커 {sum(counts.values()):,}건 · 파일 {len(counts):,}")
if args.dry_run:
print(" (--dry-run: 쓰지 않았다)")
return 0
with open(document, "a", encoding="utf-8") as fh:
fh.write(part if part.endswith("\n") else part + "\n")
rewrite_anchors(base, rules, dry=False)
if not args.keep:
studio_meta = os.path.join(base, "tech-log-studio", "_meta")
os.makedirs(studio_meta, exist_ok=True)
for name in STUDIO_NOTES:
src = os.path.join(base, "notes", f"{name}.md")
if os.path.exists(src):
shutil.move(src, os.path.join(studio_meta, f"{name}.md"))
checkpoints = os.path.join(base, "checkpoints")
if os.path.isdir(checkpoints):
shutil.move(checkpoints, os.path.join(studio_meta, "checkpoints"))
for name in ("analysis", "notes"):
shutil.rmtree(os.path.join(base, name), ignore_errors=True)
for name in ("state.json", "source-index.md"):
path = os.path.join(base, name)
if os.path.exists(path):
os.remove(path)
print(" 작업 재료를 지웠다 — 글감 선별 기록은 tech-log-studio/_meta/ 로 옮겼다")
digest = techlog.sha256_of(document)
tree = os.path.join(base, "tech-log-studio", "root-tree.md")
if os.path.exists(tree):
text = open(tree, encoding="utf-8").read()
text, n = re.subn(r"^sourceDocumentSha256:.*$",
f"sourceDocumentSha256: {digest}", text, count=1, flags=re.M)
if n:
open(tree, "w", encoding="utf-8").write(text)
print(" root-tree.md 의 sourceDocumentSha256 을 갱신했다")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""분해 작업 재료를 `tech-log-tree.json` 으로 옮기고 폴더에서 지운다.
`analysis/` `final/document.md` 옮긴 것과 같은 일을 `tech-log-studio/` 에서 한다.
끝난 프로젝트의 `tech-log-studio/` 에는 `tech-log-tree.json` 기록 폴더만 있다.
옮기는
root-tree.md 사람이 읽는 트리 + Node Specifications topics
candidate-ledger.json 후보와 처분 candidates
root-tree-source-manifest.json 원본 해시 ssotSha256
_meta/state.json 생성·편집·검증 이력 history
_meta/** 편집 과정 기록 지운다 (git 남는다)
python3 scripts/fold-studio-contract-into-index.py <프로젝트> [--dry-run] [--keep]
**옮기는 것이지 요약하는 것이 아니다.** 노드의 칸은 하나도 버리지 않는다.
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import re
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import techlog # noqa: E402
from techlog import KINDS # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
KIND_LABELS = {"CASE": "case", "CONCEPT": "concept", "REFERENCE": "reference",
"OPEN QUESTION": "question", "QUESTION": "question", "DECISION": "decision"}
FIELD_NAMES = ("slug", "readiness", "disposition", "source", "code", "evidence",
"classification", "missing-verification", "relations", "scope", "exceptions",
"known", "unknown", "next-verification", "decision-criterion",
"decision-status", "decision-evidence", "grounds", "basis-version")
MULTI = {"source", "code", "evidence", "relations", "grounds", "decision-evidence",
"known", "unknown", "scope", "exceptions"}
_TOPIC_HEAD = re.compile(r"^##\s+TOPIC(?:\s+\d+)?\s+[—-]\s+(.+?)\s*$")
_SPEC_HEAD = re.compile(r"^###\s+(CASE|CONCEPT|REFERENCE|OPEN QUESTION|QUESTION|DECISION)\s+[—-]\s+(.+?)\s*$")
_BRANCH = re.compile(r"^[├└]──\s+(CASE|CONCEPT|REFERENCE|OPEN QUESTION|QUESTION|DECISION)\s*$")
_ITEM = re.compile(r"^(?:│|\s)\s{2,}[├└]──\s+(.+?)\s*$")
_FIELD = re.compile(r"^-\s+([a-z][a-z-]*):\s*(.*)$")
_CONT = re.compile(r"^\s{2,}-\s+(.+?)\s*$")
_INLINE = re.compile(r"\s+·\s+(" + "|".join(FIELD_NAMES) + r"):\s*")
_READER_Q = re.compile(r"^독자\s*질문\s*[—:-]\s*(.+?)\s*$")
EMPTY = ("(추가 없음)", "(없음)", "(none)", "-")
def _set(fields: dict, key: str, value: str) -> None:
value = value.strip()
if not value:
fields.setdefault(key, [])
return
items = [v.strip() for v in value.split(" · ")] if key in MULTI else [value]
fields[key] = [v for v in items if v]
def parse_root_tree(path: str) -> dict:
"""사람이 읽는 트리와 Node Specifications 를 하나로 읽는다."""
text = open(path, encoding="utf-8").read()
header, body = {}, text
if text.startswith("---"):
end = text.find("\n---", 3)
for line in text[3:end].splitlines():
m = re.match(r"^([A-Za-z][A-Za-z0-9_]*):\s*(.*)$", line)
if m:
header[m.group(1)] = m.group(2).strip().strip('"')
body = text[end + 4:]
topics, specs, prose = [], [], []
in_specs = False
topic = branch = spec = None
spec_topic = ""
pending = None
expect = 0
for raw in body.splitlines():
line = raw.rstrip()
stripped = line.strip()
if line.startswith("# Node Specifications"):
in_specs = True
topic = branch = None
continue
if not in_specs:
if stripped.startswith(">"):
prose.append(stripped.lstrip("> ").rstrip())
continue
if stripped == "---":
continue
if stripped == "PROJECT":
expect = -1
continue
if expect == -1:
if stripped:
expect = 0
continue
if stripped == "TOPIC":
topic = {"topic": "", "title": "", "readerQuestion": "", "nodes": []}
topics.append(topic)
branch, expect = None, 1
continue
if topic is not None and expect in (1, 2, 3):
if expect == 1 and stripped:
topic["title"] = stripped
expect = 2
continue
if expect == 2 and stripped:
topic["topic"] = stripped
expect = 3
continue
if expect == 3:
m = _READER_Q.match(stripped)
if m:
topic["readerQuestion"] = m.group(1)
expect = 0
continue
if stripped:
expect = 0
m = _BRANCH.match(stripped)
if m:
branch = KIND_LABELS[m.group(1)]
continue
m = _ITEM.match(line)
if m and topic is not None and branch:
title = m.group(1).strip()
if title not in EMPTY:
topic["nodes"].append({"kind": branch, "title": title})
continue
m = _TOPIC_HEAD.match(line)
if m:
spec_topic, spec, pending = m.group(1).strip(), None, None
continue
m = _SPEC_HEAD.match(line)
if m:
spec = {"topic": spec_topic, "kind": KIND_LABELS[m.group(1)],
"title": m.group(2).strip(), "fields": {}}
specs.append(spec)
pending = None
continue
if spec is None:
continue
m = _FIELD.match(line)
if m:
key, value = m.group(1), m.group(2).strip()
parts = _INLINE.split(value)
_set(spec["fields"], key, parts[0])
rest = parts[1:]
while rest:
_set(spec["fields"], rest[0], rest[1])
rest = rest[2:]
pending = key if not rest else None
continue
m = _CONT.match(line)
if m and pending:
spec["fields"].setdefault(pending, []).append(m.group(1).strip())
continue
if not stripped:
pending = None
return {"header": header, "topics": topics, "specs": specs,
"prose": [p for p in prose if p]}
def node_from_spec(spec: dict) -> dict:
node = {"title": spec["title"], "kind": spec["kind"]}
for key in ("slug", "readiness"):
values = spec["fields"].get(key) or []
node[key] = values[0].strip("`").strip() if values else ""
node["readiness"] = node["readiness"].upper()
for key, values in spec["fields"].items():
if key in ("slug", "readiness") or not values:
continue
node[key] = values if key in MULTI or len(values) > 1 else values[0]
return node
def main() -> int:
ap = argparse.ArgumentParser(description="분해 작업 재료를 tech-log-tree.json 으로 옮긴다.")
ap.add_argument("project")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--keep", action="store_true", help="옮기기만 하고 지우지 않는다")
args = ap.parse_args()
base = os.path.join(ROOT, "docs", args.project)
studio = os.path.join(base, "tech-log-studio")
tree_path = os.path.join(studio, "root-tree.md")
index_path = os.path.join(studio, "tech-log-tree.json")
if not os.path.exists(tree_path):
print(f"{args.project}: root-tree.md 가 없다 — 이미 옮겼거나 분해 계약을 쓴 적이 없다")
return 0
parsed = parse_root_tree(tree_path)
header = parsed["header"]
by_key = {(s["topic"], s["kind"], s["title"]): s for s in parsed["specs"]}
topics = {}
used = set()
for t in parsed["topics"]:
entry = {"topic": t["topic"], "title": t["title"],
"readerQuestion": t["readerQuestion"], "kinds": {k: [] for k in KINDS}}
for n in t["nodes"]:
key = (t["topic"], n["kind"], n["title"])
spec = by_key.get(key)
entry["kinds"][n["kind"]].append(
node_from_spec(spec) if spec else {"title": n["title"], "kind": n["kind"],
"slug": "", "readiness": ""})
used.add(key)
topics[t["topic"]] = entry
# 사람이 읽는 트리에 줄이 없던 Node Specification 도 잃지 않는다
orphans = 0
for key, spec in by_key.items():
if key in used:
continue
orphans += 1
entry = topics.setdefault(spec["topic"], {
"topic": spec["topic"], "title": "", "readerQuestion": "",
"kinds": {k: [] for k in KINDS}})
node = node_from_spec(spec)
node["listedInTree"] = False
entry["kinds"][spec["kind"]].append(node)
ledger_path = os.path.join(studio, "candidate-ledger.json")
ledger = json.load(open(ledger_path, encoding="utf-8")) if os.path.exists(ledger_path) else {}
meta_state_path = os.path.join(studio, "_meta", "state.json")
meta_state = json.load(open(meta_state_path, encoding="utf-8")) \
if os.path.exists(meta_state_path) else {}
index = json.load(open(index_path, encoding="utf-8")) if os.path.exists(index_path) else {}
ssot = header.get("sourceDocument", "final/document.md")
out = {
"schemaVersion": 4,
"project": args.project,
"ssot": ssot,
"ssotSha256": techlog.sha256_of(os.path.join(base, ssot)),
"sourceRevision": header.get("sourceRevision", ""),
"generatedAt": datetime.date.today().isoformat(),
"note": ("이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. "
"노드의 칸(readiness·source·classification·relations…)은 사람이 적고, "
"file·publication·status 는 기록 파일에서 읽어 채운다 — "
"python3 scripts/build-tech-log-tree.py <프로젝트>"),
"contract": {
"decomposition": parsed["prose"],
"readinessValues": techlog.READINESS,
"dispositionValues": ledger.get("dispositionValues", {}),
},
"counts": {},
"topics": topics,
"candidates": ledger.get("candidates", []),
"history": {k: v for k, v in meta_state.items()
if k not in ("schemaVersion", "project", "rootTreePath")},
}
for key in ("counts", "unmapped", "explicitAnalysisCandidates", "cycle2", "cycle3",
"conceptRecall", "migration", "fold"):
if key in ledger:
out["history"].setdefault("ledger", {})[key] = ledger[key]
total = sum(len(v) for t in topics.values() for v in t["kinds"].values())
out["counts"] = {"topics": len(topics), "nodes": total,
"candidates": len(out["candidates"])}
print(f"{args.project}: 주제 {len(topics)} · 글감 {total} · 후보 {len(out['candidates'])}")
if orphans:
print(f" 사람이 읽는 트리에 줄이 없던 노드 {orphans}건은 listedInTree=false 로 옮겼다")
if args.dry_run:
print(" (--dry-run: 쓰지 않았다)")
return 0
with open(index_path, "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f" tech-log-tree.json {os.path.getsize(index_path):,} bytes")
if not args.keep:
for name in ("root-tree.md", "candidate-ledger.json", "root-tree-source-manifest.json"):
path = os.path.join(studio, name)
if os.path.exists(path):
os.remove(path)
shutil.rmtree(os.path.join(studio, "_meta"), ignore_errors=True)
print(" root-tree.md · candidate-ledger.json · manifest · _meta/ 를 지웠다")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Tech Log 파이프라인이 함께 쓰는 어휘와 보고 형식.
`tech-log-tree.json` 프로젝트의 분해 계약이자 색인이고 정본이다. 만드는
(`build-tech-log-tree.py`) 검사하는 (`verify-tech-log-tree.py`) 같은 값을 쓰도록
여기 모은다.
"""
from __future__ import annotations
import collections
import hashlib
import json
import os
KINDS = ["case", "concept", "reference", "question", "decision"]
READINESS = ["READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
DISPOSITIONS = ["PROMOTE", "MERGE_INTO", "KEEP_IN_SSOT",
"NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
def sha256_of(path: str) -> str | None:
if not os.path.exists(path):
return None
return hashlib.sha256(open(path, "rb").read()).hexdigest()
def load_index(path: str) -> dict | None:
"""`tech-log-tree.json` 을 읽는다. 없으면 None."""
if not os.path.exists(path):
return None
return json.load(open(path, encoding="utf-8"))
def nodes(index: dict):
"""(주제 slug, 종류, 노드) 를 차례로 낸다."""
for slug, topic in (index.get("topics") or {}).items():
for kind, items in (topic.get("kinds") or {}).items():
for node in items:
yield slug, kind, node
class Report:
"""검사기가 규칙별로 모아 내는 결과.
규칙에 수백 건이 걸리는 것이 정상이라 개별 줄이 아니라 규칙으로 센다.
error 계약 위반이고 warn 편집 판단이 필요한 자리다.
"""
def __init__(self, project: str) -> None:
self.project = project
self.errors: dict[str, list[str]] = collections.defaultdict(list)
self.warns: dict[str, list[str]] = collections.defaultdict(list)
self.facts: dict[str, object] = {}
def error(self, rule: str, detail: str = "") -> None:
self.errors[rule].append(detail)
def warn(self, rule: str, detail: str = "") -> None:
self.warns[rule].append(detail)
@property
def error_count(self) -> int:
return sum(len(v) for v in self.errors.values())
@property
def warn_count(self) -> int:
return sum(len(v) for v in self.warns.values())
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""글감 계약과 폴더 배치를 작은 픽스처로 확인한다.
python3 -m unittest discover -s scripts/tests
"""
from __future__ import annotations
import contextlib
import copy
import hashlib
import importlib.util
import io
import json
import os
import sys
import tempfile
import unittest
SCRIPTS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, SCRIPTS)
import techlog # noqa: E402
def _load(name: str, filename: str):
spec = importlib.util.spec_from_file_location(name, os.path.join(SCRIPTS, filename))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
verifier = _load("verify_tech_log_tree", "verify-tech-log-tree.py")
builder = _load("build_tech_log_tree", "build-tech-log-tree.py")
layout = _load("verify_project_layout", "verify-project-layout.py")
CASE = {
"title": "세션이 두 노드에 반씩 남아 로그인이 번갈아 깨졌다",
"kind": "case", "slug": "session-split-across-nodes", "readiness": "READY",
"source": ["final/document.md#4-2"], "code": ["SessionStore.java:41"],
"evidence": ["evidence/raw/session-probe.txt"],
"classification": "두 노드에 요청을 번갈아 보내 재현했고 로그로 확인했다",
"missing-verification": "없음",
"relations": ["concept:authorization-code-exchange"],
}
CONCEPT = {
"title": "Authorization Code 교환이 한 번 더 일어나는 자리",
"kind": "concept", "slug": "authorization-code-exchange", "readiness": "READY",
"source": ["final/document.md#3-1"], "basis-version": "Keycloak 26.7.0",
"classification": "이 교환을 알아야 아래 Case 의 관측을 읽을 수 있다",
"relations": ["case:session-split-across-nodes"],
}
INDEX = {
"schemaVersion": 4, "project": "fixture", "ssot": "final/document.md",
"sourceRevision": "abc1234", "generatedAt": "2026-09-05",
"candidateScope": {"document": "final/document.md", "sections": ["§3", "§11"]},
"sourceRepository": {"path": "https://example.invalid/fixture.git",
"revision": "0" * 40, "verified": "픽스처"},
"contract": {"readinessValues": techlog.READINESS},
"topics": {
"session-custody": {
"topic": "session-custody", "title": "세션을 누가 보관하는가",
"readerQuestion": "자격증명과 세션을 누가 보관하고 보호 자원은 무엇을 신뢰하는가?",
"kinds": {"case": [CASE], "concept": [CONCEPT],
"reference": [], "question": [], "decision": []},
}
},
"candidates": [
{"id": "F001", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
"target": "case:session-split-across-nodes"},
{"id": "F002", "disposition": "PROMOTE", "dispositionReview": "CONFIRMED",
"target": "concept:authorization-code-exchange"},
{"id": "F003", "disposition": "KEEP_IN_SSOT", "dispositionReview": "CONFIRMED"},
],
}
RECORD = """\
---
kind: CASE
slug: session-split-across-nodes
title: 세션이 노드에 반씩 남아 로그인이 번갈아 깨졌다
topic: session-custody
project: fixture
status: 게시
---
"""
class Fixture:
def __init__(self, index: dict | None = None, with_record: bool = True) -> None:
self.dir = tempfile.TemporaryDirectory()
self.root = self.dir.name
self.base = os.path.join(self.root, "docs/fixture")
self.studio = os.path.join(self.base, "tech-log-studio")
os.makedirs(os.path.join(self.studio, "session-custody/case"))
os.makedirs(os.path.join(self.base, "final"))
ssot = os.path.join(self.base, "final/document.md")
open(ssot, "w", encoding="utf-8").write("# fixture\n")
data = copy.deepcopy(index if index is not None else INDEX)
data["ssotSha256"] = hashlib.sha256(open(ssot, "rb").read()).hexdigest()
self.index_path = os.path.join(self.studio, "tech-log-tree.json")
self.write(data)
if with_record:
open(os.path.join(self.studio, "session-custody/case/case-session-split.md"),
"w", encoding="utf-8").write(RECORD)
def write(self, data: dict) -> None:
with open(self.index_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
def read(self) -> dict:
return json.load(open(self.index_path, encoding="utf-8"))
def diagram(self, name: str, *, bundled: bool = True, with_source: bool = True) -> None:
assets = os.path.join(self.base, "final/assets")
target = os.path.join(assets, name) if bundled else assets
os.makedirs(target, exist_ok=True)
open(os.path.join(target, f"{name}.svg"), "w", encoding="utf-8").write("<svg/>")
if with_source:
src = os.path.join(self.base, "final/.techviz", name)
os.makedirs(src, exist_ok=True)
open(os.path.join(src, "spec.json"), "w", encoding="utf-8").write("{}")
def __enter__(self):
self._saved = (verifier.ROOT, builder.ROOT, layout.ROOT)
verifier.ROOT = builder.ROOT = layout.ROOT = self.root
return self
def __exit__(self, *exc):
verifier.ROOT, builder.ROOT, layout.ROOT = self._saved
self.dir.cleanup()
def mutate(**changes):
"""글감 하나의 칸을 바꾼 색인을 만든다."""
index = copy.deepcopy(INDEX)
case = index["topics"]["session-custody"]["kinds"]["case"][0]
for key, value in changes.items():
if value is None:
case.pop(key, None)
else:
case[key] = value
return index
class ContractTest(unittest.TestCase):
def test_clean_fixture_has_no_errors(self):
with Fixture():
with contextlib.redirect_stdout(io.StringIO()):
builder.main(["build", "fixture"])
report = verifier.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
def test_missing_reader_question_is_an_error(self):
index = copy.deepcopy(INDEX)
index["topics"]["session-custody"]["readerQuestion"] = ""
with Fixture(index):
self.assertIn("Topic 에 독자 질문이 없다", verifier.verify("fixture").errors)
def test_concept_without_basis_version_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["topics"]["session-custody"]["kinds"]["concept"][0]["basis-version"]
with Fixture(index):
self.assertIn("CONCEPT 노드에 `basis-version` 가 없다",
verifier.verify("fixture").errors)
def test_case_without_classification_is_an_error(self):
with Fixture(mutate(classification=None)):
self.assertIn("CASE 노드에 `classification` 가 없다",
verifier.verify("fixture").errors)
def test_source_anchored_outside_the_ssot_is_flagged(self):
with Fixture(mutate(source=["analysis/05-persistence.md §3.5"])):
self.assertIn("근거가 SSOT 밖에만 있다", verifier.verify("fixture").warns)
def test_readiness_is_not_publication(self):
with Fixture(mutate(readiness="NEEDS_EVIDENCE")):
self.assertIn("글을 쓰면 안 되는 readiness 인데 기록이 있다",
verifier.verify("fixture").errors)
def test_unknown_readiness_is_an_error(self):
with Fixture(mutate(readiness="REJECTED")):
self.assertIn("readiness 값이 계약에 없다", verifier.verify("fixture").errors)
def test_record_outside_the_contract_is_an_error(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.studio, "orphan-topic/concept"))
open(os.path.join(fx.studio, "orphan-topic/concept/c.md"), "w",
encoding="utf-8").write("---\nkind: CONCEPT\nslug: nobody-listed-me\n---\n")
self.assertIn("계약에 없는 기록", verifier.verify("fixture").errors)
def test_stale_ssot_hash_is_an_error(self):
with Fixture() as fx:
open(os.path.join(fx.base, "final/document.md"), "a",
encoding="utf-8").write("바뀌었다\n")
self.assertIn("SSOT 가 바뀐 뒤 글감을 다시 보지 않았다",
verifier.verify("fixture").errors)
def test_pending_disposition_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["dispositionReview"] = "PENDING"
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("disposition 을 다시 판정하지 않은 후보", report.errors)
self.assertNotIn("disposition 을 다시 판정하지 않은 후보", report.warns)
def test_promote_without_a_node_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["target"] = "case:never-written"
with Fixture(index):
self.assertIn("PROMOTE 후보가 글감에 없다", verifier.verify("fixture").errors)
def test_node_without_a_promote_candidate_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidates"][0]["disposition"] = "KEEP_IN_SSOT"
index["candidates"][0]["target"] = None
with Fixture(index):
self.assertIn("글감을 낳은 PROMOTE 후보가 없다",
verifier.verify("fixture").errors)
def test_missing_candidate_scope_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["candidateScope"]
with Fixture(index):
self.assertIn("candidateScope 가 없다", verifier.verify("fixture").errors)
def test_candidate_scope_pointing_at_another_document_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidateScope"]["document"] = "analysis/05-persistence.md"
with Fixture(index):
self.assertIn("candidateScope.document 가 ssot 과 다르다",
verifier.verify("fixture").errors)
def test_a_node_sourced_only_outside_the_candidate_scope_is_an_error(self):
index = mutate(source=["final/document.md#a19-messaging-runtime-core"])
index["candidateScope"]["excludedAnchorPattern"] = r"#a\d+-"
with Fixture(index):
self.assertIn("후보를 찾는 범위 밖에서만 나온 글감",
verifier.verify("fixture").errors)
def test_a_project_without_the_contract_is_an_error(self):
index = copy.deepcopy(INDEX)
index["schemaVersion"] = 2
del index["contract"]
del index["candidateScope"]
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("글감 계약을 아직 쓰지 않았다", report.errors)
self.assertNotIn("글감 계약을 아직 쓰지 않았다", report.warns)
def test_an_old_index_is_not_flooded_with_per_field_errors(self):
index = copy.deepcopy(INDEX)
index["schemaVersion"] = 2
del index["contract"]
del index["candidateScope"]
index["topics"]["session-custody"]["readerQuestion"] = ""
for kind in ("case", "concept"):
for node in index["topics"]["session-custody"]["kinds"][kind]:
node.pop("classification", None)
node.pop("basis-version", None)
with Fixture(index):
rules = set(verifier.verify("fixture").errors)
self.assertEqual(
{r for r in rules if "노드에" in r or "독자 질문" in r}, set(),
"계약을 안 쓴 프로젝트에 칸마다 error 를 내면 안 된다")
def test_a_tree_without_the_source_repository_is_an_error(self):
index = copy.deepcopy(INDEX)
del index["sourceRepository"]
with Fixture(index):
self.assertIn("sourceRepository.path 가 없다", verifier.verify("fixture").errors)
def test_a_repository_without_a_pinned_revision_is_a_warning(self):
index = copy.deepcopy(INDEX)
index["sourceRepository"]["revision"] = None
with Fixture(index):
report = verifier.verify("fixture")
self.assertIn("sourceRepository 에 리비전이 없다", report.warns)
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
def test_branch_tips_count_as_a_pinned_revision(self):
index = copy.deepcopy(INDEX)
index["sourceRepository"]["revision"] = None
index["sourceRepository"]["revisions"] = {"pattern1": "0" * 40, "pattern2": "1" * 40}
with Fixture(index):
report = verifier.verify("fixture")
self.assertNotIn("sourceRepository 에 리비전이 없다", report.warns)
self.assertNotIn("sourceRepository 에 리비전이 없다", report.errors)
def test_duplicate_slug_is_an_error(self):
index = copy.deepcopy(INDEX)
index["topics"]["session-custody"]["kinds"]["concept"][0]["slug"] = \
"session-split-across-nodes"
with Fixture(index):
self.assertIn("slug 가 두 글감에 있다", verifier.verify("fixture").errors)
class BuildTest(unittest.TestCase):
def test_derived_fields_come_from_the_record_file(self):
with Fixture():
index, warnings = builder.build("fixture")
case = index["topics"]["session-custody"]["kinds"]["case"][0]
self.assertEqual(case["file"], "session-custody/case/case-session-split.md")
self.assertEqual(case["publication"], "초안")
self.assertEqual(case["readiness"], "READY", "readiness 는 사람이 적는다")
concept = index["topics"]["session-custody"]["kinds"]["concept"][0]
self.assertEqual(concept["publication"], "미작성")
self.assertNotIn("file", concept)
self.assertEqual(index["counts"]["written"], 1)
self.assertEqual(warnings, [])
def test_human_written_fields_survive_a_rebuild(self):
with Fixture() as fx:
with contextlib.redirect_stdout(io.StringIO()):
builder.main(["build", "fixture"])
builder.main(["build", "fixture"])
case = fx.read()["topics"]["session-custody"]["kinds"]["case"][0]
self.assertEqual(case["classification"], CASE["classification"])
self.assertEqual(case["relations"], CASE["relations"])
def test_directory_left_behind_does_not_become_a_topic(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.studio, "deleted-from-the-contract/case"))
open(os.path.join(fx.studio, "deleted-from-the-contract/case/x.md"), "w",
encoding="utf-8").write("---\nkind: CASE\nslug: revived-by-its-folder\n---\n")
index, warnings = builder.build("fixture")
self.assertEqual(set(index["topics"]), {"session-custody"})
self.assertEqual(index["unlisted"], ["deleted-from-the-contract/case/x.md"])
self.assertTrue(warnings)
class LayoutTest(unittest.TestCase):
def test_a_diagram_with_its_source_is_clean(self):
with Fixture() as fx:
fx.diagram("session-custody-map")
report = layout.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
self.assertEqual(report.warns, {}, report.warns)
def test_svg_without_a_techviz_source_is_counted(self):
with Fixture() as fx:
fx.diagram("hand-drawn", with_source=False)
self.assertIn("techviz 정본이 없는 그림", layout.verify("fixture").warns)
def test_studio_presentation_copies_need_no_source(self):
with Fixture() as fx:
assets = os.path.join(fx.base, "final/assets/tech-log-studio")
os.makedirs(assets)
open(os.path.join(assets, "custody.svg"), "w", encoding="utf-8").write("<svg/>")
self.assertEqual(layout.verify("fixture").warns, {})
def test_evidence_folder_outside_the_convention_is_an_error(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "final/evidence/screenshots"))
self.assertIn("evidence 하위 폴더 이름이 규약 밖이다",
layout.verify("fixture").errors)
def test_finished_analysis_must_not_leave_working_material(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "analysis"))
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
json.dump({"analysisStatus": "COMPLETE"}, fh)
self.assertIn("분석이 끝났는데 작업 재료가 남아 있다",
layout.verify("fixture").warns)
def test_analysis_in_progress_is_not_debt(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "analysis"))
open(os.path.join(fx.base, "source-index.md"), "w", encoding="utf-8").write("#\n")
with open(os.path.join(fx.base, "state.json"), "w", encoding="utf-8") as fh:
json.dump({"analysisStatus": "IN_PROGRESS"}, fh)
report = layout.verify("fixture")
self.assertEqual(report.errors, {}, report.errors)
self.assertEqual(report.warns, {}, report.warns)
def test_import_source_left_behind_is_counted(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "source/docs"))
open(os.path.join(fx.base, "source/docs/lab.md"), "w",
encoding="utf-8").write("원본\n")
self.assertIn("반입 원본이 남아 있다", layout.verify("fixture").warns)
if __name__ == "__main__":
unittest.main()
+125 -32
View File
@@ -2,6 +2,9 @@
from __future__ import annotations
import argparse
import importlib.util
import json
import sys
from pathlib import Path
REQUIRED_PATHS = (
@@ -9,8 +12,10 @@ REQUIRED_PATHS = (
".agents/skills/analyzing-codebase-for-tech-log/SKILL.md",
".agents/skills/deriving-tech-log-root-tree/SKILL.md",
".agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md",
".agents/skills/deriving-tech-log-root-tree/references/candidate-disposition.md",
".agents/skills/deriving-tech-log-root-tree/references/example-tech-log-tree.md",
".agents/skills/writing-tech-log-records/SKILL.md",
".agents/skills/writing-tech-log-records/references/root-tree-contract.md",
".agents/skills/writing-tech-log-records/references/tech-log-tree-contract.md",
".agents/skills/writing-tech-log-records/references/record-kinds.md",
".agents/skills/writing-tech-log-records/references/body-syntax.md",
".agents/skills/writing-tech-log-records/references/code-tables-diagrams.md",
@@ -27,34 +32,49 @@ REQUIRED_PATHS = (
".agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs",
".agents/skills/technical-visualizer/SKILL.md",
".agents/skills/refactoring-from-analysis/SKILL.md",
#
"docs/_templates/state.json",
"docs/_templates/source-index.md",
"docs/_templates/root-tree.md",
"docs/_templates/analysis/00-project-overview.md",
"docs/_templates/analysis/module.md",
# 프로젝트 폴더 틀 — 끝난 프로젝트의 모양. 작업 재료는 여기 없다
"docs/_templates/README.md",
"docs/_templates/final/document.md",
"docs/_templates/final/evidence/meta/evidence.json",
"docs/_templates/tech-log-studio/tech-log-tree.json",
# 분석하는 동안에만 있는 작업 재료의 틀
".agents/skills/analyzing-codebase-for-tech-log/templates/state.json",
".agents/skills/analyzing-codebase-for-tech-log/templates/source-index.md",
".agents/skills/analyzing-codebase-for-tech-log/templates/analysis/00-project-overview.md",
".agents/skills/analyzing-codebase-for-tech-log/templates/analysis/module.md",
".agents/skills/writing-tech-log-records/templates/case.md",
".agents/skills/writing-tech-log-records/templates/concept.md",
".agents/skills/writing-tech-log-records/templates/reference.md",
".agents/skills/writing-tech-log-records/templates/question.md",
".agents/skills/writing-tech-log-records/templates/decision.md",
# 도구
"scripts/techviz",
"scripts/build-tech-log-tree.py",
"scripts/techlog.py",
"scripts/verify-tech-log-tree.py",
"scripts/verify-project-layout.py",
"scripts/fold-analysis-into-final.py",
"scripts/fold-studio-contract-into-index.py",
"scripts/terminal-evidence/render_terminal.py",
"scripts/terminal-evidence/README.md",
".agents/skills/writing-tech-log-records/scripts/check_body.mjs",
".agents/skills/writing-tech-log-records/scripts/check_evidence.mjs",
)
ROOT_TREE_TOKENS = (
"PROJECT",
"TOPIC",
"├── CASE",
"├── REFERENCE",
"├── OPEN QUESTION",
"└── DECISION",
"# Node Specifications",
"readiness:",
"source:",
"classification:",
# 글감 계약이 요구하는 칸. 틀이 이것들을 보여 주지 않으면 아무도 채우지 않는다
INDEX_TOKENS = (
"readerQuestion",
"candidateScope",
"sourceRepository",
"readinessValues",
"dispositionValues",
"KEEP_IN_SSOT",
"classification",
"missing-verification",
"basis-version",
"relations",
"candidates",
"dispositionReview",
)
QUEUE_TOKENS = ("version:", "activeProject:", "projects:")
@@ -169,19 +189,20 @@ def verify_pipeline(shared_root: Path) -> list[str]:
if refactor_queue.exists():
errors.extend(_verify_refactor_queue(refactor_queue))
state_template = shared_root / "docs/_templates/state.json"
state_template = shared_root / (
".agents/skills/analyzing-codebase-for-tech-log/templates/state.json")
if state_template.exists():
state_text = state_template.read_text(encoding="utf-8", errors="replace")
for token in STATE_REANALYSIS_TOKENS:
if token not in state_text:
errors.append(f"state template missing reanalysis token: {token}")
root_tree = shared_root / "docs/_templates/root-tree.md"
if root_tree.exists():
text = root_tree.read_text(encoding="utf-8", errors="replace")
for token in ROOT_TREE_TOKENS:
index = shared_root / "docs/_templates/tech-log-studio/tech-log-tree.json"
if index.exists():
text = index.read_text(encoding="utf-8", errors="replace")
for token in INDEX_TOKENS:
if token not in text:
errors.append(f"root-tree template missing token: {token}")
errors.append(f"tech-log-tree template missing token: {token}")
for path in _iter_pipeline_text_files(shared_root):
text = path.read_text(encoding="utf-8", errors="replace")
@@ -200,25 +221,97 @@ def verify_pipeline(shared_root: Path) -> list[str]:
return errors
def _load(shared_root: Path, filename: str, name: str):
path = shared_root / "scripts" / filename
if not path.exists():
return None
sys.path.insert(0, str(shared_root / "scripts"))
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def verify_projects(shared_root: Path) -> list:
"""실제 프로젝트의 분해 계약 정합성. 템플릿에 토큰이 있는지와는 다른 것이다."""
verifier = _load(shared_root, "verify-tech-log-tree.py", "verify_tech_log_tree")
if verifier is None:
return []
projects = sorted(p.parent.name for p in shared_root.glob("docs/*/tech-log-studio")
if not p.parent.name.startswith("_"))
return [verifier.verify(name) for name in projects]
def verify_layouts(shared_root: Path) -> list:
"""프로젝트 폴더가 같은 모양인지. 틀은 docs/_templates 다."""
verifier = _load(shared_root, "verify-project-layout.py", "verify_project_layout")
if verifier is None:
return []
projects = sorted({p.parent.parent.name
for p in shared_root.glob("docs/*/final/document.md")
if not p.parent.parent.name.startswith("_")})
return [verifier.verify(name) for name in projects]
def main() -> int:
parser = argparse.ArgumentParser(description="Verify the Tech Log documentation pipeline workspace.")
parser.add_argument("shared_root", nargs="?", type=Path,
default=Path(__file__).resolve().parent.parent)
parser.add_argument("--skip-projects", action="store_true",
help="틀과 스킬만 본다. 프로젝트 트리 정합성은 보지 않는다")
parser.add_argument("--samples", type=int, default=2)
args = parser.parse_args()
errors = verify_pipeline(args.shared_root)
reports = [] if args.skip_projects else verify_projects(args.shared_root)
layouts = [] if args.skip_projects else verify_layouts(args.shared_root)
project_errors = sum(r.error_count for r in reports) + sum(r.error_count for r in layouts)
if errors:
print("PIPELINE VERIFICATION: FAIL")
print("PIPELINE CONTRACT: FAIL")
for error in errors:
print(f"- {error}")
return 1
else:
print("PIPELINE CONTRACT: PASS")
print(f"- required paths: {len(REQUIRED_PATHS)}")
print("- analysis queue contract: valid")
print("- tech-log-tree contract: present")
print("- forbidden legacy dependency: absent")
print("PIPELINE VERIFICATION: PASS")
print(f"- required paths: {len(REQUIRED_PATHS)}")
print("- analysis queue contract: valid")
print("- root-tree contract: present")
print("- forbidden legacy dependency: absent")
return 0
if layouts:
layout_errors = sum(r.error_count for r in layouts)
print()
print(f"PROJECT LAYOUT: {'FAIL' if layout_errors else 'PASS'}"
f" — 프로젝트 {len(layouts)} · error {layout_errors} ·"
f" warn {sum(r.warn_count for r in layouts)}")
for report in layouts:
verifier_render(report, args.samples)
if reports:
tree_errors = sum(r.error_count for r in reports)
print()
print(f"TECH LOG TREES: {'FAIL' if tree_errors else 'PASS'}"
f" — 프로젝트 {len(reports)} · error {tree_errors} ·"
f" warn {sum(r.warn_count for r in reports)}")
for report in reports:
verifier_render(report, args.samples)
return 1 if errors or project_errors else 0
def verifier_render(report, samples: int) -> None:
facts = " · ".join(
f"{k}={json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else v}"
for k, v in report.facts.items())
print(f" [{report.project}] {facts}")
for label, bucket, mark in (("error", report.errors, ""), ("warn", report.warns, "!")):
for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])):
print(f" {mark} {label} {len(details):>4} {rule}")
for detail in details[:samples]:
if detail:
print(f" · {detail}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
if __name__ == "__main__":
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""프로젝트 문서 폴더가 같은 모양인지 본다.
프로젝트 하나가 폴더 하나다. 안의 배치는 `docs/_templates/` 정본이고
CLAUDE.md 문서 위치 같은 것을 말로 적은 것이다.
docs/<프로젝트>/
source/ 밖에서 가져온 원본
state.json · source-index.md
analysis/ · notes/ · checkpoints/
final/ SSOT
document.md
assets/<이름>/ 그림 하나가 폴더 하나
assets/tech-log-studio/ Studio 올릴 표현물
.techviz/<이름>/ 그림의 정본
evidence/{raw,meta,rendered,browser}
tech-log-studio/
python3 scripts/verify-project-layout.py [프로젝트 ...] [--strict] [--samples N]
**SVG 정본이 아니다.** `.techviz/<이름>/` 없이 남은 그림은 다시 만들 없다.
검사기는 그것을 세지만 실패로 만들지는 않는다 언제 다시 만들지는 편집 판단이다.
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from techlog import Report # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EVIDENCE_DIRS = {"raw", "meta", "rendered", "browser"}
# 분석하는 동안에만 있는 작업 재료. 분석이 끝나면 final/document.md 로 합치고 지운다.
# 끝난 프로젝트의 폴더는 final/ 과 tech-log-studio/ (밖에서 가져왔으면 source/) 뿐이다
WORKING_MATERIAL = ("analysis", "notes", "checkpoints", "state.json", "source-index.md")
# 밖에서 가져올 때만 있는 재료. final/ 이 그 내용을 담으면 원본은 사본이 된다
IMPORT_MATERIAL = "source"
# Studio 에 올릴 표현물이 사는 곳. 여기 SVG 는 그림의 정본을 따로 갖지 않는다
STUDIO_ASSETS = "tech-log-studio"
def _svg_stems(assets: str) -> list[tuple[str, str]]:
"""(stem, 상대경로). assets/tech-log-studio/ 아래는 표현물이라 뺀다."""
out = []
for path in sorted(glob.glob(os.path.join(assets, "**", "*.svg"), recursive=True)):
rel = os.path.relpath(path, assets)
if rel.split(os.sep)[0] == STUDIO_ASSETS:
continue
out.append((os.path.basename(path)[:-4], rel))
return out
def verify(project: str) -> Report:
rep = Report(project)
base = os.path.join(ROOT, "docs", project)
final = os.path.join(base, "final")
studio = os.path.join(base, "tech-log-studio")
# ── SSOT ───────────────────────────────────────────────────────
if not os.path.exists(os.path.join(final, "document.md")):
rep.error("final/document.md 가 없다", project)
return rep
# ── 분석 작업 재료 ─────────────────────────────────────────────
# 분석 중이면 있어야 하고, 끝났으면 final 로 합치고 없어야 한다
left = [n for n in WORKING_MATERIAL if os.path.exists(os.path.join(base, n))]
status = None
state_path = os.path.join(base, "state.json")
if os.path.exists(state_path):
try:
status = json.load(open(state_path, encoding="utf-8")).get("analysisStatus")
except (json.JSONDecodeError, OSError):
rep.error("state.json 을 읽지 못했다", project)
if os.path.isdir(os.path.join(base, "analysis")):
for name in ("state.json", "source-index.md"):
if not os.path.exists(os.path.join(base, name)):
rep.error(f"analysis/ 가 있는데 {name} 이 없다", project)
if left:
rep.facts["analysis"] = status or "진행 중"
if status == "COMPLETE":
rep.warn("분석이 끝났는데 작업 재료가 남아 있다",
f"{' · '.join(left)} — final/document.md 로 합치고 지운다")
imported = os.path.join(base, IMPORT_MATERIAL)
if os.path.isdir(imported):
n = sum(1 for _ in glob.iglob(os.path.join(imported, "**", "*"), recursive=True))
rep.warn("반입 원본이 남아 있다",
f"source/ {n}개 — final/ 이 그 내용을 담고 있으면 사본이다")
# ── 증거 ───────────────────────────────────────────────────────
evidence = os.path.join(final, "evidence")
if os.path.isdir(evidence):
for name in sorted(os.listdir(evidence)):
if os.path.isdir(os.path.join(evidence, name)) and name not in EVIDENCE_DIRS:
rep.error("evidence 하위 폴더 이름이 규약 밖이다",
f"final/evidence/{name} — raw · meta · rendered · browser")
raw = os.path.join(evidence, "raw")
counts = {}
for name in EVIDENCE_DIRS:
d = os.path.join(evidence, name)
counts[name] = len(glob.glob(os.path.join(d, "**", "*"), recursive=True)) \
if os.path.isdir(d) else 0
rep.facts["evidence"] = counts
# 6개월 뒤에 파일 이름만으로는 못 읽는다
for d in sorted(glob.glob(os.path.join(raw, "*"))):
if os.path.isdir(d) and not os.path.exists(os.path.join(d, "README.txt")):
rep.warn("evidence/raw 하위 폴더에 README.txt 가 없다",
os.path.relpath(d, base))
if counts.get("rendered") and not counts.get("meta"):
rep.error("터미널 SVG 는 있는데 meta 가 없다",
"실행한 명령의 원문과 메타데이터가 정본이다")
elif counts.get("raw") and not counts.get("meta"):
rep.warn("raw 는 있는데 meta 가 없다",
f"raw {counts['raw']}건 — command·cwd·executedAt·exitCode·revision 이 없다")
# ── 그림 ───────────────────────────────────────────────────────
assets = os.path.join(final, "assets")
techviz = os.path.join(final, ".techviz")
if os.path.isdir(assets):
# 정본은 그림 이름 폴더다. .techviz 에 놓인 파일은 정본이 아니다
sources = {n for n in os.listdir(techviz)
if os.path.isdir(os.path.join(techviz, n))} \
if os.path.isdir(techviz) else set()
svgs = _svg_stems(assets)
rep.facts["diagrams"] = {"svg": len(svgs), "techviz": len(sources)}
for stem, rel in svgs:
if stem not in sources:
rep.warn("techviz 정본이 없는 그림", f"final/assets/{rel}")
if os.path.dirname(rel) in ("", "diagrams"):
rep.warn("그림이 이름 폴더로 묶여 있지 않다", f"final/assets/{rel}")
stems = {s for s, _ in svgs}
for name in sorted(sources - stems):
rep.warn("정본만 있고 그림이 없다", f"final/.techviz/{name}")
# ── 기록이 가리키는 그림 ───────────────────────────────────────
if os.path.isdir(studio):
wrong = 0
broken = 0
for path in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md"))):
if os.path.basename(os.path.dirname(os.path.dirname(path))).startswith("_"):
continue
text = open(path, encoding="utf-8").read()
for m in re.finditer(r"^ file: (\S+)$", text, re.M):
target = os.path.normpath(os.path.join(os.path.dirname(path), m.group(1)))
if not os.path.exists(target):
broken += 1
if broken <= 5:
rep.error("기록이 가리키는 그림이 없다",
f"{os.path.relpath(path, base)}{m.group(1)}")
continue
if f"assets{os.sep}{STUDIO_ASSETS}{os.sep}" not in target:
wrong += 1
if wrong:
rep.warn("Studio 자산이 assets/tech-log-studio/ 밖에 있다",
f"{wrong}건 — 다른 프로젝트는 전부 그 폴더를 쓴다")
return rep
def render(rep: Report, samples: int) -> None:
facts = " · ".join(f"{k}={json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else v}"
for k, v in rep.facts.items())
print(f" [{rep.project}] {facts or ''}")
for label, bucket, mark in (("error", rep.errors, ""), ("warn", rep.warns, "!")):
for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])):
print(f" {mark} {label} {len(details):>4} {rule}")
for d in details[:samples]:
if d:
print(f" · {d}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
def main() -> int:
ap = argparse.ArgumentParser(description="프로젝트 문서 폴더 배치를 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--samples", type=int, default=3)
ap.add_argument("--strict", action="store_true")
args = ap.parse_args()
projects = args.projects or sorted(
name for name in (
os.path.basename(os.path.dirname(os.path.dirname(p)))
for p in glob.glob(os.path.join(ROOT, "docs/*/final/document.md"))
) if not name.startswith("_")
)
reports = [verify(p) for p in projects]
e = sum(r.error_count for r in reports)
w = sum(r.warn_count for r in reports)
print(f"PROJECT LAYOUT: {'FAIL' if e or (args.strict and w) else 'PASS'}"
f" — 프로젝트 {len(reports)} · error {e} · warn {w}")
for r in reports:
render(r, args.samples)
return 1 if e or (args.strict and w) else 0
if __name__ == "__main__":
raise SystemExit(main())
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""분해 계약이 스스로 맞는지, 그리고 기록·색인이 계약을 따르는지 본다.
`verify-pipeline.py` 스킬과 틀이 제자리에 있는지만 본다. 검사기는 프로젝트의
실제 트리를 본다 `tech-log-tree.json` 주제·글감·후보와 디스크의 기록이 같은 것을
말하는지.
python3 scripts/verify-tech-log-tree.py [프로젝트 ...] [--strict] [--samples N] [--json]
정본 순서는 이렇다.
코드·설정·실행 증거 사실의 근거
final/document.md 글감 범위의 SSOT. candidateScope 범위를 말한다
analysis/**/*.md 이미 채택한 주장을 상세 확인하는 보조 근거 (분석 중에만 있다)
tech-log-tree.json 사람이 고른 글감. 분해 계약이자 색인이고 정본이다
error 하나라도 있으면 실패다. warn 편집 판단이 필요한 자리이고 `--strict` 에서만
실패가 된다. 선별을 마치지 않은 상태 `dispositionReview: PENDING`, PROMOTE 후보와
글감이 1:1 아닌 warn 아니라 error . 경고로 두면 재판정하지 않은 트리로
글을 쓰기 시작할 있다.
**계약을 아직 채택하지 않은 프로젝트도 error .** 칸마다 error 내지는 않는다
아직 쓰지 않았다 잘못 썼다 보이기 때문이다. 대신 계약 미채택 자체를 건의
error 센다. warn 으로 두면 스키마로 남아 있는 검사를 영원히 피한다.
"""
from __future__ import annotations
import argparse
import collections
import glob
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import techlog # noqa: E402
from techlog import KINDS, DISPOSITIONS, READINESS, Report # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 종류마다 글감이 반드시 갖는 칸
REQUIRED_FIELDS = {
"case": ("slug", "readiness", "source", "classification", "missing-verification", "relations"),
"concept": ("slug", "readiness", "source", "basis-version", "classification", "relations"),
"reference": ("slug", "readiness", "source", "classification", "scope", "exceptions", "relations"),
"question": ("slug", "readiness", "source", "known", "unknown",
"next-verification", "decision-criterion", "relations"),
"decision": ("slug", "readiness", "source", "decision-status", "decision-evidence",
"grounds", "classification", "relations"),
}
# 글을 써도 되는 readiness. 나머지는 글감으로만 남는다
GENERATABLE = {"case": {"READY"}, "concept": {"READY"}, "reference": {"READY"},
"question": {"OPEN"}, "decision": {"READY"}}
DECISION_STATUS = {"PROPOSED", "ADOPTED", "SUPERSEDED", "NOT_DECIDED"}
# 분석 문서의 절 제목을 그대로 옮겨 온 자리
COPIED_HEADING = (
(re.compile(r"^\(\d+(?:\.\d+)*\)"), "분석 문서의 절 번호가 제목에 남아 있다"),
(re.compile(r"^(Confirmed|P[1-3])\s*[—–-]"), "분석 문서의 finding 등급이 제목에 남아 있다"),
)
# Concept 은 「남의 것이 어떻게 동작하는가」다
CONCEPT_NOT_A_MECHANISM = (
(r"없다|없음|부재|미배선|배선되지|호출자|호출되지 않|실행되지 않|도달하지 않", "부재·미배선 사실"),
(r"refs?\s*=\s*0|카운트|개수|몇 개|전부 읽|샘플링|denominator", "분석 범위·계수"),
(r"보류|남은 것|다음 사이클|이번 pass|커버리지|coverage|레인과 복원|기록이다", "분석 진행 기록"),
(r"드리프트|불일치|어긋|틀렸|실패했|누락|검증되지", "Finding 문장"),
)
def _front_matter(path: str) -> dict:
text = open(path, encoding="utf-8").read()
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
out = {}
for line in text[3:end].splitlines():
m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line)
if m:
out[m.group(1)] = m.group(2).strip().strip('"')
return out
def _records_on_disk(studio: str) -> dict[tuple[str, str], str]:
found = {}
for path in sorted(glob.glob(os.path.join(studio, "*", "*", "*.md"))):
parts = path.split(os.sep)
topic_dir, kind_dir = parts[-3], parts[-2]
if topic_dir.startswith("_") or kind_dir not in KINDS:
continue
fm = _front_matter(path)
found[(kind_dir, fm.get("slug") or os.path.basename(path)[:-3])] = path
return found
def _values(node: dict, key: str) -> list[str]:
value = node.get(key)
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value if str(v).strip()]
return [str(value)] if str(value).strip() else []
def verify(project: str) -> Report:
rep = Report(project)
base = os.path.join(ROOT, "docs", project)
studio = os.path.join(base, "tech-log-studio")
records = _records_on_disk(studio)
rep.facts["records"] = len(records)
index_path = os.path.join(studio, "tech-log-tree.json")
index = techlog.load_index(index_path)
if index is None:
rep.warn("분해 계약 없음",
f"{project}: tech-log-tree.json 이 없다. 디렉터리가 정본 노릇을 하고 있다")
return rep
# ── 원본 무결성 ────────────────────────────────────────────────
ssot_rel = index.get("ssot") or "final/document.md"
ssot_path = os.path.join(base, ssot_rel)
if not os.path.exists(ssot_path):
rep.error("SSOT 파일 없음", ssot_rel)
else:
declared = index.get("ssotSha256") or ""
actual = techlog.sha256_of(ssot_path)
if not declared:
rep.error("ssotSha256 없음", ssot_rel)
elif declared != actual:
rep.error("SSOT 가 바뀐 뒤 글감을 다시 보지 않았다",
"python3 scripts/build-tech-log-tree.py 를 다시 돌린다")
if not index.get("sourceRevision"):
rep.warn("sourceRevision 없음", project)
# 옛 색인은 디렉터리를 훑어 만든 것이라 계약 칸이 아예 없다. 칸마다 error 를 내면
# 「아직 쓰지 않았다」가 「잘못 썼다」로 보인다
has_contract = index.get("schemaVersion", 1) >= 4 or "contract" in index
rep.facts["contract"] = "있음" if has_contract else "없음"
if not has_contract:
rep.error("글감 계약을 아직 쓰지 않았다",
f"{project}: 옛 색인이다. 주제·독자 질문·글감의 칸을 사람이 적어야 한다 "
"— 칸마다 error 를 내지 않는 대신 미채택 자체를 여기서 한 번 센다")
# ── 분석한 저장소 ──────────────────────────────────────────────
repo = index.get("sourceRepository") or {}
if has_contract:
if not repo.get("path"):
rep.error("sourceRepository.path 가 없다",
f"{project}: 어느 저장소를 읽고 쓴 글인지 적혀 있지 않다")
elif not os.path.isdir(repo["path"]) and "://" not in repo["path"]:
rep.warn("sourceRepository.path 가 이 기계에 없다", repo["path"])
# 갈래가 여럿이면 단일 커밋으로 표현되지 않는다. revisions 로 적는다
if not repo.get("revision") and not repo.get("revisions"):
rep.warn("sourceRepository 에 리비전이 없다",
f"{project}: 문서가 서술한 상태의 커밋을 고정하지 않았다")
elif not repo.get("verified"):
rep.warn("sourceRepository.verified 가 없다",
f"{project}: 그 리비전이 맞다고 판단한 근거가 없다")
# ── 후보를 찾는 범위 ───────────────────────────────────────────
scope = index.get("candidateScope") or {}
excluded_anchor = None
if has_contract:
if not scope:
rep.error("candidateScope 가 없다",
f"{project}: SSOT 의 어느 부분에서 후보를 찾는지 적지 않았다")
else:
if scope.get("document") and scope["document"] != ssot_rel:
rep.error("candidateScope.document 가 ssot 과 다르다",
f"{scope['document']}{ssot_rel}")
if not scope.get("sections"):
rep.error("candidateScope 에 sections 가 없다", project)
pattern = scope.get("excludedAnchorPattern")
if pattern:
try:
excluded_anchor = re.compile(pattern)
except re.error as exc:
rep.error("candidateScope.excludedAnchorPattern 이 정규식이 아니다",
f"{pattern}{exc}")
# ── 주제 ───────────────────────────────────────────────────────
topics = index.get("topics") or {}
rep.facts["topics"] = len(topics)
for slug, topic in topics.items():
if topic.get("topic") and topic["topic"] != slug:
rep.error("주제 키와 topic 이 다르다", f"{slug}{topic['topic']}")
if not (topic.get("readerQuestion") or "").strip():
if has_contract:
rep.error("Topic 에 독자 질문이 없다", slug)
elif not topic["readerQuestion"].rstrip().endswith("?"):
rep.warn("독자 질문이 물음이 아니다", f"{slug}: {topic['readerQuestion'][:60]}")
n = sum(len(v) for v in (topic.get("kinds") or {}).values())
if n == 1:
rep.warn("Topic 에 노드가 하나뿐이다", slug)
if n == 0:
rep.error("Topic 에 글감이 없다", slug)
# ── 글감 ───────────────────────────────────────────────────────
slugs: dict[str, str] = {}
listed: set[tuple[str, str]] = set()
total = 0
for topic_slug, kind, node in techlog.nodes(index):
total += 1
where = f"{topic_slug} · {kind.upper()} · {str(node.get('title',''))[:44]}"
if kind not in REQUIRED_FIELDS:
rep.error("종류 이름이 계약에 없다", f"{where}{kind}")
continue
if has_contract:
for key in REQUIRED_FIELDS[kind]:
if not _values(node, key):
rep.error(f"{kind.upper()} 노드에 `{key}` 가 없다", where)
slug = str(node.get("slug") or "")
if slug:
if slug in slugs:
rep.error("slug 가 두 글감에 있다", f"{slug}{slugs[slug]} / {where}")
slugs[slug] = where
listed.add((kind, slug))
readiness = str(node.get("readiness") or "").upper()
if readiness and readiness not in READINESS:
rep.error("readiness 값이 계약에 없다", f"{where}{readiness}")
if kind == "question" and readiness and readiness != "OPEN":
rep.error("OPEN QUESTION 의 readiness 는 OPEN 이다", f"{where}{readiness}")
if kind == "decision":
status = str(node.get("decision-status") or "").strip("`").upper()
if status and status not in DECISION_STATUS:
rep.error("decision-status 값이 계약에 없다", f"{where}{status}")
if has_contract and not _values(node, "relations"):
rep.warn("관계가 없는 노드", where)
anchors = " ".join(_values(node, "source"))
if anchors and ssot_rel not in anchors:
rep.warn("근거가 SSOT 밖에만 있다", f"{where}{anchors[:60]}")
if excluded_anchor:
outside = [a for a in _values(node, "source") if excluded_anchor.search(a)]
if outside and len(outside) == len(_values(node, "source")):
rep.error("후보를 찾는 범위 밖에서만 나온 글감",
f"{where}{outside[0][:60]}")
title = str(node.get("title") or "")
for pattern, why in COPIED_HEADING:
if pattern.match(title):
rep.warn(why, where)
break
if kind == "concept":
for pattern, why in CONCEPT_NOT_A_MECHANISM:
if re.search(pattern, title):
rep.warn(f"Concept 제목이 메커니즘이 아니다 — {why}", where)
break
rep.facts["nodes"] = total
# ── readiness ↔ 실제로 쓴 글 ───────────────────────────────────
written = 0
for (kind, slug), path in sorted(records.items()):
rel = os.path.relpath(path, ROOT)
if (kind, slug) not in listed:
rep.error("계약에 없는 기록", rel)
continue
written += 1
node = next((n for _, k, n in techlog.nodes(index)
if k == kind and n.get("slug") == slug), None)
readiness = str((node or {}).get("readiness") or "").upper()
if readiness and readiness not in GENERATABLE[kind]:
rep.error("글을 쓰면 안 되는 readiness 인데 기록이 있다",
f"{rel} — readiness={readiness}")
rep.facts["written"] = written
rep.facts["unwritten"] = total - written
# ── 후보와 처분 ────────────────────────────────────────────────
candidates = index.get("candidates") or []
if candidates:
counts = collections.Counter()
promoted: set[str] = set()
for c in candidates:
d = c.get("disposition")
counts[d] += 1
if d not in DISPOSITIONS:
rep.error("disposition 값이 계약에 없다", f"{c.get('id')}{d}")
if c.get("dispositionReview") != "CONFIRMED":
rep.error("disposition 을 다시 판정하지 않은 후보",
f"{c.get('id')} — 선별이 아니라 recall 로 방출됐다")
if d == "PROMOTE":
target = c.get("target") or ""
slug = target.split(":", 1)[1] if ":" in target else target
if not slug:
rep.error("PROMOTE 후보에 target 이 없다", str(c.get("id")))
continue
promoted.add(slug)
if slug not in slugs:
rep.error("PROMOTE 후보가 글감에 없다", f"{c.get('id')}{target}")
rep.facts["candidates"] = dict(counts)
# 반대 방향 — 후보 대장을 거치지 않고 트리에 올라온 글감
for slug, where in sorted(slugs.items()):
if slug not in promoted:
rep.error("글감을 낳은 PROMOTE 후보가 없다", f"{slug}{where}")
return rep
def render(rep: Report, samples: int) -> None:
facts = " · ".join(
f"{k}={json.dumps(v, ensure_ascii=False) if isinstance(v, dict) else v}"
for k, v in rep.facts.items())
print(f"\n[{rep.project}] {facts}")
for label, bucket, mark in (("error", rep.errors, ""), ("warn", rep.warns, "!")):
for rule, details in sorted(bucket.items(), key=lambda kv: -len(kv[1])):
print(f" {mark} {label} {len(details):>4} {rule}")
for d in details[:samples]:
if d:
print(f" · {d}")
if samples and len(details) > samples:
print(f" … 외 {len(details) - samples}")
def main() -> int:
ap = argparse.ArgumentParser(description="한 프로젝트의 글감 계약 정합성을 본다.")
ap.add_argument("projects", nargs="*")
ap.add_argument("--samples", type=int, default=3)
ap.add_argument("--strict", action="store_true", help="warn 도 실패로 센다")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
projects = args.projects or sorted(
name for name in (
os.path.basename(os.path.dirname(p))
for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio"))
) if not name.startswith("_")
)
reports = [verify(p) for p in projects]
if args.json:
print(json.dumps([{"project": r.project, "facts": r.facts,
"errors": dict(r.errors), "warns": dict(r.warns)}
for r in reports], ensure_ascii=False, indent=2))
return 1 if sum(r.error_count for r in reports) else 0
for r in reports:
render(r, args.samples)
e = sum(r.error_count for r in reports)
w = sum(r.warn_count for r in reports)
print(f"\nTECH LOG TREE: {'FAIL' if e or (args.strict and w) else 'PASS'}"
f" — 프로젝트 {len(reports)} · error {e} · warn {w}")
return 1 if e or (args.strict and w) else 0
if __name__ == "__main__":
raise SystemExit(main())