forge: citation_hash_class.py portable --repo default (fixes wrong-tree defect); + verifier
This commit is contained in:
1 parent
68a870440b
commit
4c0e823625
3 files changed
+296
-2
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user