Skip to content

Commit c72dd2d

Browse files
committed
chore: roadmap validator draft
1 parent 34e3165 commit c72dd2d

File tree

9 files changed

+690
-1
lines changed

9 files changed

+690
-1
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: Roadmap Validator
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'content/qa/**'
7+
- 'tools/roadmap_validator/validate.py'
8+
- 'tools/roadmap_validator/**'
9+
- '.github/workflows/roadmap-validator.yml'
10+
push:
11+
branches:
12+
- main
13+
paths:
14+
- 'content/qa/**'
15+
- 'tools/roadmap_validator/validate.py'
16+
- 'tools/roadmap_validator/**'
17+
- '.github/workflows/roadmap-validator.yml'
18+
19+
jobs:
20+
validate:
21+
runs-on: ubuntu-latest
22+
env:
23+
TARGET_DIRECTORIES: "content/qa"
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
with:
28+
fetch-depth: 0
29+
30+
- name: Set up Python
31+
uses: actions/setup-python@v5
32+
with:
33+
python-version: '3.x'
34+
35+
- name: Install dependencies
36+
run: pip install --disable-pip-version-check --no-cache-dir pyyaml
37+
38+
- name: Run roadmap validator
39+
run: |
40+
set -eo pipefail
41+
BASE_SHA="${{ github.event.pull_request.base.sha || github.event.before }}"
42+
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
43+
CHANGED_FILES=$(git ls-files)
44+
else
45+
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRT "$BASE_SHA" "$GITHUB_SHA")
46+
fi
47+
48+
TARGET_FILES=""
49+
for file in $CHANGED_FILES; do
50+
for dir in $TARGET_DIRECTORIES; do
51+
case "$file" in
52+
$dir/*.md)
53+
if [ -f "$file" ]; then
54+
TARGET_FILES="$TARGET_FILES $file"
55+
fi
56+
;;
57+
esac
58+
done
59+
done
60+
61+
if [ -z "$TARGET_FILES" ]; then
62+
echo "No roadmap markdown changes detected."
63+
exit 0
64+
fi
65+
66+
echo "Validating files:$TARGET_FILES"
67+
python tools/roadmap_validator/validate.py $TARGET_FILES

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ private/
88
.replit
99
replit.nix
1010
node_modules
11-
.ipynb_checkpoints/
11+
.ipynb_checkpoints/
12+
.cache
13+
__pycache__
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Roadmap validator library."""
2+
3+
from .validate import main, run_validator
4+
5+
__all__ = ["main", "run_validator"]
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Shared constants and regular expressions for roadmap validation."""
2+
3+
import re
4+
5+
REQUIRED_FRONT_MATTER_KEYS = ("title", "tags", "description")
6+
REQUIRED_TASK_FIELDS = ("owner", "status", "start-date", "end-date")
7+
8+
METADATA_ALIAS_MAP = {
9+
"fully-qualified-name": "fully-qualified-name",
10+
"fully qualified name": "fully-qualified-name",
11+
"owner": "owner",
12+
"status": "status",
13+
"start-date": "start-date",
14+
"start date": "start-date",
15+
"end-date": "end-date",
16+
"end date": "end-date",
17+
}
18+
19+
DATE_PATTERN = r"^\d{4}/\d{1,2}/\d{1,2}$"
20+
DATE_RE = re.compile(DATE_PATTERN)
21+
TODO_PATTERN = r"\bTODO\b"
22+
TODO_RE = re.compile(TODO_PATTERN, re.IGNORECASE)
23+
TASK_HEADING_PATTERN = r"^###\s+(.*)"
24+
TASK_HEADING_RE = re.compile(TASK_HEADING_PATTERN)
25+
META_LINE_PATTERN = r"^[\-\*]\s*([^:]+):\s*(.*)$"
26+
META_LINE_RE = re.compile(META_LINE_PATTERN)
27+
28+
VAGUE_KEYWORDS = (
29+
"investigate",
30+
"research",
31+
"explore",
32+
"analysis",
33+
"assess",
34+
"assessment",
35+
"evaluate",
36+
"evaluation",
37+
"review",
38+
"understand",
39+
"ideate",
40+
"brainstorm",
41+
)
42+
43+
TANGIBLE_KEYWORDS = (
44+
"pr",
45+
"issue",
46+
"issues",
47+
"pull request",
48+
"merge request",
49+
"test",
50+
"tests",
51+
"notion",
52+
"doc",
53+
"docs",
54+
"documentation",
55+
"report",
56+
"reports",
57+
"dashboard",
58+
"script",
59+
"plan",
60+
"plans",
61+
"page",
62+
"pages",
63+
"spec",
64+
"specs",
65+
"analysis doc",
66+
"deliverable",
67+
"deliverables",
68+
)
69+
70+
DEFAULT_SKIP_FILENAMES = {"index.md"}
71+
72+
__all__ = [
73+
"REQUIRED_FRONT_MATTER_KEYS",
74+
"REQUIRED_TASK_FIELDS",
75+
"METADATA_ALIAS_MAP",
76+
"DATE_RE",
77+
"TODO_RE",
78+
"TASK_HEADING_RE",
79+
"META_LINE_RE",
80+
"VAGUE_KEYWORDS",
81+
"TANGIBLE_KEYWORDS",
82+
"DEFAULT_SKIP_FILENAMES",
83+
]
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Commitment identity extraction and related validation."""
2+
3+
import re
4+
from dataclasses import dataclass
5+
from pathlib import Path
6+
from typing import Dict, List, Optional
7+
8+
from .paths import CONTENT_ROOT, should_skip
9+
10+
IDENTIFIER_PATTERN = re.compile(r"^`([^`]+)`\s*$")
11+
FILENAME_PATTERN = re.compile(r"^(?P<quarter>\d{4}q[1-4])-(?P<slug>.+)$")
12+
13+
14+
@dataclass
15+
class CommitmentIdentity:
16+
unit: str
17+
quarter: str
18+
area: str
19+
slug: str
20+
expected_tags: List[str]
21+
expected_identifier: str
22+
expected_base: str
23+
24+
25+
def derive_identity(path: Path) -> Optional[CommitmentIdentity]:
26+
"""Infer roadmap identity metadata from the file path."""
27+
try:
28+
relative = path.resolve().relative_to(CONTENT_ROOT)
29+
except ValueError:
30+
return None
31+
if len(relative.parts) < 3 or should_skip(path):
32+
return None
33+
34+
unit, area = relative.parts[0], relative.parts[1]
35+
match = FILENAME_PATTERN.match(path.stem)
36+
if not match:
37+
return None
38+
39+
quarter = match.group("quarter")
40+
slug = match.group("slug")
41+
expected_base = f"vac:{unit}:{area}:{quarter}-{slug}"
42+
expected_tags = [quarter, unit, area]
43+
expected_identifier = expected_base
44+
return CommitmentIdentity(
45+
unit=unit,
46+
quarter=quarter,
47+
area=area,
48+
slug=slug,
49+
expected_tags=expected_tags,
50+
expected_identifier=expected_identifier,
51+
expected_base=expected_base,
52+
)
53+
54+
55+
def validate_identity(
56+
path: Path,
57+
front_matter: Dict[str, object],
58+
lines: List[str],
59+
body_start: int,
60+
identity: CommitmentIdentity,
61+
) -> List[str]:
62+
"""Validate tags and inline identifier for a commitment."""
63+
issues: List[str] = []
64+
65+
tags = front_matter.get("tags")
66+
if isinstance(tags, (list, tuple)):
67+
tag_values = [str(tag) for tag in tags]
68+
if tag_values != identity.expected_tags:
69+
issues.append(
70+
f"{path}: tags should be {identity.expected_tags!r} (found {tag_values!r})"
71+
)
72+
else:
73+
issues.append(
74+
f"{path}: tags must be a list matching {identity.expected_tags!r}"
75+
)
76+
77+
identifier_value: Optional[str] = None
78+
identifier_line: Optional[int] = None
79+
for idx in range(body_start, len(lines)):
80+
stripped = lines[idx].strip()
81+
match = IDENTIFIER_PATTERN.match(stripped)
82+
if match:
83+
identifier_value = match.group(1)
84+
identifier_line = idx + 1
85+
break
86+
87+
if identifier_value is None:
88+
issues.append(
89+
f"{path}:{body_start + 1}: missing commitment identifier `"
90+
f"{identity.expected_identifier}`"
91+
)
92+
elif identifier_value != identity.expected_identifier:
93+
issues.append(
94+
f"{path}:{identifier_line}: identifier should be `"
95+
f"{identity.expected_identifier}` (found `{identifier_value}`)"
96+
)
97+
98+
return issues
99+
100+
101+
__all__ = ["CommitmentIdentity", "derive_identity", "validate_identity"]

tools/roadmap_validator/paths.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Filesystem helpers for roadmap validation."""
2+
3+
import os
4+
import sys
5+
from pathlib import Path
6+
from typing import Iterable, List, Optional
7+
8+
from .constants import DEFAULT_SKIP_FILENAMES
9+
10+
REPO_ROOT = Path(__file__).resolve().parents[2]
11+
CONTENT_ROOT = REPO_ROOT / "content"
12+
ENV_SKIP_FILENAMES = {
13+
entry.strip() for entry in os.environ.get("ROADMAP_VALIDATION_SKIP", "").split() if entry.strip()
14+
}
15+
SKIP_FILENAMES = {name.lower() for name in (DEFAULT_SKIP_FILENAMES | ENV_SKIP_FILENAMES)}
16+
17+
18+
def should_skip(path: Path) -> bool:
19+
"""Return True if the file should be ignored by the validator."""
20+
return path.name.lower() in SKIP_FILENAMES
21+
22+
23+
def resolve_user_path(raw_target: str) -> Optional[Path]:
24+
"""Resolve a user-supplied path relative to cwd, repo root, or content root."""
25+
raw_path = Path(raw_target).expanduser()
26+
search_paths: List[Path] = []
27+
if raw_path.is_absolute():
28+
search_paths.append(raw_path)
29+
else:
30+
search_paths.extend(
31+
[
32+
Path.cwd() / raw_path,
33+
REPO_ROOT / raw_path,
34+
CONTENT_ROOT / raw_path,
35+
]
36+
)
37+
for candidate in search_paths:
38+
if candidate.exists():
39+
return candidate.resolve()
40+
return None
41+
42+
43+
def resolve_targets(targets: Iterable[str]) -> List[Path]:
44+
"""Expand iterable of target paths into unique Markdown files."""
45+
md_files: List[Path] = []
46+
seen: set[Path] = set()
47+
for raw_target in targets:
48+
target = resolve_user_path(raw_target)
49+
if target is None:
50+
sys.stderr.write(f"Warning: skipping unknown path {raw_target!r}\n")
51+
continue
52+
if target.is_dir():
53+
for file_path in sorted(target.rglob("*.md")):
54+
if should_skip(file_path) or file_path in seen:
55+
continue
56+
md_files.append(file_path)
57+
seen.add(file_path)
58+
elif target.is_file() and target.suffix.lower() == ".md":
59+
if should_skip(target) or target in seen:
60+
continue
61+
md_files.append(target)
62+
seen.add(target)
63+
else:
64+
sys.stderr.write(f"Warning: skipping non-markdown path {raw_target!r}\n")
65+
return md_files
66+
67+
68+
__all__ = [
69+
"CONTENT_ROOT",
70+
"REPO_ROOT",
71+
"should_skip",
72+
"resolve_targets",
73+
]

0 commit comments

Comments
 (0)