From 9d2a3725c5fbd86ad33e15e924c04a6aa3240ae3 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Mon, 7 Sep 2026 12:39:20 +0900 Subject: [PATCH] pipeline: make tech-log-tree.json the one decomposition contract and enforce it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 두 건을 반영했다. 계약 - 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 --- .../analyzing-codebase-for-tech-log/SKILL.md | 23 +- .../references/queue-contract.md | 2 +- .../templates/README.txt | 11 + .../analysis/00-project-overview.md | 0 .../templates}/analysis/module.md | 0 .../templates}/source-index.md | 0 .../templates}/state.json | 10 +- .../deriving-tech-log-root-tree/SKILL.md | 140 +++++- .../references/candidate-disposition.md | 99 +++++ .../references/decomposition-checklist.md | 43 +- .../references/example-root-tree.md | 410 ------------------ .../references/example-tech-log-tree.md | 152 +++++++ .../scripts/check_prose.mjs | 59 ++- .../writing-as-the-person-who-did-it/SKILL.md | 117 +++++ .../references/voice-moves.md | 160 +++++++ .../scripts/check_voice.mjs | 115 +++++ .../skills/writing-tech-log-records/SKILL.md | 54 ++- .../references/ai-tells.md | 89 ++++ .../references/explaining.md | 46 +- .../references/from-ssot-to-records.md | 87 +++- .../references/record-kinds.md | 24 + .../references/review-checklist.md | 7 + .../references/root-tree-contract.md | 119 ----- .../references/tech-log-tree-contract.md | 172 ++++++++ .../references/writing-each-kind.md | 43 +- .../scripts/check_evidence.mjs | 139 ++++++ .../templates/case.md | 8 +- .../templates/concept.md | 8 +- .../templates/decision.md | 14 +- .../templates/question.md | 16 +- .../templates/reference.md | 15 +- .../skills/writing-as-the-person-who-did-it | 1 + .gitignore | 2 + CLAUDE.md | 161 +++++-- README.md | 1 + docs/_templates/README.md | 23 +- docs/_templates/final/.techviz/README.txt | 2 + docs/_templates/final/assets/README.txt | 2 + .../final/assets/tech-log-studio/README.txt | 2 + .../final/evidence/browser/README.txt | 1 + .../_templates/final/evidence/meta/README.txt | 1 + .../{ => final}/evidence/meta/evidence.json | 2 +- docs/_templates/final/evidence/raw/README.txt | 2 + .../final/evidence/rendered/README.txt | 2 + docs/_templates/root-tree.md | 85 ---- .../tech-log-studio/tech-log-tree.json | 94 ++++ scripts/build-tech-log-tree.py | 173 +++++--- scripts/fold-analysis-into-final.py | 273 ++++++++++++ scripts/fold-studio-contract-into-index.py | 292 +++++++++++++ scripts/techlog.py | 68 +++ scripts/tests/test_tech_log_tree.py | 384 ++++++++++++++++ scripts/verify-pipeline.py | 157 +++++-- scripts/verify-project-layout.py | 202 +++++++++ scripts/verify-tech-log-tree.py | 342 +++++++++++++++ 54 files changed, 3583 insertions(+), 871 deletions(-) create mode 100644 .agents/skills/analyzing-codebase-for-tech-log/templates/README.txt rename {docs/_templates => .agents/skills/analyzing-codebase-for-tech-log/templates}/analysis/00-project-overview.md (100%) rename {docs/_templates => .agents/skills/analyzing-codebase-for-tech-log/templates}/analysis/module.md (100%) rename {docs/_templates => .agents/skills/analyzing-codebase-for-tech-log/templates}/source-index.md (100%) rename {docs/_templates => .agents/skills/analyzing-codebase-for-tech-log/templates}/state.json (86%) create mode 100644 .agents/skills/deriving-tech-log-root-tree/references/candidate-disposition.md delete mode 100644 .agents/skills/deriving-tech-log-root-tree/references/example-root-tree.md create mode 100644 .agents/skills/deriving-tech-log-root-tree/references/example-tech-log-tree.md create mode 100644 .agents/skills/writing-as-the-person-who-did-it/SKILL.md create mode 100644 .agents/skills/writing-as-the-person-who-did-it/references/voice-moves.md create mode 100644 .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs delete mode 100644 .agents/skills/writing-tech-log-records/references/root-tree-contract.md create mode 100644 .agents/skills/writing-tech-log-records/references/tech-log-tree-contract.md create mode 100755 .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs create mode 120000 .claude/skills/writing-as-the-person-who-did-it create mode 100644 .gitignore create mode 100644 docs/_templates/final/.techviz/README.txt create mode 100644 docs/_templates/final/assets/README.txt create mode 100644 docs/_templates/final/assets/tech-log-studio/README.txt create mode 100644 docs/_templates/final/evidence/browser/README.txt create mode 100644 docs/_templates/final/evidence/meta/README.txt rename docs/_templates/{ => final}/evidence/meta/evidence.json (86%) create mode 100644 docs/_templates/final/evidence/raw/README.txt create mode 100644 docs/_templates/final/evidence/rendered/README.txt delete mode 100644 docs/_templates/root-tree.md create mode 100644 docs/_templates/tech-log-studio/tech-log-tree.json create mode 100755 scripts/fold-analysis-into-final.py create mode 100644 scripts/fold-studio-contract-into-index.py create mode 100644 scripts/techlog.py create mode 100644 scripts/tests/test_tech_log_tree.py create mode 100755 scripts/verify-project-layout.py create mode 100755 scripts/verify-tech-log-tree.py diff --git a/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md b/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md index 55984df..822616d 100644 --- a/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md +++ b/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md @@ -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 ` 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. diff --git a/.agents/skills/analyzing-codebase-for-tech-log/references/queue-contract.md b/.agents/skills/analyzing-codebase-for-tech-log/references/queue-contract.md index d736f4e..bb97a2d 100644 --- a/.agents/skills/analyzing-codebase-for-tech-log/references/queue-contract.md +++ b/.agents/skills/analyzing-codebase-for-tech-log/references/queue-contract.md @@ -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 diff --git a/.agents/skills/analyzing-codebase-for-tech-log/templates/README.txt b/.agents/skills/analyzing-codebase-for-tech-log/templates/README.txt new file mode 100644 index 0000000..ab25dc9 --- /dev/null +++ b/.agents/skills/analyzing-codebase-for-tech-log/templates/README.txt @@ -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/ 둘이다. diff --git a/docs/_templates/analysis/00-project-overview.md b/.agents/skills/analyzing-codebase-for-tech-log/templates/analysis/00-project-overview.md similarity index 100% rename from docs/_templates/analysis/00-project-overview.md rename to .agents/skills/analyzing-codebase-for-tech-log/templates/analysis/00-project-overview.md diff --git a/docs/_templates/analysis/module.md b/.agents/skills/analyzing-codebase-for-tech-log/templates/analysis/module.md similarity index 100% rename from docs/_templates/analysis/module.md rename to .agents/skills/analyzing-codebase-for-tech-log/templates/analysis/module.md diff --git a/docs/_templates/source-index.md b/.agents/skills/analyzing-codebase-for-tech-log/templates/source-index.md similarity index 100% rename from docs/_templates/source-index.md rename to .agents/skills/analyzing-codebase-for-tech-log/templates/source-index.md diff --git a/docs/_templates/state.json b/.agents/skills/analyzing-codebase-for-tech-log/templates/state.json similarity index 86% rename from docs/_templates/state.json rename to .agents/skills/analyzing-codebase-for-tech-log/templates/state.json index 5e882c4..5244a94 100644 --- a/docs/_templates/state.json +++ b/.agents/skills/analyzing-codebase-for-tech-log/templates/state.json @@ -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 + } } diff --git a/.agents/skills/deriving-tech-log-root-tree/SKILL.md b/.agents/skills/deriving-tech-log-root-tree/SKILL.md index 3a7ca7c..5172c28 100644 --- a/.agents/skills/deriving-tech-log-root-tree/SKILL.md +++ b/.agents/skills/deriving-tech-log-root-tree/SKILL.md @@ -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 ` — 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 +``` + +`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. diff --git a/.agents/skills/deriving-tech-log-root-tree/references/candidate-disposition.md b/.agents/skills/deriving-tech-log-root-tree/references/candidate-disposition.md new file mode 100644 index 0000000..75c5aad --- /dev/null +++ b/.agents/skills/deriving-tech-log-root-tree/references/candidate-disposition.md @@ -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 인 이유는 하나다 — 경고로 두면 재판정하지 않은 트리로 글을 쓰기 시작할 수 있다. diff --git a/.agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md b/.agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md index 042e5aa..91481a3 100644 --- a/.agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md +++ b/.agents/skills/deriving-tech-log-root-tree/references/decomposition-checklist.md @@ -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 ` reports 0 errors. diff --git a/.agents/skills/deriving-tech-log-root-tree/references/example-root-tree.md b/.agents/skills/deriving-tech-log-root-tree/references/example-root-tree.md deleted file mode 100644 index ff4b2e6..0000000 --- a/.agents/skills/deriving-tech-log-root-tree/references/example-root-tree.md +++ /dev/null @@ -1,410 +0,0 @@ ---- -schemaVersion: 1 -exampleOnly: true -generationAllowed: false -project: backend-clean-architecture -sourceDocument: final/document.md -sourceDocumentSha256: -sourceRevision: -generatedAt: ---- - -# 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: - - `` -- evidence: - - `` -- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.` -- missing-verification: `example only — 실제 project source/evidence 확인 필요` -- relations: - - `` - -## CASE — 필드 접근 없이 발생한 EAGER ToOne N+1 - -- slug: `eager-to-one-n-plus-one` -- readiness: `BLOCKED` -- source: - - `final/document.md#user-page-연관-숨은-추가-쿼리-정량화` -- code: - - `` -- evidence: - - `` -- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.` -- missing-verification: `example only — 실제 project source/evidence 확인 필요` -- relations: - - `` - -## CASE — Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 - -- slug: `fetch-join-multibag-row-explosion` -- readiness: `BLOCKED` -- source: - - `final/document.md#fetch-join을-적용하며-확인한-두-가지-문제` -- code: - - `` -- evidence: - - `` -- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.` -- missing-verification: `example only — 실제 project source/evidence 확인 필요` -- relations: - - `` - -## CASE — Collection Fetch Join Pagination의 In-memory Paging - -- slug: `collection-fetch-join-in-memory-pagination` -- readiness: `BLOCKED` -- source: - - `final/document.md#컬렉션-fetch-join-페이징` -- code: - - `` -- evidence: - - `` -- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.` -- missing-verification: `example only — 실제 project source/evidence 확인 필요` -- relations: - - `` - -## CASE — Projection 이후에도 1,509행을 읽은 Row Over-fetch - -- slug: `projection-row-over-fetch` -- readiness: `BLOCKED` -- source: - - `final/document.md#dto-프로젝션` -- code: - - `` -- evidence: - - `` -- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.` -- missing-verification: `example only — 실제 project source/evidence 확인 필요` -- relations: - - `` - -## CASE — Visibility OR이 Keyset Index를 깨뜨린 문제 - -- slug: `visibility-or-breaks-keyset-index` -- readiness: `BLOCKED` -- source: - - `final/document.md#가시성-조건` -- code: - - `` -- evidence: - - `` -- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.` -- missing-verification: `example only — 실제 project source/evidence 확인 필요` -- relations: - - `` - -## REFERENCE — JPA N+1 정량 진단 기준 - -- slug: `jpa-n-plus-one-quantitative-diagnosis` -- readiness: `BLOCKED` -- source: - - `final/document.md#컬렉션-n1-정량화` -- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.` -- scope: `` -- exceptions: `` -- relations: - - `` - -## REFERENCE — Fetch Type과 Fetch Strategy 구분 - -- slug: `fetch-type-vs-fetch-strategy` -- readiness: `BLOCKED` -- source: - - `final/document.md#최초-구현과-첫-관찰` -- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.` -- scope: `` -- exceptions: `` -- relations: - - `` - -## REFERENCE — Fetch Join · Batch · Projection 선택 기준 - -- slug: `fetch-join-batch-projection-selection` -- readiness: `BLOCKED` -- source: - - `final/document.md#배치-페치` -- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.` -- scope: `` -- exceptions: `` -- relations: - - `` - -## 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: `` -- exceptions: `` -- relations: - - `` - -## REFERENCE — Keyset Pagination 설계 기준 - -- slug: `keyset-pagination-design` -- readiness: `BLOCKED` -- source: - - `final/document.md#keyset-vs-offset` -- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.` -- scope: `` -- exceptions: `` -- relations: - - `` - -## REFERENCE — Feed Visibility Query Pattern - -- slug: `feed-visibility-query-pattern` -- readiness: `BLOCKED` -- source: - - `final/document.md#가시성-조건` -- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.` -- scope: `` -- exceptions: `` -- relations: - - `` - -## REFERENCE — PostgreSQL Query Plan 측정 기준 - -- slug: `postgresql-query-plan-measurement` -- readiness: `BLOCKED` -- source: - - `final/document.md#측정-환경과-데이터셋` -- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.` -- scope: `` -- exceptions: `` -- relations: - - `` - -## OPEN QUESTION — Highlight 없는 FeedItem을 허용할 것인가 - -- slug: `allow-feed-item-without-highlight` -- readiness: `BLOCKED` -- source: - - `final/document.md#확인된-문제와-이후-검증할-가설` -- known: - - `` -- unknown: - - `` -- next-verification: `` -- decision-criterion: `` -- relations: - - `` - -## OPEN QUESTION — Round Trip과 Row Volume을 독립 측정할 것인가 - -- slug: `measure-round-trip-and-row-volume-separately` -- readiness: `BLOCKED` -- source: - - `final/document.md#측정-환경과-데이터셋` -- known: - - `` -- unknown: - - `` -- next-verification: `` -- decision-criterion: `` -- relations: - - `` - -## OPEN QUESTION — ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가 - -- slug: `cardinality-estimate-after-analyze` -- readiness: `BLOCKED` -- source: - - `final/document.md#postgresql-query-plan-측정` -- known: - - `` -- unknown: - - `` -- next-verification: `` -- decision-criterion: `` -- relations: - - `` - -## OPEN QUESTION — feed_visible을 Production CQRS로 승격할 것인가 - -- slug: `promote-feed-visible-to-production-cqrs` -- readiness: `BLOCKED` -- source: - - `final/document.md#cqrs-lite-읽기-모델` -- known: - - `` -- unknown: - - `` -- next-verification: `` -- decision-criterion: `` -- relations: - - `` - -## OPEN QUESTION — 실제 동시 트래픽에서도 이 구조가 안정적인가 - -- slug: `stability-under-concurrent-traffic` -- readiness: `BLOCKED` -- source: - - `final/document.md#다음-단계` -- known: - - `` -- unknown: - - `` -- next-verification: `` -- decision-criterion: `` -- relations: - - `` - -## DECISION — Query Plan은 실제 PostgreSQL에서 측정한다 - -- slug: `measure-query-plan-on-postgresql` -- readiness: `NEEDS_DECISION` -- decision-status: `NOT_DECIDED` -- source: - - `final/document.md#측정-환경과-데이터셋` -- decision-evidence: - - `` -- grounds: - - `` -- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.` -- relations: - - `` - -## DECISION — Query Strategy는 FeedQueryPort 뒤에서 소유한다 - -- slug: `query-strategy-behind-feed-query-port` -- readiness: `NEEDS_DECISION` -- decision-status: `NOT_DECIDED` -- source: - - `final/document.md#조회-전략은-포트-뒤-어댑터의-책임` -- decision-evidence: - - `` -- grounds: - - `` -- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.` -- relations: - - `` - -## 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: - - `` -- grounds: - - `` -- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.` -- relations: - - `` - -## DECISION — Entity Graph 조회에는 Batch Fetch를 사용한다 - -- slug: `batch-fetch-for-entity-graph` -- readiness: `NEEDS_DECISION` -- decision-status: `NOT_DECIDED` -- source: - - `final/document.md#배치-페치` -- decision-evidence: - - `` -- grounds: - - `` -- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.` -- relations: - - `` - -## DECISION — 화면 조회는 Read Projection을 사용한다 - -- slug: `read-projection-for-screen-query` -- readiness: `NEEDS_DECISION` -- decision-status: `NOT_DECIDED` -- source: - - `final/document.md#dto-프로젝션` -- decision-evidence: - - `` -- grounds: - - `` -- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.` -- relations: - - `` - -## 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: - - `` -- grounds: - - `` -- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.` -- relations: - - `` - -## 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: - - `` -- grounds: - - `` -- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.` -- relations: - - `` - diff --git a/.agents/skills/deriving-tech-log-root-tree/references/example-tech-log-tree.md b/.agents/skills/deriving-tech-log-root-tree/references/example-tech-log-tree.md new file mode 100644 index 0000000..2aabca1 --- /dev/null +++ b/.agents/skills/deriving-tech-log-root-tree/references/example-tech-log-tree.md @@ -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": "", + "sourceRevision": "", + "generatedAt": "", + "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` 이 있다. 무엇이 나오면 닫는지를 적지 않으면 검증을 마쳐도 열려 있다. +- 다섯 종류를 억지로 채우지 않아도 된다. 여기서 다섯이 다 있는 것은 실제로 다섯이 있었기 때문이다. diff --git a/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs b/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs index 652638e..662050b 100644 --- a/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs +++ b/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs @@ -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+\.|:::|` 와 `` 사이다. 그 밖은 Studio 로 가지 않는다 ## 관계를 어디서 가져오나 -관계는 **다른 기록을 가리키는 링크**다. 지어내지 않는다. 분해 계약(`root-tree.md`)이 노드마다 +관계는 **다른 기록을 가리키는 링크**다. 지어내지 않는다. 분해 계약(`tech-log-tree.json`)이 노드마다 `relations` 를 적어 두면 그것을 그대로 옮긴다. 계약이 관계를 적지 않은 노드는 한 가지 규칙만 쓸 수 있다 — **Reference 의 근거 사건은 같은 diff --git a/.agents/skills/writing-tech-log-records/scripts/check_evidence.mjs b/.agents/skills/writing-tech-log-records/scripts/check_evidence.mjs new file mode 100755 index 0000000..fd4b5f2 --- /dev/null +++ b/.agents/skills/writing-tech-log-records/scripts/check_evidence.mjs @@ -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(""); + 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); diff --git a/.agents/skills/writing-tech-log-records/templates/case.md b/.agents/skills/writing-tech-log-records/templates/case.md index 82562fc..3b0b2f3 100644 --- a/.agents/skills/writing-tech-log-records/templates/case.md +++ b/.agents/skills/writing-tech-log-records/templates/case.md @@ -3,11 +3,15 @@ id: kind: CASE slug: title: <제목> -topic: <주제 이름> +topic: +topicName: <화면에 보이는 주제 이름> project: <프로젝트 이름> status: 게시 전 studio: "<편집 화면 주소. 아직 없으면 빈 값>" lastVerifiedOn: <실제로 확인한 날 또는 빈 값> +source: + - final/document.md# +sourceRevision: <분석한 리비전> assets: - key: <본문의 :::evidence key 와 같은 값> file: <../../../final/assets/… 상대 경로> @@ -44,6 +48,6 @@ evidence: - + diff --git a/.agents/skills/writing-tech-log-records/templates/concept.md b/.agents/skills/writing-tech-log-records/templates/concept.md index e388d66..d046176 100644 --- a/.agents/skills/writing-tech-log-records/templates/concept.md +++ b/.agents/skills/writing-tech-log-records/templates/concept.md @@ -3,11 +3,15 @@ id: kind: CONCEPT slug: title: <제목> -topic: <주제 이름> +topic: +topicName: <화면에 보이는 주제 이름> project: <프로젝트 이름> status: 게시 전 studio: "<편집 화면 주소. 아직 없으면 빈 값>" basisVersion: <무엇을 보고 썼는지. 예 Keycloak 26.7.0 · oidc-client-ts 3.3.0> +source: + - final/document.md# +sourceRevision: <분석한 리비전> assets: - key: <본문의 :::evidence key 와 같은 값> file: <../../../final/assets/… 상대 경로> @@ -34,6 +38,8 @@ evidence: ## <단계마다 실제로 일어나는 일> +<설명할 단계가 몇 개인지가 절의 개수를 정한다. 미리 정해 둔 수에 맞추지 않는다> + ## <그 설계가 막지 않는 것> ## <지금 확인한 범위> diff --git a/.agents/skills/writing-tech-log-records/templates/decision.md b/.agents/skills/writing-tech-log-records/templates/decision.md index 4ab6197..7e4f55b 100644 --- a/.agents/skills/writing-tech-log-records/templates/decision.md +++ b/.agents/skills/writing-tech-log-records/templates/decision.md @@ -3,18 +3,24 @@ id: kind: PROJECT_DECISION slug: title: <제목> -topic: <주제 이름> +topic: +topicName: <화면에 보이는 주제 이름> project: <프로젝트 이름> status: 게시 전 studio: "<편집 화면 주소. 아직 없으면 빈 값>" decisionStatus: PROPOSED -assets: - - key: <본문의 :::evidence key 와 같은 값> - file: <../../../final/assets/… 상대 경로> +source: + - final/document.md# +sourceRevision: <분석한 리비전> evidence: - <../../../final/evidence/raw/… 상대 경로> --- + + # <summary> diff --git a/.agents/skills/writing-tech-log-records/templates/question.md b/.agents/skills/writing-tech-log-records/templates/question.md index 2d5de8b..dbee19c 100644 --- a/.agents/skills/writing-tech-log-records/templates/question.md +++ b/.agents/skills/writing-tech-log-records/templates/question.md @@ -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 으로 넘기는가> diff --git a/.agents/skills/writing-tech-log-records/templates/reference.md b/.agents/skills/writing-tech-log-records/templates/reference.md index 0ba8b34..c306989 100644 --- a/.agents/skills/writing-tech-log-records/templates/reference.md +++ b/.agents/skills/writing-tech-log-records/templates/reference.md @@ -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> diff --git a/.claude/skills/writing-as-the-person-who-did-it b/.claude/skills/writing-as-the-person-who-did-it new file mode 120000 index 0000000..f66baca --- /dev/null +++ b/.claude/skills/writing-as-the-person-who-did-it @@ -0,0 +1 @@ +../../.agents/skills/writing-as-the-person-who-did-it \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md index 2af180c..2a42143 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 가 남아 있으면 덜 된 글이다. 칸 하나나 한 절만 고쳤으면 diff --git a/README.md b/README.md index 196fcc2..c5415a1 100644 --- a/README.md +++ b/README.md @@ -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/`는 위 폴더를 가리키는 상대 경로 심링크입니다. diff --git a/docs/_templates/README.md b/docs/_templates/README.md index 13eb96f..bda6f44 100644 --- a/docs/_templates/README.md +++ b/docs/_templates/README.md @@ -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` 이 정본이다. 큰 저장소에서는 한 번에 한 +모듈·서브시스템만 본다. diff --git a/docs/_templates/final/.techviz/README.txt b/docs/_templates/final/.techviz/README.txt new file mode 100644 index 0000000..c646efe --- /dev/null +++ b/docs/_templates/final/.techviz/README.txt @@ -0,0 +1,2 @@ +그림의 정본 — <이름>/{context.json, spec.json, prompt.md}. +technical-visualizer 스킬이 만든다. 손으로 SVG 를 그리지 않는다. diff --git a/docs/_templates/final/assets/README.txt b/docs/_templates/final/assets/README.txt new file mode 100644 index 0000000..ad8cc41 --- /dev/null +++ b/docs/_templates/final/assets/README.txt @@ -0,0 +1,2 @@ +그림. 그림 하나가 폴더 하나다 — <이름>/<이름>.svg 와 편집 형식들. +Studio 에 올릴 표현물은 tech-log-studio/ 아래에 flat SVG 로 둔다. diff --git a/docs/_templates/final/assets/tech-log-studio/README.txt b/docs/_templates/final/assets/tech-log-studio/README.txt new file mode 100644 index 0000000..a6de0be --- /dev/null +++ b/docs/_templates/final/assets/tech-log-studio/README.txt @@ -0,0 +1,2 @@ +Studio 에 올릴 표현물. 기록 frontmatter 의 `assets: file:` 이 가리키는 자리다. +그림의 정본은 ../<이름>/ 과 ../../.techviz/<이름>/ 에 있다. diff --git a/docs/_templates/final/evidence/browser/README.txt b/docs/_templates/final/evidence/browser/README.txt new file mode 100644 index 0000000..59f33a7 --- /dev/null +++ b/docs/_templates/final/evidence/browser/README.txt @@ -0,0 +1 @@ +Playwright MCP 로 찍은 브라우저 캡처. 무엇을 찍었는지 한 줄을 적는다. diff --git a/docs/_templates/final/evidence/meta/README.txt b/docs/_templates/final/evidence/meta/README.txt new file mode 100644 index 0000000..d643eae --- /dev/null +++ b/docs/_templates/final/evidence/meta/README.txt @@ -0,0 +1 @@ +그 실행의 command·cwd·executedAt·exitCode·revision. 형식은 evidence.json. diff --git a/docs/_templates/evidence/meta/evidence.json b/docs/_templates/final/evidence/meta/evidence.json similarity index 86% rename from docs/_templates/evidence/meta/evidence.json rename to docs/_templates/final/evidence/meta/evidence.json index 9a0d7b1..ffe6c4e 100644 --- a/docs/_templates/evidence/meta/evidence.json +++ b/docs/_templates/final/evidence/meta/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>" } diff --git a/docs/_templates/final/evidence/raw/README.txt b/docs/_templates/final/evidence/raw/README.txt new file mode 100644 index 0000000..21d79e5 --- /dev/null +++ b/docs/_templates/final/evidence/raw/README.txt @@ -0,0 +1,2 @@ +명령 출력·csv·덤프 원문. 여기가 정본이다. +하위 폴더를 자유롭게 둔다(explain/, guards/). 폴더마다 무엇을 담았는지 한 줄을 README.txt 에 적는다. diff --git a/docs/_templates/final/evidence/rendered/README.txt b/docs/_templates/final/evidence/rendered/README.txt new file mode 100644 index 0000000..6f974cb --- /dev/null +++ b/docs/_templates/final/evidence/rendered/README.txt @@ -0,0 +1,2 @@ +raw 에서 만든 터미널 SVG. 표현물이지 정본이 아니다. +scripts/terminal-evidence/render_terminal.py 로 만든다. diff --git a/docs/_templates/root-tree.md b/docs/_templates/root-tree.md deleted file mode 100644 index 98375a8..0000000 --- a/docs/_templates/root-tree.md +++ /dev/null @@ -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>` diff --git a/docs/_templates/tech-log-studio/tech-log-tree.json b/docs/_templates/tech-log-studio/tech-log-tree.json new file mode 100644 index 0000000..7cb4e7c --- /dev/null +++ b/docs/_templates/tech-log-studio/tech-log-tree.json @@ -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": {} +} diff --git a/scripts/build-tech-log-tree.py b/scripts/build-tech-log-tree.py index 029e374..eb27908 100755 --- a/scripts/build-tech-log-tree.py +++ b/scripts/build-tech-log-tree.py @@ -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__": diff --git a/scripts/fold-analysis-into-final.py b/scripts/fold-analysis-into-final.py new file mode 100755 index 0000000..d9e66a3 --- /dev/null +++ b/scripts/fold-analysis-into-final.py @@ -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()) diff --git a/scripts/fold-studio-contract-into-index.py b/scripts/fold-studio-contract-into-index.py new file mode 100644 index 0000000..adfe382 --- /dev/null +++ b/scripts/fold-studio-contract-into-index.py @@ -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()) diff --git a/scripts/techlog.py b/scripts/techlog.py new file mode 100644 index 0000000..c014352 --- /dev/null +++ b/scripts/techlog.py @@ -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()) diff --git a/scripts/tests/test_tech_log_tree.py b/scripts/tests/test_tech_log_tree.py new file mode 100644 index 0000000..8ad4d65 --- /dev/null +++ b/scripts/tests/test_tech_log_tree.py @@ -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() diff --git a/scripts/verify-pipeline.py b/scripts/verify-pipeline.py index 8d118f3..5858a23 100755 --- a/scripts/verify-pipeline.py +++ b/scripts/verify-pipeline.py @@ -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__": diff --git a/scripts/verify-project-layout.py b/scripts/verify-project-layout.py new file mode 100755 index 0000000..1434069 --- /dev/null +++ b/scripts/verify-project-layout.py @@ -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()) diff --git a/scripts/verify-tech-log-tree.py b/scripts/verify-tech-log-tree.py new file mode 100755 index 0000000..eb56994 --- /dev/null +++ b/scripts/verify-tech-log-tree.py @@ -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())