Skip to content

Commit 2ad0df0

Browse files
author
Akkari
committed
fix: address 5 HIGH findings from quorum validation of validate-docs.py
Findings from quorum run (standard depth, $0.095): - H1: Broad except Exception → specific UnicodeDecodeError + OSError - H2: Vague dict return types → typed dict[str, dict[str, Any]] - H3: YAML input unvalidated → structural validation in load_manifest() - H4: File content unvalidated → MAX_FILE_SIZE_BYTES guard + encoding handling - H5: ReDoS risk → file size limit as defense-in-depth Also addressed 4 MEDIUM (god function → extracted helpers) and 1 LOW (context managers).
1 parent d9acc02 commit 2ad0df0

1 file changed

Lines changed: 100 additions & 47 deletions

File tree

tools/validate-docs.py

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,39 +16,58 @@
1616
import re
1717
import sys
1818
from pathlib import Path
19+
from typing import Any
1920

2021
try:
2122
import yaml
2223
except ImportError:
2324
print("ERROR: PyYAML required. Install with: pip install pyyaml", file=sys.stderr)
2425
sys.exit(2)
2526

27+
# Maximum file size to process (1 MB) — guards against oversized files in regex operations
28+
MAX_FILE_SIZE_BYTES = 1_048_576
2629

27-
def load_manifest(repo_root: Path) -> dict:
28-
"""Load and parse critic-status.yaml."""
30+
31+
def load_manifest(repo_root: Path) -> dict[str, Any]:
32+
"""Load and parse critic-status.yaml with structural validation."""
2933
manifest_path = repo_root / "critic-status.yaml"
3034
if not manifest_path.exists():
3135
print(f"ERROR: {manifest_path} not found", file=sys.stderr)
3236
sys.exit(2)
33-
with open(manifest_path) as f:
34-
return yaml.safe_load(f)
3537

38+
try:
39+
with open(manifest_path, encoding="utf-8") as f:
40+
manifest = yaml.safe_load(f)
41+
except yaml.YAMLError as e:
42+
print(f"ERROR: Failed to parse {manifest_path}: {e}", file=sys.stderr)
43+
sys.exit(2)
3644

37-
def get_shipped_critics(manifest: dict) -> dict[str, dict]:
38-
"""Return dict of critic_name -> info for all shipped critics."""
39-
return {
40-
name: info
41-
for name, info in manifest.get("critics", {}).items()
42-
if info.get("status") == "shipped"
43-
}
45+
if not isinstance(manifest, dict):
46+
print(f"ERROR: {manifest_path} must be a YAML mapping, got {type(manifest).__name__}", file=sys.stderr)
47+
sys.exit(2)
48+
49+
if "critics" not in manifest or not isinstance(manifest["critics"], dict):
50+
print(f"ERROR: {manifest_path} must contain a 'critics' mapping", file=sys.stderr)
51+
sys.exit(2)
52+
53+
# Validate each critic entry has at least a 'status' field
54+
for name, info in manifest["critics"].items():
55+
if not isinstance(info, dict) or "status" not in info:
56+
print(
57+
f"ERROR: Critic '{name}' in {manifest_path} must be a mapping with a 'status' field",
58+
file=sys.stderr,
59+
)
60+
sys.exit(2)
4461

62+
return manifest
4563

46-
def get_planned_critics(manifest: dict) -> dict[str, dict]:
47-
"""Return dict of critic_name -> info for all planned critics."""
64+
65+
def get_critics_by_status(manifest: dict[str, Any], status: str) -> dict[str, dict[str, Any]]:
66+
"""Return dict of critic_name -> info for all critics matching the given status."""
4867
return {
4968
name: info
5069
for name, info in manifest.get("critics", {}).items()
51-
if info.get("status") == "planned"
70+
if info.get("status") == status
5271
}
5372

5473

@@ -70,20 +89,44 @@ def find_md_files(repo_root: Path) -> list[Path]:
7089
return sorted(results)
7190

7291

