blob: 861039f59dd8a88dcc6eddd3db7edf8190028fea [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Tests for the spec validator."""
from __future__ import annotations
import textwrap
from pathlib import Path
import pytest
from spec_validator import (
ALLOWED_KIND,
ALLOWED_MODE,
ALLOWED_STATUS,
REQUIRED_SECTIONS,
SPDX_MARKER,
extract_section_headings,
find_repo_root,
get_section_body,
has_acceptance_items,
main,
parse_frontmatter,
run_validation,
validate_body,
validate_frontmatter,
validate_spdx_header,
validate_spec_loop_runner_fixtures,
validate_validation_paths,
validation_has_code_block,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_VALID_SPEC = textwrap.dedent("""\
<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->
---
title: Example spec
status: stable
kind: feature
mode: Triage
source: MISSION.md ยง some section
acceptance:
- At least one criterion is met.
---
# Example spec
## What it does
A brief description.
## Where it lives
- `tools/example/`
## Behaviour & contract
The contract.
## Out of scope
Nothing.
## Acceptance criteria
1. Criterion one.
## Validation
```bash
pytest
```
## Known gaps
- `stable`; no gaps at this time.
""")
def _make_spec(*, status: str = "stable", spdx: bool = True, **overrides: str) -> str:
"""Build a minimal valid spec, replacing frontmatter values as needed.
Pass ``spdx=False`` to produce a spec intentionally missing the SPDX
header (used by SPDX-check tests).
"""
defaults = {
"title": "Test spec",
"kind": "feature",
"mode": "Triage",
"source": "MISSION.md",
"acceptance_items": " - One criterion.",
}
defaults.update(overrides)
acceptance_items = defaults.pop("acceptance_items")
fm_lines = [
f"title: {defaults['title']}",
f"status: {status}",
f"kind: {defaults['kind']}",
f"mode: {defaults['mode']}",
f"source: {defaults['source']}",
"acceptance:",
acceptance_items,
]
body_sections = "\n\n".join(f"## {s}\n\nContent." for s in REQUIRED_SECTIONS)
# Replace Validation section with one that has a code block
body_sections = body_sections.replace(
"## Validation\n\nContent.",
"## Validation\n\n```bash\npytest\n```",
)
fm = "\n".join(fm_lines)
header = f"<!-- {SPDX_MARKER}\n https://www.apache.org/licenses/LICENSE-2.0 -->\n\n" if spdx else ""
return f"{header}---\n{fm}\n---\n\n# Test spec\n\n{body_sections}\n"
def _make_spec_with_validation(cmd: str) -> str:
"""Build a minimal valid spec with a custom Validation command."""
body_sections = "\n\n".join(f"## {s}\n\nContent." for s in REQUIRED_SECTIONS)
body_sections = body_sections.replace(
"## Validation\n\nContent.",
f"## Validation\n\n```bash\n{cmd}\n```",
)
fm = (
"title: Test spec\n"
"status: stable\n"
"kind: feature\n"
"mode: Triage\n"
"source: MISSION.md\n"
"acceptance:\n"
" - One criterion.\n"
)
return f"---\n{fm}---\n\n# Test spec\n\n{body_sections}\n"
# ---------------------------------------------------------------------------
# Frontmatter parsing
# ---------------------------------------------------------------------------
class TestParseFrontmatter:
def test_valid_frontmatter(self) -> None:
fm = parse_frontmatter(_VALID_SPEC)
assert fm is not None
assert fm["title"] == "Example spec"
assert fm["status"] == "stable"
def test_no_frontmatter_returns_none(self) -> None:
assert parse_frontmatter("# Just a heading\n\nNo frontmatter.") is None
def test_html_comment_prefix_allowed(self) -> None:
text = "<!-- SPDX-License-Identifier: Apache-2.0 -->\n---\ntitle: foo\n---\n"
fm = parse_frontmatter(text)
assert fm is not None
assert fm["title"] == "foo"
def test_non_comment_prefix_returns_none(self) -> None:
text = "Some prose\n---\ntitle: foo\n---\n"
assert parse_frontmatter(text) is None
def test_folded_block_scalar(self) -> None:
text = "---\nsource: >\n line one\n line two\n---\n"
fm = parse_frontmatter(text)
assert fm is not None
assert "line one" in fm["source"]
def test_multiline_value(self) -> None:
text = "---\ntitle: My\n continuation\n---\n"
fm = parse_frontmatter(text)
assert fm is not None
assert "My" in fm["title"]
class TestHasAcceptanceItems:
def test_has_items(self) -> None:
text = "---\nacceptance:\n - item one\n - item two\n---\n"
assert has_acceptance_items(text) is True
def test_no_items(self) -> None:
text = "---\nacceptance:\n---\n"
assert has_acceptance_items(text) is False
def test_no_frontmatter(self) -> None:
assert has_acceptance_items("# no frontmatter") is False
# ---------------------------------------------------------------------------
# Body section extraction
# ---------------------------------------------------------------------------
class TestExtractSectionHeadings:
def test_extracts_h2_headings(self) -> None:
headings = extract_section_headings(_VALID_SPEC)
assert "What it does" in headings
assert "Validation" in headings
def test_ignores_h1(self) -> None:
headings = extract_section_headings(_VALID_SPEC)
assert "Example spec" not in headings
def test_no_frontmatter_still_works(self) -> None:
text = "# Title\n\n## Section A\n\ncontent\n"
headings = extract_section_headings(text)
assert "Section A" in headings
class TestGetSectionBody:
def test_returns_section_content(self) -> None:
body = get_section_body(_VALID_SPEC, "What it does")
assert body is not None
assert "A brief description" in body
def test_returns_none_for_missing_section(self) -> None:
assert get_section_body(_VALID_SPEC, "Nonexistent") is None
def test_stops_at_next_section(self) -> None:
body = get_section_body(_VALID_SPEC, "What it does")
assert body is not None
assert "Where it lives" not in body
class TestValidationHasCodeBlock:
def test_valid_spec_has_code_block(self) -> None:
assert validation_has_code_block(_VALID_SPEC) is True
def test_missing_code_block(self) -> None:
spec = _make_spec()
spec_no_code = spec.replace("```bash\npytest\n```", "Run pytest manually.")
assert validation_has_code_block(spec_no_code) is False
def test_no_validation_section(self) -> None:
text = "---\ntitle: t\n---\n## Other\n\ncontent\n"
assert validation_has_code_block(text) is False
# ---------------------------------------------------------------------------
# validate_spdx_header
# ---------------------------------------------------------------------------
class TestValidateSpdxHeader:
def test_spec_with_spdx_passes(self, tmp_path: Path) -> None:
text = _make_spec() # spdx=True by default
p = tmp_path / "spec.md"
p.write_text(text)
assert validate_spdx_header(p, text) == []
def test_spec_missing_spdx_flagged(self, tmp_path: Path) -> None:
text = _make_spec(spdx=False)
p = tmp_path / "spec.md"
p.write_text(text)
violations = validate_spdx_header(p, text)
assert len(violations) == 1
assert SPDX_MARKER in violations[0].message
def test_readme_without_frontmatter_skipped(self, tmp_path: Path) -> None:
text = "# README\n\nNo frontmatter, no SPDX required.\n"
p = tmp_path / "README.md"
p.write_text(text)
assert validate_spdx_header(p, text) == []
def test_valid_spec_fixture_has_spdx(self, tmp_path: Path) -> None:
p = tmp_path / "spec.md"
p.write_text(_VALID_SPEC)
assert validate_spdx_header(p, _VALID_SPEC) == []
def test_spdx_violation_line_number_is_1(self, tmp_path: Path) -> None:
text = _make_spec(spdx=False)
p = tmp_path / "spec.md"
p.write_text(text)
violations = validate_spdx_header(p, text)
assert violations[0].line == 1
def test_run_validation_catches_missing_spdx(self, tmp_path: Path) -> None:
(tmp_path / "spec.md").write_text(_make_spec(spdx=False))
violations = run_validation(tmp_path)
assert any(SPDX_MARKER in v.message for v in violations)
def test_run_validation_accepts_spec_with_spdx(self, tmp_path: Path) -> None:
(tmp_path / "spec.md").write_text(_make_spec())
violations = [v for v in run_validation(tmp_path) if SPDX_MARKER in v.message]
assert violations == []
# ---------------------------------------------------------------------------
# validate_frontmatter
# ---------------------------------------------------------------------------
class TestValidateFrontmatter:
def test_valid_spec_no_violations(self, tmp_path: Path) -> None:
p = tmp_path / "spec.md"
p.write_text(_VALID_SPEC)
assert validate_frontmatter(p, _VALID_SPEC) == []
def test_no_frontmatter_skipped(self, tmp_path: Path) -> None:
text = "# No frontmatter\n\ncontent\n"
p = tmp_path / "readme.md"
p.write_text(text)
assert validate_frontmatter(p, text) == []
def test_missing_required_key(self, tmp_path: Path) -> None:
text = "---\ntitle: foo\nstatus: stable\n---\n# foo\n"
p = tmp_path / "spec.md"
p.write_text(text)
violations = validate_frontmatter(p, text)
messages = [v.message for v in violations]
assert any("kind" in m for m in messages)
assert any("mode" in m for m in messages)
@pytest.mark.parametrize("status", sorted(ALLOWED_STATUS))
def test_all_valid_statuses_pass(self, tmp_path: Path, status: str) -> None:
text = _make_spec(status=status)
p = tmp_path / "spec.md"
p.write_text(text)
violations = [v for v in validate_frontmatter(p, text) if "status" in v.message]
assert violations == []
def test_invalid_status(self, tmp_path: Path) -> None:
text = _make_spec(status="unknown")
p = tmp_path / "spec.md"
p.write_text(text)
violations = validate_frontmatter(p, text)
assert any("invalid status" in v.message for v in violations)
@pytest.mark.parametrize("kind", sorted(ALLOWED_KIND))
def test_all_valid_kinds_pass(self, tmp_path: Path, kind: str) -> None:
text = _make_spec(kind=kind)
p = tmp_path / "spec.md"
violations = [v for v in validate_frontmatter(p, text) if "kind" in v.message]
assert violations == []
def test_invalid_kind(self, tmp_path: Path) -> None:
text = _make_spec(kind="unknown")
p = tmp_path / "spec.md"
violations = validate_frontmatter(p, text)
assert any("invalid kind" in v.message for v in violations)
@pytest.mark.parametrize("mode", sorted(ALLOWED_MODE))
def test_all_valid_modes_pass(self, tmp_path: Path, mode: str) -> None:
text = _make_spec(mode=mode)
p = tmp_path / "spec.md"
violations = [v for v in validate_frontmatter(p, text) if "mode" in v.message]
assert violations == []
def test_invalid_mode(self, tmp_path: Path) -> None:
text = _make_spec(mode="UnknownMode")
p = tmp_path / "spec.md"
violations = validate_frontmatter(p, text)
assert any("invalid mode" in v.message for v in violations)
def test_empty_acceptance_list(self, tmp_path: Path) -> None:
text = (
"---\ntitle: t\nstatus: stable\nkind: feature\nmode: Triage\nsource: x\nacceptance:\n---\n# t\n"
)
p = tmp_path / "spec.md"
violations = validate_frontmatter(p, text)
assert any("acceptance" in v.message for v in violations)
def test_acceptance_with_items_passes(self, tmp_path: Path) -> None:
text = _make_spec()
p = tmp_path / "spec.md"
violations = [v for v in validate_frontmatter(p, text) if "acceptance" in v.message]
assert violations == []
# ---------------------------------------------------------------------------
# validate_body
# ---------------------------------------------------------------------------
class TestValidateBody:
def test_valid_spec_no_violations(self, tmp_path: Path) -> None:
p = tmp_path / "spec.md"
p.write_text(_VALID_SPEC)
assert validate_body(p, _VALID_SPEC) == []
def test_no_frontmatter_skipped(self, tmp_path: Path) -> None:
text = "# No frontmatter\n\n## What it does\n\ncontent\n"
p = tmp_path / "readme.md"
assert validate_body(p, text) == []
@pytest.mark.parametrize("section", REQUIRED_SECTIONS)
def test_missing_section_flagged(self, tmp_path: Path, section: str) -> None:
text = _make_spec()
# Remove the section heading
text_no_section = text.replace(f"## {section}\n", "## REPLACED_SECTION\n")
p = tmp_path / "spec.md"
violations = validate_body(p, text_no_section)
assert any(section in v.message for v in violations)
def test_validation_without_code_block(self, tmp_path: Path) -> None:
text = _make_spec()
text_no_code = text.replace("```bash\npytest\n```", "Run manually.")
p = tmp_path / "spec.md"
violations = validate_body(p, text_no_code)
assert any("fenced code block" in v.message for v in violations)
def test_all_sections_present_no_violations(self, tmp_path: Path) -> None:
text = _make_spec()
p = tmp_path / "spec.md"
assert validate_body(p, text) == []
# ---------------------------------------------------------------------------
# run_validation (integration)
# ---------------------------------------------------------------------------
class TestRunValidation:
def test_valid_directory_no_violations(self, tmp_path: Path) -> None:
(tmp_path / "spec_a.md").write_text(_VALID_SPEC)
(tmp_path / "spec_b.md").write_text(_make_spec(status="experimental"))
assert run_validation(tmp_path) == []
def test_readme_skipped(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# README\n\nNo frontmatter.\n")
assert run_validation(tmp_path) == []
def test_invalid_spec_produces_violations(self, tmp_path: Path) -> None:
text = "---\ntitle: broken\n---\n# broken\n"
(tmp_path / "broken.md").write_text(text)
violations = run_validation(tmp_path)
assert len(violations) > 0
def test_single_file_target(self, tmp_path: Path) -> None:
p = tmp_path / "spec.md"
p.write_text(_VALID_SPEC)
assert run_validation(p) == []
def test_nonexistent_path_via_main(self, capsys: pytest.CaptureFixture[str]) -> None:
rc = main(["/nonexistent/path"])
assert rc == 1
captured = capsys.readouterr()
assert "not found" in captured.err
def test_main_ok(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
(tmp_path / "spec.md").write_text(_VALID_SPEC)
rc = main([str(tmp_path)])
assert rc == 0
captured = capsys.readouterr()
assert "OK" in captured.out
def test_main_violations(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
(tmp_path / "bad.md").write_text("---\ntitle: bad\n---\n# bad\n")
rc = main([str(tmp_path)])
assert rc == 1
captured = capsys.readouterr()
assert "violation" in captured.out
class TestSpecLoopRunnerFixtures:
def test_missing_fixture_is_skipped(self, tmp_path: Path) -> None:
(tmp_path / "tools").mkdir()
assert validate_spec_loop_runner_fixtures(tmp_path) == []
def test_passing_fixture_is_ok(self, tmp_path: Path) -> None:
fixture = tmp_path / "tools" / "spec-loop" / "tests" / "test_runner_fixtures.sh"
fixture.parent.mkdir(parents=True)
fixture.write_text("#!/usr/bin/env bash\nexit 0\n")
assert validate_spec_loop_runner_fixtures(tmp_path) == []
def test_failing_fixture_is_reported(self, tmp_path: Path) -> None:
fixture = tmp_path / "tools" / "spec-loop" / "tests" / "test_runner_fixtures.sh"
fixture.parent.mkdir(parents=True)
fixture.write_text("#!/usr/bin/env bash\necho broken >&2\nexit 7\n")
violations = validate_spec_loop_runner_fixtures(tmp_path)
assert len(violations) == 1
assert "exit code 7" in violations[0].message
assert "broken" in violations[0].message
# ---------------------------------------------------------------------------
# Validation-path check (check #8)
# ---------------------------------------------------------------------------
class TestValidateValidationPaths:
"""Tests for check #8: paths in ## Validation code blocks must exist."""
def test_existing_project_path_no_violation(self, tmp_path: Path) -> None:
(tmp_path / "tools" / "my-tool").mkdir(parents=True)
spec = _make_spec_with_validation("uv run --project tools/my-tool --group dev pytest")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_missing_project_path_violation(self, tmp_path: Path) -> None:
spec = _make_spec_with_validation("uv run --project tools/nonexistent --group dev pytest")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert len(violations) == 1
assert "tools/nonexistent" in violations[0].message
def test_shell_variable_skipped(self, tmp_path: Path) -> None:
spec = _make_spec_with_validation(
"for t in a b; do uv run --project tools/$t --group dev pytest; done"
)
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_placeholder_token_skipped(self, tmp_path: Path) -> None:
spec = _make_spec_with_validation("uv run --project tools/<tool-name> --group dev pytest")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_bare_command_skipped(self, tmp_path: Path) -> None:
# "pytest" alone has no "/" โ€” not a path
spec = _make_spec_with_validation("pytest")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_bash_n_existing_file_no_violation(self, tmp_path: Path) -> None:
script = tmp_path / "tools" / "run.sh"
script.parent.mkdir(parents=True)
script.touch()
spec = _make_spec_with_validation("bash -n tools/run.sh")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_bash_n_missing_file_violation(self, tmp_path: Path) -> None:
spec = _make_spec_with_validation("bash -n tools/run.sh")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert len(violations) == 1
assert "tools/run.sh" in violations[0].message
assert "bash -n" in violations[0].message
def test_shellcheck_missing_file_violation(self, tmp_path: Path) -> None:
spec = _make_spec_with_validation("shellcheck tools/run.sh")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert len(violations) == 1
assert "tools/run.sh" in violations[0].message
def test_shellcheck_existing_file_no_violation(self, tmp_path: Path) -> None:
script = tmp_path / "tools" / "run.sh"
script.parent.mkdir(parents=True)
script.touch()
spec = _make_spec_with_validation("shellcheck tools/run.sh")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_test_f_existing_file_no_violation(self, tmp_path: Path) -> None:
readme = tmp_path / "docs" / "setup" / "README.md"
readme.parent.mkdir(parents=True)
readme.touch()
spec = _make_spec_with_validation("test -f docs/setup/README.md")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_test_f_missing_file_violation(self, tmp_path: Path) -> None:
spec = _make_spec_with_validation("test -f docs/setup/README.md")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert len(violations) == 1
assert "docs/setup/README.md" in violations[0].message
def test_no_frontmatter_skipped(self, tmp_path: Path) -> None:
text = "# No frontmatter\n\n## Validation\n\n```bash\nuv run --project tools/nonexistent\n```\n"
violations = validate_validation_paths(Path("test.md"), text, repo_root=tmp_path)
assert violations == []
def test_uv_directory_pattern(self, tmp_path: Path) -> None:
(tmp_path / "tools" / "my-tool").mkdir(parents=True)
spec = _make_spec_with_validation("uv run --directory tools/my-tool pytest")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_uv_directory_missing_violation(self, tmp_path: Path) -> None:
spec = _make_spec_with_validation("uv run --directory tools/gone pytest")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert len(violations) == 1
assert "tools/gone" in violations[0].message
def test_line_continuation_joined(self, tmp_path: Path) -> None:
(tmp_path / "tools" / "my-tool").mkdir(parents=True)
spec = _make_spec_with_validation("uv run --project tools/my-tool \\\n --group dev pytest")
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert violations == []
def test_multiple_missing_paths(self, tmp_path: Path) -> None:
spec = _make_spec_with_validation(
"uv run --project tools/foo --group dev pytest\nbash -n tools/foo/run.sh"
)
violations = validate_validation_paths(Path("specs/test.md"), spec, repo_root=tmp_path)
assert len(violations) == 2
# ---------------------------------------------------------------------------
# find_repo_root
# ---------------------------------------------------------------------------
class TestFindRepoRoot:
def test_finds_tools_dir(self, tmp_path: Path) -> None:
(tmp_path / "tools").mkdir()
result = find_repo_root(tmp_path)
assert result == tmp_path
def test_walks_up_to_tools_dir(self, tmp_path: Path) -> None:
(tmp_path / "tools").mkdir()
child = tmp_path / "tools" / "spec-loop" / "specs"
child.mkdir(parents=True)
result = find_repo_root(child)
assert result == tmp_path
def test_falls_back_to_start_when_no_tools(self, tmp_path: Path) -> None:
isolated = tmp_path / "isolated"
isolated.mkdir()
result = find_repo_root(isolated)
# Should fall back โ€” does not raise; returns some ancestor
assert isinstance(result, Path)
# ---------------------------------------------------------------------------
# Live specs (smoke test)
# ---------------------------------------------------------------------------
class TestLiveSpecs:
"""Run the validator against the actual specs on disk."""
@pytest.fixture
def specs_dir(self) -> Path | None:
"""Locate tools/spec-loop/specs/ relative to the repo root."""
start = Path(__file__).resolve()
for candidate in (start, *start.parents):
p = candidate / "tools" / "spec-loop" / "specs"
if p.is_dir():
return p
return None
def test_live_specs_pass(self, specs_dir: Path | None) -> None:
if specs_dir is None:
pytest.skip("tools/spec-loop/specs/ not found โ€” skipping live test")
violations = run_validation(specs_dir)
if violations:
messages = "\n".join(str(v) for v in violations)
pytest.fail(f"Live spec violations found:\n{messages}")