1"""Tests for spec-first-class tools: create, show, list, and serializer round-trip."""23import json4import sys5from pathlib import Path67import pytest89# Ensure the repo root is importable10sys.path.insert(0, str(Path(__file__).resolve().parent.parent))1112import tools13import spec_parser141516# ---------------------------------------------------------------------------17# spec_to_markdown serializer18# ---------------------------------------------------------------------------1920def test_serializer_produces_valid_spec_markdown():21 requirements = [22 {23 "name": "Create spec",24 "description": "The system SHALL create a spec.",25 "scenarios": [26 {27 "name": "Happy path",28 "steps": [29 {"type": "GIVEN", "text": "a project"},30 {"type": "WHEN", "text": "an agent creates a spec"},31 {"type": "THEN", "text": "the spec is written"},32 ],33 },34 ],35 },36 ]37 md = spec_parser.spec_to_markdown("Test Spec", "Test purpose", requirements)38 assert "# Test Spec" in md39 assert "## Purpose" in md40 assert "Test purpose" in md41 assert "## Requirements" in md42 assert "### Requirement: Create spec" in md43 assert "The system SHALL create a spec." in md44 assert "#### Scenario: Happy path" in md45 assert "- **GIVEN** a project" in md46 assert "- **WHEN** an agent creates a spec" in md47 assert "- **THEN** the spec is written" in md484950def test_serializer_round_trip_with_parse_spec():51 """spec_to_markdown output must be parseable by parse_spec back to equivalent structure."""52 original = [53 {54 "name": "Round trip",55 "description": "Description here.",56 "scenarios": [57 {58 "name": "Scenario A",59 "steps": [60 {"type": "GIVEN", "text": "context"},61 {"type": "WHEN", "text": "action"},62 {"type": "THEN", "text": "result"},63 ],64 },65 ],66 },67 ]68 md = spec_parser.spec_to_markdown("Round Trip Spec", "Purpose statement", original)69 parsed = spec_parser.parse_spec(md)7071 assert parsed["title"] == "Round Trip Spec"72 assert parsed["purpose"] == "Purpose statement"73 assert len(parsed["requirements"]) == 174 req = parsed["requirements"][0]75 assert req["name"] == "Round trip"76 assert req["description"] == "Description here."77 assert len(req["scenarios"]) == 178 scn = req["scenarios"][0]79 assert scn["name"] == "Scenario A"80 assert len(scn["steps"]) == 381 assert scn["steps"][0]["type"] == "GIVEN"82 assert scn["steps"][0]["text"] == "context"838485def test_serializer_handles_empty_requirements():86 md = spec_parser.spec_to_markdown("Empty", "No reqs", [])87 assert "# Empty" in md88 assert "## Purpose" in md89 assert "## Requirements" not in md909192def test_serializer_handles_no_purpose():93 md = spec_parser.spec_to_markdown("No Purpose", "", [{"name": "R", "description": "D", "scenarios": []}])94 assert "# No Purpose" in md95 assert "## Purpose" not in md96 assert "### Requirement: R" in md979899# ---------------------------------------------------------------------------100# openspec_spec_create101# ---------------------------------------------------------------------------102103def _make_project(tmp_path: Path) -> Path:104 """Create a minimal openspec project structure."""105 root = tmp_path / "project"106 (root / "openspec" / "specs").mkdir(parents=True)107 (root / "openspec" / "changes").mkdir(parents=True)108 return root109110111def _sample_requirements():112 return [113 {114 "name": "Create spec",115 "description": "The system SHALL create a spec.",116 "scenarios": [117 {118 "name": "Happy path",119 "steps": [120 {"type": "GIVEN", "text": "a project"},121 {"type": "WHEN", "text": "an agent creates a spec"},122 {"type": "THEN", "text": "the spec is written"},123 ],124 },125 ],126 },127 ]128129130def test_spec_create_writes_baseline_spec(tmp_path):131 root = _make_project(tmp_path)132 result = json.loads(tools.openspec_spec_create({133 "workdir": str(root),134 "spec": "my-spec",135 "title": "My Spec",136 "purpose": "Test purpose",137 "requirements": _sample_requirements(),138 }))139 assert result["ok"] is True140 assert result["spec"] == "my-spec"141 assert result["change"] is None142 assert result["path"] == "openspec/specs/my-spec/spec.md"143 spec_file = root / "openspec" / "specs" / "my-spec" / "spec.md"144 assert spec_file.is_file()145 content = spec_file.read_text()146 assert "# My Spec" in content147 assert "### Requirement: Create spec" in content148149150def test_spec_create_writes_change_scoped_spec(tmp_path):151 root = _make_project(tmp_path)152 change_dir = root / "openspec" / "changes" / "my-change"153 change_dir.mkdir(parents=True)154 (change_dir / "proposal.md").write_text("## Why\nTest")155156 result = json.loads(tools.openspec_spec_create({157 "workdir": str(root),158 "spec": "feature-spec",159 "change": "my-change",160 "title": "Feature Spec",161 "purpose": "Feature purpose",162 "requirements": _sample_requirements(),163 }))164 assert result["ok"] is True165 assert result["change"] == "my-change"166 assert result["path"] == "openspec/changes/my-change/specs/feature-spec/spec.md"167 spec_file = change_dir / "specs" / "feature-spec" / "spec.md"168 assert spec_file.is_file()169170171def test_spec_create_refuses_overwrite(tmp_path):172 root = _make_project(tmp_path)173 # First create succeeds174 r1 = json.loads(tools.openspec_spec_create({175 "workdir": str(root),176 "spec": "existing",177 "title": "First",178 "purpose": "P",179 "requirements": _sample_requirements(),180 }))181 assert r1["ok"] is True182183 # Second create refuses without force184 r2 = json.loads(tools.openspec_spec_create({185 "workdir": str(root),186 "spec": "existing",187 "title": "Second",188 "purpose": "P2",189 "requirements": _sample_requirements(),190 }))191 assert r2["ok"] is False192 assert "already exists" in r2["error"]193 # Original content preserved194 content = (root / "openspec" / "specs" / "existing" / "spec.md").read_text()195 assert "# First" in content196197198def test_spec_create_force_overwrites(tmp_path):199 root = _make_project(tmp_path)200 tools.openspec_spec_create({201 "workdir": str(root),202 "spec": "overwritable",203 "title": "Original",204 "purpose": "P",205 "requirements": _sample_requirements(),206 })207 r2 = json.loads(tools.openspec_spec_create({208 "workdir": str(root),209 "spec": "overwritable",210 "title": "Replaced",211 "purpose": "P2",212 "requirements": _sample_requirements(),213 "force": True,214 }))215 assert r2["ok"] is True216 content = (root / "openspec" / "specs" / "overwritable" / "spec.md").read_text()217 assert "# Replaced" in content218219220def test_spec_create_invalid_slug(tmp_path):221 root = _make_project(tmp_path)222 for bad in ["../escape", "UPPER CASE", "has_underscore", "with/slash"]:223 r = json.loads(tools.openspec_spec_create({224 "workdir": str(root),225 "spec": bad,226 "title": "T",227 "purpose": "P",228 "requirements": _sample_requirements(),229 }))230 assert r["ok"] is False, f"expected failure for slug: {bad}"231 assert "kebab-case" in r["error"] or "parent references" in r["error"]232233234def test_spec_create_missing_title(tmp_path):235 root = _make_project(tmp_path)236 r = json.loads(tools.openspec_spec_create({237 "workdir": str(root),238 "spec": "test",239 "purpose": "P",240 "requirements": _sample_requirements(),241 }))242 assert r["ok"] is False243 assert "title" in r["error"]244245246def test_spec_create_missing_purpose(tmp_path):247 root = _make_project(tmp_path)248 r = json.loads(tools.openspec_spec_create({249 "workdir": str(root),250 "spec": "test",251 "title": "T",252 "requirements": _sample_requirements(),253 }))254 assert r["ok"] is False255 assert "purpose" in r["error"]256257258def test_spec_create_empty_requirements(tmp_path):259 root = _make_project(tmp_path)260 r = json.loads(tools.openspec_spec_create({261 "workdir": str(root),262 "spec": "test",263 "title": "T",264 "purpose": "P",265 "requirements": [],266 }))267 assert r["ok"] is False268 assert "requirements" in r["error"]269270271# ---------------------------------------------------------------------------272# openspec_spec_show273# ---------------------------------------------------------------------------274275def test_spec_show_returns_structured_json(tmp_path):276 root = _make_project(tmp_path)277 # Create a spec first278 tools.openspec_spec_create({279 "workdir": str(root),280 "spec": "showable",281 "title": "Showable Spec",282 "purpose": "Show purpose",283 "requirements": _sample_requirements(),284 })285286 result = json.loads(tools.openspec_spec_show({287 "workdir": str(root),288 "spec": "showable",289 }))290 assert result["ok"] is True291 assert result["title"] == "Showable Spec"292 assert result["purpose"] == "Show purpose"293 assert len(result["requirements"]) == 1294 req = result["requirements"][0]295 assert req["name"] == "Create spec"296 assert len(req["scenarios"]) == 1297 assert req["scenarios"][0]["name"] == "Happy path"298 assert len(req["scenarios"][0]["steps"]) == 3299300301def test_spec_show_change_scoped(tmp_path):302 root = _make_project(tmp_path)303 change_dir = root / "openspec" / "changes" / "feat"304 change_dir.mkdir(parents=True)305 (change_dir / "proposal.md").write_text("## Why\nTest")306307 tools.openspec_spec_create({308 "workdir": str(root),309 "spec": "delta-spec",310 "change": "feat",311 "title": "Delta",312 "purpose": "Delta purpose",313 "requirements": _sample_requirements(),314 })315316 result = json.loads(tools.openspec_spec_show({317 "workdir": str(root),318 "spec": "delta-spec",319 "change": "feat",320 }))321 assert result["ok"] is True322 assert result["change"] == "feat"323 assert result["title"] == "Delta"324325326def test_spec_show_missing_spec(tmp_path):327 root = _make_project(tmp_path)328 result = json.loads(tools.openspec_spec_show({329 "workdir": str(root),330 "spec": "nonexistent",331 }))332 assert result["ok"] is False333 assert "not found" in result["error"]334335336def test_spec_show_round_trip_with_real_spec(tmp_path):337 """Create a spec with structured input, then show it — the round-trip must match."""338 root = _make_project(tmp_path)339 original_reqs = [340 {341 "name": "Round Trip Req",342 "description": "Description text.",343 "scenarios": [344 {345 "name": "Scenario One",346 "steps": [347 {"type": "GIVEN", "text": "a context"},348 {"type": "WHEN", "text": "an action"},349 {"type": "THEN", "text": "a result"},350 ],351 },352 ],353 },354 ]355 tools.openspec_spec_create({356 "workdir": str(root),357 "spec": "round-trip",358 "title": "Round Trip Spec",359 "purpose": "Round trip purpose",360 "requirements": original_reqs,361 })362363 shown = json.loads(tools.openspec_spec_show({364 "workdir": str(root),365 "spec": "round-trip",366 }))367 assert shown["ok"] is True368 assert shown["title"] == "Round Trip Spec"369 assert shown["purpose"] == "Round trip purpose"370 req = shown["requirements"][0]371 assert req["name"] == "Round Trip Req"372 assert req["description"] == "Description text."373 scn = req["scenarios"][0]374 assert scn["name"] == "Scenario One"375 assert [s["type"] for s in scn["steps"]] == ["GIVEN", "WHEN", "THEN"]376 assert [s["text"] for s in scn["steps"]] == ["a context", "an action", "a result"]377378379# ---------------------------------------------------------------------------380# openspec_spec_list381# ---------------------------------------------------------------------------382383def test_spec_list_baseline(tmp_path):384 root = _make_project(tmp_path)385 # Create two baseline specs386 for name in ["alpha-spec", "beta-spec"]:387 tools.openspec_spec_create({388 "workdir": str(root),389 "spec": name,390 "title": name.title(),391 "purpose": "P",392 "requirements": _sample_requirements(),393 })394395 result = json.loads(tools.openspec_spec_list({396 "workdir": str(root),397 }))398 assert result["ok"] is True399 assert result["change"] is None400 assert result["count"] == 2401 assert "alpha-spec" in result["specs"]402 assert "beta-spec" in result["specs"]403404405def test_spec_list_change_scoped(tmp_path):406 root = _make_project(tmp_path)407 change_dir = root / "openspec" / "changes" / "my-change"408 change_dir.mkdir(parents=True)409 (change_dir / "proposal.md").write_text("## Why\nTest")410411 for name in ["delta-a", "delta-b"]:412 tools.openspec_spec_create({413 "workdir": str(root),414 "spec": name,415 "change": "my-change",416 "title": name.title(),417 "purpose": "P",418 "requirements": _sample_requirements(),419 })420421 result = json.loads(tools.openspec_spec_list({422 "workdir": str(root),423 "change": "my-change",424 }))425 assert result["ok"] is True426 assert result["change"] == "my-change"427 assert result["count"] == 2428 assert "delta-a" in result["specs"]429 assert "delta-b" in result["specs"]430431432def test_spec_list_empty(tmp_path):433 root = _make_project(tmp_path)434 result = json.loads(tools.openspec_spec_list({435 "workdir": str(root),436 }))437 assert result["ok"] is True438 assert result["count"] == 0439 assert result["specs"] == []440441442def test_spec_list_missing_specs_dir(tmp_path):443 """Listing should return empty when the specs directory doesn't exist."""444 root = tmp_path / "bare-project"445 (root / "openspec").mkdir(parents=True)446447 result = json.loads(tools.openspec_spec_list({448 "workdir": str(root),449 }))450 assert result["ok"] is True451 assert result["count"] == 0452453454# ---------------------------------------------------------------------------455# Integration: create → show → list456# ---------------------------------------------------------------------------457458def test_full_workflow_create_show_list(tmp_path):459 """End-to-end: create a spec, show it, list it."""460 root = _make_project(tmp_path)461462 # Create463 created = json.loads(tools.openspec_spec_create({464 "workdir": str(root),465 "spec": "workflow-spec",466 "title": "Workflow Spec",467 "purpose": "Workflow purpose",468 "requirements": _sample_requirements(),469 }))470 assert created["ok"] is True471472 # Show473 shown = json.loads(tools.openspec_spec_show({474 "workdir": str(root),475 "spec": "workflow-spec",476 }))477 assert shown["ok"] is True478 assert shown["title"] == "Workflow Spec"479480 # List481 listed = json.loads(tools.openspec_spec_list({482 "workdir": str(root),483 }))484 assert listed["ok"] is True485 assert "workflow-spec" in listed["specs"]