"""Tests for speckit — local rebuild of the spec-kit idea (TDD).""" import subprocess import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent SPECKIT = REPO / "speckit.py" SUBCOMMANDS = [ "init", "specify", "plan", "tasks", "switch", "status", "check", "clarify", "constitution", "checklist", "converge", ] def run_cli(*args, cwd=None): return subprocess.run( [sys.executable, str(SPECKIT), *args], capture_output=True, text=True, cwd=cwd, ) # --- T1: skeleton --- def test_help_exits_zero_and_lists_all_subcommands(): result = run_cli("--help") assert result.returncode == 0 for sub in SUBCOMMANDS: assert sub in result.stdout # --- T2: templates --- # Heading strings are hard-coded literals here on purpose: they pin the # REQUIRED_SECTIONS constant from outside (anti-tautology). TEMPLATES = REPO / "templates" def test_spec_template_headings_and_tokens(): text = (TEMPLATES / "spec-template.md").read_text() for heading in ["## User Stories", "## Requirements", "## Success Criteria", "## Assumptions"]: assert heading in text for token in ["{{DESCRIPTION}}", "{{DATE}}", "{{FEATURE}}"]: assert token in text def test_plan_template_headings_and_tokens(): text = (TEMPLATES / "plan-template.md").read_text() for heading in ["## Technical Context", "## Structure", "## Decisions"]: assert heading in text for token in ["{{DATE}}", "{{FEATURE}}"]: assert token in text def test_tasks_template_heading_and_exactly_six_open_boxes(): text = (TEMPLATES / "tasks-template.md").read_text() assert "## Phases" in text open_boxes = [l for l in text.splitlines() if l.lstrip().startswith(("- [ ]", "* [ ]"))] assert len(open_boxes) == 6 def test_constitution_template_tokens(): text = (TEMPLATES / "constitution-template.md").read_text() for token in ["{{PROJECT}}", "{{DATE}}"]: assert token in text # --- T3: init --- import json def test_init_creates_exact_scaffold(tmp_path): result = run_cli("init", "demo", cwd=tmp_path) assert result.returncode == 0 proj = tmp_path / "demo" assert (proj / ".speckit" / "state.json").is_file() for name in ["spec-template.md", "plan-template.md", "tasks-template.md", "constitution-template.md"]: assert (proj / ".speckit" / "templates" / name).is_file() assert (proj / "memory" / "constitution.md").is_file() assert (proj / "specs").is_dir() assert list((proj / "specs").iterdir()) == [] state = json.loads((proj / ".speckit" / "state.json").read_text()) assert state == {"project": "demo", "current_feature": None, "features": []} constitution = (proj / "memory" / "constitution.md").read_text() assert "{{" not in constitution assert "demo" in constitution def test_init_refuses_existing_project(tmp_path): assert run_cli("init", "demo", cwd=tmp_path).returncode == 0 result = run_cli("init", "demo", cwd=tmp_path) assert result.returncode == 1 assert ".speckit" in result.stderr # --- T4: specify + switch + root discovery --- def make_project(tmp_path, name="demo"): assert run_cli("init", name, cwd=tmp_path).returncode == 0 return tmp_path / name def read_state(proj): return json.loads((proj / ".speckit" / "state.json").read_text()) def test_specify_creates_first_feature_001(tmp_path): proj = make_project(tmp_path) result = run_cli("specify", "recipe box organizer", cwd=proj) assert result.returncode == 0 spec = proj / "specs" / "001-recipe-box-organizer" / "spec.md" assert spec.is_file() text = spec.read_text() assert "recipe box organizer" in text assert "{{" not in text state = read_state(proj) assert state["current_feature"] == "001-recipe-box-organizer" assert state["features"] == ["001-recipe-box-organizer"] def test_specify_increments_number_and_switches_current(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) result = run_cli("specify", "meal shopping list", cwd=proj) assert result.returncode == 0 assert (proj / "specs" / "002-meal-shopping-list" / "spec.md").is_file() assert read_state(proj)["current_feature"] == "002-meal-shopping-list" def test_slug_keeps_first_four_words(tmp_path): proj = make_project(tmp_path) run_cli("specify", "A Really long, feature name here!", cwd=proj) assert (proj / "specs" / "001-a-really-long-feature").is_dir() def test_switch_by_number_and_slug(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) run_cli("specify", "meal shopping list", cwd=proj) assert run_cli("switch", "001", cwd=proj).returncode == 0 assert read_state(proj)["current_feature"] == "001-recipe-box-organizer" assert run_cli("switch", "meal-shopping-list", cwd=proj).returncode == 0 assert read_state(proj)["current_feature"] == "002-meal-shopping-list" def test_switch_unknown_feature_lists_known(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) result = run_cli("switch", "nope", cwd=proj) assert result.returncode == 1 assert "001-recipe-box-organizer" in result.stderr def test_command_outside_project_exits_2_and_writes_nothing(tmp_path): before = sorted(p.name for p in tmp_path.iterdir()) result = run_cli("specify", "anything", cwd=tmp_path) assert result.returncode == 2 assert "not inside a speckit project" in result.stderr assert sorted(p.name for p in tmp_path.iterdir()) == before def test_root_discovery_walks_up_from_nested_subdir(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) nested = proj / "specs" / "001-recipe-box-organizer" result = run_cli("specify", "meal shopping list", cwd=nested) assert result.returncode == 0 assert (proj / "specs" / "002-meal-shopping-list" / "spec.md").is_file() assert not (nested / "specs").exists() # --- T5: plan/tasks phase gates --- def test_plan_without_feature_exits_1(tmp_path): proj = make_project(tmp_path) result = run_cli("plan", cwd=proj) assert result.returncode == 1 assert "no feature selected" in result.stderr def test_plan_seeds_plan_md(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) result = run_cli("plan", cwd=proj) assert result.returncode == 0 plan = proj / "specs" / "001-recipe-box-organizer" / "plan.md" assert plan.is_file() text = plan.read_text() assert "001-recipe-box-organizer" in text assert "{{" not in text def test_plan_never_overwrites(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) run_cli("plan", cwd=proj) plan = proj / "specs" / "001-recipe-box-organizer" / "plan.md" plan.write_text("edited by hand") result = run_cli("plan", cwd=proj) assert result.returncode == 0 assert "exists" in result.stdout assert plan.read_text() == "edited by hand" def test_plan_gate_requires_spec(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) (proj / "specs" / "001-recipe-box-organizer" / "spec.md").unlink() result = run_cli("plan", cwd=proj) assert result.returncode == 1 assert "spec.md" in result.stderr def test_tasks_gate_requires_plan(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) result = run_cli("tasks", cwd=proj) assert result.returncode == 1 assert "plan.md" in result.stderr def test_tasks_seeds_tasks_md_after_plan(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) run_cli("plan", cwd=proj) result = run_cli("tasks", cwd=proj) assert result.returncode == 0 tasks = proj / "specs" / "001-recipe-box-organizer" / "tasks.md" assert tasks.is_file() assert "{{" not in tasks.read_text() # --- T6: status --- # Fixture counts below are written BY HAND (3 done, 4 open), never derived # from the code's own regexes. TASKS_FIXTURE = """# Tasks ## Phases - [x] plain done - [X] indented uppercase done * [x] star bullet done - [ ] plain open - [ ] indented open * [ ] star bullet open - [ ] fourth open """ def test_status_on_empty_project(tmp_path): proj = make_project(tmp_path) result = run_cli("status", cwd=proj) assert result.returncode == 0 assert "no features" in result.stdout def test_status_shows_phases_current_and_counts(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) run_cli("plan", cwd=proj) run_cli("tasks", cwd=proj) run_cli("specify", "meal shopping list", cwd=proj) run_cli("switch", "001", cwd=proj) (proj / "specs" / "001-recipe-box-organizer" / "tasks.md").write_text(TASKS_FIXTURE) result = run_cli("status", cwd=proj) assert result.returncode == 0 out = result.stdout assert "* 001-recipe-box-organizer" in out # current marker assert "3 done / 4 open" in out # hand-counted fixture lines_002 = [l for l in out.splitlines() if "002-meal-shopping-list" in l] assert len(lines_002) == 1 assert not lines_002[0].startswith("* ") assert "spec" in lines_002[0] # --- T7: check --- def full_feature(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) run_cli("plan", cwd=proj) run_cli("tasks", cwd=proj) return proj, proj / "specs" / "001-recipe-box-organizer" def test_check_clean_full_feature_exits_0(tmp_path): proj, _ = full_feature(tmp_path) result = run_cli("check", cwd=proj) assert result.returncode == 0 assert "NEEDS CLARIFICATION: 0" in result.stdout def test_check_without_feature_exits_1(tmp_path): proj = make_project(tmp_path) result = run_cli("check", cwd=proj) assert result.returncode == 1 assert "no feature selected" in result.stderr def test_check_spec_only_feature_is_clean(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) result = run_cli("check", cwd=proj) assert result.returncode == 0 assert result.stdout.count("phase not started") == 2 # plan + tasks def test_check_exactly_three_markers_passes(tmp_path): proj, feature = full_feature(tmp_path) spec = feature / "spec.md" spec.write_text( spec.read_text() + "\n- R2: sorting order [NEEDS CLARIFICATION: by date or name?]" + "\n- R3: limits [NEEDS CLARIFICATION: max photos?]" + "\n- R4: sharing [NEEDS CLARIFICATION: export format?]\n" ) result = run_cli("check", cwd=proj) assert result.returncode == 0 assert "NEEDS CLARIFICATION: 3" in result.stdout def test_check_flags_missing_heading_and_four_markers(tmp_path): proj, feature = full_feature(tmp_path) spec = feature / "spec.md" text = spec.read_text().replace("## Requirements", "## Stuff") text += "".join( f"\n- R{i}: thing [NEEDS CLARIFICATION: q{i}?]" for i in range(4) ) spec.write_text(text) result = run_cli("check", cwd=proj) assert result.returncode == 1 assert "## Requirements" in result.stdout assert "NEEDS CLARIFICATION: 4" in result.stdout def test_check_missing_spec_is_finding(tmp_path): proj, feature = full_feature(tmp_path) (feature / "spec.md").unlink() result = run_cli("check", cwd=proj) assert result.returncode == 1 assert "spec.md" in result.stdout def test_check_missing_constitution_is_finding(tmp_path): proj, _ = full_feature(tmp_path) (proj / "memory" / "constitution.md").unlink() result = run_cli("check", cwd=proj) assert result.returncode == 1 assert "constitution" in result.stdout # --- T8: agent command prompts --- import re as _re PROMPT_FILES = { "speckit.specify.md": "spec.md", "speckit.plan.md": "plan.md", "speckit.tasks.md": "tasks.md", "speckit.implement.md": "tasks.md", } PROMPT_GATES = { "speckit.plan.md": "spec.md", # plan gated on spec "speckit.tasks.md": "plan.md", # tasks gated on plan "speckit.implement.md": "tasks.md", # implement gated on tasks existing } def test_init_with_agent_writes_four_prompts_naming_artifact_and_gate(tmp_path): assert run_cli("init", "demo", "--agent", "claude", cwd=tmp_path).returncode == 0 commands = tmp_path / "demo" / ".claude" / "commands" for name, artifact in PROMPT_FILES.items(): text = (commands / name).read_text() assert artifact in text for name, gate in PROMPT_GATES.items(): assert gate in (commands / name).read_text() def test_init_without_agent_writes_no_claude_dir(tmp_path): assert run_cli("init", "demo", cwd=tmp_path).returncode == 0 assert not (tmp_path / "demo" / ".claude").exists() # --- specify from file + --name override (bug: `specify prompt.txt` made 001-prompt-txt) --- def test_specify_reads_description_from_existing_file(tmp_path): proj = make_project(tmp_path) (proj / "prompt.txt").write_text("superhero visual room overview\n\nContext: show all installed agents.\n") result = run_cli("specify", "prompt.txt", cwd=proj) assert result.returncode == 0 assert "prompt.txt" in result.stdout # says it read the file feature_dir = proj / "specs" / "001-superhero-visual-room-overview" assert feature_dir.is_dir() # slug from content, not filename spec_text = (feature_dir / "spec.md").read_text() assert "show all installed agents" in spec_text def test_specify_name_overrides_slug(tmp_path): proj = make_project(tmp_path) (proj / "prompt.txt").write_text("title: Fancy Thing category: builds\nlots of prose here") result = run_cli("specify", "prompt.txt", "--name", "visual room", cwd=proj) assert result.returncode == 0 assert (proj / "specs" / "001-visual-room").is_dir() assert "lots of prose here" in (proj / "specs" / "001-visual-room" / "spec.md").read_text() # --- O5: timestamp numbering --- def test_specify_timestamp_mode_prefixes_datetime(tmp_path): proj = make_project(tmp_path) result = run_cli("specify", "--timestamp", "recipe box organizer", cwd=proj) assert result.returncode == 0 dirs = [p.name for p in (proj / "specs").iterdir()] assert len(dirs) == 1 assert _re.fullmatch(r"\d{8}-\d{6}-recipe-box-organizer", dirs[0]) assert read_state(proj)["current_feature"] == dirs[0] def test_switch_by_slug_works_for_timestamp_feature(tmp_path): proj = make_project(tmp_path) run_cli("specify", "--timestamp", "recipe box organizer", cwd=proj) run_cli("specify", "meal shopping list", cwd=proj) assert run_cli("switch", "recipe-box-organizer", cwd=proj).returncode == 0 assert read_state(proj)["current_feature"].endswith("-recipe-box-organizer") # --- O4: checklists --- def test_checklist_seeds_file_with_open_items(tmp_path): proj, feature = full_feature(tmp_path) result = run_cli("checklist", "requirements", cwd=proj) assert result.returncode == 0 checklist = feature / "checklists" / "requirements.md" assert checklist.is_file() text = checklist.read_text() assert "{{" not in text assert RE_OPEN_BOX_LINES(text) def RE_OPEN_BOX_LINES(text): return [l for l in text.splitlines() if l.lstrip().startswith(("- [ ]", "* [ ]"))] def test_checklist_without_feature_exits_1(tmp_path): proj = make_project(tmp_path) result = run_cli("checklist", "requirements", cwd=proj) assert result.returncode == 1 assert "no feature selected" in result.stderr def test_checklist_never_overwrites(tmp_path): proj, feature = full_feature(tmp_path) run_cli("checklist", "requirements", cwd=proj) target = feature / "checklists" / "requirements.md" target.write_text("edited by hand") result = run_cli("checklist", "requirements", cwd=proj) assert result.returncode == 0 assert "exists" in result.stdout assert target.read_text() == "edited by hand" def test_check_warns_on_unchecked_checklist_items_but_stays_clean(tmp_path): proj, feature = full_feature(tmp_path) run_cli("checklist", "requirements", cwd=proj) n_open = len(RE_OPEN_BOX_LINES((feature / "checklists" / "requirements.md").read_text())) result = run_cli("check", cwd=proj) assert result.returncode == 0 assert f"checklist requirements: {n_open} unchecked" in result.stdout # --- O6: converge --- def test_converge_reports_converged_and_leaves_tasks_untouched(tmp_path): proj, feature = full_feature(tmp_path) before = (feature / "tasks.md").read_bytes() result = run_cli("converge", cwd=proj) assert result.returncode == 0 assert "converged" in result.stdout assert (feature / "tasks.md").read_bytes() == before def test_converge_appends_tasks_for_uncovered_stories(tmp_path): proj, feature = full_feature(tmp_path) spec = feature / "spec.md" spec.write_text(spec.read_text() + "\n### Story 2 — (P2)\n\nAs a cook, I want tags.\n") before = (feature / "tasks.md").read_text() result = run_cli("converge", cwd=proj) assert result.returncode == 0 after = (feature / "tasks.md").read_text() assert after.startswith(before) # append-only appended = after[len(before):] assert "Story 2" in appended assert "### Convergence" in appended assert RE_OPEN_BOX_LINES(appended) # idempotent: second run finds nothing new result2 = run_cli("converge", cwd=proj) assert result2.returncode == 0 assert "converged" in result2.stdout assert (feature / "tasks.md").read_text() == after def test_converge_requires_tasks_md(tmp_path): proj = make_project(tmp_path) run_cli("specify", "recipe box organizer", cwd=proj) result = run_cli("converge", cwd=proj) assert result.returncode == 1 assert "tasks.md" in result.stderr # --- O1: ANSI colors --- def test_color_always_emits_ansi_codes(tmp_path): proj, _ = full_feature(tmp_path) result = run_cli("--color", "always", "status", cwd=proj) assert result.returncode == 0 assert "\x1b[" in result.stdout def test_no_color_by_default_when_not_a_tty(tmp_path): proj, _ = full_feature(tmp_path) result = run_cli("status", cwd=proj) assert result.returncode == 0 assert "\x1b[" not in result.stdout def test_color_always_paints_findings_red(tmp_path): proj, feature = full_feature(tmp_path) (feature / "spec.md").unlink() result = run_cli("--color", "always", "check", cwd=proj) assert result.returncode == 1 assert "\x1b[31m" in result.stdout # --- O3: constitution --- def test_constitution_shows_current_version(tmp_path): proj = make_project(tmp_path) result = run_cli("constitution", cwd=proj) assert result.returncode == 0 assert "1.0.0" in result.stdout def test_constitution_amend_bumps_minor_and_records_entry(tmp_path): proj = make_project(tmp_path) result = run_cli("constitution", "--amend", "Article V — no network calls at runtime", cwd=proj) assert result.returncode == 0 text = (proj / "memory" / "constitution.md").read_text() assert "version 1.1.0" in text assert "no network calls at runtime" in text assert run_cli("constitution", cwd=proj).stdout.strip().endswith("1.1.0") def test_constitution_amend_major_bump(tmp_path): proj = make_project(tmp_path) run_cli("constitution", "--amend", "first change", cwd=proj) # 1.1.0 result = run_cli("constitution", "--amend", "breaking change", "--major", cwd=proj) assert result.returncode == 0 assert "2.0.0" in (proj / "memory" / "constitution.md").read_text() def test_constitution_missing_file_exits_1(tmp_path): proj = make_project(tmp_path) (proj / "memory" / "constitution.md").unlink() result = run_cli("constitution", cwd=proj) assert result.returncode == 1 assert "constitution" in result.stderr # --- O2: clarify --- def test_clarify_lists_markers_with_file_and_line(tmp_path): proj, feature = full_feature(tmp_path) spec = feature / "spec.md" lines = spec.read_text().splitlines() lines.append("- R2: order [NEEDS CLARIFICATION: by date or name?]") spec.write_text("\n".join(lines) + "\n") marker_line = len(lines) # appended last, 1-based result = run_cli("clarify", cwd=proj) assert result.returncode == 0 assert f"spec.md:{marker_line}:" in result.stdout assert "by date or name?" in result.stdout def test_clarify_reports_when_clean(tmp_path): proj, _ = full_feature(tmp_path) result = run_cli("clarify", cwd=proj) assert result.returncode == 0 assert "no clarification markers" in result.stdout def test_clarify_without_feature_exits_1(tmp_path): proj = make_project(tmp_path) result = run_cli("clarify", cwd=proj) assert result.returncode == 1 assert "no feature selected" in result.stderr def test_prompt_verbs_are_registered_subcommands(tmp_path): assert run_cli("init", "demo", "--agent", "claude", cwd=tmp_path).returncode == 0 commands = tmp_path / "demo" / ".claude" / "commands" verbs = set() for prompt in commands.glob("*.md"): verbs.update(_re.findall(r"speckit\.py (\w+)", prompt.read_text())) assert verbs # prompts must actually reference the CLI assert verbs <= set(SUBCOMMANDS)