Files
roadmap/tools/roadmap_validator/templates.py
fbarbu15 845d6b8dcd Chore/roadmap validator (#318)
## 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>
2025-10-28 15:41:11 +02:00

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