verify_claims.py gives all 813 claim rows a mechanical disposition; the 28 flags were adjudicated by hand (REPORT.md appendix). Two survived as genuine drift (F-017): a closed issue still described as open in shared/lab-netzwerk.md, and a 'pending' decision block in hosts/cfgmon.md whose premise the same file records as executed. Also: narrow the vendored-path filter (it silently dropped 7 tracked icon files and produced false path-miss flags), record the confirmed canonical author identity in F-003, verify the Gitea#48->GitLab#46 numbering shift by title in F-005, and add the ADR-0010 draft under analysis/drafts/ for the human to git-mv into decisions/. Branch renamed to Neckbeard-v0.1.1-analyse-1 per the human.
265 lines
10 KiB
Python
265 lines
10 KiB
Python
"""Mechanically verify every claim row -> analysis/data/claims_verification.tsv.
|
|
|
|
Closes the gap HANDOFF.md named as the analysis' largest hole: claims.tsv
|
|
was extracted in full but verified selectively. This pass gives every one
|
|
of its rows an explicit disposition instead of an implicit "not chased".
|
|
|
|
What a script can and cannot do here is stated, not blurred:
|
|
|
|
* checkable here -- backticked repo paths resolved against all six trees;
|
|
issue references resolved against the group export,
|
|
including expectation words ("geschlossen", "offen")
|
|
checked against the actual state; legacy IDs against
|
|
issue titles; version tokens against git tags.
|
|
* not checkable -- host/runtime state (absolute paths, service versions,
|
|
DNS), claims about the past, and claims whose truth
|
|
lives outside the analysed repos. These are classed,
|
|
counted and left honest, not silently passed.
|
|
|
|
Rows that fail a mechanical check are FLAGged for human adjudication; the
|
|
verdicts live in REPORT.md's appendix, not here -- this file is
|
|
regenerated and must stay free of hand-written content.
|
|
"""
|
|
|
|
import csv
|
|
import json
|
|
import re
|
|
|
|
from common import DATA_DIR, MGMT_REPO, cell, git, repos, write_tsv
|
|
|
|
# --- reference data -------------------------------------------------------
|
|
|
|
PROJECT_ALIASES = {
|
|
"gitops": "axion1337.chat-gitops",
|
|
"axion1337.chat-gitops": "axion1337.chat-gitops",
|
|
"threadnet-web": "ThreadNet-Web",
|
|
"ThreadNet-Web": "ThreadNet-Web",
|
|
"threadnet-call": "threadnet-call",
|
|
"thread-net-git": "thread-net-git",
|
|
"threadnet-operating": "threadnet-operating",
|
|
"management": "management",
|
|
}
|
|
|
|
CLOSED_WORDS = re.compile(r"geschlossen|erledigt|abgeschlossen", re.I)
|
|
OPEN_WORDS = re.compile(r"\boffen(?:e[rs]?)?\b", re.I)
|
|
HISTORY_WORDS = re.compile(
|
|
r"entfernt|gelöscht|removed|hieß|war\b|bis 2026|damals|Vorgänger|alte?[rn]?\b", re.I)
|
|
|
|
PATH_EXT = (".md", ".yml", ".yaml", ".json", ".py", ".ts", ".toml", ".crt",
|
|
".sh", ".env", ".example", ".cjs", ".rst", ".txt", ".conf", ".rules",
|
|
".png", ".jpg", ".ico", ".icns", ".exe", ".production")
|
|
DOMAIN_PREFIX = re.compile(
|
|
r"^(https?://|git\.lab|registry\.|rohana|axionwiki|wiki\.lab|ghcr\.io|gcr\.io)")
|
|
FORGE_REPO = re.compile(r"^(sorb|homelab|vendor|axion1337\.chat)/")
|
|
NET_REF = re.compile(r"^\d{1,3}(\.\d{1,3}){3}(/\d+)?$")
|
|
BARE_EXT = re.compile(r"^\.[a-z0-9]{1,6}$")
|
|
VERSION_RE = re.compile(r"\bv?\d+\.\d+\.\d+(?:-[\w.]+)?\b")
|
|
NAMED_REF_RE = re.compile(r"\b([A-Za-z][\w.-]*)#(\d{1,4})\b")
|
|
URL_REF_RE = re.compile(r"/([\w.-]+)/-/issues/(\d{1,4})")
|
|
BARE_REF_RE = re.compile(r"(?<![\w/])#(\d{1,4})\b")
|
|
LEGACY_ID_RE = re.compile(r"\b([A-Z]{3,8}-\d{2})\b")
|
|
|
|
|
|
def load_issues():
|
|
data = json.loads((DATA_DIR / "gitlab_issues.json").read_text(encoding="utf-8"))
|
|
by_ref = {(i["project"], i["iid"]): i for i in data["issues"]}
|
|
id_in_titles = set()
|
|
for i in data["issues"]:
|
|
id_in_titles.update(LEGACY_ID_RE.findall(i["title"]))
|
|
return by_ref, id_in_titles
|
|
|
|
|
|
def load_trees():
|
|
"""repo -> set of tracked paths, read from the committed tree files so a
|
|
rerun without component clones still verifies identically."""
|
|
trees = {}
|
|
for name, _ in repos():
|
|
tree_file = DATA_DIR / f"tree_{name}.txt"
|
|
trees[name] = set(tree_file.read_text(encoding="utf-8").splitlines())
|
|
return trees
|
|
|
|
|
|
def load_tags():
|
|
tags = {}
|
|
for name, repo in repos():
|
|
try:
|
|
tags[name] = {t for t in git(repo, "tag", "--list").splitlines() if t}
|
|
except RuntimeError:
|
|
tags[name] = set()
|
|
return tags
|
|
|
|
|
|
def load_branches():
|
|
"""Remote branch names across all repos, with and without origin/."""
|
|
out = set()
|
|
for _, repo in repos():
|
|
try:
|
|
for b in git(repo, "branch", "-r", "--format=%(refname:short)").splitlines():
|
|
b = b.strip()
|
|
if b and "HEAD" not in b:
|
|
out.add(b)
|
|
out.add(b.removeprefix("origin/"))
|
|
except RuntimeError:
|
|
pass
|
|
return out
|
|
|
|
|
|
# --- per-token checks ------------------------------------------------------
|
|
|
|
# Doc names that qualify a path with a repo alias ("gitops/CLAUDE.md").
|
|
TREE_ALIASES = {"gitops": "axion1337.chat-gitops", "management": "management"}
|
|
|
|
|
|
def classify_path_token(tok, trees, branches, src_dir):
|
|
"""-> (check_note, flag_or_None)"""
|
|
if tok.startswith(("/", "~", "$")) or DOMAIN_PREFIX.match(tok):
|
|
return (f"runtime-path:{tok}", None)
|
|
if NET_REF.match(tok):
|
|
return (f"net-ref:{tok}", None)
|
|
if FORGE_REPO.match(tok):
|
|
return (f"forge-repo:{tok}", None)
|
|
if tok.startswith("@"):
|
|
return (f"package-ref:{tok}", None)
|
|
if ":" in tok:
|
|
return (f"image-ref:{tok}", None)
|
|
if BARE_EXT.match(tok):
|
|
return (None, None)
|
|
if any(c in tok for c in " *<{=!") or tok.startswith("-"):
|
|
return (None, None)
|
|
looks_like_path = "/" in tok or tok.endswith(PATH_EXT)
|
|
if not looks_like_path:
|
|
return (None, None)
|
|
clean = tok[2:] if tok.startswith("./") else tok
|
|
|
|
stripped = clean.removeprefix("origin/")
|
|
if stripped in branches or clean in branches:
|
|
return (f"branch-ok:{clean}", None)
|
|
|
|
first, _, rest = clean.partition("/")
|
|
if rest and first in TREE_ALIASES and rest in trees[TREE_ALIASES[first]]:
|
|
return (f"path-ok:{rest}@{TREE_ALIASES[first]}", None)
|
|
|
|
candidates = [clean]
|
|
if src_dir: # doc-relative resolution inside the management repo
|
|
candidates.append(f"{src_dir}/{clean}")
|
|
hits = []
|
|
for name, files in trees.items():
|
|
for cand in candidates:
|
|
if cand in files or any(f.endswith("/" + cand) for f in files):
|
|
hits.append(name)
|
|
break
|
|
if any(("/" + cand.rstrip("/") + "/") in ("/" + f) or
|
|
f.startswith(cand.rstrip("/") + "/") for f in files):
|
|
hits.append(name + "(dir)")
|
|
break
|
|
if hits:
|
|
return (f"path-ok:{clean}@{','.join(sorted(hits)[:3])}", None)
|
|
return (f"path-miss:{clean}", f"path-miss:{clean}")
|
|
|
|
|
|
def issue_refs(line):
|
|
"""Extract (project, iid) refs; bare #N defaults to management."""
|
|
refs, spans = [], []
|
|
for m in list(NAMED_REF_RE.finditer(line)) + list(URL_REF_RE.finditer(line)):
|
|
proj = PROJECT_ALIASES.get(m.group(1))
|
|
if proj:
|
|
refs.append((proj, int(m.group(2))))
|
|
spans.append(m.span())
|
|
named_iids = {iid for _, iid in refs}
|
|
for m in BARE_REF_RE.finditer(line):
|
|
if any(a <= m.start() < b for a, b in spans):
|
|
continue
|
|
iid = int(m.group(1))
|
|
# "[#49](…/gitops/-/issues/49)" names the same issue twice; the
|
|
# bare token is the link text, not a second (management) reference.
|
|
if iid in named_iids:
|
|
continue
|
|
refs.append(("management", iid))
|
|
return refs
|
|
|
|
|
|
def check_issue_ref(proj, iid, line, by_ref, single_ref):
|
|
issue = by_ref.get((proj, iid))
|
|
if issue is None:
|
|
return (f"issue-miss:{proj}#{iid}", f"issue-miss:{proj}#{iid}")
|
|
expect = None
|
|
# Expectation words are only attributable when the line references
|
|
# exactly one issue ("#5 geschlossen, Folgethemen in #6" must not
|
|
# expect #6 to be closed).
|
|
if single_ref and CLOSED_WORDS.search(line) and not OPEN_WORDS.search(line):
|
|
expect = "closed"
|
|
elif single_ref and OPEN_WORDS.search(line) and not CLOSED_WORDS.search(line):
|
|
expect = "opened"
|
|
if expect and issue["state"] != expect:
|
|
return (f"issue-state:{proj}#{iid}={issue['state']},text-says-{expect}",
|
|
f"issue-state:{proj}#{iid}")
|
|
return (f"issue-ok:{proj}#{iid}({issue['state']})", None)
|
|
|
|
|
|
def main():
|
|
by_ref, ids_in_titles = load_issues()
|
|
trees = load_trees()
|
|
tags = load_tags()
|
|
branches = load_branches()
|
|
all_tags = {t for s in tags.values() for t in s}
|
|
|
|
rows = []
|
|
counts = {}
|
|
with (DATA_DIR / "claims.tsv").open(encoding="utf-8") as fh:
|
|
for row in csv.DictReader(fh, delimiter="\t"):
|
|
line = row["claim_text"]
|
|
checks, flags = [], []
|
|
|
|
if row["in_code_block"] == "yes":
|
|
status = "code-block"
|
|
else:
|
|
src_dir = row["path"].rsplit("/", 1)[0] if "/" in row["path"] else ""
|
|
for tok in re.findall(r"`([^`]+)`", line):
|
|
note, flag = classify_path_token(tok, trees, branches, src_dir)
|
|
if note:
|
|
checks.append(note)
|
|
if flag:
|
|
flags.append(flag)
|
|
refs = issue_refs(line)
|
|
for proj, iid in refs:
|
|
note, flag = check_issue_ref(proj, iid, line, by_ref,
|
|
single_ref=len(refs) == 1)
|
|
checks.append(note)
|
|
if flag:
|
|
flags.append(flag)
|
|
for lid in LEGACY_ID_RE.findall(line):
|
|
checks.append(f"id-{'ok' if lid in ids_in_titles else 'no-issue'}:{lid}")
|
|
for v in VERSION_RE.findall(line):
|
|
if v in all_tags or "v" + v in all_tags:
|
|
checks.append(f"tag-ok:{v}")
|
|
|
|
if flags:
|
|
status = "FLAG"
|
|
elif any(c.startswith(("path-ok", "issue-ok")) for c in checks):
|
|
status = "checked-ok"
|
|
elif checks:
|
|
status = "informational"
|
|
else:
|
|
status = "prose-or-runtime"
|
|
|
|
hint = "historical-wording" if HISTORY_WORDS.search(line) else ""
|
|
counts[status] = counts.get(status, 0) + 1
|
|
rows.append([row["path"], row["line"], status,
|
|
";".join(checks)[:400], ";".join(flags), hint,
|
|
cell(line)[:200]])
|
|
|
|
rows.sort(key=lambda r: (r[0], int(r[1])))
|
|
write_tsv("claims_verification.tsv",
|
|
["path", "line", "status", "checks", "flags", "hint", "claim_text"],
|
|
rows)
|
|
for k in sorted(counts):
|
|
print(f" {k:<18} {counts[k]}")
|
|
print(" -- FLAG rows:")
|
|
for r in rows:
|
|
if r[2] == "FLAG":
|
|
print(f" {r[0]}:{r[1]} [{r[4]}] {'(hist?)' if r[5] else ''} {r[6][:110]}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|