refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Frozen command analysis와 CommandPatchSet을 사용해 command span만 교체한다."""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from command_pedagogy import apply_command_patch_set # noqa: E402
def load_json(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
value = json.load(fh)
if not isinstance(value, dict):
raise ValueError(f"JSON object가 아니다: {path}")
return value
def main() -> int:
ap = argparse.ArgumentParser(description="CommandPatchSet을 원래 command span 안에만 적용한다")
ap.add_argument("record", help="원본 Markdown")
ap.add_argument("analysis", help="check-command-pedagogy.py가 만든 initial analysis JSON")
ap.add_argument("patch_set", help="command-pedagogy-editor가 만든 patch JSON")
ap.add_argument("-o", "--output", required=True, help="수정된 Markdown 출력 경로")
args = ap.parse_args()
try:
with open(args.record, encoding="utf-8") as fh:
text = fh.read()
analysis = load_json(args.analysis)
patch_set = load_json(args.patch_set)
repaired = apply_command_patch_set(text=text, analysis=analysis, patch_set=patch_set)
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"command patch를 적용하지 못했다: {exc}", file=sys.stderr)
return 2
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(repaired)
print(f"command span만 적용했다: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Markdown 한 편의 shell/CLI 블록을 command-pedagogy 분석 JSON으로 만든다."""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from command_pedagogy import COMMAND_MODES, analyze_commands # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser(description="shell/CLI 교육성 위험 신호를 결정론적으로 찾는다")
ap.add_argument("record", help="검사할 Markdown 파일")
ap.add_argument("--section-id", default=None)
ap.add_argument("--mode", choices=sorted(COMMAND_MODES), default="operator")
ap.add_argument("-o", "--output", help="분석 JSON을 저장할 경로")
args = ap.parse_args()
try:
text = open(args.record, encoding="utf-8").read()
except OSError as exc:
print(f"읽지 못했다: {args.record}{exc}", file=sys.stderr)
return 2
result = analyze_commands(args.section_id or args.record, text, mode=args.mode)
rendered = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
if args.output:
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(rendered)
else:
print(rendered, end="")
print(
f"COMMAND PEDAGOGY: {result['result']} — shell blocks {len(result['blocks'])} · "
f"findings {len(result['findings'])}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+54
View File
@@ -28,6 +28,8 @@ from __future__ import annotations
import argparse
import glob
import hashlib
import json
import os
import subprocess
import sys
@@ -55,6 +57,24 @@ def _last_commit_time(rel: str) -> int | None:
return int(out) if out.isdigit() else None
def _sha256(path: str) -> str:
with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
def _manifest(svg: str) -> tuple[str, dict] | None:
name = os.path.basename(svg)[:-4]
path = os.path.join(os.path.dirname(svg), f"{name}.manifest.json")
if not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
return None
return path, data
def verify(project: str) -> techlog.Report:
rep = techlog.Report(project)
base = os.path.join(ROOT, "docs", project, "final")
@@ -70,6 +90,40 @@ def verify(project: str) -> techlog.Report:
paired += 1
spec_rel = os.path.relpath(spec, ROOT)
manifest = _manifest(svg)
if manifest is not None:
manifest_path, data = manifest
expected_spec = data.get("source_spec_file_sha256")
if isinstance(expected_spec, str) and expected_spec:
actual_spec = _sha256(spec)
if actual_spec != expected_spec:
rep.error(
"정본을 거치지 않고 바뀐 spec — manifest 불일치",
(
f"{spec_rel} sha256={actual_spec} · "
f"{os.path.relpath(manifest_path, ROOT)}{expected_spec}"
),
)
continue
output_hashes = data.get("output_sha256")
expected_svg = (
output_hashes.get(os.path.basename(svg))
if isinstance(output_hashes, dict)
else None
)
if isinstance(expected_svg, str) and expected_svg:
actual_svg = _sha256(svg)
if actual_svg != expected_svg:
rep.error(
"정본을 거치지 않고 고친 그림 — 산출물 해시",
(
f"{svg_rel} sha256={actual_svg} · "
f"{os.path.relpath(manifest_path, ROOT)}{expected_svg}"
),
)
continue
if _dirty(svg_rel) and not _dirty(spec_rel):
rep.error("정본을 거치지 않고 고친 그림 — 작업 트리",
f"{svg_rel} 이 고쳐졌는데 {spec_rel} 은 그대로다")
+579
View File
@@ -0,0 +1,579 @@
#!/usr/bin/env python3
"""Shell/CLI 예제에서 교육성 검토가 필요한 신호를 결정론적으로 찾는다.
이 모듈의 finding은 금지 규칙이 아니다. planner/editor/reviewer가 볼 범위를 좁히는
신호다. 기술적으로 정당한 compact command는 문맥에 따라 그대로 남을 수 있다.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
SHELL_FENCE_RE = re.compile(
r"^```[ \t]*(?P<language>bash|sh|shell|zsh|console)"
r"(?P<info>(?:[ \t]+[^\n]*)?)\n"
r"(?P<body>.*?)^```[ \t]*$",
re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
FENCE_LABEL_RE = re.compile(
r"(?:^|\s)label\s*=\s*(?:\"(?P<double>[^\"]*)\"|'(?P<single>[^']*)')",
re.IGNORECASE,
)
LABEL_MODE_RE = re.compile(
r"\[(tutorial|operator|diagnostic|automation|reference)\]",
re.IGNORECASE,
)
LABEL_EXECUTION_CONTEXT_RE = re.compile(
r"(?:\blab\s+host\b|\bkc-lab-[\w-]+\b|\btest-server\b|\bdev\b|"
r"\bworkstation\b|\bhost\b|\bserver\b|\bguest\b|\bcontainer\b|\bpod\b|"
r"워크스테이션|호스트|서버|게스트|컨테이너|파드)",
re.IGNORECASE,
)
RAW_SSH_IP_RE = re.compile(
r"(?m)^\s*(?:ssh|scp)\b[^\n]*?(?P<ip>(?:\d{1,3}\.){3}\d{1,3})(?=[:\s]|$)"
)
REMOTE_COMMAND_RE = re.compile(r"(?m)^\s*(?:ssh|scp)\b")
EXECUTION_CONTEXT_RE = re.compile(
r"(?:실행\s*위치|로컬|원격|호스트|서버|게스트|컨테이너|\bVM\b|"
r"\blocal\b|\bremote\b|\bhost\b|\bserver\b|\bguest\b|"
r"\bcontainer\b|\bworkstation\b)",
re.IGNORECASE,
)
REMOTE_COMPOUND_RE = re.compile(
r"(?m)^\s*ssh\s+[^\n]+?(['\"])(?:(?!\1).)*(?:;|&&|\|\|)(?:(?!\1).)*\1"
)
CLEANUP_CHAIN_RE = re.compile(
r"(?m)^.*(?:schema|verify|check|test|validate)[^\n]*(?:;|&&)\s*rm\b",
re.IGNORECASE,
)
PIPE_OPERATOR_RE = re.compile(r"(?:^|\s)\|(?!\|)\s+", re.MULTILINE)
TEXT_FENCE_RE = re.compile(
r"^```text\s*\n(?P<body>.*?)^```[ \t]*$",
re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
COMMAND_MODE_MARKER_RE = re.compile(
r"<!--\s*command-mode:\s*(tutorial|operator|diagnostic|automation|reference)\s*-->",
re.IGNORECASE,
)
COMMAND_MODE_MARKER_BEFORE_BLOCK_RE = re.compile(
r"<!--\s*command-mode:\s*(tutorial|operator|diagnostic|automation|reference)\s*-->\s*$",
re.IGNORECASE,
)
COMMAND_LIKE_LINE_RE = re.compile(
r"(?i)(?:\./gradlew|\b(?:docker|kubectl|git|npm|pnpm|yarn|npx|python3?|node|"
r"gradle|ssh|scp|curl|helm|virsh|systemctl|journalctl)\b)"
)
COMMAND_MODES = {"tutorial", "operator", "diagnostic", "automation", "reference"}
COMPACT_ALLOWED_MODES = {"automation", "reference"}
def sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _finding(
*, block_id: str, code: str, severity: str, evidence: str, instruction: str
) -> dict[str, Any]:
return {
"block_id": block_id,
"code": code,
"severity": severity,
"evidence": evidence[:240],
"instruction": instruction,
}
def _block_findings(block_id: str, body: str, mode: str) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
compact_allowed = mode in COMPACT_ALLOWED_MODES
if not compact_allowed and re.search(
r"(?m)^\s*sed\s+(?:-[A-Za-z]*i[A-Za-z]*|--in-place(?:=[^\s]+)?)(?:\s|$)",
body,
):
findings.append(
_finding(
block_id=block_id,
code="in-place-text-rewrite",
severity="minor",
evidence="sed -i",
instruction=(
"튜토리얼/운영 흐름에서는 명시적 편집 단계를 우선하고, 자동화 경계라면 "
"in-place rewrite가 필요한 이유를 설명한다."
),
)
)
if not compact_allowed and "$(" in body:
findings.append(
_finding(
block_id=block_id,
code="command-substitution",
severity="minor",
evidence="$(...)",
instruction=(
"값을 먼저 확인하거나 명시적으로 입력할 수 있으면 command substitution을 "
"첫 학습 경로로 두지 않는다."
),
)
)
if not compact_allowed and re.search(r"\$\([^)]*\$\(", body, re.DOTALL):
findings.append(
_finding(
block_id=block_id,
code="nested-command-substitution",
severity="minor",
evidence="$(...$(...)...)",
instruction=(
"중첩된 command substitution은 값을 단계별로 확인하거나 별도 스크립트로 분리한다."
),
)
)
raw_ssh = RAW_SSH_IP_RE.search(body)
if raw_ssh:
findings.append(
_finding(
block_id=block_id,
code="raw-ssh-ip",
severity="minor",
evidence=raw_ssh.group(0).strip(),
instruction=(
"같은 관리 호스트를 반복해서 쓴다면 SSH alias를 먼저 정의하고 주 흐름에서는 "
"alias를 사용한다."
),
)
)
compound = REMOTE_COMPOUND_RE.search(body)
if compound:
findings.append(
_finding(
block_id=block_id,
code="compound-remote-shell",
severity="minor",
evidence=compound.group(0).strip(),
instruction=(
"복합 원격 셸은 검토 신호다. 읽기 전용 확인이나 검증 성공 뒤 변경하는 "
"안전 조건이면 유지할 수 있고, 서로 다른 효과를 숨기면 관찰 가능한 단계로 분리한다."
),
)
)
cleanup = CLEANUP_CHAIN_RE.search(body)
if cleanup:
findings.append(
_finding(
block_id=block_id,
code="cleanup-chained-with-verification",
severity="minor" if mode == "reference" else "major",
evidence=cleanup.group(0).strip(),
instruction=(
"reference block이면 위험한 historical form을 그대로 보존하되 따라 하는 절차와 "
"분리해 설명한다. 실행 절차라면 검증과 파괴적 정리를 별도 명령으로 분리해 "
"삭제 전에 실패를 관찰할 수 있게 한다."
),
)
)
if not compact_allowed and re.search(r"(?m)^\s*printf\b[^\n]*>\s*[^\s]+", body):
findings.append(
_finding(
block_id=block_id,
code="opaque-file-generation",
severity="minor",
evidence="printf ... > file",
instruction=(
"작은 설정/학습 파일은 생성 자동화보다 파일 내용을 먼저 직접 보여준다."
),
)
)
if re.search(r"(?:2>|&>)\s*/dev/null|2>&1", body):
findings.append(
_finding(
block_id=block_id,
code="hidden-stderr",
severity="minor",
evidence="stderr redirection",
instruction=(
"주 진단 경로에서 stderr를 숨기지 말고, 원래 출력을 본 뒤 필요한 경우에만 "
"필터링한다."
),
)
)
if not compact_allowed and len(PIPE_OPERATOR_RE.findall(body)) >= 2:
findings.append(
_finding(
block_id=block_id,
code="compressed-pipeline",
severity="minor",
evidence="multi-stage shell pipeline",
instruction=(
"중간 출력이 학습에 필요하면 파이프라인을 나누거나 각 단계를 설명한 뒤 "
"compact form을 제시한다."
),
)
)
if not compact_allowed:
cd_match = re.search(r"(?m)^\s*cd\s+\S+.*$", body)
if cd_match and re.search(r"(?m)^\s*#\s*\S.*$", body[cd_match.end():]):
findings.append(
_finding(
block_id=block_id,
code="cwd-transition-crosses-command-group",
severity="minor",
evidence=cd_match.group(0).strip(),
instruction=(
"cwd를 바꾼 뒤 다른 명령 그룹이 이어지면 실행 위치가 숨는다. "
"그룹을 나누거나 이후 명령의 cwd를 다시 명시한다."
),
)
)
return findings
def _explicit_block_mode(text: str, start: int) -> str | None:
"""블록과 공백만 사이에 둔 command-mode marker만 해당 블록에 결속한다."""
nearby = text[max(0, start - 320):start]
marker = COMMAND_MODE_MARKER_BEFORE_BLOCK_RE.search(nearby)
return marker.group(1).lower() if marker else None
def _block_mode(text: str, start: int, default_mode: str) -> str:
"""명시 mode가 있으면 쓰고, 없으면 문서 기본 mode를 쓴다."""
return _explicit_block_mode(text, start) or default_mode
def _label_mode(label: str | None) -> str | None:
"""Studio가 허용하는 단일 label 안의 [reference] 같은 mode marker를 읽는다."""
if not label:
return None
match = LABEL_MODE_RE.search(label)
return match.group(1).lower() if match else None
def _fence_label(info: str) -> str | None:
"""Markdown info string의 label 값을 반환한다.
첫 토큰(language) 뒤의 metadata는 Markdown renderer용 정보이므로 command body와
섞지 않는다. 다만 `[lab host]`처럼 실행 위치를 적은 label은 사람이 보는 문맥과
동일하게 execution-context 판정에 사용할 수 있다.
"""
match = FENCE_LABEL_RE.search(info)
if not match:
return None
value = (
match.group("double")
if match.group("double") is not None
else match.group("single")
)
value = value.strip()
return value or None
def _command_like_text_blocks(text: str) -> list[dict[str, Any]]:
"""실행 흐름처럼 보이지만 text fence로 적힌 블록을 낮은 신뢰도의 신호로 남긴다."""
out: list[dict[str, Any]] = []
for ordinal, match in enumerate(TEXT_FENCE_RE.finditer(text), start=1):
body = match.group("body")
explicit_mode = _explicit_block_mode(text, match.start())
if explicit_mode in {"reference", "automation"}:
continue
command_hits = COMMAND_LIKE_LINE_RE.findall(body)
arrow_hits = len(re.findall(r"(?:→|->)", body))
if len(command_hits) < 2 and not (command_hits and arrow_hits >= 2):
continue
source = match.group(0)
out.append(
{
"id": f"text-command-like-{ordinal:03d}",
"start": match.start(),
"end": match.end(),
"source_sha256": sha256_text(source),
"evidence": body[:240],
"instruction": (
"개념 흐름도라면 text fence로 둬도 된다. 복사해 실행할 절차라면 shell fence와 "
"실행 위치·전제조건·검증 결과를 명시한다."
),
}
)
return out
def analyze_commands(
section_id: str, text: str, *, mode: str = "operator"
) -> dict[str, Any]:
"""Markdown의 shell fence를 찾아 command-pedagogy 신호를 반환한다.
문서 기본 mode와 별개로 블록 직전의 command-mode marker가 있으면 그 블록만
해당 mode로 분석한다.
"""
if mode not in COMMAND_MODES:
raise ValueError(f"unsupported command pedagogy mode: {mode}")
blocks: list[dict[str, Any]] = []
findings: list[dict[str, Any]] = []
raw_hosts: dict[str, list[str]] = {}
for ordinal, match in enumerate(SHELL_FENCE_RE.finditer(text), start=1):
source = match.group(0)
body = match.group("body")
info = match.group("info") or ""
label = _fence_label(info)
block_id = f"command-{ordinal:03d}"
explicit_mode = _label_mode(label) or _explicit_block_mode(text, match.start())
block_mode = explicit_mode or mode
blocks.append(
{
"id": block_id,
"ordinal": ordinal,
"language": match.group("language").lower(),
"info_string": info.strip(),
"label": label,
"mode": block_mode,
"mode_explicit": explicit_mode is not None,
"start": match.start(),
"end": match.end(),
"source_sha256": sha256_text(source),
"body_sha256": sha256_text(body),
"source": source,
"body": body,
}
)
findings.extend(_block_findings(block_id, body, block_mode))
if REMOTE_COMMAND_RE.search(body):
prose_before = text[max(0, match.start() - 320) : match.start()]
label_has_context = bool(
label and LABEL_EXECUTION_CONTEXT_RE.search(label)
)
if not EXECUTION_CONTEXT_RE.search(prose_before) and not label_has_context:
findings.append(
_finding(
block_id=block_id,
code="execution-context-implicit",
severity="minor",
evidence=REMOTE_COMMAND_RE.search(body).group(0).strip(),
instruction=(
"원격 명령 앞에서 이 명령을 어느 host/session에서 실행하는지 명시한다."
),
)
)
for remote in RAW_SSH_IP_RE.finditer(body):
raw_hosts.setdefault(remote.group("ip"), []).append(block_id)
for host, block_ids in sorted(raw_hosts.items()):
if len(block_ids) < 2:
continue
findings.append(
_finding(
block_id=block_ids[0],
code="repeated-raw-ssh-host",
severity="minor",
evidence=f"{host} repeated {len(block_ids)} times",
instruction=(
"같은 관리 호스트를 반복하면 SSH alias를 사전 설정으로 둘 수 있는지 검토한다."
),
)
)
return {
"schema_version": "1.0",
"authority": "deterministic",
"gate": "command-pedagogy-signals",
"section_id": section_id,
"mode": mode,
"source_sha256": sha256_text(text),
"result": "WARN" if findings else "PASS",
"requires_editor": bool(findings),
"blocks": blocks,
"findings": findings,
"extensions": {
"command_like_text_blocks": _command_like_text_blocks(text),
},
}
def _require_non_empty_string(value: Any, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must be a non-empty string")
return value
def _require_sha256(value: Any, field: str) -> str:
value = _require_non_empty_string(value, field)
if not re.fullmatch(r"[0-9a-f]{64}", value):
raise ValueError(f"{field} must be a lowercase sha256")
return value
def validate_command_plan(
plan: dict[str, Any], *, analysis: dict[str, Any] | None = None
) -> None:
"""CommandPlan의 구조와 frozen analysis 결속을 검증한다."""
if not isinstance(plan, dict):
raise ValueError("CommandPlan must be an object")
if plan.get("schema_version") != "1.0":
raise ValueError("CommandPlan schema_version must be 1.0")
_require_non_empty_string(plan.get("section_id"), "CommandPlan section_id")
source_sha = _require_sha256(plan.get("source_sha256"), "CommandPlan source_sha256")
mode = plan.get("mode")
if mode not in COMMAND_MODES:
raise ValueError(f"CommandPlan mode is unsupported: {mode!r}")
groups = plan.get("command_groups")
if not isinstance(groups, list) or not groups:
raise ValueError("CommandPlan command_groups must be a non-empty list")
known_blocks: dict[str, dict[str, Any]] = {}
if analysis is not None:
if analysis.get("source_sha256") != source_sha:
raise ValueError("CommandPlan source_sha256 does not match analysis")
known_blocks = {
str(block.get("id")): block
for block in analysis.get("blocks", [])
if isinstance(block, dict)
}
seen_ids: set[str] = set()
seen_blocks: set[str] = set()
for group in groups:
if not isinstance(group, dict):
raise ValueError("CommandPlan command group must be an object")
group_id = _require_non_empty_string(group.get("id"), "CommandPlan command group id")
if group_id in seen_ids:
raise ValueError("CommandPlan contains duplicate command group id")
seen_ids.add(group_id)
block_id = _require_non_empty_string(group.get("block_id"), "CommandPlan block_id")
if block_id in seen_blocks:
raise ValueError("CommandPlan contains duplicate block_id")
seen_blocks.add(block_id)
block_sha = _require_sha256(group.get("source_sha256"), "CommandPlan group source_sha256")
if known_blocks:
block = known_blocks.get(block_id)
if block is None:
raise ValueError("CommandPlan references an unknown block")
if block.get("source_sha256") != block_sha:
raise ValueError("CommandPlan block source_sha256 does not match analysis")
_require_non_empty_string(group.get("goal"), "CommandPlan goal")
context = group.get("execution_context")
if not isinstance(context, dict):
raise ValueError("CommandPlan execution_context must be an object")
_require_non_empty_string(context.get("host"), "CommandPlan execution_context.host")
_require_non_empty_string(context.get("cwd"), "CommandPlan execution_context.cwd")
prerequisites = group.get("prerequisites")
if not isinstance(prerequisites, list) or not all(
isinstance(item, str) and item.strip() for item in prerequisites
):
raise ValueError("CommandPlan prerequisites must be a string list")
steps = group.get("steps")
if not isinstance(steps, list) or not steps:
raise ValueError("CommandPlan steps must be a non-empty list")
for step in steps:
if not isinstance(step, dict):
raise ValueError("CommandPlan step must be an object")
_require_non_empty_string(step.get("command"), "CommandPlan step.command")
_require_non_empty_string(step.get("reason"), "CommandPlan step.reason")
_require_non_empty_string(
step.get("expected_result"), "CommandPlan step.expected_result"
)
cleanup = group.get("cleanup")
if not isinstance(cleanup, list):
raise ValueError("CommandPlan cleanup must be a list")
for step in cleanup:
if not isinstance(step, dict):
raise ValueError("CommandPlan cleanup step must be an object")
_require_non_empty_string(step.get("command"), "CommandPlan cleanup.command")
_require_non_empty_string(step.get("reason"), "CommandPlan cleanup.reason")
def validate_command_patch_set(
patch_set: dict[str, Any], *, analysis: dict[str, Any] | None = None
) -> None:
"""CommandPatchSet의 구조와 frozen analysis 결속을 검증한다."""
if not isinstance(patch_set, dict):
raise ValueError("CommandPatchSet must be an object")
if patch_set.get("schema_version") != "1.0":
raise ValueError("CommandPatchSet schema_version must be 1.0")
source_sha = _require_sha256(
patch_set.get("source_sha256"), "CommandPatchSet source_sha256"
)
patches = patch_set.get("patches")
if not isinstance(patches, list):
raise ValueError("CommandPatchSet patches must be a list")
known_blocks: dict[str, dict[str, Any]] = {}
if analysis is not None:
if analysis.get("source_sha256") != source_sha:
raise ValueError("CommandPatchSet source_sha256 does not match analysis")
known_blocks = {
str(block.get("id")): block
for block in analysis.get("blocks", [])
if isinstance(block, dict)
}
seen: set[str] = set()
for patch in patches:
if not isinstance(patch, dict):
raise ValueError("CommandPatchSet patch must be an object")
block_id = _require_non_empty_string(patch.get("block_id"), "CommandPatchSet block_id")
if block_id in seen:
raise ValueError("CommandPatchSet contains duplicate block ids")
seen.add(block_id)
expected = _require_sha256(
patch.get("expected_sha256"), "CommandPatchSet expected_sha256"
)
if known_blocks:
block = known_blocks.get(block_id)
if block is None:
raise ValueError("CommandPatchSet references an unknown block")
if block.get("source_sha256") != expected:
raise ValueError("CommandPatchSet expected block hash is stale")
_require_non_empty_string(
patch.get("replacement_markdown"), "CommandPatchSet replacement_markdown"
)
def apply_command_patch_set(
*, text: str, analysis: dict[str, Any], patch_set: dict[str, Any]
) -> str:
"""분석 당시의 shell fence span 안에서만 replacement를 적용한다.
editor가 Markdown 전체를 직접 다시 쓰지 않게 하는 경계다. patch는 분석에 존재하는
block_id와 그때의 source hash를 함께 제시해야 한다.
"""
validate_command_patch_set(patch_set, analysis=analysis)
source_sha256 = sha256_text(text)
if analysis.get("source_sha256") != source_sha256:
raise ValueError("command analysis source hash is stale")
if patch_set.get("source_sha256") != source_sha256:
raise ValueError("CommandPatchSet source hash is stale")
blocks = {item["id"]: item for item in analysis.get("blocks", [])}
patches = patch_set.get("patches")
if not isinstance(patches, list):
raise ValueError("CommandPatchSet patches must be a list")
seen: set[str] = set()
replacements: list[tuple[int, int, str]] = []
for patch in patches:
if not isinstance(patch, dict):
raise ValueError("CommandPatchSet patch must be an object")
block_id = str(patch.get("block_id") or "")
if block_id in seen:
raise ValueError("CommandPatchSet contains duplicate block ids")
seen.add(block_id)
block = blocks.get(block_id)
if block is None:
raise ValueError("CommandPatchSet references an unknown block")
expected = str(patch.get("expected_sha256") or "")
if expected != block["source_sha256"]:
raise ValueError("CommandPatchSet expected block hash is stale")
source = text[block["start"] : block["end"]]
if sha256_text(source) != block["source_sha256"]:
raise ValueError("command block span is stale")
replacement = patch.get("replacement_markdown")
if not isinstance(replacement, str) or not replacement:
raise ValueError("CommandPatchSet replacement_markdown must be non-empty")
replacements.append((block["start"], block["end"], replacement))
repaired = text
for start, end, replacement in sorted(replacements, reverse=True):
repaired = repaired[:start] + replacement + repaired[end:]
return repaired
+30 -4
View File
@@ -229,15 +229,34 @@ def cmd_begin(args) -> int:
def cmd_gate(args) -> int:
status = args.status or ("PASS" if args.exit_code == 0 else "FAIL")
if status == "PASS" and args.exit_code != 0:
raise Contract("PASS 관문은 exit 0 이어야 한다")
if status == "FAIL" and args.exit_code == 0:
raise Contract("FAIL 관문은 exit 0 으로 적을 수 없다")
if status == "UNVERIFIABLE":
if args.exit_code == 0:
raise Contract("UNVERIFIABLE 관문은 실제 대조 실패의 non-zero exit를 보존해야 한다")
if not (args.reason or "").strip():
raise Contract("UNVERIFIABLE 관문은 --reason 으로 대조하지 못한 이유를 적는다")
with Ledger(args.ledger) as led:
st = led.stage(args.stage)
_require_owner(st, args.session, args.generation)
st.setdefault("gates", []).append({
gate = {
"cmd": args.cmd, "exit": args.exit_code,
"status": status,
"session": args.session or None, "generation": int(st.get("generation", 0)),
"at": _now()})
"at": _now(),
}
if args.semantic_id:
gate["semanticId"] = args.semantic_id
if args.reason:
gate["reason"] = args.reason
if args.accepted_by_project_review:
gate["acceptedByProjectReview"] = True
st.setdefault("gates", []).append(gate)
led.save()
print(f"{args.stage} 관문 {len(st['gates'])}개 · 방금 것 exit={args.exit_code}"
print(f"{args.stage} 관문 {len(st['gates'])}개 · 방금 것 {status} exit={args.exit_code}"
f" · {args.session or '세션 미기재'}")
return 0
@@ -299,9 +318,12 @@ def cmd_status(args) -> int:
mark = {"DONE": "", "SKIPPED": "", "RUNNING": "", "FAILED": ""}.get(
st.get("status"), " ")
gates = st.get("gates") or []
bad = [g for g in gates if g.get("exit") not in (0, "0")]
unverifiable = [g for g in gates if g.get("status") == "UNVERIFIABLE"]
bad = [g for g in gates
if g.get("status") != "UNVERIFIABLE" and g.get("exit") not in (0, "0")]
print(f" {mark} {st['id']} {st.get('status'):<8} "
f"{st.get('elapsedSeconds', 0):>5}초 · 관문 {len(gates)}"
+ (f" (대조 불가 {len(unverifiable)})" if unverifiable else "")
+ (f" (exit≠0 {len(bad)})" if bad else ""))
riders = d.get("riders") or []
if riders:
@@ -339,6 +361,10 @@ def main() -> int:
p = sub.add_parser("gate"); p.add_argument("ledger")
p.add_argument("--stage", required=True); p.add_argument("--cmd", required=True)
p.add_argument("--exit", dest="exit_code", type=int, required=True)
p.add_argument("--semantic-id")
p.add_argument("--status", choices=["PASS", "UNVERIFIABLE", "FAIL"])
p.add_argument("--reason")
p.add_argument("--accepted-by-project-review", action="store_true")
p.add_argument("--session", default=""); p.add_argument("--generation", type=int)
p.set_defaults(fn=cmd_gate)
+2
View File
@@ -21,6 +21,8 @@ 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/command_pedagogy.py", "scripts/check-command-pedagogy.py",
"scripts/apply-command-pedagogy-patch.py", "scripts/validate-command-pedagogy-artifact.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",
+1 -1
View File
@@ -84,7 +84,7 @@ DIR_OF_KIND = {v: k for k, v in KIND_OF_DIR.items()}
BODY_KINDS = {"case", "concept", "setup"}
BODY_KIND_CODES = {KIND_OF_DIR[k] for k in BODY_KINDS}
READINESS = ["READY", "OPEN", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
READINESS = ["READY", "OPEN", "RESOLVED", "NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
DISPOSITIONS = ["PROMOTE", "MERGE_INTO", "KEEP_IN_SSOT",
"NEEDS_EVIDENCE", "NEEDS_DECISION", "BLOCKED"]
+61
View File
@@ -9,4 +9,65 @@ if [ ! -d "$TECHVIZ_HOME/src/techviz" ]; then
exit 1
fi
export TECHVIZ_HOME
if [ "${1:-}" = "render" ]; then
PYTHONPATH="$TECHVIZ_HOME/src${PYTHONPATH:+:$PYTHONPATH}" python3 -m techviz "$@"
spec="${2:-}"
out=""
args=("$@")
for ((i = 0; i < ${#args[@]}; i++)); do
case "${args[$i]}" in
-o|--output|--out-dir)
if (( i + 1 < ${#args[@]} )); then
out="${args[$((i + 1))]}"
fi
;;
esac
done
if [ -n "$spec" ] && [ -n "$out" ]; then
python3 - "$spec" "$out" <<'PY'
from __future__ import annotations
import hashlib
import json
import pathlib
import sys
spec_path = pathlib.Path(sys.argv[1])
out_dir = pathlib.Path(sys.argv[2])
if not spec_path.is_file() or not out_dir.is_dir():
raise SystemExit(0)
spec = json.loads(spec_path.read_text(encoding="utf-8"))
spec_id = spec.get("id")
if not isinstance(spec_id, str) or not spec_id:
raise SystemExit(0)
manifest_path = out_dir / f"{spec_id}.manifest.json"
if not manifest_path.is_file():
raise SystemExit(0)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
hashes: dict[str, str] = {}
for name in manifest.get("outputs") or []:
if not isinstance(name, str):
continue
path = out_dir / name
if not path.is_file():
continue
hashes[name] = hashlib.sha256(path.read_bytes()).hexdigest()
manifest["source_spec_file_sha256"] = hashlib.sha256(spec_path.read_bytes()).hexdigest()
manifest["output_sha256"] = hashes
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
PY
fi
exit 0
fi
PYTHONPATH="$TECHVIZ_HOME/src${PYTHONPATH:+:$PYTHONPATH}" exec python3 -m techviz "$@"
+565
View File
@@ -0,0 +1,565 @@
"""명령어 교육성 분석기 회귀 시험.
정확한 명령을 금지하는 검사가 아니라, 사람이 따라 하기 어려운 압축 표현을
후속 planner/editor/reviewer가 볼 수 있게 결정론적으로 표시하는지 본다.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(ROOT, "scripts"))
from command_pedagogy import ( # noqa: E402
analyze_commands,
apply_command_patch_set,
validate_command_patch_set,
validate_command_plan,
)
class CommandPedagogyAnalyzerTest(unittest.TestCase):
def codes(self, text: str) -> set[str]:
result = analyze_commands("record", text)
return {finding["code"] for finding in result["findings"]}
def test_sed_in_place_and_command_substitution_are_signals(self):
text = '''```bash
sed -i \\
-e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \\
lab.yaml
```
'''
self.assertEqual(
{"in-place-text-rewrite", "command-substitution"},
self.codes(text),
)
def test_sed_print_mode_is_not_in_place_rewrite(self):
text = r'''```bash
sed -n '/^COPY public.databasechangelog /,/^\\\.$/p' dump.sql | wc -l
```
'''
self.assertNotIn("in-place-text-rewrite", self.codes(text))
def test_remote_compound_flow_exposes_ip_and_cleanup_chain(self):
text = '''```bash
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml && rm ~/kc-lab-2.yaml'
```
'''
self.assertEqual(
{
"raw-ssh-ip",
"compound-remote-shell",
"cleanup-chained-with-verification",
"execution-context-implicit",
},
self.codes(text),
)
result = analyze_commands("record", text)
severity = {finding["code"]: finding["severity"] for finding in result["findings"]}
self.assertEqual("minor", severity["compound-remote-shell"])
self.assertEqual("major", severity["cleanup-chained-with-verification"])
def test_reference_mode_keeps_historical_cleanup_as_review_signal(self):
text = '''<!-- command-mode: reference -->
```bash
ssh host 'cloud-init schema -c ~/lab.yaml; rm -f ~/lab.yaml'
```
'''
result = analyze_commands("record", text, mode="operator")
severity = {
finding["code"]: finding["severity"]
for finding in result["findings"]
}
self.assertEqual(
"minor",
severity["cleanup-chained-with-verification"],
)
self.assertEqual("reference", result["blocks"][0]["mode"])
def test_label_mode_marker_can_mark_reference_without_html_marker(self):
text = '''```bash label="[lab host] [reference] historical"
ssh host 'cloud-init schema -c ~/lab.yaml; rm -f ~/lab.yaml'
```
'''
result = analyze_commands("record", text, mode="operator")
self.assertEqual("reference", result["blocks"][0]["mode"])
self.assertTrue(result["blocks"][0]["mode_explicit"])
severity = {
finding["code"]: finding["severity"]
for finding in result["findings"]
}
self.assertEqual(
"minor",
severity["cleanup-chained-with-verification"],
)
def test_printf_generated_file_and_substitution_are_signals(self):
text = '''```bash
printf 'instance-id: kc-lab-1-%s\\nlocal-hostname: kc-lab-1\\n' "$(date +%s)" > meta-kc-lab-1
```
'''
self.assertEqual(
{"opaque-file-generation", "command-substitution"},
self.codes(text),
)
def test_hidden_stderr_and_compressed_pipeline_are_signals(self):
text = '''```bash
openssl s_client -connect example.com:443 2>/dev/null | grep subject | head -1
```
'''
self.assertEqual(
{"hidden-stderr", "compressed-pipeline"},
self.codes(text),
)
def test_simple_operator_commands_are_not_globally_banned(self):
text = '''```bash
kubectl get pods
kubectl describe pod api-0
```
'''
result = analyze_commands("record", text)
self.assertEqual("PASS", result["result"])
self.assertFalse(result["requires_editor"])
self.assertEqual(1, len(result["blocks"]))
self.assertEqual([], result["findings"])
def test_non_shell_fences_are_out_of_scope(self):
result = analyze_commands("record", '''```yaml
apiVersion: v1
kind: Pod
```
''')
self.assertEqual([], result["blocks"])
self.assertEqual([], result["findings"])
def test_block_metadata_is_stable_and_bounded(self):
text = '''앞 문장
```sh
echo hello
```
뒤 문장
'''
result = analyze_commands("record", text)
block = result["blocks"][0]
self.assertEqual("command-001", block["id"])
self.assertEqual("sh", block["language"])
self.assertEqual(text[block["start"]:block["end"]], block["source"])
self.assertEqual(64, len(block["source_sha256"]))
self.assertEqual(64, len(result["source_sha256"]))
def test_labeled_shell_fence_uses_first_info_token_as_language(self):
text = '''```bash label="[lab host] 파드 상태를 본다"
kubectl get pods
```
'''
result = analyze_commands("record", text)
self.assertEqual(1, len(result["blocks"]))
block = result["blocks"][0]
self.assertEqual("bash", block["language"])
self.assertEqual('label="[lab host] 파드 상태를 본다"', block["info_string"])
self.assertEqual("[lab host] 파드 상태를 본다", block["label"])
def test_labeled_sh_fence_is_analyzed(self):
text = '''```sh label="[탐침 파드] 연결을 확인한다"
curl -fsS http://keycloak:8080/health/ready
```
'''
result = analyze_commands("record", text)
self.assertEqual(1, len(result["blocks"]))
self.assertEqual("sh", result["blocks"][0]["language"])
def test_language_must_be_the_complete_first_info_token(self):
result = analyze_commands("record", '''```bashish label="[lab host]"
echo nope
```
''')
self.assertEqual([], result["blocks"])
def test_non_context_label_does_not_hide_remote_context_signal(self):
text = '''```bash label="[결과 확인] 원격 상태를 본다"
ssh test-server
```
'''
result = analyze_commands("record", text)
codes = {finding["code"] for finding in result["findings"]}
self.assertIn("execution-context-implicit", codes)
def test_execution_context_in_label_suppresses_context_signal(self):
text = '''```bash label="[워크스테이션] test-server에 접속한다"
ssh test-server
```
'''
result = analyze_commands("record", text)
codes = {finding["code"] for finding in result["findings"]}
self.assertNotIn("execution-context-implicit", codes)
def test_inline_command_mode_overrides_document_default(self):
text = '''일반 참고 문서다.
<!-- command-mode: operator -->
```bash
echo "$(date +%s)"
```
'''
result = analyze_commands("record", text, mode="reference")
self.assertEqual("operator", result["blocks"][0]["mode"])
self.assertIn(
"command-substitution",
{finding["code"] for finding in result["findings"]},
)
def test_inline_command_mode_applies_only_to_the_next_block(self):
text = '''<!-- command-mode: operator -->
```bash
echo "$(date +%s)"
```
```bash
echo "$(date +%s)"
```
'''
result = analyze_commands("record", text, mode="reference")
self.assertTrue(result["blocks"][0]["mode_explicit"])
self.assertEqual("operator", result["blocks"][0]["mode"])
self.assertFalse(result["blocks"][1]["mode_explicit"])
self.assertEqual("reference", result["blocks"][1]["mode"])
substitution_blocks = {
finding["block_id"]
for finding in result["findings"]
if finding["code"] == "command-substitution"
}
self.assertEqual({"command-001"}, substitution_blocks)
def test_text_fence_with_cli_flow_is_reported_as_extension(self):
text = '''```text
docker build . → docker save app | gzip → scp app.tar.gz test-server:/tmp/
→ kubectl set image deployment/app app=local/app:test
```
'''
result = analyze_commands("record", text, mode="reference")
self.assertEqual([], result["blocks"])
self.assertEqual([], result["findings"])
self.assertEqual(
1,
len(result["extensions"]["command_like_text_blocks"]),
)
def test_explicit_reference_text_flow_is_not_reported_as_misfenced_command(self):
text = '''<!-- command-mode: reference -->
```text
docker build . → docker save app | gzip → scp app.tar.gz test-server:/tmp/
→ kubectl set image deployment/app app=local/app:test
```
'''
result = analyze_commands("record", text, mode="reference")
self.assertEqual([], result["extensions"]["command_like_text_blocks"])
def test_cwd_change_followed_by_new_command_group_is_a_signal(self):
text = '''```bash
cd src
./gradlew build
# 다른 도구
python3 scripts/check.py
```
'''
result = analyze_commands("record", text, mode="operator")
self.assertIn(
"cwd-transition-crosses-command-group",
{finding["code"] for finding in result["findings"]},
)
class CommandPatchBoundaryTest(unittest.TestCase):
def test_patch_can_replace_only_the_named_command_span(self):
text = '''앞 문장은 그대로다.\n\n```bash\nprintf 'x=%s\\n' "$(date +%s)" > x.conf\n```\n\n뒤 문장도 그대로다.\n'''
analysis = analyze_commands("record", text)
block = analysis["blocks"][0]
replacement = '''```bash\nnano x.conf\n```\n```text\nx=20260917\n```'''
repaired = apply_command_patch_set(
text=text,
analysis=analysis,
patch_set={
"schema_version": "1.0",
"source_sha256": analysis["source_sha256"],
"patches": [{
"block_id": block["id"],
"expected_sha256": block["source_sha256"],
"replacement_markdown": replacement,
}],
},
)
self.assertTrue(repaired.startswith("앞 문장은 그대로다.\n\n"))
self.assertTrue(repaired.endswith("\n\n뒤 문장도 그대로다.\n"))
self.assertIn(replacement, repaired)
self.assertNotIn("printf", repaired)
def test_stale_block_hash_is_rejected(self):
text = '''```bash\nsed -i 's/a/b/' x.conf\n```\n'''
analysis = analyze_commands("record", text)
with self.assertRaisesRegex(ValueError, "expected block hash"):
apply_command_patch_set(
text=text,
analysis=analysis,
patch_set={
"schema_version": "1.0",
"source_sha256": analysis["source_sha256"],
"patches": [{
"block_id": "command-001",
"expected_sha256": "0" * 64,
"replacement_markdown": "```bash\\nnano x.conf\\n```",
}],
},
)
def test_patch_cannot_reference_an_unknown_block(self):
text = '''```bash\necho hello\n```\n'''
analysis = analyze_commands("record", text)
with self.assertRaisesRegex(ValueError, "unknown block"):
apply_command_patch_set(
text=text,
analysis=analysis,
patch_set={
"schema_version": "1.0",
"source_sha256": analysis["source_sha256"],
"patches": [{
"block_id": "command-999",
"expected_sha256": "0" * 64,
"replacement_markdown": "```bash\\necho world\\n```",
}],
},
)
class CommandPatchCliTest(unittest.TestCase):
def test_cli_applies_a_frozen_patch_set(self):
script = os.path.join(ROOT, "scripts", "apply-command-pedagogy-patch.py")
text = "\n\n```bash\nprintf 'x\\n' > x.conf\n```\n\n\n"
analysis = analyze_commands("record", text)
block = analysis["blocks"][0]
patch_set = {
"schema_version": "1.0",
"source_sha256": analysis["source_sha256"],
"patches": [{
"block_id": block["id"],
"expected_sha256": block["source_sha256"],
"replacement_markdown": "```bash\nnano x.conf\n```",
}],
}
with tempfile.TemporaryDirectory() as d:
record = os.path.join(d, "record.md")
analysis_path = os.path.join(d, "analysis.json")
patch_path = os.path.join(d, "patch.json")
output = os.path.join(d, "repaired.md")
with open(record, "w", encoding="utf-8") as fh:
fh.write(text)
with open(analysis_path, "w", encoding="utf-8") as fh:
json.dump(analysis, fh, ensure_ascii=False)
with open(patch_path, "w", encoding="utf-8") as fh:
json.dump(patch_set, fh, ensure_ascii=False)
p = subprocess.run(
[sys.executable, script, record, analysis_path, patch_path, "-o", output],
cwd=ROOT,
capture_output=True,
text=True,
)
self.assertEqual(0, p.returncode, p.stdout + p.stderr)
with open(output, encoding="utf-8") as fh:
repaired = fh.read()
self.assertEqual("\n\n```bash\nnano x.conf\n```\n\n\n", repaired)
class CommandPedagogyModeTest(unittest.TestCase):
def test_zsh_fence_is_analyzed(self):
result = analyze_commands("record", "```zsh\nssh user@192.168.1.10\n```\n")
self.assertEqual(1, len(result["blocks"]))
self.assertEqual("zsh", result["blocks"][0]["language"])
def test_nested_command_substitution_is_a_distinct_signal(self):
result = analyze_commands(
"record",
"```bash\necho \"$(printf %s \"$(date +%s)\")\"\n```\n",
)
codes = {finding["code"] for finding in result["findings"]}
self.assertIn("nested-command-substitution", codes)
def test_automation_mode_does_not_blanket_flag_compact_shell(self):
text = '''자동화 스크립트는 입력 파일을 갱신한 뒤 결과를 별도 검증한다.\n\n```bash\nsed -i -e "s|__KEY__|$(cat key.pub)|" config.yaml\ncat config.yaml | grep KEY | head -1\n```\n'''
operator = analyze_commands("record", text, mode="operator")
automation = analyze_commands("record", text, mode="automation")
self.assertTrue(operator["findings"])
self.assertEqual("automation", automation["mode"])
self.assertEqual([], automation["findings"])
def test_unknown_mode_is_rejected(self):
with self.assertRaisesRegex(ValueError, "unsupported command pedagogy mode"):
analyze_commands("record", "```bash\necho ok\n```\n", mode="unknown")
def test_remote_command_without_execution_context_is_a_signal(self):
result = analyze_commands("record", "```bash\nssh test-server\n```\n")
codes = {finding["code"] for finding in result["findings"]}
self.assertIn("execution-context-implicit", codes)
def test_explicit_execution_context_suppresses_context_signal(self):
text = "이 명령은 로컬 워크스테이션에서 실행한다.\n\n```bash\nssh test-server\n```\n"
result = analyze_commands("record", text)
codes = {finding["code"] for finding in result["findings"]}
self.assertNotIn("execution-context-implicit", codes)
def test_repeated_raw_ssh_ip_is_reported(self):
text = '''```bash\nssh user@192.168.122.11\n```\n\n```bash\nscp x user@192.168.122.11:~/x\n```\n'''
result = analyze_commands("record", text)
codes = [finding["code"] for finding in result["findings"]]
self.assertIn("repeated-raw-ssh-host", codes)
class CommandArtifactContractTest(unittest.TestCase):
def setUp(self):
self.text = "```bash\nssh user@192.168.1.10\n```\n"
self.analysis = analyze_commands("section-a", self.text)
self.block = self.analysis["blocks"][0]
def valid_plan(self):
return {
"schema_version": "1.0",
"section_id": "section-a",
"source_sha256": self.analysis["source_sha256"],
"mode": "operator",
"command_groups": [
{
"id": "connect-host",
"block_id": self.block["id"],
"source_sha256": self.block["source_sha256"],
"goal": "대상 호스트 연결을 확인한다.",
"execution_context": {"host": "local", "cwd": "."},
"prerequisites": ["SSH key가 준비되어 있다."],
"steps": [
{
"command": "ssh test-server",
"reason": "별칭으로 연결한다.",
"expected_result": "원격 셸이 열린다.",
}
],
"cleanup": [],
}
],
}
def test_valid_command_plan_passes(self):
validate_command_plan(self.valid_plan(), analysis=self.analysis)
def test_command_plan_requires_supported_mode(self):
plan = self.valid_plan()
plan["mode"] = "clever"
with self.assertRaisesRegex(ValueError, "mode"):
validate_command_plan(plan, analysis=self.analysis)
def test_command_plan_rejects_duplicate_group_ids(self):
plan = self.valid_plan()
plan["command_groups"].append(dict(plan["command_groups"][0]))
with self.assertRaisesRegex(ValueError, "duplicate command group id"):
validate_command_plan(plan, analysis=self.analysis)
def test_command_plan_requires_execution_context(self):
plan = self.valid_plan()
del plan["command_groups"][0]["execution_context"]
with self.assertRaisesRegex(ValueError, "execution_context"):
validate_command_plan(plan, analysis=self.analysis)
def test_patch_set_requires_schema_version_and_known_hashes(self):
patch = {
"schema_version": "1.0",
"source_sha256": self.analysis["source_sha256"],
"patches": [
{
"block_id": self.block["id"],
"expected_sha256": self.block["source_sha256"],
"replacement_markdown": "```bash\nssh test-server\n```",
}
],
}
validate_command_patch_set(patch, analysis=self.analysis)
bad = dict(patch)
bad.pop("schema_version")
with self.assertRaisesRegex(ValueError, "schema_version"):
validate_command_patch_set(bad, analysis=self.analysis)
class CommandArtifactCliContractTest(unittest.TestCase):
def test_analysis_cli_accepts_document_mode(self):
script = os.path.join(ROOT, "scripts", "check-command-pedagogy.py")
with tempfile.TemporaryDirectory() as d:
record = os.path.join(d, "record.md")
output = os.path.join(d, "analysis.json")
with open(record, "w", encoding="utf-8") as fh:
fh.write('```bash\nsed -i "s/a/$(cat value)/" config\n```\n')
p = subprocess.run(
[sys.executable, script, record, "--mode", "automation", "-o", output],
cwd=ROOT,
capture_output=True,
text=True,
)
self.assertEqual(0, p.returncode, p.stdout + p.stderr)
with open(output, encoding="utf-8") as fh:
result = json.load(fh)
self.assertEqual("automation", result["mode"])
self.assertEqual([], result["findings"])
def test_artifact_validator_cli_checks_plan_against_analysis(self):
validator = os.path.join(ROOT, "scripts", "validate-command-pedagogy-artifact.py")
text = "```bash\nssh user@192.168.1.10\n```\n"
analysis = analyze_commands("section-a", text)
block = analysis["blocks"][0]
plan = {
"schema_version": "1.0",
"section_id": "section-a",
"source_sha256": analysis["source_sha256"],
"mode": "operator",
"command_groups": [
{
"id": "connect",
"block_id": block["id"],
"source_sha256": block["source_sha256"],
"goal": "연결한다.",
"execution_context": {"host": "local", "cwd": "."},
"prerequisites": [],
"steps": [{
"command": "ssh test-server",
"reason": "연결 확인",
"expected_result": "원격 셸",
}],
"cleanup": [],
}
],
}
with tempfile.TemporaryDirectory() as d:
analysis_path = os.path.join(d, "analysis.json")
plan_path = os.path.join(d, "plan.json")
with open(analysis_path, "w", encoding="utf-8") as fh:
json.dump(analysis, fh)
with open(plan_path, "w", encoding="utf-8") as fh:
json.dump(plan, fh)
p = subprocess.run(
[sys.executable, validator, "plan", plan_path, "--analysis", analysis_path],
cwd=ROOT,
capture_output=True,
text=True,
)
self.assertEqual(0, p.returncode, p.stdout + p.stderr)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,143 @@
"""command-pedagogy가 실제 파이프라인 문서와 프롬프트에 연결되어 있는지 본다."""
from __future__ import annotations
import json
import os
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PIPELINE = os.path.join(ROOT, ".agents", "skills", "running-tech-log-pipeline")
def read(*parts: str) -> str:
with open(os.path.join(PIPELINE, *parts), encoding="utf-8") as fh:
return fh.read()
class CommandPedagogyPipelineContractTest(unittest.TestCase):
def test_pipeline_skill_routes_command_work_to_separate_agents(self):
text = read("SKILL.md")
for token in (
"check-command-pedagogy.py",
"command-pedagogy-planner",
"command-pedagogy-editor",
"command-pedagogy-reviewer",
"qualityReviews.commandPedagogy",
"qualityReviews.technicalEvidence",
):
self.assertIn(token, text)
self.assertIn("shell/CLI block이 없으면", text)
self.assertIn("finding이 없으면 planner/editor", text)
def test_stage_contract_freezes_bounded_command_repair(self):
text = read("references", "stage-contracts.md")
for token in (
"initialAnalysis",
"finalAnalysis",
"CommandPatchSet",
"apply-command-pedagogy-patch.py",
"majorFindings",
"command-pedagogy-reviewer",
"fact-reviewer",
):
self.assertIn(token, text)
self.assertIn("command block 밖의 산문", text)
def test_subagent_prompts_include_the_command_lane_and_final_reviews(self):
text = read("references", "subagent-prompts.md")
for call in (
'Agent(subagent_type="command-pedagogy-planner"',
'Agent(subagent_type="command-pedagogy-editor"',
'Agent(subagent_type="command-pedagogy-reviewer"',
'Agent(subagent_type="fact-reviewer"',
):
self.assertIn(call, text)
self.assertIn("CommandPatchSet", text)
self.assertIn("initial command analysis", text)
self.assertIn("final command analysis", text)
self.assertIn("validate-command-pedagogy-artifact.py", text)
self.assertIn("sha256", text.lower())
class FirstClassCommandContractTest(unittest.TestCase):
def test_command_plan_and_patch_set_have_repository_schemas(self):
schema_dir = os.path.join(PIPELINE, "schemas")
expectations = {
"command-plan.schema.json": {"section_id", "source_sha256", "mode", "command_groups"},
"command-patch-set.schema.json": {"source_sha256", "patches"},
}
for name, required in expectations.items():
path = os.path.join(schema_dir, name)
self.assertTrue(os.path.isfile(path), path)
with open(path, encoding="utf-8") as fh:
schema = json.load(fh)
self.assertEqual("https://json-schema.org/draft/2020-12/schema", schema["$schema"])
self.assertTrue(required.issubset(set(schema["required"])))
def test_claude_command_agents_are_thin_adapters_to_canonical_contracts(self):
pairs = {
"command-pedagogy-planner.md": "contracts/command-pedagogy-planner.md",
"command-pedagogy-editor.md": "contracts/command-pedagogy-editor.md",
"command-pedagogy-reviewer.md": "contracts/command-pedagogy-reviewer.md",
}
for adapter_name, canonical_rel in pairs.items():
canonical = os.path.join(PIPELINE, canonical_rel)
adapter = os.path.join(ROOT, ".claude", "agents", adapter_name)
self.assertTrue(os.path.isfile(canonical), canonical)
with open(adapter, encoding="utf-8") as fh:
adapter_text = fh.read()
self.assertIn(
f".agents/skills/running-tech-log-pipeline/{canonical_rel}",
adapter_text,
)
substantive = [line for line in adapter_text.splitlines() if line.strip()]
self.assertLessEqual(len(substantive), 14, adapter_name)
def test_command_authoring_policy_declares_modes_without_blanket_bans(self):
policy = os.path.join(PIPELINE, "policies", "command-authoring.yaml")
self.assertTrue(os.path.isfile(policy), policy)
with open(policy, encoding="utf-8") as fh:
content = fh.read()
for mode in ("tutorial", "operator", "diagnostic", "automation", "reference"):
self.assertIn(f" {mode}:", content)
self.assertIn("blanket_ban: false", content)
self.assertIn("validation_cleanup_same_chain", content)
def test_pipeline_structural_gate_requires_command_pedagogy_assets(self):
verifier = os.path.join(ROOT, "scripts", "verify-pipeline.py")
versions = os.path.join(ROOT, "scripts", "skill-versions.py")
with open(verifier, encoding="utf-8") as fh:
verifier_text = fh.read()
with open(versions, encoding="utf-8") as fh:
versions_text = fh.read()
required = (
".agents/skills/writing-practitioner-guides/references/command-pedagogy.md",
".agents/skills/running-tech-log-pipeline/policies/command-authoring.yaml",
".agents/skills/running-tech-log-pipeline/schemas/command-plan.schema.json",
".agents/skills/running-tech-log-pipeline/schemas/command-patch-set.schema.json",
".agents/skills/running-tech-log-pipeline/contracts/command-pedagogy-planner.md",
".agents/skills/running-tech-log-pipeline/contracts/command-pedagogy-editor.md",
".agents/skills/running-tech-log-pipeline/contracts/command-pedagogy-reviewer.md",
".claude/agents/command-pedagogy-planner.md",
".claude/agents/command-pedagogy-editor.md",
".claude/agents/command-pedagogy-reviewer.md",
"scripts/command_pedagogy.py",
"scripts/check-command-pedagogy.py",
"scripts/apply-command-pedagogy-patch.py",
"scripts/validate-command-pedagogy-artifact.py",
)
for rel in required:
self.assertIn(f'"{rel}"', verifier_text, rel)
for rel in (
"scripts/command_pedagogy.py",
"scripts/check-command-pedagogy.py",
"scripts/apply-command-pedagogy-patch.py",
"scripts/validate-command-pedagogy-artifact.py",
):
self.assertIn(f'"{rel}"', versions_text, rel)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,470 @@
"""schemaVersion 4 런이 command-pedagogy artifact/hash 계약을 빠뜨리지 않는지 본다."""
from __future__ import annotations
import copy
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(ROOT, "scripts", "verify-pipeline-run.py")
sys.path.insert(0, os.path.join(ROOT, "scripts"))
from command_pedagogy import analyze_commands # noqa: E402
STAGE_SPECS = {
"S1": ("analyzing-codebase-for-tech-log", "ssot-analyst", True),
"S2": ("deriving-tech-log-root-tree", "tree-deriver", True),
"S3": ("writing-tech-log-records", "record-writer", False),
"S4": ("technical-visualizer", "diagram-maker", True),
"S5": ("rewriting-technical-prose-naturally", "prose-rewriter", False),
"S6": ("writing-as-the-person-who-did-it", "voice-writer", False),
"S7": ("publishing-tech-log-to-studio", "studio-validator", True),
}
def verify(path: str):
p = subprocess.run(
[sys.executable, SCRIPT, path], cwd=ROOT, capture_output=True, text=True
)
return p.returncode, p.stdout + p.stderr
def skill_echo(skill: str) -> str:
path = os.path.join(ROOT, ".agents", "skills", skill, "SKILL.md")
with open(path, encoding="utf-8") as fh:
for line in fh:
value = line.strip()
if len(value) >= 30 and not value.startswith(("#", "---", "name:", "description:")):
return value
raise AssertionError(f"usable skill echo not found: {skill}")
def stage(stage_id: str) -> dict:
skill, agent, skippable = STAGE_SPECS[stage_id]
base = {
"id": stage_id,
"name": stage_id,
"skill": skill,
"runBy": agent,
"status": "SKIPPED" if skippable else "DONE",
"skipReason": "synthetic fixture에서 생략" if skippable else "",
"skillEcho": "" if skippable else skill_echo(skill),
"skillRevision": None,
"inputs": [],
"outputs": [],
"gates": [],
"notes": "",
}
if stage_id == "S3":
base["gates"] = [
{"cmd": "node check_body.mjs", "exit": 0},
{"cmd": "node check_prose.mjs", "exit": 0},
{"cmd": "node check_evidence.mjs keycloak --repo", "exit": 0},
]
elif stage_id == "S5":
base["gates"] = [
{"cmd": "node check_prose.mjs", "exit": 0},
{"cmd": "node style_profile.mjs", "exit": 0},
{"cmd": "node check_body.mjs", "exit": 0},
{"cmd": "node check_evidence.mjs keycloak --repo", "exit": 0},
]
elif stage_id == "S6":
base["gates"] = [
{"cmd": "node check_voice.mjs", "exit": 0},
{"cmd": "node check_prose.mjs", "exit": 0},
{"cmd": "node check_body.mjs", "exit": 0},
{"cmd": "node check_evidence.mjs keycloak --repo", "exit": 0},
]
return base
def done_role(agent: str, verdict: str | None = None) -> dict:
role = {
"runBy": agent,
"status": "DONE",
"skipReason": "",
"notes": "",
}
if agent.startswith("command-pedagogy-"):
role.update(
skill="writing-practitioner-guides",
skillEcho=skill_echo("writing-practitioner-guides"),
)
if verdict is not None:
role["verdict"] = verdict
return role
def skipped_role(agent: str, reason: str) -> dict:
role = done_role(agent)
role["status"] = "SKIPPED"
role["skipReason"] = reason
role.pop("verdict", None)
return role
def good_reviews(*, shell_blocks: int = 1, findings: int = 0) -> dict:
needs_edit = findings > 0
return {
"commandPedagogy": {
"initialAnalysis": {
"cmd": "python3 scripts/check-command-pedagogy.py docs/keycloak/final/document.md",
"exit": 0,
"shellBlocks": shell_blocks,
"findings": findings,
"majorFindings": 0,
},
"finalAnalysis": {
"cmd": "python3 scripts/check-command-pedagogy.py docs/keycloak/final/document.md",
"exit": 0,
"shellBlocks": shell_blocks,
"findings": 0,
"majorFindings": 0,
},
"planner": (
done_role("command-pedagogy-planner")
if needs_edit
else skipped_role("command-pedagogy-planner", "deterministic finding 없음")
),
"editor": (
done_role("command-pedagogy-editor")
if needs_edit
else skipped_role("command-pedagogy-editor", "deterministic finding 없음")
),
"reviewer": (
done_role("command-pedagogy-reviewer", "PASS")
if shell_blocks
else skipped_role("command-pedagogy-reviewer", "shell/CLI block 없음")
),
},
"technicalEvidence": done_role("fact-reviewer", "PASS"),
}
def synthetic_run() -> dict:
return {
"schemaVersion": 4,
"runId": "synthetic-command-pedagogy",
"project": "keycloak",
"record": "docs/keycloak/final/document.md",
"startedAt": "2026-09-17T20:00:00+09:00",
"finishedAt": "2026-09-17T20:30:00+09:00",
"stages": [stage(sid) for sid in STAGE_SPECS],
"qualityReviews": good_reviews(),
}
class Schema3ReviewContractTest(unittest.TestCase):
def setUp(self):
runs_dir = os.path.join(ROOT, "runs")
os.makedirs(runs_dir, exist_ok=True)
self.tmp = tempfile.TemporaryDirectory(prefix="test-command-pedagogy-", dir=runs_dir)
self.addCleanup(self.tmp.cleanup)
self.record_path = os.path.join(self.tmp.name, "record.md")
with open(self.record_path, "w", encoding="utf-8") as fh:
fh.write("실행 확인:\n\n```bash\nkubectl get pods\n```\n")
self.record_rel = os.path.relpath(self.record_path, ROOT)
self.base = synthetic_run()
self.base["record"] = self.record_rel
self.base["qualityReviews"] = self.make_reviews()
def artifact(self, name: str, value: dict) -> dict:
path = os.path.join(self.tmp.name, name)
with open(path, "w", encoding="utf-8") as fh:
json.dump(value, fh, ensure_ascii=False, indent=2)
fh.write("\n")
with open(path, "rb") as fh:
digest = hashlib.sha256(fh.read()).hexdigest()
return {"path": os.path.relpath(path, ROOT), "sha256": digest}
def record_sha256(self) -> str:
with open(self.record_path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest()
def make_reviews(self, *, findings: int = 0) -> dict:
with open(self.record_path, encoding="utf-8") as fh:
text = fh.read()
final_analysis = analyze_commands("synthetic-command-pedagogy", text, mode="operator")
initial_analysis = copy.deepcopy(final_analysis)
block = initial_analysis["blocks"][0] if initial_analysis["blocks"] else None
if findings:
initial_analysis["result"] = "WARN"
initial_analysis["requires_editor"] = True
initial_analysis["findings"] = [
{
"block_id": block["id"],
"code": f"synthetic-{idx}",
"severity": "minor",
"evidence": "fixture",
"instruction": "fixture",
}
for idx in range(findings)
]
initial_receipt = self.artifact("command-initial.json", initial_analysis)
final_receipt = self.artifact("command-final.json", final_analysis)
source_sha = self.record_sha256()
if findings:
plan = {
"schema_version": "1.0",
"section_id": "synthetic-command-pedagogy",
"source_sha256": initial_analysis["source_sha256"],
"mode": "operator",
"command_groups": [
{
"id": "inspect-pods",
"block_id": block["id"],
"source_sha256": block["source_sha256"],
"goal": "파드 상태를 확인한다.",
"execution_context": {"host": "local", "cwd": "."},
"prerequisites": [],
"steps": [
{
"command": "kubectl get pods",
"reason": "현재 파드 목록을 본다.",
"expected_result": "파드 목록이 출력된다.",
}
],
"cleanup": [],
}
],
}
patch = {
"schema_version": "1.0",
"source_sha256": initial_analysis["source_sha256"],
"patches": [],
}
planner = done_role("command-pedagogy-planner")
planner["artifact"] = self.artifact("command-plan.json", plan)
editor = done_role("command-pedagogy-editor")
editor["artifact"] = self.artifact("command-patch-set.json", patch)
else:
planner = skipped_role("command-pedagogy-planner", "deterministic finding 없음")
planner["artifact"] = None
editor = skipped_role("command-pedagogy-editor", "deterministic finding 없음")
editor["artifact"] = None
if final_analysis["blocks"]:
review = {
"scope": "command-pedagogy",
"reviewer": "command-pedagogy-reviewer",
"verdict": "PASS",
"source_sha256": source_sha,
"findings": [],
"notes": "",
}
reviewer = done_role("command-pedagogy-reviewer", "PASS")
reviewer["sourceSha256"] = source_sha
reviewer["artifact"] = self.artifact("command-review.json", review)
else:
reviewer = skipped_role("command-pedagogy-reviewer", "shell/CLI block 없음")
reviewer["sourceSha256"] = source_sha
reviewer["artifact"] = None
technical = done_role("fact-reviewer", "PASS")
technical["sourceSha256"] = source_sha
return {
"commandPedagogy": {
"initialAnalysis": {
"cmd": f"python3 scripts/check-command-pedagogy.py {self.record_rel} --mode operator",
"exit": 0,
"shellBlocks": len(initial_analysis["blocks"]),
"findings": len(initial_analysis["findings"]),
"majorFindings": sum(1 for f in initial_analysis["findings"] if f["severity"] == "major"),
"artifact": initial_receipt,
},
"finalAnalysis": {
"cmd": f"python3 scripts/check-command-pedagogy.py {self.record_rel} --mode operator",
"exit": 0,
"shellBlocks": len(final_analysis["blocks"]),
"findings": len(final_analysis["findings"]),
"majorFindings": sum(1 for f in final_analysis["findings"] if f["severity"] == "major"),
"artifact": final_receipt,
},
"planner": planner,
"editor": editor,
"reviewer": reviewer,
},
"technicalEvidence": technical,
}
def write(self, mutate=None):
data = copy.deepcopy(self.base)
if mutate:
mutate(data)
path = os.path.join(self.tmp.name, "run.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
return path
def test_schema3_with_complete_reviews_passes(self):
code, out = verify(self.write())
self.assertEqual(0, code, out)
def test_schema3_legacy_quality_receipts_do_not_require_new_artifact_hash_fields(self):
def mutate(data):
data["schemaVersion"] = 3
command = data["qualityReviews"]["commandPedagogy"]
command["initialAnalysis"].pop("artifact", None)
command["finalAnalysis"].pop("artifact", None)
for name in ("planner", "editor", "reviewer"):
command[name].pop("artifact", None)
command[name].pop("sourceSha256", None)
data["qualityReviews"]["technicalEvidence"].pop("sourceSha256", None)
code, out = verify(self.write(mutate))
self.assertEqual(0, code, out)
def test_schema3_requires_quality_review_receipts(self):
code, out = verify(self.write(lambda d: d.pop("qualityReviews")))
self.assertEqual(1, code)
self.assertIn("품질 검토 원장이 없다", out)
def test_findings_require_planner_and_editor(self):
def mutate(data):
data["qualityReviews"] = self.make_reviews(findings=2)
data["qualityReviews"]["commandPedagogy"]["planner"] = skipped_role(
"command-pedagogy-planner", "임의 생략"
)
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("명령 finding이 있는데 planner가 끝나지 않았다", out)
def test_shell_blocks_require_independent_reviewer(self):
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["reviewer"] = skipped_role(
"command-pedagogy-reviewer", "임의 생략"
)
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("shell/CLI가 있는데 command reviewer가 끝나지 않았다", out)
def test_uncertain_command_review_blocks_acceptance(self):
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["reviewer"]["verdict"] = "UNCERTAIN"
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("command-pedagogy review가 통과하지 못했다", out)
def test_final_major_finding_blocks_acceptance(self):
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["finalAnalysis"]["majorFindings"] = 1
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("major command finding이 남았다", out)
def test_command_repair_cannot_remove_all_shell_blocks(self):
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["finalAnalysis"]["shellBlocks"] = 0
data["qualityReviews"]["commandPedagogy"]["reviewer"] = skipped_role(
"command-pedagogy-reviewer", "최종 shell block 없음"
)
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("command repair가 모든 shell block을 없앴다", out)
def test_fact_review_runs_after_repairs_and_must_pass(self):
def mutate(data):
data["qualityReviews"]["technicalEvidence"]["verdict"] = "FAIL"
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("technical-evidence review가 통과하지 못했다", out)
def test_command_free_record_skips_command_roles_but_keeps_fact_review(self):
with open(self.record_path, "w", encoding="utf-8") as fh:
fh.write("명령어가 없는 설명 문단이다.\n")
self.base["qualityReviews"] = self.make_reviews()
code, out = verify(self.write())
self.assertEqual(0, code, out)
def test_initial_analysis_requires_path_and_sha_evidence(self):
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["initialAnalysis"].pop("artifact")
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("command artifact 영수증이 없다", out)
def test_artifact_sha_mismatch_blocks_acceptance(self):
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["finalAnalysis"]["artifact"]["sha256"] = "0" * 64
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("command artifact sha256이 실제 파일과 다르다", out)
def test_plan_and_patch_artifacts_are_required_when_findings_exist(self):
self.base["qualityReviews"] = self.make_reviews(findings=1)
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["planner"].pop("artifact")
data["qualityReviews"]["commandPedagogy"]["editor"].pop("artifact")
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("command role artifact 영수증이 없다", out)
def test_command_review_is_bound_to_final_publication_hash(self):
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["reviewer"]["sourceSha256"] = "0" * 64
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("최종 publication hash와 다르다", out)
def test_technical_evidence_review_is_bound_to_final_publication_hash(self):
def mutate(data):
data["qualityReviews"]["technicalEvidence"]["sourceSha256"] = "0" * 64
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("최종 publication hash와 다르다", out)
def test_command_review_artifact_is_required_for_command_content(self):
def mutate(data):
data["qualityReviews"]["commandPedagogy"]["reviewer"].pop("artifact")
code, out = verify(self.write(mutate))
self.assertEqual(1, code)
self.assertIn("command role artifact 영수증이 없다", out)
class Schema3InitTest(unittest.TestCase):
def test_init_uses_current_schema_and_prepares_review_receipts(self):
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "run.json")
p = subprocess.run(
[
sys.executable,
SCRIPT,
"--init",
path,
"--project",
"keycloak",
"--record",
"docs/keycloak/final/document.md",
],
cwd=ROOT,
capture_output=True,
text=True,
)
self.assertEqual(0, p.returncode, p.stdout + p.stderr)
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
self.assertEqual(5, data["schemaVersion"])
self.assertIn("commandPedagogy", data["qualityReviews"])
self.assertIn("technicalEvidence", data["qualityReviews"])
command = data["qualityReviews"]["commandPedagogy"]
self.assertIn("artifact", command["initialAnalysis"])
self.assertIn("artifact", command["finalAnalysis"])
self.assertIn("artifact", command["planner"])
self.assertIn("artifact", command["editor"])
self.assertIn("artifact", command["reviewer"])
self.assertIn("sourceSha256", command["reviewer"])
technical = data["qualityReviews"]["technicalEvidence"]
self.assertIn("sourceSha256", technical)
self.assertIn("liveSourceReconciliation", technical)
self.assertIn("liveSourceReason", technical)
self.assertIn("acceptedByProjectReview", technical)
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -3,6 +3,7 @@
이 검사기의 값은 「통과시키는 것」이 아니라 「안 지킨 것을 잡는 것」이라, 시험도 전부
위반을 넣어 걸리는지 보는 모양이다.
"""
import importlib.util
import json
import os
import subprocess
@@ -13,6 +14,9 @@ import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
SCRIPT = os.path.join(ROOT, "scripts", "verify-pipeline-run.py")
LEDGER = os.path.join(ROOT, "runs", "keycloak", "2026-09-07-2215", "run.json")
_spec = importlib.util.spec_from_file_location("verify_pipeline_run", SCRIPT)
vpr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(vpr)
def run(path, *args):
@@ -125,6 +129,75 @@ class LedgerRules(unittest.TestCase):
self.assertIn("곁증명의 스킬 영수증이 그 스킬의 문장이 아니다", out)
class EvidenceGateV5(unittest.TestCase):
def check(self, gate):
rep = vpr.Report("fixture")
run_ = {"schemaVersion": vpr.EVIDENCE_RECONCILIATION_SCHEMA, "project": "demo"}
found = vpr._evidence_gate_v5(rep, run_, "S3", [gate], "S3 fixture")
return rep, found
def test_repo_flag_is_required(self):
rep, _ = self.check({
"semanticId": "evidence-repo",
"cmd": "node check_evidence.mjs demo",
"exit": 0,
"status": "PASS",
})
self.assertTrue(rep.error_count)
self.assertIn("live source evidence gate에서 --repo가 빠졌다", rep.errors)
def test_unverifiable_preserves_exit_three_and_reason(self):
rep, gate = self.check({
"semanticId": "evidence-repo",
"cmd": "node check_evidence.mjs demo --repo",
"exit": 3,
"status": "UNVERIFIABLE",
"reason": "source repository unavailable on current machine",
"acceptedByProjectReview": True,
})
self.assertEqual(0, rep.error_count, rep.errors)
self.assertIsNotNone(gate)
self.assertEqual(["S3"], rep.facts["live source UNVERIFIABLE"])
def test_unverifiable_cannot_hide_an_arbitrary_exit(self):
rep, _ = self.check({
"semanticId": "evidence-repo",
"cmd": "node check_evidence.mjs demo --repo",
"exit": 1,
"status": "UNVERIFIABLE",
"reason": "something failed",
"acceptedByProjectReview": True,
})
self.assertIn("UNVERIFIABLE evidence gate는 실제 대조 불가 exit 3이어야 한다", rep.errors)
def test_unverifiable_needs_project_review_acceptance(self):
rep, _ = self.check({
"semanticId": "evidence-repo",
"cmd": "node check_evidence.mjs demo --repo",
"exit": 3,
"status": "UNVERIFIABLE",
"reason": "source repository unavailable on current machine",
})
self.assertIn("UNVERIFIABLE evidence gate가 프로젝트 리뷰에서 수용되지 않았다", rep.errors)
def test_pass_requires_real_repo_gate_and_zero_exit(self):
rep, _ = self.check({
"semanticId": "evidence-repo",
"cmd": "node check_evidence.mjs demo --repo",
"exit": 0,
"status": "PASS",
})
self.assertEqual(0, rep.error_count, rep.errors)
def test_semantic_gate_id_is_required(self):
rep = vpr.Report("fixture")
run_ = {"schemaVersion": vpr.EVIDENCE_RECONCILIATION_SCHEMA, "project": "demo"}
vpr._evidence_gate_v5(rep, run_, "S3", [{
"cmd": "node check_evidence.mjs demo --repo", "exit": 0, "status": "PASS"
}], "S3 fixture")
self.assertIn("필수 evidence semantic gate가 정확히 하나가 아니다", rep.errors)
class Init(unittest.TestCase):
def test_틀에서_런을_연다(self):
with tempfile.TemporaryDirectory() as d:
@@ -0,0 +1,145 @@
from __future__ import annotations
import importlib.util
import json
import os
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "verify-project-layout.py"
spec = importlib.util.spec_from_file_location("verify_project_layout", SCRIPT)
layout = importlib.util.module_from_spec(spec)
spec.loader.exec_module(layout)
def _section(line: int, level: int, title: str, start: int, end: int, text: str) -> dict:
return {
"heading": {"line": line, "level": level, "text": title},
"start_line": start,
"end_line": end,
"text": text,
}
class TechVizContextFallbackTest(unittest.TestCase):
def _check(self, document: str, context: dict) -> bool | None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
doc = root / "document.md"
ctx = root / "context.json"
doc.write_text(document, encoding="utf-8")
ctx.write_text(json.dumps(context, ensure_ascii=False), encoding="utf-8")
return layout._context_snapshot_matches(str(doc), str(ctx))
def test_parent_predecessor_is_only_the_preamble_before_current_child(self):
document = "\n".join([
"# Root",
"root",
"## Parent",
"preamble",
"### Current",
"current",
"### Next",
"next",
"## Tail",
"tail",
])
context = {
"anchor": {"kind": "heading", "value": "Current", "line": 5},
"previous_section": _section(
3, 2, "Parent", 3, 4, "## Parent\npreamble"
),
"current_section": _section(
5, 3, "Current", 5, 6, "### Current\ncurrent"
),
"next_section": _section(
7, 3, "Next", 7, 8, "### Next\nnext"
),
}
self.assertTrue(self._check(document, context))
changed = document.replace("preamble", "changed preamble")
self.assertFalse(self._check(changed, context))
def test_fenced_hash_lines_do_not_end_neighbor_section(self):
document = "\n".join([
"# Root",
"## Current",
"current",
"## Data",
"intro",
"~~~text",
"# not a heading",
"## also not a heading",
"~~~",
"tail",
"## End",
"end",
])
context = {
"anchor": {"kind": "heading", "value": "Current", "line": 2},
"previous_section": _section(
1, 1, "Root", 1, 1, "# Root"
),
"current_section": _section(
2, 2, "Current", 2, 3, "## Current\ncurrent"
),
"next_section": _section(
4, 2, "Data", 4, 10,
"## Data\nintro\n~~~text\n# not a heading\n"
"## also not a heading\n~~~\ntail",
),
}
self.assertTrue(self._check(document, context))
def test_legacy_context_without_anchor_uses_current_heading(self):
document = "\n".join([
"# Root",
"## Previous",
"previous",
"## Current",
"current",
"## Next",
"next",
])
context = {
"previous_section": {
"heading": {"text": "Previous"},
"text": "## Previous\nprevious",
},
"current_section": {
"heading": {"text": "Current"},
"text": "## Current\ncurrent",
},
"next_section": {
"heading": {"text": "Next"},
"text": "## Next\nnext",
},
}
self.assertTrue(self._check(document, context))
def test_keycloak_session_store_seven_fresh_contexts_match_without_techviz(self):
ids = [
"a1-transport-vs-discovery",
"d2-upgrade-direction",
"lab-topology",
"measurement-control",
"observation-points",
"open-questions-answered",
"wrong-predictions",
]
document = ROOT / "docs" / "keycloak-session-store" / "final" / "document.md"
base = ROOT / "docs" / "keycloak-session-store" / "final" / ".techviz"
for diagram_id in ids:
with self.subTest(diagram_id=diagram_id):
context = base / diagram_id / "context.json"
self.assertTrue(
layout._context_snapshot_matches(str(document), str(context))
)
if __name__ == "__main__":
unittest.main()
+36 -2
View File
@@ -85,6 +85,39 @@ class LedgerTest(unittest.TestCase):
p = _cli("gate", self.led, "--stage", "S3", "--cmd", "x")
self.assertNotEqual(0, p.returncode)
def test_unverifiable_gate_keeps_the_real_failure_receipt(self):
p = _cli(
"gate", self.led, "--stage", "S3",
"--cmd", "node check_evidence.mjs demo --repo", "--exit", "3",
"--semantic-id", "evidence-repo", "--status", "UNVERIFIABLE",
"--reason", "source repository unavailable on current machine",
"--accepted-by-project-review",
)
self.assertEqual(0, p.returncode, p.stderr)
gate = self._stage("S3")["gates"][0]
self.assertEqual("evidence-repo", gate["semanticId"])
self.assertEqual("UNVERIFIABLE", gate["status"])
self.assertEqual(3, gate["exit"])
self.assertTrue(gate["acceptedByProjectReview"])
def test_unverifiable_gate_needs_a_reason(self):
p = _cli(
"gate", self.led, "--stage", "S3", "--cmd", "x", "--exit", "3",
"--status", "UNVERIFIABLE",
)
self.assertEqual(2, p.returncode)
self.assertIn("--reason", p.stderr)
def test_gate_status_cannot_lie_about_the_exit_code(self):
self.assertEqual(2, _cli(
"gate", self.led, "--stage", "S3", "--cmd", "x", "--exit", "3",
"--status", "PASS",
).returncode)
self.assertEqual(2, _cli(
"gate", self.led, "--stage", "S3", "--cmd", "x", "--exit", "0",
"--status", "FAIL",
).returncode)
# ── 끊겨도 이어진다 ──────────────────────────────────────────────
def test_the_file_is_never_half_written(self):
"""쓰는 도중에 죽여도 읽는 쪽은 이전 판이나 다음 판 중 하나를 본다."""
@@ -286,7 +319,8 @@ class EchoAgainstSkillHistory(unittest.TestCase):
"""S3 의 영수증만 갈아 끼운, 그 밖에는 흠이 없는 원장."""
run = json.load(open(vpr.TEMPLATE, encoding="utf-8"))
run.update({"runId": "2026-01-01-0000", "project": "demo",
"record": "CLAUDE.md", "startedAt": "2026-01-01T00:00:00+09:00"})
"record": "CLAUDE.md", "startedAt": "2026-01-01T00:00:00+09:00",
"schemaVersion": vpr.AGENT_RUNBY_SCHEMA})
for st in run["stages"]:
spec = vpr.STAGES[st["id"]]
if not carry_field:
@@ -469,7 +503,7 @@ class RunByNamesAManagedAgent(unittest.TestCase):
p = _cli("open", led, "--project", "demo", "--record", "docs/demo/x.md")
self.assertEqual(0, p.returncode, p.stderr)
run = json.load(open(led, encoding="utf-8"))
self.assertEqual(vpr.AGENT_RUNBY_SCHEMA, run["schemaVersion"])
self.assertEqual(vpr.CURRENT_RUN_SCHEMA, run["schemaVersion"])
for st in run["stages"]:
self.assertEqual(vpr.STAGES[st["id"]]["agent"], st["runBy"])
+129
View File
@@ -182,6 +182,49 @@ class ContractTest(unittest.TestCase):
with Fixture(mutate(source=["analysis/05-persistence.md §3.5"])):
self.assertIn("근거가 SSOT 밖에만 있다", verifier.verify("fixture").warns)
def test_numbered_ssot_anchor_is_resolved_against_heading(self):
index = copy.deepcopy(INDEX)
index["candidateScope"]["sections"] = ["§3"]
index["topics"]["session-custody"]["kinds"]["case"][0]["source"] = [
"final/document.md#§3.1"
]
index["topics"]["session-custody"]["kinds"]["concept"][0]["source"] = [
"final/document.md#§3.1"
]
with Fixture(index) as fx:
ssot = os.path.join(fx.base, "final/document.md")
open(ssot, "w", encoding="utf-8").write(
"# fixture\n\n## 3. 세션\n\n### 3.1 교환\n\n내용\n"
)
data = fx.read()
data["ssotSha256"] = hashlib.sha256(open(ssot, "rb").read()).hexdigest()
fx.write(data)
report = verifier.verify("fixture")
self.assertNotIn("앵커가 검사 가능한 절 제목/번호 형식이 아니다", report.warns)
self.assertNotIn("SSOT 에 없는 번호 절을 가리키는 앵커", report.errors)
def test_missing_numbered_ssot_anchor_is_an_error(self):
index = copy.deepcopy(INDEX)
index["candidateScope"]["sections"] = ["§3"]
index["topics"]["session-custody"]["kinds"]["case"][0]["source"] = [
"final/document.md#§3.9"
]
index["topics"]["session-custody"]["kinds"]["concept"][0]["source"] = [
"final/document.md#§3.1"
]
with Fixture(index) as fx:
ssot = os.path.join(fx.base, "final/document.md")
open(ssot, "w", encoding="utf-8").write(
"# fixture\n\n## 3. 세션\n\n### 3.1 교환\n\n내용\n"
)
data = fx.read()
data["ssotSha256"] = hashlib.sha256(open(ssot, "rb").read()).hexdigest()
fx.write(data)
self.assertIn(
"SSOT 에 없는 번호 절을 가리키는 앵커",
verifier.verify("fixture").errors,
)
def test_readiness_is_not_publication(self):
with Fixture(mutate(readiness="NEEDS_EVIDENCE")):
self.assertIn("글을 쓰면 안 되는 readiness 인데 기록이 있다",
@@ -191,6 +234,39 @@ class ContractTest(unittest.TestCase):
with Fixture(mutate(readiness="REJECTED")):
self.assertIn("readiness 값이 계약에 없다", verifier.verify("fixture").errors)
def test_resolved_question_is_a_valid_written_state(self):
index = copy.deepcopy(INDEX)
question = {
"title": "이미 답을 얻은 질문",
"kind": "question",
"slug": "resolved-question",
"readiness": "RESOLVED",
"source": ["final/document.md#fixture"],
"known": "직접 관측으로 답을 얻었다",
"unknown": "후속 조건만 남았다",
"next-verification": "후속 조건을 별도 검증한다",
"decision-criterion": "직접 관측으로 닫혔다",
"relations": ["case:session-split-across-nodes"],
}
index["topics"]["session-custody"]["kinds"]["question"] = [question]
index["candidates"].append({
"id": "F004",
"disposition": "PROMOTE",
"dispositionReview": "CONFIRMED",
"target": "question:resolved-question",
})
with Fixture(index) as fx:
folder = os.path.join(fx.studio, "session-custody/question")
os.makedirs(folder, exist_ok=True)
open(os.path.join(folder, "question-resolved.md"), "w", encoding="utf-8").write(
"---\nkind: QUESTION\nslug: resolved-question\n"
"title: 이미 답을 얻은 질문\ntopic: session-custody\n"
"project: fixture\nquestionStatus: RESOLVED\n---\n"
)
report = verifier.verify("fixture")
self.assertNotIn("QUESTION readiness 는 OPEN 또는 RESOLVED 다", report.errors)
self.assertNotIn("글을 쓰면 안 되는 readiness 인데 기록이 있다", report.errors)
def test_record_outside_the_contract_is_an_error(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.studio, "orphan-topic/concept"))
@@ -421,6 +497,44 @@ class LayoutTest(unittest.TestCase):
self.assertIn("기록이 가리키는 그림에 techviz 정본이 없다",
layout.verify("fixture").warns)
def test_context_snapshot_detects_ssot_drift_without_techviz_tool(self):
with Fixture() as fx:
fx.diagram("context-map", cited=True)
document = os.path.join(fx.base, "final/document.md")
open(document, "w", encoding="utf-8").write(
"# fixture\n\n## 1. 이전\n\n그대로\n\n"
"## 2. 대상\n\n그림 근거\n\n"
"## 3. 다음\n\n바뀐 내용\n"
)
source = os.path.join(fx.base, "final/.techviz/context-map")
with open(os.path.join(source, "spec.json"), "w", encoding="utf-8") as fh:
json.dump({"source_context": {
"document": "document.md",
"document_sha256": "1" * 64,
}}, fh)
with open(os.path.join(source, "context.json"), "w", encoding="utf-8") as fh:
json.dump({
"previous_section": {
"heading": {"text": "1. 이전"},
"text": "## 1. 이전\n\n그대로\n",
},
"current_section": {
"heading": {"text": "2. 대상"},
"text": "## 2. 대상\n\n그림 근거\n",
},
"next_section": {
"heading": {"text": "3. 다음"},
"text": "## 3. 다음\n\n예전 내용\n",
},
}, fh, ensure_ascii=False)
original = layout._context_sha
layout._context_sha = lambda _: None
try:
report = layout.verify("fixture")
finally:
layout._context_sha = original
self.assertIn("SSOT 문맥이 바뀐 뒤 그림을 다시 보지 않았다", report.warns)
def test_evidence_folder_outside_the_convention_is_an_error(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "final/evidence/screenshots"))
@@ -453,6 +567,21 @@ class LayoutTest(unittest.TestCase):
encoding="utf-8").write("원본\n")
self.assertIn("반입 원본이 남아 있다", layout.verify("fixture").warns)
def test_durable_import_snapshot_is_not_transient_source_debt(self):
with Fixture() as fx:
os.makedirs(os.path.join(fx.base, "source/docs"))
open(os.path.join(fx.base, "source/docs/lab.md"), "w",
encoding="utf-8").write("원본\n")
data = fx.read()
data["sourcePolicy"] = {
"mode": "DURABLE_IMPORT_SNAPSHOT",
"reason": "exact commit이 없는 반입 당시 working-tree 바이트를 보존한다",
}
fx.write(data)
report = layout.verify("fixture")
self.assertNotIn("반입 원본이 남아 있다", report.warns)
self.assertIn("durable import snapshot", report.facts.get("source", ""))
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""CommandPlan/CommandPatchSet JSON을 frozen command analysis와 대조한다."""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from command_pedagogy import validate_command_patch_set, validate_command_plan # noqa: E402
def _load(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
value = json.load(fh)
if not isinstance(value, dict):
raise ValueError(f"JSON object가 아니다: {path}")
return value
def main() -> int:
ap = argparse.ArgumentParser(description="command-pedagogy first-class artifact를 검증한다")
ap.add_argument("kind", choices=("plan", "patch"))
ap.add_argument("artifact")
ap.add_argument("--analysis", required=True, help="frozen deterministic analysis JSON")
args = ap.parse_args()
try:
artifact = _load(args.artifact)
analysis = _load(args.analysis)
if args.kind == "plan":
validate_command_plan(artifact, analysis=analysis)
else:
validate_command_patch_set(artifact, analysis=analysis)
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"COMMAND ARTIFACT: FAIL — {exc}", file=sys.stderr)
return 1
print(f"COMMAND ARTIFACT: PASS — {args.kind} {args.artifact}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+465
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
@@ -34,6 +35,11 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from techlog import Report # noqa: E402
from command_pedagogy import ( # noqa: E402
analyze_commands,
validate_command_patch_set,
validate_command_plan,
)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKILLS = os.path.join(ROOT, ".agents", "skills")
@@ -87,11 +93,27 @@ LEGACY_RUNBY = "subagent"
# 쓰였나**를 읽으면 커밋 전후로 판정이 흔들리지 않는다.
AGENT_RUNBY_SCHEMA = 2
# v3부터 S3 안의 command-pedagogy 보조 흐름과 S6 뒤 독립 검토를 원장에 남긴다.
# 과거 v1/v2 원장은 그때 없던 영수증을 소급해 요구하지 않는다.
QUALITY_REVIEW_SCHEMA = 3
COMMAND_ARTIFACT_SCHEMA = 4
EVIDENCE_RECONCILIATION_SCHEMA = 5
CURRENT_RUN_SCHEMA = EVIDENCE_RECONCILIATION_SCHEMA
COMMAND_REVIEW_SKILL = "writing-practitioner-guides"
COMMAND_REVIEW_AGENTS = {
"planner": "command-pedagogy-planner",
"editor": "command-pedagogy-editor",
"reviewer": "command-pedagogy-reviewer",
}
FACT_REVIEW_AGENT = "fact-reviewer"
# 측정 관문 — 돌았다는 것은 요구하지만 종료 코드 0 은 요구하지 않는다.
# 문서 계약이 「error 0」을 붙인 것은 check_prose 뿐이고 style_profile 은 문체 수치를 보여 주는
# 측정이다 (stage-contracts.md:178·:252). 여기에 0 을 요구하면 정직하게 적은 원장이 실패하고,
# 0 으로 고쳐 적으면 그건 지어낸 것이 된다.
MEASUREMENT_GATES = ("style_profile.mjs",)
EVIDENCE_GATE_STAGES = {"S3", "S5", "S6"}
EVIDENCE_GATE_ID = "evidence-repo"
ORDER = ["S1", "S2", "S3", "S4", "S5", "S6", "S7"]
@@ -422,6 +444,442 @@ def _side_proof(rep: Report, st: dict, sid: str, spec: dict, where: str) -> int:
return unverifiable
def _evidence_gate_v5(rep: Report, run: dict, sid: str, gates: list[dict], where: str) -> dict | None:
"""v5부터 live source reconciliation을 이름 있는 관문으로 검증한다.
예전 원장은 command 문자열에 `check_evidence.mjs`만 있으면 통과했다. 그러면 `--repo`를
빼서 live source 대조를 하지 않은 명령도 exit 0만 적으면 같은 초록색이 된다. v5에서는
S3/S5/S6마다 semanticId가 `evidence-repo`인 관문을 하나 요구하고 실제 `--repo` 호출인지
확인한다.
source checkout이 현재 기계에 없을 수 있다. 그 경우 실패를 0으로 바꾸지 않는다. 실제
exit 3을 `UNVERIFIABLE`로 적고 이유와 프로젝트 리뷰 수용 여부를 남긴 경우에만 절차를
정직하게 수행한 것으로 인정한다.
"""
schema = run.get("schemaVersion")
if not isinstance(schema, int) or schema < EVIDENCE_RECONCILIATION_SCHEMA:
return None
if sid not in EVIDENCE_GATE_STAGES:
return None
matches = [g for g in gates if g.get("semanticId") == EVIDENCE_GATE_ID]
if len(matches) != 1:
rep.error("필수 evidence semantic gate가 정확히 하나가 아니다",
f"{where} — semanticId={EVIDENCE_GATE_ID!r} · count={len(matches)}")
return None
gate = matches[0]
cmd = str(gate.get("cmd") or "")
if "check_evidence.mjs" not in cmd:
rep.error("evidence semantic gate가 check_evidence를 실행하지 않았다",
f"{where}{cmd[:90]}")
if not re.search(r"(?:^|\s)--repo(?:\s|$)", cmd):
rep.error("live source evidence gate에서 --repo가 빠졌다",
f"{where}{cmd[:90]}")
project = str(run.get("project") or "")
if project and not re.search(rf"(?<![\w/.-]){re.escape(project)}(?![\w/.-])", cmd):
rep.error("evidence semantic gate가 현재 프로젝트를 가리키지 않는다",
f"{where} — project={project} · {cmd[:90]}")
status = gate.get("status")
exit_code = gate.get("exit")
if status == "PASS":
if exit_code not in (0, "0"):
rep.error("PASS evidence gate의 종료 코드가 0이 아니다",
f"{where} — exit={exit_code}")
elif status == "UNVERIFIABLE":
if exit_code not in (3, "3"):
rep.error("UNVERIFIABLE evidence gate는 실제 대조 불가 exit 3이어야 한다",
f"{where} — exit={exit_code}")
if not str(gate.get("reason") or "").strip():
rep.error("UNVERIFIABLE evidence gate에 이유가 없다", where)
if gate.get("acceptedByProjectReview") is not True:
rep.error("UNVERIFIABLE evidence gate가 프로젝트 리뷰에서 수용되지 않았다", where)
rep.facts.setdefault("live source UNVERIFIABLE", []).append(sid)
else:
rep.error("evidence semantic gate status가 계약 밖이다",
f"{where} — status={status!r} · PASS|UNVERIFIABLE만 허용")
return gate
def _sha256_file(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _publication_sha256(run: dict) -> str | None:
rel = str(run.get("record") or "")
if not rel or os.path.isabs(rel):
return None
full = os.path.realpath(os.path.join(ROOT, rel))
try:
if os.path.commonpath([ROOT, full]) != os.path.realpath(ROOT):
return None
except ValueError:
return None
if not os.path.isfile(full):
return None
return _sha256_file(full)
def _command_artifact(
rep: Report,
receipt,
where: str,
*,
missing_error: str = "command artifact 영수증이 없다",
) -> dict | None:
"""repo-relative path + sha256 영수증을 실제 JSON artifact와 대조한다."""
if not isinstance(receipt, dict):
rep.error(missing_error, where)
return None
rel = receipt.get("path")
expected = receipt.get("sha256")
if not isinstance(rel, str) or not rel.strip() or os.path.isabs(rel):
rep.error("command artifact path가 계약 밖이다", f"{where}{rel!r}")
return None
if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-f]{64}", expected):
rep.error("command artifact sha256 형식이 잘못됐다", f"{where}{expected!r}")
return None
full = os.path.realpath(os.path.join(ROOT, rel))
try:
inside = os.path.commonpath([os.path.realpath(ROOT), full]) == os.path.realpath(ROOT)
except ValueError:
inside = False
if not inside:
rep.error("command artifact가 repository 밖을 가리킨다", f"{where}{rel}")
return None
if not os.path.isfile(full):
rep.error("command artifact 파일이 없다", f"{where}{rel}")
return None
actual = _sha256_file(full)
if actual != expected:
rep.error(
"command artifact sha256이 실제 파일과 다르다",
f"{where} — expected={expected[:12]} actual={actual[:12]} · {rel}",
)
return None
try:
with open(full, encoding="utf-8") as fh:
value = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
rep.error("command artifact JSON을 읽지 못했다", f"{where}{rel}{exc}")
return None
if not isinstance(value, dict):
rep.error("command artifact가 JSON object가 아니다", f"{where}{rel}")
return None
return value
def _review_agent(rep: Report, role: dict, expected: str, where: str) -> None:
"""품질 검토 역할이 지정된 독립 에이전트를 실제로 가리키는지 본다."""
actual = role.get("runBy")
if actual != expected:
rep.error("품질 검토 agent가 계약과 다르다",
f"{where} — runBy={actual!r} · 계약은 {expected!r}")
if not os.path.exists(os.path.join(AGENTS_DIR, f"{expected}.md")):
rep.error("품질 검토 agent 정의가 없다",
f"{where} — .claude/agents/{expected}.md 가 없다")
def _analysis_counts(
rep: Report, value, where: str, *, require_artifact: bool
) -> dict[str, object] | None:
"""결정론적 command 분석 영수증과 frozen JSON artifact를 함께 검증한다."""
if not isinstance(value, dict):
rep.error("command analysis 영수증이 없다", where)
return None
cmd = str(value.get("cmd") or "")
if "check-command-pedagogy.py" not in cmd:
rep.error("command analysis가 결정론적 검사기를 쓰지 않았다",
f"{where}{cmd or 'cmd 없음'}")
if value.get("exit") not in (0, "0"):
rep.error("command analysis를 끝내지 못했다",
f"{where} — exit {value.get('exit')}")
out: dict[str, object] = {}
for key in ("shellBlocks", "findings", "majorFindings"):
raw = value.get(key)
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:
rep.error("command analysis 수치가 계약 밖이다",
f"{where}{key}={raw!r}")
return None
out[key] = raw
if int(out["majorFindings"]) > int(out["findings"]):
rep.error("major command finding 수가 전체 finding보다 크다", where)
receipt = value.get("artifact")
artifact = (
_command_artifact(rep, receipt, where)
if require_artifact or isinstance(receipt, dict)
else None
)
out["artifact"] = artifact
if artifact is not None:
blocks = artifact.get("blocks")
findings = artifact.get("findings")
if not isinstance(blocks, list) or not isinstance(findings, list):
rep.error("command analysis artifact 구조가 잘못됐다", where)
else:
actual_major = sum(
1 for finding in findings
if isinstance(finding, dict) and finding.get("severity") == "major"
)
expected_counts = (len(blocks), len(findings), actual_major)
receipt_counts = (
int(out["shellBlocks"]), int(out["findings"]), int(out["majorFindings"])
)
if expected_counts != receipt_counts:
rep.error(
"command analysis 영수증과 artifact 수치가 다르다",
f"{where} — receipt={receipt_counts} artifact={expected_counts}",
)
if artifact.get("authority") != "deterministic":
rep.error("command analysis artifact authority가 deterministic이 아니다", where)
return out
def _command_role(
rep: Report,
role,
*,
name: str,
required: bool,
required_error: str,
verdict: bool = False,
artifact_kind: str | None = None,
analysis: dict | None = None,
publication_sha256: str | None = None,
require_artifact: bool = True,
) -> int:
"""planner/editor/reviewer receipt와 first-class artifact를 검사한다."""
where = f"qualityReviews.commandPedagogy.{name}"
if not isinstance(role, dict):
rep.error(required_error if required else "command role 영수증이 없다", where)
return 0
expected = COMMAND_REVIEW_AGENTS[name]
_review_agent(rep, role, expected, where)
status = role.get("status")
if required and status != "DONE":
rep.error(required_error, f"{where} — status={status!r}")
elif not required and status not in ("DONE", "SKIPPED"):
rep.error("선택적 command role의 상태가 끝나지 않았다",
f"{where} — status={status!r}")
if status == "SKIPPED":
if not str(role.get("skipReason") or "").strip():
rep.error("command role을 건너뛴 사유가 없다", where)
return 0
if status != "DONE":
return 0
if role.get("skill") != COMMAND_REVIEW_SKILL:
rep.error("command role이 다른 스킬을 썼다",
f"{where}{role.get('skill')!r} · 계약은 {COMMAND_REVIEW_SKILL!r}")
echo = _norm(role.get("skillEcho") or "")
unverifiable = 0
if not echo:
rep.error("command role에 스킬 영수증이 없다", where)
elif _skill_text(COMMAND_REVIEW_SKILL) is None:
rep.error("command role의 스킬 폴더가 없다", COMMAND_REVIEW_SKILL)
else:
unverifiable += _judge_echo(
rep,
COMMAND_REVIEW_SKILL,
echo,
role.get(REVISION_FIELD),
where,
"품질 검토의 ",
)
if verdict and role.get("verdict") != "PASS":
rep.error("command-pedagogy review가 통과하지 못했다",
f"{where} — verdict={role.get('verdict')!r}")
artifact_receipt = role.get("artifact")
artifact = (
_command_artifact(
rep,
artifact_receipt,
where,
missing_error="command role artifact 영수증이 없다",
)
if require_artifact or isinstance(artifact_receipt, dict)
else None
)
if artifact is not None and artifact_kind == "plan":
try:
validate_command_plan(artifact, analysis=analysis)
except ValueError as exc:
rep.error("CommandPlan artifact가 계약과 다르다", f"{where}{exc}")
elif artifact is not None and artifact_kind == "patch":
try:
validate_command_patch_set(artifact, analysis=analysis)
except ValueError as exc:
rep.error("CommandPatchSet artifact가 계약과 다르다", f"{where}{exc}")
elif artifact is not None and artifact_kind == "review":
if artifact.get("reviewer") != expected or artifact.get("verdict") != role.get("verdict"):
rep.error("command review artifact와 reviewer 영수증이 다르다", where)
if publication_sha256 and artifact.get("source_sha256") != publication_sha256:
rep.error("command review artifact가 최종 publication hash와 다르다", where)
if require_artifact and artifact_kind == "review" and publication_sha256:
if role.get("sourceSha256") != publication_sha256:
rep.error("command review가 최종 publication hash와 다르다", where)
return unverifiable
def _verify_quality_reviews(
rep: Report,
run: dict,
*,
check_current_publication: bool = True,
) -> int:
"""v3의 command-pedagogy + 최종 technical-evidence review 계약을 검사한다.
historical/superseded run도 당시 artifact 자체의 영수증과 stage 계약은 계속 검증한다.
다만 같은 Record에 더 최신 run이 있으면 그 옛 run을 *현재* publication hash와 다시
맞추지는 않는다. 현재 publication 대조는 authoritative latest run 하나가 맡는다.
"""
schema = run.get("schemaVersion")
if not isinstance(schema, int) or schema < QUALITY_REVIEW_SCHEMA:
return 0
reviews = run.get("qualityReviews")
if not isinstance(reviews, dict):
rep.error("품질 검토 원장이 없다", "schemaVersion 3부터 qualityReviews가 필요하다")
return 0
command = reviews.get("commandPedagogy")
if not isinstance(command, dict):
rep.error("command-pedagogy 원장이 없다", "qualityReviews.commandPedagogy")
command = {}
publication_sha = _publication_sha256(run)
artifact_required = schema >= COMMAND_ARTIFACT_SCHEMA
initial = _analysis_counts(
rep, command.get("initialAnalysis"), "command initial analysis",
require_artifact=artifact_required,
)
final = _analysis_counts(
rep, command.get("finalAnalysis"), "command final analysis",
require_artifact=artifact_required,
)
unverifiable = 0
initial_findings = int(initial["findings"]) if initial else 0
final_blocks = int(final["shellBlocks"]) if final else 0
initial_artifact = initial.get("artifact") if initial else None
final_artifact = final.get("artifact") if final else None
if initial and final:
if int(initial["shellBlocks"]) > 0 and int(final["shellBlocks"]) == 0:
rep.error("command repair가 모든 shell block을 없앴다",
f"initial={initial['shellBlocks']} · final=0")
if int(final["majorFindings"]) > 0:
rep.error("major command finding이 남았다",
f"final major findings={final['majorFindings']}")
if artifact_required and publication_sha and isinstance(final_artifact, dict):
if final_artifact.get("source_sha256") != publication_sha:
rep.error("final command analysis가 최종 publication hash와 다르다", "command final analysis")
record = str(run.get("record") or "")
try:
with open(os.path.join(ROOT, record), encoding="utf-8") as fh:
publication_text = fh.read()
mode = str(final_artifact.get("mode") or "operator")
rerun = analyze_commands(
str(final_artifact.get("section_id") or record), publication_text, mode=mode
)
if (
rerun.get("source_sha256") != final_artifact.get("source_sha256")
or len(rerun.get("blocks", [])) != len(final_artifact.get("blocks", []))
or len(rerun.get("findings", [])) != len(final_artifact.get("findings", []))
):
rep.error("final command analysis artifact를 현재 publication에서 재현할 수 없다", record)
except (OSError, ValueError) as exc:
rep.error("final command analysis를 재검증하지 못했다", str(exc))
needs_edit = initial_findings > 0
unverifiable += _command_role(
rep,
command.get("planner"),
name="planner",
required=needs_edit,
required_error="명령 finding이 있는데 planner가 끝나지 않았다",
artifact_kind="plan" if needs_edit else None,
analysis=initial_artifact if isinstance(initial_artifact, dict) else None,
require_artifact=artifact_required,
)
unverifiable += _command_role(
rep,
command.get("editor"),
name="editor",
required=needs_edit,
required_error="명령 finding이 있는데 editor가 끝나지 않았다",
artifact_kind="patch" if needs_edit else None,
analysis=initial_artifact if isinstance(initial_artifact, dict) else None,
require_artifact=artifact_required,
)
unverifiable += _command_role(
rep,
command.get("reviewer"),
name="reviewer",
required=final_blocks > 0,
required_error="shell/CLI가 있는데 command reviewer가 끝나지 않았다",
verdict=True,
artifact_kind="review" if final_blocks > 0 else None,
publication_sha256=publication_sha,
require_artifact=artifact_required,
)
fact = reviews.get("technicalEvidence")
where = "qualityReviews.technicalEvidence"
if not isinstance(fact, dict):
rep.error("technical-evidence review 영수증이 없다", where)
else:
_review_agent(rep, fact, FACT_REVIEW_AGENT, where)
if fact.get("status") != "DONE":
rep.error("technical-evidence review가 끝나지 않았다",
f"{where} — status={fact.get('status')!r}")
if fact.get("verdict") != "PASS":
rep.error("technical-evidence review가 통과하지 못했다",
f"{where} — verdict={fact.get('verdict')!r}")
if artifact_required and publication_sha and fact.get("sourceSha256") != publication_sha:
rep.error("technical-evidence review가 최종 publication hash와 다르다", where)
schema = run.get("schemaVersion")
if isinstance(schema, int) and schema >= EVIDENCE_RECONCILIATION_SCHEMA:
evidence_gates = []
for st in run.get("stages") or []:
if st.get("id") not in EVIDENCE_GATE_STAGES:
continue
evidence_gates.extend(
g for g in (st.get("gates") or [])
if g.get("semanticId") == EVIDENCE_GATE_ID
)
states = {g.get("status") for g in evidence_gates}
expected = "UNVERIFIABLE" if "UNVERIFIABLE" in states else "VERIFIED"
if fact.get("liveSourceReconciliation") != expected:
rep.error("technical-evidence review의 live source 상태가 stage evidence와 다르다",
f"{where} — expected={expected} · got={fact.get('liveSourceReconciliation')!r}")
if expected == "UNVERIFIABLE":
if not str(fact.get("liveSourceReason") or "").strip():
rep.error("technical-evidence review에 live source 대조 불가 이유가 없다", where)
if fact.get("acceptedByProjectReview") is not True:
rep.error("technical-evidence review의 UNVERIFIABLE이 프로젝트 리뷰에서 수용되지 않았다",
where)
identities = []
for name in ("planner", "editor", "reviewer"):
role = command.get(name)
if isinstance(role, dict) and role.get("status") == "DONE":
identities.append(role.get("runBy"))
if isinstance(fact, dict) and fact.get("status") == "DONE":
identities.append(fact.get("runBy"))
present = [identity for identity in identities if identity]
if len(present) != len(set(present)):
rep.error("품질 검토 역할은 독립된 agent여야 한다", " · ".join(map(str, present)))
return unverifiable
def verify(path: str) -> Report:
rel = os.path.relpath(path, ROOT)
rep = Report(rel)
@@ -492,6 +950,7 @@ def verify(path: str) -> Report:
st.get(REVISION_FIELD), where)
gates = st.get("gates") or []
evidence_gate = _evidence_gate_v5(rep, run, sid, gates, where)
cmds = " ; ".join(str(g.get("cmd") or "") for g in gates)
for g in gates:
other = _wrong_target(str(g.get("cmd") or ""), project, known_projects)
@@ -512,6 +971,9 @@ def verify(path: str) -> Report:
rep.error("관문이 빠졌다", f"{where}{token}")
for g in gates:
cmd = str(g.get("cmd") or "")
if evidence_gate is g:
# semantic evidence gate는 위에서 PASS/UNVERIFIABLE 두 상태를 따로 검증했다.
continue
if any(tok in cmd for tok in MEASUREMENT_GATES):
# 측정 관문 — 돌았는지만 본다. exit 칸이 아예 없으면 안 돌린 것이다
if g.get("exit") is None:
@@ -528,6 +990,9 @@ def verify(path: str) -> Report:
# 한 단계를 두 번 돌렸으면 두 번째 것도 같은 잣대로 본다
unverifiable += _side_proof(rep, st, sid, spec, where)
# v3부터는 S3의 command repair와 S6 이후 독립 review도 같은 원장에서 검증한다.
unverifiable += _verify_quality_reviews(rep, run)
rep.facts["stages"] = counts
if unverifiable:
# 「위조가 아니다」와 「맞다」는 다른 말이다. 대조를 못 한 것은 수로 남긴다
+103
View File
@@ -50,6 +50,13 @@ REQUIRED_PATHS = (
".agents/skills/writing-practitioner-guides/references/linux-systemd.md",
".agents/skills/writing-practitioner-guides/references/networking-tls.md",
".agents/skills/writing-practitioner-guides/references/datastores.md",
".agents/skills/writing-practitioner-guides/references/command-pedagogy.md",
".agents/skills/running-tech-log-pipeline/policies/command-authoring.yaml",
".agents/skills/running-tech-log-pipeline/schemas/command-plan.schema.json",
".agents/skills/running-tech-log-pipeline/schemas/command-patch-set.schema.json",
".agents/skills/running-tech-log-pipeline/contracts/command-pedagogy-planner.md",
".agents/skills/running-tech-log-pipeline/contracts/command-pedagogy-editor.md",
".agents/skills/running-tech-log-pipeline/contracts/command-pedagogy-reviewer.md",
# 역할이 나뉜 서브에이전트. 한 세션이 쓰기와 검증을 겸하면 자동 검사가 전부 통과한
# 상태로 사실 오류가 새어 나간다 — 실제로 그렇게 새어 나간 것이 이 저장소에 있었다.
#
@@ -68,6 +75,9 @@ REQUIRED_PATHS = (
".claude/agents/reader-reviewer.md",
".claude/agents/setup-runner.md",
".claude/agents/studio-validator.md",
".claude/agents/command-pedagogy-planner.md",
".claude/agents/command-pedagogy-editor.md",
".claude/agents/command-pedagogy-reviewer.md",
# 프로젝트 폴더 틀 — 끝난 프로젝트의 모양. 작업 재료는 여기 없다
"docs/_templates/README.md",
"docs/_templates/final/document.md",
@@ -91,6 +101,10 @@ REQUIRED_PATHS = (
"scripts/techlog.py",
"scripts/verify-tech-log-tree.py",
"scripts/verify-pipeline-run.py",
"scripts/command_pedagogy.py",
"scripts/check-command-pedagogy.py",
"scripts/apply-command-pedagogy-patch.py",
"scripts/validate-command-pedagogy-artifact.py",
"scripts/check-figure-overlap.py",
"scripts/verify-project-layout.py",
"scripts/fold-analysis-into-final.py",
@@ -375,6 +389,11 @@ def verify_outputs(shared_root: Path) -> list:
except (OSError, subprocess.TimeoutExpired) as exc:
rep.error(f"{name} 을 돌리지 못했다", str(exc)[:120])
continue
if name == "check_evidence --repo" and run.returncode == 3:
rep.facts[name] = "UNVERIFIABLE (exit 3)"
rep.warn(f"{name} 대조 불가",
_last_meaningful_line(run.stdout or run.stderr))
continue
rep.facts[name] = f"exit {run.returncode}"
if run.returncode != 0:
rep.error(f"{name} 이 실패했다",
@@ -383,6 +402,81 @@ def verify_outputs(shared_root: Path) -> list:
return out
def verify_command_corpus(shared_root: Path) -> list:
"""기존 SSOT/기록에도 command-pedagogy analyzer를 읽기 전용으로 적용한다.
새 run 계약만 좋아지고 기존 문서 debt가 영원히 보이지 않는 것을 막는 dogfooding lane이다.
기존 문서를 자동 수정하지 않으며, mode를 확정할 근거가 없는 문서는 reference를 기본으로
읽고 명시적인 command-mode marker가 개별 block의 mode를 덮어쓴다.
"""
analyzer = _load(shared_root, "command_pedagogy.py", "command_pedagogy_corpus")
if analyzer is None:
return []
reports = []
for project_dir in sorted((shared_root / "docs").iterdir()):
if not project_dir.is_dir() or project_dir.name.startswith(("_", ".")):
continue
documents = []
ssot = project_dir / "final" / "document.md"
if ssot.exists():
documents.append(ssot)
studio = project_dir / "tech-log-studio"
if studio.exists():
documents.extend(sorted(studio.rglob("*.md")))
if not documents:
continue
rep = _OutputReport(project_dir.name)
shell_blocks = 0
findings = 0
command_like_text = 0
unclassified = 0
for path in documents:
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
rep.warn("command corpus 문서를 읽지 못했다",
f"{path.relative_to(shared_root)}{exc}")
continue
rel = str(path.relative_to(shared_root))
result = analyzer.analyze_commands(rel, content, mode="reference")
blocks = result.get("blocks") or []
hits = result.get("findings") or []
text_hits = ((result.get("extensions") or {})
.get("command_like_text_blocks") or [])
shell_blocks += len(blocks)
findings += len(hits)
command_like_text += len(text_hits)
unclassified_blocks = [
block for block in blocks if not block.get("mode_explicit")
]
if unclassified_blocks:
unclassified += len(unclassified_blocks)
rep.warn("기존 shell block의 mode가 명시되지 않았다",
f"{rel}{len(unclassified_blocks)} block")
for finding in hits:
rep.warn(
"command-pedagogy finding",
f"{rel}{finding.get('code')}{finding.get('evidence', '')}",
)
for hit in text_hits:
evidence = str(hit.get("evidence") or "").splitlines()
preview = evidence[0][:90] if evidence else ""
rep.warn(
"command처럼 보이는 text fence를 분류해야 한다",
f"{rel}{preview}",
)
rep.facts["shell blocks"] = shell_blocks
rep.facts["findings"] = findings
rep.facts["mode 미분류"] = unclassified
rep.facts["command-like text"] = command_like_text
reports.append(rep)
return reports
def verify_runs(shared_root: Path):
"""`runs/<프로젝트>/<runId>/run.json` 이 절차를 지켰는지 본다.
@@ -449,6 +543,7 @@ def main() -> int:
layouts = [] if args.skip_projects else verify_layouts(args.shared_root)
runs = [] if args.skip_projects else verify_runs(args.shared_root)
outputs = [] if args.skip_projects else verify_outputs(args.shared_root)
command_corpus = [] if args.skip_projects else verify_command_corpus(args.shared_root)
coverage = [] if args.skip_projects else verify_run_coverage(args.shared_root)
project_errors = (sum(r.error_count for r in reports)
+ sum(r.error_count for r in layouts)
@@ -510,6 +605,14 @@ def main() -> int:
for report in outputs:
verifier_render(report, args.samples)
if command_corpus:
command_warns = sum(r.warn_count for r in command_corpus)
print()
print(f"COMMAND PEDAGOGY CORPUS: {'WARN' if command_warns else 'PASS'}"
f" — 프로젝트 {len(command_corpus)} · warn {command_warns}")
for report in command_corpus:
verifier_render(report, args.samples)
if coverage:
total = sum(int(r.facts.get("기록") or 0) for r in coverage)
cov = sum(int(r.facts.get("원장이 덮은 기록") or 0) for r in coverage)
+298 -4
View File
@@ -89,6 +89,273 @@ def _context_sha(path: str) -> str | None:
return hashlib.sha256(canonicalize_document(raw).encode("utf-8")).hexdigest()
TECHVIZ_MANAGED_BLOCK_RE = re.compile(
r"<!-- techviz:begin id=(?P<id>[^\s]+)[^>]*-->.*?"
r"<!-- techviz:end id=(?P=id) -->",
re.DOTALL,
)
def _collapse_techviz_blocks(text: str) -> str:
"""외부 techviz 도구 없이도 context snapshot을 비교할 수 있게 관리 블록을 접는다."""
def replace_block(match):
block = match.group(0)
generated = re.search(r"<!-- techviz:generate id=[^>]+ -->", block)
if generated:
return generated.group(0)
return f"<!-- techviz:generate id={match.group('id')} -->"
return TECHVIZ_MANAGED_BLOCK_RE.sub(replace_block, text)
def _parse_canonical_headings(lines: list[str]) -> list[dict]:
"""TechViz parse_headings와 같은 규칙으로 fence 밖 heading만 읽는다."""
headings: list[dict] = []
in_fence = False
fence_token = ""
for index, line in enumerate(lines, start=1):
stripped = line.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
token = stripped[:3]
if not in_fence:
in_fence = True
fence_token = token
elif token == fence_token:
in_fence = False
fence_token = ""
continue
if in_fence:
continue
match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
if match:
headings.append({
"line": index,
"level": len(match.group(1)),
"text": match.group(2).strip(),
})
return headings
def _find_heading_line(headings: list[dict], heading_text: str) -> int | None:
"""TechViz처럼 유일한 exact/casefold heading만 anchor로 인정한다."""
exact = [item for item in headings if item["text"] == heading_text]
if len(exact) == 1:
return int(exact[0]["line"])
if len(exact) > 1:
return None
folded = [
item for item in headings
if item["text"].casefold() == heading_text.casefold()
]
return int(folded[0]["line"]) if len(folded) == 1 else None
def _section_for_line(lines: list[str], headings: list[dict],
line_number: int) -> dict | None:
if line_number < 1 or line_number > max(1, len(lines)):
return None
current = None
for heading in headings:
if heading["line"] <= line_number:
current = heading
else:
break
start_line = int(current["line"]) if current else 1
end_line = len(lines)
if current:
for heading in headings:
if heading["line"] > current["line"] and heading["level"] <= current["level"]:
end_line = int(heading["line"]) - 1
break
elif headings:
end_line = int(headings[0]["line"]) - 1
return {
"heading": current,
"start_line": start_line,
"end_line": end_line,
"text": "\n".join(lines[start_line - 1:end_line]),
}
def _sibling_sections(lines: list[str], headings: list[dict],
current: dict) -> tuple[dict | None, dict | None]:
"""TechViz sibling_sections의 parent-preamble 규칙까지 그대로 재현한다."""
current_heading = current.get("heading")
if current_heading is None:
following = (
_section_for_line(lines, headings, int(headings[0]["line"]))
if headings else None
)
return None, following
same_or_higher = [
item for item in headings
if item["level"] <= current_heading["level"]
]
current_index = next(
(
index for index, item in enumerate(same_or_higher)
if item["line"] == current_heading["line"]
),
None,
)
if current_index is None:
return None, None
previous = None
following = None
if current_index > 0:
previous_heading = same_or_higher[current_index - 1]
previous = _section_for_line(lines, headings, int(previous_heading["line"]))
if previous_heading["level"] < current_heading["level"]:
previous = {
"heading": previous_heading,
"start_line": int(previous_heading["line"]),
"end_line": int(current_heading["line"]) - 1,
"text": "\n".join(
lines[
int(previous_heading["line"]) - 1:
int(current_heading["line"]) - 1
]
),
}
if current_index + 1 < len(same_or_higher):
following = _section_for_line(
lines,
headings,
int(same_or_higher[current_index + 1]["line"]),
)
return previous, following
def _context_anchor_line(lines: list[str], headings: list[dict],
context: dict) -> int | None:
anchor = context.get("anchor") or {}
kind = anchor.get("kind")
value = anchor.get("value")
if kind is None:
# 초기 context snapshot에는 anchor가 없었다. 그 형식도 current heading이
# 유일하면 같은 구조로 재구성할 수 있어야 historical drift를 계속 잡는다.
saved_current = context.get("current_section") or {}
saved_heading = (
saved_current.get("heading")
if isinstance(saved_current, dict) else None
)
title = (
saved_heading.get("text")
if isinstance(saved_heading, dict) else None
)
if isinstance(title, str):
return _find_heading_line(headings, title)
return None
if kind == "heading" and isinstance(value, str):
return _find_heading_line(headings, value)
if kind == "marker" and isinstance(value, str):
marker = re.compile(
rf"<!--\s*techviz:generate\s+id={re.escape(value)}(?:\s+[^>]*)?-->"
)
for index, line in enumerate(lines, start=1):
if marker.search(line):
return index
return None
if kind == "line":
saved_current = context.get("current_section") or {}
saved_heading = (
saved_current.get("heading")
if isinstance(saved_current, dict) else None
)
title = (
saved_heading.get("text")
if isinstance(saved_heading, dict) else None
)
if isinstance(title, str):
found = _find_heading_line(headings, title)
if found is not None:
return found
try:
line_number = int(value)
except (TypeError, ValueError):
return None
return (
line_number
if 1 <= line_number <= max(1, len(lines))
else None
)
return None
def _same_section_snapshot(saved: object, current: object) -> bool:
if saved is None or current is None:
return saved is None and current is None
if not isinstance(saved, dict) or not isinstance(current, dict):
return False
if not isinstance(saved.get("text"), str) or not isinstance(current.get("text"), str):
return False
saved_heading = saved.get("heading")
current_heading = current.get("heading")
if saved_heading is None or current_heading is None:
if saved_heading is not None or current_heading is not None:
return False
elif not isinstance(saved_heading, dict) or not isinstance(current_heading, dict):
return False
else:
if ("level" in saved_heading and
saved_heading.get("level") != current_heading.get("level")):
return False
if ("text" in saved_heading and
saved_heading.get("text") != current_heading.get("text")):
return False
return saved["text"].rstrip() == current["text"].rstrip()
def _context_snapshot_matches(document_path: str, context_path: str) -> bool | None:
"""외부 TechViz 없이도 build_context의 neighboring-section 의미로 snapshot을 대조한다."""
try:
with open(document_path, encoding="utf-8") as handle:
document = handle.read()
with open(context_path, encoding="utf-8") as handle:
context = json.load(handle)
except (OSError, ValueError):
return None
lines = _collapse_techviz_blocks(document).splitlines()
headings = _parse_canonical_headings(lines)
anchor_line = _context_anchor_line(lines, headings, context)
if anchor_line is None:
return None
current = _section_for_line(lines, headings, anchor_line)
if current is None:
return None
previous, following = _sibling_sections(lines, headings, current)
rebuilt = {
"previous_section": previous,
"current_section": current,
"next_section": following,
}
compared = 0
for key in ("previous_section", "current_section", "next_section"):
if key not in context:
continue
compared += 1
if not _same_section_snapshot(context.get(key), rebuilt.get(key)):
return False
return True if compared else None
def verify(project: str) -> Report:
rep = Report(project)
base = os.path.join(ROOT, "docs", project)
@@ -122,8 +389,25 @@ def verify(project: str) -> Report:
imported = os.path.join(base, IMPORT_MATERIAL)
if os.path.isdir(imported):
n = sum(1 for _ in glob.iglob(os.path.join(imported, "**", "*"), recursive=True))
rep.warn("반입 원본이 남아 있다",
f"source/ {n}개 — final/ 이 그 내용을 담고 있으면 사본이다")
durable_snapshot = False
index_path = os.path.join(studio, "tech-log-tree.json")
try:
with open(index_path, encoding="utf-8") as handle:
index = json.load(handle)
policy = index.get("sourcePolicy") or {}
durable_snapshot = (
isinstance(policy, dict)
and policy.get("mode") == "DURABLE_IMPORT_SNAPSHOT"
)
except (OSError, ValueError):
durable_snapshot = False
if durable_snapshot:
rep.facts["source"] = (
f"durable import snapshot · source/ {n}개 — exact commit 없는 반입 바이트 보존"
)
else:
rep.warn("반입 원본이 남아 있다",
f"source/ {n}개 — final/ 이 그 내용을 담고 있으면 사본이다")
# ── 증거 ───────────────────────────────────────────────────────
evidence = os.path.join(final, "evidence")
@@ -199,8 +483,18 @@ def verify(project: str) -> Report:
rep.warn("SSOT 가 바뀐 뒤 그림을 다시 보지 않았다",
f"final/.techviz/{name}")
elif ssot_sha is None and ctx.get("document_sha256"):
rep.warn("그림이 어느 SSOT 를 보고 만들어졌는지 대조하지 못했다",
f"final/.techviz/{name} — techviz 도구가 없다")
context_path = os.path.join(techviz, name, "context.json")
snapshot_match = _context_snapshot_matches(
os.path.join(final, "document.md"), context_path)
if snapshot_match is False:
rep.warn("SSOT 문맥이 바뀐 뒤 그림을 다시 보지 않았다",
f"final/.techviz/{name} — techviz 도구 없이 context snapshot으로 확인")
elif snapshot_match is True:
rep.warn("그림 전체 SSOT hash를 대조하지 못했다",
f"final/.techviz/{name} — techviz 도구가 없다; context snapshot은 일치")
else:
rep.warn("그림이 어느 SSOT 를 보고 만들어졌는지 대조하지 못했다",
f"final/.techviz/{name} — techviz 도구가 없고 context snapshot도 없다")
nodes = spec.get("nodes") or []
if spec.get("edges") or len(nodes) < 2:
+33 -11
View File
@@ -55,7 +55,7 @@ REQUIRED_FIELDS = {
}
# 글을 써도 되는 readiness. 나머지는 글감으로만 남는다
GENERATABLE = {"case": {"READY"}, "concept": {"READY"}, "reference": {"READY"},
"question": {"OPEN"}, "decision": {"READY"}, "setup": {"READY"}}
"question": {"OPEN", "RESOLVED"}, "decision": {"READY"}, "setup": {"READY"}}
# 표를 손으로 채우다 종류를 빠뜨리면 그 종류의 글감은 **아무 칸도 요구받지 않는다** —
# 조용히 통과한다. 빠진 것이 있으면 import 할 때 걸리게 둔다
@@ -128,6 +128,16 @@ def _headings(path: str) -> list[tuple[int, str, str]]:
return out
def _numbered_heading_anchors(heads: list[tuple[int, str, str]]) -> set[str]:
"""제목 앞의 절 번호를 section marker 앵커 집합으로 만든다."""
out: set[str] = set()
for _, title, _ in heads:
match = re.match(r"^((?:\d+|[A-Za-z])(?:\.\d+)*)\b", title)
if match:
out.add(f"§{match.group(1)}")
return out
def _anchor_base(anchor: str, slugs: list[str]) -> str | None:
"""앵커가 어느 절 슬러그로 시작하는가. 가장 긴 것을 고른다.
@@ -318,8 +328,8 @@ def verify(project: str) -> Report:
readiness = str(node.get("readiness") or "").upper()
if readiness and readiness not in READINESS:
rep.error("readiness 값이 계약에 없다", f"{where}{readiness}")
if kind == "question" and readiness and readiness != "OPEN":
rep.error("OPEN QUESTION readiness 는 OPEN ", f"{where}{readiness}")
if kind == "question" and readiness and readiness not in {"OPEN", "RESOLVED"}:
rep.error("QUESTION readiness 는 OPEN 또는 RESOLVED ", f"{where}{readiness}")
if kind == "decision":
status = str(node.get("decision-status") or "").strip("`").upper()
if status and status not in DECISION_STATUS:
@@ -414,18 +424,21 @@ def verify(project: str) -> Report:
# 가리키지 않는 앵커가 그대로 통과했다
heads = _headings(ssot_path) if os.path.exists(ssot_path) else []
head_slugs = [h[2] for h in heads]
numbered_anchors = _numbered_heading_anchors(heads)
if heads and has_contract:
# 앵커 형식은 프로젝트마다 다르다 — 절 제목 슬러그를 쓰는 곳도 있고
# `§1.1`·`10-2`·`a18` 처럼 번호나 마커를 쓰는 곳도 있다. 형식을 강요하지 않고,
# 슬러그를 쓰는 프로젝트에서만 실재를 대조한다
# 앵커 형식은 프로젝트마다 다르다 — 절 제목 슬러그와 §1.1 같은 번호 형식은
# 둘 다 실재 여부를 대조한다. 그 밖의 사용자 정의 marker만 검증 불가 경고로 남긴다.
seen_anchors: list[tuple[str, str]] = []
for where, refs in _all_anchors(index):
for ref in refs:
if "#" in ref:
seen_anchors.append((where, ref.split("#", 1)[1]))
anchor = ref.split("#", 1)[1].split()[0].strip("`")
seen_anchors.append((where, anchor))
resolved = [(w, a, _anchor_base(a, head_slugs)) for w, a in seen_anchors]
hit = sum(1 for _, _, b in resolved if b)
slug_style = seen_anchors and hit * 2 >= len(seen_anchors)
slug_hits = sum(1 for _, _, base in resolved if base)
number_hits = sum(1 for _, anchor in seen_anchors if anchor in numbered_anchors)
slug_style = bool(seen_anchors) and slug_hits * 2 >= len(seen_anchors)
number_style = bool(seen_anchors) and number_hits * 2 >= len(seen_anchors)
pointed: list[tuple[str, str]] = []
if slug_style:
@@ -435,9 +448,18 @@ def verify(project: str) -> Report:
f"{where} — #{anchor_slug}")
else:
pointed.append((base_slug, anchor_slug[len(base_slug):].lstrip("-")))
elif number_style:
for where, anchor in seen_anchors:
if anchor.startswith("§") and anchor not in numbered_anchors:
rep.error("SSOT 에 없는 번호 절을 가리키는 앵커",
f"{where} — #{anchor}")
for section in scope.get("sections") or []:
if str(section).startswith("§") and str(section) not in numbered_anchors:
rep.error("candidateScope 가 SSOT 에 없는 번호 절을 가리킨다",
str(section))
elif seen_anchors:
rep.warn("앵커가 절 제목이 아니라 번호·마커",
f"{len(seen_anchors)}건 — 검사기가 그 절이 실재하는지 대조하지 못한다")
rep.warn("앵커가 검사 가능한 절 제목/번호 형식이 아니",
f"{len(seen_anchors)}건 — 사용자 정의 marker는 실재 여부를 대조하지 못한다")
# 범위 안의 절을 후보 대장이 하나도 안 짚었나.
# 검사기는 「후보 ↔ 글감」만 봐서 SSOT 재료를 통째로 지나쳐도 error 가 0 이었다.