From 328f7d8181ab3dbd1be154cb083cf526cf6ea61c Mon Sep 17 00:00:00 2001 From: mansurarief Date: Fri, 17 Apr 2026 22:12:14 +0300 Subject: [PATCH 1/3] fix: CLI validate now accepts YAML/TOML specs, not just JSON The `vnvspec validate` command was hardcoded to use `json.loads()`, causing it to fail on YAML and TOML spec files with a misleading "Spec validation error" message. This was inconsistent with the programmatic API which already supported all three formats. Changes: - Add `Spec.from_file(path)` classmethod that auto-detects format by file extension (.yaml/.yml, .json, .toml) - Rewrite CLI validate to use `Spec.from_file()` instead of raw `json.loads()` - Add REQ-SELF-CLI-002 and REQ-SELF-IO-002 to self-spec - Add 10 new tests covering from_file and CLI multi-format support --- .vnvspec/self-spec.yaml | 26 +++++++++++++ CHANGELOG.md | 10 +++++ src/vnvspec/cli/main.py | 9 ++--- src/vnvspec/core/spec.py | 26 +++++++++++++ tests/cli/test_validate_cli.py | 70 ++++++++++++++++++++++++++++++++++ tests/core/test_spec.py | 35 +++++++++++++++++ tests/test_self_spec.py | 2 +- 7 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 tests/cli/test_validate_cli.py 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..ec69140 100644 --- a/src/vnvspec/cli/main.py +++ b/src/vnvspec/cli/main.py @@ -145,13 +145,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..6199b9a 100644 --- a/src/vnvspec/core/spec.py +++ b/src/vnvspec/core/spec.py @@ -189,6 +189,32 @@ 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..e7dd37b --- /dev/null +++ b/tests/cli/test_validate_cli.py @@ -0,0 +1,70 @@ +"""Tests for vnvspec CLI validate command — multi-format support.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +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']}" ) From e970f050b35a0a56247184bb2bc7feb53d019203 Mon Sep 17 00:00:00 2001 From: mansurarief Date: Fri, 17 Apr 2026 22:14:20 +0300 Subject: [PATCH 2/3] style: format spec.py with ruff --- src/vnvspec/core/spec.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vnvspec/core/spec.py b/src/vnvspec/core/spec.py index 6199b9a..dce6e1e 100644 --- a/src/vnvspec/core/spec.py +++ b/src/vnvspec/core/spec.py @@ -211,8 +211,7 @@ def from_file(cls, path: Path | str) -> Self: if suffix == ".json": return cls.from_json(path) raise SpecError( - f"Unsupported file extension '{suffix}' for {path}. " - "Use .yaml, .yml, .json, or .toml." + f"Unsupported file extension '{suffix}' for {path}. Use .yaml, .yml, .json, or .toml." ) @classmethod From 6e4c22a414b4040ed79ff363eb427330eb92e9e4 Mon Sep 17 00:00:00 2001 From: mansurarief Date: Fri, 17 Apr 2026 22:16:43 +0300 Subject: [PATCH 3/3] fix: remove unused imports flagged by ruff --- src/vnvspec/cli/main.py | 1 - tests/cli/test_validate_cli.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/vnvspec/cli/main.py b/src/vnvspec/cli/main.py index ec69140..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) diff --git a/tests/cli/test_validate_cli.py b/tests/cli/test_validate_cli.py index e7dd37b..36a2cc4 100644 --- a/tests/cli/test_validate_cli.py +++ b/tests/cli/test_validate_cli.py @@ -2,11 +2,9 @@ from __future__ import annotations -import json from pathlib import Path import pytest -import yaml from typer.testing import CliRunner from vnvspec.cli.main import ExitCode, app