"""명령어 교육성 분석기 회귀 시험. 정확한 명령을 금지하는 검사가 아니라, 사람이 따라 하기 어려운 압축 표현을 후속 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 = ''' ```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 = '''일반 참고 문서다. ```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 = ''' ```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 = ''' ```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()