diff --git a/features/feature-spec-discovery.md b/features/feature-spec-discovery.md index a8e0864..8877a75 100644 --- a/features/feature-spec-discovery.md +++ b/features/feature-spec-discovery.md @@ -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. @@ -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. diff --git a/src/specleft/commands/status.py b/src/specleft/commands/status.py index 8b604cc..17519c8 100644 --- a/src/specleft/commands/status.py +++ b/src/specleft/commands/status.py @@ -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 @@ -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 [] @@ -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: @@ -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, @@ -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, ) ) @@ -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) @@ -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("") @@ -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}") diff --git a/src/specleft/commands/types.py b/src/specleft/commands/types.py index 8b93ddb..2f39979 100644 --- a/src/specleft/commands/types.py +++ b/src/specleft/commands/types.py @@ -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) @@ -83,6 +84,7 @@ class ScenarioStatusEntry: test_file: str test_function: str reason: str | None + match_kind: str | None = None @dataclass(frozen=True) diff --git a/src/specleft/discovery/__init__.py b/src/specleft/discovery/__init__.py index 9ab14bf..7574e6e 100644 --- a/src/specleft/discovery/__init__.py +++ b/src/specleft/discovery/__init__.py @@ -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, diff --git a/src/specleft/discovery/spec_writer.py b/src/specleft/discovery/spec_writer.py index 622ff08..b9a71da 100644 --- a/src/specleft/discovery/spec_writer.py +++ b/src/specleft/discovery/spec_writer.py @@ -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, @@ -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) @@ -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 @@ -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, @@ -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"") @@ -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 diff --git a/src/specleft/discovery/traceability.py b/src/specleft/discovery/traceability.py new file mode 100644 index 0000000..f796e0d --- /dev/null +++ b/src/specleft/discovery/traceability.py @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Convention-based traceability inference for discovered test functions.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from specleft.discovery.models import DiscoveredItem, ItemKind +from specleft.schema import FeatureSpec, ScenarioSpec, SpecsConfig + + +@dataclass(frozen=True) +class TraceabilityLink: + """Inferred relationship between a test function and a spec scenario.""" + + test_file: Path + test_function: str + spec_file: Path + scenario_id: str + match_kind: str + confidence: float + + +@dataclass(frozen=True) +class _FeatureTraceabilityContext: + spec_file: Path + file_keys: frozenset[str] + scenarios: tuple[ScenarioSpec, ...] + + +def infer_traceability( + discovered: list[DiscoveredItem], + specs: SpecsConfig, +) -> list[TraceabilityLink]: + """Infer test-to-scenario links using conservative naming conventions.""" + if not specs.features: + return [] + + contexts = [_build_feature_context(feature) for feature in specs.features] + links: list[TraceabilityLink] = [] + seen: set[tuple[str, str, str, str, str, float]] = set() + + for item in discovered: + if item.kind is not ItemKind.TEST_FUNCTION or item.file_path is None: + continue + + file_key = _normalize_filename(item.file_path.stem) + if not file_key: + continue + + function_key = _normalize_function(item.name) + for context in contexts: + if file_key not in context.file_keys: + continue + + matches = _scenario_matches(function_key, context.scenarios) + if not matches and len(context.scenarios) == 1: + scenario = context.scenarios[0] + scenario_key = _normalize_scenario_id(scenario.scenario_id) + if scenario_key: + matches = [(scenario.scenario_id, "filename", 0.5)] + + for scenario_id, match_kind, confidence in matches: + dedupe_key = ( + item.file_path.as_posix(), + item.name, + context.spec_file.as_posix(), + scenario_id, + match_kind, + confidence, + ) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + links.append( + TraceabilityLink( + test_file=item.file_path, + test_function=item.name, + spec_file=context.spec_file, + scenario_id=scenario_id, + match_kind=match_kind, + confidence=confidence, + ) + ) + + return sorted( + links, + key=lambda link: ( + link.spec_file.as_posix(), + link.scenario_id, + link.test_file.as_posix(), + link.test_function, + ), + ) + + +def _build_feature_context(feature: FeatureSpec) -> _FeatureTraceabilityContext: + if feature.source_file is not None: + spec_file = feature.source_file + elif feature.source_dir is not None: + spec_file = feature.source_dir / "_feature.md" + else: + spec_file = Path(f"{feature.feature_id}.md") + + file_keys = { + _normalize_filename(feature.feature_id), + _normalize_filename(spec_file.stem), + } + if feature.source_dir is not None: + file_keys.add(_normalize_filename(feature.source_dir.name)) + + scenarios = tuple( + scenario + for story in feature.stories + for scenario in story.scenarios + if scenario.scenario_id + ) + return _FeatureTraceabilityContext( + spec_file=spec_file, + file_keys=frozenset(value for value in file_keys if value), + scenarios=scenarios, + ) + + +def _scenario_matches( + function_key: str, + scenarios: tuple[ScenarioSpec, ...], +) -> list[tuple[str, str, float]]: + if not function_key: + return [] + + matches: list[tuple[str, str, float]] = [] + for scenario in scenarios: + scenario_key = _normalize_scenario_id(scenario.scenario_id) + if not scenario_key: + continue + + if function_key == scenario_key: + matches.append((scenario.scenario_id, "both", 0.9)) + continue + + if _common_prefix_ratio(function_key, scenario_key) >= 0.6: + matches.append((scenario.scenario_id, "function", 0.6)) + + return matches + + +def _common_prefix_ratio(left: str, right: str) -> float: + if not left or not right: + return 0.0 + + left_tokens = left.split() + right_tokens = right.split() + if not left_tokens or not right_tokens: + return 0.0 + + common_tokens = 0 + for left_token, right_token in zip(left_tokens, right_tokens, strict=False): + if left_token != right_token: + break + common_tokens += 1 + + if common_tokens == 0: + return 0.0 + + prefix = " ".join(left_tokens[:common_tokens]).strip() + if not prefix: + return 0.0 + + shorter = min(len(left), len(right)) + if shorter == 0: + return 0.0 + return len(prefix) / shorter + + +def _normalize_filename(stem: str) -> str: + return _normalize_label(stem, strip_test_prefix=True) + + +def _normalize_function(function_name: str) -> str: + return _normalize_label(function_name, strip_test_prefix=True) + + +def _normalize_scenario_id(scenario_id: str) -> str: + return _normalize_label(scenario_id, strip_test_prefix=False) + + +def _normalize_label(value: str, *, strip_test_prefix: bool) -> str: + normalized = value.strip().lower().replace("-", " ").replace("_", " ") + if strip_test_prefix: + if normalized.startswith("test "): + normalized = normalized[len("test ") :] + elif normalized.startswith("test"): + normalized = normalized[len("test") :] + return " ".join(normalized.split()) diff --git a/tests/commands/test_status.py b/tests/commands/test_status.py index 9b4623f..6d51a91 100644 --- a/tests/commands/test_status.py +++ b/tests/commands/test_status.py @@ -194,3 +194,44 @@ def test_status_json_canonical_shape(self) -> None: assert "priority" in scenario assert "tags" in scenario assert "steps" in scenario + + def test_status_uses_convention_match_when_decorator_is_missing(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + create_single_file_feature_spec( + Path("."), + feature_id="user-authentication", + scenario_id="valid-credentials", + ) + Path("tests").mkdir(parents=True, exist_ok=True) + Path("tests/test_user_authentication.py").write_text(""" +def test_valid_credentials(): + pass +""") + + result = runner.invoke(cli, ["status", "--format", "json", "--verbose"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + scenario = payload["features"][0]["scenarios"][0] + assert scenario["status"] == "implemented" + assert scenario["match_kind"] == "convention" + assert scenario["test_file"] == "tests/test_user_authentication.py" + + def test_status_table_marks_convention_matches(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + create_single_file_feature_spec( + Path("."), + feature_id="user-authentication", + scenario_id="valid-credentials", + ) + Path("tests").mkdir(parents=True, exist_ok=True) + Path("tests/test_user_authentication.py").write_text(""" +def test_valid_credentials(): + pass +""") + + result = runner.invoke(cli, ["status", "--format", "table"]) + assert result.exit_code == 0 + assert "✓ (convention)" in result.output + assert "valid-credentials" in result.output diff --git a/tests/discovery/test_spec_writer.py b/tests/discovery/test_spec_writer.py index c863cec..bffeb7c 100644 --- a/tests/discovery/test_spec_writer.py +++ b/tests/discovery/test_spec_writer.py @@ -20,6 +20,7 @@ SupportedLanguage, ) from specleft.discovery.spec_writer import generate_draft_specs +from specleft.discovery.traceability import TraceabilityLink from specleft.parser import SpecParser from specleft.schema import SpecStep, SpecsConfig, StepType from specleft.utils.feature_writer import validate_feature_id, validate_scenario_id @@ -107,6 +108,31 @@ def test_generate_draft_specs_writes_parser_compatible_markdown(tmp_path: Path) validate_scenario_id(scenario_id) +def test_generate_draft_specs_writes_linked_tests_frontmatter(tmp_path: Path) -> None: + output_dir = tmp_path / ".specleft" / "specs" / "_discovered" + feature = _draft_feature() + links = [ + TraceabilityLink( + test_file=Path("tests/test_user_authentication.py"), + test_function="test_valid_credentials", + spec_file=tmp_path / ".specleft" / "specs" / "user-authentication.md", + scenario_id="valid-credentials", + match_kind="both", + confidence=0.9, + ) + ] + + written_paths = generate_draft_specs( + [feature], output_dir, traceability_links=links + ) + content = written_paths[0].read_text() + + assert "linked_tests:" in content + assert "file: tests/test_user_authentication.py" in content + assert "function: test_valid_credentials" in content + assert "confidence: 0.9" in content + + def test_existing_file_is_not_overwritten_by_default(tmp_path: Path) -> None: output_dir = tmp_path / "out" output_dir.mkdir(parents=True) diff --git a/tests/discovery/test_traceability.py b/tests/discovery/test_traceability.py new file mode 100644 index 0000000..63b3a70 --- /dev/null +++ b/tests/discovery/test_traceability.py @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Tests for convention-based discovery traceability inference.""" + +from __future__ import annotations + +from pathlib import Path + +from specleft.discovery.models import ( + DiscoveredItem, + ItemKind, + SupportedLanguage, + TestFunctionMeta as DiscoveryTestFunctionMeta, +) +from specleft.discovery.traceability import infer_traceability +from specleft.schema import SpecsConfig + + +def _write_feature_spec( + specs_dir: Path, *, feature_id: str, scenario_title: str +) -> Path: + path = specs_dir / f"{feature_id}.md" + path.write_text( + "\n".join( + [ + f"# Feature: {feature_id.replace('-', ' ').title()}", + "", + "## Scenarios", + "", + f"### Scenario: {scenario_title}", + "priority: medium", + "", + "- Given a precondition", + "- When an action happens", + "- Then an outcome occurs", + "", + ] + ) + ) + return path + + +def _discovered_test_item(*, file_path: str, function_name: str) -> DiscoveredItem: + return DiscoveredItem( + kind=ItemKind.TEST_FUNCTION, + name=function_name, + file_path=Path(file_path), + line_number=10, + language=SupportedLanguage.PYTHON, + raw_text=None, + metadata=DiscoveryTestFunctionMeta(framework="pytest").model_dump(), + confidence=0.9, + ) + + +def test_infer_traceability_links_matching_file_and_function(tmp_path: Path) -> None: + specs_dir = tmp_path / ".specleft" / "specs" + specs_dir.mkdir(parents=True) + spec_file = _write_feature_spec( + specs_dir, + feature_id="user-authentication", + scenario_title="valid-credentials", + ) + config = SpecsConfig.from_directory(specs_dir) + + links = infer_traceability( + [ + _discovered_test_item( + file_path="tests/test_user_authentication.py", + function_name="test_valid_credentials", + ) + ], + config, + ) + + assert len(links) == 1 + link = links[0] + assert link.test_file == Path("tests/test_user_authentication.py") + assert link.test_function == "test_valid_credentials" + assert link.spec_file == spec_file + assert link.scenario_id == "valid-credentials" + assert link.match_kind == "both" + assert link.confidence == 0.9 + + +def test_infer_traceability_avoids_false_positive_for_payment_file( + tmp_path: Path, +) -> None: + specs_dir = tmp_path / ".specleft" / "specs" + specs_dir.mkdir(parents=True) + _write_feature_spec( + specs_dir, + feature_id="user-authentication", + scenario_title="valid-credentials", + ) + config = SpecsConfig.from_directory(specs_dir) + + links = infer_traceability( + [ + _discovered_test_item( + file_path="tests/test_payment.py", + function_name="test_valid_credentials", + ) + ], + config, + ) + + assert links == [] + + +def test_infer_traceability_returns_empty_for_empty_specs() -> None: + links = infer_traceability( + [ + _discovered_test_item( + file_path="tests/test_user_authentication.py", + function_name="test_valid_credentials", + ) + ], + SpecsConfig(features=[]), + ) + + assert links == [] + + +def test_infer_traceability_prefix_match_uses_function_kind(tmp_path: Path) -> None: + specs_dir = tmp_path / ".specleft" / "specs" + specs_dir.mkdir(parents=True) + _write_feature_spec( + specs_dir, + feature_id="user-authentication", + scenario_title="valid-credentials", + ) + config = SpecsConfig.from_directory(specs_dir) + + links = infer_traceability( + [ + _discovered_test_item( + file_path="tests/test_user_authentication.py", + function_name="test_valid_credentials_with_mfa", + ) + ], + config, + ) + + assert len(links) == 1 + assert links[0].match_kind == "function" + assert links[0].confidence == 0.6 + + +def test_infer_traceability_uses_filename_match_for_single_scenario( + tmp_path: Path, +) -> None: + specs_dir = tmp_path / ".specleft" / "specs" + specs_dir.mkdir(parents=True) + _write_feature_spec( + specs_dir, + feature_id="user-authentication", + scenario_title="valid-credentials", + ) + config = SpecsConfig.from_directory(specs_dir) + + links = infer_traceability( + [ + _discovered_test_item( + file_path="tests/test_user_authentication.py", + function_name="test_unrelated_case", + ) + ], + config, + ) + + assert len(links) == 1 + assert links[0].match_kind == "filename" + assert links[0].confidence == 0.5