Skip to content

Commit 1249a35

Browse files
authored
Merge pull request #19 from SharedIntellect/enhance/ps007-repo-wide-links
PS-007: Repo-wide link resolution + staleness prevention
2 parents 088626e + d593ef3 commit 1249a35

5 files changed

Lines changed: 293 additions & 4 deletions

File tree

ports/copilot-cli/quorum-prescreen.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
1414
Usage:
1515
python3 quorum-prescreen.py <target-file>
16+
python3 quorum-prescreen.py --scan-links [DIR]
1617
"""
1718

1819
from __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

169187
def _make_pass(
@@ -412,6 +430,7 @@ def ps006_python_syntax(artifact_path: Path, artifact_text: str) -> dict:
412430
def 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

453532
def ps008_todo_markers(artifact_path: Path, artifact_text: str) -> dict:
@@ -686,8 +765,24 @@ def run_prescreen(target_path: Path) -> dict:
686765

687766

688767
def 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()
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Tests for the Copilot CLI quorum-prescreen.py — batch link scanning."""
2+
3+
from __future__ import annotations
4+
5+
import importlib
6+
import sys
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
# Add the copilot-cli directory to sys.path so we can import the module
12+
_CLI_DIR = str(Path(__file__).resolve().parent)
13+
if _CLI_DIR not in sys.path:
14+
sys.path.insert(0, _CLI_DIR)
15+
16+
# Import from the hyphenated filename
17+
quorum_prescreen = importlib.import_module("quorum-prescreen")
18+
19+
20+
class TestScanAllLinks:
21+
"""Tests for the scan_all_links batch scanning function."""
22+
23+
def test_clean_repo_returns_zero_broken(self, tmp_path):
24+
(tmp_path / ".git").mkdir()
25+
(tmp_path / "README.md").write_text("# Hello\n[other](other.md)\n")
26+
(tmp_path / "other.md").write_text("# Other\n")
27+
result = quorum_prescreen.scan_all_links(tmp_path)
28+
assert result["total_broken"] == 0
29+
assert result["files_with_broken_links"] == 0
30+
31+
def test_broken_link_detected_across_files(self, tmp_path):
32+
(tmp_path / ".git").mkdir()
33+
(tmp_path / "README.md").write_text("[missing](NONEXISTENT.md)\n")
34+
(tmp_path / "docs").mkdir()
35+
(tmp_path / "docs" / "guide.md").write_text("[also missing](nope.md)\n")
36+
result = quorum_prescreen.scan_all_links(tmp_path)
37+
assert result["files_with_broken_links"] == 2
38+
assert result["total_broken"] >= 2
39+
40+
def test_skips_hidden_dirs(self, tmp_path):
41+
(tmp_path / ".git").mkdir()
42+
git_md = tmp_path / ".git" / "description.md"
43+
git_md.write_text("[broken](nope.md)\n")
44+
(tmp_path / "README.md").write_text("# Clean\n")
45+
result = quorum_prescreen.scan_all_links(tmp_path)
46+
assert result["files_with_broken_links"] == 0
47+
48+
def test_upward_links_within_repo_not_false_positive(self, tmp_path):
49+
(tmp_path / ".git").mkdir()
50+
(tmp_path / "SPEC.md").write_text("# Spec\n")
51+
sub = tmp_path / "docs" / "guides"
52+
sub.mkdir(parents=True)
53+
(sub / "tutorial.md").write_text("[spec](../../SPEC.md)\n")
54+
result = quorum_prescreen.scan_all_links(tmp_path)
55+
assert result["total_broken"] == 0
56+
57+
def test_non_directory_returns_error(self, tmp_path):
58+
f = tmp_path / "file.txt"
59+
f.write_text("hi")
60+
result = quorum_prescreen.scan_all_links(f)
61+
assert "error" in result

reference-implementation/quorum/prescreen.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,26 @@
9898
_RE_TRAILING_SPACE = re.compile(r"[ \t]+$", re.MULTILINE)
9999

100100

