1616import re
1717import sys
1818from pathlib import Path
19+ from typing import Any
1920
2021try :
2122 import yaml
2223except 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+
73115def 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
112153def 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
161198def 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
244297if __name__ == "__main__" :
0 commit comments