mirror of
https://github.com/vacp2p/roadmap.git
synced 2026-01-08 15:23:55 -05:00
## Summary - Introduce a standalone Python roadmap validator with a CLI entry point, modular validation pipeline, and GitHub Actions wiring so roadmap content can be linted locally and in CI. - Provide reusable validation primitives for path resolution, front-matter parsing, identity checks, task parsing, catalog enforcement, and template adherence. - Document usage, configuration, and workflow behaviour to make the validator approachable for contributors. ## Validator Details - **Core tooling** - Added the `tools/roadmap_validator/` package with `validate.py` (CLI), `validator.py` (orchestration), and helper modules (`tasks.py`, `identity.py`, `paths.py`, `constants.py`, `issues.py`). - CLI supports directory/file targets, skips default filenames, emits GitHub annotations, and integrates optional substring filtering - README explains features, environment variables, and development guidance. - **Catalog and template enforcement** - `catalog.py` verifies each allowed content unit has `index.md` and `preview.md`, confirms roadmap entries appear under the proper quarter/area, and flags stale or missing links. - `templates.py` enforces template basics: front matter completeness, `## Description` ordering/content, template placeholder cleanup, and task section detection. - **Task validation** - `tasks.py` checks required metadata (`owner`, `status`, `start-date`, `end-date`), date formats, populated descriptions/deliverables, TODO markers, tangible deliverable heuristics, and `fully-qualified-name` prefixes. - **Workflow integration** - `.github/workflows/roadmap-validator.yml` runs the validator on pushes and manual dispatch, installs dependencies, scopes validation to changed Markdown, and surfaces findings via GitHub annotations. ## Existing Roadmap Updates - Normalised 2025q4 commitments across Web, DST, QA, SC, and other units by filling in missing descriptions, deliverables, schedule notes, recurring task statuses, and maintenance tasks. - Added tasks where absent, removed remaining template placeholders, aligned fully qualified names, and ensured roadmap files conform to the new validator checks. ## Testing ```bash python tools/roadmap_validator/validate.py *2025q4* ``` CI: `Roadmap Validator` workflow runs automatically on pushes/dispatch. --------- Co-authored-by: kaiserd <1684595+kaiserd@users.noreply.github.com>
169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
"""Unified template validation for roadmap commitments."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from constants import REQUIRED_FRONT_MATTER_KEYS
|
|
from issues import ValidationIssue
|
|
from paths import ALLOWED_CONTENT_SUBDIRS, CONTENT_ROOT
|
|
|
|
TEMPLATE_PATH = CONTENT_ROOT / "templates" / "commitment-template.md"
|
|
PLACEHOLDER_PATTERN = re.compile(r"<[^>\n]+>")
|
|
|
|
|
|
def _load_template_placeholders() -> List[str]:
|
|
try:
|
|
text = TEMPLATE_PATH.read_text(encoding="utf-8")
|
|
except FileNotFoundError:
|
|
return []
|
|
placeholders: List[str] = []
|
|
seen = set()
|
|
for match in PLACEHOLDER_PATTERN.finditer(text):
|
|
token = match.group(0)
|
|
if token not in seen:
|
|
placeholders.append(token)
|
|
seen.add(token)
|
|
return placeholders
|
|
|
|
|
|
TEMPLATE_PLACEHOLDERS = _load_template_placeholders()
|
|
|
|
|
|
def _is_commitment_file(path: Path) -> bool:
|
|
try:
|
|
relative = path.resolve().relative_to(CONTENT_ROOT)
|
|
except ValueError:
|
|
return False
|
|
if len(relative.parts) < 3:
|
|
return False
|
|
if relative.parts[0] == "templates":
|
|
return False
|
|
if path.suffix.lower() != ".md":
|
|
return False
|
|
if path.stem in ("index", "preview"):
|
|
return False
|
|
return relative.parts[0] in ALLOWED_CONTENT_SUBDIRS
|
|
|
|
|
|
@dataclass
|
|
class TemplateValidator:
|
|
path: Path
|
|
lines: List[str]
|
|
body_start: int
|
|
front_matter: Dict[str, object]
|
|
task_section: Optional[Tuple[int, int]] = None
|
|
|
|
def run(self) -> List[ValidationIssue]:
|
|
issues: List[ValidationIssue] = []
|
|
if not _is_commitment_file(self.path):
|
|
return issues
|
|
|
|
issues.extend(self._validate_front_matter())
|
|
self.task_section = self._find_task_section()
|
|
issues.extend(self._validate_description_section())
|
|
issues.extend(self._validate_placeholders())
|
|
return issues
|
|
|
|
def _validate_front_matter(self) -> List[ValidationIssue]:
|
|
issues: List[ValidationIssue] = []
|
|
for key in REQUIRED_FRONT_MATTER_KEYS:
|
|
value = self.front_matter.get(key)
|
|
if value in (None, "", []):
|
|
issues.append(
|
|
ValidationIssue(
|
|
path=self.path,
|
|
line=1,
|
|
message=f"missing `{key}` in front matter",
|
|
)
|
|
)
|
|
tags = self.front_matter.get("tags")
|
|
if tags is not None and not isinstance(tags, (list, tuple)):
|
|
issues.append(
|
|
ValidationIssue(
|
|
path=self.path,
|
|
line=1,
|
|
message="`tags` must be a list",
|
|
)
|
|
)
|
|
return issues
|
|
|
|
def _find_task_section(self) -> Optional[Tuple[int, int]]:
|
|
for idx in range(self.body_start, len(self.lines)):
|
|
if self.lines[idx].strip().lower() == "## task list":
|
|
end = len(self.lines)
|
|
for j in range(idx + 1, len(self.lines)):
|
|
if self.lines[j].startswith("## "):
|
|
end = j
|
|
break
|
|
return idx + 1, end
|
|
return None
|
|
|
|
def _validate_description_section(self) -> List[ValidationIssue]:
|
|
issues: List[ValidationIssue] = []
|
|
description_idx: Optional[int] = None
|
|
for idx in range(self.body_start, len(self.lines)):
|
|
if self.lines[idx].strip().lower() == "## description":
|
|
description_idx = idx
|
|
break
|
|
|
|
if description_idx is None:
|
|
issues.append(
|
|
ValidationIssue(
|
|
path=self.path,
|
|
line=None,
|
|
message="missing `## Description` section",
|
|
)
|
|
)
|
|
return issues
|
|
|
|
if self.task_section:
|
|
task_heading_idx = self.task_section[0] - 1
|
|
if description_idx > task_heading_idx:
|
|
issues.append(
|
|
ValidationIssue(
|
|
path=self.path,
|
|
line=description_idx + 1,
|
|
message="`## Description` section should appear before `## Task List`",
|
|
)
|
|
)
|
|
|
|
next_heading = len(self.lines)
|
|
for j in range(description_idx + 1, len(self.lines)):
|
|
if self.lines[j].strip().startswith("## "):
|
|
next_heading = j
|
|
break
|
|
|
|
if all(
|
|
not self.lines[k].strip() for k in range(description_idx + 1, next_heading)
|
|
):
|
|
issues.append(
|
|
ValidationIssue(
|
|
path=self.path,
|
|
line=description_idx + 2,
|
|
message="`## Description` section should contain descriptive content",
|
|
)
|
|
)
|
|
return issues
|
|
|
|
def _validate_placeholders(self) -> List[ValidationIssue]:
|
|
if not TEMPLATE_PLACEHOLDERS:
|
|
return []
|
|
|
|
issues: List[ValidationIssue] = []
|
|
for idx, line in enumerate(self.lines):
|
|
for match in PLACEHOLDER_PATTERN.finditer(line):
|
|
token = match.group(0)
|
|
if token in TEMPLATE_PLACEHOLDERS:
|
|
issues.append(
|
|
ValidationIssue(
|
|
path=self.path,
|
|
line=idx + 1,
|
|
message=f"remove placeholder `{token}` copied from the template",
|
|
)
|
|
)
|
|
return issues
|