analysis: resolve SHA references and measure timestamp anonymisation

Two checks the human asked for after Phase 1: whether the mapping list
from the 2026-08-07 history rewrite still resolves older references, and
how far the anonymisation rule actually reaches.

sha_refs.tsv verifies all 251 mapping rows against the repos and
resolves every SHA cited in a management doc.
timestamp_anonymisation.tsv separates the project's own commits from
upstream fork history before counting non-compliant timestamps.
This commit is contained in:
Thore Cimbal
2026-08-10 12:00:00 +00:00
parent b8b853163d
commit 959bf882e9
6 changed files with 674 additions and 3 deletions
+11 -3
View File
@@ -82,11 +82,19 @@ def assert_untouched():
"field-test mandate forbids:\n " + "\n ".join(offending))
def git(repo, *args):
"""Run git in repo, return stdout as text. Raises on non-zero exit."""
def git(repo, *args, env_tz=None):
"""Run git in repo, return stdout as text. Raises on non-zero exit.
env_tz pins the timezone git renders dates in. Date rules in this
project are stated in UTC; rendering them in the machine's local zone
silently misclassifies every commit.
"""
env = None
if env_tz:
env = dict(os.environ, TZ=env_tz)
res = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True, text=True, errors="replace",
capture_output=True, text=True, errors="replace", env=env,
)
if res.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} in {repo}: {res.stderr.strip()}")
+144
View File
@@ -0,0 +1,144 @@
"""Resolve commit-SHA references -> analysis/data/sha_refs.tsv.
Added at the human's prompt during Phase 2: the 2026-08-07 history
rewrite gave 251 commits new SHAs, and shared/commit-zuordnung-2026-08-07.md
is the mapping list meant to keep older references resolvable. This
script tests whether that actually holds.
Two row kinds share one file:
doc-reference a SHA cited in a management-repo doc
mapping-entry an (old -> new) pair from the mapping table itself
Scope note: only management-repo docs are scanned for references. The
component repos are forks whose upstream docs cite thousands of upstream
SHAs that were never part of this project's history; including them
would bury the signal.
"""
import re
from common import (
DOC_SUFFIXES, MGMT_REPO, cell, git, repos, tracked_files, write_tsv,
)
MAPPING_DOC = "shared/commit-zuordnung-2026-08-07.md"
# Section heading -> repo name used elsewhere in the analysis.
SECTION_REPO = {
"gitops": "axion1337.chat-gitops",
"management": "management",
"ThreadNet-Web": "ThreadNet-Web",
"threadnet-call": "threadnet-call",
}
MAPPING_ROW = re.compile(r"^\|\s*`([0-9a-f]{7,40})`\s*\|\s*`([0-9a-f]{7,40})`\s*\|")
SECTION = re.compile(r"^##\s+(.+?)\s+—\s+(\d+)\s+Commits\s*$")
# A SHA-shaped token: 7-40 hex chars, mixing letters and digits so that
# plain numbers and hex-free words do not qualify.
SHA_TOKEN = re.compile(r"(?<![0-9a-zA-Z_/-])([0-9a-f]{7,40})(?![0-9a-zA-Z_-])")
def all_commits(repo):
"""Every commit SHA reachable from any ref, including remotes and tags."""
out = git(repo, "rev-list", "--all")
return {line.strip() for line in out.splitlines() if line.strip()}
def resolve(sha, index):
"""Repos in which sha is a commit (prefix match), sorted."""
hits = []
for name, commits in index.items():
if sha in commits or any(c.startswith(sha) for c in commits):
hits.append(name)
return sorted(hits)
def parse_mapping(text):
"""[(repo, old, new, declared_section_count)] from the mapping table."""
rows, section, declared = [], None, 0
for line in text.splitlines():
m = SECTION.match(line)
if m:
section = SECTION_REPO.get(m.group(1), m.group(1))
declared = int(m.group(2))
continue
m = MAPPING_ROW.match(line)
if m and section:
rows.append((section, m.group(1), m.group(2), declared))
return rows
def main():
index = {name: all_commits(path) for name, path in repos()}
known_repos = set(index)
mapping_text = (MGMT_REPO / MAPPING_DOC).read_text(encoding="utf-8")
mapping = parse_mapping(mapping_text)
old_to_new = {old: (repo, new) for repo, old, new, _ in mapping}
rows = []
# 1. The mapping table checked against the repos it describes.
for repo, old, new, _declared in mapping:
if repo not in known_repos:
status, note = "unverifiable", f"repo {repo} not in analysis scope"
else:
new_ok = bool(resolve(new, {repo: index[repo]}))
old_ok = bool(resolve(old, {repo: index[repo]}))
if new_ok and not old_ok:
status, note = "ok", "new resolves, old gone as expected"
elif new_ok and old_ok:
status, note = "ok-both-present", "old SHA still reachable from some ref"
elif not new_ok and old_ok:
status, note = "inverted", "new SHA missing but old one resolves"
else:
status, note = "unresolvable", "neither old nor new SHA exists in the repo"
rows.append(["mapping-entry", repo, MAPPING_DOC, "", f"{old}->{new}",
status, repo if status.startswith("ok") else "", note])
# 2. Every SHA cited in a management doc.
docs = [f for f in tracked_files("management", MGMT_REPO)
if any(f.endswith(s) for s in DOC_SUFFIXES)]
for src in sorted(docs):
text = (MGMT_REPO / src).read_text(encoding="utf-8")
for lineno, line in enumerate(text.splitlines(), start=1):
if src == MAPPING_DOC and MAPPING_ROW.match(line):
continue # already covered as mapping-entry rows
for sha in SHA_TOKEN.findall(line):
hits = resolve(sha, index)
if hits:
status = "resolves"
note = f"found in {','.join(hits)}"
elif sha in old_to_new:
repo, new = old_to_new[sha]
if repo in known_repos and resolve(new, {repo: index[repo]}):
status = "resolves-via-mapping"
note = f"pre-rewrite SHA; mapping -> {new} in {repo}"
hits = [repo]
else:
status = "mapping-stale"
note = f"mapping points to {new} in {repo}, which does not exist"
else:
status = "orphan"
note = "resolves in no analysed repo and is not in the mapping table"
rows.append(["doc-reference", "management", src, lineno, sha,
status, ",".join(hits), cell(line.strip())[:160]])
rows.sort(key=lambda r: (r[0], r[2], r[3] if r[3] != "" else 0, r[4]))
write_tsv(
"sha_refs.tsv",
["kind", "repo", "path", "line", "sha", "status", "resolved_in", "note"],
rows,
)
counts = {}
for r in rows:
counts[(r[0], r[5])] = counts.get((r[0], r[5]), 0) + 1
for key in sorted(counts):
print(f" {key[0]:<14} {key[1]:<22} {counts[key]}")
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
"""Measure the 2026-08-07 timestamp anonymisation -> timestamp_anonymisation.tsv.
CLAUDE.md:116-134 requires author *and* committer date of every commit in
the group to be 12:00:00 UTC, so that personal working hours cannot be
read out of the history. This script measures how far that holds.
Upstream fork history is separated out: threadnet-call and ThreadNet-Web
carry thousands of commits by Element/Matrix contributors that were never
this project's to rewrite. Counting those as a leak would be dishonest,
so every row carries the author identity and an `own_identity` flag.
Own identities are listed explicitly rather than guessed, and were read
off the actual author list of the six repos.
"""
import re
from collections import Counter
from common import cell, git, repos, write_tsv
# The one person behind this project, in every spelling that appears in
# the six repos' author fields -- including the malformed one.
OWN_IDENTITIES = {
"thore cimbal <cfx@riot.8shield.net>",
"scrublord macbad <scrublord@mac.bad>",
"scrublordmcbad <gamemaster@axion1337.de>",
"sorb <gamemaster@axion1337.de>",
"sorb <cfxqriot.8shield.net>",
}
# Agent-authored commits: not a person's working hours, but they were made
# during one, so they are reported separately rather than ignored.
AGENT_IDENTITIES = {"claude <noreply@anthropic.com>"}
ANONYMISED = "12:00:00"
def classify(ident):
low = ident.lower()
if low in OWN_IDENTITIES:
return "own"
if low in AGENT_IDENTITIES:
return "agent"
return "upstream-or-bot"
def main():
rows = []
for name, repo in repos():
# Author time forced to UTC: the rule is stated in UTC, and reading
# it in local time silently classifies every commit as non-compliant.
log = git(repo, "log", "--all", "--date=format-local:%H:%M:%S",
"--format=%H%x09%ad%x09%an <%ae>", env_tz="UTC")
main_shas = {
line.strip()
for line in git(repo, "log", "--format=%H", "origin/main").splitlines()
if line.strip()
}
buckets = Counter()
for line in log.splitlines():
if not line.strip():
continue
sha, when, ident = line.split("\t", 2)
anon = when == ANONYMISED
buckets[(classify(ident), anon, sha in main_shas, ident)] += 1
for (who, anon, on_main, ident), n in sorted(buckets.items()):
rows.append([name, who, ident,
"anonymised" if anon else "real-clock-time",
"main" if on_main else "side-branch-only", n])
rows.sort(key=lambda r: (r[0], r[1], r[3], r[4], r[2]))
write_tsv(
"timestamp_anonymisation.tsv",
["repo", "identity_class", "author", "timestamp_state", "reachable_from",
"commits"],
rows,
)
leak = sum(int(r[5]) for r in rows
if r[1] in ("own", "agent") and r[3] == "real-clock-time")
print(f" commits by own/agent identities still carrying real clock time: {leak}")
if __name__ == "__main__":
main()
+8
View File
@@ -64,5 +64,13 @@ echo
echo "== claims"
python3 "$SCRIPTS/inv_claims.py"
echo
echo "== commit-SHA references and the 2026-08-07 rewrite mapping"
python3 "$SCRIPTS/inv_shas.py"
echo
echo "== timestamp anonymisation coverage"
python3 "$SCRIPTS/inv_timestamps.py"
echo
echo "done. raw data in $MGMT_REPO/analysis/data/"