Merge branch 'harness/B-implementation' into harness/A-integration
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: analyzing-codebase-for-tech-log
|
name: analyzing-codebase-for-tech-log
|
||||||
description: Use when a project under <분석 대상 저장소> must be deeply analyzed and documented under docs, especially when the repository is too large for one pass and analysis must proceed by bounded module or subsystem.
|
description: Use when a project under <분석 대상 저장소> must be deeply analyzed and documented under docs, especially when the repository is too large for one pass and analysis must proceed by bounded module or subsystem.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
language: "ko-KR"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Analyzing Codebase For Tech Log
|
# Analyzing Codebase For Tech Log
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: deriving-tech-log-root-tree
|
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, Concept, 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.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
language: "ko-KR"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Deriving Tech Log Root Tree
|
# Deriving Tech Log Root Tree
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: publishing-tech-log-to-studio
|
name: publishing-tech-log-to-studio
|
||||||
description: Use when a finished Tech Log record .md must be put into Tech Log Studio through the browser with Playwright MCP — creating or opening the working copy, uploading assets, filling the per-kind fields, and saving. Save only; this skill never publishes.
|
description: Use when a finished Tech Log record .md must be put into Tech Log Studio through the browser with Playwright MCP — creating or opening the working copy, uploading assets, filling the per-kind fields, and saving. Save only; this skill never publishes.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
language: "ko-KR"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Studio 반입 — 저장까지만
|
# Studio 반입 — 저장까지만
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: refactoring-from-analysis
|
name: refactoring-from-analysis
|
||||||
description: Use when a completed codebase analysis should be turned into one bounded, evidence-backed refactoring WorkItem and implemented in isolation.
|
description: Use when a completed codebase analysis should be turned into one bounded, evidence-backed refactoring WorkItem and implemented in isolation.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
language: "ko-KR"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Refactoring From Analysis
|
# Refactoring From Analysis
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: rewriting-technical-prose-naturally
|
name: rewriting-technical-prose-naturally
|
||||||
description: Use when an existing Korean technical document, section, or heading already contains grounded facts but sounds AI-written, slogan-like, overly polished, abstract, compressed, or mechanically contrasted and must be rewritten without changing its technical meaning.
|
description: Use when an existing Korean technical document, section, or heading already contains grounded facts but sounds AI-written, slogan-like, overly polished, abstract, compressed, or mechanically contrasted and must be rewritten without changing its technical meaning.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
language: "ko-KR"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Rewriting Technical Prose Naturally
|
# Rewriting Technical Prose Naturally
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: running-tech-log-pipeline
|
name: running-tech-log-pipeline
|
||||||
description: Use when a codebase must go all the way to a saved Tech Log Studio draft — running the seven stages (SSOT, tree, record, diagram, prose, voice, Studio save) as separate subagents, one skill per stage, with a run ledger that records which skill each stage actually used and which gate it passed.
|
description: Use when a codebase must go all the way to a saved Tech Log Studio draft — running the seven stages (SSOT, tree, record, diagram, prose, voice, Studio save) as separate subagents, one skill per stage, with a run ledger that records which skill each stage actually used and which gate it passed.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
language: "ko-KR"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Tech Log 파이프라인 실행
|
# Tech Log 파이프라인 실행
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: technical-visualizer
|
name: technical-visualizer
|
||||||
description: Create source-grounded, diagram-only technical visuals from nearby documentation context. Select a logical composition grammar, compile VizSpec 1.1 into SVG and editable formats, and reject disconnected-card output.
|
description: Create source-grounded, diagram-only technical visuals from nearby documentation context. Select a logical composition grammar, compile VizSpec 1.1 into SVG and editable formats, and reject disconnected-card output.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
language: "ko-KR"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Technical Visualizer
|
# Technical Visualizer
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: writing-as-the-person-who-did-it
|
name: writing-as-the-person-who-did-it
|
||||||
description: Use when a Korean technical document is accurate, well-ordered and well-evidenced but reads like a report produced by nobody — no one chose anything, nothing surprised anyone, and the limits are an inventory instead of an admission.
|
description: Use when a Korean technical document is accurate, well-ordered and well-evidenced but reads like a report produced by nobody — no one chose anything, nothing surprised anyone, and the limits are an inventory instead of an admission.
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
language: "ko-KR"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 일한 사람이 쓴 글로 만들기
|
# 일한 사람이 쓴 글로 만들기
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
그림의 정본 — <이름>/{context.json, spec.json, prompt.md}.
|
||||||
|
technical-visualizer 스킬이 만든다. 손으로 SVG 를 그리지 않는다.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
그림. 그림 하나가 폴더 하나다 — <이름>/<이름>.svg 와 편집 형식들.
|
||||||
|
기록의 assets: file: 도 이 폴더를 가리킨다. Studio 에 올릴 사본을 따로 두지 않는다 —
|
||||||
|
사본을 두면 정본이 둘이 되고, 사본 쪽에는 ../../.techviz/<이름>/ 이 없어 다시 만들 수 없다.
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# document-haness 의 검사 층 — 관문이 무엇을 보고 무엇을 안 보는가
|
||||||
|
|
||||||
|
이 문서는 `document-haness` 저장소 자신의 검사 층을 대상으로 삼는다. 이 저장소는 기술 글을
|
||||||
|
만드는 파이프라인이고, 그 파이프라인이 글을 통과시킬지 말지는 `scripts/` 의 검사기와
|
||||||
|
스킬이 들고 있는 `.mjs` 검사기가 정한다. **그 검사기들이 실제로 무엇을 보는지**를 실행으로
|
||||||
|
확인한 기록이다.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 대상 저장소 | `document-haness` |
|
||||||
|
| 리비전 | `43e1aadef077ad93c30495df428ee3a71dd73f4a` (`main`) |
|
||||||
|
| 확인한 worktree | `dh-B` (`harness/B-implementation`, 같은 커밋에서 갈라짐) |
|
||||||
|
| 확인한 날 | 2026-09-10 |
|
||||||
|
| 실행 환경 | python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 검사기 열넷이 있는 자리
|
||||||
|
|
||||||
|
`scripts/` 아래에 `.py` 파일 열넷이 있다. 리비전 `43e1aad` 에서 센 값이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
audit-records.py
|
||||||
|
build-tech-log-tree.py
|
||||||
|
check-figure-overlap.py
|
||||||
|
check-figure-text.py
|
||||||
|
fold-analysis-into-final.py
|
||||||
|
fold-studio-contract-into-index.py
|
||||||
|
preview-figure.py
|
||||||
|
studio-body.py
|
||||||
|
techlog.py
|
||||||
|
verify-pipeline-run.py
|
||||||
|
verify-pipeline.py
|
||||||
|
verify-project-layout.py
|
||||||
|
verify-refactor-work-item.py
|
||||||
|
verify-tech-log-tree.py
|
||||||
|
```
|
||||||
|
|
||||||
|
이 중 `techlog.py` 는 CLI 가 아니라 공유 라이브러리다. `fold-*.py` 둘은 검사기가 아니라
|
||||||
|
작업 재료를 옮기는 도구다. 나머지 열하나가 관문으로 불린다.
|
||||||
|
|
||||||
|
관문은 **종료 코드로 말한다.** `running-tech-log-pipeline` 의 단계 계약이
|
||||||
|
「관문은 종료 코드가 0 이어야 지난 것이다. 0 이 아니면 그 단계는 `FAILED` 이고 다음 단계로
|
||||||
|
넘어가지 않는다」 라고 적었고, 런 원장 `runs/<프로젝트>/<runId>/run.json` 의
|
||||||
|
`stages[].gates[].exit` 에 그 값이 남는다.
|
||||||
|
|
||||||
|
## §2 관찰한 것
|
||||||
|
|
||||||
|
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸다. 한 줄짜리 셸 반복문이었고, 출력이 길어서
|
||||||
|
`head` 로 잘랐다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
열넷이 전부 `exit=0` 이었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
audit-records.py exit=0
|
||||||
|
build-tech-log-tree.py exit=0
|
||||||
|
check-figure-overlap.py exit=0
|
||||||
|
check-figure-text.py exit=0
|
||||||
|
fold-analysis-into-final.py exit=0
|
||||||
|
fold-studio-contract-into-index.py exit=0
|
||||||
|
preview-figure.py exit=0
|
||||||
|
studio-body.py exit=0
|
||||||
|
techlog.py exit=0
|
||||||
|
verify-pipeline-run.py exit=0
|
||||||
|
verify-pipeline.py exit=0
|
||||||
|
verify-project-layout.py exit=0
|
||||||
|
verify-refactor-work-item.py exit=0
|
||||||
|
verify-tech-log-tree.py exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help` 가
|
||||||
|
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
|
||||||
|
맞는 값처럼 보였다.
|
||||||
|
|
||||||
|
## §3 파이프 뒤의 종료 코드
|
||||||
|
|
||||||
|
틀린 것은 검사기가 아니라 재는 방법이었다. 파이프라인의 `$?` 는 **마지막** 명령의 종료
|
||||||
|
코드다. `head` 는 언제나 성공하므로 앞의 `python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +o pipefail
|
||||||
|
false | head -1; echo "false | head -1 -> exit=$?"
|
||||||
|
false; echo "false -> exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
false | head -1 -> exit=0
|
||||||
|
false -> exit=1
|
||||||
|
bash 5.2.21(1)-release
|
||||||
|
```
|
||||||
|
|
||||||
|
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
|
||||||
|
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
|
||||||
|
|
||||||
|
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
echo "$(basename $s) exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어썼다. 그래서 두 번째 측정도
|
||||||
|
열넷 전부 0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
|
||||||
|
|
||||||
|
## §4 다시 잰 값
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
audit-records.py exit=0
|
||||||
|
build-tech-log-tree.py exit=1
|
||||||
|
check-figure-overlap.py exit=0
|
||||||
|
check-figure-text.py exit=0
|
||||||
|
fold-analysis-into-final.py exit=0
|
||||||
|
fold-studio-contract-into-index.py exit=0
|
||||||
|
preview-figure.py exit=0
|
||||||
|
studio-body.py exit=0
|
||||||
|
techlog.py exit=0
|
||||||
|
verify-pipeline-run.py exit=0
|
||||||
|
verify-pipeline.py exit=0
|
||||||
|
verify-project-layout.py exit=0
|
||||||
|
verify-refactor-work-item.py exit=1
|
||||||
|
verify-tech-log-tree.py exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
두 측정의 차이는 두 줄뿐이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
2c2
|
||||||
|
< build-tech-log-tree.py exit=0
|
||||||
|
---
|
||||||
|
> build-tech-log-tree.py exit=1
|
||||||
|
13c13
|
||||||
|
< verify-refactor-work-item.py exit=0
|
||||||
|
---
|
||||||
|
> verify-refactor-work-item.py exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## §5 argparse 를 쓰지 않는 넷
|
||||||
|
|
||||||
|
열넷 중 넷이 `argparse` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
```
|
||||||
|
ARGPARSE 없음 audit-records.py
|
||||||
|
ARGPARSE 없음 build-tech-log-tree.py
|
||||||
|
argparse check-figure-overlap.py
|
||||||
|
argparse check-figure-text.py
|
||||||
|
argparse fold-analysis-into-final.py
|
||||||
|
argparse fold-studio-contract-into-index.py
|
||||||
|
argparse preview-figure.py
|
||||||
|
argparse studio-body.py
|
||||||
|
ARGPARSE 없음 techlog.py
|
||||||
|
argparse verify-pipeline-run.py
|
||||||
|
argparse verify-pipeline.py
|
||||||
|
argparse verify-project-layout.py
|
||||||
|
ARGPARSE 없음 verify-refactor-work-item.py
|
||||||
|
argparse verify-tech-log-tree.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`techlog.py` 는 CLI 가 아니라서 인자를 안 읽는다. 남은 셋은 `--help` 를 **옵션이 아니라
|
||||||
|
위치 인자로 먹는다.** `audit-records.py` 는 그것을 프로젝트 이름으로 받아 「문제 없음」을
|
||||||
|
찍고, `build-tech-log-tree.py` 와 `verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아
|
||||||
|
실패한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python3 scripts/audit-records.py --help
|
||||||
|
|
||||||
|
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
|
||||||
|
문제 없음
|
||||||
|
|
||||||
|
합계 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/build-tech-log-tree.py --help
|
||||||
|
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
|
||||||
|
exit=1
|
||||||
|
|
||||||
|
$ python3 scripts/verify-refactor-work-item.py --help
|
||||||
|
REFACTOR WORK ITEM VERIFICATION: FAIL
|
||||||
|
- invalid work-item.json: --help/work-item.json
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--help` 가 exit 0 이라는 것이 usage 가 나왔다는 뜻은 아니다.** `audit-records.py` 는
|
||||||
|
`--help` 라는 이름의 프로젝트를 검사해서 통과시킨 것이다.
|
||||||
|
|
||||||
|
## §6 실재하지 않는 프로젝트 이름
|
||||||
|
|
||||||
|
`audit-records.py --help` 가 「문제 없음」을 찍은 것은 `--help` 가 특별해서가 아니다.
|
||||||
|
**없는 프로젝트를 검사하면 검사할 것이 없어 통과가 된다.** 같은 이름
|
||||||
|
`nonexistent-project` 를 여섯 관문에 주면 이렇게 갈린다.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python3 scripts/audit-records.py nonexistent-project
|
||||||
|
nonexistent-project — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
|
||||||
|
문제 없음
|
||||||
|
합계 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/verify-tech-log-tree.py nonexistent-project
|
||||||
|
[nonexistent-project] records=0
|
||||||
|
! warn 1 분해 계약 없음
|
||||||
|
· nonexistent-project: tech-log-tree.json 이 없다. 디렉터리가 정본 노릇을 하고 있다
|
||||||
|
TECH LOG TREE: PASS — 프로젝트 1 · error 0 · warn 1
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/check-figure-text.py nonexistent-project
|
||||||
|
FIGURE TEXT: PASS — 그림 0장 · 문장 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/check-figure-overlap.py nonexistent-project
|
||||||
|
볼 그림이 없다
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/verify-project-layout.py nonexistent-project
|
||||||
|
PROJECT LAYOUT: FAIL — 프로젝트 1 · error 1 · warn 0
|
||||||
|
[nonexistent-project] —
|
||||||
|
✗ error 1 final/document.md 가 없다
|
||||||
|
· nonexistent-project
|
||||||
|
exit=1
|
||||||
|
|
||||||
|
$ node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs nonexistent-project --repo
|
||||||
|
nonexistent-project: tech-log-tree.json 이 없다
|
||||||
|
exit=2
|
||||||
|
```
|
||||||
|
|
||||||
|
여섯 중 넷이 아무것도 검사하지 않고 통과를 보고한다. 잡아내는 것은
|
||||||
|
`verify-project-layout.py`(`final/document.md` 를 먼저 찾는다)와
|
||||||
|
`check_evidence.mjs`(`tech-log-tree.json` 을 먼저 찾는다) 둘뿐이다.
|
||||||
|
|
||||||
|
## §7 원장은 종료 코드를 다시 재지 않는다
|
||||||
|
|
||||||
|
런 원장 검사기 `scripts/verify-pipeline-run.py` 가 관문에 대해 보는 것은 두 가지다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
cmds = " ; ".join(str(g.get("cmd") or "") for g in gates)
|
||||||
|
for token in spec["gates"]:
|
||||||
|
if token not in cmds:
|
||||||
|
...
|
||||||
|
for g in gates:
|
||||||
|
if g.get("exit") not in (0, "0"):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
관문 명령을 **다시 실행하지 않는다.** `exit` 는 그 런이 스스로 적어 넣은 숫자다. 그리고
|
||||||
|
`cmd` 안에 토큰 문자열(`"check-figure-text.py"`)이 있는지만 보고 **어느 프로젝트에 돌렸는지는
|
||||||
|
보지 않는다.**
|
||||||
|
|
||||||
|
§6 과 이어 붙이면 이렇게 된다. 원장에
|
||||||
|
`{"cmd": "python3 scripts/check-figure-text.py <오타난 이름>", "exit": 0}` 을 적으면 그 명령은
|
||||||
|
실제로도 exit 0 이고, 원장 검사기도 통과시킨다. 그림을 한 장도 검사하지 않은 런이 「절차를
|
||||||
|
지켰다」로 판정된다.
|
||||||
|
|
||||||
|
실제 원장에도 명령이 자리표시자가 든 요약형으로 적혀 있어 그대로 재실행할 수 없다.
|
||||||
|
|
||||||
|
```
|
||||||
|
"cmd": "python3 scripts/studio-body.py <기록.md> -o /tmp/sb-<slug>.md (44건)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## §8 종료 코드를 손으로 적을 수 없게 만든다
|
||||||
|
|
||||||
|
관문 결과를 사람이 옮겨 적는 한 §2 와 §7 은 같은 뿌리에서 계속 난다. 그래서 명령을 돌리는
|
||||||
|
자리와 종료 코드를 적는 자리를 하나로 붙였다. `scripts/capture-evidence.py` 는 명령을
|
||||||
|
`subprocess` 로 직접 실행하고 **그 프로세스의 반환값을 그대로** 메타에 적는다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
proc = subprocess.run(command, cwd=cwd, capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
exit_code, out = proc.returncode, proc.stdout + proc.stderr
|
||||||
|
```
|
||||||
|
|
||||||
|
셸을 거치지 않으므로 파이프가 낄 자리가 없고, 종료 코드를 인자로 받지 않으므로 손으로 적을
|
||||||
|
경로도 없다. 원문은 `final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/` 에 같은
|
||||||
|
이름으로 함께 떨어진다.
|
||||||
|
|
||||||
|
실제로 떨어진 메타 하나다. `§4` 의 측정을 담은 것이다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "help-exit-codes-measured-without-a-pipe",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:41+09:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
|
||||||
|
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
|
||||||
|
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
|
||||||
|
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
|
||||||
|
"bytes": 410
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
여기서 `exitCode: 0` 은 **측정 반복문 자체가 성공했다**는 뜻이다. 열넷 각각의 종료 코드는
|
||||||
|
원문 `raw/` 안에 있다. 반복문의 종료 코드와 그 안에서 잰 값은 다른 것이고, 메타는 앞의 것을
|
||||||
|
적는다.
|
||||||
|
|
||||||
|
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지다. 있으면
|
||||||
|
`sourceRevision` 이 그 출력을 설명하지 못한다.
|
||||||
|
|
||||||
|
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
|
||||||
|
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
|
||||||
|
`--` 앞뒤를 직접 가르도록 고쳤고 그 이유를 코드에 한 줄로 남겼다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
```
|
||||||
|
|
||||||
|
§5 에서 `--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다.
|
||||||
|
|
||||||
|
셸 한 줄을 감싸는 래퍼로 만들 수도 있었다. 그러면 파이프를 다시 쓸 수 있게 되고, 값을 두 번
|
||||||
|
틀리게 만든 것이 그 셸이라 아예 거치지 않기로 했다. 감수한 것은 셸 문법이 필요한 명령을
|
||||||
|
`bash -c` 로 넘겨야 한다는 점이다 — 그 안에서 다시 파이프를 쓰면 같은 실수가 난다.
|
||||||
|
|
||||||
|
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 유효하고 이 도구는
|
||||||
|
선택적으로 부른다.
|
||||||
|
|
||||||
|
## §9 확인하지 못한 것
|
||||||
|
|
||||||
|
- **셸을 쓰는 다른 자리.** `--help` 하나만 쟀다. 다른 인자에서 같은 모양인지는 안 봤다.
|
||||||
|
- **`argparse` 를 쓰는 열이 모든 인자를 옳게 다루는지.** `--help` 만 통과한 것을 확인했다.
|
||||||
|
- **`nonexistent-project` 넷이 실재하는 프로젝트에서도 통과만 내는지.** 아니다 —
|
||||||
|
`check-figure-text.py ca-tmpl` 은 exit 1 을 낸다. §6 은 「대상이 없을 때」의 동작이다.
|
||||||
|
- **`capture-evidence.py` 가 이 저장소의 모든 증거 수집을 대체할 수 있는지.** 브라우저 캡처와
|
||||||
|
대화형 명령은 이 경로로 못 담는다.
|
||||||
|
- **원장 검사기 수정의 영향.** §7 을 고치는 것은 이 저장소의 다른 담당 몫이고 여기서는
|
||||||
|
현상만 적는다.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Playwright MCP 로 찍은 브라우저 캡처. 무엇을 찍었는지 한 줄을 적는다.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
그 실행의 command·cwd·executedAt·exitCode·revision. 형식은 evidence.json.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "argparse-absent-scripts",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:57+09:00",
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\\.py$'); do if grep -q '^import argparse' $s; then echo \"argparse $(basename $s)\"; else echo \"ARGPARSE 없음 $(basename $s)\"; fi; done",
|
||||||
|
"cwd": ".",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/argparse-absent-scripts.txt",
|
||||||
|
"presentationPath": null,
|
||||||
|
"proves": "14개 중 넷이 argparse 를 쓰지 않는다",
|
||||||
|
"doesNotProve": "argparse 를 쓰는 열이 모든 인자를 옳게 다룬다는 것",
|
||||||
|
"sha256": "2412e5266e3889012f6a0c9732234dbfcc1a611c3368c1851d61b6c40307ff67",
|
||||||
|
"bytes": 496
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "exit-code-through-a-pipe",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:19+09:00",
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": "bash -c set +o pipefail; false | head -1; echo \"false | head -1 -> exit=$?\"; false; echo \"false -> exit=$?\"; echo \"bash $BASH_VERSION\"",
|
||||||
|
"cwd": ".",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/exit-code-through-a-pipe.txt",
|
||||||
|
"presentationPath": null,
|
||||||
|
"proves": "파이프의 마지막 명령이 성공하면 $? 가 0 이다. 앞 명령의 종료 코드는 사라진다",
|
||||||
|
"doesNotProve": "이 셸이 특별하다는 것. POSIX 셸의 정의된 동작이다",
|
||||||
|
"sha256": "b17529b23ee69c887fbf28a474a7c566c9d9d3ef2e5b0d1c115e90649d60c36f",
|
||||||
|
"bytes": 77
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "gates-on-a-nonexistent-project",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:57:21+09:00",
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for cmd in 'python3 scripts/audit-records.py nonexistent-project' 'python3 scripts/verify-tech-log-tree.py nonexistent-project' 'python3 scripts/check-figure-text.py nonexistent-project' 'python3 scripts/check-figure-overlap.py nonexistent-project' 'python3 scripts/verify-project-layout.py nonexistent-project' 'node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs nonexistent-project --repo'; do out=$(timeout 120 sh -c \"$cmd\" 2>&1); code=$?; echo \"\\$ $cmd\"; echo \"$out\" | grep -v '^$' | head -4; echo \"exit=$code\"; echo; done",
|
||||||
|
"cwd": ".",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/gates-on-a-nonexistent-project.txt",
|
||||||
|
"presentationPath": null,
|
||||||
|
"proves": "실재하지 않는 프로젝트 이름에 검사기 여섯 중 넷이 exit 0 을 낸다",
|
||||||
|
"doesNotProve": "그 넷이 실재하는 프로젝트에서도 통과만 낸다는 것. check-figure-text.py ca-tmpl 은 exit 1 이다",
|
||||||
|
"sha256": "3afad37da2e49290c5cf75a6970562b649fd5ddab6e54870ba9d45dd4c80ab8f",
|
||||||
|
"bytes": 1080
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "help-exit-codes-measured-through-a-pipe",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:41+09:00",
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\\.py$'); do timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null; code=$?; n=$(basename $s); echo \"$n exit=$code\"; done",
|
||||||
|
"cwd": ".",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/help-exit-codes-measured-through-a-pipe.txt",
|
||||||
|
"presentationPath": null,
|
||||||
|
"proves": "파이프를 끼고 재면 43e1aad 의 scripts/*.py 14개가 전부 --help exit 0 으로 보인다",
|
||||||
|
"doesNotProve": "그 14개가 실제로 --help 를 지원한다는 것",
|
||||||
|
"sha256": "0827ec5c575d24c9998fa79f3cb26f0d4e98b1c647e5121d9e651b13784ff611",
|
||||||
|
"bytes": 410
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "help-exit-codes-measured-without-a-pipe",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:41+09:00",
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\\.py$'); do out=$(timeout 60 python3 $s --help 2>&1); code=$?; n=$(basename $s); echo \"$n exit=$code\"; done",
|
||||||
|
"cwd": ".",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
|
||||||
|
"presentationPath": null,
|
||||||
|
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
|
||||||
|
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
|
||||||
|
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
|
||||||
|
"bytes": 410
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "redaction-covers-command-lines",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T10:09:26+09:00",
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; python3 scripts/terminal-evidence/render_terminal.py /tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/raw.txt /tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/out.svg --command 'cat evidence-sample.txt' --cwd /tmp --exit-code 0 --executed-at 2026-09-10T10:20:00+09:00; echo '--- 렌더한 SVG 의 text 노드 ---'; python3 -c \"\nimport re\ns=open('/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/out.svg').read()\nfor t in re.findall(r'<text[^>]*>(.*?)</text>', s, re.S):\n if any(k in t for k in ('curl','psql','Authorization','GET')): print(t)\nprint('TESTONLY 남은 횟수:', s.count('TESTONLY'))\n\"",
|
||||||
|
"cwd": ".",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/guards/redaction-covers-command-lines.txt",
|
||||||
|
"presentationPath": null,
|
||||||
|
"proves": "렌더러가 명령 인자 안의 Bearer·Cookie 와 접속 문자열 암호를 [REDACTED] 로 덮고, 렌더한 SVG 에 합성 자격증명이 한 건도 남지 않는다",
|
||||||
|
"doesNotProve": "raw 원문에 secret 이 들어가도 된다는 것. 렌더러 마스킹은 그물이지 방벽이 아니다",
|
||||||
|
"sha256": "2fa64df31ba1dea4194ea744a33c2e4fb921e0a45293f46cdf70e89e8ce7c2bb",
|
||||||
|
"bytes": 320
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "redaction-keeps-the-command-intact",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T10:33:11+09:00",
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; python3 -c \"\nimport importlib.util, html, re, subprocess\nspec=importlib.util.spec_from_file_location('rt','scripts/terminal-evidence/render_terminal.py')\nm=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)\nraw=open('/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/raw2.txt').read().splitlines()\nsubprocess.run(['python3','scripts/terminal-evidence/render_terminal.py','/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/raw2.txt','/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/out2.svg','--command','cat evidence-sample.txt','--cwd','/tmp','--exit-code','0','--executed-at','2026-09-10T10:45:00+09:00'],capture_output=True)\nsvg=open('/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/r8/out2.svg').read()\nprint('원문 줄 -> 렌더 줄 (따옴표 수)')\nfor line in raw:\n out=m.redact_line(line)\n print(f' {line.count(chr(34))} -> {out.count(chr(34))} {out}')\nprint()\nprint('렌더한 SVG 에 TESTONLY 가 남은 횟수:', svg.count('TESTONLY'))\n\"",
|
||||||
|
"cwd": ".",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/guards/redaction-keeps-the-command-intact.txt",
|
||||||
|
"presentationPath": null,
|
||||||
|
"proves": "마스킹이 자격증명을 덮으면서 줄마다 따옴표 수를 그대로 둔다. 합성값(TESTONLY-*)이 렌더 결과에 하나도 안 남는다",
|
||||||
|
"doesNotProve": "모든 자격증명 모양을 덮는다는 것. 확인한 것은 여기 여섯 줄이다",
|
||||||
|
"sha256": "40ed59329c1b4d1a66e49c941216c8953c1f63f0a459f2f583c48e060d0ce4b2",
|
||||||
|
"bytes": 464
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "what-the-three-print-for-help",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:57:21+09:00",
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B; for s in audit-records build-tech-log-tree verify-refactor-work-item; do echo \"\\$ python3 scripts/$s.py --help\"; out=$(timeout 60 python3 scripts/$s.py --help 2>&1); code=$?; echo \"$out\"; echo \"exit=$code\"; echo; done",
|
||||||
|
"cwd": ".",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/what-the-three-print-for-help.txt",
|
||||||
|
"presentationPath": null,
|
||||||
|
"proves": "argparse 없는 넷 가운데 CLI 인 셋이 --help 를 프로젝트 이름·경로로 먹는다",
|
||||||
|
"doesNotProve": "다른 잘못된 인자에서도 같은 모양이라는 것",
|
||||||
|
"sha256": "91101c72e788bce9c81d090fe605ba92433f67b94476cfcb641f6e55e75d0035",
|
||||||
|
"bytes": 409
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
명령 출력·csv·덤프 원문. 여기가 정본이다.
|
||||||
|
하위 폴더를 자유롭게 둔다(explain/, guards/). 폴더마다 무엇을 담았는지 한 줄을 README.txt 에 적는다.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ARGPARSE 없음 audit-records.py
|
||||||
|
ARGPARSE 없음 build-tech-log-tree.py
|
||||||
|
argparse check-figure-overlap.py
|
||||||
|
argparse check-figure-text.py
|
||||||
|
argparse fold-analysis-into-final.py
|
||||||
|
argparse fold-studio-contract-into-index.py
|
||||||
|
argparse preview-figure.py
|
||||||
|
argparse studio-body.py
|
||||||
|
ARGPARSE 없음 techlog.py
|
||||||
|
argparse verify-pipeline-run.py
|
||||||
|
argparse verify-pipeline.py
|
||||||
|
argparse verify-project-layout.py
|
||||||
|
ARGPARSE 없음 verify-refactor-work-item.py
|
||||||
|
argparse verify-tech-log-tree.py
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
false | head -1 -> exit=0
|
||||||
|
false -> exit=1
|
||||||
|
bash 5.2.21(1)-release
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
$ python3 scripts/audit-records.py nonexistent-project
|
||||||
|
nonexistent-project — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
|
||||||
|
문제 없음
|
||||||
|
합계 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/verify-tech-log-tree.py nonexistent-project
|
||||||
|
[nonexistent-project] records=0
|
||||||
|
! warn 1 분해 계약 없음
|
||||||
|
· nonexistent-project: tech-log-tree.json 이 없다. 디렉터리가 정본 노릇을 하고 있다
|
||||||
|
TECH LOG TREE: PASS — 프로젝트 1 · error 0 · warn 1
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/check-figure-text.py nonexistent-project
|
||||||
|
FIGURE TEXT: PASS — 그림 0장 · 문장 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/check-figure-overlap.py nonexistent-project
|
||||||
|
볼 그림이 없다
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/verify-project-layout.py nonexistent-project
|
||||||
|
PROJECT LAYOUT: FAIL — 프로젝트 1 · error 1 · warn 0
|
||||||
|
[nonexistent-project] —
|
||||||
|
✗ error 1 final/document.md 가 없다
|
||||||
|
· nonexistent-project
|
||||||
|
exit=1
|
||||||
|
|
||||||
|
$ node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs nonexistent-project --repo
|
||||||
|
nonexistent-project: tech-log-tree.json 이 없다
|
||||||
|
exit=2
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
터미널 증거 렌더러의 마스킹이 실제로 덮는지 확인한 출력.
|
||||||
|
쓰인 자격증명은 전부 합성(TESTONLY-*)이고 실제 비밀값이 아니다.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
--- 렌더한 SVG 의 text 노드 ---
|
||||||
|
$ curl -H "Authorization: Bearer [REDACTED]" https://example.test/api
|
||||||
|
$ curl -H "Cookie: [REDACTED]" https://example.test/api
|
||||||
|
$ psql postgresql://app:[REDACTED]@db:5432/app
|
||||||
|
Authorization: Bearer [REDACTED]
|
||||||
|
GET /api/me -> 200 in 14ms
|
||||||
|
TESTONLY 남은 횟수: 0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
원문 줄 -> 렌더 줄 (따옴표 수)
|
||||||
|
2 -> 2 $ curl -H "X-Api-Key: [REDACTED]" https://example.invalid/d
|
||||||
|
2 -> 2 $ curl -H "Authorization: Basic [REDACTED]" https://example.invalid/d
|
||||||
|
2 -> 2 $ curl -H "Proxy-Authorization: Basic [REDACTED]" https://example.invalid/d
|
||||||
|
0 -> 0 $ curl -u admin:[REDACTED] https://example.invalid/d
|
||||||
|
2 -> 2 $ export TOKEN="[REDACTED]"
|
||||||
|
0 -> 0 GET /api/me -> 200 in 14ms
|
||||||
|
|
||||||
|
렌더한 SVG 에 TESTONLY 가 남은 횟수: 0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
audit-records.py exit=0
|
||||||
|
build-tech-log-tree.py exit=0
|
||||||
|
check-figure-overlap.py exit=0
|
||||||
|
check-figure-text.py exit=0
|
||||||
|
fold-analysis-into-final.py exit=0
|
||||||
|
fold-studio-contract-into-index.py exit=0
|
||||||
|
preview-figure.py exit=0
|
||||||
|
studio-body.py exit=0
|
||||||
|
techlog.py exit=0
|
||||||
|
verify-pipeline-run.py exit=0
|
||||||
|
verify-pipeline.py exit=0
|
||||||
|
verify-project-layout.py exit=0
|
||||||
|
verify-refactor-work-item.py exit=0
|
||||||
|
verify-tech-log-tree.py exit=0
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
audit-records.py exit=0
|
||||||
|
build-tech-log-tree.py exit=1
|
||||||
|
check-figure-overlap.py exit=0
|
||||||
|
check-figure-text.py exit=0
|
||||||
|
fold-analysis-into-final.py exit=0
|
||||||
|
fold-studio-contract-into-index.py exit=0
|
||||||
|
preview-figure.py exit=0
|
||||||
|
studio-body.py exit=0
|
||||||
|
techlog.py exit=0
|
||||||
|
verify-pipeline-run.py exit=0
|
||||||
|
verify-pipeline.py exit=0
|
||||||
|
verify-project-layout.py exit=0
|
||||||
|
verify-refactor-work-item.py exit=1
|
||||||
|
verify-tech-log-tree.py exit=0
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
$ python3 scripts/audit-records.py --help
|
||||||
|
|
||||||
|
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
|
||||||
|
문제 없음
|
||||||
|
|
||||||
|
합계 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/build-tech-log-tree.py --help
|
||||||
|
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
|
||||||
|
exit=1
|
||||||
|
|
||||||
|
$ python3 scripts/verify-refactor-work-item.py --help
|
||||||
|
REFACTOR WORK ITEM VERIFICATION: FAIL
|
||||||
|
- invalid work-item.json: --help/work-item.json
|
||||||
|
exit=1
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
raw 에서 만든 터미널 SVG. 표현물이지 정본이 아니다.
|
||||||
|
scripts/terminal-evidence/render_terminal.py 로 만든다.
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
id:
|
||||||
|
kind: CASE
|
||||||
|
slug: exit-code-read-behind-a-pipe
|
||||||
|
title: 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
|
||||||
|
topic: pipeline-gate-exit-codes
|
||||||
|
topicName: 관문의 종료 코드
|
||||||
|
project: document-haness
|
||||||
|
status: 게시 전
|
||||||
|
studio: ""
|
||||||
|
lastVerifiedOn: 2026-09-10
|
||||||
|
source:
|
||||||
|
- final/document.md#§2-관찰한-것
|
||||||
|
- final/document.md#§3-파이프-뒤의-종료-코드
|
||||||
|
- final/document.md#§4-다시-잰-값
|
||||||
|
- final/document.md#§5-argparse-를-쓰지-않는-넷
|
||||||
|
- final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다
|
||||||
|
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
|
||||||
|
evidence:
|
||||||
|
- ../../../final/evidence/raw/exit-code-through-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/argparse-absent-scripts.txt
|
||||||
|
- ../../../final/evidence/raw/what-the-three-print-for-help.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
|
||||||
|
|
||||||
|
scripts/ 의 검사기 열넷에 --help 를 돌려 전부 종료 코드 0 을 받았고, 이 저장소를 함께
|
||||||
|
조사한 다른 세션도 같은 값을 얻어 확인 등급 확인함으로 적었다. 값은 검사기가 아니라 재는
|
||||||
|
방법이 만든 것이었다. 파이프를 걷어 내고 다시 재니 둘이 exit 1 이다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **검사할 것이 없을 때 관문은 무엇을 내야 하는가**
|
||||||
|
이 사건에서 `audit-records.py --help` 가 「문제 없음」을 찍으면서 그 물음이 열렸다.
|
||||||
|
`--help` 라는 이름의 프로젝트에는 검사할 기록이 하나도 없어 그대로 통과했다.
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
관문은 종료 코드로 말한다. 단계 계약이 「관문은 종료 코드가 0 이어야 지난 것이다」 라고
|
||||||
|
적었고, 런 원장 run.json 의 stages[].gates[].exit 에 그 값이 남는데, 그 값을 사람이 셸에서
|
||||||
|
읽어 옮겨 적는다.
|
||||||
|
|
||||||
|
--help 하나를 잰 것뿐인데 값이 두 번 틀렸다. 관문 결과 전부가 같은 방법으로 적히고 있다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
리비전 43e1aad 의 scripts/*.py 열넷 중 열둘이 --help 에 exit 0,
|
||||||
|
build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다.
|
||||||
|
exit 0 이 usage 가 나왔다는 뜻은 아니다 — audit-records.py 는 --help 를 프로젝트
|
||||||
|
이름으로 받아 검사하고 통과시킨다.
|
||||||
|
|
||||||
|
값을 틀리게 만든 것은 셸의 동작 둘이다. 파이프의 종료 코드 변수는 마지막 명령을 가리키고,
|
||||||
|
명령 치환은 그 변수를 덮어쓴다. 둘 다 정의된 동작이라 셸이 경고하지 않는다.
|
||||||
|
|
||||||
|
조치로 만든 scripts/capture-evidence.py 는 명령을 subprocess 로 직접 돌리고 그 프로세스의
|
||||||
|
반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을 경로가 없다.
|
||||||
|
|
||||||
|
## 검증 환경
|
||||||
|
|
||||||
|
python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release · Linux.
|
||||||
|
대상 저장소 document-haness 리비전 43e1aadef077ad93c30495df428ee3a71dd73f4a.
|
||||||
|
측정은 그 커밋에서 갈라진 worktree dh-B 에서 했고, 그 시점 작업 트리에는
|
||||||
|
capture-evidence.py 가 더해져 있어 증거 메타의 sourceDirty 가 true 다.
|
||||||
|
측정 대상 열넷은 git ls-tree 로 그 커밋의 목록만 골라 냈다.
|
||||||
|
|
||||||
|
## 재현 조건
|
||||||
|
|
||||||
|
1. document-haness 를 43e1aad 로 체크아웃한다.
|
||||||
|
2. 파이프를 끼고 잰다 — 반복문 안에서 python3 출력을 head 로 넘기고 그다음 줄에서 $? 를 읽는다.
|
||||||
|
3. 열넷이 전부 exit=0 으로 나오는 것을 본다.
|
||||||
|
4. 파이프를 걷고 out=$(...) 다음 줄에서 code=$? 로 받아 다시 잰다.
|
||||||
|
5. build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit=1 로 갈리는 것을 본다.
|
||||||
|
|
||||||
|
## 본문
|
||||||
|
|
||||||
|
<!-- body:start -->
|
||||||
|
|
||||||
|
## 두 번 같은 값이 나왔다
|
||||||
|
|
||||||
|
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸는데, 출력이 길어서 `head` 로 잘랐다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
열넷이 전부 `exit=0` 이었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
audit-records.py exit=0
|
||||||
|
build-tech-log-tree.py exit=0
|
||||||
|
check-figure-overlap.py exit=0
|
||||||
|
check-figure-text.py exit=0
|
||||||
|
fold-analysis-into-final.py exit=0
|
||||||
|
fold-studio-contract-into-index.py exit=0
|
||||||
|
preview-figure.py exit=0
|
||||||
|
studio-body.py exit=0
|
||||||
|
techlog.py exit=0
|
||||||
|
verify-pipeline-run.py exit=0
|
||||||
|
verify-pipeline.py exit=0
|
||||||
|
verify-project-layout.py exit=0
|
||||||
|
verify-refactor-work-item.py exit=0
|
||||||
|
verify-tech-log-tree.py exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help` 가
|
||||||
|
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
|
||||||
|
맞는 값처럼 보였다.
|
||||||
|
|
||||||
|
## 값을 만든 것은 셸이다
|
||||||
|
|
||||||
|
파이프라인의 `$?` 는 **마지막** 명령의 종료 코드다. `head` 는 언제나 성공하므로 앞의
|
||||||
|
`python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +o pipefail
|
||||||
|
false | head -1; echo "false | head -1 -> exit=$?"
|
||||||
|
false; echo "false -> exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
false | head -1 -> exit=0
|
||||||
|
false -> exit=1
|
||||||
|
bash 5.2.21(1)-release
|
||||||
|
```
|
||||||
|
|
||||||
|
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
|
||||||
|
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
|
||||||
|
|
||||||
|
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
echo "$(basename $s) exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어써서 두 번째 측정도 열넷 전부
|
||||||
|
0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
|
||||||
|
|
||||||
|
## 다시 잰 값
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
두 측정의 차이는 두 줄뿐이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
2c2
|
||||||
|
< build-tech-log-tree.py exit=0
|
||||||
|
---
|
||||||
|
> build-tech-log-tree.py exit=1
|
||||||
|
13c13
|
||||||
|
< verify-refactor-work-item.py exit=0
|
||||||
|
---
|
||||||
|
> verify-refactor-work-item.py exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
잰 것은 `--help` 하나뿐이라 나머지 열둘이 다른 인자에서 어떻게 도는지는 이 값이 말해 주지
|
||||||
|
않는다.
|
||||||
|
|
||||||
|
## 왜 그 둘만인가
|
||||||
|
|
||||||
|
열넷 중 넷이 `argparse` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
| 스크립트 | `argparse` | `--help` |
|
||||||
|
|---|---|---|
|
||||||
|
| `audit-records.py` | 없음 | `exit 0` — 「문제 없음」 |
|
||||||
|
| `build-tech-log-tree.py` | 없음 | `exit 1` |
|
||||||
|
| `techlog.py` | 없음 | `exit 0` — CLI 가 아니라 인자를 안 읽는다 |
|
||||||
|
| `verify-refactor-work-item.py` | 없음 | `exit 1` |
|
||||||
|
| 나머지 열 | 있음 | `exit 0` — usage |
|
||||||
|
|
||||||
|
남은 셋은 `--help` 를 옵션이 아니라 위치 인자로 먹는다. `audit-records.py` 는 그것을
|
||||||
|
프로젝트 이름으로 받아 「문제 없음」을 찍고, `build-tech-log-tree.py` 와
|
||||||
|
`verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아 실패한다.
|
||||||
|
|
||||||
|
이 두 숫자를 한 번 섞었다. 넷을 셋으로 적었고, 그 셋이 분석 문서 네 곳과 증거 메타 둘에
|
||||||
|
그대로 실렸다. 증거 원문에서 줄을 다시 센 뒤에 넷으로 고쳤다.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python3 scripts/audit-records.py --help
|
||||||
|
|
||||||
|
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
|
||||||
|
문제 없음
|
||||||
|
|
||||||
|
합계 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/build-tech-log-tree.py --help
|
||||||
|
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
|
||||||
|
exit=1
|
||||||
|
|
||||||
|
$ python3 scripts/verify-refactor-work-item.py --help
|
||||||
|
REFACTOR WORK ITEM VERIFICATION: FAIL
|
||||||
|
- invalid work-item.json: --help/work-item.json
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`exit 0` 이 usage 가 나왔다는 뜻은 아니다. `audit-records.py` 는 `--help` 라는 이름의
|
||||||
|
프로젝트를 찾아 검사하고 통과시켰다.
|
||||||
|
|
||||||
|
## 손으로 적지 못하게 했다
|
||||||
|
|
||||||
|
관문 결과를 사람이 옮겨 적는 한 같은 뿌리에서 같은 실수가 계속 난다. 그래서 명령을 돌리는
|
||||||
|
쪽과 종료 코드를 적는 쪽을 하나로 붙였다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
proc = subprocess.run(command, cwd=cwd, capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
exit_code, out = proc.returncode, proc.stdout + proc.stderr
|
||||||
|
```
|
||||||
|
|
||||||
|
종료 코드를 인자로 받지 않으므로 손으로 적어 넣을 수는 없다. 도구의 첫머리 주석이 목표를
|
||||||
|
적는다 — 돌리지 않은 검증을 완료로 적는 경로가 없어야 한다. 원문은
|
||||||
|
`final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/` 에 같은 이름으로 함께 떨어진다.
|
||||||
|
둘을 사람이 따로 적으면 갈라지기 때문이다.
|
||||||
|
|
||||||
|
셸 한 줄을 감싸는 래퍼도 됐지만 그러면 파이프를 다시 쓸 수 있게 된다. 값을 두 번 틀리게
|
||||||
|
만든 것이 바로 그 셸이라 아예 거치지 않기로 했다. 대신 셸 문법이 필요한 명령은 `bash -c` 를
|
||||||
|
인자로 넘겨야 하고, 그 안에서 다시 파이프를 쓰면 같은 실수가 난다 — 이 기록의 증거
|
||||||
|
다섯 개도 그렇게 수집했다.
|
||||||
|
|
||||||
|
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
|
||||||
|
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
|
||||||
|
지금은 `--` 앞뒤를 직접 가르고, 왜 그렇게 했는지를 코드에 한 줄로 남겨 두었다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
```
|
||||||
|
|
||||||
|
`--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다. 이 저장소에서 인자를 손으로
|
||||||
|
가르는 코드는 대개 여기서 걸린다.
|
||||||
|
|
||||||
|
이 기록이 인용한 원문 다섯 개를 그 도구가 수집했다. 메타 하나는 이렇게 생겼다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "help-exit-codes-measured-without-a-pipe",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:41+09:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
|
||||||
|
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
|
||||||
|
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
|
||||||
|
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
|
||||||
|
"bytes": 410
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
여기서 `exitCode: 0` 은 **측정 반복문 자체가 끝까지 돌았다**고 말한다. 열넷 각각의 종료
|
||||||
|
코드는 원문 `raw/` 안에 있다. 반복문이 반환한 값과 그 안에서 잰 값을 섞지 않는다 — 메타에는
|
||||||
|
반복문 쪽이 들어간다.
|
||||||
|
|
||||||
|
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지 적는다. 있으면
|
||||||
|
`sourceRevision` 이 그 출력을 설명하지 못한다. 이 다섯은 전부 `true` 다 — 수집기 자신을
|
||||||
|
더한 상태에서 쟀다.
|
||||||
|
|
||||||
|
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 쓰이고, 이 도구는 필요할
|
||||||
|
때만 부른다.
|
||||||
|
|
||||||
|
## 이 사건이 닫지 못한 것
|
||||||
|
|
||||||
|
수집기는 브라우저 캡처와 대화형 명령을 담지 못한다. 이 저장소의 증거 가운데
|
||||||
|
`final/evidence/browser/` 쪽은 여전히 Playwright 로 찍고 메타를 손으로 적는다 — 종료 코드를
|
||||||
|
손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다.
|
||||||
|
|
||||||
|
<!-- body:end -->
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
id:
|
||||||
|
kind: QUESTION
|
||||||
|
slug: what-a-gate-returns-with-nothing-to-check
|
||||||
|
title: 검사할 것이 없을 때 관문은 무엇을 내야 하는가
|
||||||
|
topic: pipeline-gate-exit-codes
|
||||||
|
topicName: 관문의 종료 코드
|
||||||
|
project: document-haness
|
||||||
|
status: 게시 전
|
||||||
|
studio: ""
|
||||||
|
questionStatus: OPEN
|
||||||
|
source:
|
||||||
|
- final/document.md#§6-실재하지-않는-프로젝트-이름
|
||||||
|
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
|
||||||
|
evidence:
|
||||||
|
- ../../../final/evidence/raw/gates-on-a-nonexistent-project.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# 검사할 것이 없을 때 관문은 무엇을 내야 하는가
|
||||||
|
|
||||||
|
실재하지 않는 프로젝트 이름을 관문 여섯에 주면 넷이 통과를 보고한다. 검사할 것이 없으니
|
||||||
|
찾은 문제도 없다는 뜻이라 틀린 값은 아니다. 다만 관문 결과를 읽는 쪽은 그것을 검사를
|
||||||
|
지났다는 뜻으로 읽는다. 어느 쪽이 맞는지 이 저장소가 아직 정하지 않았다.
|
||||||
|
|
||||||
|
분해 계약에서는 이 후보를 Case 로 올리려다 Question 으로 돌렸다. 현상은 재현했는데 조치가
|
||||||
|
없어서다 — 조치 없이 쓰면 관찰만 있고 결과가 없는 글이 된다. 검사기 수정은 이 저장소의
|
||||||
|
다른 담당이 맡고 있어 여기서 고칠 수도 없었다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다**
|
||||||
|
그 사건에서 audit-records.py 가 --help 를 프로젝트 이름으로 받아 「문제 없음」을 찍은
|
||||||
|
것이 이 물음의 입구다. 같은 동작을 오타난 프로젝트 이름에서 다시 보게 된다.
|
||||||
|
|
||||||
|
## 사실
|
||||||
|
|
||||||
|
- 관문 여섯에 nonexistent-project 를 주면 넷이 exit 0 을 낸다. audit-records.py 는 「문제 없음」, verify-tech-log-tree.py 는 「PASS — 프로젝트 1 · error 0 · warn 1」, check-figure-text.py 는 「PASS — 그림 0장 · 문장 0건」, check-figure-overlap.py 는 「볼 그림이 없다」를 찍는다.
|
||||||
|
- 잡아내는 둘은 대상 파일을 먼저 찾는다. verify-project-layout.py 는 final/document.md 가 없다며 exit 1, check_evidence.mjs 는 tech-log-tree.json 이 없다며 exit 2 다.
|
||||||
|
- 검사기가 고장난 것이 아니다. 실재하는 프로젝트에서는 판정을 낸다 — check-figure-text.py ca-tmpl 은 그림 29장에서 문장 13건을 찾아 exit 1 이다.
|
||||||
|
- 런 원장 검사기 verify-pipeline-run.py 는 관문의 대상 프로젝트를 보지 않는다. 명령 문자열에 토큰이 들어 있는지와 원장이 적어 낸 exit 값만 본다.
|
||||||
|
|
||||||
|
## 가정
|
||||||
|
|
||||||
|
- 관문 결과를 읽는 쪽이 exit 0 을 「그 프로젝트를 검사했고 문제가 없었다」로 읽는다고 보고 있다. 실제로 그렇게 읽는지는 사람에게 물어보지 않았다.
|
||||||
|
|
||||||
|
## 미지수
|
||||||
|
|
||||||
|
- 「대상 없음」을 통과로 볼지 실패로 볼지 정해지지 않았다. 아직 만들지 않은 프로젝트에 관문을 미리 걸어 보는 쓰임이 있다면 exit 0 이 맞는 값이다.
|
||||||
|
- 넷 각각이 「대상 없음」과 「대상이 있는데 검사할 것이 0 건」을 구분할 수 있는지 모른다. 그 코드를 읽지 않았다.
|
||||||
|
- 오타로 관문이 무효가 된 런이 실제로 있었는지 모른다. 지금 있는 원장 세 건은 프로젝트 이름이 전부 맞다.
|
||||||
|
|
||||||
|
## 제약
|
||||||
|
|
||||||
|
- 검사기 자체의 수정은 이 저장소의 다른 담당 몫이다. 여기서는 현상만 적는다.
|
||||||
|
- 관문을 실패로 바꾸면 기존 원장과 전체 훑기가 함께 움직인다. 한쪽만 보고 정할 수 없다.
|
||||||
|
|
||||||
|
## 선택지
|
||||||
|
|
||||||
|
### 1. 넷이 「대상 없음」에서 실패를 내게 한다
|
||||||
|
|
||||||
|
대상 프로젝트 폴더가 없으면 exit 1 을 낸다. verify-project-layout.py 가 이미 그렇게 한다.
|
||||||
|
오타 한 번에 관문이 무효가 되는 경로가 막힌다. 대신 아직 만들지 않은 프로젝트에 미리 돌려
|
||||||
|
보는 쓰임이 사라지고, 기존 원장 세 건과 전체 훑기가 그대로 통과하는지 확인해야 한다.
|
||||||
|
|
||||||
|
### 2. 관문은 그대로 두고 원장 쪽에서 대상 이름을 대조한다
|
||||||
|
|
||||||
|
verify-pipeline-run.py 가 gates[].cmd 에서 프로젝트 이름을 뽑아 원장의 project 칸과
|
||||||
|
견준다. 검사기 넷을 건드리지 않아 다른 쓰임이 안 깨진다. 대신 명령을 「<기록.md>」 같은 꼴로
|
||||||
|
줄여 적은 기존 원장에서는 이름을 못 뽑는다 — 원장 세 건에 이미 그런 줄이 있다.
|
||||||
|
|
||||||
|
## 다음 검증
|
||||||
|
|
||||||
|
1. 넷에 실재하는 프로젝트를 주고 「검사 대상 0 건」이 나오는 경우와 「프로젝트 없음」이 나오는 경우의 출력을 나란히 잰다. 둘이 지금 구분되는지를 먼저 본다.
|
||||||
|
2. 원장의 gates[].cmd 에서 프로젝트 이름을 뽑아 project 칸과 대조하는 검사를 넣어 보고, 기존 원장 세 건이 통과하는지 본다.
|
||||||
|
|
||||||
|
닫는 조건 : 넷이 「대상 없음」에서 exit 0 이 아닌 값을 내도 기존 원장 세 건과 전체 훑기가 그대로 통과하면 선택지 1 로 정하고 Decision 으로 넘긴다. 하나라도 깨지면 선택지 2 로 넘긴다.
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 4,
|
||||||
|
"project": "document-haness",
|
||||||
|
"ssot": "final/document.md",
|
||||||
|
"ssotSha256": "6d2c41d2d02eecd1476b95c83caa91e262151236b38aedf5dda775114fed561f",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"generatedAt": "2026-09-10",
|
||||||
|
"sourceRepository": {
|
||||||
|
"path": "/home/donghyeon/workspace/chat-gpt-container/document-haness",
|
||||||
|
"revision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"verified": "worktree dh-B 에서 git rev-parse HEAD 로 확인했다. 측정 대상 열넷은 git ls-tree --name-only 43e1aad scripts/ 로 그 커밋의 목록만 골라 냈다. code[] 의 세 파일도 git cat-file -e 43e1aad:<경로> 로 그 커밋에 실재하는 것을 대조했고, 인용한 줄 155·148·193 이 셋 다 raise SystemExit(...) 인 것을 git show 로 확인했다. 이 런이 더한 scripts/capture-evidence.py 와 scripts/skill-versions.py 는 43e1aad 에 없으므로 code[] 에 넣지 않았다 — 기록 본문에서는 「이 배치에서 만든 것」으로 밝힌다. 증거 메타의 sourceDirty 가 전부 true 인 것이 그 상태를 적는다."
|
||||||
|
},
|
||||||
|
"candidateScope": {
|
||||||
|
"document": "final/document.md",
|
||||||
|
"sections": [
|
||||||
|
"§2 관찰한 것",
|
||||||
|
"§3 파이프 뒤의 종료 코드",
|
||||||
|
"§4 다시 잰 값",
|
||||||
|
"§5 argparse 를 쓰지 않는 넷",
|
||||||
|
"§6 실재하지 않는 프로젝트 이름",
|
||||||
|
"§7 원장은 종료 코드를 다시 재지 않는다",
|
||||||
|
"§8 종료 코드를 손으로 적을 수 없게 만든다"
|
||||||
|
],
|
||||||
|
"excluded": [
|
||||||
|
"§1 검사기 열넷이 있는 자리",
|
||||||
|
"§9 확인하지 못한 것"
|
||||||
|
],
|
||||||
|
"note": "§1 은 배경이고 §9 는 한계 목록이라 후보 자리가 아니다. 후보는 §2~§8 에서만 나온다"
|
||||||
|
},
|
||||||
|
"note": "이 프로젝트의 글감 전부다. 분해 계약이자 색인이고, 이 파일이 정본이다. 노드의 칸(readiness·source·classification·relations…)은 사람이 적고, file·publication·status 는 기록 파일에서 읽어 채운다 — python3 scripts/build-tech-log-tree.py document-haness",
|
||||||
|
"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": 2,
|
||||||
|
"unwritten": 0,
|
||||||
|
"unlisted": 0,
|
||||||
|
"candidates": 5
|
||||||
|
},
|
||||||
|
"topics": {
|
||||||
|
"pipeline-gate-exit-codes": {
|
||||||
|
"topic": "pipeline-gate-exit-codes",
|
||||||
|
"title": "관문의 종료 코드",
|
||||||
|
"readerQuestion": "관문이 exit 0 을 냈다는 것은 무엇을 확인했다는 뜻인가?",
|
||||||
|
"kinds": {
|
||||||
|
"case": [
|
||||||
|
{
|
||||||
|
"title": "파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다",
|
||||||
|
"kind": "case",
|
||||||
|
"slug": "exit-code-read-behind-a-pipe",
|
||||||
|
"readiness": "READY",
|
||||||
|
"source": [
|
||||||
|
"final/document.md#§2-관찰한-것",
|
||||||
|
"final/document.md#§3-파이프-뒤의-종료-코드",
|
||||||
|
"final/document.md#§4-다시-잰-값",
|
||||||
|
"final/document.md#§5-argparse-를-쓰지-않는-넷",
|
||||||
|
"final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다"
|
||||||
|
],
|
||||||
|
"code": [
|
||||||
|
"scripts/audit-records.py:155",
|
||||||
|
"scripts/build-tech-log-tree.py:148",
|
||||||
|
"scripts/verify-refactor-work-item.py:193"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"evidence/raw/exit-code-through-a-pipe.txt",
|
||||||
|
"evidence/raw/help-exit-codes-measured-through-a-pipe.txt",
|
||||||
|
"evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
|
||||||
|
"evidence/raw/argparse-absent-scripts.txt",
|
||||||
|
"evidence/raw/what-the-three-print-for-help.txt"
|
||||||
|
],
|
||||||
|
"classification": "재현·진단·조치가 닫히는 하나의 사건이다. 두 세션이 같은 오측정을 하고, 원인이 셸의 정의된 동작으로 좁혀지고, 종료 코드를 손으로 적을 수 없게 만드는 수집기로 닫았다",
|
||||||
|
"missing-verification": "--help 하나만 쟀다. 다른 인자에서 argparse 를 쓰는 열이 어떻게 도는지는 확인하지 않았다",
|
||||||
|
"relations": [
|
||||||
|
"question:what-a-gate-returns-with-nothing-to-check"
|
||||||
|
],
|
||||||
|
"publication": "초안",
|
||||||
|
"file": "pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"status": "게시 전",
|
||||||
|
"studioId": "",
|
||||||
|
"assets": [],
|
||||||
|
"assetFiles": [],
|
||||||
|
"evidenceFiles": [
|
||||||
|
"../../../final/evidence/raw/exit-code-through-a-pipe.txt",
|
||||||
|
"../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt",
|
||||||
|
"../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
|
||||||
|
"../../../final/evidence/raw/argparse-absent-scripts.txt",
|
||||||
|
"../../../final/evidence/raw/what-the-three-print-for-help.txt"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concept": [],
|
||||||
|
"reference": [],
|
||||||
|
"question": [
|
||||||
|
{
|
||||||
|
"title": "검사할 것이 없을 때 관문은 무엇을 내야 하는가",
|
||||||
|
"kind": "question",
|
||||||
|
"slug": "what-a-gate-returns-with-nothing-to-check",
|
||||||
|
"readiness": "OPEN",
|
||||||
|
"source": [
|
||||||
|
"final/document.md#§6-실재하지-않는-프로젝트-이름"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"evidence/raw/gates-on-a-nonexistent-project.txt"
|
||||||
|
],
|
||||||
|
"classification": "현상은 재현했고 조치는 정해지지 않았다. 답이 나오려면 이 저장소가 「대상 없음」을 통과로 볼지 실패로 볼지 정해야 한다",
|
||||||
|
"missing-verification": "실재하는 프로젝트 전부에서 같은 넷이 어떻게 도는지는 안 쟀다. ca-tmpl 하나에서 check-figure-text.py 가 exit 1 을 내는 것만 봤다",
|
||||||
|
"relations": [
|
||||||
|
"case:exit-code-read-behind-a-pipe"
|
||||||
|
],
|
||||||
|
"known": [
|
||||||
|
"관문 여섯에 실재하지 않는 프로젝트 이름 nonexistent-project 를 주면 넷이 exit 0 을 낸다 — audit-records.py 는 「문제 없음」, verify-tech-log-tree.py 는 「PASS — error 0 · warn 1」, check-figure-text.py 는 「PASS — 그림 0장」, check-figure-overlap.py 는 「볼 그림이 없다」",
|
||||||
|
"잡아내는 둘은 대상 파일을 먼저 찾는다 — verify-project-layout.py 는 final/document.md 가 없다고 exit 1, check_evidence.mjs 는 tech-log-tree.json 이 없다고 exit 2",
|
||||||
|
"이것은 「검사기가 고장났다」가 아니다. 실재하는 프로젝트에서는 판정을 낸다 — check-figure-text.py ca-tmpl 은 그림 29장에서 문장 13건을 찾아 exit 1 이다",
|
||||||
|
"런 원장 검사기 verify-pipeline-run.py 는 관문의 대상 프로젝트를 보지 않고 명령 문자열에 토큰이 있는지와 원장이 적어 낸 exit 만 본다"
|
||||||
|
],
|
||||||
|
"unknown": [
|
||||||
|
"「대상 없음」을 통과로 볼지 실패로 볼지 이 저장소가 정하지 않았다. 아직 안 만든 프로젝트에 관문을 미리 걸어 보는 쓰임이 있다면 exit 0 이 맞다",
|
||||||
|
"넷 각각이 「대상 없음」과 「대상이 있는데 검사할 것이 0 건」을 구분할 수 있는지 — 코드를 읽지 않았다",
|
||||||
|
"오타로 관문이 무효가 된 런이 실제로 있었는지. 원장 세 건은 전부 프로젝트 이름이 맞다"
|
||||||
|
],
|
||||||
|
"next-verification": [
|
||||||
|
"넷에 실재하는 프로젝트를 주고 「검사 대상 0 건」이 나오는 경우와 「프로젝트 없음」이 나오는 경우의 출력을 나란히 잰다",
|
||||||
|
"런 원장의 gates[].cmd 에서 프로젝트 이름을 뽑아 원장의 project 칸과 대조하는 검사를 verify-pipeline-run.py 에 넣어 보고, 기존 원장 세 건이 통과하는지 본다"
|
||||||
|
],
|
||||||
|
"decision-criterion": "넷이 「대상 없음」에서 exit 0 이 아닌 값을 내도 기존 원장 세 건과 verify-pipeline.py 전체 훑기가 그대로 통과하면 그 방향으로 정한다. 하나라도 깨지면 「대상 없음」을 통과로 두고 대신 원장 쪽에서 대상 이름을 대조한다",
|
||||||
|
"publication": "초안",
|
||||||
|
"file": "pipeline-gate-exit-codes/question/question-what-a-gate-returns-with-nothing-to-check.md",
|
||||||
|
"status": "게시 전",
|
||||||
|
"studioId": "",
|
||||||
|
"assets": [],
|
||||||
|
"assetFiles": [],
|
||||||
|
"evidenceFiles": [
|
||||||
|
"../../../final/evidence/raw/gates-on-a-nonexistent-project.txt"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decision": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"id": "DH-C01",
|
||||||
|
"kindCandidate": "CASE",
|
||||||
|
"sourceRefs": [
|
||||||
|
"final/document.md#§2-관찰한-것",
|
||||||
|
"final/document.md#§3-파이프-뒤의-종료-코드",
|
||||||
|
"final/document.md#§4-다시-잰-값"
|
||||||
|
],
|
||||||
|
"summary": "파이프를 끼고 재서 검사기 열넷이 전부 --help 를 받는 것으로 보였고, 다시 재니 둘이 exit 1 이었다",
|
||||||
|
"disposition": "PROMOTE",
|
||||||
|
"dispositionReview": "CONFIRMED",
|
||||||
|
"target": "case:exit-code-read-behind-a-pipe",
|
||||||
|
"reason": "관찰·원인·조치가 한 사건으로 닫히고 두 세션이 같은 값을 얻어 계약 문서에까지 들어간 자리다. 독립 기록으로 남길 값이 있다"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "DH-C02",
|
||||||
|
"kindCandidate": "CASE",
|
||||||
|
"sourceRefs": [
|
||||||
|
"final/document.md#§5-argparse-를-쓰지-않는-넷"
|
||||||
|
],
|
||||||
|
"summary": "열넷 중 넷이 argparse 를 쓰지 않고, 그중 셋이 --help 를 위치 인자로 먹는다",
|
||||||
|
"disposition": "MERGE_INTO",
|
||||||
|
"dispositionReview": "CONFIRMED",
|
||||||
|
"target": "case:exit-code-read-behind-a-pipe",
|
||||||
|
"reason": "왜 그 둘만 exit 1 이었는지를 설명하는 절이다. 떼어 내면 CASE 의 원인 절이 비고, 따로 읽을 값도 없다"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "DH-C03",
|
||||||
|
"kindCandidate": "CASE",
|
||||||
|
"sourceRefs": [
|
||||||
|
"final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다"
|
||||||
|
],
|
||||||
|
"summary": "명령 실행과 종료 코드 기록을 한 자리로 붙인 증거 수집기를 만들었다",
|
||||||
|
"disposition": "MERGE_INTO",
|
||||||
|
"dispositionReview": "CONFIRMED",
|
||||||
|
"target": "case:exit-code-read-behind-a-pipe",
|
||||||
|
"reason": "CASE 의 조치 절이다. 도구 자체의 사용법은 Reference 감이지만 아직 이 저장소 하나에서만 써서 「주의할 조건」을 적을 근거가 없다 — 실행 여덟 건이 전부 이 프로젝트의 증거 수집이고, 다른 프로젝트나 브라우저 캡처에 걸어 본 적이 없다"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "DH-C04",
|
||||||
|
"kindCandidate": "CASE",
|
||||||
|
"sourceRefs": [
|
||||||
|
"final/document.md#§6-실재하지-않는-프로젝트-이름"
|
||||||
|
],
|
||||||
|
"summary": "실재하지 않는 프로젝트 이름에 관문 여섯 중 넷이 exit 0 을 낸다",
|
||||||
|
"disposition": "PROMOTE",
|
||||||
|
"dispositionReview": "CONFIRMED",
|
||||||
|
"target": "question:what-a-gate-returns-with-nothing-to-check",
|
||||||
|
"reason": "현상은 재현했지만 조치가 없다. 조치 없는 CASE 는 「관찰만 있고 결과가 없는 글」이 되므로 Question 으로 올린다 — 확인된 사실과 답을 구할 방법은 있고 답만 없다. 검사기 수정은 이 저장소의 다른 담당 몫이다"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "DH-C05",
|
||||||
|
"kindCandidate": "QUESTION",
|
||||||
|
"sourceRefs": [
|
||||||
|
"final/document.md#§7-원장은-종료-코드를-다시-재지-않는다"
|
||||||
|
],
|
||||||
|
"summary": "런 원장이 관문을 재실행하지 않고 자기 신고한 exit 만 본다. 재실행이 옳은 답인지는 안 정했다",
|
||||||
|
"disposition": "NEEDS_DECISION",
|
||||||
|
"dispositionReview": "CONFIRMED",
|
||||||
|
"target": null,
|
||||||
|
"reason": "재실행하면 원장 검사가 느려지고 부작용 있는 관문(파일을 고치는 build-tech-log-tree.py)도 다시 돌게 된다. 무엇을 재실행하고 무엇을 신고로 받을지 이 저장소가 정하지 않았다. 정해지기 전에는 Question 으로도 못 쓴다 — 「답을 구할 방법」이 결정에 달려 있다"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unlisted": [],
|
||||||
|
"history": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"runId": "2026-09-10-1033",
|
||||||
|
"project": "document-haness",
|
||||||
|
"record": "docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"startedAt": "2026-09-10T10:33:45+09:00",
|
||||||
|
"finishedAt": "2026-09-10T11:00:39+09:00",
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"id": "S1",
|
||||||
|
"name": "코드베이스 → SSOT",
|
||||||
|
"skill": "analyzing-codebase-for-tech-log",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "DONE",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "Do not turn inference into observation in the final document.",
|
||||||
|
"inputs": [
|
||||||
|
"docs/document-haness/final/document.md",
|
||||||
|
"docs/document-haness/final/evidence/raw/**",
|
||||||
|
"docs/document-haness/final/evidence/meta/**"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"docs/document-haness/final/document.md",
|
||||||
|
"docs/document-haness/final/evidence/meta/argparse-absent-scripts.json",
|
||||||
|
"docs/document-haness/final/evidence/meta/what-the-three-print-for-help.json",
|
||||||
|
"docs/document-haness/tech-log-studio/tech-log-tree.json"
|
||||||
|
],
|
||||||
|
"gates": [
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/verify-project-layout.py document-haness",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/build-tech-log-tree.py document-haness",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/verify-tech-log-tree.py document-haness",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs document-haness --repo",
|
||||||
|
"exit": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "대조 모드다. 분석을 새로 하지 않고 SSOT 가 증거 원문과 어긋나는지만 봤다. S3 이 올린 어긋남 하나가 실물이었다 — 증거 정본 argparse-absent-scripts.txt 의 「ARGPARSE 없음」이 4줄인데 SSOT 는 셋이라고 적었다. 「argparse 가 없는 것(넷)」과 「--help 를 위치 인자로 먹는 것(셋)」을 한 숫자로 섞은 것이다. SSOT 네 곳(:155 절 제목 · :157 · :176 · §9)을 고치고, 절 제목이 바뀌어 앵커를 가리키는 세 곳(계약 2 · 기록 frontmatter 1)을 함께 고쳤다. 증거 메타 둘의 proves·doesNotProve 도 같은 오산을 담고 있어 고쳤다 — 그것은 사람이 적은 주장이다. exitCode·sha256·bytes·command 는 실행이 적은 값이라 안 건드렸고, raw 원문은 한 글자도 안 건드렸다(메타 8건의 sha256 을 다시 계산해 원문과 일치 확인). runs/.../stage/S3-before.md 는 과거 사본이라 옛 앵커를 그대로 두었다. 그 밖에 §1~§9 의 인용 블록 다섯을 raw 와 diff 로 대조했고 §7 의 원장 인용이 runs/virtualization/2026-09-09-1052/run.json:78 에 실재하는 것까지 확인했다. 오케스트레이터가 증거 원문과 코드로 다시 세어 넷인 것을 독립으로 확인했다. 판단이 필요해 안 고친 것 둘 — guards/ 증거 둘을 인용하는 기록이 없고(warn 2건), sourceRepository.path 가 worktree 가 아니라 원본 경로를 가리킨다(verified 칸이 사유를 적는다)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S2",
|
||||||
|
"name": "SSOT → 분해 계약",
|
||||||
|
"skill": "deriving-tech-log-root-tree",
|
||||||
|
"runBy": "orchestrator",
|
||||||
|
"status": "SKIPPED",
|
||||||
|
"skipReason": "이 글감이 tech-log-tree.json 에 이미 PROMOTE · dispositionReview CONFIRMED 로 있다 (candidates[DH-C01], target=case:exit-code-read-behind-a-pipe). 분해를 다시 하지 않았다.",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [
|
||||||
|
"docs/document-haness/final/document.md",
|
||||||
|
"docs/document-haness/tech-log-studio/tech-log-tree.json"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"docs/document-haness/tech-log-studio/tech-log-tree.json"
|
||||||
|
],
|
||||||
|
"gates": [
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/build-tech-log-tree.py document-haness",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/verify-tech-log-tree.py document-haness",
|
||||||
|
"exit": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "build 는 파생 칸(file·publication·status·ssotSha256)만 다시 채운다. 사람이 적은 칸은 그대로다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S3",
|
||||||
|
"name": "글감 → 기록",
|
||||||
|
"skill": "writing-tech-log-records",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "DONE",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "**옮겨 적었다고 확인한 것이 아니다.** 앞선 기록에서 코드를 가져오거나 기억으로 경로를 쓰면",
|
||||||
|
"inputs": [
|
||||||
|
"docs/document-haness/tech-log-studio/tech-log-tree.json",
|
||||||
|
"docs/document-haness/final/document.md",
|
||||||
|
"runs/document-haness/2026-09-10-1033/stage/S3-before.md"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md"
|
||||||
|
],
|
||||||
|
"gates": [
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/studio-body.py docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md -o /tmp/s3-body.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/s3-body.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs document-haness --repo",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/verify-tech-log-tree.py document-haness",
|
||||||
|
"exit": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "기록은 이미 있었고 서브에이전트가 계약·SSOT 와 대조해 세 곳을 고쳤다. (1) 요약 칸의 백틱 — 평문으로 렌더링되는 칸이다. (2) 요약이 「계약 문서에까지 들어갔다」고 적었는데 SSOT 는 「확인 등급 확인함으로 적었다」까지만 말한다. SSOT 가 뒷받침하는 문장으로 바꿨다. (3) 본문이 「열넷 중 셋이 argparse 를 쓰지 않는다」로 시작하면서 바로 아래 표에 「없음」을 넷 적어 두었다. 증거 정본 argparse-absent-scripts.txt 의 「ARGPARSE 없음」이 4줄이므로 표가 맞고 문장이 틀렸다 — 셋→넷, 남은 둘→남은 셋으로 고쳤다. 오케스트레이터가 증거 원문과 코드로 다시 세어 넷인 것을 확인했다. 같은 오산이 SSOT·계약·증거 메타에 그대로 있다는 것을 서브에이전트가 크게 적어 올렸고, SSOT 를 고치는 것은 자기 단계 밖이라 손대지 않았다 — S1 로 돌렸다. check_prose 경고 2건(CASE·POSIX 약어)은 남겼다. CASE 는 frontmatter 의 kind 값이고 POSIX 는 SSOT 가 쓰는 표준 명칭이라 풀어 쓰면 보호 구간을 건드린다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S4",
|
||||||
|
"name": "기록 → 그림",
|
||||||
|
"skill": "technical-visualizer",
|
||||||
|
"runBy": "orchestrator",
|
||||||
|
"status": "SKIPPED",
|
||||||
|
"skipReason": "그림이 필요 없다. 세 관문 중 셋째에 걸린다 — 본문의 「왜 그 둘만인가」 절이 argparse 유무와 --help 결과를 표 하나로 답하고 있어, 같은 것을 그림으로 다시 그리면 옆 문단이 이미 말한 것을 되풀이한다. 계약의 이 노드에도 assets 가 없다.",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": "이 프로젝트의 final/assets 는 비어 있다. check-figure-text.py 와 check-figure-overlap.py 는 볼 그림이 없어 돌리지 않았다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S5",
|
||||||
|
"name": "AI 티 제거",
|
||||||
|
"skill": "rewriting-technical-prose-naturally",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "DONE",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "| Sentences are all short and choppy | Rejoin with `~기 때문에`, `~다 보니`, `~어서`; break only where the subject changes |",
|
||||||
|
"inputs": [
|
||||||
|
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"runs/document-haness/2026-09-10-1033/stage/S5-before.md"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md"
|
||||||
|
],
|
||||||
|
"gates": [
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"exit": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/studio-body.py docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md -o /tmp/s5-body.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/s5-body.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs document-haness --repo",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/check-preservation.py runs/document-haness/2026-09-10-1033/stage/S5-before.md docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"exit": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "서브에이전트가 아홉 곳을 고쳤다. 전부 문장을 잇거나 문단 순서를 옮긴 것이고 삭제한 문장도 새로 쓴 문장도 없다(68문장 → 63문장). check-preservation.py 가 「보호 구간 변화 0건 · 유보 감소 0종」으로 확인했다 — 윤문이 수치·코드·인용·URL 을 건드리지 않았고 유보 표현도 안 지웠다는 뜻이다. style_profile.mjs 는 exit 1 로 그대로 적는다. 관문이 아니라 측정이고 문서 계약이 이것에만 「error 0」을 안 붙였다. 이유 연결어미는 5.9 → 9.5 로 기준 안에 들어왔고 문장 평균 길이는 41.6 → 45 로 기준(48~75) 밖에 남았다. 서브에이전트가 남은 짧은 문장 22개를 전부 열어 보고 재현 조건 단계·수치 한 줄·코드로 넘기는 도입·정의 한 줄·방향 전환이라 잇지 않았다고 적었다. 수치를 맞추려고 문장을 넣지 말라는 것이 스킬의 규칙이다. 서브에이전트가 사실 어긋남 하나를 올렸다 — 본문이 「이 기록의 증거 여섯 개」라고 적었는데 frontmatter 의 evidence 는 다섯이다. 수치는 보호 구간이라 S5 가 못 고친다고 판단해 넘겼고, 오케스트레이터가 frontmatter 를 세어 확인한 뒤 다섯으로 고쳤다. check-preservation 은 이 고침을 못 잡는다 — 한글 수사는 아라비아 숫자가 아니라서 보호 구간 비교에 안 걸린다. 이 검사기의 알려진 한계다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S6",
|
||||||
|
"name": "일한 사람의 목소리",
|
||||||
|
"skill": "writing-as-the-person-who-did-it",
|
||||||
|
"runBy": "subagent",
|
||||||
|
"status": "DONE",
|
||||||
|
"skipReason": "",
|
||||||
|
"skillEcho": "고치는 방법은 하나뿐이다. **자료에 남아 있는 사람의 흔적을 찾아서 제자리에 놓는다.**",
|
||||||
|
"inputs": [
|
||||||
|
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"docs/document-haness/final/document.md",
|
||||||
|
"docs/document-haness/tech-log-studio/tech-log-tree.json",
|
||||||
|
"docs/document-haness/final/evidence/meta/*.json",
|
||||||
|
"scripts/capture-evidence.py",
|
||||||
|
"runs/document-haness/2026-09-10-1033/run.json",
|
||||||
|
"runs/document-haness/2026-09-10-1033/stage/S6-before.md"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md"
|
||||||
|
],
|
||||||
|
"gates": [
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/studio-body.py docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md -o /tmp/s6-body.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs /tmp/s6-body.md",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs document-haness --repo",
|
||||||
|
"exit": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cmd": "python3 scripts/check-preservation.py runs/document-haness/2026-09-10-1033/stage/S6-before.md docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md",
|
||||||
|
"exit": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": "흔적 있음. 두 곳을 넣었고 둘 다 근거를 파일과 칸으로 댔다. (A) 「왜 그 둘만인가」 절에 이 런이 겪은 어긋남을 그 대목에 놓았다 — 두 숫자를 섞어 넷을 셋으로 적었고 그것이 분석 문서 네 곳과 증거 메타 둘에 실렸으며 증거 원문에서 줄을 다시 세어 고쳤다는 것. 근거는 이 원장의 S1·S3 notes 다. (B) 「손으로 적지 못하게 했다」 절에 도구를 쓴 사람의 말을 옮겼다 — scripts/capture-evidence.py 의 docstring :5 와 :9 다. 기록이 기능만 적고 목표와 이유는 안 적던 자리다. 인용부호나 백틱으로 감싸지 않고 평문으로 녹였다 — check-preservation 이 새로 생긴 인라인코드와 「」 직접인용을 「새로생김」으로 세기 때문이다. 낱말은 docstring 그대로다. 안 넣은 것도 적었다. 「처음에는」·「고민 끝에」·「놀랍게도」는 자료에 없어 한 건도 안 썼고, 커밋 메시지는 capture-evidence.py 가 아직 untracked 라 흔적이 없다 — 찾아봤고 없었다. 이미 있던 사람의 흔적 셋(같은 착각이 한 번 더 났다 · 셸 래퍼도 됐지만 · 처음 판에서는 이 도구도 인자를 잘못 먹었다)에는 손대지 않았다. 서브에이전트가 계약의 어긋남 하나를 올렸다 — candidates[DH-C03].reason 이 「한 번밖에 안 써서」라고 적는데 이 도구가 적은 메타가 여덟 건이다. 자료와 어긋나 그 문장을 근거로 못 쓴다고 판단하고 안 넣었다. 오케스트레이터가 메타를 세어 확인한 뒤 계약을 「이 저장소 하나에서만 써서 … 실행 여덟 건이 전부 이 프로젝트의 증거 수집이고 다른 프로젝트나 브라우저 캡처에 걸어 본 적이 없다」로 고쳤다."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "S7",
|
||||||
|
"name": "Studio 저장",
|
||||||
|
"skill": "publishing-tech-log-to-studio",
|
||||||
|
"runBy": "orchestrator",
|
||||||
|
"status": "SKIPPED",
|
||||||
|
"skipReason": "Studio 반입을 요청받지 않았다. 이 배치에서 Studio 브라우저 세션은 통합 세션 A 가 소유하고, 게시 권한이 저장 권한과 분리돼 있지 않아 무인 저장이 막혀 있다 (A-studio-change-requests.md 의 CR-001). 화면을 열지 않았다.",
|
||||||
|
"skillEcho": "",
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"gates": [],
|
||||||
|
"notes": "기록 frontmatter 의 id 와 studio 는 비어 있다. 저장한 적이 없다는 뜻이고 그대로 둔다."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
---
|
||||||
|
id:
|
||||||
|
kind: CASE
|
||||||
|
slug: exit-code-read-behind-a-pipe
|
||||||
|
title: 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
|
||||||
|
topic: pipeline-gate-exit-codes
|
||||||
|
topicName: 관문의 종료 코드
|
||||||
|
project: document-haness
|
||||||
|
status: 게시 전
|
||||||
|
studio: ""
|
||||||
|
lastVerifiedOn: 2026-09-10
|
||||||
|
source:
|
||||||
|
- final/document.md#§2-관찰한-것
|
||||||
|
- final/document.md#§3-파이프-뒤의-종료-코드
|
||||||
|
- final/document.md#§4-다시-잰-값
|
||||||
|
- final/document.md#§5-argparse-를-쓰지-않는-셋
|
||||||
|
- final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다
|
||||||
|
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
|
||||||
|
evidence:
|
||||||
|
- ../../../final/evidence/raw/exit-code-through-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/argparse-absent-scripts.txt
|
||||||
|
- ../../../final/evidence/raw/what-the-three-print-for-help.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
|
||||||
|
|
||||||
|
`scripts/` 의 검사기 열넷에 `--help` 를 돌려 전부 종료 코드 0 을 받았다. 두 세션이 각자
|
||||||
|
같은 값을 얻어 계약 문서에까지 「14개 전부 `--help` 가 종료 코드 0」으로 들어갔다. 값은
|
||||||
|
검사기가 아니라 재는 방법이 만든 것이었다. 파이프를 걷어 내고 다시 재니 둘이 `exit 1` 이다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **검사할 것이 없을 때 관문은 무엇을 내야 하는가**
|
||||||
|
이 사건에서 `audit-records.py --help` 가 「문제 없음」을 찍으면서 그 물음이 열렸다.
|
||||||
|
`--help` 라는 이름의 프로젝트에는 검사할 기록이 하나도 없어 그대로 통과했다.
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
관문은 종료 코드로 말한다. 단계 계약이 「관문은 종료 코드가 0 이어야 지난 것이다」 라고
|
||||||
|
적었고, 런 원장 run.json 의 stages[].gates[].exit 에 그 값이 남는다. 사람이 셸에서 그 값을
|
||||||
|
읽어 옮겨 적는다.
|
||||||
|
|
||||||
|
--help 하나를 잰 것뿐인데 값이 두 번 틀렸다. 관문 결과 전부가 같은 방법으로 적히고 있다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
리비전 43e1aad 의 scripts/*.py 열넷 중 열둘이 --help 에 exit 0,
|
||||||
|
build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다.
|
||||||
|
exit 0 이 usage 가 나왔다는 뜻은 아니다 — audit-records.py 는 --help 를 프로젝트
|
||||||
|
이름으로 받아 검사하고 통과시킨다.
|
||||||
|
|
||||||
|
값을 틀리게 만든 것은 셸의 동작 둘이다. 파이프의 종료 코드 변수는 마지막 명령을 가리키고,
|
||||||
|
명령 치환은 그 변수를 덮어쓴다. 둘 다 정의된 동작이라 셸이 경고하지 않는다.
|
||||||
|
|
||||||
|
조치로 scripts/capture-evidence.py 를 만들었다. 명령을 subprocess 로 직접 돌리고 그
|
||||||
|
프로세스의 반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을
|
||||||
|
경로가 없다.
|
||||||
|
|
||||||
|
## 검증 환경
|
||||||
|
|
||||||
|
python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release · Linux.
|
||||||
|
대상 저장소 document-haness 리비전 43e1aadef077ad93c30495df428ee3a71dd73f4a.
|
||||||
|
측정은 그 커밋에서 갈라진 worktree dh-B 에서 했고, 그 시점 작업 트리에는
|
||||||
|
capture-evidence.py 가 더해져 있어 증거 메타의 sourceDirty 가 true 다.
|
||||||
|
측정 대상 열넷은 git ls-tree 로 그 커밋의 목록만 골라 냈다.
|
||||||
|
|
||||||
|
## 재현 조건
|
||||||
|
|
||||||
|
1. document-haness 를 43e1aad 로 체크아웃한다.
|
||||||
|
2. 파이프를 끼고 잰다 — 반복문 안에서 python3 출력을 head 로 넘기고 그다음 줄에서 $? 를 읽는다.
|
||||||
|
3. 열넷이 전부 exit=0 으로 나오는 것을 본다.
|
||||||
|
4. 파이프를 걷고 out=$(...) 다음 줄에서 code=$? 로 받아 다시 잰다.
|
||||||
|
5. build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit=1 로 갈리는 것을 본다.
|
||||||
|
|
||||||
|
## 본문
|
||||||
|
|
||||||
|
<!-- body:start -->
|
||||||
|
|
||||||
|
## 두 번 같은 값이 나왔다
|
||||||
|
|
||||||
|
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸다. 출력이 길어서 `head` 로 잘랐다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
열넷이 전부 `exit=0` 이었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
audit-records.py exit=0
|
||||||
|
build-tech-log-tree.py exit=0
|
||||||
|
check-figure-overlap.py exit=0
|
||||||
|
check-figure-text.py exit=0
|
||||||
|
fold-analysis-into-final.py exit=0
|
||||||
|
fold-studio-contract-into-index.py exit=0
|
||||||
|
preview-figure.py exit=0
|
||||||
|
studio-body.py exit=0
|
||||||
|
techlog.py exit=0
|
||||||
|
verify-pipeline-run.py exit=0
|
||||||
|
verify-pipeline.py exit=0
|
||||||
|
verify-project-layout.py exit=0
|
||||||
|
verify-refactor-work-item.py exit=0
|
||||||
|
verify-tech-log-tree.py exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help` 가
|
||||||
|
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
|
||||||
|
맞는 값처럼 보였다.
|
||||||
|
|
||||||
|
## 값을 만든 것은 셸이다
|
||||||
|
|
||||||
|
파이프라인의 `$?` 는 **마지막** 명령의 종료 코드다. `head` 는 언제나 성공하므로 앞의
|
||||||
|
`python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +o pipefail
|
||||||
|
false | head -1; echo "false | head -1 -> exit=$?"
|
||||||
|
false; echo "false -> exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
false | head -1 -> exit=0
|
||||||
|
false -> exit=1
|
||||||
|
bash 5.2.21(1)-release
|
||||||
|
```
|
||||||
|
|
||||||
|
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
|
||||||
|
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
|
||||||
|
|
||||||
|
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
echo "$(basename $s) exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어썼다. 그래서 두 번째 측정도
|
||||||
|
열넷 전부 0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
|
||||||
|
|
||||||
|
## 다시 잰 값
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
두 측정의 차이는 두 줄뿐이다. 잰 것은 `--help` 하나뿐이라 나머지 열둘이 다른 인자에서
|
||||||
|
어떻게 도는지는 이 값이 말해 주지 않는다.
|
||||||
|
|
||||||
|
```
|
||||||
|
2c2
|
||||||
|
< build-tech-log-tree.py exit=0
|
||||||
|
---
|
||||||
|
> build-tech-log-tree.py exit=1
|
||||||
|
13c13
|
||||||
|
< verify-refactor-work-item.py exit=0
|
||||||
|
---
|
||||||
|
> verify-refactor-work-item.py exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 왜 그 둘만인가
|
||||||
|
|
||||||
|
열넷 중 셋이 `argparse` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
| 스크립트 | `argparse` | `--help` |
|
||||||
|
|---|---|---|
|
||||||
|
| `audit-records.py` | 없음 | `exit 0` — 「문제 없음」 |
|
||||||
|
| `build-tech-log-tree.py` | 없음 | `exit 1` |
|
||||||
|
| `techlog.py` | 없음 | `exit 0` — CLI 가 아니라 인자를 안 읽는다 |
|
||||||
|
| `verify-refactor-work-item.py` | 없음 | `exit 1` |
|
||||||
|
| 나머지 열 | 있음 | `exit 0` — usage |
|
||||||
|
|
||||||
|
남은 둘은 `--help` 를 옵션이 아니라 위치 인자로 먹는다. `audit-records.py` 는 그것을
|
||||||
|
프로젝트 이름으로 받아 「문제 없음」을 찍고, `build-tech-log-tree.py` 와
|
||||||
|
`verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아 실패한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python3 scripts/audit-records.py --help
|
||||||
|
|
||||||
|
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
|
||||||
|
문제 없음
|
||||||
|
|
||||||
|
합계 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/build-tech-log-tree.py --help
|
||||||
|
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
|
||||||
|
exit=1
|
||||||
|
|
||||||
|
$ python3 scripts/verify-refactor-work-item.py --help
|
||||||
|
REFACTOR WORK ITEM VERIFICATION: FAIL
|
||||||
|
- invalid work-item.json: --help/work-item.json
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`exit 0` 이 usage 가 나왔다는 뜻은 아니다. `audit-records.py` 는 `--help` 라는 이름의
|
||||||
|
프로젝트를 찾아 검사하고 통과시켰다.
|
||||||
|
|
||||||
|
## 손으로 적지 못하게 했다
|
||||||
|
|
||||||
|
관문 결과를 사람이 옮겨 적는 한 같은 뿌리에서 계속 난다. 그래서 명령을 돌리는 쪽과
|
||||||
|
종료 코드를 적는 쪽을 하나로 붙였다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
proc = subprocess.run(command, cwd=cwd, capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
exit_code, out = proc.returncode, proc.stdout + proc.stderr
|
||||||
|
```
|
||||||
|
|
||||||
|
셸 한 줄을 감싸는 래퍼도 됐지만 그러면 파이프를 다시 쓸 수 있게 된다. 값을 두 번 틀리게
|
||||||
|
만든 것이 바로 그 셸이라 아예 거치지 않기로 했다. 대신 셸 문법이 필요한 명령은
|
||||||
|
`bash -c` 를 인자로 넘겨야 하고, 그 안에서 다시 파이프를 쓰면 같은 실수가 난다 — 이 기록의
|
||||||
|
증거 여섯 개도 그렇게 수집했다. 종료 코드를 인자로 받지 않으므로 손으로 적어 넣을 수는 없다. 원문은 `final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/` 에
|
||||||
|
같은 이름으로 함께 떨어진다.
|
||||||
|
|
||||||
|
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
|
||||||
|
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
|
||||||
|
지금은 `--` 앞뒤를 직접 가르고, 왜 그렇게 했는지를 코드에 한 줄로 남겨 두었다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
```
|
||||||
|
|
||||||
|
`--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다. 이 저장소에서 인자를 손으로
|
||||||
|
가르는 코드는 대개 여기서 걸린다.
|
||||||
|
|
||||||
|
이 기록이 인용한 원문 다섯 개를 그 도구가 수집했다. 메타 하나는 이렇게 생겼다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "help-exit-codes-measured-without-a-pipe",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:41+09:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
|
||||||
|
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
|
||||||
|
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
|
||||||
|
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
|
||||||
|
"bytes": 410
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
여기서 `exitCode: 0` 은 **측정 반복문 자체가 끝까지 돌았다**고 말한다. 열넷 각각의 종료
|
||||||
|
코드는 원문 `raw/` 안에 있다. 반복문이 반환한 값과 그 안에서 잰 값을 섞지 않는다 — 메타에는
|
||||||
|
반복문 쪽이 들어간다.
|
||||||
|
|
||||||
|
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지 적는다. 있으면
|
||||||
|
`sourceRevision` 이 그 출력을 설명하지 못한다. 이 다섯은 전부 `true` 다 — 수집기 자신을
|
||||||
|
더한 상태에서 쟀다.
|
||||||
|
|
||||||
|
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 쓰이고, 이 도구는 필요할
|
||||||
|
때만 부른다.
|
||||||
|
|
||||||
|
## 이 사건이 닫지 못한 것
|
||||||
|
|
||||||
|
수집기는 브라우저 캡처와 대화형 명령을 담지 못한다. 이 저장소의 증거 가운데
|
||||||
|
`final/evidence/browser/` 쪽은 여전히 Playwright 로 찍고 메타를 손으로 적는다 — 종료 코드를
|
||||||
|
손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다.
|
||||||
|
|
||||||
|
<!-- body:end -->
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
---
|
||||||
|
id:
|
||||||
|
kind: CASE
|
||||||
|
slug: exit-code-read-behind-a-pipe
|
||||||
|
title: 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
|
||||||
|
topic: pipeline-gate-exit-codes
|
||||||
|
topicName: 관문의 종료 코드
|
||||||
|
project: document-haness
|
||||||
|
status: 게시 전
|
||||||
|
studio: ""
|
||||||
|
lastVerifiedOn: 2026-09-10
|
||||||
|
source:
|
||||||
|
- final/document.md#§2-관찰한-것
|
||||||
|
- final/document.md#§3-파이프-뒤의-종료-코드
|
||||||
|
- final/document.md#§4-다시-잰-값
|
||||||
|
- final/document.md#§5-argparse-를-쓰지-않는-넷
|
||||||
|
- final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다
|
||||||
|
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
|
||||||
|
evidence:
|
||||||
|
- ../../../final/evidence/raw/exit-code-through-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/argparse-absent-scripts.txt
|
||||||
|
- ../../../final/evidence/raw/what-the-three-print-for-help.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
|
||||||
|
|
||||||
|
scripts/ 의 검사기 열넷에 --help 를 돌려 전부 종료 코드 0 을 받았다. 이 저장소를 함께
|
||||||
|
조사한 다른 세션도 같은 값을 얻어 확인 등급 확인함으로 적었다. 값은 검사기가 아니라 재는
|
||||||
|
방법이 만든 것이었다. 파이프를 걷어 내고 다시 재니 둘이 exit 1 이다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **검사할 것이 없을 때 관문은 무엇을 내야 하는가**
|
||||||
|
이 사건에서 `audit-records.py --help` 가 「문제 없음」을 찍으면서 그 물음이 열렸다.
|
||||||
|
`--help` 라는 이름의 프로젝트에는 검사할 기록이 하나도 없어 그대로 통과했다.
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
관문은 종료 코드로 말한다. 단계 계약이 「관문은 종료 코드가 0 이어야 지난 것이다」 라고
|
||||||
|
적었고, 런 원장 run.json 의 stages[].gates[].exit 에 그 값이 남는다. 사람이 셸에서 그 값을
|
||||||
|
읽어 옮겨 적는다.
|
||||||
|
|
||||||
|
--help 하나를 잰 것뿐인데 값이 두 번 틀렸다. 관문 결과 전부가 같은 방법으로 적히고 있다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
리비전 43e1aad 의 scripts/*.py 열넷 중 열둘이 --help 에 exit 0,
|
||||||
|
build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다.
|
||||||
|
exit 0 이 usage 가 나왔다는 뜻은 아니다 — audit-records.py 는 --help 를 프로젝트
|
||||||
|
이름으로 받아 검사하고 통과시킨다.
|
||||||
|
|
||||||
|
값을 틀리게 만든 것은 셸의 동작 둘이다. 파이프의 종료 코드 변수는 마지막 명령을 가리키고,
|
||||||
|
명령 치환은 그 변수를 덮어쓴다. 둘 다 정의된 동작이라 셸이 경고하지 않는다.
|
||||||
|
|
||||||
|
조치로 scripts/capture-evidence.py 를 만들었다. 명령을 subprocess 로 직접 돌리고 그
|
||||||
|
프로세스의 반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을
|
||||||
|
경로가 없다.
|
||||||
|
|
||||||
|
## 검증 환경
|
||||||
|
|
||||||
|
python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release · Linux.
|
||||||
|
대상 저장소 document-haness 리비전 43e1aadef077ad93c30495df428ee3a71dd73f4a.
|
||||||
|
측정은 그 커밋에서 갈라진 worktree dh-B 에서 했고, 그 시점 작업 트리에는
|
||||||
|
capture-evidence.py 가 더해져 있어 증거 메타의 sourceDirty 가 true 다.
|
||||||
|
측정 대상 열넷은 git ls-tree 로 그 커밋의 목록만 골라 냈다.
|
||||||
|
|
||||||
|
## 재현 조건
|
||||||
|
|
||||||
|
1. document-haness 를 43e1aad 로 체크아웃한다.
|
||||||
|
2. 파이프를 끼고 잰다 — 반복문 안에서 python3 출력을 head 로 넘기고 그다음 줄에서 $? 를 읽는다.
|
||||||
|
3. 열넷이 전부 exit=0 으로 나오는 것을 본다.
|
||||||
|
4. 파이프를 걷고 out=$(...) 다음 줄에서 code=$? 로 받아 다시 잰다.
|
||||||
|
5. build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit=1 로 갈리는 것을 본다.
|
||||||
|
|
||||||
|
## 본문
|
||||||
|
|
||||||
|
<!-- body:start -->
|
||||||
|
|
||||||
|
## 두 번 같은 값이 나왔다
|
||||||
|
|
||||||
|
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸다. 출력이 길어서 `head` 로 잘랐다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
열넷이 전부 `exit=0` 이었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
audit-records.py exit=0
|
||||||
|
build-tech-log-tree.py exit=0
|
||||||
|
check-figure-overlap.py exit=0
|
||||||
|
check-figure-text.py exit=0
|
||||||
|
fold-analysis-into-final.py exit=0
|
||||||
|
fold-studio-contract-into-index.py exit=0
|
||||||
|
preview-figure.py exit=0
|
||||||
|
studio-body.py exit=0
|
||||||
|
techlog.py exit=0
|
||||||
|
verify-pipeline-run.py exit=0
|
||||||
|
verify-pipeline.py exit=0
|
||||||
|
verify-project-layout.py exit=0
|
||||||
|
verify-refactor-work-item.py exit=0
|
||||||
|
verify-tech-log-tree.py exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help` 가
|
||||||
|
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
|
||||||
|
맞는 값처럼 보였다.
|
||||||
|
|
||||||
|
## 값을 만든 것은 셸이다
|
||||||
|
|
||||||
|
파이프라인의 `$?` 는 **마지막** 명령의 종료 코드다. `head` 는 언제나 성공하므로 앞의
|
||||||
|
`python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +o pipefail
|
||||||
|
false | head -1; echo "false | head -1 -> exit=$?"
|
||||||
|
false; echo "false -> exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
false | head -1 -> exit=0
|
||||||
|
false -> exit=1
|
||||||
|
bash 5.2.21(1)-release
|
||||||
|
```
|
||||||
|
|
||||||
|
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
|
||||||
|
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
|
||||||
|
|
||||||
|
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
echo "$(basename $s) exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어썼다. 그래서 두 번째 측정도
|
||||||
|
열넷 전부 0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
|
||||||
|
|
||||||
|
## 다시 잰 값
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
두 측정의 차이는 두 줄뿐이다. 잰 것은 `--help` 하나뿐이라 나머지 열둘이 다른 인자에서
|
||||||
|
어떻게 도는지는 이 값이 말해 주지 않는다.
|
||||||
|
|
||||||
|
```
|
||||||
|
2c2
|
||||||
|
< build-tech-log-tree.py exit=0
|
||||||
|
---
|
||||||
|
> build-tech-log-tree.py exit=1
|
||||||
|
13c13
|
||||||
|
< verify-refactor-work-item.py exit=0
|
||||||
|
---
|
||||||
|
> verify-refactor-work-item.py exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 왜 그 둘만인가
|
||||||
|
|
||||||
|
열넷 중 넷이 `argparse` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
| 스크립트 | `argparse` | `--help` |
|
||||||
|
|---|---|---|
|
||||||
|
| `audit-records.py` | 없음 | `exit 0` — 「문제 없음」 |
|
||||||
|
| `build-tech-log-tree.py` | 없음 | `exit 1` |
|
||||||
|
| `techlog.py` | 없음 | `exit 0` — CLI 가 아니라 인자를 안 읽는다 |
|
||||||
|
| `verify-refactor-work-item.py` | 없음 | `exit 1` |
|
||||||
|
| 나머지 열 | 있음 | `exit 0` — usage |
|
||||||
|
|
||||||
|
남은 셋은 `--help` 를 옵션이 아니라 위치 인자로 먹는다. `audit-records.py` 는 그것을
|
||||||
|
프로젝트 이름으로 받아 「문제 없음」을 찍고, `build-tech-log-tree.py` 와
|
||||||
|
`verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아 실패한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python3 scripts/audit-records.py --help
|
||||||
|
|
||||||
|
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
|
||||||
|
문제 없음
|
||||||
|
|
||||||
|
합계 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/build-tech-log-tree.py --help
|
||||||
|
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
|
||||||
|
exit=1
|
||||||
|
|
||||||
|
$ python3 scripts/verify-refactor-work-item.py --help
|
||||||
|
REFACTOR WORK ITEM VERIFICATION: FAIL
|
||||||
|
- invalid work-item.json: --help/work-item.json
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`exit 0` 이 usage 가 나왔다는 뜻은 아니다. `audit-records.py` 는 `--help` 라는 이름의
|
||||||
|
프로젝트를 찾아 검사하고 통과시켰다.
|
||||||
|
|
||||||
|
## 손으로 적지 못하게 했다
|
||||||
|
|
||||||
|
관문 결과를 사람이 옮겨 적는 한 같은 뿌리에서 계속 난다. 그래서 명령을 돌리는 쪽과
|
||||||
|
종료 코드를 적는 쪽을 하나로 붙였다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
proc = subprocess.run(command, cwd=cwd, capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
exit_code, out = proc.returncode, proc.stdout + proc.stderr
|
||||||
|
```
|
||||||
|
|
||||||
|
셸 한 줄을 감싸는 래퍼도 됐지만 그러면 파이프를 다시 쓸 수 있게 된다. 값을 두 번 틀리게
|
||||||
|
만든 것이 바로 그 셸이라 아예 거치지 않기로 했다. 대신 셸 문법이 필요한 명령은
|
||||||
|
`bash -c` 를 인자로 넘겨야 하고, 그 안에서 다시 파이프를 쓰면 같은 실수가 난다 — 이 기록의
|
||||||
|
증거 여섯 개도 그렇게 수집했다. 종료 코드를 인자로 받지 않으므로 손으로 적어 넣을 수는 없다. 원문은 `final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/` 에
|
||||||
|
같은 이름으로 함께 떨어진다.
|
||||||
|
|
||||||
|
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
|
||||||
|
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
|
||||||
|
지금은 `--` 앞뒤를 직접 가르고, 왜 그렇게 했는지를 코드에 한 줄로 남겨 두었다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
```
|
||||||
|
|
||||||
|
`--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다. 이 저장소에서 인자를 손으로
|
||||||
|
가르는 코드는 대개 여기서 걸린다.
|
||||||
|
|
||||||
|
이 기록이 인용한 원문 다섯 개를 그 도구가 수집했다. 메타 하나는 이렇게 생겼다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "help-exit-codes-measured-without-a-pipe",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:41+09:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
|
||||||
|
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
|
||||||
|
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
|
||||||
|
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
|
||||||
|
"bytes": 410
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
여기서 `exitCode: 0` 은 **측정 반복문 자체가 끝까지 돌았다**고 말한다. 열넷 각각의 종료
|
||||||
|
코드는 원문 `raw/` 안에 있다. 반복문이 반환한 값과 그 안에서 잰 값을 섞지 않는다 — 메타에는
|
||||||
|
반복문 쪽이 들어간다.
|
||||||
|
|
||||||
|
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지 적는다. 있으면
|
||||||
|
`sourceRevision` 이 그 출력을 설명하지 못한다. 이 다섯은 전부 `true` 다 — 수집기 자신을
|
||||||
|
더한 상태에서 쟀다.
|
||||||
|
|
||||||
|
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 쓰이고, 이 도구는 필요할
|
||||||
|
때만 부른다.
|
||||||
|
|
||||||
|
## 이 사건이 닫지 못한 것
|
||||||
|
|
||||||
|
수집기는 브라우저 캡처와 대화형 명령을 담지 못한다. 이 저장소의 증거 가운데
|
||||||
|
`final/evidence/browser/` 쪽은 여전히 Playwright 로 찍고 메타를 손으로 적는다 — 종료 코드를
|
||||||
|
손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다.
|
||||||
|
|
||||||
|
<!-- body:end -->
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
---
|
||||||
|
id:
|
||||||
|
kind: CASE
|
||||||
|
slug: exit-code-read-behind-a-pipe
|
||||||
|
title: 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
|
||||||
|
topic: pipeline-gate-exit-codes
|
||||||
|
topicName: 관문의 종료 코드
|
||||||
|
project: document-haness
|
||||||
|
status: 게시 전
|
||||||
|
studio: ""
|
||||||
|
lastVerifiedOn: 2026-09-10
|
||||||
|
source:
|
||||||
|
- final/document.md#§2-관찰한-것
|
||||||
|
- final/document.md#§3-파이프-뒤의-종료-코드
|
||||||
|
- final/document.md#§4-다시-잰-값
|
||||||
|
- final/document.md#§5-argparse-를-쓰지-않는-넷
|
||||||
|
- final/document.md#§8-종료-코드를-손으로-적을-수-없게-만든다
|
||||||
|
sourceRevision: 43e1aadef077ad93c30495df428ee3a71dd73f4a
|
||||||
|
evidence:
|
||||||
|
- ../../../final/evidence/raw/exit-code-through-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt
|
||||||
|
- ../../../final/evidence/raw/argparse-absent-scripts.txt
|
||||||
|
- ../../../final/evidence/raw/what-the-three-print-for-help.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었다
|
||||||
|
|
||||||
|
scripts/ 의 검사기 열넷에 --help 를 돌려 전부 종료 코드 0 을 받았고, 이 저장소를 함께
|
||||||
|
조사한 다른 세션도 같은 값을 얻어 확인 등급 확인함으로 적었다. 값은 검사기가 아니라 재는
|
||||||
|
방법이 만든 것이었다. 파이프를 걷어 내고 다시 재니 둘이 exit 1 이다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **검사할 것이 없을 때 관문은 무엇을 내야 하는가**
|
||||||
|
이 사건에서 `audit-records.py --help` 가 「문제 없음」을 찍으면서 그 물음이 열렸다.
|
||||||
|
`--help` 라는 이름의 프로젝트에는 검사할 기록이 하나도 없어 그대로 통과했다.
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
관문은 종료 코드로 말한다. 단계 계약이 「관문은 종료 코드가 0 이어야 지난 것이다」 라고
|
||||||
|
적었고, 런 원장 run.json 의 stages[].gates[].exit 에 그 값이 남는데, 그 값을 사람이 셸에서
|
||||||
|
읽어 옮겨 적는다.
|
||||||
|
|
||||||
|
--help 하나를 잰 것뿐인데 값이 두 번 틀렸다. 관문 결과 전부가 같은 방법으로 적히고 있다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
리비전 43e1aad 의 scripts/*.py 열넷 중 열둘이 --help 에 exit 0,
|
||||||
|
build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다.
|
||||||
|
exit 0 이 usage 가 나왔다는 뜻은 아니다 — audit-records.py 는 --help 를 프로젝트
|
||||||
|
이름으로 받아 검사하고 통과시킨다.
|
||||||
|
|
||||||
|
값을 틀리게 만든 것은 셸의 동작 둘이다. 파이프의 종료 코드 변수는 마지막 명령을 가리키고,
|
||||||
|
명령 치환은 그 변수를 덮어쓴다. 둘 다 정의된 동작이라 셸이 경고하지 않는다.
|
||||||
|
|
||||||
|
조치로 만든 scripts/capture-evidence.py 는 명령을 subprocess 로 직접 돌리고 그 프로세스의
|
||||||
|
반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을 경로가 없다.
|
||||||
|
|
||||||
|
## 검증 환경
|
||||||
|
|
||||||
|
python 3.12.3 · node v24.14.0 · bash 5.2.21(1)-release · Linux.
|
||||||
|
대상 저장소 document-haness 리비전 43e1aadef077ad93c30495df428ee3a71dd73f4a.
|
||||||
|
측정은 그 커밋에서 갈라진 worktree dh-B 에서 했고, 그 시점 작업 트리에는
|
||||||
|
capture-evidence.py 가 더해져 있어 증거 메타의 sourceDirty 가 true 다.
|
||||||
|
측정 대상 열넷은 git ls-tree 로 그 커밋의 목록만 골라 냈다.
|
||||||
|
|
||||||
|
## 재현 조건
|
||||||
|
|
||||||
|
1. document-haness 를 43e1aad 로 체크아웃한다.
|
||||||
|
2. 파이프를 끼고 잰다 — 반복문 안에서 python3 출력을 head 로 넘기고 그다음 줄에서 $? 를 읽는다.
|
||||||
|
3. 열넷이 전부 exit=0 으로 나오는 것을 본다.
|
||||||
|
4. 파이프를 걷고 out=$(...) 다음 줄에서 code=$? 로 받아 다시 잰다.
|
||||||
|
5. build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit=1 로 갈리는 것을 본다.
|
||||||
|
|
||||||
|
## 본문
|
||||||
|
|
||||||
|
<!-- body:start -->
|
||||||
|
|
||||||
|
## 두 번 같은 값이 나왔다
|
||||||
|
|
||||||
|
검사기 목록을 만들려고 열넷에 `--help` 를 돌렸는데, 출력이 길어서 `head` 로 잘랐다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
timeout 60 python3 $s --help 2>&1 | head -30 >/dev/null
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
열넷이 전부 `exit=0` 이었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
audit-records.py exit=0
|
||||||
|
build-tech-log-tree.py exit=0
|
||||||
|
check-figure-overlap.py exit=0
|
||||||
|
check-figure-text.py exit=0
|
||||||
|
fold-analysis-into-final.py exit=0
|
||||||
|
fold-studio-contract-into-index.py exit=0
|
||||||
|
preview-figure.py exit=0
|
||||||
|
studio-body.py exit=0
|
||||||
|
techlog.py exit=0
|
||||||
|
verify-pipeline-run.py exit=0
|
||||||
|
verify-pipeline.py exit=0
|
||||||
|
verify-project-layout.py exit=0
|
||||||
|
verify-refactor-work-item.py exit=0
|
||||||
|
verify-tech-log-tree.py exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
이 저장소를 함께 조사한 다른 세션도 같은 값을 얻어 「`scripts/*.py` 14개 전부 `--help` 가
|
||||||
|
종료 코드 0 으로 돌아온다」 를 확인 등급 **확인함**으로 적었다. 두 사람이 같은 값을 얻었으니
|
||||||
|
맞는 값처럼 보였다.
|
||||||
|
|
||||||
|
## 값을 만든 것은 셸이다
|
||||||
|
|
||||||
|
파이프라인의 `$?` 는 **마지막** 명령의 종료 코드다. `head` 는 언제나 성공하므로 앞의
|
||||||
|
`python3` 가 무엇을 반환하든 `$?` 는 0 이 된다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set +o pipefail
|
||||||
|
false | head -1; echo "false | head -1 -> exit=$?"
|
||||||
|
false; echo "false -> exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
false | head -1 -> exit=0
|
||||||
|
false -> exit=1
|
||||||
|
bash 5.2.21(1)-release
|
||||||
|
```
|
||||||
|
|
||||||
|
POSIX 셸의 정의된 동작이고 이 셸의 특이점이 아니다. `pipefail` 을 켜거나
|
||||||
|
`${PIPESTATUS[0]}` 를 읽으면 앞 명령의 값을 얻는다.
|
||||||
|
|
||||||
|
같은 착각이 한 번 더 났다. 파이프를 걷어 내고 다시 잴 때 이렇게 썼다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
echo "$(basename $s) exit=$?"
|
||||||
|
```
|
||||||
|
|
||||||
|
명령 치환 `$(basename $s)` 가 먼저 실행되면서 `$?` 를 덮어써서 두 번째 측정도 열넷 전부
|
||||||
|
0 이었다. `code=$?` 를 명령 바로 다음 줄에 두고서야 값이 갈렸다.
|
||||||
|
|
||||||
|
## 다시 잰 값
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for s in $(git ls-tree --name-only 43e1aad scripts/ | grep '\.py$'); do
|
||||||
|
out=$(timeout 60 python3 $s --help 2>&1)
|
||||||
|
code=$?
|
||||||
|
n=$(basename $s)
|
||||||
|
echo "$n exit=$code"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
두 측정의 차이는 두 줄뿐이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
2c2
|
||||||
|
< build-tech-log-tree.py exit=0
|
||||||
|
---
|
||||||
|
> build-tech-log-tree.py exit=1
|
||||||
|
13c13
|
||||||
|
< verify-refactor-work-item.py exit=0
|
||||||
|
---
|
||||||
|
> verify-refactor-work-item.py exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
잰 것은 `--help` 하나뿐이라 나머지 열둘이 다른 인자에서 어떻게 도는지는 이 값이 말해 주지
|
||||||
|
않는다.
|
||||||
|
|
||||||
|
## 왜 그 둘만인가
|
||||||
|
|
||||||
|
열넷 중 넷이 `argparse` 를 쓰지 않는다.
|
||||||
|
|
||||||
|
| 스크립트 | `argparse` | `--help` |
|
||||||
|
|---|---|---|
|
||||||
|
| `audit-records.py` | 없음 | `exit 0` — 「문제 없음」 |
|
||||||
|
| `build-tech-log-tree.py` | 없음 | `exit 1` |
|
||||||
|
| `techlog.py` | 없음 | `exit 0` — CLI 가 아니라 인자를 안 읽는다 |
|
||||||
|
| `verify-refactor-work-item.py` | 없음 | `exit 1` |
|
||||||
|
| 나머지 열 | 있음 | `exit 0` — usage |
|
||||||
|
|
||||||
|
남은 셋은 `--help` 를 옵션이 아니라 위치 인자로 먹는다. `audit-records.py` 는 그것을
|
||||||
|
프로젝트 이름으로 받아 「문제 없음」을 찍고, `build-tech-log-tree.py` 와
|
||||||
|
`verify-refactor-work-item.py` 는 그 이름의 파일을 못 찾아 실패한다.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python3 scripts/audit-records.py --help
|
||||||
|
|
||||||
|
--help — 기록 0건 · 원문 0 · 메타 0 · 렌더 0
|
||||||
|
문제 없음
|
||||||
|
|
||||||
|
합계 0건
|
||||||
|
exit=0
|
||||||
|
|
||||||
|
$ python3 scripts/build-tech-log-tree.py --help
|
||||||
|
--help: tech-log-tree.json 이 없다. 글감을 먼저 적는다
|
||||||
|
exit=1
|
||||||
|
|
||||||
|
$ python3 scripts/verify-refactor-work-item.py --help
|
||||||
|
REFACTOR WORK ITEM VERIFICATION: FAIL
|
||||||
|
- invalid work-item.json: --help/work-item.json
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`exit 0` 이 usage 가 나왔다는 뜻은 아니다. `audit-records.py` 는 `--help` 라는 이름의
|
||||||
|
프로젝트를 찾아 검사하고 통과시켰다.
|
||||||
|
|
||||||
|
## 손으로 적지 못하게 했다
|
||||||
|
|
||||||
|
관문 결과를 사람이 옮겨 적는 한 같은 뿌리에서 같은 실수가 계속 난다. 그래서 명령을 돌리는
|
||||||
|
쪽과 종료 코드를 적는 쪽을 하나로 붙였다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
proc = subprocess.run(command, cwd=cwd, capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
exit_code, out = proc.returncode, proc.stdout + proc.stderr
|
||||||
|
```
|
||||||
|
|
||||||
|
종료 코드를 인자로 받지 않으므로 손으로 적어 넣을 수는 없다. 원문은
|
||||||
|
`final/evidence/raw/` 에, 실행 메타는 `final/evidence/meta/` 에 같은 이름으로 함께 떨어진다.
|
||||||
|
|
||||||
|
셸 한 줄을 감싸는 래퍼도 됐지만 그러면 파이프를 다시 쓸 수 있게 된다. 값을 두 번 틀리게
|
||||||
|
만든 것이 바로 그 셸이라 아예 거치지 않기로 했다. 대신 셸 문법이 필요한 명령은 `bash -c` 를
|
||||||
|
인자로 넘겨야 하고, 그 안에서 다시 파이프를 쓰면 같은 실수가 난다 — 이 기록의 증거
|
||||||
|
다섯 개도 그렇게 수집했다.
|
||||||
|
|
||||||
|
처음 판에서는 이 도구도 인자를 잘못 먹었다. `argparse.REMAINDER` 로 명령을 받았더니
|
||||||
|
`--proves` 부터가 실행할 명령으로 딸려 가 `No such file or directory: '--proves'` 로 죽었다.
|
||||||
|
지금은 `--` 앞뒤를 직접 가르고, 왜 그렇게 했는지를 코드에 한 줄로 남겨 두었다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
```
|
||||||
|
|
||||||
|
`--help` 를 위치 인자로 먹은 세 스크립트와 같은 종류의 실수다. 이 저장소에서 인자를 손으로
|
||||||
|
가르는 코드는 대개 여기서 걸린다.
|
||||||
|
|
||||||
|
이 기록이 인용한 원문 다섯 개를 그 도구가 수집했다. 메타 하나는 이렇게 생겼다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "help-exit-codes-measured-without-a-pipe",
|
||||||
|
"kind": "terminal",
|
||||||
|
"sourceRevision": "43e1aadef077ad93c30495df428ee3a71dd73f4a",
|
||||||
|
"sourceDirty": true,
|
||||||
|
"executedAt": "2026-09-10T09:54:41+09:00",
|
||||||
|
"exitCode": 0,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": "evidence/raw/help-exit-codes-measured-without-a-pipe.txt",
|
||||||
|
"proves": "파이프 없이 재면 같은 14개 중 build-tech-log-tree.py 와 verify-refactor-work-item.py 가 exit 1 이다",
|
||||||
|
"doesNotProve": "다른 인자에서의 동작. --help 하나만 잰 값이다",
|
||||||
|
"sha256": "e8c134d07592bb1b051ea6ad3abf952a9fa502fc85eb26cdb56194447ea03a9b",
|
||||||
|
"bytes": 410
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
여기서 `exitCode: 0` 은 **측정 반복문 자체가 끝까지 돌았다**고 말한다. 열넷 각각의 종료
|
||||||
|
코드는 원문 `raw/` 안에 있다. 반복문이 반환한 값과 그 안에서 잰 값을 섞지 않는다 — 메타에는
|
||||||
|
반복문 쪽이 들어간다.
|
||||||
|
|
||||||
|
`sourceDirty` 는 그 실행 시점에 작업 트리에 커밋 안 된 변경이 있었는지 적는다. 있으면
|
||||||
|
`sourceRevision` 이 그 출력을 설명하지 못한다. 이 다섯은 전부 `true` 다 — 수집기 자신을
|
||||||
|
더한 상태에서 쟀다.
|
||||||
|
|
||||||
|
기존 경로를 지우지 않았다. 손으로 만든 `raw/`·`meta/` 도 그대로 쓰이고, 이 도구는 필요할
|
||||||
|
때만 부른다.
|
||||||
|
|
||||||
|
## 이 사건이 닫지 못한 것
|
||||||
|
|
||||||
|
수집기는 브라우저 캡처와 대화형 명령을 담지 못한다. 이 저장소의 증거 가운데
|
||||||
|
`final/evidence/browser/` 쪽은 여전히 Playwright 로 찍고 메타를 손으로 적는다 — 종료 코드를
|
||||||
|
손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다.
|
||||||
|
|
||||||
|
<!-- body:end -->
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""명령을 실제로 돌려 원문과 실행 메타를 함께 적립한다.
|
||||||
|
|
||||||
|
`final/evidence/raw/` 는 정본이고 `final/evidence/meta/` 는 그 실행의
|
||||||
|
command·cwd·executedAt·exitCode·revision 이다. 둘을 사람이 따로 적으면 갈라진다 —
|
||||||
|
`verify-project-layout.py` 가 「raw 는 있는데 meta 가 없다」로 세는 자리가 그것이다.
|
||||||
|
|
||||||
|
이 도구는 **종료 코드를 손으로 적을 수 없게 만든다.** 명령을 여기서 돌리고, 그 프로세스의
|
||||||
|
반환값을 그대로 meta 에 적는다. 돌리지 않은 검증을 완료로 적는 경로가 없어야 한다.
|
||||||
|
|
||||||
|
기존 경로를 지우지 않는다 — 손으로 만든 raw/meta 도 그대로 유효하고, 이 도구는 선택적으로 쓴다.
|
||||||
|
|
||||||
|
python3 scripts/capture-evidence.py <프로젝트> <증거 id> -- <명령...>
|
||||||
|
python3 scripts/capture-evidence.py <프로젝트> <증거 id> --cwd <경로> \
|
||||||
|
--proves "<이 출력이 뒷받침하는 것>" --does-not-prove "<뒷받침하지 못하는 것>" -- <명령...>
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def _revision(cwd: str) -> str | None:
|
||||||
|
"""그 작업 디렉터리 저장소의 HEAD. 저장소가 아니면 None 이다."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=cwd,
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return out.stdout.strip() if out.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _dirty(cwd: str) -> bool | None:
|
||||||
|
"""작업 트리에 커밋 안 된 변경이 있나. 있으면 revision 이 출력을 설명하지 못한다."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["git", "status", "--porcelain"], cwd=cwd,
|
||||||
|
capture_output=True, text=True, timeout=15)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return bool(out.stdout.strip()) if out.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def capture(project: str, eid: str, command: list[str], cwd: str,
|
||||||
|
proves: str, does_not_prove: str, kind: str,
|
||||||
|
timeout: int, subdir: str) -> int:
|
||||||
|
base = os.path.join(ROOT, "docs", project, "final", "evidence")
|
||||||
|
raw_dir = os.path.join(base, "raw", subdir) if subdir else os.path.join(base, "raw")
|
||||||
|
meta_dir = os.path.join(base, "meta")
|
||||||
|
os.makedirs(raw_dir, exist_ok=True)
|
||||||
|
os.makedirs(meta_dir, exist_ok=True)
|
||||||
|
|
||||||
|
started = datetime.datetime.now().astimezone()
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(command, cwd=cwd, capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
exit_code, out = proc.returncode, proc.stdout + proc.stderr
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
exit_code = 124
|
||||||
|
out = (e.stdout or "") + (e.stderr or "") + f"\n[timeout {timeout}s]\n"
|
||||||
|
except OSError as e:
|
||||||
|
print(f"명령을 실행하지 못했다: {e}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
raw_rel = os.path.join("raw", subdir, f"{eid}.txt") if subdir else os.path.join("raw", f"{eid}.txt")
|
||||||
|
raw_path = os.path.join(base, raw_rel)
|
||||||
|
with open(raw_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(out)
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"id": eid,
|
||||||
|
"kind": kind,
|
||||||
|
"sourceRevision": _revision(cwd),
|
||||||
|
"sourceDirty": _dirty(cwd),
|
||||||
|
"executedAt": started.isoformat(timespec="seconds"),
|
||||||
|
"executedAtSource": "이 도구가 명령을 실행한 시각",
|
||||||
|
"command": " ".join(command),
|
||||||
|
"cwd": os.path.relpath(cwd, ROOT) if cwd.startswith(ROOT) else cwd,
|
||||||
|
"exitCode": exit_code,
|
||||||
|
"exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다",
|
||||||
|
"rawPath": f"evidence/{raw_rel}",
|
||||||
|
"presentationPath": None,
|
||||||
|
"proves": proves,
|
||||||
|
"doesNotProve": does_not_prove,
|
||||||
|
"sha256": hashlib.sha256(out.encode("utf-8")).hexdigest(),
|
||||||
|
"bytes": len(out.encode("utf-8")),
|
||||||
|
}
|
||||||
|
meta_path = os.path.join(meta_dir, f"{eid}.json")
|
||||||
|
tmp = meta_path + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(meta, fh, ensure_ascii=False, indent=2)
|
||||||
|
fh.write("\n")
|
||||||
|
os.replace(tmp, meta_path)
|
||||||
|
|
||||||
|
print(f"{os.path.relpath(raw_path, ROOT)} exit={exit_code} {meta['bytes']}B")
|
||||||
|
print(f"{os.path.relpath(meta_path, ROOT)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="명령을 돌려 raw 원문과 실행 메타를 함께 적립한다.")
|
||||||
|
ap.add_argument("project")
|
||||||
|
ap.add_argument("evidence_id")
|
||||||
|
ap.add_argument("--cwd", default=ROOT)
|
||||||
|
ap.add_argument("--subdir", default="", help="raw/ 아래 하위 폴더")
|
||||||
|
ap.add_argument("--kind", default="terminal",
|
||||||
|
choices=["terminal", "browser", "query-plan", "benchmark", "other"])
|
||||||
|
ap.add_argument("--proves", default="", help="이 출력이 뒷받침하는 것 (경계까지)")
|
||||||
|
ap.add_argument("--does-not-prove", default="", help="이 출력이 뒷받침하지 못하는 것")
|
||||||
|
ap.add_argument("--timeout", type=int, default=600)
|
||||||
|
|
||||||
|
# `--` 앞뒤를 먼저 가른다. argparse.REMAINDER 에 맡기면 옵션이 명령으로 딸려 간다
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
if "-h" in argv or "--help" in argv:
|
||||||
|
ap.parse_args(["--help"])
|
||||||
|
if "--" not in argv:
|
||||||
|
ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다")
|
||||||
|
cut = argv.index("--")
|
||||||
|
args = ap.parse_args(argv[:cut])
|
||||||
|
command = argv[cut + 1:]
|
||||||
|
if not command:
|
||||||
|
ap.error("돌릴 명령이 없다. `-- <명령...>` 으로 준다")
|
||||||
|
return capture(args.project, args.evidence_id, command,
|
||||||
|
os.path.abspath(args.cwd), args.proves, args.does_not_prove,
|
||||||
|
args.kind, args.timeout, args.subdir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""문장을 고치기 전과 후에 보호 구간이 그대로인지 본다.
|
||||||
|
|
||||||
|
윤문(S5·S6)은 뜻을 바꾸지 않고 문장만 고치는 단계다. 그런데 지금 관문 가운데 **편집 전후를
|
||||||
|
견주는 것이 하나도 없다.** `check_prose` 는 고친 뒤 파일만 보고, `check_evidence` 는 인용이
|
||||||
|
SSOT 에 있는지만 본다. 그래서 수치를 바꾸거나 유보를 지운 편집이 그대로 통과한다.
|
||||||
|
|
||||||
|
보는 것은 둘이다.
|
||||||
|
|
||||||
|
**1. 보호 구간** — 수치·날짜·버전·단위·코드·명령어·URL·직접 인용은 한 글자도 달라지면 안 된다
|
||||||
|
(CLAUDE.md 「작업 규칙」). 사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸
|
||||||
|
값일 수 있어서 사라진 것과 같은 무게로 본다.
|
||||||
|
|
||||||
|
**2. 유보 표현의 수** — 「추정」·「보인다」·「확인하지 못했다」 같은 말이 편집으로 줄면
|
||||||
|
확신이 올라간 것이다. **이 검사기는 그것이 옳은지 모른다.** 줄었다는 사실만 내고 판단은
|
||||||
|
근거를 받은 검토가 한다. 늘어난 것은 세지 않는다 — 유보를 더하는 것은 이 규범에서 안전한 쪽이다.
|
||||||
|
|
||||||
|
python3 scripts/check-preservation.py <편집 전.md> <편집 후.md>
|
||||||
|
python3 scripts/check-preservation.py --json <before> <after>
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# 보호 구간. CLAUDE.md 「수치, 날짜, 버전, 단위, 코드, 명령어, URL, 직접 인용, 공식 명칭」
|
||||||
|
EXTRACTORS: dict[str, re.Pattern[str]] = {
|
||||||
|
"코드블록": re.compile(r"```[^\n]*\n(.*?)```", re.S),
|
||||||
|
"인라인코드": re.compile(r"`([^`\n]+)`"),
|
||||||
|
"URL": re.compile(r"(https?://[^\s`)\"'\]]+)"),
|
||||||
|
"직접인용": re.compile(r"「([^」]+)」"),
|
||||||
|
# 수치 — 소수·천단위 구분·단위·백분율·시각까지 한 덩어리로 잡는다.
|
||||||
|
# 앞뒤가 한글이면 낱말의 일부일 수 있어 낱말 경계를 요구한다
|
||||||
|
"수치": re.compile(r"(?<![\w.-])(\d[\d,]*(?:\.\d+)?(?:\s?%|ms|s|MB|GB|KB|B|건|장|개|줄|분|초|회)?)(?![\w.-])"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 유보 표현. 늘어난 것은 세지 않고 줄어든 것만 낸다
|
||||||
|
HEDGES = (
|
||||||
|
"추정", "가능성", "보인다", "보였다", "아마", "듯", "일 수 있다", "일지도",
|
||||||
|
"확인하지 못했다", "확인하지 않았다", "미확인", "안 봤다", "못 봤다",
|
||||||
|
"모른다", "정하지 않았다", "재지 않았다", "돌리지 않았다", "열지 않았다",
|
||||||
|
"로컬", "이 환경에서", "이번에는", "한정", "범위 안",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _counts(text: str) -> dict[str, collections.Counter]:
|
||||||
|
out = {}
|
||||||
|
for name, pat in EXTRACTORS.items():
|
||||||
|
out[name] = collections.Counter(m.strip() for m in pat.findall(text))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hedges(text: str) -> collections.Counter:
|
||||||
|
return collections.Counter({h: text.count(h) for h in HEDGES if text.count(h)})
|
||||||
|
|
||||||
|
|
||||||
|
def compare(before: str, after: str) -> dict:
|
||||||
|
b, a = _counts(before), _counts(after)
|
||||||
|
findings = []
|
||||||
|
for name in EXTRACTORS:
|
||||||
|
lost = b[name] - a[name]
|
||||||
|
gained = a[name] - b[name]
|
||||||
|
for value, n in sorted(lost.items()):
|
||||||
|
findings.append({"kind": name, "change": "사라짐", "count": n, "value": value})
|
||||||
|
for value, n in sorted(gained.items()):
|
||||||
|
findings.append({"kind": name, "change": "새로생김", "count": n, "value": value})
|
||||||
|
|
||||||
|
hb, ha = _hedges(before), _hedges(after)
|
||||||
|
dropped = hb - ha
|
||||||
|
hedge = [{"word": w, "before": hb[w], "after": ha[w]} for w in sorted(dropped)]
|
||||||
|
return {"findings": findings, "hedgesDropped": hedge,
|
||||||
|
"hedgeTotalBefore": sum(hb.values()), "hedgeTotalAfter": sum(ha.values())}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="편집 전후 보호 구간이 그대로인지 본다.")
|
||||||
|
ap.add_argument("before")
|
||||||
|
ap.add_argument("after")
|
||||||
|
ap.add_argument("--json", action="store_true")
|
||||||
|
ap.add_argument("--samples", type=int, default=5)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
for p in (args.before, args.after):
|
||||||
|
if not os.path.isfile(p):
|
||||||
|
print(f"그런 파일이 없다: {p}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
before = open(args.before, encoding="utf-8").read()
|
||||||
|
after = open(args.after, encoding="utf-8").read()
|
||||||
|
res = compare(before, after)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(res, ensure_ascii=False, indent=2))
|
||||||
|
return 1 if res["findings"] else 0
|
||||||
|
|
||||||
|
print(f"\n편집 전 {os.path.relpath(args.before, ROOT)}"
|
||||||
|
f"\n편집 후 {os.path.relpath(args.after, ROOT)}")
|
||||||
|
grouped = collections.defaultdict(list)
|
||||||
|
for f in res["findings"]:
|
||||||
|
grouped[(f["kind"], f["change"])].append(f)
|
||||||
|
for (kind, change), items in sorted(grouped.items()):
|
||||||
|
print(f" ✗ {kind} {change} {len(items):>3}건")
|
||||||
|
for f in items[:args.samples]:
|
||||||
|
v = f["value"].replace("\n", "⏎")
|
||||||
|
print(f" · {v[:96]}")
|
||||||
|
if len(items) > args.samples:
|
||||||
|
print(f" … 외 {len(items) - args.samples}건")
|
||||||
|
|
||||||
|
if res["hedgesDropped"]:
|
||||||
|
print(f" ! 유보 표현이 줄었다 — 편집 전 {res['hedgeTotalBefore']}"
|
||||||
|
f" → 편집 후 {res['hedgeTotalAfter']}")
|
||||||
|
for h in res["hedgesDropped"][:args.samples]:
|
||||||
|
print(f" · {h['word']} {h['before']}회 → {h['after']}회")
|
||||||
|
print(" 확신이 올라간 것인지는 이 검사기가 모른다. 근거를 읽는 검토가 판단한다")
|
||||||
|
|
||||||
|
n = len(res["findings"])
|
||||||
|
print(f"\nPRESERVATION: {'FAIL' if n else 'PASS'} — 보호 구간 변화 {n}건"
|
||||||
|
f" · 유보 감소 {len(res['hedgesDropped'])}종")
|
||||||
|
return 1 if n else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""종류가 요구하는 내용이 실제로 채워져 있는지 본다.
|
||||||
|
|
||||||
|
`audit-records.py` 는 평문 칸 **안에 마크업이 있는지**만 본다. 칸이 아예 없거나 제목만 있고
|
||||||
|
비어 있는 것은 세지 않는다. Studio 는 빈 칸도 받아 주므로 그대로 저장되고, 화면에서는
|
||||||
|
제목만 남은 칸으로 보인다.
|
||||||
|
|
||||||
|
**결정적으로 판정 가능한 것만 본다.** 칸이 있는가, 비어 있지 않은가, 계약에 없는 `##` 이
|
||||||
|
있는가(화면에 자리가 없어 통째로 사라진다), 종류가 요구하는 근거의 자리가 채워졌는가.
|
||||||
|
내용이 옳은지·인과가 맞는지는 보지 않는다 — 그것은 근거를 받은 검토 컨텍스트의 몫이다.
|
||||||
|
|
||||||
|
**강제하지 않는 것 셋.**
|
||||||
|
|
||||||
|
- 고정 목차. 본문(`## 본문`) 안의 절 구성은 글마다 다르다
|
||||||
|
- 자료 개수. 그림 몇 장·증거 몇 건을 요구하지 않는다
|
||||||
|
- 답. 답이 없는 QUESTION 은 정상이다. 물음과 확인된 사실과 답을 구할 방법만 요구한다
|
||||||
|
|
||||||
|
python3 scripts/check-required-content.py <프로젝트>
|
||||||
|
python3 scripts/check-required-content.py --file <기록.md>
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
||||||
|
import techlog # noqa: E402
|
||||||
|
|
||||||
|
# 종류마다의 `##` 칸. 정본은
|
||||||
|
# .agents/skills/publishing-tech-log-to-studio/references/studio-form-map.md 다.
|
||||||
|
# 계약에 없는 `##` 는 화면에 자리가 없어 통째로 사라진다
|
||||||
|
SECTIONS: dict[str, dict[str, tuple[str, ...]]] = {
|
||||||
|
"case": {"required": ("관계", "문제", "결론", "검증 환경", "재현 조건", "본문"),
|
||||||
|
"optional": ()},
|
||||||
|
"concept": {"required": ("관계", "본문"), "optional": ()},
|
||||||
|
"reference": {"required": ("관계", "목적", "규칙", "적용 조건", "예외"),
|
||||||
|
"optional": ("예시",)},
|
||||||
|
"question": {"required": ("관계", "사실", "미지수", "다음 검증"),
|
||||||
|
"optional": ("가정", "제약", "선택지")},
|
||||||
|
# Decision 의 틀에는 `관계` 가 없다 (templates/decision.md). 있으면 받되 요구하지 않는다
|
||||||
|
"decision": {"required": ("근거", "결정문", "판단 이유", "영향"),
|
||||||
|
"optional": ("관계",)},
|
||||||
|
}
|
||||||
|
BODY_KINDS = {"case", "concept"}
|
||||||
|
|
||||||
|
# frontmatter 의 `kind` 는 Studio 가 쓰는 값이다. 폴더 이름과 하나가 다르다 —
|
||||||
|
# decision/ 폴더의 기록은 `kind: PROJECT_DECISION` 이다 (templates/decision.md:3)
|
||||||
|
KIND_ALIASES = {"CASE": "case", "CONCEPT": "concept", "REFERENCE": "reference",
|
||||||
|
"QUESTION": "question", "PROJECT_DECISION": "decision"}
|
||||||
|
|
||||||
|
# 종류가 요구하는 근거의 자리. 값이 옳은지가 아니라 **자리가 채워졌는지**만 본다
|
||||||
|
FRONTMATTER: dict[str, tuple[str, ...]] = {
|
||||||
|
"case": ("sourceRevision",),
|
||||||
|
"concept": ("basisVersion",),
|
||||||
|
"reference": ("sourceRevision",),
|
||||||
|
"question": ("questionStatus",),
|
||||||
|
"decision": ("decisionStatus",),
|
||||||
|
}
|
||||||
|
|
||||||
|
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
|
||||||
|
|
||||||
|
|
||||||
|
def _front_matter(text: str) -> tuple[dict, int]:
|
||||||
|
"""frontmatter 와 그것이 끝나는 줄 번호."""
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return {}, 0
|
||||||
|
end = text.find("\n---", 3)
|
||||||
|
if end < 0:
|
||||||
|
return {}, 0
|
||||||
|
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, text[:end].count("\n") + 2
|
||||||
|
|
||||||
|
|
||||||
|
def _sections(text: str) -> dict[str, str]:
|
||||||
|
"""`## 이름` → 그 아래 내용. 본문 구간 안의 `##` 은 세지 않는다."""
|
||||||
|
body_a = text.find(BODY_START)
|
||||||
|
body_b = text.find(BODY_END)
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
order: list[tuple[str, int]] = []
|
||||||
|
for m in re.finditer(r"^##\s+(.+)$", text, re.M):
|
||||||
|
if body_a >= 0 <= body_b and body_a < m.start() < body_b:
|
||||||
|
continue # 본문 안의 절은 글마다 다르다. 강제하지 않는다
|
||||||
|
order.append((m.group(1).strip(), m.end()))
|
||||||
|
for i, (name, start) in enumerate(order):
|
||||||
|
stop = order[i + 1][1] - len(f"## {order[i + 1][0]}") if i + 1 < len(order) else len(text)
|
||||||
|
chunk = text[start:stop]
|
||||||
|
if name == "본문":
|
||||||
|
chunk = chunk.replace(BODY_START, "").replace(BODY_END, "")
|
||||||
|
out[name] = chunk.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(text: str, fm_end: int) -> str:
|
||||||
|
"""제목 바로 아래 첫 문단. Studio 의 `요약` 칸이다."""
|
||||||
|
rest = text.split("\n", fm_end)[-1] if fm_end else text
|
||||||
|
m = re.search(r"^#\s+.+$", rest, re.M)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
after = rest[m.end():]
|
||||||
|
after = re.split(r"^##\s", after, maxsplit=1, flags=re.M)[0]
|
||||||
|
for para in (p.strip() for p in after.split("\n\n")):
|
||||||
|
if para and not para.startswith("<!--"):
|
||||||
|
return para
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def check_record(path: str, rep: techlog.Report) -> None:
|
||||||
|
rel = os.path.relpath(path, ROOT)
|
||||||
|
text = open(path, encoding="utf-8").read()
|
||||||
|
fm, fm_end = _front_matter(text)
|
||||||
|
kind = KIND_ALIASES.get((fm.get("kind") or "").upper(),
|
||||||
|
(fm.get("kind") or "").lower())
|
||||||
|
if kind not in SECTIONS:
|
||||||
|
rep.error("kind 를 모르겠다", f"{rel} — kind={fm.get('kind')!r}")
|
||||||
|
return
|
||||||
|
|
||||||
|
spec = SECTIONS[kind]
|
||||||
|
found = _sections(text)
|
||||||
|
|
||||||
|
for name in spec["required"]:
|
||||||
|
if name not in found:
|
||||||
|
rep.error(f"{kind.upper()} 에 `{name}` 칸이 없다", rel)
|
||||||
|
elif not found[name]:
|
||||||
|
rep.error(f"{kind.upper()} 의 `{name}` 칸이 비었다",
|
||||||
|
f"{rel} — 제목만 있고 내용이 없다")
|
||||||
|
|
||||||
|
known = set(spec["required"]) | set(spec["optional"])
|
||||||
|
for name in found:
|
||||||
|
if name not in known:
|
||||||
|
rep.error("계약에 없는 칸 — 화면에 자리가 없어 사라진다",
|
||||||
|
f"{rel} — ## {name}")
|
||||||
|
|
||||||
|
if not _summary(text, fm_end):
|
||||||
|
rep.error("요약이 없다", f"{rel} — 제목 바로 아래 첫 문단이 `요약` 칸이다")
|
||||||
|
|
||||||
|
for key in FRONTMATTER.get(kind, ()):
|
||||||
|
if not fm.get(key):
|
||||||
|
rep.error(f"{kind.upper()} 에 `{key}` 가 없다", rel)
|
||||||
|
|
||||||
|
# 본문이 있는 종류만 본문 마커를 갖는다. 없는 종류에 있으면 평문으로 새어 나간다
|
||||||
|
has_body = BODY_START in text and BODY_END in text
|
||||||
|
if kind in BODY_KINDS and not has_body:
|
||||||
|
rep.error(f"{kind.upper()} 에 본문 마커가 없다", rel)
|
||||||
|
if kind not in BODY_KINDS and has_body:
|
||||||
|
rep.error(f"{kind.upper()} 은 본문이 없는 종류인데 본문 마커가 있다", rel)
|
||||||
|
|
||||||
|
# 근거의 자리 — CASE 는 관찰을 뒷받침할 것이 있어야 한다.
|
||||||
|
# 무엇을 가리키는지는 check_evidence 가 보고, 여기서는 자리가 비었는지만 본다
|
||||||
|
if kind == "case" and "evidence:" not in text and "source:" not in text:
|
||||||
|
rep.error("CASE 에 근거 목록이 없다", f"{rel} — evidence: 도 source: 도 없다")
|
||||||
|
|
||||||
|
# 답이 없는 QUESTION 은 정상이다. 답을 구할 방법이 없는 것이 결함이다
|
||||||
|
if kind == "question" and found.get("다음 검증", "").strip() in ("", "-"):
|
||||||
|
rep.error("QUESTION 에 답을 구할 방법이 없다",
|
||||||
|
f"{rel} — 답이 없는 것은 결함이 아니지만 방법이 없는 것은 결함이다")
|
||||||
|
|
||||||
|
|
||||||
|
def verify(project: str) -> tuple[techlog.Report, str | None]:
|
||||||
|
"""(보고, 대상이 성립하지 않는 사유). 사유가 있으면 검사한 것이 하나도 없다.
|
||||||
|
|
||||||
|
「봤고 괜찮다」와 「볼 것이 없어서 통과」를 가른다. 셋으로 나뉜다.
|
||||||
|
|
||||||
|
| 상태 | 종료 코드 | 문구 |
|
||||||
|
|---|---|---|
|
||||||
|
| 봤고 괜찮다 | 0 | `error 0` |
|
||||||
|
| 대상이 성립하지 않는다 (프로젝트 없음 · 계약 없음) | 2 | `대상이 성립하지 않는다 — <이유>` |
|
||||||
|
| 볼 것이 아직 없다 (계약은 있고 기록 0건) | 0 | `기록 0건 — 계약의 글감 N개가 아직 안 쓰였다` |
|
||||||
|
|
||||||
|
가운데는 결함이다. 아래는 결함이 아니다 — 아직 안 쓴 것은 잘못이 아니다. 다만 초록으로
|
||||||
|
보이면 안 된다.
|
||||||
|
"""
|
||||||
|
rep = techlog.Report(project)
|
||||||
|
studio = os.path.join(ROOT, "docs", project, "tech-log-studio")
|
||||||
|
index_path = os.path.join(studio, "tech-log-tree.json")
|
||||||
|
index = techlog.load_index(index_path)
|
||||||
|
if index is None:
|
||||||
|
return rep, "tech-log-tree.json 이 없다"
|
||||||
|
|
||||||
|
records = [f for f in sorted(glob.glob(f"{studio}/*/*/*.md"))
|
||||||
|
if not f.split(os.sep)[-3].startswith("_")]
|
||||||
|
rep.facts["records"] = len(records)
|
||||||
|
planned = len(list(techlog.nodes(index)))
|
||||||
|
if not records:
|
||||||
|
rep.facts["미작성"] = f"계약의 글감 {planned}개가 아직 안 쓰였다"
|
||||||
|
kinds = collections.Counter()
|
||||||
|
for f in records:
|
||||||
|
kinds[os.path.basename(os.path.dirname(f))] += 1
|
||||||
|
check_record(f, rep)
|
||||||
|
rep.facts["kinds"] = dict(kinds)
|
||||||
|
return rep, None
|
||||||
|
|
||||||
|
|
||||||
|
def render(rep: techlog.Report, samples: int) -> None:
|
||||||
|
facts = " · ".join(f"{k}={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]:
|
||||||
|
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("--file", action="append", default=[], help="기록 .md 를 직접 준다")
|
||||||
|
ap.add_argument("--samples", type=int, default=3)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
rep = techlog.Report("파일")
|
||||||
|
rep.facts["records"] = len(args.file)
|
||||||
|
for f in args.file:
|
||||||
|
check_record(os.path.abspath(f), rep)
|
||||||
|
reports = [rep]
|
||||||
|
else:
|
||||||
|
projects = args.projects or sorted(
|
||||||
|
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("_"))
|
||||||
|
if not projects:
|
||||||
|
print("볼 프로젝트가 없다", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
missing = [p for p in projects
|
||||||
|
if not os.path.isdir(os.path.join(ROOT, "docs", p))]
|
||||||
|
if missing:
|
||||||
|
print("대상이 성립하지 않는다 — 그런 프로젝트가 없다: "
|
||||||
|
f"{', '.join(missing)}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
pairs = [verify(p) for p in projects]
|
||||||
|
ungrounded = [(p, why) for (r, why), p in zip(pairs, projects) if why]
|
||||||
|
if ungrounded:
|
||||||
|
for p, why in ungrounded:
|
||||||
|
print(f"대상이 성립하지 않는다 — {p}: {why}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
reports = [r for r, _ in pairs]
|
||||||
|
|
||||||
|
for r in reports:
|
||||||
|
render(r, args.samples)
|
||||||
|
e = sum(r.error_count for r in reports)
|
||||||
|
total = sum(r.facts.get("records", 0) for r in reports)
|
||||||
|
unwritten = [f"{r.project}: {r.facts['미작성']}" for r in reports if "미작성" in r.facts]
|
||||||
|
for line in unwritten:
|
||||||
|
print(f" · {line}")
|
||||||
|
print(f"\nREQUIRED CONTENT: {'FAIL' if e else 'PASS'}"
|
||||||
|
f" — 기록 {total} · error {e}"
|
||||||
|
+ (f" · 아직 안 쓴 프로젝트 {len(unwritten)}개" if unwritten else ""))
|
||||||
|
return 1 if e else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""의미 검토가 받을 입력을 한 파일로 묶는다.
|
||||||
|
|
||||||
|
설계가 정한 분업이다 — 「결정적으로 판정 가능한 조건만 코드로 검사한다. 의미·인과·가독성은
|
||||||
|
근거를 받은 별도 검토 컨텍스트가 판단하고 확신이 없으면 보류한다」.
|
||||||
|
이 도구는 **판단하지 않는다.** 판단할 사람이 받을 것을 모은다.
|
||||||
|
|
||||||
|
담는 것 다섯.
|
||||||
|
|
||||||
|
1. **대상과 해시** — 기록·SSOT·증거·그림의 sha256. 검토가 끝난 뒤 파일이 바뀌면 그 판정은
|
||||||
|
이 해시에 안 맞는다
|
||||||
|
2. **검사기 버전** — 스킬의 `metadata.version` 과 검사기 파일의 내용 해시
|
||||||
|
3. **관문 결과** — 여기서 **실제로 돌려** 종료 코드를 적는다. 받아 적지 않는다
|
||||||
|
4. **주장 후보** — 본문에서 검증 가능한 문장을 기계로 뽑는다. 사람이 적은 주장 목록과
|
||||||
|
견주면 「본문에 있으나 주장 목록에는 빠진 것」이 보인다
|
||||||
|
5. **판정 기준** — 주장 종류마다 무엇이 있어야 하는지 (`quality-policy@1` §3)
|
||||||
|
|
||||||
|
python3 scripts/review-package.py <프로젝트> --record <기록.md> -o <출력.json>
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# quality-policy@1 §3 — 주장 종류마다 필요한 근거와 허용되는 표현
|
||||||
|
CLAIM_KINDS = {
|
||||||
|
"코드 구조·설정": {"근거": "저장소 ID, 커밋, 파일, 심볼 또는 범위",
|
||||||
|
"허용": "지정된 버전에 이 구현·설정이 존재함"},
|
||||||
|
"실제 실행 동작": {"근거": "실행 ID, 환경·입력·명령·종료 코드, 원본 출력",
|
||||||
|
"허용": "기록된 조건에서 관찰된 결과"},
|
||||||
|
"성능 비교": {"근거": "실험 코드·데이터·부하·반복 횟수·원시 측정치·집계 방식",
|
||||||
|
"허용": "측정한 조건과 변동 범위 안의 비교"},
|
||||||
|
"개념·제품 동작": {"근거": "해당 버전의 공식 문서 또는 명세", "허용": "출처가 설명하는 적용 범위"},
|
||||||
|
"선택 이유·개인 경험": {"근거": "작성자 기록·ADR·승인된 메모", "허용": "기록에 있는 이유와 실제 수행한 일"},
|
||||||
|
"해석·가설": {"근거": "해석의 전제가 되는 근거와 아직 확인하지 못한 부분",
|
||||||
|
"허용": "가능성·추정임을 명시한 설명"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 검증 가능한 문장을 고르는 표지. 뜻을 보지 않고 표면만 본다
|
||||||
|
CLAIM_MARKS = (
|
||||||
|
(re.compile(r"\d"), "수치"),
|
||||||
|
(re.compile(r"exit\s*=?\s*\d|종료 코드"), "종료 코드"),
|
||||||
|
(re.compile(r"`[^`]+`"), "식별자"),
|
||||||
|
(re.compile(r"(더|덜|보다|만큼|배|비해)\s"), "비교"),
|
||||||
|
(re.compile(r"(때문|므로|따라서|그래서|원인)"), "인과"),
|
||||||
|
(re.compile(r"(항상|절대|전부|모두|하나도|없다|never|always)"), "전칭"),
|
||||||
|
)
|
||||||
|
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: str) -> str | None:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
return hashlib.sha256(fh.read()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _front_matter_block(text: str) -> str:
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return ""
|
||||||
|
end = text.find("\n---", 3)
|
||||||
|
return text[3:end] if end > 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _listed(fm: str, key: str) -> list[str]:
|
||||||
|
"""`key:` 아래의 `- 값` 목록. `- key: x` 짝은 `file:` 쪽을 쓴다."""
|
||||||
|
out = []
|
||||||
|
grab = False
|
||||||
|
for line in fm.splitlines():
|
||||||
|
if re.match(rf"^{key}:\s*$", line):
|
||||||
|
grab = True
|
||||||
|
continue
|
||||||
|
if grab:
|
||||||
|
if re.match(r"^\S", line):
|
||||||
|
break
|
||||||
|
m = re.match(r"^\s+-\s+(\S.*)$", line) or re.match(r"^\s+file:\s*(\S+)$", line)
|
||||||
|
if m and not m.group(1).startswith("key:"):
|
||||||
|
out.append(m.group(1).strip())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _scalar(fm: str, key: str) -> str | None:
|
||||||
|
m = re.search(rf"^{key}:\s*(.*)$", fm, re.M)
|
||||||
|
return m.group(1).strip().strip('"') or None if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_candidates(text: str) -> list[dict]:
|
||||||
|
a, b = text.find(BODY_START), text.find(BODY_END)
|
||||||
|
region = text[a:b] if a >= 0 <= b else text
|
||||||
|
offset = text[:a].count("\n") + 1 if a >= 0 else 0
|
||||||
|
out = []
|
||||||
|
in_fence = False
|
||||||
|
for i, line in enumerate(region.splitlines()):
|
||||||
|
if line.lstrip().startswith("```"):
|
||||||
|
in_fence = not in_fence
|
||||||
|
continue
|
||||||
|
if in_fence or not line.strip() or line.lstrip().startswith(("#", "|", "<!--")):
|
||||||
|
continue
|
||||||
|
for sentence in re.split(r"(?<=[.!?다])\s+", line.strip()):
|
||||||
|
marks = [name for pat, name in CLAIM_MARKS if pat.search(sentence)]
|
||||||
|
if len(sentence) > 12 and marks:
|
||||||
|
out.append({"line": offset + i + 1, "marks": marks, "text": sentence.strip()})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str]) -> dict:
|
||||||
|
try:
|
||||||
|
p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
|
||||||
|
tail = (p.stdout + p.stderr).strip().splitlines()
|
||||||
|
return {"cmd": " ".join(cmd), "exit": p.returncode,
|
||||||
|
"tail": tail[-3:] if tail else []}
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
return {"cmd": " ".join(cmd), "exit": None, "tail": [f"실행 실패: {e}"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _checker_versions() -> dict:
|
||||||
|
path = os.path.join(ROOT, "scripts", "skill-versions.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("skill_versions", path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
return {}
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
return m.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_figures(assets: list[dict], out_dir: str) -> None:
|
||||||
|
"""그림을 PNG 로 떠서 검토가 **눈으로 볼** 수 있게 한다.
|
||||||
|
|
||||||
|
`check-figure-text.py` 는 `<text>` 가 이름인지 보고 `check-figure-overlap.py` 는 상자와
|
||||||
|
라벨이 겹치는지 본다. 둘 다 좌표와 문자열만 본다 — 그림이 말하는 것이 본문과 같은지는
|
||||||
|
사람이 봐야 안다. 렌더가 실패하면 실패했다고 적는다. 안 본 것을 본 것으로 만들지 않는다.
|
||||||
|
"""
|
||||||
|
for a in assets:
|
||||||
|
a["preview"] = None
|
||||||
|
if not a.get("exists") or not a["path"].endswith(".svg"):
|
||||||
|
continue
|
||||||
|
r = _run(["python3", "scripts/preview-figure.py",
|
||||||
|
"--file", a["path"], "-o", out_dir])
|
||||||
|
a["preview"] = {"exit": r["exit"],
|
||||||
|
"png": r["tail"][-1] if r["exit"] == 0 and r["tail"] else None,
|
||||||
|
"note": "검사기는 좌표와 문자열만 본다. 그림이 본문과 같은 것을 말하는지는 눈으로 본다"}
|
||||||
|
|
||||||
|
|
||||||
|
def _preservation(before: str, record: str) -> dict:
|
||||||
|
"""윤문 전후 비교의 결과를 통째로 싣는다.
|
||||||
|
|
||||||
|
`check-preservation.py` 는 유보 표현이 줄어든 것을 **경고로만** 낸다 — 종료 코드가 0 이라
|
||||||
|
관문으로는 안 걸린다. 확신 승격(가설→확인, 로컬→운영)이 딱 그 모양이라, 실어 보내지
|
||||||
|
않으면 그 편집은 아무도 못 본다. 그래서 `gates` 가 아니라 `warnings` 로 올린다.
|
||||||
|
"""
|
||||||
|
path = os.path.join(ROOT, "scripts", "check-preservation.py")
|
||||||
|
spec = importlib.util.spec_from_file_location("check_preservation", path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
return {"available": False}
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
res = m.compare(open(before, encoding="utf-8").read(),
|
||||||
|
open(record, encoding="utf-8").read())
|
||||||
|
res["before"] = os.path.relpath(os.path.abspath(before), ROOT)
|
||||||
|
res["beforeSha256"] = _sha256(before)
|
||||||
|
res["available"] = True
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def build(project: str, record: str, figures_dir: str | None = None,
|
||||||
|
before: str | None = None) -> dict:
|
||||||
|
rec_abs = os.path.abspath(record)
|
||||||
|
text = open(rec_abs, encoding="utf-8").read()
|
||||||
|
fm = _front_matter_block(text)
|
||||||
|
rec_dir = os.path.dirname(rec_abs)
|
||||||
|
base = os.path.join(ROOT, "docs", project)
|
||||||
|
ssot = os.path.join(base, "final", "document.md")
|
||||||
|
|
||||||
|
def _ref(rel: str) -> dict:
|
||||||
|
p = rel if os.path.isabs(rel) else os.path.normpath(os.path.join(rec_dir, rel))
|
||||||
|
return {"path": os.path.relpath(p, ROOT), "sha256": _sha256(p),
|
||||||
|
"exists": os.path.exists(p)}
|
||||||
|
|
||||||
|
evidence = [_ref(r) for r in _listed(fm, "evidence")]
|
||||||
|
for e in evidence:
|
||||||
|
meta = os.path.join(base, "final", "evidence", "meta",
|
||||||
|
os.path.basename(e["path"]).rsplit(".", 1)[0] + ".json")
|
||||||
|
if os.path.exists(meta):
|
||||||
|
with open(meta, encoding="utf-8") as fh:
|
||||||
|
m = json.load(fh)
|
||||||
|
e["meta"] = {k: m.get(k) for k in
|
||||||
|
("command", "cwd", "exitCode", "sourceRevision", "sourceDirty",
|
||||||
|
"executedAt", "proves", "doesNotProve", "sha256")}
|
||||||
|
else:
|
||||||
|
e["meta"] = None
|
||||||
|
|
||||||
|
assets = [_ref(r) for r in _listed(fm, "assets")]
|
||||||
|
if figures_dir:
|
||||||
|
_render_figures(assets, figures_dir)
|
||||||
|
|
||||||
|
gates = [
|
||||||
|
_run(["python3", "scripts/verify-tech-log-tree.py", project]),
|
||||||
|
_run(["python3", "scripts/verify-project-layout.py", project]),
|
||||||
|
_run(["python3", "scripts/audit-records.py", project]),
|
||||||
|
_run(["python3", "scripts/check-required-content.py", project]),
|
||||||
|
_run(["node", ".agents/skills/writing-tech-log-records/scripts/check_evidence.mjs",
|
||||||
|
project, "--repo"]),
|
||||||
|
_run(["node", ".agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs",
|
||||||
|
"--warn", os.path.relpath(rec_abs, ROOT)]),
|
||||||
|
_run(["node", ".agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs",
|
||||||
|
os.path.relpath(rec_abs, ROOT)]),
|
||||||
|
]
|
||||||
|
if any(a["path"].endswith(".svg") for a in assets):
|
||||||
|
gates.append(_run(["python3", "scripts/check-figure-text.py", project]))
|
||||||
|
gates.append(_run(["python3", "scripts/check-figure-overlap.py", project]))
|
||||||
|
|
||||||
|
warnings: list[dict] = []
|
||||||
|
preservation = _preservation(before, rec_abs) if before else {"available": False}
|
||||||
|
if preservation.get("available"):
|
||||||
|
for h in preservation["hedgesDropped"]:
|
||||||
|
warnings.append({
|
||||||
|
"id": "유보 감소",
|
||||||
|
"detail": f"{h['word']} {h['before']}회 → {h['after']}회",
|
||||||
|
"note": "종료 코드로는 안 걸린다. 확신이 올라간 것인지 그 자리에서 "
|
||||||
|
"덜어 낼 만했던 것인지는 근거를 읽어야 안다",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"project": project,
|
||||||
|
"policyVersion": "quality-policy@1",
|
||||||
|
"target": {
|
||||||
|
"record": os.path.relpath(rec_abs, ROOT),
|
||||||
|
"sha256": _sha256(rec_abs),
|
||||||
|
"kind": _scalar(fm, "kind"),
|
||||||
|
"slug": _scalar(fm, "slug"),
|
||||||
|
"title": _scalar(fm, "title"),
|
||||||
|
"sourceRevision": _scalar(fm, "sourceRevision"),
|
||||||
|
},
|
||||||
|
"ssot": {"path": os.path.relpath(ssot, ROOT), "sha256": _sha256(ssot)},
|
||||||
|
"sourceAnchors": _listed(fm, "source"),
|
||||||
|
"evidence": evidence,
|
||||||
|
"assets": assets,
|
||||||
|
"checkerVersions": _checker_versions(),
|
||||||
|
"gates": gates,
|
||||||
|
"preservation": preservation,
|
||||||
|
"warnings": warnings,
|
||||||
|
"claimCandidates": _claim_candidates(text),
|
||||||
|
"judgmentCriteria": CLAIM_KINDS,
|
||||||
|
"reviewerNotes": [
|
||||||
|
"이 파일의 어느 값도 판정이 아니다. 관문의 exit 는 형식 검사의 결과일 뿐이다.",
|
||||||
|
"claimCandidates 는 표면 표지로 뽑은 것이라 주장이 아닌 문장이 섞인다. "
|
||||||
|
"반대로 표지가 없는 주장은 빠진다 — 본문을 읽고 빠진 것을 찾는 것이 검토의 일이다.",
|
||||||
|
"판정을 낼 때 target.sha256 과 evidence[].sha256 을 함께 적는다. "
|
||||||
|
"그 값이 바뀌면 판정은 다른 파일에 대한 것이 된다.",
|
||||||
|
"assets[].preview.png 가 있으면 열어서 본다. 그림 검사기는 좌표와 문자열만 보므로 "
|
||||||
|
"그림이 본문과 다른 것을 말해도 통과한다.",
|
||||||
|
"warnings 는 관문이 아니다. 종료 코드로 안 걸리는 것만 여기 올라온다 — "
|
||||||
|
"유보 표현이 줄어든 자리가 그것이고, 확신 승격이 딱 그 모양이다. "
|
||||||
|
"gates 가 전부 0 이어도 warnings 는 따로 읽는다.",
|
||||||
|
"이 묶음이 못 보는 것이 있다. 수치도 인용도 없이 더한 산문 — 자료에 없는 1인칭 "
|
||||||
|
"경험이나 선택 이유 — 은 보호 구간 비교로 원리적으로 안 보이고 gates 도 warnings 도 "
|
||||||
|
"비어 있다. 본문을 읽는 것 말고는 방법이 없다.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="의미 검토가 받을 입력을 한 파일로 묶는다.")
|
||||||
|
ap.add_argument("project")
|
||||||
|
ap.add_argument("--record", required=True)
|
||||||
|
ap.add_argument("-o", "--out")
|
||||||
|
ap.add_argument("--figures", help="그림을 PNG 로 떠서 둘 폴더. 검토가 눈으로 보는 자리다")
|
||||||
|
ap.add_argument("--before", help="윤문 전 사본. 주면 편집 전후 비교를 실어 보낸다")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isdir(os.path.join(ROOT, "docs", args.project)):
|
||||||
|
print(f"그런 프로젝트가 없다: {args.project}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not os.path.isfile(args.record):
|
||||||
|
print(f"그런 기록이 없다: {args.record}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
pkg = build(args.project, args.record, args.figures, args.before)
|
||||||
|
text = json.dumps(pkg, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
if args.out:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
|
||||||
|
with open(args.out, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(text)
|
||||||
|
failed = [g for g in pkg["gates"] if g["exit"] != 0]
|
||||||
|
print(f"{args.out} — 증거 {len(pkg['evidence'])} · 주장 후보 "
|
||||||
|
f"{len(pkg['claimCandidates'])} · 관문 {len(pkg['gates'])}"
|
||||||
|
f" (exit≠0 {len(failed)}건) · 경고 {len(pkg['warnings'])}건")
|
||||||
|
else:
|
||||||
|
print(text, end="")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""스킬과 검사기의 버전을 한 장으로 뽑는다.
|
||||||
|
|
||||||
|
통과 판정은 「어느 문서를 어느 검사기로 봤는가」에 묶여야 한다. 스킬은 `SKILL.md` 의
|
||||||
|
`metadata.version` 이 그 값이고, 스크립트 검사기는 버전 칸이 없으므로 파일 내용의 sha256
|
||||||
|
앞 12자를 쓴다. 버전을 올리는 것을 잊어도 sha 는 따라 움직인다.
|
||||||
|
|
||||||
|
python3 scripts/skill-versions.py # 사람이 읽는 표
|
||||||
|
python3 scripts/skill-versions.py --json # 판정에 붙일 값
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
GATE_SCRIPTS = [
|
||||||
|
"scripts/verify-tech-log-tree.py", "scripts/verify-project-layout.py",
|
||||||
|
"scripts/verify-pipeline-run.py", "scripts/verify-pipeline.py",
|
||||||
|
"scripts/audit-records.py", "scripts/check-figure-text.py",
|
||||||
|
"scripts/check-figure-overlap.py", "scripts/build-tech-log-tree.py",
|
||||||
|
"scripts/studio-body.py", "scripts/capture-evidence.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sha12(path: str) -> str | None:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
return hashlib.sha256(fh.read()).hexdigest()[:12]
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def collect() -> dict:
|
||||||
|
skills = {}
|
||||||
|
for path in sorted(glob.glob(os.path.join(ROOT, ".agents/skills/*/SKILL.md"))):
|
||||||
|
name = os.path.basename(os.path.dirname(path))
|
||||||
|
text = open(path, encoding="utf-8").read()
|
||||||
|
m = re.search(r"^metadata:\n(?: .*\n)*? version:\s*\"?([^\"\n]+)\"?",
|
||||||
|
text, re.M)
|
||||||
|
skills[name] = {"version": m.group(1).strip() if m else None,
|
||||||
|
"sha12": _sha12(path)}
|
||||||
|
|
||||||
|
checkers = {}
|
||||||
|
for rel in GATE_SCRIPTS:
|
||||||
|
checkers[rel] = {"version": None, "sha12": _sha12(os.path.join(ROOT, rel))}
|
||||||
|
for path in sorted(glob.glob(os.path.join(ROOT, ".agents/skills/*/scripts/*.mjs"))):
|
||||||
|
rel = os.path.relpath(path, ROOT)
|
||||||
|
checkers[rel] = {"version": None, "sha12": _sha12(path)}
|
||||||
|
return {"skills": skills, "checkers": checkers}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="스킬·검사기의 버전과 내용 해시를 뽑는다.")
|
||||||
|
ap.add_argument("--json", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
data = collect()
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
missing = 0
|
||||||
|
print("스킬")
|
||||||
|
for name, v in data["skills"].items():
|
||||||
|
mark = " " if v["version"] else "✗"
|
||||||
|
if not v["version"]:
|
||||||
|
missing += 1
|
||||||
|
print(f" {mark} {name:<40} {v['version'] or '버전 없음':<10} {v['sha12']}")
|
||||||
|
print("\n검사기 — 버전 칸이 없어 내용 해시로 묶는다")
|
||||||
|
for name, v in data["checkers"].items():
|
||||||
|
print(f" {name:<70} {v['sha12']}")
|
||||||
|
print(f"\nSKILL VERSIONS: {'FAIL' if missing else 'PASS'} — "
|
||||||
|
f"스킬 {len(data['skills'])} · 버전 없음 {missing} · 검사기 {len(data['checkers'])}")
|
||||||
|
return 1 if missing else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -9,13 +9,33 @@ from pathlib import Path
|
|||||||
ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||||
|
|
||||||
_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||||
(re.compile(r"(?i)^(\s*authorization\s*:\s*bearer\s+).*$"), r"\1[REDACTED]"),
|
# 줄 맨 앞에 앵커를 두면 `curl -H "Authorization: Bearer ..."` 를 놓친다.
|
||||||
(re.compile(r"(?i)^(\s*(?:cookie|set-cookie)\s*:\s*).*$"), r"\1[REDACTED]"),
|
# 터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 그 명령줄이다.
|
||||||
|
# 값은 따옴표와 줄바꿈 전까지 먹는다 — 헤더 한 줄이면 줄 끝까지, 인용부호 안이면 닫는
|
||||||
|
# 따옴표 앞까지다. 따옴표를 넘겨 먹으면 명령의 나머지가 통째로 가려진다
|
||||||
|
(re.compile(r"(?i)(\b(?:proxy-)?authorization\s*:\s*(?:bearer|basic)\s+)[^\"'\r\n]*"),
|
||||||
|
r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)(\b(?:set-cookie|cookie)\s*:\s*)[^\"'\r\n]*"), r"\1[REDACTED]"),
|
||||||
|
# `curl -u user:pw` · `--user user:pw`. 사용자 이름은 남긴다
|
||||||
|
(re.compile(r"(?i)((?:^|\s)(?:-u|--user)[=\s]+)([^\s:\"']+):([^\s\"']+)"),
|
||||||
|
r"\1\2:[REDACTED]"),
|
||||||
|
# JWT 자체. `eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다
|
||||||
|
(re.compile(r"\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]+"),
|
||||||
|
"[REDACTED]"),
|
||||||
|
# 접속 문자열의 자격증명 — postgresql://app:<암호>@db:5432/app.
|
||||||
|
# 사용자 이름은 남긴다. 어느 계정으로 붙었는지가 증거의 일부다
|
||||||
|
(
|
||||||
|
re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://)([^:/?#\s@]+):([^@\s/]+)@"),
|
||||||
|
r"\1\2:[REDACTED]@",
|
||||||
|
),
|
||||||
|
# 값의 끝을 **따옴표 앞에서** 막는다. `[^\s,;]+` 로 두면 닫는 따옴표까지 먹어
|
||||||
|
# `-H "X-Api-Key: [REDACTED] https://...` 가 되고, 증거에 실린 명령이 실제로 돌린
|
||||||
|
# 명령과 달라진다. 감싼 따옴표가 있으면 그대로 되돌려 놓는다
|
||||||
(
|
(
|
||||||
re.compile(
|
re.compile(
|
||||||
r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([^\s,;]+)"
|
r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|client[_-]?secret|api[_-]?key|secret|aws_secret_access_key)\b\s*[=:]\s*)([\"']?)([^\s,;\"'\r\n]+)([\"']?)"
|
||||||
),
|
),
|
||||||
r"\1[REDACTED]",
|
r"\1\2[REDACTED]\4",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
re.compile(
|
re.compile(
|
||||||
|
|||||||
@@ -43,6 +43,97 @@ class RenderTerminalTest(unittest.TestCase):
|
|||||||
with self.subTest(raw=raw):
|
with self.subTest(raw=raw):
|
||||||
self.assertEqual(expected, redact_line(raw))
|
self.assertEqual(expected, redact_line(raw))
|
||||||
|
|
||||||
|
def test_credentials_inside_a_command_line_are_redacted(self):
|
||||||
|
"""줄 맨 앞이 아니라 명령 인자 안에 있는 자격증명.
|
||||||
|
|
||||||
|
터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 `curl -H` 의 인자다.
|
||||||
|
값은 닫는 따옴표 앞까지만 먹는다 — 넘겨 먹으면 명령의 나머지가 통째로 가려진다.
|
||||||
|
아래 값은 전부 합성이고 실제 비밀값이 아니다.
|
||||||
|
"""
|
||||||
|
cases = {
|
||||||
|
'curl -H "Authorization: Bearer TESTONLY-aaa.bbb.ccc" https://example.test/api':
|
||||||
|
'curl -H "Authorization: Bearer [REDACTED]" https://example.test/api',
|
||||||
|
"curl -H 'Authorization: Bearer TESTONLY-xyz' -sS https://example.test":
|
||||||
|
"curl -H 'Authorization: Bearer [REDACTED]' -sS https://example.test",
|
||||||
|
'curl -H "Cookie: SESSION=TESTONLY-sess" https://example.test/api':
|
||||||
|
'curl -H "Cookie: [REDACTED]" https://example.test/api',
|
||||||
|
"Set-Cookie: SESSION=TESTONLY-x; HttpOnly":
|
||||||
|
"Set-Cookie: [REDACTED]",
|
||||||
|
}
|
||||||
|
for raw, expected in cases.items():
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertEqual(expected, redact_line(raw))
|
||||||
|
|
||||||
|
def test_connection_string_password_is_redacted_and_user_is_kept(self):
|
||||||
|
"""scheme://user:pw@host 의 암호만 가린다.
|
||||||
|
|
||||||
|
어느 계정으로 붙었는지는 증거의 일부라 사용자 이름을 남긴다.
|
||||||
|
"""
|
||||||
|
cases = {
|
||||||
|
"psql postgresql://app:TESTONLY-pw@db:5432/app":
|
||||||
|
"psql postgresql://app:[REDACTED]@db:5432/app",
|
||||||
|
"DATABASE_URL=mysql://root:TESTONLY-pw@127.0.0.1:3306/app":
|
||||||
|
"DATABASE_URL=mysql://root:[REDACTED]@127.0.0.1:3306/app",
|
||||||
|
"redis://default:TESTONLY-pw@cache:6379/0":
|
||||||
|
"redis://default:[REDACTED]@cache:6379/0",
|
||||||
|
}
|
||||||
|
for raw, expected in cases.items():
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertEqual(expected, redact_line(raw))
|
||||||
|
|
||||||
|
def test_masking_does_not_eat_the_closing_quote(self):
|
||||||
|
"""가려졌다는 것과 명령이 그대로라는 것은 다르다.
|
||||||
|
|
||||||
|
값의 끝을 공백까지로 두면 닫는 따옴표까지 먹어 증거에 실린 명령이 실제로 돌린
|
||||||
|
명령과 달라진다. 가려진 것만 보고 지나치지 않도록 따옴표 수를 함께 센다.
|
||||||
|
아래 값은 전부 합성이고 실제 비밀값이 아니다.
|
||||||
|
"""
|
||||||
|
lines = (
|
||||||
|
'curl -H "X-Api-Key: TESTONLY-i-apikey-header" https://example.invalid/d',
|
||||||
|
'curl -H "X-Token: TESTONLY-x" -H "Accept: application/json" https://example.invalid/d',
|
||||||
|
'export TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig"',
|
||||||
|
"curl -H 'X-Api-Key: TESTONLY-single' https://example.invalid/d",
|
||||||
|
)
|
||||||
|
for raw in lines:
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
out = redact_line(raw)
|
||||||
|
self.assertIn("[REDACTED]", out)
|
||||||
|
self.assertNotIn("TESTONLY", out)
|
||||||
|
self.assertEqual(raw.count('"'), out.count('"'), out)
|
||||||
|
self.assertEqual(raw.count("'"), out.count("'"), out)
|
||||||
|
|
||||||
|
def test_basic_auth_shapes_are_redacted(self):
|
||||||
|
"""Bearer 말고도 자격증명이 실리는 자리가 있다."""
|
||||||
|
cases = {
|
||||||
|
'curl -H "Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d':
|
||||||
|
'curl -H "Authorization: Basic [REDACTED]" https://example.invalid/d',
|
||||||
|
'curl -H "Proxy-Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d':
|
||||||
|
'curl -H "Proxy-Authorization: Basic [REDACTED]" https://example.invalid/d',
|
||||||
|
"curl -u admin:TESTONLY-basic-pw https://example.invalid/d":
|
||||||
|
"curl -u admin:[REDACTED] https://example.invalid/d",
|
||||||
|
"curl --user admin:TESTONLY-basic-pw https://example.invalid/d":
|
||||||
|
"curl --user admin:[REDACTED] https://example.invalid/d",
|
||||||
|
}
|
||||||
|
for raw, expected in cases.items():
|
||||||
|
with self.subTest(raw=raw):
|
||||||
|
self.assertEqual(expected, redact_line(raw))
|
||||||
|
|
||||||
|
def test_a_bare_jwt_is_redacted(self):
|
||||||
|
"""`eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다."""
|
||||||
|
raw = "Set token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig now"
|
||||||
|
out = redact_line(raw)
|
||||||
|
self.assertEqual("Set token [REDACTED] now", out)
|
||||||
|
|
||||||
|
def test_ordinary_urls_are_not_touched(self):
|
||||||
|
"""자격증명이 없는 주소는 그대로 둔다. 과하게 가리면 증거를 못 읽는다."""
|
||||||
|
for line in (
|
||||||
|
"https://example.test/api?x=1",
|
||||||
|
"git clone https://github.com/org/repo.git",
|
||||||
|
"GET https://example.test/studio/documents/abc-123/edit -> 200",
|
||||||
|
):
|
||||||
|
with self.subTest(line=line):
|
||||||
|
self.assertEqual(line, redact_line(line))
|
||||||
|
|
||||||
def test_normal_output_is_not_changed_by_redaction(self):
|
def test_normal_output_is_not_changed_by_redaction(self):
|
||||||
line = "GET /api/me -> 200 in 14ms"
|
line = "GET /api/me -> 200 in 14ms"
|
||||||
self.assertEqual(line, redact_line(line))
|
self.assertEqual(line, redact_line(line))
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# 측정 결과
|
||||||
|
|
||||||
|
리비전 `43e1aad` 에서 열넷 가운데 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
|
||||||
|
이 값은 로컬에서 잰 것이고 운영에서 같은지는 확인하지 못했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
build-tech-log-tree.py exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
문서가 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적어 두었다.
|
||||||
|
자세한 것은 https://example.test/docs 에 있다.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 측정 결과
|
||||||
|
|
||||||
|
리비전 `43e1aad` 에서 열넷 중 셋이 `exit 1` 이었다. 응답 시간은 4ms 였다.
|
||||||
|
이 값은 운영에서 확인한 값이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
build-tech-log-tree.py exit=0
|
||||||
|
```
|
||||||
|
|
||||||
|
문서는 「관문은 종료 코드가 0 이면 지난 것이다」 라고 적었다.
|
||||||
|
자세한 것은 https://example.test/doc 에 있다.
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# 측정 결과
|
||||||
|
|
||||||
|
리비전 `43e1aad` 에서 열넷 중 둘이 `exit 1` 이었다. 응답 시간은 14ms 였다.
|
||||||
|
이 값은 로컬에서 잰 것이라 운영에서 같은지는 확인하지 못했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
build-tech-log-tree.py exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
문서는 「관문은 종료 코드가 0 이어야 지난 것이다」 라고 적었다.
|
||||||
|
자세한 것은 https://example.test/docs 에 있다.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
kind: CASE
|
||||||
|
slug: fixture-case
|
||||||
|
title: 고정 사례 케이스
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
source:
|
||||||
|
- final/document.md#s1
|
||||||
|
evidence:
|
||||||
|
- ../../../final/evidence/raw/x.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 케이스
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **고정 사례 개념**
|
||||||
|
이 사건을 읽으려면 그 개념이 먼저 필요하다.
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
관측한 현상을 적는다. 범위도 함께 적는다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
|
||||||
|
## 검증 환경
|
||||||
|
|
||||||
|
python 3.12.3 · 리비전 0000000
|
||||||
|
|
||||||
|
## 재현 조건
|
||||||
|
|
||||||
|
1. 이 순서로 돌린다.
|
||||||
|
2. 값이 갈리는 것을 본다.
|
||||||
|
|
||||||
|
## 본문
|
||||||
|
|
||||||
|
<!-- body:start -->
|
||||||
|
|
||||||
|
## 무엇이 있었나
|
||||||
|
|
||||||
|
본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다.
|
||||||
|
|
||||||
|
<!-- body:end -->
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
kind: CONCEPT
|
||||||
|
slug: fixture-concept
|
||||||
|
title: 고정 사례 개념
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 개념
|
||||||
|
|
||||||
|
개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **고정 사례 케이스**
|
||||||
|
그 사건이 이 개념 위에서 벌어진다.
|
||||||
|
|
||||||
|
## 본문
|
||||||
|
|
||||||
|
<!-- body:start -->
|
||||||
|
|
||||||
|
## 정의
|
||||||
|
|
||||||
|
적용 범위까지 함께 적는다.
|
||||||
|
|
||||||
|
<!-- body:end -->
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
kind: PROJECT_DECISION
|
||||||
|
slug: fixture-decision
|
||||||
|
title: 고정 사례 결정
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
decisionStatus: PROPOSED
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 결정
|
||||||
|
|
||||||
|
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
기록에 있는 근거만 적는다.
|
||||||
|
|
||||||
|
## 결정문
|
||||||
|
|
||||||
|
실제로 고른 것을 적는다.
|
||||||
|
|
||||||
|
## 영향
|
||||||
|
|
||||||
|
감수한 비용과 재검토 조건을 적는다.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
kind: QUESTION
|
||||||
|
slug: fixture-question
|
||||||
|
title: 고정 사례 물음
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
questionStatus: OPEN
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 물음
|
||||||
|
|
||||||
|
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **고정 사례 케이스**
|
||||||
|
그 사건이 이 물음을 열었다.
|
||||||
|
|
||||||
|
## 사실
|
||||||
|
|
||||||
|
- 확인된 사실을 적는다.
|
||||||
|
|
||||||
|
## 미지수
|
||||||
|
|
||||||
|
- 아직 모르는 것을 적는다.
|
||||||
|
|
||||||
|
## 다음 검증
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
kind: REFERENCE
|
||||||
|
slug: fixture-reference
|
||||||
|
title: 고정 사례 참조
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 참조
|
||||||
|
|
||||||
|
무엇을 참고하는 기준인지 한 문단으로 적는다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **고정 사례 케이스**
|
||||||
|
그 사건에서 이 기준이 쓰였다.
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
이 기준을 쓰는 이유를 적는다.
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
|
||||||
|
1. 판단 기준을 적는다
|
||||||
|
근거와 함께 적는다.
|
||||||
|
|
||||||
|
## 예외
|
||||||
|
|
||||||
|
적용되지 않는 조건을 적는다.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
kind: CASE
|
||||||
|
slug: fixture-case
|
||||||
|
title: 고정 사례 케이스
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
source:
|
||||||
|
- final/document.md#s1
|
||||||
|
evidence:
|
||||||
|
- ../../../final/evidence/raw/x.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 케이스
|
||||||
|
|
||||||
|
한 문장으로 무엇이 있었는지 적는다. 이 문단이 Studio 의 요약 칸이 된다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **고정 사례 개념**
|
||||||
|
이 사건을 읽으려면 그 개념이 먼저 필요하다.
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
관측한 현상을 적는다. 범위도 함께 적는다.
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
근거가 뒷받침하는 만큼만 적는다.
|
||||||
|
|
||||||
|
## 검증 환경
|
||||||
|
|
||||||
|
python 3.12.3 · 리비전 0000000
|
||||||
|
|
||||||
|
## 재현 조건
|
||||||
|
|
||||||
|
1. 이 순서로 돌린다.
|
||||||
|
2. 값이 갈리는 것을 본다.
|
||||||
|
|
||||||
|
## 본문
|
||||||
|
|
||||||
|
<!-- body:start -->
|
||||||
|
|
||||||
|
## 무엇이 있었나
|
||||||
|
|
||||||
|
본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다.
|
||||||
|
|
||||||
|
<!-- body:end -->
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
kind: CONCEPT
|
||||||
|
slug: fixture-concept
|
||||||
|
title: 고정 사례 개념
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
basisVersion: 예시 명세 1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 개념
|
||||||
|
|
||||||
|
개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **고정 사례 케이스**
|
||||||
|
그 사건이 이 개념 위에서 벌어진다.
|
||||||
|
|
||||||
|
## 본문
|
||||||
|
|
||||||
|
<!-- body:start -->
|
||||||
|
|
||||||
|
## 정의
|
||||||
|
|
||||||
|
적용 범위까지 함께 적는다.
|
||||||
|
|
||||||
|
<!-- body:end -->
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
kind: PROJECT_DECISION
|
||||||
|
slug: fixture-decision
|
||||||
|
title: 고정 사례 결정
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
decisionStatus: PROPOSED
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 결정
|
||||||
|
|
||||||
|
무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다.
|
||||||
|
|
||||||
|
## 근거
|
||||||
|
|
||||||
|
기록에 있는 근거만 적는다.
|
||||||
|
|
||||||
|
## 결정문
|
||||||
|
|
||||||
|
실제로 고른 것을 적는다.
|
||||||
|
|
||||||
|
## 판단 이유
|
||||||
|
|
||||||
|
확인된 대안과 그것을 고르지 않은 이유를 적는다.
|
||||||
|
|
||||||
|
## 영향
|
||||||
|
|
||||||
|
감수한 비용과 재검토 조건을 적는다.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
kind: QUESTION
|
||||||
|
slug: fixture-question
|
||||||
|
title: 고정 사례 물음
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
questionStatus: OPEN
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 물음
|
||||||
|
|
||||||
|
무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **고정 사례 케이스**
|
||||||
|
그 사건이 이 물음을 열었다.
|
||||||
|
|
||||||
|
## 사실
|
||||||
|
|
||||||
|
- 확인된 사실을 적는다.
|
||||||
|
|
||||||
|
## 미지수
|
||||||
|
|
||||||
|
- 아직 모르는 것을 적는다.
|
||||||
|
|
||||||
|
## 다음 검증
|
||||||
|
|
||||||
|
1. 답을 구할 방법을 적는다.
|
||||||
|
|
||||||
|
닫는 조건 : 어떤 결과가 나오면 닫는지 적는다.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
kind: REFERENCE
|
||||||
|
slug: fixture-reference
|
||||||
|
title: 고정 사례 참조
|
||||||
|
topic: fixture-topic
|
||||||
|
project: fixture
|
||||||
|
status: 게시 전
|
||||||
|
sourceRevision: 0000000000000000000000000000000000000000
|
||||||
|
---
|
||||||
|
|
||||||
|
# 고정 사례 참조
|
||||||
|
|
||||||
|
무엇을 참고하는 기준인지 한 문단으로 적는다.
|
||||||
|
|
||||||
|
## 관계
|
||||||
|
|
||||||
|
- **고정 사례 케이스**
|
||||||
|
그 사건에서 이 기준이 쓰였다.
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
이 기준을 쓰는 이유를 적는다.
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
|
||||||
|
1. 판단 기준을 적는다
|
||||||
|
근거와 함께 적는다.
|
||||||
|
|
||||||
|
## 적용 조건
|
||||||
|
|
||||||
|
어느 버전·어느 전제에서 쓰는지 적는다.
|
||||||
|
|
||||||
|
## 예외
|
||||||
|
|
||||||
|
적용되지 않는 조건을 적는다.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""윤문 전후에 보호 구간이 그대로인지 보는 검사기.
|
||||||
|
|
||||||
|
정상 윤문은 통과하고, 수치·코드·인용·URL 을 건드린 편집은 걸린다.
|
||||||
|
유보 표현이 줄어든 것은 걸러 내되 판정하지 않는다 — 판단은 근거를 읽는 검토가 한다.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
F = os.path.join(ROOT, "scripts", "tests", "fixtures", "preservation")
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"check_preservation", os.path.join(ROOT, "scripts", "check-preservation.py"))
|
||||||
|
cp = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(cp)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(name):
|
||||||
|
return open(os.path.join(F, name), encoding="utf-8").read()
|
||||||
|
|
||||||
|
|
||||||
|
class PreservationTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.before = _read("before.md")
|
||||||
|
|
||||||
|
def test_an_ordinary_rewrite_passes(self):
|
||||||
|
res = cp.compare(self.before, _read("after-ok.md"))
|
||||||
|
self.assertEqual([], res["findings"])
|
||||||
|
self.assertEqual([], res["hedgesDropped"])
|
||||||
|
|
||||||
|
def test_a_number_changed_in_prose_is_caught(self):
|
||||||
|
res = cp.compare(self.before, _read("after-tampered.md"))
|
||||||
|
values = {f["value"] for f in res["findings"] if f["kind"] == "수치"}
|
||||||
|
self.assertIn("14ms", values)
|
||||||
|
self.assertIn("4ms", values)
|
||||||
|
|
||||||
|
def test_a_number_changed_inside_a_code_block_is_caught(self):
|
||||||
|
res = cp.compare(self.before, _read("after-tampered.md"))
|
||||||
|
blocks = {f["value"] for f in res["findings"] if f["kind"] == "코드블록"}
|
||||||
|
self.assertTrue(any("exit=1" in b for b in blocks))
|
||||||
|
self.assertTrue(any("exit=0" in b for b in blocks))
|
||||||
|
|
||||||
|
def test_a_changed_direct_quotation_is_caught(self):
|
||||||
|
res = cp.compare(self.before, _read("after-tampered.md"))
|
||||||
|
kinds = {f["kind"] for f in res["findings"]}
|
||||||
|
self.assertIn("직접인용", kinds)
|
||||||
|
|
||||||
|
def test_a_changed_url_is_caught(self):
|
||||||
|
res = cp.compare(self.before, _read("after-tampered.md"))
|
||||||
|
kinds = {f["kind"] for f in res["findings"]}
|
||||||
|
self.assertIn("URL", kinds)
|
||||||
|
|
||||||
|
def test_dropped_hedges_are_surfaced_without_a_verdict(self):
|
||||||
|
"""유보가 줄면 낸다. 옳은지 그른지는 말하지 않는다."""
|
||||||
|
res = cp.compare(self.before, _read("after-tampered.md"))
|
||||||
|
dropped = {h["word"] for h in res["hedgesDropped"]}
|
||||||
|
self.assertIn("확인하지 못했다", dropped)
|
||||||
|
self.assertIn("로컬", dropped)
|
||||||
|
self.assertEqual(0, res["hedgeTotalAfter"])
|
||||||
|
|
||||||
|
def test_adding_a_hedge_is_not_reported(self):
|
||||||
|
"""유보를 더하는 것은 이 규범에서 안전한 쪽이다."""
|
||||||
|
after = self.before.replace("14ms 였다", "14ms 였다. 다만 한 번만 쟀다")
|
||||||
|
res = cp.compare(self.before, after)
|
||||||
|
self.assertEqual([], res["hedgesDropped"])
|
||||||
|
|
||||||
|
def test_a_missing_file_is_not_reported_as_clean(self):
|
||||||
|
import subprocess
|
||||||
|
p = subprocess.run(
|
||||||
|
["python3", os.path.join(ROOT, "scripts", "check-preservation.py"),
|
||||||
|
os.path.join(F, "before.md"), os.path.join(F, "nope.md")],
|
||||||
|
cwd=ROOT, capture_output=True, text=True)
|
||||||
|
self.assertEqual(2, p.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""종류가 요구하는 내용이 채워졌는지 보는 검사기.
|
||||||
|
|
||||||
|
고정 사례는 다섯 종류마다 둘이다 — 채운 것과 하나를 뺀 것.
|
||||||
|
「무조건 통과」도 「무조건 거절」도 아닌 것을 이 짝이 확인한다.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
FIXTURES = os.path.join(ROOT, "scripts", "tests", "fixtures", "required-content")
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"check_required_content", os.path.join(ROOT, "scripts", "check-required-content.py"))
|
||||||
|
crc = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(crc)
|
||||||
|
|
||||||
|
KINDS = ("case", "concept", "reference", "question", "decision")
|
||||||
|
|
||||||
|
|
||||||
|
def _report(sub, name):
|
||||||
|
rep = crc.techlog.Report("fixture")
|
||||||
|
crc.check_record(os.path.join(FIXTURES, sub, f"{name}.md"), rep)
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
class RequiredContentTest(unittest.TestCase):
|
||||||
|
def test_every_kind_passes_when_filled(self):
|
||||||
|
for kind in KINDS:
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
rep = _report("ok", kind)
|
||||||
|
self.assertEqual(0, rep.error_count,
|
||||||
|
f"{kind}: {dict(rep.errors)}")
|
||||||
|
|
||||||
|
def test_every_kind_fails_when_a_required_part_is_missing(self):
|
||||||
|
for kind in KINDS:
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
rep = _report("missing", kind)
|
||||||
|
self.assertGreater(rep.error_count, 0,
|
||||||
|
f"{kind} 의 누락 사례가 통과했다")
|
||||||
|
|
||||||
|
def test_the_missing_part_is_named(self):
|
||||||
|
"""무엇이 빠졌는지 말한다. 「어딘가 잘못됐다」로 끝나지 않는다."""
|
||||||
|
expected = {
|
||||||
|
"case": "결론",
|
||||||
|
"concept": "basisVersion",
|
||||||
|
"reference": "적용 조건",
|
||||||
|
"question": "다음 검증",
|
||||||
|
"decision": "판단 이유",
|
||||||
|
}
|
||||||
|
for kind, part in expected.items():
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
rules = " / ".join(_report("missing", kind).errors)
|
||||||
|
self.assertIn(part, rules)
|
||||||
|
|
||||||
|
def test_decision_kind_is_project_decision_in_frontmatter(self):
|
||||||
|
"""decision/ 폴더의 기록은 kind: PROJECT_DECISION 이다 (templates/decision.md)."""
|
||||||
|
self.assertEqual("decision", crc.KIND_ALIASES["PROJECT_DECISION"])
|
||||||
|
rep = _report("ok", "decision")
|
||||||
|
self.assertNotIn("kind 를 모르겠다", rep.errors)
|
||||||
|
|
||||||
|
def test_an_unanswered_question_is_not_an_error(self):
|
||||||
|
"""답이 없는 QUESTION 자체는 결함이 아니다. 답을 구할 방법이 없는 것이 결함이다."""
|
||||||
|
rep = _report("ok", "question")
|
||||||
|
self.assertEqual(0, rep.error_count)
|
||||||
|
|
||||||
|
def test_body_markers_belong_only_to_case_and_concept(self):
|
||||||
|
self.assertEqual({"case", "concept"}, crc.BODY_KINDS)
|
||||||
|
|
||||||
|
def _cli(self, *args):
|
||||||
|
import subprocess
|
||||||
|
return subprocess.run(
|
||||||
|
["python3", os.path.join(ROOT, "scripts", "check-required-content.py"), *args],
|
||||||
|
cwd=ROOT, capture_output=True, text=True)
|
||||||
|
|
||||||
|
def test_a_missing_project_is_not_reported_as_clean(self):
|
||||||
|
"""대상이 없으면 통과가 아니다. 오타 하나로 관문이 무효가 되면 안 된다."""
|
||||||
|
p = self._cli("nonexistent-project")
|
||||||
|
self.assertEqual(2, p.returncode)
|
||||||
|
self.assertIn("대상이 성립하지 않는다", p.stderr)
|
||||||
|
|
||||||
|
def test_a_project_without_a_contract_does_not_come_back_green(self):
|
||||||
|
"""계약이 없으면 「볼 것이 없어서 통과」다. 그것을 초록으로 내지 않는다."""
|
||||||
|
p = self._cli("ca-tmpl")
|
||||||
|
self.assertEqual(2, p.returncode)
|
||||||
|
self.assertIn("tech-log-tree.json 이 없다", p.stderr)
|
||||||
|
|
||||||
|
def test_a_contract_with_no_records_yet_is_not_an_error(self):
|
||||||
|
"""아직 안 쓴 것은 결함이 아니다. 다만 초록으로 보이면 안 된다."""
|
||||||
|
p = self._cli("keycloak-session-store")
|
||||||
|
self.assertEqual(0, p.returncode)
|
||||||
|
self.assertIn("아직 안 쓰였다", p.stdout)
|
||||||
|
|
||||||
|
def test_one_ungrounded_target_stops_the_whole_run(self):
|
||||||
|
"""성립하는 것과 안 하는 것을 함께 주면 통과로 뭉개지 않는다.
|
||||||
|
|
||||||
|
성립하는 쪽으로 `keycloak` 을 쓴다. `verify-pipeline.py` 의 계약이 scripts/ 안에
|
||||||
|
저장소 체크아웃 이름을 적는 것을 금지해서(`FORBIDDEN_LITERAL`), 그 이름과 같은
|
||||||
|
프로젝트를 테스트에 적으면 계약 검사가 깨진다.
|
||||||
|
"""
|
||||||
|
p = self._cli("keycloak", "ca-tmpl")
|
||||||
|
self.assertEqual(2, p.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user