101+
# ── Repo-root discovery ───────────────────────────────────────────────────────
102+
103+
def _find_repo_root(start: Path) -> Path:
104+
"""Walk up from *start* to find the nearest directory containing .git.
105+
106+
Returns *start* (resolved) if no .git is found — this preserves the
107+
old behaviour where files outside a repo are bounded to their own
108+
directory.
109+
"""
110+
current = start.resolve()
111+
while True:
112+
if (current / ".git").exists():
113+
return current
114+
parent = current.parent
115+
if parent == current:
116+
# Reached filesystem root without finding .git
117+
return start.resolve()
118+
current = parent
119+
120+
101121
# ── Helper ─────────────────────────────────────────────────────────────────────
102122

103123
def _scan_lines(
@@ -442,6 +462,7 @@ def _ps007_broken_md_links(
442462
) -> PreScreenCheck:
443463
"""PS-007: Detect broken relative links in Markdown files."""
444464
base_dir = artifact_path.parent
465+
repo_root = _find_repo_root(base_dir)
445466
broken: list[tuple[int, str, str]] = [] # (line_no, text, target)
446467

447468
for i, line in enumerate(artifact_text.splitlines(), start=1):
@@ -458,11 +479,11 @@ def _ps007_broken_md_links(
458479
if not path_part:
459480
continue # anchor-only link
460481
resolved = (base_dir / path_part).resolve()
461-
# V002 fix: skip links that escape artifact directory
482+
# V002 fix: skip links that escape the repo root
462483
try:
463-
resolved.relative_to(base_dir.resolve())
484+
resolved.relative_to(repo_root)
464485
except ValueError:
465-
continue # path traversal — don't follow
486+
continue # path traversal — escapes repo boundary
466487
if not resolved.exists():
467488
broken.append((i, match.group(1), target))
468489

reference-implementation/tests/test_prescreen.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,63 @@ def test_ignores_mailto_links(self, ps, tmp_path):
406406
assert check.result == "PASS"
407407

408408

409+
class TestPrescreenPS007RepoRootTraversal:
410+
"""Tests for the V002 fix: repo-root-bounded traversal guard."""
411+
412+
def test_upward_traversal_within_repo_allowed(self, ps, tmp_path):
413+
"""A link like ../../target.md that resolves inside the repo root should PASS."""
414+
(tmp_path / ".git").mkdir()
415+
(tmp_path / "target.md").write_text("# Target\n")
416+
subdir = tmp_path / "docs" / "guides"
417+
subdir.mkdir(parents=True)
418+
f = subdir / "tutorial.md"
419+
f.write_text("[spec](../../target.md)\n")
420+
result = ps.run(f, f.read_text())
421+
check = next(c for c in result.checks if c.id == "PS-007")
422+
assert check.result == "PASS", (
423+
"Up-traversal to repo root should not be flagged as broken"
424+
)
425+
426+
def test_upward_traversal_outside_repo_blocked(self, ps, tmp_path):
427+
"""A link that escapes past the .git boundary should be silently skipped."""
428+
repo = tmp_path / "myrepo"
429+
repo.mkdir()
430+
(repo / ".git").mkdir()
431+
(tmp_path / "outside.md").write_text("# Outside\n")
432+
f = repo / "README.md"
433+
f.write_text("[escape](../outside.md)\n")
434+
result = ps.run(f, f.read_text())
435+
check = next(c for c in result.checks if c.id == "PS-007")
436+
assert check.result == "PASS", (
437+
"Links escaping repo root should be silently skipped, not flagged broken"
438+
)
439+
440+
def test_no_git_dir_falls_back_to_parent(self, ps, tmp_path):
441+
"""Without .git, falls back to artifact parent dir (old behaviour)."""
442+
subdir = tmp_path / "sub"
443+
subdir.mkdir()
444+
f = subdir / "doc.md"
445+
f.write_text("[up](../nonexistent.md)\n")
446+
result = ps.run(f, f.read_text())
447+
check = next(c for c in result.checks if c.id == "PS-007")
448+
# Without .git, _find_repo_root returns the start dir (artifact parent).
449+
# ../nonexistent.md resolves outside that boundary, so it's silently skipped.
450+
assert check.result == "PASS"
451+
452+
def test_broken_link_within_repo_still_detected(self, ps, tmp_path):
453+
"""A broken link that stays within repo boundaries is still caught."""
454+
(tmp_path / ".git").mkdir()
455+
subdir = tmp_path / "docs"
456+
subdir.mkdir()
457+
f = subdir / "guide.md"
458+
f.write_text("[missing](../NONEXISTENT.md)\n")
459+
result = ps.run(f, f.read_text())
460+
check = next(c for c in result.checks if c.id == "PS-007")
461+
assert check.result == "FAIL", (
462+
"Broken links within repo should still be detected"
463+
)
464+
465+
409466
# ── PS-008: TODO Markers ─────────────────────────────────────────────────────
410467

411468

tools/validate-docs.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,59 @@ def check_depth_config_claims(
338338
return findings
339339

340340

341+
# ── Framework version header staleness ────────────────────────────────────────
342+
343+
# File basenames where version headers are expected and should be current
344+
_FRAMEWORK_DOC_SUFFIXES = ("_FRAMEWORK.md", "IMPLEMENTATION.md", "TESTER_CRITIC_BRIEF.md")
345+
346+
_RE_VERSION_HEADER = re.compile(
347+
r"""
348+
(?:
349+
\*{2}v(\d+\.\d+\.\d+)\s+(?:State|Status) # **v0.5.1 State
350+
| \#{1,4}\s+v(\d+\.\d+\.\d+)\s+(?:State|Status) # ## v0.5.1 Status
351+
| Status\s*\(v(\d+\.\d+\.\d+)\) # Status (v0.5.1)
352+
)
353+
""",
354+
re.VERBOSE | re.IGNORECASE,
355+
)
356+
357+
358+
def check_framework_version_strings(
359+
lines: list[str], manifest_version: str, file_path: Path
360+
) -> list[str]:
361+
"""Flag version strings in framework docs that don't match the manifest.
362+
363+
Only applies to files whose name ends with a known framework suffix.
364+
Skips version strings inside fenced code blocks.
365+
"""
366+
name = str(file_path)
367+
if not any(name.endswith(s) for s in _FRAMEWORK_DOC_SUFFIXES):
368+
return []
369+
370+
findings: list[str] = []
371+
in_code_block = False
372+
373+
for i, line in enumerate(lines, 1):
374+
stripped = line.strip()
375+
if stripped.startswith("```"):
376+
in_code_block = not in_code_block
377+
continue
378+
if in_code_block:
379+
continue
380+
381+
m = _RE_VERSION_HEADER.search(line)
382+
if m:
383+
found_version = next(g for g in m.groups() if g is not None)
384+
if found_version != manifest_version:
385+
findings.append(
386+
f" {file_path}:{i}: Stale version header "
387+
f"'v{found_version}' (manifest={manifest_version}): "
388+
f"{line.strip()[:MAX_LINE_DISPLAY_CHARS]}"
389+
)
390+
391+
return findings
392+
393+
341394
def validate_docs(repo_root: Path) -> list[str]:
342395
"""Run all validation checks and return list of findings."""
343396
manifest = load_manifest(repo_root)
@@ -365,6 +418,7 @@ def validate_docs(repo_root: Path) -> list[str]:
365418
all_findings.extend(check_stale_status_markers(lines, shipped, rel_path))
366419
all_findings.extend(check_roadmap_shipped(lines, shipped, rel_path))
367420
all_findings.extend(check_depth_config_claims(lines, manifest, rel_path))
421+
all_findings.extend(check_framework_version_strings(lines, manifest_version, rel_path))
368422

369423
return all_findings
370424

@@ -417,6 +471,7 @@ def main() -> int:
417471
all_findings.extend(check_stale_status_markers(lines, shipped, rel_path))
418472
all_findings.extend(check_roadmap_shipped(lines, shipped, rel_path))
419473
all_findings.extend(check_depth_config_claims(lines, manifest, rel_path))
474+
all_findings.extend(check_framework_version_strings(lines, manifest_version, rel_path))
420475

421476
if all_findings:
422477
print(f"FINDINGS ({len(all_findings)}):\n")

0 commit comments

Comments
 (0)