Summary
Convert a list[DraftFeature] into SpecLeft-format markdown files written to .specleft/specs/discovered/. Reuses existing feature_writer.py utilities for ID generation and validation. Uses SpecStep objects from DraftScenario directly — no lossy string round-trip.
Depends on: #124, #133
New file
src/specleft/discovery/spec_writer.py
from specleft.discovery.models import DraftFeature, DraftScenario
from specleft.schema import SpecStep, StepType
def generate_draft_specs(
draft_features: list[DraftFeature],
output_dir: Path,
dry_run: bool = False,
overwrite: bool = False,
) -> list[Path]:
"""
Returns list of written file paths (or would-be paths if dry_run=True).
Skips existing files unless overwrite=True.
Creates output_dir if it does not exist (unless dry_run).
"""
Output format per file
# Feature: User Authentication
<!-- generated by specleft discover — review before promoting to .specleft/specs/ -->
## Scenarios
### Scenario: valid-credentials
priority: medium
<!-- source: tests/auth/test_login.py:14 -->
- Given a user with valid credentials
- When they attempt to login
- Then they should be authenticated
### Scenario: expired-token
priority: medium
<!-- source: tests/auth/test_login.py:28 -->
- Given a user with an expired token
- When they attempt an authenticated request
- Then they should receive a 401 response
Step generation from SpecStep objects
DraftScenario.steps is list[SpecStep] — no string parsing needed. The writer serialises each step directly:
for step in scenario.steps:
# step.type is StepType (GIVEN, WHEN, THEN, AND, BUT)
# step.description is the text
line = f"- {step.type.value} {step.description}"
Miners and the grouping algorithm are responsible for constructing SpecStep objects. The step generation rules per ItemKind remain:
| Kind |
Given |
When |
Then |
TEST_FUNCTION |
context from name tokens before first verb |
verb token as action |
remainder as outcome |
API_ROUTE |
"a valid request" |
"{METHOD} {path} is called" |
"a response is returned" |
DOCSTRING |
first sentence of raw_text |
second sentence |
third sentence; pad to 3 |
GIT_COMMIT |
"the system is in a known state" |
commit subject as action |
"the expected outcome occurs" |
These rules are applied when constructing DraftScenario objects (in grouping.py or a helper), not in the writer.
Parser exclusion — _discovered/ convention
The staging directory should use an underscore prefix convention to distinguish it from user-created spec directories:
Default staging path: .specleft/specs/_discovered/
Update src/specleft/parser.py to skip directories whose name starts with _ when recursing. This is consistent with Python's _private convention and prevents naming collisions with user features:
_discovered/ → skipped by parser
_drafts/ → would also be skipped (future-proof)
discovered/ → would NOT be skipped (user could name a feature this)
Note: This changes the staging path from .specleft/specs/discovered/ to .specleft/specs/_discovered/. Update all references in #124 (DraftSpec.output_dir), #136 (discover command), and #137 (start command).
Utilities to reuse
src/specleft/utils/feature_writer.py → generate_feature_id(), generate_scenario_id(), validate_feature_id(), validate_scenario_id()
Acceptance criteria
Summary
Convert a
list[DraftFeature]into SpecLeft-format markdown files written to.specleft/specs/discovered/. Reuses existingfeature_writer.pyutilities for ID generation and validation. UsesSpecStepobjects fromDraftScenariodirectly — no lossy string round-trip.Depends on: #124, #133
New file
src/specleft/discovery/spec_writer.pyOutput format per file
Step generation from SpecStep objects
DraftScenario.stepsislist[SpecStep]— no string parsing needed. The writer serialises each step directly:Miners and the grouping algorithm are responsible for constructing
SpecStepobjects. The step generation rules perItemKindremain:TEST_FUNCTIONAPI_ROUTE"a valid request""{METHOD} {path} is called""a response is returned"DOCSTRINGraw_textGIT_COMMIT"the system is in a known state""the expected outcome occurs"These rules are applied when constructing
DraftScenarioobjects (ingrouping.pyor a helper), not in the writer.Parser exclusion —
_discovered/conventionThe staging directory should use an underscore prefix convention to distinguish it from user-created spec directories:
Default staging path:
.specleft/specs/_discovered/Update
src/specleft/parser.pyto skip directories whose name starts with_when recursing. This is consistent with Python's_privateconvention and prevents naming collisions with user features:_discovered/→ skipped by parser_drafts/→ would also be skipped (future-proof)discovered/→ would NOT be skipped (user could name a feature this)Note: This changes the staging path from
.specleft/specs/discovered/to.specleft/specs/_discovered/. Update all references in #124 (DraftSpec.output_dir), #136 (discover command), and #137 (start command).Utilities to reuse
src/specleft/utils/feature_writer.py→generate_feature_id(),generate_scenario_id(),validate_feature_id(),validate_scenario_id()Acceptance criteria
dry_run=Truereturns correct file paths without writing any filesSpecParserafter promotion (zero validation errors)SpecStepobjects — no string→parse round-tripfeature_idandscenario_idpassvalidate_feature_id()andvalidate_scenario_id()overwrite=False(default)overwrite=Truereplaces the existing fileSpecsConfig.from_directory(".specleft/specs/")does NOT load files from.specleft/specs/_discovered/_when recursingtests/discovery/test_spec_writer.pyfeatures/feature-spec-discovery.mdto cover the functionality introduced by this issue