diff --git a/.vnvspec/self-spec.yaml b/.vnvspec/self-spec.yaml index 0a7c352..11c5010 100644 --- a/.vnvspec/self-spec.yaml +++ b/.vnvspec/self-spec.yaml @@ -215,6 +215,32 @@ requirements: metadata: since: "0.2.0" + - id: REQ-SELF-CLI-002 + statement: The vnvspec CLI validate command shall accept YAML, JSON, and TOML spec files, auto-detecting format by file extension. + rationale: Users author specs in YAML or TOML; the CLI must not restrict to JSON-only parsing. + verification_method: test + acceptance_criteria: + - vnvspec validate accepts .yaml/.yml files. + - vnvspec validate accepts .json files. + - vnvspec validate accepts .toml files. + - vnvspec validate returns SPEC_VALIDATION_ERROR (exit 3) for unsupported extensions. + priority: high + metadata: + since: "0.3.1" + + - id: REQ-SELF-IO-002 + statement: Spec.from_file(path) shall auto-detect format by file extension and delegate to the appropriate from_yaml/from_json/from_toml method. + rationale: A single entry point simplifies callers and prevents format-detection bugs. + verification_method: test + acceptance_criteria: + - Spec.from_file("x.yaml") delegates to from_yaml. + - Spec.from_file("x.json") delegates to from_json. + - Spec.from_file("x.toml") delegates to from_toml. + - Spec.from_file("x.txt") raises SpecError. + priority: high + metadata: + since: "0.3.1" + # --- v0.2 EvidenceCollector --- - id: REQ-SELF-COLLECTOR-001 statement: The EvidenceCollector shall validate requirement IDs against the spec and raise RequirementError for unknown IDs. diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d6d67..cc56876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Fixed + +- CLI `vnvspec validate` now accepts YAML and TOML spec files (previously only JSON worked due to hardcoded `json.loads()`). + +### Added + +- `Spec.from_file(path)` classmethod that auto-detects format by file extension (`.yaml`/`.yml`, `.json`, `.toml`). + ## [0.3.0] — 2026-04-17 ### Changed diff --git a/src/vnvspec/cli/main.py b/src/vnvspec/cli/main.py index 8593891..08ba76b 100644 --- a/src/vnvspec/cli/main.py +++ b/src/vnvspec/cli/main.py @@ -129,7 +129,6 @@ def validate( ) -> None: """Run GtWR quality checks on a spec file.""" from vnvspec.core._internal.gtwr_rules import RuleProfile # noqa: PLC0415 - from vnvspec.core.requirement import Requirement # noqa: PLC0415 try: rule_profile = RuleProfile(profile) @@ -145,13 +144,10 @@ def validate( raise typer.Exit(code=ExitCode.USAGE_ERROR) try: - data = json.loads(spec_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError) as exc: - console.print(f"[red]Spec validation error:[/red] {exc}") - raise typer.Exit(code=ExitCode.SPEC_VALIDATION_ERROR) from exc + from vnvspec.core.spec import Spec # noqa: PLC0415 - try: - requirements = [Requirement.model_validate(r) for r in data.get("requirements", [])] + spec = Spec.from_file(spec_path) + requirements = list(spec.requirements) except Exception as exc: console.print(f"[red]Spec validation error:[/red] {exc}") raise typer.Exit(code=ExitCode.SPEC_VALIDATION_ERROR) from exc diff --git a/src/vnvspec/core/spec.py b/src/vnvspec/core/spec.py index f8f6eba..dce6e1e 100644 --- a/src/vnvspec/core/spec.py +++ b/src/vnvspec/core/spec.py @@ -189,6 +189,31 @@ def coverage_summary(self) -> dict[str, int]: # --- Serialization: YAML / TOML / JSON --- + @classmethod + def from_file(cls, path: Path | str) -> Self: + """Load a Spec from a YAML, JSON, or TOML file (auto-detected by extension). + + Example: + >>> import tempfile, os + >>> from vnvspec.core.spec import Spec + >>> s = Spec(name="t") + >>> p = os.path.join(tempfile.mkdtemp(), "s.yaml") + >>> _ = s.to_yaml(p) + >>> Spec.from_file(p).name + 't' + """ + path = Path(path) + suffix = path.suffix.lower() + if suffix in (".yaml", ".yml"): + return cls.from_yaml(path) + if suffix == ".toml": + return cls.from_toml(path) + if suffix == ".json": + return cls.from_json(path) + raise SpecError( + f"Unsupported file extension '{suffix}' for {path}. Use .yaml, .yml, .json, or .toml." + ) + @classmethod def from_yaml(cls, path: Path | str) -> Self: """Load a Spec from a YAML file. diff --git a/tests/cli/test_validate_cli.py b/tests/cli/test_validate_cli.py new file mode 100644 index 0000000..36a2cc4 --- /dev/null +++ b/tests/cli/test_validate_cli.py @@ -0,0 +1,68 @@ +"""Tests for vnvspec CLI validate command — multi-format support.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from vnvspec.cli.main import ExitCode, app +from vnvspec.core.requirement import Requirement +from vnvspec.core.spec import Spec + +runner = CliRunner() + + +@pytest.fixture() +def sample_spec() -> Spec: + req = Requirement( + id="REQ-001", + statement="The system shall produce valid outputs.", + rationale="Safety.", + verification_method="test", + acceptance_criteria=["All outputs are valid."], + ) + return Spec(name="test-cli", requirements=[req]) + + +class TestValidateMultiFormat: + @pytest.mark.vnvspec("REQ-SELF-CLI-002") + def test_validate_yaml(self, sample_spec: Spec, tmp_path: Path) -> None: + p = tmp_path / "spec.yaml" + sample_spec.to_yaml(p) + result = runner.invoke(app, ["validate", str(p)]) + assert result.exit_code in (ExitCode.OK, ExitCode.ASSESSMENT_FAILURES) + assert "1 requirements" in result.stdout + + @pytest.mark.vnvspec("REQ-SELF-CLI-002") + def test_validate_yml(self, sample_spec: Spec, tmp_path: Path) -> None: + p = tmp_path / "spec.yml" + sample_spec.to_yaml(p) + result = runner.invoke(app, ["validate", str(p)]) + assert result.exit_code in (ExitCode.OK, ExitCode.ASSESSMENT_FAILURES) + assert "1 requirements" in result.stdout + + @pytest.mark.vnvspec("REQ-SELF-CLI-002") + def test_validate_json(self, sample_spec: Spec, tmp_path: Path) -> None: + p = tmp_path / "spec.json" + sample_spec.to_json(p) + result = runner.invoke(app, ["validate", str(p)]) + assert result.exit_code in (ExitCode.OK, ExitCode.ASSESSMENT_FAILURES) + assert "1 requirements" in result.stdout + + @pytest.mark.vnvspec("REQ-SELF-CLI-002") + def test_validate_toml(self, sample_spec: Spec, tmp_path: Path) -> None: + p = tmp_path / "spec.toml" + sample_spec.to_toml(p) + result = runner.invoke(app, ["validate", str(p)]) + assert result.exit_code in (ExitCode.OK, ExitCode.ASSESSMENT_FAILURES) + assert "1 requirements" in result.stdout + + @pytest.mark.vnvspec("REQ-SELF-CLI-002") + def test_validate_unsupported_ext(self, tmp_path: Path) -> None: + p = tmp_path / "spec.txt" + p.write_text("name: x\n") + result = runner.invoke(app, ["validate", str(p)]) + assert result.exit_code == ExitCode.SPEC_VALIDATION_ERROR + assert "Unsupported file extension" in result.stdout diff --git a/tests/core/test_spec.py b/tests/core/test_spec.py index 42a3df7..29e70e4 100644 --- a/tests/core/test_spec.py +++ b/tests/core/test_spec.py @@ -209,6 +209,41 @@ def test_json_file_round_trip(self, sample_req: Requirement, tmp_path: Path) -> assert spec2.name == spec.name assert len(spec2.requirements) == 1 + @pytest.mark.vnvspec("REQ-SELF-IO-002") + def test_from_file_yaml(self, sample_req: Requirement, tmp_path: Path) -> None: + spec = Spec(name="test-ff", requirements=[sample_req]) + p = tmp_path / "spec.yaml" + spec.to_yaml(p) + assert Spec.from_file(p).name == "test-ff" + + @pytest.mark.vnvspec("REQ-SELF-IO-002") + def test_from_file_yml(self, sample_req: Requirement, tmp_path: Path) -> None: + spec = Spec(name="test-ff", requirements=[sample_req]) + p = tmp_path / "spec.yml" + spec.to_yaml(p) + assert Spec.from_file(p).name == "test-ff" + + @pytest.mark.vnvspec("REQ-SELF-IO-002") + def test_from_file_json(self, sample_req: Requirement, tmp_path: Path) -> None: + spec = Spec(name="test-ff", requirements=[sample_req]) + p = tmp_path / "spec.json" + spec.to_json(p) + assert Spec.from_file(p).name == "test-ff" + + @pytest.mark.vnvspec("REQ-SELF-IO-002") + def test_from_file_toml(self, sample_req: Requirement, tmp_path: Path) -> None: + spec = Spec(name="test-ff", requirements=[sample_req]) + p = tmp_path / "spec.toml" + spec.to_toml(p) + assert Spec.from_file(p).name == "test-ff" + + @pytest.mark.vnvspec("REQ-SELF-IO-002") + def test_from_file_unsupported_extension(self, tmp_path: Path) -> None: + p = tmp_path / "spec.txt" + p.write_text("name: x\n") + with pytest.raises(SpecError, match="Unsupported file extension"): + Spec.from_file(p) + def test_yaml_invalid_raises(self, tmp_path: Path) -> None: bad_yaml = tmp_path / "bad.yaml" bad_yaml.write_text("- just a list\n- not a mapping\n") diff --git a/tests/test_self_spec.py b/tests/test_self_spec.py index 4d187df..23f8263 100644 --- a/tests/test_self_spec.py +++ b/tests/test_self_spec.py @@ -261,6 +261,6 @@ def test_all_requirements_have_since_metadata(self) -> None: spec = Spec.from_yaml(ROOT / ".vnvspec" / "self-spec.yaml") for req in spec.requirements: assert "since" in req.metadata, f"{req.id} missing metadata.since" - assert req.metadata["since"] in ("0.1.0", "0.2.0", "0.3.0"), ( + assert req.metadata["since"] in ("0.1.0", "0.2.0", "0.3.0", "0.3.1"), ( f"{req.id} has unexpected since={req.metadata['since']}" )