forge: citation_hash_class.py portable --repo default (fixes wrong-tree defect); + verifier

This commit is contained in:
forge committed 2026-10-23 04:10:47 +00:00
1 parent 68a870440b
commit 4c0e823625
3 files changed
+296 -2

No files matched your search

+9 -2
View File
@@ -84,7 +84,14 @@ USAGE
[--json OUT] [--only mismatch|classless|bound|flags|all] [--min-len N]
[--context] [--siblings] [--selftest] # 19 fixtures
"""
import argparse, collections, hashlib, json, re, subprocess, sys
import argparse, collections, hashlib, json, os, re, subprocess, sys
# Portable repo root: derived from this file's location, not a host literal.
# Matches the convention all sibling tools (path_claim_check, citation_resolve_check,
# selftest_sweep, write_probe, tool_map, doc_census_check) already settled on; a
# bare invocation grades the checkout it is run out of, never /root/troupe-standup
# from wherever it happens to be copied. (forge lane: Refactor -- wrong-tree defect)
DEFAULT_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MIN_LEN_DEFAULT = 8
PREFIX_LENS = (7, 8, 10, 12, 16, 20, 40, 64)
@@ -357,7 +364,7 @@ def selftest(repo):
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--repo', default='/root/troupe-standup')
ap.add_argument('--repo', default=DEFAULT_REPO)
ap.add_argument('--rev', default='HEAD')
ap.add_argument('--corpus', nargs='*')
ap.add_argument('--json')
@@ -0,0 +1,67 @@
# citation_hash_class.py — portable --repo default (forge lane: Refactor)
## The smell
`tools/citation_hash_class.py` defaulted `--repo` to the host literal
`/root/troupe-standup`. The tool's own USAGE docstring states that paths are
resolved against `--repo`, not the caller's cwd — but a bare invocation from
any clone, copy, or other host graded `/root/troupe-standup` from wherever it
happened to run. tool_map.py calls this the "wrong-tree defect": a tool that
grades the tree it lives in, not the tree it names.
Every sibling tool (path_claim_check, citation_resolve_check, selftest_sweep,
write_probe, tool_map, doc_census_check) already derives its default repo root
from `__file__`. citation_hash_class.py was the last holdout.
## The refactor (two edits, working tree)
1. `import` gains `os`.
2. `DEFAULT_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))`
replaces the inline host literal in `ap.add_argument('--repo', default=DEFAULT_REPO)`.
The portable expression resolves to the parent of `tools/` — i.e. the checkout
root — so in this checkout (living at `/root/troupe-standup`) a bare run still
grades `/root/troupe-standup`: a no-op HERE, and the bug is fixed everywhere else.
## What is NOT touched
The actual scanning/census logic (`scan`, `verdict_of`, `selftest`, `classify`)
is byte-for-byte unchanged. Only the argument default and the import are edited.
## Verification
Verifier: `verify_citation_hash_class_portable_default.py`
ORIGINAL = `git show HEAD:tools/citation_hash_class.py` (committed; carries the
host-literal default).
REFACTORED = working tree (portable DEFAULT_REPO).
```
[ok ] selftest: original 19/19 rc 0
[ok ] selftest: refactored 19/19 rc 0
[ok ] selftest: identical stdout (byte-for-byte)
[ok ] bare live scan: original rc 0
[ok ] bare live scan: refactored rc 0
[ok ] bare live scan: identical findings
[ok ] bare live scan: identical stats
[ok ] explicit --repo scan: original rc 0
[ok ] explicit --repo scan: refactored rc 0
[ok ] explicit --repo scan: identical findings
[ok ] explicit --repo scan: identical stats
[ok ] refactored DEFAULT_REPO is portable (__file__- based) and free of host literal
[ok ] original DEFAULT_REPO is the host literal (no portable expr)
[ok ] portable DEFAULT_REPO resolves to /root/troupe-standup here
[ok ] live census non-vacuous (findings > 0, stats > 0)
verify: 15 checks, 15 passed, 0 failed
```
Raw run log: `_chc_verify_run.log` (artifacts in this directory).
Exit code: 0.
## Artifacts
- Refined tool: `../../tools/citation_hash_class.py` (diff: 2 added lines / 1 changed line)
- Verifier: `verify_citation_hash_class_portable_default.py`
- Raw run log: `_chc_verify_run.log`
- This report: `CITATION_HASH_CLASS_PORTABLE_DEFAULT_VERIFICATION.md`
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""verify_citation_hash_class_portable_default.py
Equivalence proof for the behavior-preserving refactor in
tools/citation_hash_class.py (forge lane: Refactor).
THE SMELL
`--repo` defaulted to a host literal:
ap.add_argument('--repo', default='/root/troupe-standup')
A bare invocation from any other checkout / clone / host graded the path
/root/troupe-standup from HERE -- the wrong tree. tool_map.py names this the
"wrong-tree defect". All six sibling tools (path_claim_check, citation_resolve_check,
selftest_sweep, write_probe, tool_map, doc_census_check) already derive the
default from __file__; citation_hash_class.py was the last holdout.
THE REFACTOR (two edits on the working tree, uncommitted at HEAD)
1. import gains `os`
2. DEFAULT_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ap.add_argument('--repo', default=DEFAULT_REPO)
The portable expression resolves to the parent of tools/ -- i.e. the checkout
root -- so in THIS checkout (living at /root/troupe-standup) a bare run still
grades /root/troupe-standup: a no-op HERE, and the bug is fixed everywhere else.
ORIGINAL vs REFACTORED
ORIGINAL = git show HEAD:tools/citation_hash_class.py (committed; verdict_of applied,
but still carries the host-literal default)
REFACTORED = working tree (portable DEFAULT_REPO)
WHAT THIS VERIFIER PROVES
The refactor is behavior-preserving: identical selftest contract, identical live
census (findings + stats) on BOTH the bare default path (the refactor's touchpoint)
and the explicit --repo path, plus a code-level portability check.
LEGS
[1] selftest: original (git HEAD) passes 19/19, rc 0
[2] selftest: refactored (working tree) passes 19/19, rc 0
[3] selftest: stdout is byte-identical (byte-for-byte)
[4] BARE live scan --json (default --repo path): original & refactored both rc 0,
findings identical, stats identical
[5] EXPLICIT --repo live scan --json: findings identical, stats identical
[6] code-level: refactored DEFAULT_REPO portable (__file__-based) and free of the
host-literal default; original carries the host literal and no portable expr
[7] portable expression resolves to /root/troupe-standup HERE
[8] non-vacuity: the live census is non-empty (real findings)
Exit 0 iff all checks pass, else 1.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
REPO = "/root/troupe-standup"
TOOL = "tools/citation_hash_class.py"
ORIG_SPEC = f"HEAD:{TOOL}"
PORTABLE_EXPR = "os.path.dirname(os.path.dirname(os.path.abspath(__file__)))"
HARD_CODED = "default='/root/troupe-standup'"
SELFTEST_SUMMARY = "19 passed, 0 failed"
class Verdict(Exception):
pass
def materialize_original():
"""Write the PRE-refactor module (git HEAD) to a temp path; return (dir, path).
HEAD is the commit the refactor descends from before it lands, so this reads the
genuine original."""
out = tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="wb")
try:
subprocess.run(["git", "-C", REPO, "show", ORIG_SPEC],
check=True, stdout=out, timeout=30)
except subprocess.CalledProcessError as exc:
raise Verdict("could not read %s: %s" % (ORIG_SPEC, exc))
finally:
out.close()
return os.path.dirname(out.name), out.name
def run(path, args, cwd=REPO, timeout=320):
proc = subprocess.run([sys.executable, path, *args],
capture_output=True, text=True, cwd=cwd, timeout=timeout)
return proc.returncode, proc.stdout, proc.stderr
def run_json(path, out_path, repo=None, timeout=320):
"""Run citation_hash_class.py --json <out_path> [, --repo <repo>]; write JSON to out_path.
Returns (rc, stdout, stderr)."""
args = []
if repo is not None:
args += ["--repo", repo]
args += ["--json", out_path]
proc = subprocess.run([sys.executable, path, *args],
capture_output=True, text=True, cwd=REPO, timeout=timeout)
return proc.returncode, proc.stdout, proc.stderr
def load_json(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def code_lines(text):
"""Strip naive comments (#) and docstring-open lines so a smell that lives only
in prose is not counted as code -- same convention the other forge verifiers use."""
out = []
for ln in text.splitlines():
s = ln.lstrip()
if s.startswith("#") or s.startswith('"""'):
continue
out.append(ln)
return "\n".join(out)
def main():
cells, failed = [], 0
def check(label, ok, detail=""):
cells.append((label, ok))
nonlocal failed
if not ok:
failed += 1
print(" [%s] %s%s"
% ("ok " if ok else "FAIL", label,
(f" [{detail}]" if detail and not ok else "")))
orig_dir, orig_path = materialize_original()
ref_path = os.path.join(REPO, TOOL)
tmpdir = tempfile.mkdtemp(prefix="chc_verify_",
dir=os.environ.get("TMPDIR", "/tmp"))
try:
# ---- [1-3] selftest parity -----------------------------------------
rc_o, out_o, _ = run(orig_path, ["--selftest"])
rc_r, out_r, _ = run(ref_path, ["--selftest"])
check("selftest: original 19/19 rc 0",
rc_o == 0 and SELFTEST_SUMMARY in out_o, f"rc={rc_o}")
check("selftest: refactored 19/19 rc 0",
rc_r == 0 and SELFTEST_SUMMARY in out_r, f"rc={rc_r}")
check("selftest: identical stdout (byte-for-byte)",
out_o == out_r, f"len_o={len(out_o)} len_r={len(out_r)}")
# ---- [4] BARE live scan (default --repo path = the refactor touchpoint) ---
oj_path = os.path.join(tmpdir, "orig_bare.json")
rj_path = os.path.join(tmpdir, "ref_bare.json")
rc_o, _, _ = run_json(orig_path, oj_path)
rc_r, _, _ = run_json(ref_path, rj_path)
check("bare live scan: original rc 0", rc_o == 0, f"rc={rc_o}")
check("bare live scan: refactored rc 0", rc_r == 0, f"rc={rc_r}")
bare_ok = False
if os.path.exists(oj_path) and os.path.exists(rj_path):
oj = load_json(oj_path)
rj = load_json(rj_path)
bare_findings = oj["findings"] == rj["findings"]
bare_stats = oj["stats"] == rj["stats"]
check("bare live scan: identical findings", bare_findings,
f"orig={len(oj['findings'])} ref={len(rj['findings'])}")
check("bare live scan: identical stats", bare_stats,
f"orig={dict(oj['stats'])} ref={dict(rj['stats'])}")
bare_ok = bare_findings and bare_stats
# ---- [5] EXPLICIT --repo live scan (proves scan() logic is untouched) ---
oj2 = os.path.join(tmpdir, "orig_explicit.json")
rj2 = os.path.join(tmpdir, "ref_explicit.json")
rc_o, _, _ = run_json(orig_path, oj2, REPO)
rc_r, _, _ = run_json(ref_path, rj2, REPO)
check("explicit --repo scan: original rc 0", rc_o == 0, f"rc={rc_o}")
check("explicit --repo scan: refactored rc 0", rc_r == 0, f"rc={rc_r}")
if os.path.exists(oj2) and os.path.exists(rj2):
oj = load_json(oj2)
rj = load_json(rj2)
check("explicit --repo scan: identical findings",
oj["findings"] == rj["findings"],
f"orig={len(oj['findings'])} ref={len(rj['findings'])}")
check("explicit --repo scan: identical stats",
oj["stats"] == rj["stats"])
# ---- [6] code-level: DEFAULT_REPO portability ----------------------
ref_text = open(ref_path, encoding="utf-8").read()
orig_text = open(orig_path, encoding="utf-8").read()
ref_code = code_lines(ref_text)
orig_code = code_lines(orig_text)
check("refactored DEFAULT_REPO is portable (__file__-based) and free of host literal",
PORTABLE_EXPR in ref_code and HARD_CODED not in ref_code,
f"portable={PORTABLE_EXPR in ref_code} hardcoded={HARD_CODED in ref_code}")
check("original DEFAULT_REPO is the host literal (no portable expr)",
HARD_CODED in orig_code and PORTABLE_EXPR not in orig_code,
f"hardcoded={HARD_CODED in orig_code} portable={PORTABLE_EXPR in orig_code}")
# ---- [7] portable expression resolves to /root/troupe-standup HERE ---
resolved = subprocess.run(
[sys.executable, "-c",
"import os;print(os.path.dirname(os.path.dirname(os.path.abspath(%r))))" % ref_path],
capture_output=True, text=True).stdout.strip()
check("portable DEFAULT_REPO resolves to /root/troupe-standup here",
resolved == REPO, f"got={resolved}")
# ---- [8] non-vacuity ------------------------------------------------
if os.path.exists(rj_path):
rj = load_json(rj_path)
nonvac = len(rj["findings"]) > 0 and sum(rj["stats"].values()) > 0
check("live census non-vacuous (findings > 0, stats > 0)", nonvac,
f"findings={len(rj['findings'])} stats_sum={sum(rj['stats'].values())}")
finally:
shutil.rmtree(orig_dir, ignore_errors=True)
shutil.rmtree(tmpdir, ignore_errors=True)
total = len(cells)
passed = total - failed
print(f"\nverify: {total} checks, {passed} passed, {failed} failed")
return 1 if failed else 0
if __name__ == "__main__":
try:
sys.exit(main())
except Verdict as e:
print(f"\nVERDICT: MISMATCH -- {e}")
sys.exit(1)