From 5e65e47a7e1b4ea852633fba5c240596acd5cc33 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sat, 7 Mar 2026 22:35:10 +0000 Subject: [PATCH] Add discovery draft spec writer (#134) --- features/feature-spec-discovery.md | 34 +++++ src/specleft/discovery/__init__.py | 1 + src/specleft/discovery/models.py | 4 +- src/specleft/discovery/spec_writer.py | 127 ++++++++++++++++++ src/specleft/parser.py | 6 +- tests/discovery/test_spec_writer.py | 183 ++++++++++++++++++++++++++ 6 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 src/specleft/discovery/spec_writer.py create mode 100644 tests/discovery/test_spec_writer.py diff --git a/features/feature-spec-discovery.md b/features/feature-spec-discovery.md index c0c4258..a8e0864 100644 --- a/features/feature-spec-discovery.md +++ b/features/feature-spec-discovery.md @@ -179,6 +179,30 @@ Add shared discovery infrastructure for Issues #125 and #126: centralized parser **Then** every `DiscoveredItem` appears in exactly one `DraftFeature`. **And** grouping uses `item.typed_meta()` for API and git metadata access. +### Story 14: Draft spec markdown generation and parser-safe staging +**Scenario:** As a discovery command, I need dry-run-safe draft spec generation. +**Given** grouped `DraftFeature` objects and an output dir +**When** `generate_draft_specs(..., dry_run=True)` is called +**Then** it returns the expected output file paths without writing files. + +**Scenario:** As a discovery pipeline, I need parser-compatible markdown output. +**Given** a `DraftFeature` with `DraftScenario.steps` as `SpecStep` models +**When** `generate_draft_specs(...)` writes markdown +**Then** each scenario is rendered with exactly three `Given/When/Then` bullet steps from the existing `SpecStep` objects. +**And** promoted files parse successfully with `SpecParser` / `SpecsConfig.from_directory`. + +**Scenario:** As a maintainer, I need safe overwrite controls. +**Given** an existing draft markdown file in the output directory +**When** `overwrite=False` (default) +**Then** the file is skipped and left unchanged. +**And when** `overwrite=True` +**Then** the existing file is replaced. + +**Scenario:** As a parser consumer, I need `_discovered` isolation. +**Given** `.specleft/specs/_discovered/` contains draft markdown files +**When** `SpecsConfig.from_directory(".specleft/specs")` parses specs +**Then** `_discovered` is skipped because directories prefixed with `_` are ignored during recursion. + ## Acceptance Criteria - Language abstraction returns `SupportedLanguage` members for `.py`, `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs` and `None` otherwise. - `LanguageRegistry().parse(path_to_py_file)` returns `(node, SupportedLanguage.PYTHON)` for valid Python input. @@ -240,3 +264,13 @@ Add shared discovery infrastructure for Issues #125 and #126: centralized parser - Git commit items are merged into nearest matching feature via `GitCommitMeta.file_prefixes`. - Grouping uses `item.typed_meta()` for API and git metadata access. - Single-item groups are valid outputs. +- `generate_draft_specs(..., dry_run=True)` returns expected file paths and does not create/write files. +- `generate_draft_specs(...)` writes markdown with a generated-feature note and scenario source comments when source location exists. +- Scenario step rendering is direct from `SpecStep` objects and does not perform string-to-step parsing. +- Written/promotion-ready markdown parses successfully via `SpecParser` with zero validation errors. +- Each generated scenario includes exactly 3 steps (Given, When, Then). +- Generated feature and scenario IDs validate against `validate_feature_id()` and `validate_scenario_id()`. +- Existing files are skipped when `overwrite=False`. +- Existing files are replaced when `overwrite=True`. +- Parser recursion skips all underscore-prefixed directories (for example, `.specleft/specs/_discovered/`). +- `SpecsConfig.from_directory(".specleft/specs")` excludes files from `_discovered/`. diff --git a/src/specleft/discovery/__init__.py b/src/specleft/discovery/__init__.py index 23bfbc0..9ab14bf 100644 --- a/src/specleft/discovery/__init__.py +++ b/src/specleft/discovery/__init__.py @@ -9,6 +9,7 @@ from specleft.discovery.language_detect import detect_project_languages from specleft.discovery.language_registry import SUPPORTED_EXTENSIONS, LanguageRegistry from specleft.discovery.grouping import group_items +from specleft.discovery.spec_writer import generate_draft_specs from specleft.discovery.pipeline import ( BaseMiner, DiscoveryPipeline, diff --git a/src/specleft/discovery/models.py b/src/specleft/discovery/models.py index bc70950..3df94de 100644 --- a/src/specleft/discovery/models.py +++ b/src/specleft/discovery/models.py @@ -85,6 +85,8 @@ class GitCommitMeta(BaseModel): ItemKind.GIT_COMMIT: GitCommitMeta, } +DEFAULT_DISCOVERY_OUTPUT_DIR = Path(".specleft/specs/_discovered") + class MinerErrorKind(str, Enum): """Categories for structured miner errors.""" @@ -184,5 +186,5 @@ class DraftSpec(BaseModel): """Container for generated draft specs.""" features: list[DraftFeature] - output_dir: Path + output_dir: Path = DEFAULT_DISCOVERY_OUTPUT_DIR generated_at: str diff --git a/src/specleft/discovery/spec_writer.py b/src/specleft/discovery/spec_writer.py new file mode 100644 index 0000000..622ff08 --- /dev/null +++ b/src/specleft/discovery/spec_writer.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Draft spec markdown writer for discovery phase 2.""" + +from __future__ import annotations + +from pathlib import Path + +from specleft.discovery.models import DraftFeature, DraftScenario +from specleft.schema import SpecStep +from specleft.utils.feature_writer import ( + generate_feature_id, + generate_scenario_id, + validate_feature_id, + validate_scenario_id, +) + +_GENERATED_NOTE = ( + "" +) + + +def generate_draft_specs( + draft_features: list[DraftFeature], + output_dir: Path, + dry_run: bool = False, + overwrite: bool = False, +) -> list[Path]: + """Write draft specs and return written paths (or would-be paths in dry-run).""" + if not dry_run: + output_dir.mkdir(parents=True, exist_ok=True) + + output_paths: list[Path] = [] + for feature in draft_features: + feature_id = _resolve_feature_id(feature) + feature_path = output_dir / f"{feature_id}.md" + + if feature_path.exists() and not overwrite: + continue + + output_paths.append(feature_path) + if dry_run: + continue + + feature_path.write_text(_render_feature(feature)) + + return output_paths + + +def _resolve_feature_id(feature: DraftFeature) -> str: + candidate = feature.feature_id.strip() + if not candidate: + candidate = generate_feature_id(feature.name) + + try: + validate_feature_id(candidate) + return candidate + except ValueError: + fallback = generate_feature_id(feature.name) + validate_feature_id(fallback) + return fallback + + +def _resolve_scenario_id(scenario: DraftScenario) -> str: + scenario_id = generate_scenario_id(scenario.title) + validate_scenario_id(scenario_id) + return scenario_id + + +def _render_feature(feature: DraftFeature) -> str: + lines: list[str] = [ + f"# Feature: {feature.name}", + _GENERATED_NOTE, + "", + "## Scenarios", + ] + + for scenario in feature.scenarios: + scenario_id = _resolve_scenario_id(scenario) + steps = _validate_steps(scenario) + + lines.extend( + [ + "", + f"### Scenario: {scenario_id}", + f"priority: {scenario.priority}", + "", + ] + ) + + source_line = _source_line(scenario) + if source_line is not None: + lines.append(f"") + + lines.extend(_format_steps(steps)) + + lines.append("") + return "\n".join(lines) + + +def _validate_steps(scenario: DraftScenario) -> list[SpecStep]: + if len(scenario.steps) != 3: + raise ValueError( + f"Scenario '{scenario.title}' must have exactly 3 steps, got " + f"{len(scenario.steps)}" + ) + return scenario.steps + + +def _source_line(scenario: DraftScenario) -> str | None: + if not scenario.source_items: + return None + + source = scenario.source_items[0] + if source.file_path is None: + return None + + location = source.file_path.as_posix() + if source.line_number is not None: + location = f"{location}:{source.line_number}" + return location + + +def _format_steps(steps: list[SpecStep]) -> list[str]: + return [f"- {step.type.value.capitalize()} {step.description}" for step in steps] diff --git a/src/specleft/parser.py b/src/specleft/parser.py index 8b6964b..bf67031 100644 --- a/src/specleft/parser.py +++ b/src/specleft/parser.py @@ -58,7 +58,11 @@ def parse_directory(self, features_dir: Path) -> SpecsConfig: features: list[FeatureSpec] = [] for feature_path in sorted(features_dir.iterdir()): - if feature_path.is_dir() and not feature_path.name.startswith("."): + if ( + feature_path.is_dir() + and not feature_path.name.startswith(".") + and not feature_path.name.startswith("_") + ): feature = self._parse_feature_dir(feature_path) if feature: features.append(feature) diff --git a/tests/discovery/test_spec_writer.py b/tests/discovery/test_spec_writer.py new file mode 100644 index 0000000..c863cec --- /dev/null +++ b/tests/discovery/test_spec_writer.py @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Tests for discovery draft spec markdown writing.""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +import pytest + +from specleft.discovery.models import ( + TestFunctionMeta as DiscoveryTestFunctionMeta, + DiscoveredItem, + DraftFeature, + DraftScenario, + ItemKind, + SupportedLanguage, +) +from specleft.discovery.spec_writer import generate_draft_specs +from specleft.parser import SpecParser +from specleft.schema import SpecStep, SpecsConfig, StepType +from specleft.utils.feature_writer import validate_feature_id, validate_scenario_id + + +def _source_item(path: Path, line_number: int = 14) -> DiscoveredItem: + return DiscoveredItem( + kind=ItemKind.TEST_FUNCTION, + name="test_valid_credentials", + file_path=path, + line_number=line_number, + language=SupportedLanguage.PYTHON, + raw_text=None, + metadata=DiscoveryTestFunctionMeta(framework="pytest").model_dump(), + confidence=0.9, + ) + + +def _draft_feature( + *, + feature_id: str = "user-authentication", + scenario_title: str = "valid-credentials", + steps: list[SpecStep] | None = None, +) -> DraftFeature: + scenario_steps = steps or [ + SpecStep(type=StepType.GIVEN, description="a user with valid credentials"), + SpecStep(type=StepType.WHEN, description="they attempt to login"), + SpecStep(type=StepType.THEN, description="they should be authenticated"), + ] + source = _source_item(Path("tests/auth/test_login.py")) + scenario = DraftScenario( + title=scenario_title, + priority="medium", + steps=scenario_steps, + source_items=[source], + ) + return DraftFeature( + feature_id=feature_id, + name="User Authentication", + scenarios=[scenario], + source_items=[source], + confidence=0.8, + ) + + +def test_generate_draft_specs_dry_run_returns_paths_without_writing( + tmp_path: Path, +) -> None: + output_dir = tmp_path / ".specleft" / "specs" / "_discovered" + + paths = generate_draft_specs([_draft_feature()], output_dir, dry_run=True) + + assert paths == [output_dir / "user-authentication.md"] + assert not output_dir.exists() + + +def test_generate_draft_specs_writes_parser_compatible_markdown(tmp_path: Path) -> None: + output_dir = tmp_path / ".specleft" / "specs" / "_discovered" + promoted_dir = tmp_path / ".specleft" / "specs" + feature = _draft_feature() + + written_paths = generate_draft_specs([feature], output_dir) + assert written_paths == [output_dir / "user-authentication.md"] + assert written_paths[0].exists() + + content = written_paths[0].read_text() + assert "" in content + assert content.count("\n- Given ") == 1 + assert content.count("\n- When ") == 1 + assert content.count("\n- Then ") == 1 + + shutil.copy2(written_paths[0], promoted_dir / written_paths[0].name) + parser = SpecParser() + parsed = parser.parse_directory(promoted_dir) + + assert len(parsed.features) == 1 + scenario = parsed.features[0].stories[0].scenarios[0] + assert scenario.scenario_id == "valid-credentials" + assert len(scenario.steps) == 3 + + validate_feature_id(written_paths[0].stem) + scenario_ids = re.findall(r"^### Scenario:\s*([a-z0-9-]+)$", content, re.MULTILINE) + for scenario_id in scenario_ids: + validate_scenario_id(scenario_id) + + +def test_existing_file_is_not_overwritten_by_default(tmp_path: Path) -> None: + output_dir = tmp_path / "out" + output_dir.mkdir(parents=True) + output_file = output_dir / "user-authentication.md" + output_file.write_text("original-content") + + paths = generate_draft_specs([_draft_feature()], output_dir, overwrite=False) + + assert paths == [] + assert output_file.read_text() == "original-content" + + +def test_overwrite_replaces_existing_file_when_enabled(tmp_path: Path) -> None: + output_dir = tmp_path / "out" + output_dir.mkdir(parents=True) + output_file = output_dir / "user-authentication.md" + output_file.write_text("original-content") + + paths = generate_draft_specs([_draft_feature()], output_dir, overwrite=True) + + assert paths == [output_file] + updated = output_file.read_text() + assert "original-content" not in updated + assert "### Scenario: valid-credentials" in updated + + +def test_generate_draft_specs_requires_three_steps_per_scenario(tmp_path: Path) -> None: + output_dir = tmp_path / "out" + invalid_steps = [ + SpecStep(type=StepType.GIVEN, description="a user exists"), + SpecStep(type=StepType.WHEN, description="a request is sent"), + ] + feature = _draft_feature(steps=invalid_steps) + + with pytest.raises(ValueError, match="must have exactly 3 steps"): + generate_draft_specs([feature], output_dir) + + +def test_specs_config_skips_underscore_discovery_directory(tmp_path: Path) -> None: + specs_dir = tmp_path / ".specleft" / "specs" + specs_dir.mkdir(parents=True) + + (specs_dir / "auth.md").write_text( + "# Feature: Authentication\n" + "\n" + "## Scenarios\n" + "\n" + "### Scenario: sign-in\n" + "priority: medium\n" + "\n" + "- Given a valid account\n" + "- When credentials are submitted\n" + "- Then sign-in succeeds\n" + ) + + discovered_dir = specs_dir / "_discovered" + discovered_dir.mkdir() + (discovered_dir / "payments.md").write_text( + "# Feature: Payments\n" + "\n" + "## Scenarios\n" + "\n" + "### Scenario: refund\n" + "priority: medium\n" + "\n" + "- Given a paid invoice\n" + "- When a refund is requested\n" + "- Then funds are returned\n" + ) + + config = SpecsConfig.from_directory(specs_dir) + + assert len(config.features) == 1 + assert config.features[0].feature_id == "auth"