refactor: 문서 개선 중
This commit is contained in:
Executable
+48
@@ -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())
|
||||
Executable
+46
@@ -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())
|
||||
@@ -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} 은 그대로다")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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:
|
||||
# 「위조가 아니다」와 「맞다」는 다른 말이다. 대조를 못 한 것은 수로 남긴다
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 이었다.
|
||||
|
||||
Reference in New Issue
Block a user