92+
def read_file_lines(file_path: Path) -> list[str] | None:
93+
"""Read a markdown file and return its lines, or None on failure.
94+
95+
Skips files exceeding MAX_FILE_SIZE_BYTES to guard against oversized inputs.
96+
"""
97+
try:
98+
file_size = file_path.stat().st_size
99+
if file_size > MAX_FILE_SIZE_BYTES:
100+
print(
101+
f"WARNING: Skipping {file_path} ({file_size} bytes exceeds "
102+
f"{MAX_FILE_SIZE_BYTES} byte limit)",
103+
file=sys.stderr,
104+
)
105+
return None
106+
return file_path.read_text(encoding="utf-8").splitlines()
107+
except UnicodeDecodeError as e:
108+
print(f"WARNING: Could not decode {file_path} as UTF-8: {e}", file=sys.stderr)
109+
return None
110+
except OSError as e:
111+
print(f"WARNING: Could not read {file_path}: {e}", file=sys.stderr)
112+
return None
113+
114+
73115
def check_hardcoded_counts(lines: list[str], shipped_count: int, file_path: Path) -> list[str]:
74116
"""Flag lines with hardcoded critic counts that don't match shipped count."""
75-
findings = []
117+
findings: list[str] = []
76118
# Match patterns like "4 critics", "ships with 4", "currently 5 critics"
77119
count_pattern = re.compile(r'\b(\d+)\s+critics?\b', re.IGNORECASE)
78120
# Also match "ships with N", "ships N critics"
79121
ships_pattern = re.compile(r'ship[s]?\s+(?:with\s+)?(\d+)', re.IGNORECASE)
80122

81123
for i, line in enumerate(lines, 1):
124+
line_lower = line.lower()
82125
# Skip lines that are clearly talking about the target architecture (9 critics)
83-
if "9" in line and ("target" in line.lower() or "architecture" in line.lower() or "full" in line.lower()):
126+
if "9" in line and ("target" in line_lower or "architecture" in line_lower or "full" in line_lower):
84127
continue
85128
# Skip lines about thread pool / parallel limits (e.g., "max 4 critics in parallel")
86-
if "parallel" in line.lower() or "threadpool" in line.lower() or "max" in line.lower():
129+
if "parallel" in line_lower or "threadpool" in line_lower or "max" in line_lower:
87130
continue
88131

