From 4c86c1ac1ecd6ff40c6c5b5aafcbeab695412ea3 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Tue, 10 Feb 2026 23:36:24 +0000 Subject: [PATCH] Add PRD template and update plan command to use default template - Introduced a new PRD template in YAML format for project documentation. - Updated the plan command to default to the new PRD template if no template is specified. - Enhanced tests to verify the creation and usage of the new template. --- src/specleft/commands/init.py | 39 +++++++++++++++ src/specleft/commands/plan.py | 15 +++++- tests/commands/test_init.py | 6 ++- tests/commands/test_plan.py | 94 +++++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/specleft/commands/init.py b/src/specleft/commands/init.py index 0efc14d..5a36cd7 100644 --- a/src/specleft/commands/init.py +++ b/src/specleft/commands/init.py @@ -15,6 +15,43 @@ from specleft.utils.messaging import print_support_footer +_PRD_TEMPLATE_CONTENT = """\ +version: "1.0" + +features: + heading_level: 2 + patterns: + - "Feature: {title}" + - "Feature {title}" + contains: [] + match_mode: "any" # any=pattern OR contains, all=pattern AND contains, patterns=pattern only, contains=contains only + exclude: + - "Overview" + - "Goals" + - "Non-Goals" + - "Open Questions" + - "Notes" + +scenarios: + heading_level: [3, 4] + patterns: + - "Scenario: {title}" + contains: [] + match_mode: "any" # any=pattern OR contains, all=pattern AND contains, patterns=pattern only, contains=contains only + step_keywords: + - "Given" + - "When" + - "Then" + - "And" + - "But" + +priorities: + patterns: + - "priority: {value}" + - "Priority: {value}" + mapping: {} +""" + def _init_example_content() -> dict[str, str]: """Generate single-file example feature using canonical template.""" @@ -65,6 +102,7 @@ def _init_plan(example: bool) -> tuple[list[Path], list[tuple[Path, str]]]: Path("tests"), Path(".specleft"), Path(".specleft/policies"), + Path(".specleft/templates"), ] files: list[tuple[Path, str]] = [] if example: @@ -72,6 +110,7 @@ def _init_plan(example: bool) -> tuple[list[Path], list[tuple[Path, str]]]: files.append((Path(rel_path), content)) files.append((Path(".specleft/.gitkeep"), "")) files.append((Path(".specleft/policies/.gitkeep"), "")) + files.append((Path(".specleft/templates/prd-template.yml"), _PRD_TEMPLATE_CONTENT)) return directories, files diff --git a/src/specleft/commands/plan.py b/src/specleft/commands/plan.py index 28ca36f..c5b0cc6 100644 --- a/src/specleft/commands/plan.py +++ b/src/specleft/commands/plan.py @@ -400,7 +400,7 @@ def _render_scenarios(scenarios: list[dict[str, object]]) -> str: blocks: list[str] = [] for scenario in scenarios: title = str(scenario.get("title", "Scenario")) - priority = scenario.get("priority") + priority = scenario.get("priority", "medium") steps_value = scenario.get("steps") steps: list[str] = [] if isinstance(steps_value, list): @@ -606,12 +606,25 @@ def plan( prd_file = Path(prd_path) template = default_template() template_info: dict[str, str] | None = None + + default_template_path = Path(".specleft/templates/prd-template.yml") if template_path is not None: template = load_template(template_path) template_info = { "path": str(template_path), "version": template.version, } + elif default_template_path.exists(): + template = load_template(default_template_path) + template_path = default_template_path + template_info = { + "path": str(default_template_path), + "version": template.version, + } + + if template_path is not None and format_type != "json": + click.echo(f"Using template: {template_path}") + click.echo("") prd_content, warnings = _read_prd(prd_file) if prd_content is None: if format_type == "json": diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py index 236a55f..0a559c2 100644 --- a/tests/commands/test_init.py +++ b/tests/commands/test_init.py @@ -18,6 +18,7 @@ def test_init_creates_single_file_example(self) -> None: result = runner.invoke(cli, ["init"], input="1\n") assert result.exit_code == 0 assert Path(".specleft/specs/example-feature.md").exists() + assert Path(".specleft/templates/prd-template.yml").exists() def test_init_json_dry_run(self) -> None: runner = CliRunner() @@ -26,8 +27,9 @@ def test_init_json_dry_run(self) -> None: assert result.exit_code == 0 payload = json.loads(result.output) assert payload["dry_run"] is True - assert payload["summary"]["directories"] == 4 + assert payload["summary"]["directories"] == 5 assert ".specleft/specs/example-feature.md" in payload["would_create"] + assert ".specleft/templates/prd-template.yml" in payload["would_create"] def test_init_json_requires_dry_run(self) -> None: runner = CliRunner() @@ -46,6 +48,8 @@ def test_init_blank_creates_directories(self) -> None: assert Path("tests").exists() assert Path(".specleft").exists() assert Path(".specleft/policies").exists() + assert Path(".specleft/templates").exists() + assert Path(".specleft/templates/prd-template.yml").exists() assert "Creating SpecLeft directory structure" in result.output def test_init_example_and_blank_conflict(self) -> None: diff --git a/tests/commands/test_plan.py b/tests/commands/test_plan.py index d638447..4f6fdce 100644 --- a/tests/commands/test_plan.py +++ b/tests/commands/test_plan.py @@ -253,6 +253,36 @@ def test_extract_prd_scenarios_with_contains_only(self) -> None: ] } + def test_render_scenarios_defaults_priority_to_medium(self) -> None: + plan_module: ModuleType = importlib.import_module("specleft.commands.plan") + scenarios: list[dict[str, object]] = [ + { + "title": "No explicit priority", + "steps": ["Given something", "When action", "Then result"], + }, + ] + rendered = plan_module._render_scenarios(scenarios) + assert "priority: medium" in rendered + + def test_plan_scenario_without_priority_gets_medium(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path("prd.md").write_text( + "# PRD\n\n## Feature: Billing\n\n" + "### Scenario: Refund requested\n" + "- Given a customer\n" + "- When they request a refund\n" + "- Then we mark it pending\n" + ) + result = runner.invoke(cli, ["plan"]) + + assert result.exit_code == 0 + feature_file = Path(".specleft/specs/feature-billing.md") + assert feature_file.exists() + content = feature_file.read_text() + assert "### Scenario: Refund requested" in content + assert "priority: medium" in content + class TestPlanAnalyzeMode: def test_analyze_flag_is_recognized(self) -> None: @@ -370,3 +400,67 @@ def test_plan_template_json_includes_metadata(self) -> None: payload = json.loads(result.output) assert payload["template"]["path"] == "template.yml" assert payload["template"]["version"] == "1.0" + + +class TestPlanTemplateAutoDetect: + def _write_default_template(self) -> None: + template_dir = Path(".specleft/templates") + template_dir.mkdir(parents=True, exist_ok=True) + (template_dir / "prd-template.yml").write_text( + 'version: "1.0"\n' + "features:\n" + " heading_level: 2\n" + " patterns:\n" + ' - "Feature: {title}"\n' + ) + + def test_auto_detects_template_when_present(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path("prd.md").write_text("# PRD\n\n## Feature: Billing\n") + self._write_default_template() + result = runner.invoke(cli, ["plan"]) + + assert result.exit_code == 0 + assert ( + "Using template: .specleft/templates/prd-template.yml" in result.output + ) + + def test_auto_detect_template_json_includes_metadata(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path("prd.md").write_text("# PRD\n\n## Feature: Billing\n") + self._write_default_template() + result = runner.invoke(cli, ["plan", "--format", "json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["template"]["path"] == ".specleft/templates/prd-template.yml" + assert payload["template"]["version"] == "1.0" + + def test_no_template_message_when_file_absent(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path("prd.md").write_text("# PRD\n\n## Feature: Billing\n") + result = runner.invoke(cli, ["plan"]) + + assert result.exit_code == 0 + assert "Using template:" not in result.output + + def test_explicit_template_overrides_auto_detect(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path("prd.md").write_text("# PRD\n\n## Epic: Billing\n") + self._write_default_template() + Path("custom.yml").write_text( + 'version: "2.0"\n' + "features:\n" + " heading_level: 2\n" + " patterns:\n" + ' - "Epic: {title}"\n' + ) + result = runner.invoke(cli, ["plan", "--template", "custom.yml"]) + + assert result.exit_code == 0 + assert "Using template: custom.yml" in result.output + assert Path(".specleft/specs/epic-billing.md").exists()