Skip to content

Commit 2b1e5c7

Browse files
committed
chore: roadmap validator test 2
1 parent 46e4893 commit 2b1e5c7

File tree

5 files changed

+127
-36
lines changed

5 files changed

+127
-36
lines changed

tools/roadmap_validator/identity.py

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass
55
from pathlib import Path
66
from typing import Dict, List, Optional, Tuple
7+
from issues import ValidationIssue
78
from paths import CONTENT_ROOT, should_skip
89

910
IDENTIFIER_PATTERN = re.compile(r"^`([^`]+)`\s*$")
@@ -21,9 +22,9 @@ class CommitmentIdentity:
2122
expected_base: str
2223

2324

24-
def derive_identity(path: Path) -> Tuple[Optional[CommitmentIdentity], List[str]]:
25+
def derive_identity(path: Path) -> Tuple[Optional[CommitmentIdentity], List[ValidationIssue]]:
2526
"""Infer roadmap identity metadata from the file path."""
26-
issues: List[str] = []
27+
issues: List[ValidationIssue] = []
2728
try:
2829
relative = path.resolve().relative_to(CONTENT_ROOT)
2930
except ValueError:
@@ -35,7 +36,11 @@ def derive_identity(path: Path) -> Tuple[Optional[CommitmentIdentity], List[str]
3536
match = FILENAME_PATTERN.match(path.stem)
3637
if not match:
3738
issues.append(
38-
f"{path}: filename should follow `<year>q<quarter>-<slug>.md` (found `{path.name}`)"
39+
ValidationIssue(
40+
path=path,
41+
line=None,
42+
message=f"filename should follow `<year>q<quarter>-<slug>.md` (found `{path.name}`)",
43+
)
3944
)
4045
return None, issues
4146

@@ -44,18 +49,16 @@ def derive_identity(path: Path) -> Tuple[Optional[CommitmentIdentity], List[str]
4449
expected_base = f"vac:{unit}:{area}:{quarter}-{slug}"
4550
expected_tags = [quarter, unit, area]
4651
expected_identifier = expected_base
47-
return (
48-
CommitmentIdentity(
52+
identity = CommitmentIdentity(
4953
unit=unit,
5054
quarter=quarter,
5155
area=area,
5256
slug=slug,
5357
expected_tags=expected_tags,
5458
expected_identifier=expected_identifier,
5559
expected_base=expected_base,
56-
),
57-
issues,
5860
)
61+
return identity, issues
5962

6063

6164
def validate_identity(
@@ -64,20 +67,28 @@ def validate_identity(
6467
lines: List[str],
6568
body_start: int,
6669
identity: CommitmentIdentity,
67-
) -> List[str]:
70+
) -> List[ValidationIssue]:
6871
"""Validate tags and inline identifier for a commitment."""
69-
issues: List[str] = []
72+
issues: List[ValidationIssue] = []
7073

7174
tags = front_matter.get("tags")
7275
if isinstance(tags, (list, tuple)):
7376
tag_values = [str(tag) for tag in tags]
7477
if tag_values != identity.expected_tags:
7578
issues.append(
76-
f"{path}: tags should be {identity.expected_tags!r} (found {tag_values!r})"
79+
ValidationIssue(
80+
path=path,
81+
line=1,
82+
message=f"tags should be {identity.expected_tags!r} (found {tag_values!r})",
83+
)
7784
)
7885
else:
7986
issues.append(
80-
f"{path}: tags must be a list matching {identity.expected_tags!r}"
87+
ValidationIssue(
88+
path=path,
89+
line=1,
90+
message=f"tags must be a list matching {identity.expected_tags!r}",
91+
)
8192
)
8293

8394
identifier_value: Optional[str] = None
@@ -92,13 +103,21 @@ def validate_identity(
92103

93104
if identifier_value is None:
94105
issues.append(
95-
f"{path}:{body_start + 1}: missing commitment identifier `"
96-
f"{identity.expected_identifier}`"
106+
ValidationIssue(
107+
path=path,
108+
line=body_start + 1,
109+
message=f"missing commitment identifier `{identity.expected_identifier}`",
110+
)
97111
)
98112
elif identifier_value != identity.expected_identifier:
99113
issues.append(
100-
f"{path}:{identifier_line}: identifier should be `"
101-
f"{identity.expected_identifier}` (found `{identifier_value}`)"
114+
ValidationIssue(
115+
path=path,
116+
line=identifier_line,
117+
message=(
118+
f"identifier should be `{identity.expected_identifier}` (found `{identifier_value}`)"
119+
),
120+
)
102121
)
103122

104123
return issues

tools/roadmap_validator/issues.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Validation issue data structures."""
2+
3+
from dataclasses import dataclass
4+
from pathlib import Path
5+
from typing import Optional
6+
7+
8+
@dataclass
9+
class ValidationIssue:
10+
path: Path
11+
line: Optional[int]
12+
message: str

tools/roadmap_validator/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
@dataclass
1818
class TaskIssue:
1919
message: str
20-
line: int
20+
line: Optional[int]
2121

2222

2323
@dataclass

tools/roadmap_validator/validate.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,63 @@
55
import os
66
import shlex
77
import sys
8+
from collections import defaultdict
9+
from pathlib import Path
810
from typing import Iterable, List, Optional
9-
from paths import resolve_targets
11+
from paths import REPO_ROOT, resolve_targets
1012
from validator import validate_file
13+
from issues import ValidationIssue
1114

12-
DEFAULT_TARGETS = "acz dst qa nes nim p2p rfc sc sec tke web"
15+
DEFAULT_TARGETS = "content"
16+
17+
18+
def _relpath(path: Path) -> Path:
19+
if path.is_absolute():
20+
try:
21+
return path.relative_to(REPO_ROOT)
22+
except ValueError:
23+
return path
24+
return path
25+
26+
27+
def format_issue(issue: ValidationIssue) -> str:
28+
location = _relpath(issue.path)
29+
prefix = f"{location}"
30+
if issue.line:
31+
prefix += f":{issue.line}"
32+
return f"{prefix}: {issue.message}"
33+
34+
35+
def emit_github_annotations(issues: List[ValidationIssue]) -> None:
36+
if os.getenv("GITHUB_ACTIONS") != "true":
37+
return
38+
for issue in issues:
39+
location = _relpath(issue.path)
40+
file_str = str(location)
41+
if issue.line:
42+
print(f"::error file={file_str},line={issue.line}::{issue.message}")
43+
else:
44+
print(f"::error file={file_str}::{issue.message}")
45+
46+
summary_path = os.getenv("GITHUB_STEP_SUMMARY")
47+
if not summary_path:
48+
return
49+
50+
issues_by_file = defaultdict(list)
51+
for issue in issues:
52+
issues_by_file[str(_relpath(issue.path))].append(issue)
53+
54+
unique_files = {str(_relpath(issue.path)) for issue in issues}
55+
56+
with open(summary_path, "a", encoding="utf-8") as summary:
57+
summary.write("## Roadmap Validator Report\n\n")
58+
summary.write(f"Found {len(issues)} issues across {len(unique_files)} file(s).\n\n")
59+
for file_path, file_issues in sorted(issues_by_file.items()):
60+
summary.write(f"- `{file_path}`\n")
61+
for issue in file_issues:
62+
line_info = f" (line {issue.line})" if issue.line else ""
63+
summary.write(f" - {issue.message}{line_info}\n")
64+
summary.write("\n")
1365

1466

1567
def run_validator(targets: Iterable[str]) -> int:
@@ -18,13 +70,16 @@ def run_validator(targets: Iterable[str]) -> int:
1870
sys.stderr.write("No markdown files found for validation.\n")
1971
return 0
2072

21-
all_issues: List[str] = []
73+
all_issues: List[ValidationIssue] = []
2274
for file_path in files:
2375
all_issues.extend(validate_file(file_path))
2476

2577
if all_issues:
26-
sys.stderr.write("\n".join(all_issues) + "\n")
27-
sys.stderr.write(f"\nValidation failed for {len(files)} file(s).\n")
78+
emit_github_annotations(all_issues)
79+
for issue in sorted(all_issues, key=lambda i: (str(_relpath(i.path)), i.line or 0, i.message)):
80+
print(format_issue(issue))
81+
unique_files = { _relpath(i.path) for i in all_issues }
82+
print(f"\nValidation failed for {len(unique_files)} file(s).")
2883
return 1
2984

3085
print(f"All checks passed for {len(files)} file(s).")
@@ -38,7 +93,7 @@ def build_parser() -> argparse.ArgumentParser:
3893
parser.add_argument(
3994
"paths",
4095
nargs="*",
41-
help="Files or directories to validate. Defaults to ROADMAP_VALIDATION_PATHS or 'qa'.",
96+
help="Files or directories to validate. Defaults to ROADMAP_VALIDATION_PATHS or 'content'.",
4297
)
4398
return parser
4499

tools/roadmap_validator/validator.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from constants import REQUIRED_FRONT_MATTER_KEYS
99
from identity import derive_identity, validate_identity
10+
from issues import ValidationIssue
1011
from paths import should_skip
1112
from tasks import TaskReport, parse_tasks
1213

@@ -30,13 +31,13 @@ def parse_front_matter(lines: List[str]) -> Tuple[dict, int]:
3031
return data, end_idx + 1
3132

3233

33-
def validate_front_matter(data: dict) -> List[str]:
34-
issues: List[str] = []
34+
def validate_front_matter(path: Path, data: dict) -> List[ValidationIssue]:
35+
issues: List[ValidationIssue] = []
3536
for key in REQUIRED_FRONT_MATTER_KEYS:
3637
if key not in data or data[key] in (None, "", []):
37-
issues.append(f"missing `{key}` in front matter")
38+
issues.append(ValidationIssue(path, 1, f"missing `{key}` in front matter"))
3839
if "tags" in data and not isinstance(data["tags"], (list, tuple)):
39-
issues.append("`tags` must be a list")
40+
issues.append(ValidationIssue(path, 1, "`tags` must be a list"))
4041
return issues
4142

4243

@@ -52,22 +53,20 @@ def find_task_section(lines: List[str], start: int) -> Optional[Tuple[int, int]]
5253
return None
5354

5455

55-
def validate_file(path: Path) -> List[str]:
56+
def validate_file(path: Path) -> List[ValidationIssue]:
5657
if should_skip(path):
5758
return []
5859

5960
content = path.read_text(encoding="utf-8")
6061
lines = content.splitlines()
61-
issues: List[str] = []
62+
issues: List[ValidationIssue] = []
6263

6364
try:
6465
front_matter, body_start = parse_front_matter(lines)
6566
except Exception as exc: # pragma: no cover - defensive guard
66-
return [f"{path}: failed to parse front matter: {exc}"]
67+
return [ValidationIssue(path, 1, f"failed to parse front matter: {exc}")]
6768

68-
issues.extend(
69-
f"{path}: {message}" for message in validate_front_matter(front_matter)
70-
)
69+
issues.extend(validate_front_matter(path, front_matter))
7170

7271
identity, identity_issues = derive_identity(path)
7372
issues.extend(identity_issues)
@@ -81,16 +80,22 @@ def validate_file(path: Path) -> List[str]:
8180

8281
section = find_task_section(lines, body_start)
8382
if not section:
84-
issues.append(f"{path}: missing `## Task List` section")
83+
issues.append(ValidationIssue(path, None, "missing `## Task List` section"))
8584
return issues
8685

8786
start, end = section
8887
tasks: List[TaskReport] = parse_tasks(lines, start, end, expected_base)
8988
if not tasks:
90-
issues.append(f"{path}: no tasks found under `## Task List`")
89+
issues.append(ValidationIssue(path, None, "no tasks found under `## Task List`"))
9190
return issues
9291

9392
for task in tasks:
94-
for issue in task.issues:
95-
issues.append(f"{path}:{issue.line}: Task `{task.name}` {issue.message}")
93+
for task_issue in task.issues:
94+
issues.append(
95+
ValidationIssue(
96+
path=path,
97+
line=task_issue.line,
98+
message=f"Task `{task.name}` {task_issue.message}",
99+
)
100+
)
96101
return issues

0 commit comments

Comments
 (0)