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>
143 lines
4.5 KiB
Python
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())
|