89132
for pattern in [count_pattern, ships_pattern]:
@@ -92,8 +135,6 @@ def check_hardcoded_counts(lines: list[str], shipped_count: int, file_path: Path
92135
# Only flag if it looks like a shipped count that's wrong
93136
# Skip 9 (total architecture), 3 (planned), and the correct count
94137
if n != shipped_count and n in range(2, 9) and n != 9:
95-
# Check if this line is about shipped/current critics (not planned/remaining)
96-
line_lower = line.lower()
97138
# Skip lines about specific depth profiles with intentionally fewer critics
98139
# (e.g., "quick" depth legitimately runs 2 critics)
99140
if "quick" in line_lower and n == 2:
@@ -110,14 +151,10 @@ def check_hardcoded_counts(lines: list[str], shipped_count: int, file_path: Path
110151

111152

112153
def check_stale_status_markers(
113-
lines: list[str], shipped_critics: dict, file_path: Path
154+
lines: list[str], shipped_critics: dict[str, dict[str, Any]], file_path: Path
114155
) -> list[str]:
115156
"""Flag status markers (🔜, Planned, coming) for critics that are actually shipped."""
116-
findings = []
117-
shipped_names = {
118-
name.replace("_", "[ _-]?"): name
119-
for name in shipped_critics
120-
}
157+
findings: list[str] = []
121158
# Also add display names
122159
display_map = {
123160
"cross.consistency": "cross_consistency",
@@ -159,20 +196,15 @@ def check_stale_status_markers(
159196

160197

161198
def check_roadmap_shipped(
162-
lines: list[str], shipped_critics: dict, file_path: Path
199+
lines: list[str], shipped_critics: dict[str, dict[str, Any]], file_path: Path
163200
) -> list[str]:
164201
"""Flag 'roadmap' or 'what's coming' sections that list shipped critics."""
165-
findings = []
202+
findings: list[str] = []
166203
in_roadmap = False
167204
# Only check for critic names that are distinctive enough to not be common words.
168205
# "security" and "correctness" are too generic — they appear in rubric domain
169206
# descriptions, improvement roadmaps, etc. without referring to the critic.
170-
shipped_lower = set()
171-
shipped_lower.add("tester")
172-
shipped_lower.add("cross-consistency")
173-
shipped_lower.add("cross consistency")
174-
shipped_lower.add("cross artifact")
175-
shipped_lower.add("code hygiene")
207+
shipped_lower = {"tester", "cross-consistency", "cross consistency", "cross artifact", "code hygiene"}
176208

177209
for i, line in enumerate(lines, 1):
178210
line_lower = line.lower()
@@ -182,7 +214,7 @@ def check_roadmap_shipped(
182214
in_roadmap = True
183215
continue
184216
# Exit roadmap section on next heading
185-
if in_roadmap and line.startswith("#") and "coming" not in line.lower() and "roadmap" not in line.lower():
217+
if in_roadmap and line.startswith("#") and "coming" not in line_lower and "roadmap" not in line_lower:
186218
in_roadmap = False
187219

188220
if in_roadmap:
@@ -197,14 +229,37 @@ def check_roadmap_shipped(
197229
return findings
198230

199231

200-
def main():
232+
def validate_docs(repo_root: Path) -> list[str]:
233+
"""Run all validation checks and return list of findings."""
234+
manifest = load_manifest(repo_root)
235+
shipped = get_critics_by_status(manifest, "shipped")
236+
shipped_count = len(shipped)
237+
238+
md_files = find_md_files(repo_root)
239+
240+
all_findings: list[str] = []
241+
for md_file in md_files:
242+
lines = read_file_lines(md_file)
243+
if lines is None:
244+
continue
245+
246+
rel_path = md_file.relative_to(repo_root)
247+
all_findings.extend(check_hardcoded_counts(lines, shipped_count, rel_path))
248+
all_findings.extend(check_stale_status_markers(lines, shipped, rel_path))
249+
all_findings.extend(check_roadmap_shipped(lines, shipped, rel_path))
250+
251+
return all_findings
252+
253+
254+
def main() -> int:
255+
"""Entry point. Returns exit code: 0=clean, 1=findings, 2=error."""
201256
# Find repo root (script is in tools/)
202257
script_dir = Path(__file__).resolve().parent
203258
repo_root = script_dir.parent
204259

205260
manifest = load_manifest(repo_root)
206-
shipped = get_shipped_critics(manifest)
207-
planned = get_planned_critics(manifest)
261+
shipped = get_critics_by_status(manifest, "shipped")
262+
planned = get_critics_by_status(manifest, "planned")
208263
shipped_count = len(shipped)
209264
manifest_version = manifest.get("version", "unknown")
210265

@@ -216,12 +271,10 @@ def main():
216271
md_files = find_md_files(repo_root)
217272
print(f"Scanning {len(md_files)} markdown files...\n")
218273

219-
all_findings = []
274+
all_findings: list[str] = []
220275
for md_file in md_files:
221-
try:
222-
lines = md_file.read_text(encoding="utf-8").splitlines()
223-
except Exception as e:
224-
print(f"WARNING: Could not read {md_file}: {e}", file=sys.stderr)
276+
lines = read_file_lines(md_file)
277+
if lines is None:
225278
continue
226279

227280
rel_path = md_file.relative_to(repo_root)
@@ -231,14 +284,14 @@ def main():
231284

232285
if all_findings:
233286
print(f"FINDINGS ({len(all_findings)}):\n")
234-
for f in all_findings:
235-
print(f)
287+
for finding in all_findings:
288+
print(finding)
236289
print(f"\n{len(all_findings)} documentation discrepancies found.")
237290
print("Update the listed files to match critic-status.yaml, then re-run.")
238291
return 1
239-
else:
240-
print("✅ All documentation matches critic-status.yaml. No stale references found.")
241-
return 0
292+
293+
print("✅ All documentation matches critic-status.yaml. No stale references found.")
294+
return 0
242295

243296

244297
if __name__ == "__main__":

0 commit comments

Comments
 (0)