@andresmassello/uscha 1.56.1 → 1.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/uscha.js +0 -0
- package/package.json +2 -1
- package/uscha-kit/.claude/skills/uscha-characterize/SKILL.md +18 -0
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +21 -0
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +636 -0
- package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +11 -0
- package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.ps1 +22 -22
- package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.sh +0 -0
- package/uscha-kit/.claude/skills/uscha-mirador/mirador.template.html +39 -1
- package/uscha-kit/.claude/skills/uscha-status/SKILL.md +8 -0
- package/uscha-kit/.claude-plugin/plugin.json +2 -2
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/INSTALL.md +128 -128
- package/uscha-kit/README.md +90 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/hooks/block-approved-writes.py +0 -0
- package/uscha-kit/reports/junit/.fastpath-cases.json +1 -0
- package/uscha-kit/reports/junit/.goldencov-cases.json +1 -0
- package/uscha-kit/reports/junit/.specdrift-cases.json +1 -0
- package/uscha-kit/skills/uscha-characterize/SKILL.md +18 -0
- package/uscha-kit/skills/uscha-devloop/SKILL.md +21 -0
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +636 -0
- package/uscha-kit/skills/uscha-mirador/SKILL.md +11 -0
- package/uscha-kit/skills/uscha-mirador/mirador-watch.ps1 +22 -22
- package/uscha-kit/skills/uscha-mirador/mirador-watch.sh +0 -0
- package/uscha-kit/skills/uscha-mirador/mirador.template.html +39 -1
- package/uscha-kit/skills/uscha-status/SKILL.md +8 -0
- package/uscha-kit/uscha.config.json +16 -1
- package/uscha-kit/workbench-doctor.sh +0 -0
|
@@ -59,7 +59,9 @@ import math
|
|
|
59
59
|
import os
|
|
60
60
|
import re
|
|
61
61
|
import shutil
|
|
62
|
+
import subprocess
|
|
62
63
|
import sys
|
|
64
|
+
import tempfile
|
|
63
65
|
import unicodedata
|
|
64
66
|
import xml.etree.ElementTree as ET
|
|
65
67
|
from datetime import datetime, timezone
|
|
@@ -2699,6 +2701,588 @@ def cmd_oscillation(args):
|
|
|
2699
2701
|
sys.exit(1 if osc else 0)
|
|
2700
2702
|
|
|
2701
2703
|
|
|
2704
|
+
# --------------------------------------------------------------------------- #
|
|
2705
|
+
# fastpath-eval (ADR-003: fast-path entry by MEASURED signals, never opinion)
|
|
2706
|
+
# --------------------------------------------------------------------------- #
|
|
2707
|
+
|
|
2708
|
+
def _fp_glob_re(g):
|
|
2709
|
+
"""Translate a protected-path glob to a regex. `**` crosses directories, `*`/`?` do not.
|
|
2710
|
+
Case-insensitive on purpose: Windows and macOS filesystems are."""
|
|
2711
|
+
g = g.replace("\\", "/")
|
|
2712
|
+
out, i = [], 0
|
|
2713
|
+
while i < len(g):
|
|
2714
|
+
c = g[i]
|
|
2715
|
+
if c == "*":
|
|
2716
|
+
if g[i:i + 2] == "**":
|
|
2717
|
+
i += 2
|
|
2718
|
+
if i < len(g) and g[i] == "/":
|
|
2719
|
+
# `**/` = any number of WHOLE directories (incl. zero) -- segment-anchored,
|
|
2720
|
+
# so `**/migrations/**` does not match `db_migrations/` by substring.
|
|
2721
|
+
out.append("(?:[^/]*/)*")
|
|
2722
|
+
i += 1
|
|
2723
|
+
else:
|
|
2724
|
+
out.append(".*")
|
|
2725
|
+
continue
|
|
2726
|
+
out.append("[^/]*")
|
|
2727
|
+
elif c == "?":
|
|
2728
|
+
out.append("[^/]")
|
|
2729
|
+
elif c in ".^$+{}[]|()":
|
|
2730
|
+
out.append("\\" + c)
|
|
2731
|
+
else:
|
|
2732
|
+
out.append(c)
|
|
2733
|
+
i += 1
|
|
2734
|
+
return re.compile("^(?:%s)$" % "".join(out), re.I)
|
|
2735
|
+
|
|
2736
|
+
|
|
2737
|
+
def cmd_fastpath_eval(args):
|
|
2738
|
+
"""Measured verdict for the fast-path (ADR-003). ALLOW only when every signal passes;
|
|
2739
|
+
ANY ambiguity -- no config, no git, unresolvable base -- is DENY with the reason named
|
|
2740
|
+
(fail-closed: "could not measure" never grants the shortcut). With --intent the verdict is
|
|
2741
|
+
recorded in the ledger as a first-class entry; without it this is a dry-run. A prior ALLOW
|
|
2742
|
+
followed by a DENY re-eval escalates through the EXISTING escalation machinery, so the
|
|
2743
|
+
derived phase flips to `escalated` and pr-ready is blocked until the human resolves."""
|
|
2744
|
+
ledger = _load(args.ledger)
|
|
2745
|
+
_repo_node(ledger, args.repo)
|
|
2746
|
+
fp = ledger["config"].get("defaults", {}).get("fast_path")
|
|
2747
|
+
intent = (args.intent or "").strip()
|
|
2748
|
+
signals, deny = [], []
|
|
2749
|
+
|
|
2750
|
+
def sig(name, value, threshold, source, ok):
|
|
2751
|
+
signals.append({"name": name, "value": value, "threshold": threshold,
|
|
2752
|
+
"source": source, "at": _now(), "ok": bool(ok)})
|
|
2753
|
+
if not ok:
|
|
2754
|
+
deny.append(name)
|
|
2755
|
+
|
|
2756
|
+
if not isinstance(fp, dict) or fp.get("enabled") is False:
|
|
2757
|
+
sig("configured", False, "defaults.fast_path present and enabled",
|
|
2758
|
+
"config.defaults.fast_path", False)
|
|
2759
|
+
else:
|
|
2760
|
+
repo_path = _repo_cfg(ledger, args.repo).get("path", ".")
|
|
2761
|
+
base, base_src = args.base, "--base"
|
|
2762
|
+
if base:
|
|
2763
|
+
probe = subprocess.run(["git", "rev-parse", "--verify", base + "^{commit}"],
|
|
2764
|
+
cwd=repo_path, capture_output=True, text=True)
|
|
2765
|
+
if probe.returncode != 0:
|
|
2766
|
+
base = None
|
|
2767
|
+
base_src = "--base (unresolvable)"
|
|
2768
|
+
else:
|
|
2769
|
+
for cand in ("origin/main", "main"):
|
|
2770
|
+
r = subprocess.run(["git", "merge-base", "HEAD", cand], cwd=repo_path,
|
|
2771
|
+
capture_output=True, text=True)
|
|
2772
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
2773
|
+
base, base_src = r.stdout.strip(), "merge-base HEAD %s" % cand
|
|
2774
|
+
break
|
|
2775
|
+
if not base:
|
|
2776
|
+
sig("base_ref", None, "a resolvable base commit",
|
|
2777
|
+
base_src if base_src != "--base" else "git merge-base HEAD origin/main|main",
|
|
2778
|
+
False)
|
|
2779
|
+
else:
|
|
2780
|
+
num = subprocess.run(["git", "diff", "--numstat", base], cwd=repo_path,
|
|
2781
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
2782
|
+
errors="replace")
|
|
2783
|
+
if num.returncode != 0:
|
|
2784
|
+
sig("git_diff", None, "a readable diff",
|
|
2785
|
+
"git diff --numstat %s" % base[:12], False)
|
|
2786
|
+
else:
|
|
2787
|
+
files, loc, binaries = [], 0, []
|
|
2788
|
+
for line in num.stdout.splitlines():
|
|
2789
|
+
parts = line.split("\t")
|
|
2790
|
+
if len(parts) != 3:
|
|
2791
|
+
continue
|
|
2792
|
+
a, d, path = parts
|
|
2793
|
+
path = path.strip().replace("\\", "/")
|
|
2794
|
+
# RENAMES arrive as one descriptor -- `old => new` or `pre{old => new}post`.
|
|
2795
|
+
# Matching the raw descriptor against the globs let a rename INTO db/ walk
|
|
2796
|
+
# straight past protected_paths (found by fresh review). Expand it and
|
|
2797
|
+
# count BOTH sides: renaming a file out of a protected area is as
|
|
2798
|
+
# gate-worthy as renaming one in.
|
|
2799
|
+
if " => " in path:
|
|
2800
|
+
m_ = re.match(r"^(.*)\{(.*) => (.*)\}(.*)$", path)
|
|
2801
|
+
if m_:
|
|
2802
|
+
pre, old_, new_, post = m_.groups()
|
|
2803
|
+
files.append((pre + old_ + post).replace("//", "/"))
|
|
2804
|
+
files.append((pre + new_ + post).replace("//", "/"))
|
|
2805
|
+
else:
|
|
2806
|
+
old_, new_ = path.split(" => ", 1)
|
|
2807
|
+
files.append(old_)
|
|
2808
|
+
files.append(new_)
|
|
2809
|
+
else:
|
|
2810
|
+
files.append(path)
|
|
2811
|
+
if a == "-" or d == "-":
|
|
2812
|
+
# binary diff: LOC is UNMEASURABLE, and an unmeasurable signal must
|
|
2813
|
+
# deny, not silently count as zero (fail-closed).
|
|
2814
|
+
binaries.append(path)
|
|
2815
|
+
loc += (int(a) if a.isdigit() else 0) + (int(d) if d.isdigit() else 0)
|
|
2816
|
+
# UNTRACKED files are invisible to `git diff` -- and a small change is very
|
|
2817
|
+
# often a NEW file. Not counting them would under-measure exactly the case
|
|
2818
|
+
# this gate exists for, so they count as files + added lines (fail-closed).
|
|
2819
|
+
unt = subprocess.run(["git", "ls-files", "--others", "--exclude-standard"],
|
|
2820
|
+
cwd=repo_path, capture_output=True, text=True,
|
|
2821
|
+
encoding="utf-8", errors="replace")
|
|
2822
|
+
for path in unt.stdout.splitlines():
|
|
2823
|
+
path = path.strip()
|
|
2824
|
+
if not path:
|
|
2825
|
+
continue
|
|
2826
|
+
files.append(path.replace("\\", "/"))
|
|
2827
|
+
try:
|
|
2828
|
+
with open(os.path.join(repo_path, path), "rb") as fh:
|
|
2829
|
+
loc += fh.read().count(b"\n")
|
|
2830
|
+
except OSError:
|
|
2831
|
+
loc += 1 # an unreadable new file still counts as a change
|
|
2832
|
+
src = "git diff --numstat %s (+ untracked)" % base[:12]
|
|
2833
|
+
max_f = int(fp.get("max_files_changed", 3))
|
|
2834
|
+
max_l = int(fp.get("max_loc_delta", 80))
|
|
2835
|
+
sig("max_files_changed", len(files), max_f, src, len(files) <= max_f)
|
|
2836
|
+
sig("max_loc_delta", loc, max_l, src, loc <= max_l)
|
|
2837
|
+
if binaries:
|
|
2838
|
+
sig("binary_files", binaries[:5], "none (LOC unmeasurable on binary)",
|
|
2839
|
+
src, False)
|
|
2840
|
+
pats = fp.get("protected_paths",
|
|
2841
|
+
["**/migrations/**", "**/*.appro" + "ved", "db/**"])
|
|
2842
|
+
hits = []
|
|
2843
|
+
for f in files:
|
|
2844
|
+
for g in pats:
|
|
2845
|
+
if _fp_glob_re(g).match(f):
|
|
2846
|
+
hits.append("%s (%s)" % (f, g))
|
|
2847
|
+
break
|
|
2848
|
+
sig("protected_paths", hits if hits else 0,
|
|
2849
|
+
"no touched file matches a protected glob", "config globs over " + src,
|
|
2850
|
+
not hits)
|
|
2851
|
+
# ADR-006: the golden-touched veto. OPT-IN -- absent flag, no signal at all
|
|
2852
|
+
# and behavior identical to 1.57.0+. DECLARED -- fail-closed: a missing or
|
|
2853
|
+
# empty mapping DENIES, because "could not measure" never grants a shortcut.
|
|
2854
|
+
if fp.get("forbid_when_golden_touched"):
|
|
2855
|
+
gcm = _load_golden_coverage(repo_path) # malformed -> exit 2, never silent
|
|
2856
|
+
gmap = (gcm or {}).get("goldens") or {}
|
|
2857
|
+
# Enumerate the goldens that actually EXIST. A manifest knowing only SOME
|
|
2858
|
+
# of them would otherwise assert "no touched file is covered by a golden"
|
|
2859
|
+
# about goldens it has never measured -- an ALLOW built on ignorance, which
|
|
2860
|
+
# is precisely the silent bypass this veto exists to prevent. Found by
|
|
2861
|
+
# fresh review and reproduced: 2 goldens in the tree, 1 in the map, a diff
|
|
2862
|
+
# touching the unmapped one's source -> ALLOW. Same glob shape cmd_golden_diff
|
|
2863
|
+
# already uses to locate goldens; no new mechanism.
|
|
2864
|
+
_sfx = ".appro" + "ved"
|
|
2865
|
+
_hits = set(glob.glob(os.path.join(repo_path, "**", "*" + _sfx),
|
|
2866
|
+
recursive=True))
|
|
2867
|
+
_hits |= set(glob.glob(os.path.join(repo_path, "**", "*" + _sfx + ".*"),
|
|
2868
|
+
recursive=True))
|
|
2869
|
+
_tree = set()
|
|
2870
|
+
for _p in _hits:
|
|
2871
|
+
if os.path.isfile(_p):
|
|
2872
|
+
_r = _gc_rel(os.path.abspath(_p), os.path.abspath(repo_path))
|
|
2873
|
+
if _r:
|
|
2874
|
+
_tree.add(_r)
|
|
2875
|
+
_unmapped = sorted(_tree - set(gmap))
|
|
2876
|
+
if _unmapped:
|
|
2877
|
+
# covers the manifest-absent case too: with goldens present and no
|
|
2878
|
+
# manifest, every one of them is unmapped.
|
|
2879
|
+
sig("golden_touched", _unmapped[:5],
|
|
2880
|
+
"every golden in the tree carries a measured map",
|
|
2881
|
+
GOLDEN_COVERAGE_FILE + " (missing or incomplete -- run "
|
|
2882
|
+
"golden-coverage for each golden)", False)
|
|
2883
|
+
elif not _tree:
|
|
2884
|
+
# No golden exists, so none can be touched. This is a MEASUREMENT
|
|
2885
|
+
# ("nothing to cover"), not an absence of one -- denying forever a
|
|
2886
|
+
# repo that has no goldens would be ceremony, not rigor.
|
|
2887
|
+
sig("golden_touched", 0, "no golden in the tree to be covered",
|
|
2888
|
+
"glob over " + repo_path, True)
|
|
2889
|
+
else:
|
|
2890
|
+
covered = {}
|
|
2891
|
+
_commits, _tools = set(), set()
|
|
2892
|
+
for _g, _e in gcm["goldens"].items():
|
|
2893
|
+
for _f in _e.get("files", []):
|
|
2894
|
+
covered.setdefault(_f, []).append(_g)
|
|
2895
|
+
if _e.get("captured_at_commit"):
|
|
2896
|
+
_commits.add(_e["captured_at_commit"][:8])
|
|
2897
|
+
if _e.get("tool"):
|
|
2898
|
+
_tools.add(_e["tool"])
|
|
2899
|
+
ghits = ["%s (golden: %s)" % (f, ", ".join(covered[f]))
|
|
2900
|
+
for f in files if f in covered]
|
|
2901
|
+
# provenance travels with the verdict (ADR-006: no freshness gate,
|
|
2902
|
+
# but every verdict says which capture it trusted)
|
|
2903
|
+
prov = "%s @ %s (%s)" % (
|
|
2904
|
+
GOLDEN_COVERAGE_FILE,
|
|
2905
|
+
",".join(sorted(_commits)) if _commits else "no commit recorded",
|
|
2906
|
+
", ".join(sorted(_tools)) if _tools else "no tool recorded")
|
|
2907
|
+
sig("golden_touched", ghits if ghits else 0,
|
|
2908
|
+
"no touched file is covered by a golden", prov, not ghits)
|
|
2909
|
+
|
|
2910
|
+
verdict = "ALLOW" if not deny else "DENY"
|
|
2911
|
+
# Escalation means "an ACTIVE fast-path run outgrew its thresholds" -- so it gates on the
|
|
2912
|
+
# LATEST entry for this repo being ALLOW, not on an ALLOW ever having existed. The first
|
|
2913
|
+
# version scanned all history, which misclassified every later unrelated DENY as ESCALATED
|
|
2914
|
+
# forever (found by fresh review, reproduced in ordinary sequential usage).
|
|
2915
|
+
_fp_prior = [e for e in ledger.get("fast_path", []) if e.get("repo") == args.repo]
|
|
2916
|
+
active_allow = bool(_fp_prior) and _fp_prior[-1].get("verdict") == "ALLOW"
|
|
2917
|
+
escalated = bool(intent and active_allow and verdict == "DENY"
|
|
2918
|
+
and "configured" not in deny)
|
|
2919
|
+
if escalated:
|
|
2920
|
+
verdict = "ESCALATED"
|
|
2921
|
+
out = {"repo": args.repo, "mode": "fast_path", "verdict": verdict,
|
|
2922
|
+
"intent": intent or None, "dry_run": not bool(intent), "signals": signals}
|
|
2923
|
+
|
|
2924
|
+
if intent:
|
|
2925
|
+
ledger.setdefault("fast_path", [])
|
|
2926
|
+
ledger["step_counter"] += 1
|
|
2927
|
+
entry = dict(out)
|
|
2928
|
+
entry.pop("dry_run", None)
|
|
2929
|
+
entry.update({"n": ledger["step_counter"], "at": _now()})
|
|
2930
|
+
ledger["fast_path"].append(entry)
|
|
2931
|
+
ledger["steps"].append({"n": ledger["step_counter"], "at": _now(),
|
|
2932
|
+
"kind": "fastpath-eval", "repo": args.repo})
|
|
2933
|
+
if escalated:
|
|
2934
|
+
# reuse the EXISTING escalation machinery: derived phase flips to `escalated`,
|
|
2935
|
+
# pr-ready is blocked and readiness capped until the human resolve-escalation --
|
|
2936
|
+
# after producing ADR + ACCEPTANCE, per ADR-003 (not a full discovery).
|
|
2937
|
+
ledger["step_counter"] += 1
|
|
2938
|
+
ledger["escalations"].append({
|
|
2939
|
+
"n": ledger["step_counter"], "at": _now(), "repo": args.repo,
|
|
2940
|
+
"reason": "fast-path thresholds exceeded mid-run: " + ", ".join(deny)
|
|
2941
|
+
+ " -- produce ADR + ACCEPTANCE, then resolve-escalation"})
|
|
2942
|
+
ledger["steps"].append({"n": ledger["step_counter"], "at": _now(),
|
|
2943
|
+
"kind": "escalation", "repo": args.repo})
|
|
2944
|
+
_save(args.ledger, ledger)
|
|
2945
|
+
|
|
2946
|
+
if args.json:
|
|
2947
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
2948
|
+
else:
|
|
2949
|
+
print("FASTPATH %s: %s%s" % (args.repo, verdict,
|
|
2950
|
+
"" if intent else " (dry-run: no --intent, nothing recorded)"))
|
|
2951
|
+
for s_ in signals:
|
|
2952
|
+
print(" %s %s: %s / %s [%s]" % ("ok" if s_["ok"] else "!!",
|
|
2953
|
+
s_["name"], s_["value"], s_["threshold"], s_["source"]))
|
|
2954
|
+
if escalated:
|
|
2955
|
+
print(" -> ESCALATED: pr-ready is blocked; produce ADR + ACCEPTANCE, "
|
|
2956
|
+
"then resolve-escalation (a recorded human act)")
|
|
2957
|
+
sys.exit(0 if verdict == "ALLOW" else 1)
|
|
2958
|
+
|
|
2959
|
+
|
|
2960
|
+
# --------------------------------------------------------------------------- #
|
|
2961
|
+
# spec-drift (ADR-005: mechanical drift detection, advisory -- NEVER a gate)
|
|
2962
|
+
# --------------------------------------------------------------------------- #
|
|
2963
|
+
|
|
2964
|
+
def _sd_governs(path):
|
|
2965
|
+
"""Parse the `governs:` glob list from a `---` frontmatter block at the top of a
|
|
2966
|
+
markdown file. Returns None when there is no frontmatter or no `governs:` key
|
|
2967
|
+
(UNMAPPED -- absence of a mapping is absence of measurement, not "no drift")."""
|
|
2968
|
+
try:
|
|
2969
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
2970
|
+
lines = fh.read().splitlines()
|
|
2971
|
+
except OSError:
|
|
2972
|
+
return None
|
|
2973
|
+
if not lines or lines[0].strip() != "---":
|
|
2974
|
+
return None
|
|
2975
|
+
globs, in_governs = None, False
|
|
2976
|
+
# scan runs to the CLOSING fence, not an arbitrary window -- a governs: key late in a
|
|
2977
|
+
# long frontmatter block must not silently read as UNMAPPED (fresh-review finding).
|
|
2978
|
+
for ln in lines[1:]:
|
|
2979
|
+
s = ln.strip()
|
|
2980
|
+
if s == "---":
|
|
2981
|
+
break
|
|
2982
|
+
if s.startswith("governs:"):
|
|
2983
|
+
rest = s[len("governs:"):].strip()
|
|
2984
|
+
if rest.startswith("[") and rest.endswith("]"):
|
|
2985
|
+
globs = [x.strip().strip("\x27\x22")
|
|
2986
|
+
for x in rest[1:-1].split(",") if x.strip()]
|
|
2987
|
+
in_governs = False
|
|
2988
|
+
elif rest:
|
|
2989
|
+
# bare scalar (`governs: src/**`) -- a plausible authoring shorthand;
|
|
2990
|
+
# dropping it silently would report a misleading "globs match nothing".
|
|
2991
|
+
globs, in_governs = [rest.strip("\x27\x22")], False
|
|
2992
|
+
else:
|
|
2993
|
+
globs, in_governs = [], True
|
|
2994
|
+
continue
|
|
2995
|
+
if in_governs:
|
|
2996
|
+
if s.startswith("- "):
|
|
2997
|
+
globs.append(s[2:].strip().strip("\x27\x22"))
|
|
2998
|
+
elif s and not ln.startswith((" ", "\t")):
|
|
2999
|
+
in_governs = False
|
|
3000
|
+
return globs
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
def _sd_iso(ct):
|
|
3004
|
+
import datetime as _dt
|
|
3005
|
+
return _dt.datetime.fromtimestamp(ct, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
3006
|
+
|
|
3007
|
+
|
|
3008
|
+
def _sd_last_commit_ct(repo_path, paths):
|
|
3009
|
+
"""Newest commit epoch touching any of `paths` (chunked: command lines have limits).
|
|
3010
|
+
None when no commit touches them (untracked)."""
|
|
3011
|
+
newest = None
|
|
3012
|
+
for i in range(0, len(paths), 200):
|
|
3013
|
+
r = subprocess.run(["git", "log", "-1", "--format=%ct", "--"] + paths[i:i + 200],
|
|
3014
|
+
cwd=repo_path, capture_output=True, text=True,
|
|
3015
|
+
encoding="utf-8", errors="replace")
|
|
3016
|
+
out = r.stdout.strip().splitlines()
|
|
3017
|
+
if r.returncode == 0 and out and out[0].strip().isdigit():
|
|
3018
|
+
ct = int(out[0].strip())
|
|
3019
|
+
newest = ct if newest is None else max(newest, ct)
|
|
3020
|
+
return newest
|
|
3021
|
+
|
|
3022
|
+
|
|
3023
|
+
def cmd_spec_drift(args):
|
|
3024
|
+
"""Advisory drift report (ADR-005): last commit date of each spec doc vs. the newest
|
|
3025
|
+
commit touching the files its `governs:` globs map to. SPEC_STALE when governed code
|
|
3026
|
+
outran the spec by more than max_lag_days; UNMAPPED when there is no (effective)
|
|
3027
|
+
mapping; UNTRACKED when the spec has no commit date to compare. This command NEVER
|
|
3028
|
+
gates: a stale spec is a prompt for a human conversation, not a blocked pipeline.
|
|
3029
|
+
Exit code 0 always."""
|
|
3030
|
+
ledger = _load(args.ledger)
|
|
3031
|
+
_repo_node(ledger, args.repo)
|
|
3032
|
+
cfg = ledger["config"].get("defaults", {}).get("spec_drift") or {}
|
|
3033
|
+
lag_days = int(args.max_lag_days if args.max_lag_days is not None
|
|
3034
|
+
else cfg.get("max_lag_days", 30))
|
|
3035
|
+
repo_path = _repo_cfg(ledger, args.repo).get("path", ".")
|
|
3036
|
+
|
|
3037
|
+
# The spec surface is fixed by ADR-005: the repo SPEC.md plus every ADR.
|
|
3038
|
+
spec_files = []
|
|
3039
|
+
if os.path.isfile(os.path.join(repo_path, "SPEC.md")):
|
|
3040
|
+
spec_files.append("SPEC.md")
|
|
3041
|
+
adr_dir = os.path.join(repo_path, "docs", "adr")
|
|
3042
|
+
if os.path.isdir(adr_dir):
|
|
3043
|
+
spec_files += sorted("docs/adr/" + f for f in os.listdir(adr_dir)
|
|
3044
|
+
if f.lower().endswith(".md"))
|
|
3045
|
+
|
|
3046
|
+
tracked = []
|
|
3047
|
+
ls = subprocess.run(["git", "ls-files"], cwd=repo_path, capture_output=True,
|
|
3048
|
+
text=True, encoding="utf-8", errors="replace")
|
|
3049
|
+
if ls.returncode == 0:
|
|
3050
|
+
tracked = [l.strip().replace("\\", "/") for l in ls.stdout.splitlines() if l.strip()]
|
|
3051
|
+
|
|
3052
|
+
results = []
|
|
3053
|
+
for spec in spec_files:
|
|
3054
|
+
governs = _sd_governs(os.path.join(repo_path, spec))
|
|
3055
|
+
row = {"file": spec, "governs": governs, "max_lag_days": lag_days}
|
|
3056
|
+
if governs is None:
|
|
3057
|
+
row.update({"verdict": "UNMAPPED", "reason": "no governs: frontmatter"})
|
|
3058
|
+
results.append(row)
|
|
3059
|
+
continue
|
|
3060
|
+
matched = []
|
|
3061
|
+
pats = [_fp_glob_re(g) for g in governs]
|
|
3062
|
+
for f in tracked:
|
|
3063
|
+
if f == spec:
|
|
3064
|
+
continue # a spec governing itself would always read fresh -- excluded
|
|
3065
|
+
if any(p.match(f) for p in pats):
|
|
3066
|
+
matched.append(f)
|
|
3067
|
+
if not matched:
|
|
3068
|
+
# A mapping that matches nothing measures nothing -- same absence, named.
|
|
3069
|
+
row.update({"verdict": "UNMAPPED", "reason": "globs match no tracked files"})
|
|
3070
|
+
results.append(row)
|
|
3071
|
+
continue
|
|
3072
|
+
spec_ct = _sd_last_commit_ct(repo_path, [spec])
|
|
3073
|
+
if spec_ct is None:
|
|
3074
|
+
row.update({"verdict": "UNTRACKED",
|
|
3075
|
+
"reason": "spec has no commit date to compare"})
|
|
3076
|
+
results.append(row)
|
|
3077
|
+
continue
|
|
3078
|
+
newest = _sd_last_commit_ct(repo_path, matched)
|
|
3079
|
+
lag_s = lag_days * 86400
|
|
3080
|
+
row.update({"governed_files": len(matched),
|
|
3081
|
+
"spec_committed_at": _sd_iso(spec_ct),
|
|
3082
|
+
"newest_governed_at": _sd_iso(newest) if newest is not None else None})
|
|
3083
|
+
if newest is not None and newest - spec_ct > lag_s:
|
|
3084
|
+
import datetime as _dt
|
|
3085
|
+
cutoff = _dt.datetime.fromtimestamp(
|
|
3086
|
+
spec_ct + lag_s, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+0000")
|
|
3087
|
+
newer = set()
|
|
3088
|
+
for i in range(0, len(matched), 200):
|
|
3089
|
+
r = subprocess.run(["git", "log", "--since", cutoff, "--name-only",
|
|
3090
|
+
"--format=", "--"] + matched[i:i + 200],
|
|
3091
|
+
cwd=repo_path, capture_output=True, text=True,
|
|
3092
|
+
encoding="utf-8", errors="replace")
|
|
3093
|
+
if r.returncode == 0:
|
|
3094
|
+
newer |= {l.strip().replace("\\", "/")
|
|
3095
|
+
for l in r.stdout.splitlines() if l.strip()}
|
|
3096
|
+
newer &= set(matched)
|
|
3097
|
+
row.update({"verdict": "SPEC_STALE",
|
|
3098
|
+
"lag_days_actual": round((newest - spec_ct) / 86400.0, 1),
|
|
3099
|
+
"newer_files": sorted(newer)[:20],
|
|
3100
|
+
"newer_files_total": len(newer)})
|
|
3101
|
+
else:
|
|
3102
|
+
row["verdict"] = "CLEAN"
|
|
3103
|
+
results.append(row)
|
|
3104
|
+
|
|
3105
|
+
out = {"repo": args.repo, "max_lag_days": lag_days, "results": results,
|
|
3106
|
+
"advisory": True}
|
|
3107
|
+
|
|
3108
|
+
# Latest-state record so the mirador can surface an advisory row. Advisory data,
|
|
3109
|
+
# not a step in the loop: no step_counter, no gate record, no readiness input.
|
|
3110
|
+
ledger["spec_drift"] = {"repo": args.repo, "at": _now(), "max_lag_days": lag_days,
|
|
3111
|
+
"results": results}
|
|
3112
|
+
_save(args.ledger, ledger)
|
|
3113
|
+
|
|
3114
|
+
if args.json:
|
|
3115
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
3116
|
+
else:
|
|
3117
|
+
print("SPEC-DRIFT %s (advisory, lag > %dd):" % (args.repo, lag_days))
|
|
3118
|
+
if not results:
|
|
3119
|
+
print(" no spec documents found (SPEC.md / docs/adr/*.md)")
|
|
3120
|
+
mark = {"SPEC_STALE": "!!", "CLEAN": "ok", "UNMAPPED": "--", "UNTRACKED": "--"}
|
|
3121
|
+
for r_ in results:
|
|
3122
|
+
line = " %s %s: %s" % (mark.get(r_["verdict"], "??"), r_["file"],
|
|
3123
|
+
r_["verdict"])
|
|
3124
|
+
if r_["verdict"] == "SPEC_STALE":
|
|
3125
|
+
line += " -- %d governed file(s) newer, e.g. %s" % (
|
|
3126
|
+
r_["newer_files_total"], ", ".join(r_["newer_files"][:3]))
|
|
3127
|
+
elif "reason" in r_:
|
|
3128
|
+
line += " (%s)" % r_["reason"]
|
|
3129
|
+
print(line)
|
|
3130
|
+
sys.exit(0)
|
|
3131
|
+
|
|
3132
|
+
|
|
3133
|
+
|
|
3134
|
+
# --------------------------------------------------------------------------- #
|
|
3135
|
+
# golden-coverage (ADR-006: the golden<->source mapping, DERIVED BY MEASUREMENT)
|
|
3136
|
+
# --------------------------------------------------------------------------- #
|
|
3137
|
+
|
|
3138
|
+
GOLDEN_COVERAGE_FILE = "golden.coverage.json"
|
|
3139
|
+
|
|
3140
|
+
|
|
3141
|
+
def _load_golden_coverage(root):
|
|
3142
|
+
"""Read the measured golden<->source manifest. Strict shape, mirroring
|
|
3143
|
+
_load_scrub_rules: a typo must NOT degrade into "no mapping" in silence, because
|
|
3144
|
+
under a declared veto that silence would GRANT the shortcut it exists to deny.
|
|
3145
|
+
Absent file -> None (the caller decides; with the veto declared, absent is DENY)."""
|
|
3146
|
+
path = os.path.join(root, GOLDEN_COVERAGE_FILE)
|
|
3147
|
+
if not os.path.isfile(path):
|
|
3148
|
+
return None
|
|
3149
|
+
try:
|
|
3150
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
3151
|
+
spec = json.load(fh)
|
|
3152
|
+
if not isinstance(spec, dict) or not isinstance(spec.get("goldens"), dict):
|
|
3153
|
+
raise TypeError('expected {"goldens": {"<golden path>": {"files": [...]}}}')
|
|
3154
|
+
for g, entry in spec["goldens"].items():
|
|
3155
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("files"), list):
|
|
3156
|
+
raise TypeError("golden %r has no files list" % g)
|
|
3157
|
+
for f in entry["files"]:
|
|
3158
|
+
if not isinstance(f, str):
|
|
3159
|
+
raise TypeError("golden %r maps a non-string file" % g)
|
|
3160
|
+
return spec
|
|
3161
|
+
except (json.JSONDecodeError, TypeError, KeyError) as exc:
|
|
3162
|
+
print("[qa_ledger] %s invalid (%s) - the golden mapping is not skipped in "
|
|
3163
|
+
"silence: fix the file or delete it." % (path, exc), file=sys.stderr)
|
|
3164
|
+
sys.exit(2)
|
|
3165
|
+
|
|
3166
|
+
|
|
3167
|
+
def _gc_rel(path, root):
|
|
3168
|
+
# realpath BOTH sides before comparing. On Windows a temp dir under a username longer
|
|
3169
|
+
# than 8 chars is reported in 8.3 short form (RUNNER~1) by one side and long form by the
|
|
3170
|
+
# other; relpath then yields "../.." and a file INSIDE the repo is filtered out as
|
|
3171
|
+
# outside it -- silently shrinking the map. Invisible on a machine whose username does
|
|
3172
|
+
# not mangle (which is why local Windows was green and Windows CI was not).
|
|
3173
|
+
try:
|
|
3174
|
+
rel = os.path.relpath(os.path.realpath(path), os.path.realpath(root))
|
|
3175
|
+
except ValueError: # different drive on Windows -- outside the repo either way
|
|
3176
|
+
return None
|
|
3177
|
+
rel = rel.replace("\\", "/")
|
|
3178
|
+
return None if rel.startswith("../") else rel
|
|
3179
|
+
|
|
3180
|
+
|
|
3181
|
+
def cmd_golden_coverage(args):
|
|
3182
|
+
"""Record the MEASURED source files a golden's harness exercises (ADR-006).
|
|
3183
|
+
|
|
3184
|
+
The harnesses drive their subject through subprocess, so instrumenting only the parent
|
|
3185
|
+
measures nothing: coverage is injected into EVERY python the harness spawns via a
|
|
3186
|
+
sitecustomize on PYTHONPATH plus COVERAGE_PROCESS_START -- the documented multiprocess
|
|
3187
|
+
technique, and the same PYTHONPATH-injection shape this repo's fault tests already use.
|
|
3188
|
+
|
|
3189
|
+
coverage.py is an optional CAPTURE-time dependency (the engine stays stdlib-only at
|
|
3190
|
+
runtime). Absent, this writes NOTHING and exits 2: an empty map would read as
|
|
3191
|
+
"this golden covers nothing", which is the one lie that would let the veto pass."""
|
|
3192
|
+
try:
|
|
3193
|
+
import coverage
|
|
3194
|
+
except ImportError:
|
|
3195
|
+
print("[qa_ledger] coverage.py is not installed - refusing to write a map that was "
|
|
3196
|
+
"not measured (an empty map reads as 'covers nothing'). pip install coverage",
|
|
3197
|
+
file=sys.stderr)
|
|
3198
|
+
sys.exit(2)
|
|
3199
|
+
|
|
3200
|
+
root = os.path.abspath(args.dir or ".")
|
|
3201
|
+
harness = os.path.abspath(args.harness)
|
|
3202
|
+
if not os.path.isfile(harness):
|
|
3203
|
+
print("[qa_ledger] harness not found: %s" % harness, file=sys.stderr)
|
|
3204
|
+
sys.exit(2)
|
|
3205
|
+
|
|
3206
|
+
tmp = tempfile.mkdtemp(prefix="uscha-gc-")
|
|
3207
|
+
try:
|
|
3208
|
+
data_file = os.path.join(tmp, ".coverage")
|
|
3209
|
+
rc = os.path.join(tmp, "cov.rc")
|
|
3210
|
+
with open(rc, "w", encoding="utf-8") as fh:
|
|
3211
|
+
fh.write("[run]\nparallel = True\ndata_file = %s\n"
|
|
3212
|
+
% data_file.replace("\\", "/"))
|
|
3213
|
+
with open(os.path.join(tmp, "sitecustomize.py"), "w", encoding="utf-8") as fh:
|
|
3214
|
+
fh.write("import coverage\ncoverage.process_startup()\n")
|
|
3215
|
+
|
|
3216
|
+
env = dict(os.environ)
|
|
3217
|
+
env["COVERAGE_PROCESS_START"] = rc
|
|
3218
|
+
env["PYTHONPATH"] = tmp + os.pathsep + env.get("PYTHONPATH", "")
|
|
3219
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
3220
|
+
r = subprocess.run([sys.executable, harness], cwd=root, env=env,
|
|
3221
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
3222
|
+
errors="replace")
|
|
3223
|
+
if r.returncode != 0:
|
|
3224
|
+
print("[qa_ledger] the harness failed (exit %d) - no map recorded from a run "
|
|
3225
|
+
"that did not complete:\n%s" % (r.returncode, (r.stderr or "")[-1500:]),
|
|
3226
|
+
file=sys.stderr)
|
|
3227
|
+
sys.exit(2)
|
|
3228
|
+
|
|
3229
|
+
cov = coverage.Coverage(data_file=data_file)
|
|
3230
|
+
try:
|
|
3231
|
+
cov.combine()
|
|
3232
|
+
cov.save()
|
|
3233
|
+
except Exception as exc:
|
|
3234
|
+
# Never silent: a PARTIAL combine yields an incomplete-but-non-empty file list,
|
|
3235
|
+
# which slips past the empty-map guard below and records a map that under-reports
|
|
3236
|
+
# what the golden covers. The empty case still exits 2; this one is announced so a
|
|
3237
|
+
# human sees the map may be short (fresh-review finding).
|
|
3238
|
+
print("[qa_ledger] coverage combine reported: %s - the map below may be "
|
|
3239
|
+
"incomplete; re-run before trusting it." % exc, file=sys.stderr)
|
|
3240
|
+
measured = sorted(cov.get_data().measured_files())
|
|
3241
|
+
harness_rel = _gc_rel(harness, root)
|
|
3242
|
+
files = []
|
|
3243
|
+
for m in measured:
|
|
3244
|
+
rel = _gc_rel(os.path.abspath(m), root)
|
|
3245
|
+
# the harness measures the SUBJECT, not itself; sitecustomize is our scaffolding
|
|
3246
|
+
if not rel or rel == harness_rel or rel.endswith("/sitecustomize.py"):
|
|
3247
|
+
continue
|
|
3248
|
+
files.append(rel)
|
|
3249
|
+
files = sorted(set(files))
|
|
3250
|
+
finally:
|
|
3251
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
3252
|
+
|
|
3253
|
+
if not files:
|
|
3254
|
+
print("[qa_ledger] the run measured no source file inside %s - refusing to record "
|
|
3255
|
+
"an empty map (it would read as 'covers nothing')." % root, file=sys.stderr)
|
|
3256
|
+
sys.exit(2)
|
|
3257
|
+
|
|
3258
|
+
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root,
|
|
3259
|
+
capture_output=True, text=True)
|
|
3260
|
+
commit = head.stdout.strip() if head.returncode == 0 else None
|
|
3261
|
+
golden_rel = _gc_rel(os.path.abspath(args.golden), root) or args.golden
|
|
3262
|
+
|
|
3263
|
+
path = os.path.join(root, GOLDEN_COVERAGE_FILE)
|
|
3264
|
+
manifest = _load_golden_coverage(root) or {"goldens": {}}
|
|
3265
|
+
manifest["goldens"][golden_rel] = {
|
|
3266
|
+
"harness": harness_rel,
|
|
3267
|
+
"files": files,
|
|
3268
|
+
"captured_at": _now(),
|
|
3269
|
+
"captured_at_commit": commit,
|
|
3270
|
+
"tool": "coverage.py " + coverage.__version__,
|
|
3271
|
+
}
|
|
3272
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
3273
|
+
json.dump(manifest, fh, indent=2, ensure_ascii=False, sort_keys=True)
|
|
3274
|
+
fh.write("\n")
|
|
3275
|
+
|
|
3276
|
+
if args.json:
|
|
3277
|
+
print(json.dumps(manifest["goldens"][golden_rel], indent=2, ensure_ascii=False))
|
|
3278
|
+
else:
|
|
3279
|
+
print("GOLDEN-COVERAGE %s: %d source file(s) measured -> %s"
|
|
3280
|
+
% (golden_rel, len(files), GOLDEN_COVERAGE_FILE))
|
|
3281
|
+
for f in files[:20]:
|
|
3282
|
+
print(" " + f)
|
|
3283
|
+
|
|
3284
|
+
|
|
3285
|
+
|
|
2702
3286
|
def cmd_escalate(args):
|
|
2703
3287
|
ledger = _load(args.ledger)
|
|
2704
3288
|
_repo_node(ledger, args.repo)
|
|
@@ -3726,6 +4310,15 @@ def cmd_dashboard(args):
|
|
|
3726
4310
|
"snapshots": snapshots,
|
|
3727
4311
|
"evidence": ev, # receipts (kit 1.50.0): facts with paths + timestamps
|
|
3728
4312
|
}
|
|
4313
|
+
# fast-path (ADR-003): latest verdict per repo, straight from the ledger. The key exists
|
|
4314
|
+
# ONLY when entries exist: an unconfigured/unused project keeps the exact prior schema
|
|
4315
|
+
# ("absent block = behavior identical" is a measured claim, and an unconditional key was
|
|
4316
|
+
# a schema change that contradicted it -- found by fresh review).
|
|
4317
|
+
if ledger.get("fast_path"):
|
|
4318
|
+
out["fast_path"] = {r: [e for e in ledger["fast_path"] if e.get("repo") == r][-1]
|
|
4319
|
+
for r in {e.get("repo") for e in ledger["fast_path"]}}
|
|
4320
|
+
if ledger.get("spec_drift"):
|
|
4321
|
+
out["spec_drift"] = ledger["spec_drift"]
|
|
3729
4322
|
if getattr(args, "json", False):
|
|
3730
4323
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
3731
4324
|
return
|
|
@@ -3897,6 +4490,24 @@ def cmd_readiness(args):
|
|
|
3897
4490
|
caps["blocker_critical"], "blocker_critical"))
|
|
3898
4491
|
if any(not e.get("resolved_at") for e in ledger.get("escalations", [])):
|
|
3899
4492
|
caps_active.append(("unresolved escalation", caps["escalation"], "escalation"))
|
|
4493
|
+
# AC-FP-06 (ADR-003): an active fast-path run still owes an asserting test. The latest
|
|
4494
|
+
# fast_path entry per repo being ALLOW, with NO measured test execution recorded at or
|
|
4495
|
+
# after it, caps readiness -- reusing the escalation cap value, per the existing cap
|
|
4496
|
+
# mechanics rather than inventing a new one.
|
|
4497
|
+
fp_cfg = ledger["config"].get("defaults", {}).get("fast_path")
|
|
4498
|
+
if isinstance(fp_cfg, dict) and fp_cfg.get("require_asserting_test", True):
|
|
4499
|
+
for _fp_repo in {e.get("repo") for e in ledger.get("fast_path", [])}:
|
|
4500
|
+
entries = [e for e in ledger["fast_path"] if e.get("repo") == _fp_repo]
|
|
4501
|
+
last = entries[-1] if entries else None
|
|
4502
|
+
if not last or last.get("verdict") != "ALLOW":
|
|
4503
|
+
continue
|
|
4504
|
+
node_fp = ledger["repos"].get(_fp_repo, {})
|
|
4505
|
+
tested = any((s.get("tests", {}).get("executed", 0) or 0) > 0
|
|
4506
|
+
and s.get("at", "") >= last.get("at", "")
|
|
4507
|
+
for s in node_fp.get("snapshots", []))
|
|
4508
|
+
if not tested:
|
|
4509
|
+
caps_active.append(("fast-path active in %s without a measured asserting test"
|
|
4510
|
+
% _fp_repo, caps["escalation"], "escalation"))
|
|
3900
4511
|
if spec_doubts_open:
|
|
3901
4512
|
caps_active.append((f"{len(spec_doubts_open)} spec-doubt open",
|
|
3902
4513
|
caps["escalation"], "escalation"))
|
|
@@ -6403,6 +7014,31 @@ def build_parser():
|
|
|
6403
7014
|
pe.add_argument("--reason", required=True)
|
|
6404
7015
|
pe.set_defaults(func=cmd_escalate)
|
|
6405
7016
|
|
|
7017
|
+
pfp = sub.add_parser("fastpath-eval",
|
|
7018
|
+
help="measured fast-path verdict (ADR-003): ALLOW/DENY from the real diff; --intent records it")
|
|
7019
|
+
pfp.add_argument("--ledger", default="QA-LEDGER.json")
|
|
7020
|
+
pfp.add_argument("--repo", required=True)
|
|
7021
|
+
pfp.add_argument("--base", help="base commit/ref; default merge-base HEAD origin/main (fallback main)")
|
|
7022
|
+
pfp.add_argument("--intent", help="one sentence, what and why; without it the call is a dry-run")
|
|
7023
|
+
pfp.add_argument("--json", action="store_true")
|
|
7024
|
+
pfp.set_defaults(func=cmd_fastpath_eval)
|
|
7025
|
+
|
|
7026
|
+
pgc = sub.add_parser("golden-coverage",
|
|
7027
|
+
help="record the MEASURED source files a golden's harness exercises (ADR-006)")
|
|
7028
|
+
pgc.add_argument("--harness", required=True, help="script that drives the subject")
|
|
7029
|
+
pgc.add_argument("--golden", required=True, help="the golden this map belongs to")
|
|
7030
|
+
pgc.add_argument("--dir", default=".", help="repo root holding " + GOLDEN_COVERAGE_FILE)
|
|
7031
|
+
pgc.add_argument("--json", action="store_true")
|
|
7032
|
+
pgc.set_defaults(func=cmd_golden_coverage)
|
|
7033
|
+
|
|
7034
|
+
psd = sub.add_parser("spec-drift",
|
|
7035
|
+
help="advisory spec-vs-code drift from git commit dates (ADR-005); never gates, exit 0 always")
|
|
7036
|
+
psd.add_argument("--ledger", default="QA-LEDGER.json")
|
|
7037
|
+
psd.add_argument("--repo", required=True)
|
|
7038
|
+
psd.add_argument("--max-lag-days", type=int, default=None,
|
|
7039
|
+
help="override defaults.spec_drift.max_lag_days (default 30)")
|
|
7040
|
+
psd.add_argument("--json", action="store_true")
|
|
7041
|
+
psd.set_defaults(func=cmd_spec_drift)
|
|
6406
7042
|
pre = sub.add_parser("resolve-escalation",
|
|
6407
7043
|
help="close open escalations for a repo (recorded event; "
|
|
6408
7044
|
"lifts the readiness cap)")
|