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
26 changes: 26 additions & 0 deletions .vnvspec/self-spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 3 additions & 7 deletions src/vnvspec/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/vnvspec/core/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions tests/cli/test_validate_cli.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions tests/core/test_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_self_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}"
)
Loading