This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git


The following commit(s) were added to refs/heads/main by this push:
     new 8e474a7  feat(meta): add spec-validator tool (#336)
8e474a7 is described below

commit 8e474a7cd9309371d5f23a130493b829b8b9cbe5
Author: Justin Mclean <[email protected]>
AuthorDate: Thu May 28 09:45:20 2026 +1000

    feat(meta): add spec-validator tool (#336)
    
    * feat(meta): add spec-validator tool — validate spec frontmatter and body 
sections
    
    Adds tools/spec-validator/, a stdlib-only uv tool analogous to
    tools/skill-validator/ that validates every .md file carrying a YAML
    frontmatter block in tools/spec-loop/specs/:
    
      1. Required frontmatter keys (title, status, kind, mode, source, 
acceptance)
      2. Valid status / kind / mode values
      3. Non-empty acceptance list
      4. Required body sections (What it does, Where it lives,
         Behaviour & contract, Out of scope, Acceptance criteria, Validation)
      5. Validation section contains at least one fenced code block
    
    Files without frontmatter (README.md, overview.md) are silently skipped.
    56 tests pass; 11 live specs from the control branch validate clean.
    
    Note: all 7 IMPLEMENTATION_PLAN.md items were found to be merged or
    in-flight before this iteration; this item was derived from the Known
    gap in specs/meta-and-quality-tooling.md ("a spec validator analogous
    to the skill validator"). A plan/update beat should reconcile.
    
    Generated-by: Claude (Opus 4.7)
    
    * chore: declare spec-validator capability + sync capabilities doc
    
    The capability-sync check from #340 requires every tool with a
    `**Capability:**` declaration to carry a row in
    `docs/labels-and-capabilities.md`, and every tool README to declare
    its capability. `tools/spec-validator/README.md` predates the rule;
    add the line (matches sibling meta tools — `skill-and-tool-validator`,
    `spec-loop` — at `capability:setup`) and the corresponding table row.
    
    Also fix a stale ref in the README: `tools/skill-validator/` →
    `tools/skill-and-tool-validator/` (renamed in #340).
    
    Generated-by: Claude Code (Opus 4.7)
    
    * chore: clear codeql findings on test file (unused imports + var)
    
    CodeQL flagged two unused names in `tests/test_spec_validator.py`:
    - unused imports `REQUIRED_FRONTMATTER_KEYS` and `validate_file`
    - unused local variable `text` in `test_missing_code_block` (left over
      from an earlier draft; the test already uses `spec_no_code` for the
      actual assertion)
    
    Remove all three. Ruff clean; 57 tests still pass.
    
    Generated-by: Claude Code (Opus 4.7)
    
    ---------
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 docs/labels-and-capabilities.md                    |   1 +
 tools/spec-validator/README.md                     |  51 +++
 tools/spec-validator/pyproject.toml                |  58 +++
 .../spec-validator/src/spec_validator/__init__.py  | 346 +++++++++++++++++
 tools/spec-validator/tests/test_spec_validator.py  | 413 +++++++++++++++++++++
 tools/spec-validator/uv.lock                       | 112 ++++++
 6 files changed, 981 insertions(+)

diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 346c7cc..1792142 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -187,6 +187,7 @@ Tools under [`tools/`](../tools/). Tools with two values 
(separated by
 | [`tools/skill-evals`](../tools/skill-evals/) | `capability:setup` + 
`capability:stats` | Eval harness for skills; the harness is setup 
infrastructure, the run output is governance evidence |
 | [`tools/skill-and-tool-validator`](../tools/skill-and-tool-validator/) | 
`capability:setup` | Skill-frontmatter and convention validator |
 | [`tools/spec-status-index`](../tools/spec-status-index/) | 
`capability:setup` + `capability:stats` | Index of spec / RFC implementation 
status — substrate that also doubles as a governance/stats view |
+| [`tools/spec-validator`](../tools/spec-validator/) | `capability:setup` | 
Spec-frontmatter and body-section validator — counterpart to 
`skill-and-tool-validator` for `tools/spec-loop/specs/` |
 | [`tools/vulnogram`](../tools/vulnogram/) | `capability:resolve` | ASF 
Vulnogram CVE-allocation client |
 
 A tool's capabilities are determined by its **use-case lifecycle
diff --git a/tools/spec-validator/README.md b/tools/spec-validator/README.md
new file mode 100644
index 0000000..4782201
--- /dev/null
+++ b/tools/spec-validator/README.md
@@ -0,0 +1,51 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update 
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents**  *generated with 
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [spec-validator](#spec-validator)
+  - [What it checks](#what-it-checks)
+  - [Usage](#usage)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# spec-validator
+
+**Capability:** capability:setup
+
+Validates spec files in `tools/spec-loop/specs/` — the counterpart to
+`tools/skill-and-tool-validator/` for the spec side of the framework.
+
+## What it checks
+
+For every `.md` file that carries a YAML frontmatter block:
+
+1. **Required frontmatter keys** — `title`, `status`, `kind`, `mode`,
+   `source`, `acceptance`.
+2. **Valid `status`** — `stable` | `experimental` | `proposed` | `off`.
+3. **Valid `kind`** — `feature` | `fix` | `docs` | `chore`.
+4. **Valid `mode`** — `Triage` | `Mentoring` | `Drafting` | `Pairing` | 
`infra`.
+5. **Non-empty `acceptance` list** — at least one `- item` entry.
+6. **Required body sections** — `## What it does`, `## Where it lives`,
+   `## Behaviour & contract`, `## Out of scope`, `## Acceptance criteria`,
+   `## Validation`.
+7. **Validation section has a fenced code block** — at least one `` ```…``` ``
+   block so build-loop backpressure commands are always explicit.
+
+Files without frontmatter (e.g. `README.md`, `overview.md`) are silently
+skipped — they are index/overview docs, not functional specs.
+
+## Usage
+
+```bash
+# Run against the default spec directory
+uv run --project tools/spec-validator spec-validate
+
+# Run against a specific directory or file
+uv run --project tools/spec-validator spec-validate tools/spec-loop/specs/
+
+# Run the test suite
+uv run --project tools/spec-validator --group dev pytest
+```
diff --git a/tools/spec-validator/pyproject.toml 
b/tools/spec-validator/pyproject.toml
new file mode 100644
index 0000000..0fd95bc
--- /dev/null
+++ b/tools/spec-validator/pyproject.toml
@@ -0,0 +1,58 @@
+# 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.
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "spec-validator"
+version = "0.1.0"
+description = "Validate spec files — YAML frontmatter and required body 
sections."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+dependencies = []
+
+[project.scripts]
+spec-validate = "spec_validator:main"
+
+[dependency-groups]
+dev = [
+  "pytest>=8.0",
+  "ruff>=0.6",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/spec_validator"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "RUF"]
+ignore = ["E501"]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"]
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
diff --git a/tools/spec-validator/src/spec_validator/__init__.py 
b/tools/spec-validator/src/spec_validator/__init__.py
new file mode 100644
index 0000000..7b5166e
--- /dev/null
+++ b/tools/spec-validator/src/spec_validator/__init__.py
@@ -0,0 +1,346 @@
+# 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.
+
+"""Validate spec files in tools/spec-loop/specs/.
+
+Checks every .md file that carries a YAML frontmatter block:
+
+1. Required frontmatter keys — title, status, kind, mode, source, acceptance.
+2. Valid ``status`` value — stable | experimental | proposed | off.
+3. Valid ``kind`` value  — feature | fix | docs | chore.
+4. Valid ``mode`` value  — Triage | Mentoring | Drafting | Pairing | infra.
+5. Non-empty ``acceptance`` list — at least one ``- item`` entry.
+6. Required body sections — What it does, Where it lives,
+   Behaviour & contract, Out of scope, Acceptance criteria, Validation.
+7. Validation section contains at least one fenced code block.
+
+Files without frontmatter (README.md, overview.md) are skipped silently.
+
+Run from repo root::
+
+    uv run --project tools/spec-validator --group dev pytest
+    uv run --project tools/spec-validator spec-validate tools/spec-loop/specs/
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+REQUIRED_FRONTMATTER_KEYS: frozenset[str] = frozenset(
+    {"title", "status", "kind", "mode", "source", "acceptance"}
+)
+ALLOWED_STATUS: frozenset[str] = frozenset({"stable", "experimental", 
"proposed", "off"})
+ALLOWED_KIND: frozenset[str] = frozenset({"feature", "fix", "docs", "chore"})
+ALLOWED_MODE: frozenset[str] = frozenset({"Triage", "Mentoring", "Drafting", 
"Pairing", "infra"})
+
+REQUIRED_SECTIONS: tuple[str, ...] = (
+    "What it does",
+    "Where it lives",
+    "Behaviour & contract",
+    "Out of scope",
+    "Acceptance criteria",
+    "Validation",
+)
+
+DEFAULT_SPEC_DIR = Path("tools/spec-loop/specs")
+
+_HTML_COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
+_FENCED_CODE_RE = re.compile(r"^ {0,3}```[\s\S]*?^ {0,3}```", re.MULTILINE)
+_YAML_BLOCK_SCALAR_HEADERS: frozenset[str] = frozenset({"|", ">", "|-", "|+", 
">-", ">+"})
+
+
+# ---------------------------------------------------------------------------
+# Data structures
+# ---------------------------------------------------------------------------
+
+
+class Violation:
+    def __init__(self, path: Path, line: int | None, message: str) -> None:
+        self.path = path
+        self.line = line
+        self.message = message
+
+    def __str__(self) -> str:
+        if self.line is not None:
+            return f"{self.path}:{self.line}: {self.message}"
+        return f"{self.path}: {self.message}"
+
+
+# ---------------------------------------------------------------------------
+# Frontmatter parsing
+# ---------------------------------------------------------------------------
+
+
+def _frontmatter_bounds(text: str) -> tuple[int, int] | None:
+    """Return (block_start, block_end) for the frontmatter content, or None.
+
+    Handles files whose first non-whitespace content is an HTML comment
+    (e.g. the SPDX license header) before the ``---`` delimiter.
+    """
+    idx = text.find("---\n")
+    if idx == -1:
+        return None
+    # Verify only HTML comments / whitespace precede the opening ---
+    prefix = text[:idx]
+    clean = _HTML_COMMENT_RE.sub("", prefix).strip()
+    if clean:
+        return None
+    try:
+        end = text.index("\n---\n", idx + 4)
+    except ValueError:
+        return None
+    return (idx + 4, end)
+
+
+def parse_frontmatter(text: str) -> dict[str, str] | None:
+    """Return a dict of top-level frontmatter key→value, or None if absent."""
+    bounds = _frontmatter_bounds(text)
+    if bounds is None:
+        return None
+    block = text[bounds[0] : bounds[1]]
+
+    result: dict[str, str] = {}
+    current_key: str | None = None
+    current_value_lines: list[str] = []
+
+    for raw_line in block.splitlines():
+        line = raw_line.rstrip()
+        if line == "":
+            if current_key is not None:
+                current_value_lines.append("")
+            continue
+        if not line.startswith((" ", "\t")) and ":" in line:
+            if current_key is not None:
+                result[current_key] = "\n".join(current_value_lines).strip()
+            key, _, value = line.partition(":")
+            current_key = key.strip()
+            inline = value.strip()
+            current_value_lines = [inline] if inline and inline not in 
_YAML_BLOCK_SCALAR_HEADERS else []
+            continue
+        if current_key is not None:
+            stripped = line[2:] if line.startswith("  ") else line
+            current_value_lines.append(stripped)
+
+    if current_key is not None:
+        result[current_key] = "\n".join(current_value_lines).strip()
+    return result
+
+
+def has_acceptance_items(text: str) -> bool:
+    """Return True if the ``acceptance`` frontmatter key has at least one list 
item."""
+    bounds = _frontmatter_bounds(text)
+    if bounds is None:
+        return False
+    block = text[bounds[0] : bounds[1]]
+    in_acceptance = False
+    for line in block.splitlines():
+        if not line.startswith((" ", "\t")) and ":" in line:
+            in_acceptance = line.split(":", 1)[0].strip() == "acceptance"
+            continue
+        if in_acceptance and re.match(r"\s+-\s", line):
+            return True
+    return False
+
+
+# ---------------------------------------------------------------------------
+# Body section validation
+# ---------------------------------------------------------------------------
+
+
+def _spec_body(text: str) -> str:
+    """Return the doc body — everything after the closing ``---`` frontmatter 
delimiter."""
+    bounds = _frontmatter_bounds(text)
+    if bounds is None:
+        return text
+    # body starts after "\n---\n"
+    return text[bounds[1] + 5 :]
+
+
+def extract_section_headings(text: str) -> set[str]:
+    """Return the text of every ## heading in the spec body."""
+    body = _spec_body(text)
+    headings: set[str] = set()
+    for line in body.splitlines():
+        if line.startswith("## "):
+            headings.add(line[3:].strip())
+    return headings
+
+
+def get_section_body(text: str, section: str) -> str | None:
+    """Return the content of a named ## section, or None."""
+    body = _spec_body(text)
+    lines = body.splitlines()
+    collecting = False
+    collected: list[str] = []
+    for line in lines:
+        if line.startswith("## "):
+            heading = line[3:].strip()
+            if heading == section:
+                collecting = True
+                continue
+            if collecting:
+                break
+        if collecting:
+            collected.append(line)
+    return "\n".join(collected) if collected else None
+
+
+def validation_has_code_block(text: str) -> bool:
+    """Return True if the Validation section contains at least one fenced code 
block."""
+    section = get_section_body(text, "Validation")
+    if not section:
+        return False
+    return bool(_FENCED_CODE_RE.search(section))
+
+
+# ---------------------------------------------------------------------------
+# Validators
+# ---------------------------------------------------------------------------
+
+
+def validate_frontmatter(path: Path, text: str) -> list[Violation]:
+    fm = parse_frontmatter(text)
+    if fm is None:
+        return []  # No frontmatter — not a spec file; skip silently
+
+    violations: list[Violation] = []
+
+    missing = REQUIRED_FRONTMATTER_KEYS - set(fm.keys())
+    for key in sorted(missing):
+        violations.append(Violation(path, 1, f"missing required frontmatter 
key: '{key}'"))
+
+    if "status" in fm and fm["status"] not in ALLOWED_STATUS:
+        violations.append(
+            Violation(
+                path,
+                1,
+                f"invalid status '{fm['status']}' — must be one of 
{sorted(ALLOWED_STATUS)}",
+            )
+        )
+
+    if "kind" in fm and fm["kind"] not in ALLOWED_KIND:
+        violations.append(
+            Violation(
+                path,
+                1,
+                f"invalid kind '{fm['kind']}' — must be one of 
{sorted(ALLOWED_KIND)}",
+            )
+        )
+
+    if "mode" in fm and fm["mode"] not in ALLOWED_MODE:
+        violations.append(
+            Violation(
+                path,
+                1,
+                f"invalid mode '{fm['mode']}' — must be one of 
{sorted(ALLOWED_MODE)}",
+            )
+        )
+
+    if "acceptance" in fm and not has_acceptance_items(text):
+        violations.append(
+            Violation(path, 1, "acceptance key is present but has no list 
items (expected '  - ...')")
+        )
+
+    return violations
+
+
+def validate_body(path: Path, text: str) -> list[Violation]:
+    if parse_frontmatter(text) is None:
+        return []  # Not a spec file
+
+    violations: list[Violation] = []
+    headings = extract_section_headings(text)
+
+    for section in REQUIRED_SECTIONS:
+        if section not in headings:
+            violations.append(Violation(path, None, f"missing required 
section: '## {section}'"))
+
+    if "Validation" in headings and not validation_has_code_block(text):
+        violations.append(
+            Violation(path, None, "Validation section has no fenced code block 
(expected ```...```)")
+        )
+
+    return violations
+
+
+# ---------------------------------------------------------------------------
+# Orchestrator
+# ---------------------------------------------------------------------------
+
+
+def validate_file(path: Path) -> list[Violation]:
+    try:
+        text = path.read_text(encoding="utf-8")
+    except OSError as exc:
+        return [Violation(path, None, f"cannot read file: {exc}")]
+    return validate_frontmatter(path, text) + validate_body(path, text)
+
+
+def collect_spec_files(target: Path) -> list[Path]:
+    """Return all .md files under *target* (or *target* itself if a file)."""
+    if target.is_file():
+        return [target]
+    return sorted(target.rglob("*.md"))
+
+
+def run_validation(target: Path) -> list[Violation]:
+    violations: list[Violation] = []
+    for path in collect_spec_files(target):
+        violations.extend(validate_file(path))
+    return violations
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description="Validate spec files.")
+    parser.add_argument(
+        "path",
+        nargs="?",
+        default=str(DEFAULT_SPEC_DIR),
+        help="Spec file or directory to validate (default: 
tools/spec-loop/specs/)",
+    )
+    args = parser.parse_args(argv)
+
+    target = Path(args.path)
+    if not target.exists():
+        print(f"spec-validator: path not found: {target}", file=sys.stderr)
+        return 1
+
+    violations = run_validation(target)
+    if not violations:
+        print("spec-validator: OK (no violations)")
+        return 0
+
+    print(f"spec-validator: {len(violations)} violation(s) found\n")
+    for v in violations:
+        print(v)
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/spec-validator/tests/test_spec_validator.py 
b/tools/spec-validator/tests/test_spec_validator.py
new file mode 100644
index 0000000..0e91f1c
--- /dev/null
+++ b/tools/spec-validator/tests/test_spec_validator.py
@@ -0,0 +1,413 @@
+# 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,
+    extract_section_headings,
+    get_section_body,
+    has_acceptance_items,
+    main,
+    parse_frontmatter,
+    run_validation,
+    validate_body,
+    validate_frontmatter,
+    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
+    uv run --project tools/example --group dev pytest
+    ```
+    """)
+
+
+def _make_spec(*, status: str = "stable", **overrides: str) -> str:
+    """Build a minimal valid spec, replacing frontmatter values as needed."""
+    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)
+    return f"---\n{fm}\n---\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_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
+
+
+# ---------------------------------------------------------------------------
+# 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}")
diff --git a/tools/spec-validator/uv.lock b/tools/spec-validator/uv.lock
new file mode 100644
index 0000000..4abefef
--- /dev/null
+++ b/tools/spec-validator/uv.lock
@@ -0,0 +1,112 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[options]
+exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included 
for backwards compatibility when using relative exclude-newer values.
+exclude-newer-span = "P7D"
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz";,
 hash = 
"sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size 
= 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl";,
 hash = 
"sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size 
= 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz";,
 hash = 
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size 
= 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl";,
 hash = 
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size 
= 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz";,
 hash = 
"sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size 
= 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl";,
 hash = 
"sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size 
= 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz";,
 hash = 
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size 
= 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl";,
 hash = 
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size 
= 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz";,
 hash = 
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size 
= 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl";,
 hash = 
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size 
= 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz";,
 hash = 
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size 
= 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl";,
 hash = 
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size 
= 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.13"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz";,
 hash = 
"sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size 
= 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl";,
 hash = 
"sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size 
= 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
+    { url = 
"https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl";,
 hash = 
"sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size 
= 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
+    { url = 
"https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl";,
 hash = 
"sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size 
= 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
+    { url = 
"https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl";,
 hash = 
"sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size 
= 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
+    { url = 
"https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl";,
 hash = 
"sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size 
= 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
+    { url = 
"https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl";,
 hash = 
"sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size 
= 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
+    { url = 
"https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl";,
 hash = 
"sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size 
= 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
+    { url = 
"https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl";,
 hash = 
"sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size 
= 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
+    { url = 
"https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl";,
 hash = 
"sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size 
= 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
+    { url = 
"https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl";,
 hash = 
"sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size 
= 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
+    { url = 
"https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl";,
 hash = 
"sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size 
= 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
+    { url = 
"https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl";,
 hash = 
"sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size 
= 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
+    { url = 
"https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl";,
 hash = 
"sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size 
= 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
+    { url = 
"https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl";,
 hash = 
"sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size 
= 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
+    { url = 
"https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl";,
 hash = 
"sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size 
= 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
+    { url = 
"https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl";,
 hash = 
"sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size 
= 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
+    { url = 
"https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl";,
 hash = 
"sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size 
= 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
+]
+
+[[package]]
+name = "spec-validator"
+version = "0.1.0"
+source = { editable = "." }
+
+[package.dev-dependencies]
+dev = [
+    { name = "pytest" },
+    { name = "ruff" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "pytest", specifier = ">=8.0" },
+    { name = "ruff", specifier = ">=0.6" },
+]


Reply via email to