Files
roadmap/tools/roadmap_validator/validate.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

143 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""CLI entry point for roadmap validator."""
import argparse
import os
import shlex
import sys
from collections import defaultdict
from pathlib import Path
from typing import Iterable, List, Optional, Sequence
from paths import REPO_ROOT, resolve_targets
from catalog import validate_catalog
from validator import validate_file
from issues import ValidationIssue
DEFAULT_TARGETS = "content"
def _relpath(path: Path) -> Path:
if path.is_absolute():
try:
return path.relative_to(REPO_ROOT)
except ValueError:
return path
return path
def format_issue(issue: ValidationIssue) -> str:
location = _relpath(issue.path)
prefix = f"{location}"
if issue.line:
prefix += f":{issue.line}"
return f"{prefix}: {issue.message}"
def emit_github_annotations(issues: List[ValidationIssue]) -> None:
if os.getenv("GITHUB_ACTIONS") != "true":
return
for issue in issues:
location = _relpath(issue.path)
file_str = str(location)
if issue.line:
print(f"::error file={file_str},line={issue.line}::{issue.message}")
else:
print(f"::error file={file_str}::{issue.message}")
summary_path = os.getenv("GITHUB_STEP_SUMMARY")
if not summary_path:
return
issues_by_file = defaultdict(list)
for issue in issues:
issues_by_file[str(_relpath(issue.path))].append(issue)
unique_files = {str(_relpath(issue.path)) for issue in issues}
with open(summary_path, "a", encoding="utf-8") as summary:
summary.write("## Roadmap Validator Report\n\n")
summary.write(f"Found {len(issues)} issues across {len(unique_files)} file(s).\n\n")
for file_path, file_issues in sorted(issues_by_file.items()):
summary.write(f"- `{file_path}`\n")
for issue in file_issues:
line_info = f" (line {issue.line})" if issue.line else ""
summary.write(f" - {issue.message}{line_info}\n")
summary.write("\n")
def run_validator(
targets: Iterable[str],
required_substrings: Optional[Sequence[str]] = None,
) -> int:
files = resolve_targets(targets)
if required_substrings:
filtered_files: List[Path] = []
for file_path in files:
try:
contents = file_path.read_text(encoding="utf-8")
if all(substring in contents for substring in required_substrings):
filtered_files.append(file_path)
except Exception as exc:
sys.stderr.write(f"Warning: failed to read {file_path}: {exc}\n")
files = filtered_files
if not files:
sys.stderr.write("No markdown files found for validation.\n")
return 0
all_issues: List[ValidationIssue] = []
for file_path in files:
all_issues.extend(validate_file(file_path))
all_issues.extend(validate_catalog(files))
if all_issues:
emit_github_annotations(all_issues)
for issue in sorted(all_issues, key=lambda i: (str(_relpath(i.path)), i.line or 0, i.message)):
print(format_issue(issue))
unique_files = { _relpath(i.path) for i in all_issues }
print(f"\nValidation failed for {len(unique_files)} file(s).")
return 1
print(f"All checks passed for {len(files)} file(s).")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Validate roadmap Markdown files for actionability standards.",
)
parser.add_argument(
"paths",
nargs="*",
help="Files or directories to validate. Defaults to 'content'. "
"Tokens like '*substring*' filter files whose contents include that substring.",
)
return parser
def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
raw_targets = list(args.paths) or [DEFAULT_TARGETS]
substring_filters: List[str] = []
filtered_targets: List[str] = []
for entry in raw_targets:
if entry.startswith("*") and entry.endswith("*") and len(entry) > 2:
substring_filters.append(entry.strip("*"))
else:
filtered_targets.append(entry)
if not filtered_targets:
filtered_targets = [DEFAULT_TARGETS]
required_substrings: Optional[List[str]] = substring_filters or None
return run_validator(filtered_targets, required_substrings)
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())