From edd45dfec66d155a621cd41186b83b5582f2244c Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 11:06:07 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(scripts):=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=86=90=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9D=84=20=EC=88=98=20=EC=97=86=EA=B2=8C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B3=A0=20=EC=8A=A4=ED=82=AC=EC=97=90=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9D=84=20=EB=B6=99=EC=9D=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capture-evidence.py 는 명령을 subprocess 로 직접 돌리고 그 프로세스의 반환값을 그대로 메타에 적는다. 종료 코드를 인자로 받지 않으므로 손으로 적을 경로가 없다. raw 원문과 실행 메타가 같은 이름으로 함께 떨어져 「raw 는 있는데 meta 가 없다」가 구조적으로 안 생긴다. skill-versions.py 는 스킬 9개의 metadata.version 과 검사기 17개의 내용 해시를 한 장으로 낸다. 통과 판정을 검증기 버전에 묶으려면 묶을 값이 있어야 한다. 버전 칸이 없던 스킬 여덟에 1.0.0 을 붙였다. 산문은 한 줄도 안 바꿨다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- .../analyzing-codebase-for-tech-log/SKILL.md | 3 + .../deriving-tech-log-root-tree/SKILL.md | 3 + .../publishing-tech-log-to-studio/SKILL.md | 3 + .../skills/refactoring-from-analysis/SKILL.md | 3 + .../SKILL.md | 3 + .../skills/running-tech-log-pipeline/SKILL.md | 3 + .agents/skills/technical-visualizer/SKILL.md | 3 + .../writing-as-the-person-who-did-it/SKILL.md | 3 + scripts/capture-evidence.py | 136 ++++++++++++++++++ scripts/skill-versions.py | 83 +++++++++++ 10 files changed, 243 insertions(+) create mode 100644 scripts/capture-evidence.py create mode 100644 scripts/skill-versions.py diff --git a/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md b/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md index 8acd15e..f152f7d 100644 --- a/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md +++ b/.agents/skills/analyzing-codebase-for-tech-log/SKILL.md @@ -1,6 +1,9 @@ --- 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. +metadata: + version: "1.0.0" + language: "ko-KR" --- # Analyzing Codebase For Tech Log diff --git a/.agents/skills/deriving-tech-log-root-tree/SKILL.md b/.agents/skills/deriving-tech-log-root-tree/SKILL.md index e7ba951..d73d691 100644 --- a/.agents/skills/deriving-tech-log-root-tree/SKILL.md +++ b/.agents/skills/deriving-tech-log-root-tree/SKILL.md @@ -1,6 +1,9 @@ --- 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. +metadata: + version: "1.0.0" + language: "ko-KR" --- # Deriving Tech Log Root Tree diff --git a/.agents/skills/publishing-tech-log-to-studio/SKILL.md b/.agents/skills/publishing-tech-log-to-studio/SKILL.md index 4acdbd6..bedbdf6 100644 --- a/.agents/skills/publishing-tech-log-to-studio/SKILL.md +++ b/.agents/skills/publishing-tech-log-to-studio/SKILL.md @@ -1,6 +1,9 @@ --- 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. +metadata: + version: "1.0.0" + language: "ko-KR" --- # Studio 반입 — 저장까지만 diff --git a/.agents/skills/refactoring-from-analysis/SKILL.md b/.agents/skills/refactoring-from-analysis/SKILL.md index 0ec7244..a946352 100644 --- a/.agents/skills/refactoring-from-analysis/SKILL.md +++ b/.agents/skills/refactoring-from-analysis/SKILL.md @@ -1,6 +1,9 @@ --- 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. +metadata: + version: "1.0.0" + language: "ko-KR" --- # Refactoring From Analysis diff --git a/.agents/skills/rewriting-technical-prose-naturally/SKILL.md b/.agents/skills/rewriting-technical-prose-naturally/SKILL.md index d45c96a..27c5420 100644 --- a/.agents/skills/rewriting-technical-prose-naturally/SKILL.md +++ b/.agents/skills/rewriting-technical-prose-naturally/SKILL.md @@ -1,6 +1,9 @@ --- 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. +metadata: + version: "1.0.0" + language: "ko-KR" --- # Rewriting Technical Prose Naturally diff --git a/.agents/skills/running-tech-log-pipeline/SKILL.md b/.agents/skills/running-tech-log-pipeline/SKILL.md index 9e2e81c..9097b05 100644 --- a/.agents/skills/running-tech-log-pipeline/SKILL.md +++ b/.agents/skills/running-tech-log-pipeline/SKILL.md @@ -1,6 +1,9 @@ --- 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. +metadata: + version: "1.0.0" + language: "ko-KR" --- # Tech Log 파이프라인 실행 diff --git a/.agents/skills/technical-visualizer/SKILL.md b/.agents/skills/technical-visualizer/SKILL.md index a7d8e8b..5cfe1f4 100644 --- a/.agents/skills/technical-visualizer/SKILL.md +++ b/.agents/skills/technical-visualizer/SKILL.md @@ -1,6 +1,9 @@ --- 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. +metadata: + version: "1.0.0" + language: "ko-KR" --- # Technical Visualizer diff --git a/.agents/skills/writing-as-the-person-who-did-it/SKILL.md b/.agents/skills/writing-as-the-person-who-did-it/SKILL.md index 1b7a131..f52663e 100644 --- a/.agents/skills/writing-as-the-person-who-did-it/SKILL.md +++ b/.agents/skills/writing-as-the-person-who-did-it/SKILL.md @@ -1,6 +1,9 @@ --- 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. +metadata: + version: "1.0.0" + language: "ko-KR" --- # 일한 사람이 쓴 글로 만들기 diff --git a/scripts/capture-evidence.py b/scripts/capture-evidence.py new file mode 100644 index 0000000..756c531 --- /dev/null +++ b/scripts/capture-evidence.py @@ -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()) diff --git a/scripts/skill-versions.py b/scripts/skill-versions.py new file mode 100644 index 0000000..7595c6c --- /dev/null +++ b/scripts/skill-versions.py @@ -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()) From 7fc7c69157302428336fece947a59b8180eeca68 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 11:06:32 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(terminal-evidence):=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=82=B9=EC=9D=B4=20=EC=9E=90=EA=B2=A9=EC=A6=9D?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=EB=8D=AE=EC=9C=BC=EB=A9=B4=EC=84=9C=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EC=9D=84=20=EA=B3=A0=EC=B9=98=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 가지가 겹쳐 있었다. 줄 맨 앞 앵커 때문에 `curl -H "Authorization: Bearer ..."` 처럼 명령 인자 안에 든 자격증명을 놓쳤다. 터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 그 명령줄이다. 그리고 키워드 패턴의 값이 `[^\s,;]+` 라 공백까지 먹어 닫는 따옴표를 넘어갔다. `curl -H "X-Api-Key: TESTONLY-x" https://...` 가 `curl -H "X-Api-Key: [REDACTED] https://...` 가 된다. 다중 -H 에서는 다음 인자의 경계까지 무너진다. 증거에 실린 명령이 실제로 돌린 명령과 달라진다. 값의 끝을 따옴표 앞에서 막되, 감싼 따옴표는 되돌려 놓는다. 문자 집합만 좁히면 `TOKEN="eyJ..."` 가 여는 따옴표에서 막혀 아예 안 가려진다. 함께 메운 것: Authorization/Proxy-Authorization 의 Basic, `curl -u`/`--user` (사용자 이름은 남긴다 — 어느 계정으로 붙었는지가 증거의 일부다), 그리고 JWT. 회귀는 「가려졌는가」만 묻지 않는다. 원문과 따옴표 수가 같은지 함께 본다. 앞선 회귀가 그것을 안 물어서 이 결함을 통과시켰다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- scripts/terminal-evidence/render_terminal.py | 28 +++++- .../tests/test_render_terminal.py | 91 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/scripts/terminal-evidence/render_terminal.py b/scripts/terminal-evidence/render_terminal.py index a00699a..2da2d53 100755 --- a/scripts/terminal-evidence/render_terminal.py +++ b/scripts/terminal-evidence/render_terminal.py @@ -9,13 +9,33 @@ from pathlib import Path ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") _REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( - (re.compile(r"(?i)^(\s*authorization\s*:\s*bearer\s+).*$"), r"\1[REDACTED]"), - (re.compile(r"(?i)^(\s*(?:cookie|set-cookie)\s*:\s*).*$"), r"\1[REDACTED]"), + # 줄 맨 앞에 앵커를 두면 `curl -H "Authorization: Bearer ..."` 를 놓친다. + # 터미널 증거에서 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( - 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( diff --git a/scripts/terminal-evidence/tests/test_render_terminal.py b/scripts/terminal-evidence/tests/test_render_terminal.py index ed3e1ac..9cc54cf 100644 --- a/scripts/terminal-evidence/tests/test_render_terminal.py +++ b/scripts/terminal-evidence/tests/test_render_terminal.py @@ -43,6 +43,97 @@ class RenderTerminalTest(unittest.TestCase): with self.subTest(raw=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): line = "GET /api/me -> 200 in 14ms" self.assertEqual(line, redact_line(line)) From cf3996711f2baf590fb0525ce99650bd975f3e7e Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 11:06:32 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(scripts):=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=8C=90=EC=A0=95=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=9C=20=EA=B2=83=EB=A7=8C=20=EC=BD=94=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=89=EA=B3=A0=20=EB=82=98=EB=A8=B8=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=EB=A1=9C=20=EB=84=98=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 형식 관문 여덟이 확신 승격·수치 조작·화살표 뒤집기·필수 칸 삭제를 하나도 못 막는 것이 재현됐다. 그 가운데 결정적으로 판정 가능한 것을 코드로 옮긴다. check-required-content.py — 종류가 요구하는 칸이 없거나 비었는지 본다. audit-records.py 는 평문 칸 안에 마크업이 있는지만 보고 칸이 있는지는 안 센다. 고정 목차·자료 개수·답은 강제하지 않는다. 답이 없는 QUESTION 은 정상이고 「다음 검증」이 빈 것만 결함이다. 대상이 성립하지 않으면(프로젝트 없음·계약 없음) exit 2 로 막고, 계약은 있고 기록이 0건이면 통과시키되 초록으로 두지 않는다. 「봤고 괜찮다」와 「볼 것이 없어서 통과」는 다르다. check-preservation.py — 윤문 전후를 견준다. 지금 관문 가운데 편집 전후를 보는 것이 하나도 없어 수치를 바꾸거나 유보를 지운 편집이 그대로 통과했다. 사라진 것과 새로 생긴 것을 따로 센다. 새로 생긴 수치는 지어낸 값일 수 있다. 유보 표현이 줄면 내되 옳은지는 판정하지 않는다. 늘어난 것은 세지 않는다. review-package.py — 아무것도 판정하지 않는다. 판정할 사람이 받을 것을 모은다. 해시·검사기 버전·여기서 실제로 돌린 관문·주장 후보·판정 기준. 종료 코드로 안 걸리는 것은 warnings 로 따로 올린다 — 확신 승격이 딱 그 모양이라 안 실으면 아무도 못 본다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- scripts/check-preservation.py | 129 ++++++++ scripts/check-required-content.py | 263 +++++++++++++++ scripts/review-package.py | 304 ++++++++++++++++++ .../tests/fixtures/preservation/after-ok.md | 11 + .../fixtures/preservation/after-tampered.md | 11 + scripts/tests/fixtures/preservation/before.md | 11 + .../fixtures/required-content/missing/case.md | 46 +++ .../required-content/missing/concept.md | 28 ++ .../required-content/missing/decision.md | 26 ++ .../required-content/missing/question.md | 30 ++ .../required-content/missing/reference.md | 31 ++ .../fixtures/required-content/ok/case.md | 49 +++ .../fixtures/required-content/ok/concept.md | 29 ++ .../fixtures/required-content/ok/decision.md | 30 ++ .../fixtures/required-content/ok/question.md | 33 ++ .../fixtures/required-content/ok/reference.md | 35 ++ scripts/tests/test_preservation.py | 78 +++++ scripts/tests/test_required_content.py | 106 ++++++ 18 files changed, 1250 insertions(+) create mode 100644 scripts/check-preservation.py create mode 100644 scripts/check-required-content.py create mode 100644 scripts/review-package.py create mode 100644 scripts/tests/fixtures/preservation/after-ok.md create mode 100644 scripts/tests/fixtures/preservation/after-tampered.md create mode 100644 scripts/tests/fixtures/preservation/before.md create mode 100644 scripts/tests/fixtures/required-content/missing/case.md create mode 100644 scripts/tests/fixtures/required-content/missing/concept.md create mode 100644 scripts/tests/fixtures/required-content/missing/decision.md create mode 100644 scripts/tests/fixtures/required-content/missing/question.md create mode 100644 scripts/tests/fixtures/required-content/missing/reference.md create mode 100644 scripts/tests/fixtures/required-content/ok/case.md create mode 100644 scripts/tests/fixtures/required-content/ok/concept.md create mode 100644 scripts/tests/fixtures/required-content/ok/decision.md create mode 100644 scripts/tests/fixtures/required-content/ok/question.md create mode 100644 scripts/tests/fixtures/required-content/ok/reference.md create mode 100644 scripts/tests/test_preservation.py create mode 100644 scripts/tests/test_required_content.py diff --git a/scripts/check-preservation.py b/scripts/check-preservation.py new file mode 100644 index 0000000..b4bd39f --- /dev/null +++ b/scripts/check-preservation.py @@ -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 +""" +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"(? 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()) diff --git a/scripts/check-required-content.py b/scripts/check-required-content.py new file mode 100644 index 0000000..3e96c2c --- /dev/null +++ b/scripts/check-required-content.py @@ -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 = "", "" + + +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("", "" + + +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(("#", "|", " + +## 무엇이 있었나 + +본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다. + + diff --git a/scripts/tests/fixtures/required-content/missing/concept.md b/scripts/tests/fixtures/required-content/missing/concept.md new file mode 100644 index 0000000..ea3e3b2 --- /dev/null +++ b/scripts/tests/fixtures/required-content/missing/concept.md @@ -0,0 +1,28 @@ +--- +kind: CONCEPT +slug: fixture-concept +title: 고정 사례 개념 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +--- + +# 고정 사례 개념 + +개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다. + +## 관계 + +- **고정 사례 케이스** + 그 사건이 이 개념 위에서 벌어진다. + +## 본문 + + + +## 정의 + +적용 범위까지 함께 적는다. + + diff --git a/scripts/tests/fixtures/required-content/missing/decision.md b/scripts/tests/fixtures/required-content/missing/decision.md new file mode 100644 index 0000000..caa2311 --- /dev/null +++ b/scripts/tests/fixtures/required-content/missing/decision.md @@ -0,0 +1,26 @@ +--- +kind: PROJECT_DECISION +slug: fixture-decision +title: 고정 사례 결정 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +decisionStatus: PROPOSED +--- + +# 고정 사례 결정 + +무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다. + +## 근거 + +기록에 있는 근거만 적는다. + +## 결정문 + +실제로 고른 것을 적는다. + +## 영향 + +감수한 비용과 재검토 조건을 적는다. diff --git a/scripts/tests/fixtures/required-content/missing/question.md b/scripts/tests/fixtures/required-content/missing/question.md new file mode 100644 index 0000000..a7a1d57 --- /dev/null +++ b/scripts/tests/fixtures/required-content/missing/question.md @@ -0,0 +1,30 @@ +--- +kind: QUESTION +slug: fixture-question +title: 고정 사례 물음 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +questionStatus: OPEN +--- + +# 고정 사례 물음 + +무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다. + +## 관계 + +- **고정 사례 케이스** + 그 사건이 이 물음을 열었다. + +## 사실 + +- 확인된 사실을 적는다. + +## 미지수 + +- 아직 모르는 것을 적는다. + +## 다음 검증 + diff --git a/scripts/tests/fixtures/required-content/missing/reference.md b/scripts/tests/fixtures/required-content/missing/reference.md new file mode 100644 index 0000000..dc37d26 --- /dev/null +++ b/scripts/tests/fixtures/required-content/missing/reference.md @@ -0,0 +1,31 @@ +--- +kind: REFERENCE +slug: fixture-reference +title: 고정 사례 참조 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +--- + +# 고정 사례 참조 + +무엇을 참고하는 기준인지 한 문단으로 적는다. + +## 관계 + +- **고정 사례 케이스** + 그 사건에서 이 기준이 쓰였다. + +## 목적 + +이 기준을 쓰는 이유를 적는다. + +## 규칙 + +1. 판단 기준을 적는다 + 근거와 함께 적는다. + +## 예외 + +적용되지 않는 조건을 적는다. diff --git a/scripts/tests/fixtures/required-content/ok/case.md b/scripts/tests/fixtures/required-content/ok/case.md new file mode 100644 index 0000000..cbfa4f2 --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/case.md @@ -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. 값이 갈리는 것을 본다. + +## 본문 + + + +## 무엇이 있었나 + +본문은 절 구성이 글마다 다르다. 검사기는 여기를 보지 않는다. + + diff --git a/scripts/tests/fixtures/required-content/ok/concept.md b/scripts/tests/fixtures/required-content/ok/concept.md new file mode 100644 index 0000000..10338a0 --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/concept.md @@ -0,0 +1,29 @@ +--- +kind: CONCEPT +slug: fixture-concept +title: 고정 사례 개념 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +basisVersion: 예시 명세 1.0 +--- + +# 고정 사례 개념 + +개념이 무엇이고 이 코드에서 어떻게 나타나는지 한 문단으로 적는다. + +## 관계 + +- **고정 사례 케이스** + 그 사건이 이 개념 위에서 벌어진다. + +## 본문 + + + +## 정의 + +적용 범위까지 함께 적는다. + + diff --git a/scripts/tests/fixtures/required-content/ok/decision.md b/scripts/tests/fixtures/required-content/ok/decision.md new file mode 100644 index 0000000..0296eb2 --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/decision.md @@ -0,0 +1,30 @@ +--- +kind: PROJECT_DECISION +slug: fixture-decision +title: 고정 사례 결정 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +decisionStatus: PROPOSED +--- + +# 고정 사례 결정 + +무엇을 어떤 조건에서 골랐는지 한 문단으로 적는다. + +## 근거 + +기록에 있는 근거만 적는다. + +## 결정문 + +실제로 고른 것을 적는다. + +## 판단 이유 + +확인된 대안과 그것을 고르지 않은 이유를 적는다. + +## 영향 + +감수한 비용과 재검토 조건을 적는다. diff --git a/scripts/tests/fixtures/required-content/ok/question.md b/scripts/tests/fixtures/required-content/ok/question.md new file mode 100644 index 0000000..82a3f3e --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/question.md @@ -0,0 +1,33 @@ +--- +kind: QUESTION +slug: fixture-question +title: 고정 사례 물음 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +questionStatus: OPEN +--- + +# 고정 사례 물음 + +무엇이 아직 불명확한지 한 문단으로 적는다. 답이 없는 것 자체는 결함이 아니다. + +## 관계 + +- **고정 사례 케이스** + 그 사건이 이 물음을 열었다. + +## 사실 + +- 확인된 사실을 적는다. + +## 미지수 + +- 아직 모르는 것을 적는다. + +## 다음 검증 + +1. 답을 구할 방법을 적는다. + +닫는 조건 : 어떤 결과가 나오면 닫는지 적는다. diff --git a/scripts/tests/fixtures/required-content/ok/reference.md b/scripts/tests/fixtures/required-content/ok/reference.md new file mode 100644 index 0000000..e7112dd --- /dev/null +++ b/scripts/tests/fixtures/required-content/ok/reference.md @@ -0,0 +1,35 @@ +--- +kind: REFERENCE +slug: fixture-reference +title: 고정 사례 참조 +topic: fixture-topic +project: fixture +status: 게시 전 +sourceRevision: 0000000000000000000000000000000000000000 +--- + +# 고정 사례 참조 + +무엇을 참고하는 기준인지 한 문단으로 적는다. + +## 관계 + +- **고정 사례 케이스** + 그 사건에서 이 기준이 쓰였다. + +## 목적 + +이 기준을 쓰는 이유를 적는다. + +## 규칙 + +1. 판단 기준을 적는다 + 근거와 함께 적는다. + +## 적용 조건 + +어느 버전·어느 전제에서 쓰는지 적는다. + +## 예외 + +적용되지 않는 조건을 적는다. diff --git a/scripts/tests/test_preservation.py b/scripts/tests/test_preservation.py new file mode 100644 index 0000000..cff5572 --- /dev/null +++ b/scripts/tests/test_preservation.py @@ -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() diff --git a/scripts/tests/test_required_content.py b/scripts/tests/test_required_content.py new file mode 100644 index 0000000..d173ef4 --- /dev/null +++ b/scripts/tests/test_required_content.py @@ -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() From 230e1b20cbfc0a5a6f9d9705a8c91a050c971c47 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 11:06:50 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs(document-haness):=20=EA=B4=80=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=20=EC=A2=85=EB=A3=8C=20=EC=BD=94=EB=93=9C=20=E2=80=94?= =?UTF-8?q?=20CASE=20=ED=95=9C=20=ED=8E=B8=EC=9D=84=20=EA=B7=BC=EA=B1=B0?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EC=9E=87=EA=B3=A0=20=EB=9F=B0=20=EC=9B=90?= =?UTF-8?q?=EC=9E=A5=EC=9D=84=20=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 저장소 자신의 검사 층을 대상으로 삼았다. 파이프 뒤의 $? 를 읽고 검사기 열넷이 전부 --help 를 받는다고 적었다가, 파이프를 걷고 다시 재니 둘이 exit 1 이었던 일. 관찰·원인·조치가 한 사건으로 닫히고 조치가 capture-evidence.py 다. 증거 여덟 건은 전부 그 도구가 수집했다. proves/doesNotProve 로 「이 출력이 뒷받침하는 것」과 「뒷받침하지 못하는 것」을 증거 쪽에 적어 두었다 — 본문이 그 경계를 넘었는지 대조할 것이 생긴다. 함께 올린 Question 은 「검사할 것이 없을 때 관문은 무엇을 내야 하는가」다. 현상은 재현했고 조치가 없어 Case 로 올리지 않았다. 조치 없이 쓰면 관찰만 있고 결과가 없는 글이 된다. 런 원장은 단계마다 서브에이전트를 하나씩 띄운 기록이다. 넷이 실제로 무언가를 잡았다 — S3 이 「argparse 를 쓰지 않는 셋」이 증거 원문과 어긋나는 것을(넷이다), S1 이 그 뿌리를 SSOT·앵커·증거 메타에서, S5 가 「증거 여섯 개」가 frontmatter 의 다섯과 어긋나는 것을, S6 이 계약의 「한 번밖에 안 써서」가 메타 여덟 건과 어긋나는 것을. 셋 다 한 세션이 일곱 단계를 겸했으면 안 나왔다. 내가 쓴 글을 내가 다시 읽는 것이기 때문이다. style_profile 은 exit 1 로 그대로 적었다. 관문이 아니라 측정이고, 돌리지 않은 값을 0 으로 적는 것이 이 원장이 막으려는 바로 그것이다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- .../document-haness/final/.techviz/README.txt | 2 + docs/document-haness/final/assets/README.txt | 3 + docs/document-haness/final/document.md | 345 ++++++++++++++++++ .../final/evidence/browser/README.txt | 1 + .../final/evidence/meta/README.txt | 1 + .../meta/argparse-absent-scripts.json | 18 + .../meta/exit-code-through-a-pipe.json | 18 + .../meta/gates-on-a-nonexistent-project.json | 18 + ...lp-exit-codes-measured-through-a-pipe.json | 18 + ...lp-exit-codes-measured-without-a-pipe.json | 18 + .../meta/redaction-covers-command-lines.json | 18 + .../redaction-keeps-the-command-intact.json | 18 + .../meta/what-the-three-print-for-help.json | 18 + .../final/evidence/raw/README.txt | 2 + .../evidence/raw/argparse-absent-scripts.txt | 14 + .../evidence/raw/exit-code-through-a-pipe.txt | 3 + .../raw/gates-on-a-nonexistent-project.txt | 32 ++ .../final/evidence/raw/guards/README.txt | 2 + .../guards/redaction-covers-command-lines.txt | 7 + .../redaction-keeps-the-command-intact.txt | 9 + ...elp-exit-codes-measured-through-a-pipe.txt | 14 + ...elp-exit-codes-measured-without-a-pipe.txt | 14 + .../raw/what-the-three-print-for-help.txt | 17 + .../final/evidence/rendered/README.txt | 2 + .../case/case-exit-code-read-behind-a-pipe.md | 283 ++++++++++++++ ...at-a-gate-returns-with-nothing-to-check.md | 76 ++++ .../tech-log-studio/tech-log-tree.json | 230 ++++++++++++ runs/document-haness/2026-09-10-1033/run.json | 233 ++++++++++++ .../2026-09-10-1033/stage/S3-before.md | 275 ++++++++++++++ .../2026-09-10-1033/stage/S5-before.md | 275 ++++++++++++++ .../2026-09-10-1033/stage/S6-before.md | 278 ++++++++++++++ 31 files changed, 2262 insertions(+) create mode 100644 docs/document-haness/final/.techviz/README.txt create mode 100644 docs/document-haness/final/assets/README.txt create mode 100644 docs/document-haness/final/document.md create mode 100644 docs/document-haness/final/evidence/browser/README.txt create mode 100644 docs/document-haness/final/evidence/meta/README.txt create mode 100644 docs/document-haness/final/evidence/meta/argparse-absent-scripts.json create mode 100644 docs/document-haness/final/evidence/meta/exit-code-through-a-pipe.json create mode 100644 docs/document-haness/final/evidence/meta/gates-on-a-nonexistent-project.json create mode 100644 docs/document-haness/final/evidence/meta/help-exit-codes-measured-through-a-pipe.json create mode 100644 docs/document-haness/final/evidence/meta/help-exit-codes-measured-without-a-pipe.json create mode 100644 docs/document-haness/final/evidence/meta/redaction-covers-command-lines.json create mode 100644 docs/document-haness/final/evidence/meta/redaction-keeps-the-command-intact.json create mode 100644 docs/document-haness/final/evidence/meta/what-the-three-print-for-help.json create mode 100644 docs/document-haness/final/evidence/raw/README.txt create mode 100644 docs/document-haness/final/evidence/raw/argparse-absent-scripts.txt create mode 100644 docs/document-haness/final/evidence/raw/exit-code-through-a-pipe.txt create mode 100644 docs/document-haness/final/evidence/raw/gates-on-a-nonexistent-project.txt create mode 100644 docs/document-haness/final/evidence/raw/guards/README.txt create mode 100644 docs/document-haness/final/evidence/raw/guards/redaction-covers-command-lines.txt create mode 100644 docs/document-haness/final/evidence/raw/guards/redaction-keeps-the-command-intact.txt create mode 100644 docs/document-haness/final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt create mode 100644 docs/document-haness/final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt create mode 100644 docs/document-haness/final/evidence/raw/what-the-three-print-for-help.txt create mode 100644 docs/document-haness/final/evidence/rendered/README.txt create mode 100644 docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md create mode 100644 docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/question/question-what-a-gate-returns-with-nothing-to-check.md create mode 100644 docs/document-haness/tech-log-studio/tech-log-tree.json create mode 100644 runs/document-haness/2026-09-10-1033/run.json create mode 100644 runs/document-haness/2026-09-10-1033/stage/S3-before.md create mode 100644 runs/document-haness/2026-09-10-1033/stage/S5-before.md create mode 100644 runs/document-haness/2026-09-10-1033/stage/S6-before.md diff --git a/docs/document-haness/final/.techviz/README.txt b/docs/document-haness/final/.techviz/README.txt new file mode 100644 index 0000000..c646efe --- /dev/null +++ b/docs/document-haness/final/.techviz/README.txt @@ -0,0 +1,2 @@ +그림의 정본 — <이름>/{context.json, spec.json, prompt.md}. +technical-visualizer 스킬이 만든다. 손으로 SVG 를 그리지 않는다. diff --git a/docs/document-haness/final/assets/README.txt b/docs/document-haness/final/assets/README.txt new file mode 100644 index 0000000..4dbe2eb --- /dev/null +++ b/docs/document-haness/final/assets/README.txt @@ -0,0 +1,3 @@ +그림. 그림 하나가 폴더 하나다 — <이름>/<이름>.svg 와 편집 형식들. +기록의 assets: file: 도 이 폴더를 가리킨다. Studio 에 올릴 사본을 따로 두지 않는다 — +사본을 두면 정본이 둘이 되고, 사본 쪽에는 ../../.techviz/<이름>/ 이 없어 다시 만들 수 없다. diff --git a/docs/document-haness/final/document.md b/docs/document-haness/final/document.md new file mode 100644 index 0000000..945ef5f --- /dev/null +++ b/docs/document-haness/final/document.md @@ -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/<프로젝트>//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-.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 을 고치는 것은 이 저장소의 다른 담당 몫이고 여기서는 + 현상만 적는다. diff --git a/docs/document-haness/final/evidence/browser/README.txt b/docs/document-haness/final/evidence/browser/README.txt new file mode 100644 index 0000000..59f33a7 --- /dev/null +++ b/docs/document-haness/final/evidence/browser/README.txt @@ -0,0 +1 @@ +Playwright MCP 로 찍은 브라우저 캡처. 무엇을 찍었는지 한 줄을 적는다. diff --git a/docs/document-haness/final/evidence/meta/README.txt b/docs/document-haness/final/evidence/meta/README.txt new file mode 100644 index 0000000..d643eae --- /dev/null +++ b/docs/document-haness/final/evidence/meta/README.txt @@ -0,0 +1 @@ +그 실행의 command·cwd·executedAt·exitCode·revision. 형식은 evidence.json. diff --git a/docs/document-haness/final/evidence/meta/argparse-absent-scripts.json b/docs/document-haness/final/evidence/meta/argparse-absent-scripts.json new file mode 100644 index 0000000..b37db8d --- /dev/null +++ b/docs/document-haness/final/evidence/meta/argparse-absent-scripts.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 +} diff --git a/docs/document-haness/final/evidence/meta/exit-code-through-a-pipe.json b/docs/document-haness/final/evidence/meta/exit-code-through-a-pipe.json new file mode 100644 index 0000000..e5880d8 --- /dev/null +++ b/docs/document-haness/final/evidence/meta/exit-code-through-a-pipe.json @@ -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 +} diff --git a/docs/document-haness/final/evidence/meta/gates-on-a-nonexistent-project.json b/docs/document-haness/final/evidence/meta/gates-on-a-nonexistent-project.json new file mode 100644 index 0000000..a0c925d --- /dev/null +++ b/docs/document-haness/final/evidence/meta/gates-on-a-nonexistent-project.json @@ -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 +} diff --git a/docs/document-haness/final/evidence/meta/help-exit-codes-measured-through-a-pipe.json b/docs/document-haness/final/evidence/meta/help-exit-codes-measured-through-a-pipe.json new file mode 100644 index 0000000..2c405bd --- /dev/null +++ b/docs/document-haness/final/evidence/meta/help-exit-codes-measured-through-a-pipe.json @@ -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 +} diff --git a/docs/document-haness/final/evidence/meta/help-exit-codes-measured-without-a-pipe.json b/docs/document-haness/final/evidence/meta/help-exit-codes-measured-without-a-pipe.json new file mode 100644 index 0000000..c7dbdf3 --- /dev/null +++ b/docs/document-haness/final/evidence/meta/help-exit-codes-measured-without-a-pipe.json @@ -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 +} diff --git a/docs/document-haness/final/evidence/meta/redaction-covers-command-lines.json b/docs/document-haness/final/evidence/meta/redaction-covers-command-lines.json new file mode 100644 index 0000000..dd8f749 --- /dev/null +++ b/docs/document-haness/final/evidence/meta/redaction-covers-command-lines.json @@ -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']*>(.*?)', 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 +} diff --git a/docs/document-haness/final/evidence/meta/redaction-keeps-the-command-intact.json b/docs/document-haness/final/evidence/meta/redaction-keeps-the-command-intact.json new file mode 100644 index 0000000..0be1629 --- /dev/null +++ b/docs/document-haness/final/evidence/meta/redaction-keeps-the-command-intact.json @@ -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 +} diff --git a/docs/document-haness/final/evidence/meta/what-the-three-print-for-help.json b/docs/document-haness/final/evidence/meta/what-the-three-print-for-help.json new file mode 100644 index 0000000..9c7914e --- /dev/null +++ b/docs/document-haness/final/evidence/meta/what-the-three-print-for-help.json @@ -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 +} diff --git a/docs/document-haness/final/evidence/raw/README.txt b/docs/document-haness/final/evidence/raw/README.txt new file mode 100644 index 0000000..21d79e5 --- /dev/null +++ b/docs/document-haness/final/evidence/raw/README.txt @@ -0,0 +1,2 @@ +명령 출력·csv·덤프 원문. 여기가 정본이다. +하위 폴더를 자유롭게 둔다(explain/, guards/). 폴더마다 무엇을 담았는지 한 줄을 README.txt 에 적는다. diff --git a/docs/document-haness/final/evidence/raw/argparse-absent-scripts.txt b/docs/document-haness/final/evidence/raw/argparse-absent-scripts.txt new file mode 100644 index 0000000..175c4f9 --- /dev/null +++ b/docs/document-haness/final/evidence/raw/argparse-absent-scripts.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 diff --git a/docs/document-haness/final/evidence/raw/exit-code-through-a-pipe.txt b/docs/document-haness/final/evidence/raw/exit-code-through-a-pipe.txt new file mode 100644 index 0000000..0e1a58d --- /dev/null +++ b/docs/document-haness/final/evidence/raw/exit-code-through-a-pipe.txt @@ -0,0 +1,3 @@ +false | head -1 -> exit=0 +false -> exit=1 +bash 5.2.21(1)-release diff --git a/docs/document-haness/final/evidence/raw/gates-on-a-nonexistent-project.txt b/docs/document-haness/final/evidence/raw/gates-on-a-nonexistent-project.txt new file mode 100644 index 0000000..694ae5e --- /dev/null +++ b/docs/document-haness/final/evidence/raw/gates-on-a-nonexistent-project.txt @@ -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 + diff --git a/docs/document-haness/final/evidence/raw/guards/README.txt b/docs/document-haness/final/evidence/raw/guards/README.txt new file mode 100644 index 0000000..39a9474 --- /dev/null +++ b/docs/document-haness/final/evidence/raw/guards/README.txt @@ -0,0 +1,2 @@ +터미널 증거 렌더러의 마스킹이 실제로 덮는지 확인한 출력. +쓰인 자격증명은 전부 합성(TESTONLY-*)이고 실제 비밀값이 아니다. diff --git a/docs/document-haness/final/evidence/raw/guards/redaction-covers-command-lines.txt b/docs/document-haness/final/evidence/raw/guards/redaction-covers-command-lines.txt new file mode 100644 index 0000000..12f0317 --- /dev/null +++ b/docs/document-haness/final/evidence/raw/guards/redaction-covers-command-lines.txt @@ -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 diff --git a/docs/document-haness/final/evidence/raw/guards/redaction-keeps-the-command-intact.txt b/docs/document-haness/final/evidence/raw/guards/redaction-keeps-the-command-intact.txt new file mode 100644 index 0000000..5207d12 --- /dev/null +++ b/docs/document-haness/final/evidence/raw/guards/redaction-keeps-the-command-intact.txt @@ -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 diff --git a/docs/document-haness/final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt b/docs/document-haness/final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt new file mode 100644 index 0000000..051d01a --- /dev/null +++ b/docs/document-haness/final/evidence/raw/help-exit-codes-measured-through-a-pipe.txt @@ -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 diff --git a/docs/document-haness/final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt b/docs/document-haness/final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt new file mode 100644 index 0000000..a0d9c9b --- /dev/null +++ b/docs/document-haness/final/evidence/raw/help-exit-codes-measured-without-a-pipe.txt @@ -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 diff --git a/docs/document-haness/final/evidence/raw/what-the-three-print-for-help.txt b/docs/document-haness/final/evidence/raw/what-the-three-print-for-help.txt new file mode 100644 index 0000000..8ff5d0d --- /dev/null +++ b/docs/document-haness/final/evidence/raw/what-the-three-print-for-help.txt @@ -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 + diff --git a/docs/document-haness/final/evidence/rendered/README.txt b/docs/document-haness/final/evidence/rendered/README.txt new file mode 100644 index 0000000..6f974cb --- /dev/null +++ b/docs/document-haness/final/evidence/rendered/README.txt @@ -0,0 +1,2 @@ +raw 에서 만든 터미널 SVG. 표현물이지 정본이 아니다. +scripts/terminal-evidence/render_terminal.py 로 만든다. diff --git a/docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md b/docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md new file mode 100644 index 0000000..6296f36 --- /dev/null +++ b/docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md @@ -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 로 갈리는 것을 본다. + +## 본문 + + + +## 두 번 같은 값이 나왔다 + +검사기 목록을 만들려고 열넷에 `--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 로 찍고 메타를 손으로 적는다 — 종료 코드를 +손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다. + + diff --git a/docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/question/question-what-a-gate-returns-with-nothing-to-check.md b/docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/question/question-what-a-gate-returns-with-nothing-to-check.md new file mode 100644 index 0000000..d100302 --- /dev/null +++ b/docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/question/question-what-a-gate-returns-with-nothing-to-check.md @@ -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 로 넘긴다. diff --git a/docs/document-haness/tech-log-studio/tech-log-tree.json b/docs/document-haness/tech-log-studio/tech-log-tree.json new file mode 100644 index 0000000..238f304 --- /dev/null +++ b/docs/document-haness/tech-log-studio/tech-log-tree.json @@ -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": {} +} diff --git a/runs/document-haness/2026-09-10-1033/run.json b/runs/document-haness/2026-09-10-1033/run.json new file mode 100644 index 0000000..f50ea4c --- /dev/null +++ b/runs/document-haness/2026-09-10-1033/run.json @@ -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 는 비어 있다. 저장한 적이 없다는 뜻이고 그대로 둔다." + } + ] +} diff --git a/runs/document-haness/2026-09-10-1033/stage/S3-before.md b/runs/document-haness/2026-09-10-1033/stage/S3-before.md new file mode 100644 index 0000000..d735353 --- /dev/null +++ b/runs/document-haness/2026-09-10-1033/stage/S3-before.md @@ -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 로 갈리는 것을 본다. + +## 본문 + + + +## 두 번 같은 값이 나왔다 + +검사기 목록을 만들려고 열넷에 `--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 로 찍고 메타를 손으로 적는다 — 종료 코드를 +손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다. + + diff --git a/runs/document-haness/2026-09-10-1033/stage/S5-before.md b/runs/document-haness/2026-09-10-1033/stage/S5-before.md new file mode 100644 index 0000000..86a5cf7 --- /dev/null +++ b/runs/document-haness/2026-09-10-1033/stage/S5-before.md @@ -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 로 갈리는 것을 본다. + +## 본문 + + + +## 두 번 같은 값이 나왔다 + +검사기 목록을 만들려고 열넷에 `--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 로 찍고 메타를 손으로 적는다 — 종료 코드를 +손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다. + + diff --git a/runs/document-haness/2026-09-10-1033/stage/S6-before.md b/runs/document-haness/2026-09-10-1033/stage/S6-before.md new file mode 100644 index 0000000..98de0a9 --- /dev/null +++ b/runs/document-haness/2026-09-10-1033/stage/S6-before.md @@ -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 로 갈리는 것을 본다. + +## 본문 + + + +## 두 번 같은 값이 나왔다 + +검사기 목록을 만들려고 열넷에 `--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 로 찍고 메타를 손으로 적는다 — 종료 코드를 +손으로 적을 수 없게 만든 것이 아직 절반이라는 뜻이다. + +