diff --git a/README.md b/README.md index 1119cd6..72f5eeb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,15 @@ pip install specleft specleft init ``` +### Path 0: New to SpecLeft? Start here + +Scans your codebase, discovers features from existing code, and shows a side-by-side report of code vs specs. + +```bash +cd my-project +specleft start +``` + ### Path 1: Add one feature (and generate a test skeleton) Create a feature, then add a scenario and generate a skeleton test for it: @@ -86,6 +95,7 @@ If you are integrating SpecLeft into an agent loop, it's recommended to install Otherwise begin with: ```bash +specleft start --format json specleft doctor --format json specleft contract --format json specleft features stats --format json diff --git a/docs/SKILL.md b/docs/SKILL.md index 5f6fa7d..be5dab5 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -5,6 +5,7 @@ All commands below run in compact mode. ## Workflow +0. specleft start --format json 1. specleft next --limit 1 2. Implement test logic 3. specleft features validate @@ -27,6 +28,11 @@ All commands below run in compact mode. ## Features +## Discovery + +### Start discovery report +`specleft start --format json [PROJECT_ROOT] [--save] [--specs-dir PATH]` + ### Validate specs `specleft features validate --format json [--dir PATH] [--strict]` Validate before generating tests. `--strict` treats warnings as errors. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f89f191..915cef8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -173,6 +173,28 @@ Options: --format [table|json] Output format (default: table) ``` +## Start + +### `specleft start` + +Discovery-first entrypoint for existing codebases. Runs health checks, scans code, +discovers features, and compares discovered scenarios against existing specs. + +```bash +specleft start [OPTIONS] [PROJECT_ROOT] + +Options: + --format [table|json] Output format (default: auto-detect TTY) + --save Write draft specs to .specleft/specs/_discovered/ + --specs-dir PATH Existing specs dir to compare against + --pretty Pretty-print JSON output +``` + +`--save` stages drafts in `.specleft/specs/_discovered/` only. It does not +promote them automatically to active specs. + +To generate and write specs directly, use `specleft discover`. + ## Discover ### `specleft discover` @@ -196,6 +218,18 @@ Table output example: ```text Scanning project... +✓ Detected: Python (pytest), 847 files, 142 test functions + +Discovering features... +✓ Found 14 features, 47 scenarios + +Your project vs your specs: +┌──────────────────────────┬───────────┬─────────────┐ +│ Feature │ Code │ Specs │ +├──────────────────────────┼───────────┼─────────────┤ +│ user-authentication │ 12 tests │ none │ +│ payment-processing │ 8 tests │ 6 specs │ +└──────────────────────────┴───────────┴─────────────┘ ✓ Python (pytest) — 142 test functions ✓ API routes — 24 routes ✓ Docstrings — 67 items @@ -216,6 +250,31 @@ JSON output schema: ```json { + "project": { + "root": "/path/to/project", + "languages": ["python"], + "test_frameworks": ["pytest"], + "files_scanned": 847 + }, + "discovery": { + "features": 14, + "scenarios": 47, + "items_by_kind": { + "test_function": 142, + "api_route": 24, + "docstring": 67, + "git_commit": 200 + } + }, + "comparison": [ + { + "feature_id": "user-authentication", + "name": "User Authentication", + "code_scenarios": 12, + "spec_scenarios": 0 + } + ], + "saved": false, "features": [ { "feature_id": "user-authentication", diff --git a/docs/getting-started.md b/docs/getting-started.md index 0df39ad..f1da172 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,12 +1,21 @@ # SpecLeft Getting Started +## Quickstart (existing codebase) + +```bash +specleft start +``` + +If you already have code, `specleft start` is faster. It discovers specs from +your existing tests, routes, and docstrings. + ## Install ```bash pip install -e ".[dev]" ``` -## Create Specs +## Create Specs (greenfield alternative) ```bash mkdir -p .specleft/specs/calculator/addition diff --git a/features/feature-spec-discovery.md b/features/feature-spec-discovery.md index 35d656e..6ef7d12 100644 --- a/features/feature-spec-discovery.md +++ b/features/feature-spec-discovery.md @@ -62,6 +62,19 @@ Add shared discovery infrastructure for Issues #125 and #126: centralized parser **When** I call `build_default_pipeline(root).run()` **Then** a `DiscoveryReport` is returned with run duration, detected languages, miner results, and total item counts. +### Story 6.1: Start command onboarding flow +**Scenario:** As a new user with existing code, I need a single command to discover specs without writing by default. +**Given** a valid Python project +**When** I run `specleft start` +**Then** it performs health check, project detection, discovery, and side-by-side code-vs-spec comparison. +**And** no files are written to `.specleft/specs/_discovered/` unless `--save` is passed. + +**Scenario:** As a pipeline consumer, I need resilient startup output. +**Given** a discovery run where the git miner fails (for example, non-git directory) +**When** I run `specleft start --format json` +**Then** the command exits successfully +**And** miner failures are included in `errors[]`. + ### Story 7: Shared docstring and JSDoc mining **Scenario:** As a discovery pipeline, I need intent-rich text signals from source code comments. **Given** configured source directories and a shared miner context @@ -322,6 +335,10 @@ Add shared discovery infrastructure for Issues #125 and #126: centralized parser - `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. +- `specleft start` exits 0 on a healthy Python project and returns table/json output. +- `specleft start --format json` returns `project`, `discovery`, `comparison`, `saved`, and `errors`. +- `specleft start --save` writes draft markdown files to `.specleft/specs/_discovered/`. +- `specleft start` without `--save` performs a read-only discovery run. - `specleft discover --dry-run` does not write files and reports planned outputs. - `specleft discover --format json` exits `0` with valid JSON output even when miners report errors. - `specleft discover` writes drafts to `.specleft/specs/_discovered/` by default and supports `--output-dir` override. diff --git a/src/specleft/cli/main.py b/src/specleft/cli/main.py index 1fd8c06..6dd9b80 100644 --- a/src/specleft/cli/main.py +++ b/src/specleft/cli/main.py @@ -19,6 +19,7 @@ next_command, plan, skill_group, + start, status, test, ) @@ -53,6 +54,7 @@ def cli() -> None: cli.add_command(skill_group) cli.add_command(guide) cli.add_command(mcp) +cli.add_command(start) __all__ = ["cli"] diff --git a/src/specleft/commands/__init__.py b/src/specleft/commands/__init__.py index 578fa22..d4f8c03 100644 --- a/src/specleft/commands/__init__.py +++ b/src/specleft/commands/__init__.py @@ -16,6 +16,7 @@ from specleft.commands.next import next_command from specleft.commands.plan import plan from specleft.commands.skill import skill_group +from specleft.commands.start import start from specleft.commands.status import status from specleft.commands.test import test @@ -31,6 +32,7 @@ "next_command", "plan", "skill_group", + "start", "status", "test", ] diff --git a/src/specleft/commands/start.py b/src/specleft/commands/start.py new file mode 100644 index 0000000..edd575f --- /dev/null +++ b/src/specleft/commands/start.py @@ -0,0 +1,350 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Start command for zero-to-value discovery onboarding.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import click + +from specleft.commands.doctor import _build_doctor_checks, _build_doctor_output +from specleft.commands.output import json_dumps, resolve_output_format +from specleft.discovery.file_index import FileIndex +from specleft.discovery.framework_detector import FrameworkDetector +from specleft.discovery.grouping import group_items +from specleft.discovery.models import ( + DEFAULT_DISCOVERY_OUTPUT_DIR, + DraftFeature, + ItemKind, +) +from specleft.discovery.pipeline import build_default_pipeline +from specleft.discovery.spec_writer import generate_draft_specs +from specleft.schema import SpecsConfig +from specleft.utils.specs_dir import DEFAULT_SPECS_DIR, FALLBACK_SPECS_DIR + + +@dataclass(frozen=True) +class _ComparisonRow: + feature_id: str + name: str + code_scenarios: int + spec_scenarios: int + + +def _run_health_check() -> dict[str, Any]: + checks = _build_doctor_checks(verify_skill=False) + return _build_doctor_output(checks) + + +def _resolve_specs_dir_for_root( + root: Path, + preferred: str | Path | None, +) -> Path: + if preferred: + preferred_path = Path(preferred) + if preferred_path.is_absolute(): + return preferred_path + return root / preferred_path + + project_default = root / DEFAULT_SPECS_DIR + if project_default.exists(): + return project_default + + project_fallback = root / FALLBACK_SPECS_DIR + if project_fallback.exists(): + return project_fallback + + return project_default + + +def _normalize_feature_id(feature_id: str) -> str: + return feature_id.strip().lower().replace("_", "-") + + +def _load_specs_counts(specs_dir: Path) -> tuple[dict[str, int], list[str]]: + if not specs_dir.exists(): + return {}, [] + + try: + config = SpecsConfig.from_directory(specs_dir) + except Exception as exc: + return {}, [f"specs_parse: {exc}"] + + counts: dict[str, int] = {} + for feature in config.features: + normalized = _normalize_feature_id(feature.feature_id) + counts[normalized] = len(feature.all_scenarios) + return counts, [] + + +def _build_comparison_rows( + features: list[DraftFeature], + specs_counts: dict[str, int], +) -> list[_ComparisonRow]: + rows: list[_ComparisonRow] = [] + for feature in sorted(features, key=lambda item: item.feature_id): + code_scenarios = len(feature.scenarios) + spec_scenarios = specs_counts.get(_normalize_feature_id(feature.feature_id), 0) + rows.append( + _ComparisonRow( + feature_id=feature.feature_id, + name=feature.name, + code_scenarios=code_scenarios, + spec_scenarios=spec_scenarios, + ) + ) + return rows + + +def _missing_scenarios(rows: list[_ComparisonRow]) -> int: + return sum(max(row.code_scenarios - row.spec_scenarios, 0) for row in rows) + + +def _items_by_kind_counts(report: Any) -> dict[str, int]: + return {kind.value: len(report.items_by_kind.get(kind, [])) for kind in ItemKind} + + +def _frameworks_list( + frameworks: dict[Any, list[str]], +) -> list[str]: + flattened: list[str] = [] + seen: set[str] = set() + for names in frameworks.values(): + for name in names: + lowered = name.strip().lower() + if not lowered or lowered in seen: + continue + seen.add(lowered) + flattened.append(lowered) + return flattened + + +def _render_detection_summary( + *, + languages: list[str], + frameworks: list[str], + files_scanned: int, + test_functions: int, +) -> str: + if not languages: + language_label = "unknown" + else: + language_label = ", ".join(language.title() for language in languages) + + if frameworks: + framework_label = ", ".join(frameworks) + return ( + f"Detected: {language_label} ({framework_label}), " + f"{files_scanned} files, {test_functions} test functions" + ) + return f"Detected: {language_label}, {files_scanned} files, {test_functions} test functions" + + +def _build_table_lines(rows: list[_ComparisonRow]) -> list[str]: + header_feature = "Feature" + header_code = "Code" + header_specs = "Specs" + + table_rows = [ + ( + row.feature_id, + f"{row.code_scenarios} tests", + "none" if row.spec_scenarios == 0 else f"{row.spec_scenarios} specs", + ) + for row in rows + ] + + feature_width = max( + [len(header_feature), *[len(row[0]) for row in table_rows]], default=0 + ) + code_width = max( + [len(header_code), *[len(row[1]) for row in table_rows]], default=0 + ) + specs_width = max( + [len(header_specs), *[len(row[2]) for row in table_rows]], default=0 + ) + + top = f"┌{'─' * (feature_width + 2)}┬{'─' * (code_width + 2)}┬{'─' * (specs_width + 2)}┐" + divider = f"├{'─' * (feature_width + 2)}┼{'─' * (code_width + 2)}┼{'─' * (specs_width + 2)}┤" + bottom = f"└{'─' * (feature_width + 2)}┴{'─' * (code_width + 2)}┴{'─' * (specs_width + 2)}┘" + + lines = [ + top, + f"│ {header_feature.ljust(feature_width)} │ {header_code.ljust(code_width)} │ {header_specs.ljust(specs_width)} │", + divider, + ] + for feature, code, specs in table_rows: + lines.append( + f"│ {feature.ljust(feature_width)} │ {code.ljust(code_width)} │ {specs.ljust(specs_width)} │" + ) + lines.append(bottom) + return lines + + +def _print_table_output( + *, + root: Path, + rows: list[_ComparisonRow], + languages: list[str], + frameworks: list[str], + files_scanned: int, + items_by_kind: dict[str, int], + feature_count: int, + scenario_count: int, + saved_paths: list[Path], + errors: list[str], +) -> None: + test_functions = items_by_kind.get(ItemKind.TEST_FUNCTION.value, 0) + click.echo("Scanning project...") + click.echo( + f"✓ {_render_detection_summary(languages=languages, frameworks=frameworks, files_scanned=files_scanned, test_functions=test_functions)}" + ) + click.echo("") + click.echo("Discovering features...") + click.echo(f"✓ Found {feature_count} features, {scenario_count} scenarios") + click.echo("") + click.echo("Your project vs your specs:") + if rows: + for line in _build_table_lines(rows): + click.echo(line) + else: + click.echo("(no discovered features)") + click.echo("") + click.echo( + f"You have {_missing_scenarios(rows)} test scenarios with no specifications." + ) + click.echo("") + click.echo("→ Run `specleft discover` to generate specs") + click.echo("→ Run `specleft start --save` to save these results") + + if saved_paths: + click.echo("") + click.echo("Saved draft specs:") + for path in saved_paths: + click.echo(f"- {path}") + + if errors: + click.echo("") + click.echo("Errors:") + for error in errors: + click.echo(f"- {error}") + click.echo(f"Project root: {root}") + + +@click.command("start") +@click.argument( + "project_root", + required=False, + type=click.Path(exists=True, file_okay=False, path_type=Path), +) +@click.option( + "--format", + "format_type", + type=click.Choice(["table", "json"], case_sensitive=False), + default=None, + help="Output format. Defaults to table in a terminal and json otherwise.", +) +@click.option( + "--save", + is_flag=True, + help="Persist draft specs to .specleft/specs/_discovered/.", +) +@click.option( + "--specs-dir", + type=click.Path(path_type=Path), + default=None, + help="Existing specs dir to compare against.", +) +@click.option("--pretty", is_flag=True, help="Pretty-print JSON output.") +def start( + project_root: Path | None, + format_type: str | None, + save: bool, + specs_dir: Path | None, + pretty: bool, +) -> None: + """Run a discovery-first onboarding flow for existing codebases.""" + selected_format = resolve_output_format(format_type) + root = (project_root or Path.cwd()).resolve() + + health = _run_health_check() + if not health.get("healthy"): + message = ( + "Health check failed. Run `specleft doctor` to diagnose your environment." + ) + if selected_format == "json": + payload = { + "error": message, + "checks": health.get("checks", {}), + "errors": health.get("errors", []), + } + click.echo(json_dumps(payload, pretty=pretty)) + else: + click.secho(message, fg="red", err=True) + sys.exit(1) + + file_index = FileIndex(root) + frameworks = FrameworkDetector().detect(root, file_index) + report = build_default_pipeline(root).run() + features = group_items(report.all_items) + + comparison_specs_dir = _resolve_specs_dir_for_root(root, specs_dir) + specs_counts, specs_errors = _load_specs_counts(comparison_specs_dir) + comparison_rows = _build_comparison_rows(features, specs_counts) + + saved_paths: list[Path] = [] + if save: + output_dir = root / DEFAULT_DISCOVERY_OUTPUT_DIR + saved_paths = generate_draft_specs(features, output_dir, dry_run=False) + + scenario_count = sum(len(feature.scenarios) for feature in features) + languages = [language.value for language in report.languages_detected] + framework_names = _frameworks_list(frameworks) + items_by_kind = _items_by_kind_counts(report) + errors = [*report.errors, *specs_errors] + + if selected_format == "json": + payload = { + "project": { + "root": str(root), + "languages": languages, + "test_frameworks": framework_names, + "files_scanned": file_index.total_files, + }, + "discovery": { + "features": len(features), + "scenarios": scenario_count, + "items_by_kind": items_by_kind, + }, + "comparison": [ + { + "feature_id": row.feature_id, + "name": row.name, + "code_scenarios": row.code_scenarios, + "spec_scenarios": row.spec_scenarios, + } + for row in comparison_rows + ], + "saved": bool(saved_paths), + "errors": errors, + } + click.echo(json_dumps(payload, pretty=pretty)) + return + + _print_table_output( + root=root, + rows=comparison_rows, + languages=languages, + frameworks=framework_names, + files_scanned=file_index.total_files, + items_by_kind=items_by_kind, + feature_count=len(features), + scenario_count=scenario_count, + saved_paths=saved_paths, + errors=errors, + ) diff --git a/tests/cli/test_start.py b/tests/cli/test_start.py new file mode 100644 index 0000000..43b8b3d --- /dev/null +++ b/tests/cli/test_start.py @@ -0,0 +1,245 @@ +"""Tests for `specleft start` command.""" + +from __future__ import annotations + +from importlib import import_module +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from specleft.cli.main import cli +from specleft.discovery.models import ( + DiscoveryReport, + DraftFeature, + DraftScenario, + SupportedLanguage, +) +from specleft.schema import SpecStep, StepType + +start_module = import_module("specleft.commands.start") + + +@pytest.fixture +def fake_python_project(tmp_path: Path) -> Path: + """Create a minimal Python project that discovery can scan.""" + (tmp_path / "src").mkdir(parents=True, exist_ok=True) + (tmp_path / "tests" / "auth").mkdir(parents=True, exist_ok=True) + (tmp_path / "src" / "service.py").write_text(""" +def add(a: int, b: int) -> int: + return a + b +""".strip() + "\n") + (tmp_path / "tests" / "auth" / "test_login.py").write_text(""" +def test_valid_login() -> None: + assert True +""".strip() + "\n") + return tmp_path + + +def _patch_healthy(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + start_module, + "_run_health_check", + lambda: {"healthy": True, "checks": {}}, + ) + + +def _patch_discovery_with_feature( + monkeypatch: pytest.MonkeyPatch, + root: Path, + *, + feature_id: str = "authentication", +) -> None: + feature = DraftFeature( + feature_id=feature_id, + name="Authentication", + scenarios=[ + DraftScenario( + title="valid-login", + priority="medium", + steps=[ + SpecStep(type=StepType.GIVEN, description="context"), + SpecStep(type=StepType.WHEN, description="action"), + SpecStep(type=StepType.THEN, description="result"), + ], + source_items=[], + ) + ], + source_items=[], + confidence=0.8, + ) + + class _FakePipeline: + def run(self) -> DiscoveryReport: + return DiscoveryReport( + project_root=root, + languages_detected=[SupportedLanguage.PYTHON], + miner_results=[], + total_items=1, + errors=[], + duration_ms=1, + ) + + monkeypatch.setattr( + start_module, "build_default_pipeline", lambda _root: _FakePipeline() + ) + monkeypatch.setattr(start_module, "group_items", lambda _items: [feature]) + + +def test_start_exits_zero_and_returns_json_shape( + fake_python_project: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_healthy(monkeypatch) + monkeypatch.chdir(fake_python_project) + runner = CliRunner() + + result = runner.invoke(cli, ["start", "--format", "json", "."]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + + assert set(payload) == {"project", "discovery", "comparison", "saved", "errors"} + assert set(payload["project"]) == { + "root", + "languages", + "test_frameworks", + "files_scanned", + } + assert set(payload["discovery"]) == {"features", "scenarios", "items_by_kind"} + assert set(payload["discovery"]["items_by_kind"]) == { + "test_function", + "api_route", + "docstring", + "git_commit", + } + assert payload["project"]["root"] == str(fake_python_project.resolve()) + assert "python" in payload["project"]["languages"] + assert payload["saved"] is False + + +def test_start_without_save_does_not_write_discovered_specs( + fake_python_project: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_healthy(monkeypatch) + monkeypatch.chdir(fake_python_project) + runner = CliRunner() + + result = runner.invoke(cli, ["start", "--format", "table", "."]) + + assert result.exit_code == 0 + assert not (fake_python_project / ".specleft" / "specs" / "_discovered").exists() + + +def test_start_save_writes_draft_specs_and_prints_paths( + fake_python_project: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_healthy(monkeypatch) + monkeypatch.chdir(fake_python_project) + _patch_discovery_with_feature(monkeypatch, fake_python_project.resolve()) + runner = CliRunner() + + result = runner.invoke(cli, ["start", "--save", "--format", "table", "."]) + + assert result.exit_code == 0 + output_dir = fake_python_project / ".specleft" / "specs" / "_discovered" + saved_files = sorted(output_dir.glob("*.md")) + assert saved_files + assert "Saved draft specs:" in result.output + assert str(saved_files[0]) in result.output + + +def test_start_shows_non_zero_specs_count_when_existing_specs_found( + fake_python_project: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_healthy(monkeypatch) + monkeypatch.chdir(fake_python_project) + _patch_discovery_with_feature(monkeypatch, fake_python_project.resolve()) + specs_dir = fake_python_project / ".specleft" / "specs" + specs_dir.mkdir(parents=True, exist_ok=True) + (specs_dir / "authentication.md").write_text(""" +# Feature: Authentication + +## Scenarios + +### Scenario: valid-login +priority: medium + +- Given an existing account +- When valid credentials are provided +- Then access is granted +""".strip() + "\n") + + runner = CliRunner() + result = runner.invoke(cli, ["start", "--format", "table", "."]) + + assert result.exit_code == 0 + assert "authentication" in result.output + assert "1 specs" in result.output + + +def test_start_reports_git_errors_but_exits_zero( + fake_python_project: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_healthy(monkeypatch) + monkeypatch.chdir(fake_python_project) + runner = CliRunner() + + result = runner.invoke(cli, ["start", "--format", "json", "."]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert any("git" in error.lower() for error in payload["errors"]) + + +def test_start_uses_framework_detector_for_project_detection( + fake_python_project: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_healthy(monkeypatch) + monkeypatch.chdir(fake_python_project) + + calls: list[Path] = [] + + class _FrameworkDetectorSpy: + def detect( + self, + root: Path, + file_index: object, + ) -> dict[SupportedLanguage, list[str]]: + _ = file_index + calls.append(root) + return {SupportedLanguage.PYTHON: ["pytest"]} + + class _FakePipeline: + def __init__(self, root: Path) -> None: + self._root = root + + def run(self) -> DiscoveryReport: + return DiscoveryReport( + project_root=self._root, + languages_detected=[SupportedLanguage.PYTHON], + miner_results=[], + total_items=0, + errors=[], + duration_ms=1, + ) + + monkeypatch.setattr(start_module, "FrameworkDetector", _FrameworkDetectorSpy) + monkeypatch.setattr( + start_module, "build_default_pipeline", lambda root: _FakePipeline(root) + ) + monkeypatch.setattr(start_module, "group_items", lambda _items: []) + + runner = CliRunner() + result = runner.invoke(cli, ["start", "--format", "json", "."]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["project"]["test_frameworks"] == ["pytest"] + assert calls == [fake_python_project.resolve()]