| # 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, |
| Known gaps. |
| 7. Validation section contains at least one fenced code block. |
| 8. SPDX license header — every spec file must carry the Apache-2.0 SPDX |
| identifier (``<!-- SPDX-License-Identifier: Apache-2.0``) before the |
| opening ``---`` frontmatter delimiter. |
| 9. Validation-path existence — every filesystem path referenced by a |
| shell pattern (``--project``, ``--directory``, ``bash -n``, |
| ``shellcheck``, ``test -f``) in a Validation code block must exist |
| under the repository root. Catches stale paths after renames. |
| 10. Spec-loop runner fixtures — when present, execute the deterministic |
| Bash fixture suite for prompt assembly, harness argv construction, and |
| ``.last-sync`` marker helpers. |
| |
| Files without frontmatter (README.md, overview.md) are skipped silently. |
| |
| Run from repo root:: |
| |
| uv run --project tools/spec-validator --group dev pytest tools/spec-validator/tests/ |
| uv run --project tools/spec-validator spec-validate tools/spec-loop/specs/ |
| """ |
| |
| from __future__ import annotations |
| |
| import argparse |
| import re |
| import subprocess |
| 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", |
| "Known gaps", |
| ) |
| |
| # Every spec .md file must carry this SPDX identifier before the frontmatter. |
| SPDX_MARKER = "SPDX-License-Identifier: Apache-2.0" |
| |
| 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({"|", ">", "|-", "|+", ">-", ">+"}) |
| |
| # --------------------------------------------------------------------------- |
| # Validation-path check constants (check #9) |
| # --------------------------------------------------------------------------- |
| |
| # Patterns that extract filesystem paths from shell validation commands. |
| # Each tuple is (compiled regex with one capturing group, human-readable label). |
| _VALIDATION_PATH_EXTRACTORS: list[tuple[re.Pattern[str], str]] = [ |
| (re.compile(r"--(?:project|directory)\s+(\S+)"), "uv --project/--directory"), |
| (re.compile(r"\bbash\s+-n\s+(\S+)"), "bash -n"), |
| (re.compile(r"\bshellcheck\s+(\S+)"), "shellcheck"), |
| (re.compile(r"\btest\s+-f\s+(\S+)"), "test -f"), |
| ] |
| |
| # Characters that mark a captured path as a shell variable or placeholder token |
| # and should be silently skipped by the path-existence check. |
| _PATH_SKIP_CHARS: tuple[str, ...] = ("$", "<") |
| |
| |
| # --------------------------------------------------------------------------- |
| # 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 |
| |
| |
| # --------------------------------------------------------------------------- |
| # SPDX header validation (check #8) |
| # --------------------------------------------------------------------------- |
| |
| |
| def validate_spdx_header(path: Path, text: str) -> list[Violation]: |
| """Check that a spec file carries the Apache-2.0 SPDX license header. |
| |
| Every ``.md`` file that has a YAML frontmatter block (i.e. is a spec, |
| not a README or overview) must contain ``SPDX-License-Identifier: |
| Apache-2.0`` somewhere before the opening ``---`` delimiter. Files |
| without frontmatter are skipped silently — they are not spec files. |
| """ |
| if parse_frontmatter(text) is None: |
| return [] # Not a spec file; no SPDX required. |
| |
| if SPDX_MARKER not in text: |
| return [ |
| Violation( |
| path, |
| 1, |
| f"missing SPDX license header — spec files must contain " |
| f"'<!-- {SPDX_MARKER}' before the frontmatter delimiter; " |
| f"see AGENTS.md § Commit and PR conventions", |
| ) |
| ] |
| return [] |
| |
| |
| # --------------------------------------------------------------------------- |
| # Validation-path existence check (check #9) |
| # --------------------------------------------------------------------------- |
| |
| |
| def _join_line_continuations(text: str) -> str: |
| r"""Join shell line-continuations (trailing ``\``) into single logical lines.""" |
| return re.sub(r"\\\n\s*", " ", text) |
| |
| |
| def find_repo_root(start: Path | None = None) -> Path: |
| """Walk up from *start* (or CWD) to find the repository root. |
| |
| Uses the presence of a ``tools/`` directory as the canonical marker. |
| Falls back to the starting path when no such ancestor is found. |
| """ |
| cur = (start or Path.cwd()).resolve() |
| for candidate in (cur, *cur.parents): |
| if (candidate / "tools").is_dir(): |
| return candidate |
| return cur |
| |
| |
| def validate_validation_paths(path: Path, text: str, repo_root: Path | None = None) -> list[Violation]: |
| """Check that filesystem paths in ## Validation code blocks exist on disk. |
| |
| Extracts path arguments from these shell patterns inside fenced code |
| blocks of the ``## Validation`` section: |
| |
| - ``uv run --project <path>`` and ``uv run --directory <path>`` |
| - ``bash -n <file>`` |
| - ``shellcheck <file>`` |
| - ``test -f <file>`` |
| |
| Paths containing ``$`` (shell variables) or ``<`` (placeholder tokens) |
| are silently skipped. Bare tokens without a ``/`` are also skipped. |
| Relative paths are resolved against the repository root. |
| """ |
| if parse_frontmatter(text) is None: |
| return [] # Not a spec file |
| |
| section_body = get_section_body(text, "Validation") |
| if not section_body: |
| return [] |
| |
| root = repo_root or find_repo_root(path.parent) |
| violations: list[Violation] = [] |
| |
| for block_match in _FENCED_CODE_RE.finditer(section_body): |
| block_text = _join_line_continuations(block_match.group()) |
| for pattern, label in _VALIDATION_PATH_EXTRACTORS: |
| for m in pattern.finditer(block_text): |
| raw = m.group(1).rstrip(";\\|&") |
| if not raw: |
| continue |
| # Skip shell variables and placeholder tokens |
| if any(skip in raw for skip in _PATH_SKIP_CHARS): |
| continue |
| # Skip bare command names without a path separator |
| if "/" not in raw: |
| continue |
| target = Path(raw) if Path(raw).is_absolute() else root / raw |
| if not target.exists(): |
| violations.append( |
| Violation( |
| path, |
| None, |
| f"validation command references missing path: {raw!r} " |
| f"(pattern: {label}; resolved to {target})", |
| ) |
| ) |
| |
| return violations |
| |
| |
| # --------------------------------------------------------------------------- |
| # Orchestrator |
| # --------------------------------------------------------------------------- |
| |
| |
| def validate_file(path: Path, repo_root: Path | None = None) -> 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_spdx_header(path, text) |
| + validate_frontmatter(path, text) |
| + validate_body(path, text) |
| + validate_validation_paths(path, text, repo_root=repo_root) |
| ) |
| |
| |
| 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, repo_root: Path | None = None) -> list[Violation]: |
| root = repo_root or find_repo_root(target) |
| violations: list[Violation] = [] |
| for path in collect_spec_files(target): |
| violations.extend(validate_file(path, repo_root=root)) |
| violations.extend(validate_spec_loop_runner_fixtures(root)) |
| return violations |
| |
| |
| def validate_spec_loop_runner_fixtures(repo_root: Path) -> list[Violation]: |
| """Run deterministic fixture tests for the spec-loop runner when present.""" |
| fixture = repo_root / "tools" / "spec-loop" / "tests" / "test_runner_fixtures.sh" |
| if not fixture.exists(): |
| return [] |
| |
| result = subprocess.run( |
| ["bash", str(fixture)], |
| cwd=repo_root, |
| text=True, |
| capture_output=True, |
| check=False, |
| ) |
| if result.returncode == 0: |
| return [] |
| |
| output = "\n".join(part for part in (result.stdout.strip(), result.stderr.strip()) if part) |
| return [ |
| Violation( |
| fixture.relative_to(repo_root), |
| None, |
| f"spec-loop runner fixture tests failed with exit code {result.returncode}: {output}", |
| ) |
| ] |
| |
| |
| # --------------------------------------------------------------------------- |
| # 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()) |