1"""Tests for the semantic spec diff parser, diff function, and agent tool."""23import json4import sys5from pathlib import Path67import pytest89# Make the repo root importable10_repo_root = Path(__file__).resolve().parent.parent11sys.path.insert(0, str(_repo_root))1213import spec_parser14import tools151617# ─── Parser tests ───────────────────────────────────────────────────────181920SAMPLE_SPEC = """# Agent Tools Specification2122## Purpose2324Tools for agents to interact with OpenSpec.2526## Requirements2728### Requirement: Context resolution29Resolve identifiers into repo paths.3031#### Scenario: Resolve a source name32- **GIVEN** a source is registered33- **WHEN** an agent calls context34- **THEN** the tool returns the repo path3536#### Scenario: Resolve a change token37- **GIVEN** a change exists38- **WHEN** an agent calls context with a token39- **THEN** the tool returns the change content4041### Requirement: Validation42Validate artifacts.4344#### Scenario: Validate a change45- **WHEN** an agent calls validate46- **THEN** the tool returns validation errors47"""484950def test_parse_spec_well_formed():51 result = spec_parser.parse_spec(SAMPLE_SPEC)52 assert result["title"] == "Agent Tools"53 assert "Tools for agents" in result["purpose"]54 assert len(result["requirements"]) == 25556 req1 = result["requirements"][0]57 assert req1["name"] == "Context resolution"58 assert "Resolve identifiers" in req1["description"]59 assert len(req1["scenarios"]) == 26061 scn1 = req1["scenarios"][0]62 assert scn1["name"] == "Resolve a source name"63 assert len(scn1["steps"]) == 364 assert scn1["steps"][0]["type"] == "GIVEN"65 assert scn1["steps"][0]["text"] == "a source is registered"666768def test_parse_spec_no_requirements():69 md = "# Title\n\n## Purpose\n\nSome purpose text.\n"70 result = spec_parser.parse_spec(md)71 assert result["title"] == "Title"72 assert result["purpose"] == "Some purpose text."73 assert result["requirements"] == []747576def test_parse_spec_empty():77 result = spec_parser.parse_spec("")78 assert result == {"title": "", "purpose": "", "requirements": []}798081def test_parse_spec_none():82 result = spec_parser.parse_spec(None)83 assert result == {"title": "", "purpose": "", "requirements": []}848586def test_parse_spec_fallback_no_requirements():87 md = "# Title\n\nSome text without requirement headers.\n"88 result = spec_parser.parse_spec(md)89 assert result["title"] == "Title"90 assert result["requirements"] == []919293# ─── Semantic diff tests ────────────────────────────────────────────────949596def test_diff_added_requirement():97 before = "# S\n## Purpose\nP\n"98 after = "# S\n## Purpose\nP\n### Requirement: New Req\nDesc.\n"99 diff = spec_parser.semantic_spec_diff(before, after)100 assert diff["status"] == "modified"101 assert len(diff["requirements"]["added"]) == 1102 assert diff["requirements"]["added"][0]["name"] == "New Req"103104105def test_diff_removed_requirement():106 before = "# S\n### Requirement: Old Req\nDesc.\n"107 after = "# S\n"108 diff = spec_parser.semantic_spec_diff(before, after)109 assert diff["status"] == "modified"110 assert len(diff["requirements"]["removed"]) == 1111 assert diff["requirements"]["removed"][0]["name"] == "Old Req"112113114def test_diff_modified_requirement_description():115 before = "# S\n### Requirement: Req A\nOld description.\n"116 after = "# S\n### Requirement: Req A\nNew description.\n"117 diff = spec_parser.semantic_spec_diff(before, after)118 assert diff["status"] == "modified"119 assert len(diff["requirements"]["modified"]) == 1120 mod = diff["requirements"]["modified"][0]121 assert mod["name"] == "Req A"122 assert mod["before"]["description"] == "Old description."123 assert mod["after"]["description"] == "New description."124125126def test_diff_scenario_level_delta():127 before = """# S128### Requirement: Req A129Description.130131#### Scenario: Scenario 1132- **GIVEN** something133- **THEN** result134135#### Scenario: Scenario 2136- **WHEN** action137- **THEN** result2138"""139 after = """# S140### Requirement: Req A141Description.142143#### Scenario: Scenario 1144- **GIVEN** something else145- **THEN** result146147#### Scenario: Scenario 3148- **WHEN** new action149- **THEN** new result150"""151 diff = spec_parser.semantic_spec_diff(before, after)152 assert diff["status"] == "modified"153 mod = diff["requirements"]["modified"][0]154 assert len(mod["scenarios_added"]) == 1155 assert mod["scenarios_added"][0]["name"] == "Scenario 3"156 assert len(mod["scenarios_modified"]) == 1157 assert mod["scenarios_modified"][0]["name"] == "Scenario 1"158 assert len(mod["scenarios_removed"]) == 1159 assert mod["scenarios_removed"][0]["name"] == "Scenario 2"160161162def test_diff_unchanged_requirement():163 before = "# S\n### Requirement: Req A\nSame desc.\n"164 after = "# S\n### Requirement: Req A\nSame desc.\n"165 diff = spec_parser.semantic_spec_diff(before, after)166 assert diff["status"] == "unchanged"167 assert "Req A" in diff["requirements"]["unchanged"]168169170def test_diff_no_baseline():171 before = None172 after = "# S\n### Requirement: Req A\nDesc.\n"173 diff = spec_parser.semantic_spec_diff(before, after)174 assert diff["status"] == "added"175 assert len(diff["requirements"]["added"]) == 1176177178def test_diff_deleted_spec():179 before = "# S\n### Requirement: Req A\nDesc.\n"180 after = None181 diff = spec_parser.semantic_spec_diff(before, after)182 assert diff["status"] == "deleted"183 assert len(diff["requirements"]["removed"]) == 1184185186def test_semantic_summary():187 diff = {188 "requirements": {189 "added": [{"name": "A"}, {"name": "B"}],190 "modified": [{"name": "C"}],191 "removed": [],192 "unchanged": ["D"],193 }194 }195 summary = spec_parser.semantic_summary(diff)196 assert summary == {"added": 2, "modified": 1, "removed": 0}197198199# ─── Tool handler tests ─────────────────────────────────────────────────200201202203def tmp_project(tmp_path):204 """Create a minimal OpenSpec project with a baseline spec and a change."""205 openspec = tmp_path / "openspec"206 openspec.mkdir()207208 # Baseline spec209 baseline = openspec / "specs" / "agent-tools" / "spec.md"210 baseline.parent.mkdir(parents=True)211 baseline.write_text("""# Agent Tools Specification212213## Purpose214215Tools for agents.216217## Requirements218219### Requirement: Context resolution220Resolve identifiers.221222#### Scenario: Resolve source223- **GIVEN** a source224- **WHEN** called225- **THEN** returns path226227### Requirement: Validation228Validate artifacts.229""")230231 # Change spec (adds a requirement, modifies one)232 change_spec = openspec / "changes" / "test-change" / "specs" / "agent-tools" / "spec.md"233 change_spec.parent.mkdir(parents=True)234 change_spec.write_text("""# Agent Tools Specification235236## Purpose237238Tools for agents.239240## Requirements241242### Requirement: Context resolution243Resolve identifiers into repo paths and workdir hints.244245#### Scenario: Resolve source246- **GIVEN** a source247- **WHEN** called248- **THEN** returns path249250### Requirement: Semantic diff251Compare specs at the requirement level.252""")253254 # Minimal proposal so the change is valid255 (openspec / "changes" / "test-change" / "proposal.md").write_text("## Why\nTest.\n")256257 return tmp_path258259260def test_tool_diff_change_vs_baseline(tmp_project):261 result = json.loads(tools.openspec_spec_diff({262 "workdir": str(tmp_project),263 "spec": "agent-tools",264 "change": "test-change",265 }))266 assert result["ok"] is True267 assert result["status"] == "modified"268 assert result["baseline_exists"] is True269 reqs = result["requirements"]270 assert len(reqs["added"]) == 1271 assert reqs["added"][0]["name"] == "Semantic diff"272 assert len(reqs["removed"]) == 1273 assert reqs["removed"][0]["name"] == "Validation"274 assert len(reqs["modified"]) == 1275 assert reqs["modified"][0]["name"] == "Context resolution"276 assert "line_diff" in result277 assert result["line_diff"] # non-empty278279280def test_tool_new_spec_no_baseline(tmp_project):281 # Create a change with a spec that has no baseline282 change_spec = tmp_project / "openspec" / "changes" / "test-change" / "specs" / "new-spec" / "spec.md"283 change_spec.parent.mkdir(parents=True)284 change_spec.write_text("# New Spec\n### Requirement: New Req\nDesc.\n")285286 result = json.loads(tools.openspec_spec_diff({287 "workdir": str(tmp_project),288 "spec": "new-spec",289 "change": "test-change",290 }))291 assert result["ok"] is True292 assert result["status"] == "added"293 assert result["baseline_exists"] is False294 assert len(result["requirements"]["added"]) == 1295296297def test_tool_missing_change_spec(tmp_project):298 result = json.loads(tools.openspec_spec_diff({299 "workdir": str(tmp_project),300 "spec": "nonexistent",301 "change": "test-change",302 }))303 assert result["ok"] is False304 assert "not found" in result["error"]305306307def test_tool_missing_spec_no_change(tmp_project):308 result = json.loads(tools.openspec_spec_diff({309 "workdir": str(tmp_project),310 "spec": "nonexistent",311 }))312 assert result["ok"] is False313 assert "not found" in result["error"]314315316def test_tool_path_traversal(tmp_project):317 result = json.loads(tools.openspec_spec_diff({318 "workdir": str(tmp_project),319 "spec": "../../../etc/passwd",320 "change": "test-change",321 }))322 assert result["ok"] is False323 assert "parent references" in result["error"]324325326def test_tool_no_spec(tmp_project):327 result = json.loads(tools.openspec_spec_diff({328 "workdir": str(tmp_project),329 }))330 assert result["ok"] is False331 assert "spec is required" in result["error"]