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
25 changes: 25 additions & 0 deletions features/feature-spec-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,25 @@ Add shared discovery infrastructure for Issues #125 and #126: centralized parser
**When** `SpecsConfig.from_directory(".specleft/specs")` parses specs
**Then** `_discovered` is skipped because directories prefixed with `_` are ignored during recursion.

### Story 15: Convention-based traceability fallback
**Scenario:** As a status consumer, I need convention matching when decorators are absent.
**Given** `.specleft/specs/user-authentication.md` contains scenario `valid-credentials`
**And** a test file `tests/test_user_authentication.py` defines `test_valid_credentials`
**When** `specleft status` evaluates implementation coverage
**Then** the scenario is marked implemented via convention matching
**And** table output shows `✓ (convention)` for that scenario.

**Scenario:** As a traceability engine, I need false-positive protection.
**Given** `.specleft/specs/user-authentication.md` exists
**And** a test file `tests/test_payment.py` contains similarly named test functions
**When** `infer_traceability(discovered, specs)` is called
**Then** no link is produced because filename matching must succeed first.

**Scenario:** As a discovery writer, I need inferred links embedded in draft specs.
**Given** inferred `TraceabilityLink` records for a matched scenario
**When** `generate_draft_specs(..., traceability_links=links)` writes markdown
**Then** the scenario block includes a `linked_tests` frontmatter section with file, function, and confidence values.

