Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions features/feature-spec-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/`.
1 change: 1 addition & 0 deletions src/specleft/discovery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/specleft/discovery/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
127 changes: 127 additions & 0 deletions src/specleft/discovery/spec_writer.py
Original file line number Diff line number Diff line change
@@ -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 = (
"<!-- generated by specleft discover -- review before promoting to "
".specleft/specs/ -->"
)


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"<!-- source: {source_line} -->")

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]
6 changes: 5 additions & 1 deletion src/specleft/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
183 changes: 183 additions & 0 deletions tests/discovery/test_spec_writer.py
Original file line number Diff line number Diff line change
@@ -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 "<!-- generated by specleft discover" in content
assert "<!-- source: tests/auth/test_login.py:14 -->" 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"