Skip to content

Commit 725488c

Browse files
committed
Fix CI validators to use repo-local scripts only
1 parent a6590d6 commit 725488c

3 files changed

Lines changed: 229 additions & 3 deletions

File tree

.github/scripts/validate_changed_files_ci.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from pathlib import Path
1010

1111
ROOT = Path(__file__).resolve().parents[2]
12-
SKILL_SCRIPTS = ROOT / ".codex" / "skills" / "pr-review-repo" / "scripts"
1312
CI_SCRIPTS = ROOT / ".github" / "scripts"
1413
ALLOWED_PREFIXES = ("wiki/", "docs/", "assets/")
1514

@@ -62,13 +61,13 @@ def main() -> int:
6261
status = 0
6362

6463
if md_files:
65-
status |= _run([sys.executable, str(SKILL_SCRIPTS / "validate_markdown.py"), *md_files])
64+
status |= _run([sys.executable, str(CI_SCRIPTS / "validate_markdown_ci.py"), *md_files])
6665
status |= _run([sys.executable, str(CI_SCRIPTS / "validate_images_ci.py"), *md_files])
6766
else:
6867
print("Skipping markdown/image validation (no markdown files changed).")
6968

7069
if yaml_files:
71-
status |= _run([sys.executable, str(SKILL_SCRIPTS / "validate_yaml.py"), *yaml_files])
70+
status |= _run([sys.executable, str(CI_SCRIPTS / "validate_yaml_ci.py"), *yaml_files])
7271
else:
7372
print("Skipping YAML validation (no yaml files changed).")
7473

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
#!/usr/bin/env python3
2+
"""CI markdown validation for changed markdown files."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import re
8+
import subprocess
9+
import sys
10+
from pathlib import Path
11+
from urllib.parse import unquote
12+
13+
MD_EXTS = {".md", ".markdown"}
14+
LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
15+
IMAGE_RE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")
16+
FENCE_RE = re.compile(r"^```")
17+
INLINE_MATH_RE = re.compile(r"\$(?:\\.|[^\n$])+\$")
18+
BLOCK_MATH_RE = re.compile(r"\$\$(?:.|\n)*?\$\$", re.MULTILINE)
19+
VALID_MD_BASENAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*\.md$")
20+
21+
22+
def _clean_target(target: str) -> str:
23+
return target.strip().split()[0].strip("<>").split("#", 1)[0]
24+
25+
26+
def _is_external(target: str) -> bool:
27+
return target.startswith(("http://", "https://", "mailto:", "tel:"))
28+
29+
30+
def _iter_markdown_paths(root: Path, inputs: list[str]) -> list[Path]:
31+
out: list[Path] = []
32+
for raw in inputs:
33+
p = (root / raw).resolve() if not Path(raw).is_absolute() else Path(raw)
34+
if p.is_file() and p.suffix.lower() in MD_EXTS:
35+
out.append(p)
36+
return sorted(set(out))
37+
38+
39+
def _validate_front_matter(lines: list[str], path: Path) -> list[str]:
40+
issues = []
41+
if lines and lines[0].strip() == "---":
42+
try:
43+
end_idx = next(i for i, line in enumerate(lines[1:], start=1) if line.strip() == "---")
44+
if end_idx == 1:
45+
issues.append(f"{path}:1 empty front matter block")
46+
except StopIteration:
47+
issues.append(f"{path}:1 missing closing front matter delimiter '---'")
48+
return issues
49+
50+
51+
def _validate_filename(path: Path, root: Path) -> list[str]:
52+
rel = path.relative_to(root)
53+
name = path.name
54+
if name in {"index.md", "__all_subsections.md"}:
55+
return []
56+
if name.lower() != name:
57+
return [f"{path}:1 invalid markdown filename casing: {rel}"]
58+
if not VALID_MD_BASENAME_RE.match(name):
59+
return [f"{path}:1 invalid markdown filename format (use kebab-case): {rel}"]
60+
return []
61+
62+
63+
def _candidate_targets(base: Path) -> list[Path]:
64+
s = str(base)
65+
cands = [base]
66+
if not base.suffix:
67+
cands.append(Path(s + ".md"))
68+
cands.append(Path(s + ".markdown"))
69+
cands.append(base / "index.md")
70+
cands.append(base / "index.markdown")
71+
return cands
72+
73+
74+
def _resolve_target(raw: str, path: Path, root: Path) -> Path | None:
75+
cleaned = unquote(raw)
76+
base = (root / cleaned.lstrip("/")) if cleaned.startswith("/") else (path.parent / cleaned)
77+
for cand in _candidate_targets(base):
78+
if cand.exists():
79+
return cand
80+
return None
81+
82+
83+
def _validate_links(text: str, path: Path, root: Path) -> list[str]:
84+
issues = []
85+
for regex, kind in ((LINK_RE, "link"), (IMAGE_RE, "image")):
86+
for m in regex.finditer(text):
87+
raw = _clean_target(m.group(1))
88+
if not raw or raw.startswith("#") or _is_external(raw):
89+
continue
90+
if _resolve_target(raw, path, root) is None:
91+
line = text.count("\n", 0, m.start()) + 1
92+
issues.append(f"{path}:{line} broken {kind} target: {raw}")
93+
return issues
94+
95+
96+
def _mask_fenced_code_blocks(text: str) -> str:
97+
lines = text.splitlines(keepends=True)
98+
masked: list[str] = []
99+
in_fence = False
100+
for line in lines:
101+
if FENCE_RE.match(line.strip()):
102+
in_fence = not in_fence
103+
masked.append("\n")
104+
continue
105+
masked.append("\n" if in_fence else line)
106+
return "".join(masked)
107+
108+
109+
def _mask_math_spans(text: str) -> str:
110+
def _blank(match: re.Match[str]) -> str:
111+
return "".join("\n" if ch == "\n" else " " for ch in match.group(0))
112+
113+
out = BLOCK_MATH_RE.sub(_blank, text)
114+
out = INLINE_MATH_RE.sub(_blank, out)
115+
return out
116+
117+
118+
def main() -> int:
119+
parser = argparse.ArgumentParser(description="Validate changed markdown files")
120+
parser.add_argument("paths", nargs="*", help="Changed file paths")
121+
args = parser.parse_args()
122+
123+
root = Path('.').resolve()
124+
md_files = _iter_markdown_paths(root, args.paths)
125+
if not md_files:
126+
print("Markdown CI validation skipped (no markdown files changed).")
127+
return 0
128+
129+
issues: list[str] = []
130+
failed_files: set[Path] = set()
131+
for path in md_files:
132+
try:
133+
text = path.read_text(encoding="utf-8")
134+
except Exception as exc:
135+
issues.append(f"{path}:1 unreadable markdown: {exc}")
136+
failed_files.add(path)
137+
continue
138+
path_issues = []
139+
path_issues.extend(_validate_filename(path, root))
140+
path_issues.extend(_validate_front_matter(text.splitlines(), path))
141+
scan_text = _mask_math_spans(_mask_fenced_code_blocks(text))
142+
path_issues.extend(_validate_links(scan_text, path, root))
143+
if path_issues:
144+
issues.extend(path_issues)
145+
failed_files.add(path)
146+
147+
passed = len(md_files) - len(failed_files)
148+
failed = len(failed_files)
149+
if issues:
150+
print("Markdown validation failed:")
151+
for issue in issues:
152+
print(f"- {issue}")
153+
print(f"\nSummary: {passed} file(s) passed, {failed} file(s) failed.")
154+
return 1
155+
156+
print(f"Markdown validation passed ({len(md_files)} file(s)).")
157+
print(f"Summary: {passed} file(s) passed, {failed} file(s) failed.")
158+
return 0
159+
160+
161+
if __name__ == "__main__":
162+
sys.exit(main())
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env python3
2+
"""CI YAML validation for changed yaml files."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
YAML_EXTS = {".yml", ".yaml"}
12+
13+
14+
def _iter_yaml_paths(root: Path, inputs: list[str]) -> list[Path]:
15+
out: list[Path] = []
16+
for raw in inputs:
17+
p = (root / raw).resolve() if not Path(raw).is_absolute() else Path(raw)
18+
if p.is_file() and p.suffix.lower() in YAML_EXTS:
19+
out.append(p)
20+
return sorted(set(out))
21+
22+
23+
def _validate_yaml(path: Path) -> str | None:
24+
cmd = [
25+
"ruby",
26+
"-e",
27+
"require 'yaml'; YAML.safe_load(File.read(ARGV[0]), permitted_classes: [], aliases: true)",
28+
str(path),
29+
]
30+
proc = subprocess.run(cmd, capture_output=True, text=True)
31+
if proc.returncode != 0:
32+
err = (proc.stderr or proc.stdout).strip().splitlines()[-1]
33+
return f"{path}: {err}"
34+
return None
35+
36+
37+
def main() -> int:
38+
parser = argparse.ArgumentParser(description="Validate changed yaml files")
39+
parser.add_argument("paths", nargs="*", help="Changed file paths")
40+
args = parser.parse_args()
41+
42+
root = Path('.').resolve()
43+
yaml_files = _iter_yaml_paths(root, args.paths)
44+
if not yaml_files:
45+
print("YAML CI validation skipped (no yaml files changed).")
46+
return 0
47+
48+
issues: list[str] = []
49+
for path in yaml_files:
50+
err = _validate_yaml(path)
51+
if err:
52+
issues.append(err)
53+
54+
if issues:
55+
print("YAML validation failed:")
56+
for issue in issues:
57+
print(f"- {issue}")
58+
return 1
59+
60+
print(f"YAML validation passed ({len(yaml_files)} file(s)).")
61+
return 0
62+
63+
64+
if __name__ == "__main__":
65+
sys.exit(main())

0 commit comments

Comments
 (0)