1313
1414Usage:
1515 python3 quorum-prescreen.py <target-file>
16+ python3 quorum-prescreen.py --scan-links [DIR]
1617"""
1718
1819from __future__ import annotations
@@ -164,6 +165,23 @@ def _mask(m: re.Match) -> str:
164165 return redacted [:max_len ]
165166
166167
168+ # ── Repo-root discovery ──────────────────────────────────────────────────────
169+
170+ def _find_repo_root (start : Path ) -> Path :
171+ """Walk up from *start* to find the nearest directory containing .git.
172+
173+ Returns *start* (resolved) if no .git is found.
174+ """
175+ current = start .resolve ()
176+ while True :
177+ if (current / ".git" ).exists ():
178+ return current
179+ parent = current .parent
180+ if parent == current :
181+ return start .resolve ()
182+ current = parent
183+
184+
167185# ── Check dict factory helpers ────────────────────────────────────────────────
168186
169187def _make_pass (
@@ -412,6 +430,7 @@ def ps006_python_syntax(artifact_path: Path, artifact_text: str) -> dict:
412430def ps007_broken_md_links (artifact_path : Path , artifact_text : str ) -> dict :
413431 """PS-007: Detect broken relative links in Markdown files."""
414432 base_dir = artifact_path .parent
433+ repo_root = _find_repo_root (base_dir )
415434 broken : list [tuple [int , str , str ]] = [] # (line_no, text, target)
416435
417436 for i , line in enumerate (artifact_text .splitlines (), start = 1 ):
@@ -425,6 +444,11 @@ def ps007_broken_md_links(artifact_path: Path, artifact_text: str) -> dict:
425444 if not path_part :
426445 continue # anchor-only link
427446 resolved = (base_dir / path_part ).resolve ()
447+ # Skip links that escape the repo root
448+ try :
449+ resolved .relative_to (repo_root )
450+ except ValueError :
451+ continue # path traversal — escapes repo boundary
428452 if not resolved .exists ():
429453 broken .append ((i , match .group (1 ), target ))
430454
@@ -448,6 +472,61 @@ def ps007_broken_md_links(artifact_path: Path, artifact_text: str) -> dict:
448472 )
449473
450474
475+ # ── Batch link scanning ──────────────────────────────────────────────────────
476+
477+ _SKIP_DIRS = {
478+ ".git" , "node_modules" , "venv" , "__pycache__" , "dist" , ".hypothesis" ,
479+ "quorum-runs" , # historical run output — links are point-in-time snapshots
480+ "golden-test-set" , # test artifacts with intentionally broken links
481+ "external-reviews" , "reviews" , # external review snapshots
482+ }
483+
484+
485+ def scan_all_links (repo_root : Path | None = None ) -> dict :
486+ """Walk all .md files under *repo_root* and run PS-007 on each.
487+
488+ Returns a consolidated JSON-serialisable dict with per-file results
489+ and a summary. If *repo_root* is None, auto-detect from cwd.
490+ """
491+ if repo_root is None :
492+ repo_root = _find_repo_root (Path .cwd ())
493+
494+ if not repo_root .is_dir ():
495+ return {"error" : f"Not a directory: { repo_root } " , "files" : [], "total_broken" : 0 }
496+
497+ md_files : list [Path ] = []
498+ for md_file in sorted (repo_root .rglob ("*.md" )):
499+ parts = md_file .relative_to (repo_root ).parts
500+ if any (p .startswith ("." ) or p in _SKIP_DIRS for p in parts ):
501+ continue
502+ md_files .append (md_file )
503+
504+ results : list [dict ] = []
505+ total_broken = 0
506+
507+ for md_file in md_files :
508+ try :
509+ text = md_file .read_text (encoding = "utf-8" , errors = "replace" )
510+ except OSError :
511+ continue
512+ check = ps007_broken_md_links (md_file , text )
513+ if check ["status" ] == "FAIL" :
514+ rel = str (md_file .relative_to (repo_root ))
515+ # Extract broken count from description
516+ count_match = re .match (r"(\d+) broken" , check .get ("details" , "" ))
517+ file_broken = int (count_match .group (1 )) if count_match else 0
518+ total_broken += file_broken
519+ results .append ({"file" : rel , ** check })
520+
521+ return {
522+ "repo_root" : str (repo_root ),
523+ "files_scanned" : len (md_files ),
524+ "files_with_broken_links" : len (results ),
525+ "total_broken" : total_broken ,
526+ "results" : results ,
527+ }
528+
529+
451530# ── PS-008: TODO markers ─────────────────────────────────────────────────────
452531
453532def ps008_todo_markers (artifact_path : Path , artifact_text : str ) -> dict :
@@ -686,8 +765,24 @@ def run_prescreen(target_path: Path) -> dict:
686765
687766
688767def main () -> None :
768+ # ── --scan-links mode ────────────────────────────────────────────────
769+ if len (sys .argv ) >= 2 and sys .argv [1 ] == "--scan-links" :
770+ scan_dir = Path (sys .argv [2 ]).resolve () if len (sys .argv ) >= 3 else None
771+ result = scan_all_links (scan_dir )
772+ if "error" in result :
773+ print (f"Error: { result ['error' ]} " , file = sys .stderr )
774+ sys .exit (1 )
775+ json .dump (result , sys .stdout , indent = 2 )
776+ print ()
777+ sys .exit (0 if result ["total_broken" ] == 0 else 1 )
778+
779+ # ── Single-file mode (existing behaviour) ────────────────────────────
689780 if len (sys .argv ) != 2 :
690- print ("Usage: quorum-prescreen.py <target-file>" , file = sys .stderr )
781+ print (
782+ "Usage: quorum-prescreen.py <target-file>\n "
783+ " quorum-prescreen.py --scan-links [DIR]" ,
784+ file = sys .stderr ,
785+ )
691786 sys .exit (2 )
692787
693788 target = Path (sys .argv [1 ]).resolve ()
0 commit comments