refactor: 문서 개선 중
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user