## 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 @@ -274,3 +293,9 @@ Add shared discovery infrastructure for Issues #125 and #126: centralized parser
- 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/`.
- `infer_traceability(discovered, specs)` links `tests/test_user_authentication.py::test_valid_credentials` to `user-authentication.md` scenario `valid-credentials`.
- `infer_traceability(...)` does not link `tests/test_payment.py` to `user-authentication.md`.
- `infer_traceability(...)` accepts pre-loaded `SpecsConfig` and returns `[]` when no features exist.
- `specleft status` marks convention-linked scenarios as implemented with `match_kind="convention"` in verbose JSON output.
- `specleft status --format table` displays `✓ (convention)` for convention-linked scenarios.
- `generate_draft_specs(..., traceability_links=...)` emits `linked_tests` frontmatter for matched scenarios.
133 changes: 128 additions & 5 deletions src/specleft/commands/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
from specleft.commands.input_validation import validate_id_parameter
from specleft.commands.output import json_dumps, resolve_output_format
from specleft.commands.types import ScenarioStatus, ScenarioStatusEntry, StatusSummary
from specleft.discovery.models import (
DiscoveredItem,
ItemKind,
SupportedLanguage,
TestFunctionMeta,
)
from specleft.discovery.traceability import TraceabilityLink, infer_traceability
from specleft.schema import SpecsConfig
from specleft.utils.messaging import print_support_footer
from specleft.utils.specs_dir import resolve_specs_dir
Expand Down Expand Up @@ -58,6 +65,93 @@ def _index_specleft_tests(tests_dir: Path) -> dict[str, dict[str, object]]:
return scenario_map


def _discover_python_test_functions_for_traceability(
tests_dir: Path,
) -> list[DiscoveredItem]:
items: list[DiscoveredItem] = []
metadata = TestFunctionMeta(framework="unknown").model_dump()

for file_path in _iter_py_files(tests_dir):
try:
content = file_path.read_text()
except OSError:
continue

try:
tree = ast.parse(content)
except SyntaxError:
continue

for function_node in _iter_test_nodes(tree):
relative_path = _to_relative(file_path)
items.append(
DiscoveredItem(
kind=ItemKind.TEST_FUNCTION,
name=function_node.name,
file_path=relative_path,
line_number=getattr(function_node, "lineno", None),
language=SupportedLanguage.PYTHON,
raw_text=None,
metadata=metadata,
confidence=0.6,
)
)

return items


def _iter_test_nodes(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
test_nodes: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
for node in tree.body:
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
if node.name.startswith("test_"):
test_nodes.append(node)
continue

if not isinstance(node, ast.ClassDef) or not node.name.startswith("Test"):
continue

for member in node.body:
if isinstance(member, ast.FunctionDef | ast.AsyncFunctionDef) and (
member.name.startswith("test_")
):
test_nodes.append(member)

return test_nodes


def _to_relative(file_path: Path) -> Path:
try:
return file_path.relative_to(Path.cwd())
except ValueError:
return file_path


def _build_convention_index(
links: list[TraceabilityLink],
) -> dict[tuple[str, str], TraceabilityLink]:
index: dict[tuple[str, str], TraceabilityLink] = {}
for link in links:
key = (link.spec_file.as_posix(), link.scenario_id)
current = index.get(key)
if current is None or link.confidence > current.confidence:
index[key] = link
return index


def _traceability_spec_file(feature: object) -> Path:
source_file = getattr(feature, "source_file", None)
if isinstance(source_file, Path):
return source_file

source_dir = getattr(feature, "source_dir", None)
if isinstance(source_dir, Path):
return source_dir / "_feature.md"

feature_id = str(getattr(feature, "feature_id", "feature"))
return Path(f"{feature_id}.md")


def _build_status_table_rows(entries: list[ScenarioStatusEntry]) -> list[str]:
if not entries:
return []
Expand Down Expand Up @@ -110,6 +204,12 @@ def build_status_entries(
story_id: str | None = None,
) -> list[ScenarioStatusEntry]:
scenario_map = _index_specleft_tests(tests_dir)
convention_index = _build_convention_index(
infer_traceability(
_discover_python_test_functions_for_traceability(tests_dir),
config,
)
)
entries: list[ScenarioStatusEntry] = []

for feature in config.features:
Expand All @@ -127,12 +227,29 @@ def build_status_entries(
tests_dir, feature.feature_id, story.story_id
)

traceability_spec_file = _traceability_spec_file(feature)
for scenario in story.scenarios:
info = scenario_map.get(scenario.scenario_id)
status = _determine_scenario_status(
test_file_path=str(test_file),
test_info=info,
)
if info is None:
convention_link = convention_index.get(
(traceability_spec_file.as_posix(), scenario.scenario_id)
)
else:
convention_link = None

if convention_link is not None:
status = ScenarioStatus(
status="implemented",
test_file=convention_link.test_file.as_posix(),
test_function=convention_link.test_function,
reason=None,
match_kind="convention",
)
else:
status = _determine_scenario_status(
test_file_path=str(test_file),
test_info=info,
)
entries.append(
ScenarioStatusEntry(
feature=feature,
Expand All @@ -143,6 +260,7 @@ def build_status_entries(
test_function=status.test_function
or scenario.test_function_name,
reason=status.reason,
match_kind=status.match_kind,
)
)

Expand Down Expand Up @@ -223,6 +341,8 @@ def build_status_json(
status_info["execution_time"] = entry.scenario.execution_time.value
if entry.reason:
status_info["reason"] = entry.reason
if entry.match_kind:
status_info["match_kind"] = entry.match_kind
scenario_status[entry.scenario.scenario_id] = status_info

# Get all scenarios from entries (flattened from stories)
Expand Down Expand Up @@ -302,7 +422,8 @@ def print_status_table(
if entry.status != "implemented":
continue
path = f"{entry.feature.feature_id}/{entry.story.story_id}/{entry.scenario.scenario_id}"
click.echo(f"✓ {path}")
marker = "✓ (convention)" if entry.match_kind == "convention" else "✓"
click.echo(f"{marker} {path}")
click.echo(f" → {entry.test_file}::{entry.test_function}")
click.echo("")

Expand Down Expand Up @@ -343,6 +464,8 @@ def print_status_table(
# Show scenarios directly (flattened from stories)
for entry in feature_entries:
marker = "✓" if entry.status == "implemented" else "⚠"
if entry.status == "implemented" and entry.match_kind == "convention":
marker = "✓ (convention)"
path = f"{entry.test_file}::{entry.test_function}"
suffix = "" if entry.status == "implemented" else " (skipped)"
click.echo(f" {marker} {entry.scenario.scenario_id:<25} {path}{suffix}")
Expand Down
2 changes: 2 additions & 0 deletions src/specleft/commands/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class ScenarioStatus:
test_file: str | None
test_function: str | None
reason: str | None
match_kind: str | None = None


@dataclass(frozen=True)
Expand All @@ -83,6 +84,7 @@ class ScenarioStatusEntry:
test_file: str
test_function: str
reason: str | None
match_kind: str | None = None


@dataclass(frozen=True)
Expand Down
1 change: 1 addition & 0 deletions src/specleft/discovery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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.traceability import TraceabilityLink, infer_traceability
from specleft.discovery.pipeline import (
BaseMiner,
DiscoveryPipeline,
Expand Down
53 changes: 51 additions & 2 deletions src/specleft/discovery/spec_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path

from specleft.discovery.models import DraftFeature, DraftScenario
from specleft.discovery.traceability import TraceabilityLink
from specleft.schema import SpecStep
from specleft.utils.feature_writer import (
generate_feature_id,
Expand All @@ -27,11 +28,13 @@ def generate_draft_specs(
output_dir: Path,
dry_run: bool = False,
overwrite: bool = False,
traceability_links: list[TraceabilityLink] | None = None,
) -> 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)

traceability_index = _index_traceability_links(traceability_links or [])
output_paths: list[Path] = []
for feature in draft_features:
feature_id = _resolve_feature_id(feature)
Expand All @@ -44,7 +47,9 @@ def generate_draft_specs(
if dry_run:
continue

feature_path.write_text(_render_feature(feature))
feature_path.write_text(
_render_feature(feature, feature_id, traceability_index)
)

return output_paths

Expand All @@ -69,7 +74,11 @@ def _resolve_scenario_id(scenario: DraftScenario) -> str:
return scenario_id


def _render_feature(feature: DraftFeature) -> str:
def _render_feature(
feature: DraftFeature,
feature_id: str,
traceability_index: dict[tuple[str, str], list[TraceabilityLink]],
) -> str:
lines: list[str] = [
f"# Feature: {feature.name}",
_GENERATED_NOTE,
Expand All @@ -90,6 +99,11 @@ def _render_feature(feature: DraftFeature) -> str:
]
)

feature_key = feature_id.lower().replace("_", "-")
scenario_links = traceability_index.get((feature_key, scenario_id), [])
if scenario_links:
lines.extend(_render_linked_tests_frontmatter(scenario_links))

source_line = _source_line(scenario)
if source_line is not None:
lines.append(f"<!-- source: {source_line} -->")
Expand Down Expand Up @@ -125,3 +139,38 @@ def _source_line(scenario: DraftScenario) -> str | None:

def _format_steps(steps: list[SpecStep]) -> list[str]:
return [f"- {step.type.value.capitalize()} {step.description}" for step in steps]


def _index_traceability_links(
links: list[TraceabilityLink],
) -> dict[tuple[str, str], list[TraceabilityLink]]:
indexed: dict[tuple[str, str], list[TraceabilityLink]] = {}
for link in links:
feature_key = link.spec_file.stem.strip().lower().replace("_", "-")
scenario_key = link.scenario_id.strip().lower()
if not feature_key or not scenario_key:
continue
indexed.setdefault((feature_key, scenario_key), []).append(link)
return indexed


def _render_linked_tests_frontmatter(links: list[TraceabilityLink]) -> list[str]:
ordered = sorted(
links,
key=lambda link: (
-link.confidence,
link.test_file.as_posix(),
link.test_function,
),
)
lines = ["---", "linked_tests:"]
for link in ordered:
lines.extend(
[
f" - file: {link.test_file.as_posix()}",
f" function: {link.test_function}",
f" confidence: {link.confidence:.1f}",
]
)
lines.extend(["---", ""])
return lines
Loading