Files
2026-07-13 12:20:01 +08:00

347 lines
13 KiB
Python

#!/usr/bin/env python3
"""Validate cited claims in a solution doc against the git tree.
Usage:
python3 validate-doc-claims.py <doc-path>
Exit codes:
0 — nothing flagged
1 — one or more flags need adjudication (report on stdout)
2 — usage error (bad arguments, missing file)
Scope: mechanical grounding checks on a written doc's *body*. Complements
validate-frontmatter.py (parser-safety) — this script checks the body's
citations against the repository:
1. Cited repo-relative paths (backticked, containing at least one '/')
exist in the working tree; tokens containing '../' resolve from the
doc's directory (those escaping the repo are skipped). Misses tracked
at HEAD or the upstream default branch still count as real paths and
are classified (deleted/uncommitted vs stale checkout). Tokens
missing everywhere are flagged only when path-shaped; slash-delimited
identifiers (branch names, git refs, provider/model IDs) are skipped.
2. Cited commit SHAs (7-40 hex chars with at least one digit and one
a-f letter) resolve to commits, classified by reachability from
HEAD and the upstream default branch.
3. Relative markdown link targets resolve from the doc's location.
4. Dangling drafting scaffold: "Learning(s) N" numbering and
unresolved {{...}} placeholder tokens.
Flags are adjudication input, NOT hard failures — a doc may legitimately
cite a path deleted by the very fix it documents. The calling agent
decides per flag: fix, annotate as historical, or confirm intentional.
Only the summary exit code distinguishes "clean" from "needs a look".
The script never touches the network (no fetch); classification uses
whatever refs exist locally. Run a best-effort `git fetch --quiet` first
when freshness matters. Pure stdlib (no third-party deps).
"""
import os
import re
import subprocess
import sys
# Tokens containing these are placeholders/examples, not real citations.
PLACEHOLDER_CHARS = set("<>{}*$")
PLACEHOLDER_SUBSTRINGS = ("path/to", "...", "…")
SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b")
BACKTICK_RE = re.compile(r"`([^`\n]+)`")
MD_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
SCAFFOLD_RES = (
re.compile(r"\bLearnings?\s+#?\d"),
re.compile(r"\{\{[^}\n]*\}\}"),
)
def usage_fail(msg: str) -> "NoReturn":
sys.stderr.write(f"validate-doc-claims: {msg}\n")
sys.exit(2)
def git(args: list[str], cwd: str) -> tuple[int, str]:
try:
result = subprocess.run(
["git", *args],
cwd=cwd,
capture_output=True,
text=True,
timeout=30,
)
return result.returncode, result.stdout.strip()
except (OSError, subprocess.TimeoutExpired):
return 1, ""
def split_body(text: str) -> tuple[str, int]:
"""Return (body, 1-indexed line number the body starts on).
Skips YAML frontmatter when present so frontmatter fields are not
scanned as body citations.
"""
lines = text.split("\n")
if lines and lines[0].rstrip() == "---":
for i in range(1, len(lines)):
if lines[i].rstrip() == "---":
return "\n".join(lines[i + 1 :]), i + 2
return text, 1
def is_path_candidate(token: str) -> bool:
if any(ch.isspace() for ch in token):
return False
if "/" not in token:
return False
if "://" in token or token.startswith(("http", "#", "/", "~")):
return False
if token.startswith(("origin/", "upstream/", "refs/")):
return False # git refs, not repo paths
if PLACEHOLDER_CHARS & set(token):
return False
if any(sub in token for sub in PLACEHOLDER_SUBSTRINGS):
return False
return True
def is_path_shaped(token: str, base: str) -> bool:
"""Distinguish a path citation from a slash-delimited identifier
(branch name, provider/model ID) among tokens found nowhere in git."""
segments = token.split("/")
if re.search(r"\.[A-Za-z0-9]{1,8}$", segments[-1]):
return True
if token.endswith("/"):
return True
return os.path.isdir(os.path.join(base, segments[0]))
def normalize_path(token: str) -> str:
token = token.strip().rstrip(".,;")
token = re.sub(r":\d+(-\d+)?$", "", token) # strip `:line` / `:a-b` refs
if token.startswith("./"):
token = token[2:]
return token
def main(argv: list[str]) -> int:
if len(argv) != 2:
usage_fail(f"usage: {os.path.basename(argv[0])} <doc-path>")
doc_path = argv[1]
if not os.path.isfile(doc_path):
usage_fail(f"file not found: {doc_path}")
with open(doc_path) as f:
text = f.read()
doc_dir = os.path.dirname(os.path.abspath(doc_path))
body, body_start = split_body(text)
body_lines = body.split("\n")
def loc_suffix(needle: str) -> str:
for i, line in enumerate(body_lines):
if needle in line:
return f" (line {body_start + i})"
return ""
infos: list[str] = []
flags: list[str] = []
# --- Repo context -----------------------------------------------------
code, repo_root = git(["rev-parse", "--show-toplevel"], doc_dir)
in_git = code == 0 and bool(repo_root)
upstream: str | None = None
if in_git:
code, ref = git(["rev-parse", "--abbrev-ref", "origin/HEAD"], repo_root)
if code == 0 and ref:
upstream = ref
else:
for candidate in ("origin/main", "origin/master"):
code, _ = git(
["rev-parse", "--verify", "--quiet", candidate], repo_root
)
if code == 0:
upstream = candidate
break
if upstream:
code, behind = git(
["rev-list", "--count", f"HEAD..{upstream}"], repo_root
)
if code == 0 and behind.isdigit() and int(behind) > 0:
infos.append(
f"INFO: worktree is {behind} commits behind {upstream} — "
"verify merge-state claims against remote truth (gh pr view), "
"not this checkout"
)
else:
infos.append(
"INFO: no upstream default branch found — "
"path/SHA classification limited to HEAD"
)
else:
infos.append(
"INFO: not a git repository — path and SHA classification skipped "
"(scaffold and link checks still apply)"
)
def upstream_has_path(path: str) -> bool:
if not (in_git and upstream):
return False
code, _ = git(["cat-file", "-e", f"{upstream}:{path}"], repo_root)
return code == 0
def head_has_path(path: str) -> bool:
if not in_git:
return False
code, _ = git(["cat-file", "-e", f"HEAD:{path}"], repo_root)
return code == 0
# --- 1. Cited repo paths ----------------------------------------------
checked_paths = 0
seen_paths: set[str] = set()
base = repo_root if in_git else os.getcwd()
for raw in BACKTICK_RE.findall(body):
token = normalize_path(raw)
if not is_path_candidate(token):
continue
check = token
if token.startswith("../") or "/../" in token:
# A `../` citation is doc-relative (matching how markdown links
# resolve), so map it to a repo-root path before checking.
if not in_git:
continue
resolved = os.path.realpath(os.path.join(doc_dir, token))
check = os.path.relpath(resolved, os.path.realpath(base))
if check.startswith(".."):
continue # escapes the repo — not checkable as a repo path
if check in seen_paths:
continue
seen_paths.add(check)
if os.path.exists(os.path.join(base, check)):
checked_paths += 1
continue
tracked_head = head_has_path(check)
tracked_upstream = upstream_has_path(check)
if not (tracked_head or tracked_upstream) and not is_path_shaped(
check, base
):
continue # branch name / provider ID, not a path citation
checked_paths += 1
loc = loc_suffix(raw)
if tracked_head:
flags.append(
f"FLAG path `{token}`{loc} — tracked at HEAD but missing from "
"the working tree: deleted or uncommitted removal? Annotate as "
"historical (e.g. removed by this fix) or restore it."
)
elif tracked_upstream:
flags.append(
f"FLAG path `{token}`{loc} — not in working tree but exists at "
f"{upstream}: stale checkout? Annotate or verify against upstream."
)
else:
where = (
f"working tree or {upstream}" if upstream else "working tree"
)
flags.append(
f"FLAG path `{token}`{loc} — not found in {where}. Fix the "
"citation, or annotate it as historical (e.g. removed by this fix)."
)
# --- 2. Cited commit SHAs ----------------------------------------------
checked_shas = 0
seen_shas: set[str] = set()
if in_git:
for m in SHA_RE.finditer(body):
sha = m.group(0)
if sha in seen_shas:
continue
if not (any(c.isdigit() for c in sha) and any(c in "abcdef" for c in sha)):
continue # dates and decimal ids are not SHAs
seen_shas.add(sha)
checked_shas += 1
loc = loc_suffix(sha)
code, _ = git(["cat-file", "-e", f"{sha}^{{commit}}"], repo_root)
if code != 0:
flags.append(
f"FLAG sha {sha}{loc} — does not resolve to a commit in this "
"repository. Replace with the PR number, or drop it."
)
continue
in_head = (
git(["merge-base", "--is-ancestor", sha, "HEAD"], repo_root)[0] == 0
)
in_up = (
upstream is not None
and git(["merge-base", "--is-ancestor", sha, upstream], repo_root)[0]
== 0
)
if in_head and (in_up or upstream is None):
continue
if in_head and not in_up:
flags.append(
f"FLAG sha {sha}{loc} — reachable from HEAD but not {upstream}: "
"local-only commit whose SHA may be rewritten on merge "
"(rebase/squash). Prefer citing the PR number."
)
elif in_up:
flags.append(
f"FLAG sha {sha}{loc} — not reachable from HEAD but reachable "
f"from {upstream}: this checkout predates the merge. Add a "
"temporal qualifier or verify the claim via gh."
)
else:
flags.append(
f"FLAG sha {sha}{loc} — exists but unreachable from HEAD"
+ (f" or {upstream}" if upstream else "")
+ ": likely a rebased-away commit. Prefer citing the PR number."
)
# --- 3. Relative markdown links -----------------------------------------
checked_links = 0
seen_links: set[str] = set()
for target in MD_LINK_RE.findall(body):
if re.match(r"^[a-z][a-z0-9+.-]*:", target, re.IGNORECASE):
continue # URL scheme
if target.startswith("#"):
continue # intra-doc anchor
bare = target.split("#", 1)[0]
if not bare or bare in seen_links:
continue
seen_links.add(bare)
checked_links += 1
if not os.path.exists(os.path.normpath(os.path.join(doc_dir, bare))):
loc = loc_suffix(target)
flags.append(
f"FLAG link ({target}){loc} — relative target does not resolve "
"from the doc's location. Fix the path."
)
# --- 4. Dangling drafting scaffold ---------------------------------------
for i, line_text in enumerate(body_lines):
for pattern in SCAFFOLD_RES:
m = pattern.search(line_text)
if m:
flags.append(
f'FLAG scaffold "{m.group(0)}" (line {body_start + i}) — '
"drafting-context reference leaked into the doc. Rewrite it "
"as a real path or link."
)
# --- Report ---------------------------------------------------------------
for info in infos:
print(info)
for flag in flags:
print(flag)
print(
f"checked {checked_paths} paths, {checked_shas} SHAs, "
f"{checked_links} links; {len(flags)} flags"
)
if flags:
return 1
print(f"OK: {doc_path}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))