Files
management/analysis/scripts/common.py
T
Thore Cimbal b8b853163d analysis: add deterministic inventory scripts and raw data
run_all.sh reproduces every file under analysis/data/ from zero: it
clones the in-scope components if missing, exports group issue metadata
from git.lab, and regenerates the inventories. Reruns are diff-clean --
no wall-clock time enters an output; 'days since' is measured against
the management repo's HEAD date.

The management repo is inventoried at main, not at the analysis branch,
so this analysis does not observe its own commits. inv_repo.py aborts
the run if anything outside analysis/ was modified.
2026-08-09 12:00:00 +00:00

153 lines
5.2 KiB
Python

"""Shared helpers for the neckbeard field-test inventory scripts.
Determinism rules obeyed by every consumer of this module:
* No wall-clock time enters any output file. Where an output needs a
"days since" figure, it is measured against REFERENCE_DATE, which is
the committer date of the management repo's HEAD -- a property of the
tree under analysis, not of the moment the script runs.
* Every collection written out is sorted by an explicit key.
* Paths in outputs are repo-relative, never absolute.
"""
import os
import re
import subprocess
from datetime import date
from pathlib import Path
# Repo layout. WORKSPACE holds the management clone plus a components/
# directory; override with NB_WORKSPACE when the checkout lives elsewhere.
MGMT_REPO = Path(__file__).resolve().parents[2]
WORKSPACE = Path(os.environ.get("NB_WORKSPACE", MGMT_REPO.parent))
COMPONENTS_DIR = WORKSPACE / "components"
DATA_DIR = MGMT_REPO / "analysis" / "data"
GITLAB_HOST = "https://git.lab"
GROUP = "axion1337.chat"
# Frozen in analysis/SCOPE.md, confirmed by the human at the Phase-0 STOP.
COMPONENTS = [
("threadnet-call", "ThreadNet Call"),
("thread-net-git", "ThreadNet Git"),
("threadnet-operating", "ThreadNet Operating"),
("axion1337.chat-gitops", "ThreadNet Server Suite"),
("ThreadNet-Web", "ThreadNet Web"),
]
# Named out of scope as analysis targets by the human. References into
# them are still recorded, tagged points-outside-scope.
OUT_OF_SCOPE_PROJECTS = ["game-operating", "gameserver"]
OUT_OF_SCOPE_SUBGROUPS = ["vendor", "Archiv"]
# Tracked-but-vendored paths, excluded from trees and scans.
VENDORED = re.compile(r"(^|/)(node_modules|dist|build|\.yarn|vendor)/")
DOC_SUFFIXES = {".md", ".rst"}
# The management repo is measured at its default branch, never at the
# analysis branch: otherwise this analysis observes its own commits and
# its own analysis/ files and reports them as project reality.
BASELINE_REF = {"management": "main"}
ANALYSIS_DIR = "analysis/"
def repos():
"""(name, path) for the management repo and every in-scope component."""
out = [("management", MGMT_REPO)]
out += [(slug, COMPONENTS_DIR / slug) for slug, _ in COMPONENTS]
return out
def ref_of(name):
return BASELINE_REF.get(name, "HEAD")
def assert_untouched():
"""Fail loudly if this session modified anything outside analysis/.
The mandate forbids changing any existing file. That is checked here
mechanically rather than trusted, on every run.
"""
ref = ref_of("management")
diff = git(MGMT_REPO, "diff", "--name-only", ref, "--", ".",
f":(exclude){ANALYSIS_DIR}")
untracked = git(MGMT_REPO, "ls-files", "--others", "--exclude-standard",
"--", ".", f":(exclude){ANALYSIS_DIR}")
offending = [p for p in (diff + untracked).split("\n") if p.strip()]
if offending:
raise SystemExit(
"ABORT: this session changed files outside analysis/, which the "
"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."""
res = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True, text=True, errors="replace",
)
if res.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} in {repo}: {res.stderr.strip()}")
return res.stdout
def tracked_files(name, repo, skip_vendored=True):
"""Sorted repo-relative paths tracked at the repo's baseline ref.
Reads the ref, not the index, so the analysis branch's own files stay
out of the inventory.
"""
files = [f for f in
git(repo, "ls-tree", "-r", "-z", "--name-only", ref_of(name)).split("\0")
if f]
files = [f for f in files if not f.startswith(ANALYSIS_DIR)]
if skip_vendored:
files = [f for f in files if not VENDORED.search(f)]
return sorted(files)
def last_commit(name, repo, path):
"""(iso_date, author_name, short_sha) of the newest commit touching path."""
out = git(repo, "log", "-1", "--format=%ad\t%an\t%h", "--date=short",
ref_of(name), "--", path)
if not out.strip():
return ("", "", "")
return tuple(out.strip().split("\t"))
def _reference_date():
out = git(MGMT_REPO, "log", "-1", "--format=%cd", "--date=short",
ref_of("management"))
y, m, d = (int(x) for x in out.strip().split("-"))
return date(y, m, d)
REFERENCE_DATE = _reference_date()
def days_since(iso_date):
"""Whole days from iso_date to REFERENCE_DATE; '' when the date is empty."""
if not iso_date:
return ""
y, m, d = (int(x) for x in iso_date.split("-"))
return (REFERENCE_DATE - date(y, m, d)).days
def cell(text):
"""Make a value safe for a TSV cell: no tabs, no newlines, trimmed."""
return str(text).replace("\t", " ").replace("\r", " ").replace("\n", " ").strip()
def write_tsv(name, header, rows):
"""Write sorted rows to analysis/data/<name>, tab separated."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
path = DATA_DIR / name
with path.open("w", encoding="utf-8") as fh:
fh.write("\t".join(header) + "\n")
for row in rows:
fh.write("\t".join(cell(v) for v in row) + "\n")
print(f" {name}: {len(rows)} rows")
return path