Spaces:
Running
Running
File size: 6,734 Bytes
94004a2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | #!/usr/bin/env python3
"""validate-install-docs.py — Phase 1 INST-06 docs-drift CI gate.
Extract every fenced code block tagged with an HTML comment marker
`<!-- validate -->` from `docs/install/*.md` and assert each line of the
block appears (after normalisation) in `scripts/desktop-prod.sh`. The script
exits 1 on the first drift and prints the offending file + line on stderr so
CI logs lead the contributor straight to the fix.
Markers:
<!-- validate --> — gate the next fenced code block
<!-- validate: skip --> — opt-out: block exists for human readability only
Normalisation (per RESEARCH Pitfall #4):
- rstrip trailing whitespace
- normalise CRLF → LF
- strip `$ ` and `>>> ` REPL/prompt prefixes
- skip blank lines + lines that are only `#` comments
The validator is intentionally a one-way check: every validated docs line
must appear in the install script, but the script may contain extra setup
the docs don't surface (cleanup, log dirs, etc.). That asymmetry catches
"docs claim a command that the install path doesn't run" without forcing
docs to repeat every line of the install script.
Public entry point: `main(root: Path | None = None) -> int`
Returns 0 on success, 1 on drift. Importable from unit tests so we can
exercise the validator against tmp-path fixtures (per checker B-5 — the
validator itself is regression-tested).
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Iterable
# Match the `<!-- validate -->` (or `<!-- validate: skip -->`) marker on its
# own line, followed by an optional blank line, followed by a fenced block.
_MARKER_RE = re.compile(
r"<!--\s*validate(?:\s*:\s*(?P<modifier>skip))?\s*-->",
re.IGNORECASE,
)
_FENCE_OPEN_RE = re.compile(r"^```([A-Za-z0-9_+\-]*)\s*$")
_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
_PROMPT_PREFIXES = ("$ ", ">>> ")
def _normalise_line(line: str) -> str:
"""Strip prompt prefixes + trailing whitespace + CRs. Returns '' for
blank and comment-only lines (the caller treats '' as 'skip')."""
# CRLF → LF was done at file read time; rstrip handles trailing CR too.
s = line.rstrip("\r\n").rstrip()
if not s:
return ""
if s.lstrip().startswith("#"):
# Skip pure-comment lines — they're docs scaffolding, not commands.
return ""
for prefix in _PROMPT_PREFIXES:
if s.lstrip().startswith(prefix):
s = s.replace(prefix, "", 1)
break
return s.strip()
def _normalise_script(text: str) -> set[str]:
"""Return the set of normalised lines from the install script.
The script has shebangs, env exports, function defs, etc. — we
intentionally compare against the *entire* normalised contents (minus
blanks/comments) so docs may pull any line that survives the install
flow."""
out: set[str] = set()
for raw in text.splitlines():
norm = _normalise_line(raw)
if norm:
out.add(norm)
return out
def _extract_validated_blocks(md_text: str) -> list[tuple[int, str, bool]]:
"""Return a list of (start_line_no_1_indexed, body, skip_flag) tuples for
every `<!-- validate -->` block found in the markdown."""
lines = md_text.splitlines()
blocks: list[tuple[int, str, bool]] = []
i = 0
pending_marker: tuple[int, bool] | None = None
while i < len(lines):
line = lines[i]
m = _MARKER_RE.search(line)
if m:
pending_marker = (i + 1, (m.group("modifier") == "skip"))
i += 1
continue
if pending_marker is not None and _FENCE_OPEN_RE.match(line):
# Consume until matching close fence.
block_lines: list[str] = []
block_start = pending_marker[0]
skip = pending_marker[1]
pending_marker = None
i += 1
while i < len(lines) and not _FENCE_CLOSE_RE.match(lines[i]):
block_lines.append(lines[i])
i += 1
i += 1 # skip the closing fence
blocks.append((block_start, "\n".join(block_lines), skip))
continue
# Marker followed by something other than a fence — drop it.
if pending_marker is not None and line.strip() and not _FENCE_OPEN_RE.match(line):
pending_marker = None
i += 1
return blocks
def _iter_docs(root: Path) -> Iterable[Path]:
docs_dir = root / "docs" / "install"
if not docs_dir.exists():
return []
return sorted(docs_dir.glob("*.md"))
def main(root: Path | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--root",
type=Path,
default=None,
help="Repo root to scan (defaults to the repo containing this script).",
)
# When `main()` is called programmatically (unit tests), we still want
# argparse to work — pass an empty argv so it doesn't accidentally see
# pytest's command-line args.
if root is not None:
args = parser.parse_args([])
args.root = root
else:
args = parser.parse_args()
if args.root is None:
args.root = Path(__file__).resolve().parent.parent
script_path = args.root / "scripts" / "desktop-prod.sh"
if not script_path.exists():
print(
f"validate-install-docs: missing {script_path}; nothing to validate against",
file=sys.stderr,
)
return 1
canonical = _normalise_script(script_path.read_text(encoding="utf-8"))
errors: list[str] = []
validated = 0
for md_path in _iter_docs(args.root):
md_text = md_path.read_text(encoding="utf-8")
for start_line, body, skip in _extract_validated_blocks(md_text):
validated += 1
if skip:
continue
for offset, raw in enumerate(body.splitlines(), start=0):
norm = _normalise_line(raw)
if not norm:
continue
if norm not in canonical:
errors.append(
f"{md_path.relative_to(args.root)}:{start_line + 1 + offset}: "
f"docs line not present in scripts/desktop-prod.sh: {norm!r}"
)
if errors:
for e in errors:
print(e, file=sys.stderr)
print(
f"\nvalidate-install-docs: {len(errors)} drift(s) in {validated} validated block(s).",
file=sys.stderr,
)
return 1
print(f"OK — {validated} install docs block(s) validated against {script_path.name}")
return 0
if __name__ == "__main__":
sys.exit(main())
|