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:
@@ -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()}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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/"
|
||||
|
||||
Reference in New Issue
Block a user