@andresmassello/uscha 1.92.0 → 1.93.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 +2 -2
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +10 -2
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +273 -25
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +1 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/skills/uscha-devloop/SKILL.md +10 -2
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +273 -25
- package/uscha-kit/uscha.config.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
|
|
|
40
40
|
runtime dependencies). The npm package is a thin router; the canonical installer is
|
|
41
41
|
`uscha-kit/install-uscha.py`.
|
|
42
42
|
|
|
43
|
-
**Kit v1.
|
|
43
|
+
**Kit v1.93.1** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
|
|
44
44
|
[changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG.md)
|
|
45
45
|
(the per-release changelogs live in the repo, not in the npm tarball)
|
|
46
46
|
|
|
@@ -85,7 +85,7 @@ automatic tool can perform: a human verdict.
|
|
|
85
85
|
from the compiled code: 0.828 measured (12 archetypes) — names AND behaviour
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
**What each arrow is, in the engine (kit 1.
|
|
88
|
+
**What each arrow is, in the engine (kit 1.93.1, 53 subcommands, all measured):**
|
|
89
89
|
|
|
90
90
|
| Leg | Subcommands | What it establishes |
|
|
91
91
|
|---|---|---|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andresmassello/uscha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.93.1",
|
|
4
4
|
"description": "Spec-driven development for LLM coding agents: 9 skills + a stdlib evidence engine. Facts block, guesses advise; the human approves.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Andres Massello",
|
|
@@ -466,8 +466,10 @@ python3 $QL check-terminado # 0 = sealed · 1 = broken · 2 = UNMEASURE
|
|
|
466
466
|
```
|
|
467
467
|
|
|
468
468
|
It recomputes, from the ledger and the tree, whether the recorded evidence still belongs to
|
|
469
|
-
the code on disk: the repo subtree clean,
|
|
470
|
-
|
|
469
|
+
the code on disk: the repo subtree clean, no source-relevant change since the last
|
|
470
|
+
snapshot's commit (a non-source difference — docs, the ledger, the reports themselves —
|
|
471
|
+
seals with a `note` naming what moved; ADR-039), every ingested report still hashing to
|
|
472
|
+
what was recorded. **Exit 1** — do not declare TERMINADO:
|
|
471
473
|
re-snapshot on the CURRENT state (`snapshot --repo <REPO> --phase post`) and record why the
|
|
472
474
|
seal broke. **Exit 2** — the seal is UNMEASURED (no git, or no snapshot recorded): say so
|
|
473
475
|
plainly; an answer nobody could measure is not a TERMINADO either.
|
|
@@ -556,6 +558,12 @@ up as `narrated_only` and does NOT close (measured beats narrated, per criterion
|
|
|
556
558
|
A JUnit report older than the repo's source code is treated as STALE (the code changed
|
|
557
559
|
after the tests ran) and is DISCARDED — a criterion backed only by stale reports stays
|
|
558
560
|
UNMEASURED, never falsely closed or vetoed (kit 1.31.0; surfaced as `stale_reports`).
|
|
561
|
+
Since 1.93.0 (ADR-039) that clock rule is not the only one: a report the clock rejects is still
|
|
562
|
+
FRESH when its `sha256` matches what the last `snapshot` recorded for it AND git shows no
|
|
563
|
+
source-relevant change since that snapshot's commit — so a clone, a `git worktree add`, a merge
|
|
564
|
+
or a CI checkout, which re-date every file without changing a byte, no longer un-measure green
|
|
565
|
+
evidence. Either rule suffices; with no git, no recorded commit or no recorded hash the clock
|
|
566
|
+
rule decides alone, exactly as before.
|
|
559
567
|
So: when you write the tests for a criterion, put its AC-n in the test name; run
|
|
560
568
|
`spec-check --acceptance ACCEPTANCE.md` up front (zero traceable criteria / duplicate
|
|
561
569
|
IDs block as structural FACTS). Files without IDs fall back to the checkbox ratio
|
|
@@ -642,7 +642,7 @@ _SRC_EXT = {
|
|
|
642
642
|
".java", ".kt", ".kts", ".scala", ".groovy", ".py", ".js", ".jsx",
|
|
643
643
|
".ts", ".tsx", ".mjs", ".cjs", ".go", ".rs", ".cs", ".vb", ".fs",
|
|
644
644
|
".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".swift", ".m", ".mm",
|
|
645
|
-
".rb", ".php", ".dart", ".gradle",
|
|
645
|
+
".rb", ".php", ".dart", ".gradle", ".hh", ".hxx",
|
|
646
646
|
}
|
|
647
647
|
_SRC_SKIP_DIRS = SKIP_DIRS | {"reports", "Pods", ".vs"}
|
|
648
648
|
|
|
@@ -701,7 +701,127 @@ def _source_newest_mtime(repo_path):
|
|
|
701
701
|
return newest["mtime_ns"] / 1_000_000_000 if newest else 0.0
|
|
702
702
|
|
|
703
703
|
|
|
704
|
-
|
|
704
|
+
# kit 1.93.0 (ADR-039): the clock is not the only honest freshness rule. A fresh clone, a
|
|
705
|
+
# `git worktree add`, a merge or a CI checkout re-date every source file without changing a
|
|
706
|
+
# byte of source, and rule (a) then throws away evidence that IS current -- the day INV-T1
|
|
707
|
+
# shipped, the release machine read 0/195 for exactly that reason. Rule (b) asks the question
|
|
708
|
+
# a timestamp cannot: has any SOURCE file changed since the commit the last snapshot was
|
|
709
|
+
# measured at, and is the report still the exact file that was ingested? Either rule
|
|
710
|
+
# suffices; both are measured. No git, no recorded commit, no recorded hash -> (a) alone,
|
|
711
|
+
# byte-identical to 1.92.0.
|
|
712
|
+
def _git_path_list(text):
|
|
713
|
+
"""Paths from `git diff --name-only`: C-quotes stripped, forward slashes. An escape
|
|
714
|
+
inside a quoted path is left as-is -- that can only fail to MATCH, which withholds
|
|
715
|
+
freshness rather than granting it (the safe direction, same posture as `_porcelain_paths`)."""
|
|
716
|
+
out = []
|
|
717
|
+
for line in (text or "").splitlines():
|
|
718
|
+
p = line.strip()
|
|
719
|
+
if len(p) >= 2 and p[0] == '"' and p[-1] == '"':
|
|
720
|
+
p = p[1:-1]
|
|
721
|
+
if p:
|
|
722
|
+
out.append(p.replace("\\", "/"))
|
|
723
|
+
return out
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# What rule (b) and the seal treat as "a change that invalidates a test run", BEYOND the source
|
|
727
|
+
# code itself: the build and harness files that decide WHAT the suite runs and HOW. A commit that
|
|
728
|
+
# rewrites `smoke-engine.sh`, a `pom.xml` or a CI workflow changes the meaning of a green report
|
|
729
|
+
# just as surely as editing the code under test, and a seal that tolerated it would be tolerating
|
|
730
|
+
# the one edit a green board cannot survive. Deliberately NOT here, and named in ADR-039 and
|
|
731
|
+
# SPEC 4: `.md`, `.json`, `.xml`, `.txt` -- docs, changelogs, the ledger and the JUnit reports
|
|
732
|
+
# themselves are non-source BY CONSTRUCTION, which is the whole point of the tolerant seal.
|
|
733
|
+
# (`package.json` and `pyproject.toml` are the named exceptions: they carry the test command.)
|
|
734
|
+
_HARNESS_EXT = {".sh", ".bash", ".ps1", ".yml", ".yaml", ".toml", ".sql", ".tf",
|
|
735
|
+
".gradle", ".cmake"}
|
|
736
|
+
_HARNESS_NAMES = {"makefile", "pom.xml", "build.gradle", "package.json",
|
|
737
|
+
"pyproject.toml", "setup.py", "cargo.toml", "go.mod"}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _src_relevant(paths, repo_type=None):
|
|
741
|
+
"""Only the paths that can invalidate a test run: the engine's own source-extension set --
|
|
742
|
+
a true SUPERSET of what rule (a) looked at, because provenance narrows the clock rule to
|
|
743
|
+
`SOURCE_EXT[repo_type]` and that set is unioned in here rather than assumed to be inside
|
|
744
|
+
`_SRC_EXT` (`.hh`/`.hxx` were in one and not the other until 1.93.0) -- plus the build and
|
|
745
|
+
harness files above. The same generated/build/report/vendor trees `_newest_source` prunes
|
|
746
|
+
are pruned here. ONE definition for rule (b) and for the seal (ADR-039): widening it widens
|
|
747
|
+
both at once, never one."""
|
|
748
|
+
allowed = _SRC_EXT | SOURCE_EXT.get(repo_type or "", set()) | _HARNESS_EXT
|
|
749
|
+
out = []
|
|
750
|
+
for path in paths:
|
|
751
|
+
parts = path.split("/")
|
|
752
|
+
if any(d in _SRC_SKIP_DIRS or d.startswith("cmake-build-") for d in parts[:-1]):
|
|
753
|
+
continue
|
|
754
|
+
name = parts[-1].lower()
|
|
755
|
+
if os.path.splitext(name)[1] in allowed or name in _HARNESS_NAMES:
|
|
756
|
+
out.append(path)
|
|
757
|
+
return out
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _content_state(repo_path, repo_type, last_snapshot):
|
|
761
|
+
"""What rule (b) needs, measured ONCE per repo instead of once per report: the commit the
|
|
762
|
+
last snapshot was taken at (`origin.commit`, ADR-007), the sha256 that snapshot recorded
|
|
763
|
+
per report path (ADR-038), and every source-relevant path that changed between that commit
|
|
764
|
+
and the tree on disk -- the committed diff AND the working tree, because a source edit that
|
|
765
|
+
is not committed yet is exactly as invalidating as one that is.
|
|
766
|
+
|
|
767
|
+
`None` whenever the question cannot be answered: no snapshot, no recorded commit, no
|
|
768
|
+
recorded hash (a pre-1.92.0 ledger), no git. Absence leaves rule (a) alone; it never
|
|
769
|
+
grants freshness."""
|
|
770
|
+
snap = last_snapshot if isinstance(last_snapshot, dict) else {}
|
|
771
|
+
commit = (snap.get("origin") or {}).get("commit")
|
|
772
|
+
if not commit:
|
|
773
|
+
return None
|
|
774
|
+
# THE BASELINE MUST NOT LAUNDER ITSELF (fixed before 1.93.0 shipped, found in blind review).
|
|
775
|
+
# `snapshot` records the tree AS IT IS: taken on a repo whose code moved and whose tests were
|
|
776
|
+
# never re-run, it faithfully writes `freshness: stale` -- and then, at read time, that same
|
|
777
|
+
# record would say "the report hashes to what I ingested, and nothing changed since MY commit"
|
|
778
|
+
# (its commit is HEAD, its hash was taken over that very file) and turn its own UNMEASURED
|
|
779
|
+
# verdict into a GREEN. A snapshot can only anchor evidence it judged CURRENT: a stale
|
|
780
|
+
# verdict, or a report the record itself marked stale, is no anchor at all.
|
|
781
|
+
if ((snap.get("tests") or {}).get("freshness") or {}).get("status") == "stale":
|
|
782
|
+
return None
|
|
783
|
+
# the RECORD, not just the hash string: `_evidence_hash_matches` needs the `sha256_eol`
|
|
784
|
+
# marker beside it to know whether the compatibility comparison is still on offer
|
|
785
|
+
hashes = {r["path"]: r
|
|
786
|
+
for r in ((snap.get("tests") or {}).get("reports") or [])
|
|
787
|
+
if isinstance(r, dict) and r.get("path") and r.get("sha256")
|
|
788
|
+
and r.get("fresh_by") != "stale"}
|
|
789
|
+
if not hashes:
|
|
790
|
+
return None
|
|
791
|
+
diff = _seal_git(repo_path, "-c", "core.quotepath=false", "diff", "--name-only",
|
|
792
|
+
commit, "HEAD", "--", ".")
|
|
793
|
+
st = _seal_git(repo_path, "-c", "core.quotepath=false", "status", "--porcelain",
|
|
794
|
+
"-uall", "--", ".")
|
|
795
|
+
if diff is None or st is None:
|
|
796
|
+
return None
|
|
797
|
+
changed = _src_relevant(_git_path_list(diff.stdout) + _porcelain_paths(st.stdout),
|
|
798
|
+
repo_type)
|
|
799
|
+
return {"commit": commit, "hashes": hashes, "src_changed": sorted(set(changed))}
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _report_fresh(repo_path, report_path, clock_fresh, content_state):
|
|
803
|
+
"""'clock' | 'content' | None (stale) for ONE report -- the single derivation both the
|
|
804
|
+
snapshot record (`_test_evidence_provenance`) and the tag ingest (`_ac_tags`) read, so the
|
|
805
|
+
two surfaces cannot disagree about which report is current.
|
|
806
|
+
|
|
807
|
+
The CLOCK verdict is supplied by the caller on purpose: the two callers have always applied
|
|
808
|
+
it with different tolerances, and both stay byte-identical to 1.92.0 rather than being
|
|
809
|
+
quietly unified here."""
|
|
810
|
+
if clock_fresh:
|
|
811
|
+
return "clock"
|
|
812
|
+
if not content_state or content_state["src_changed"]:
|
|
813
|
+
return None
|
|
814
|
+
# the SAME relpath form the snapshot recorded the report under. No realpath on either
|
|
815
|
+
# side: both come from the same `repo_path` string, so the 8.3 mismatch of 2026-08-02
|
|
816
|
+
# cannot arise here, while normalizing one side would stop matching the recorded key.
|
|
817
|
+
rel = os.path.relpath(report_path, repo_path).replace("\\", "/")
|
|
818
|
+
recorded = content_state["hashes"].get(rel)
|
|
819
|
+
if _evidence_hash_matches(report_path, recorded) is not True:
|
|
820
|
+
return None
|
|
821
|
+
return "content"
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _test_evidence_provenance(repo_path, repo_type, last_snapshot=None):
|
|
705
825
|
"""Explain which JUnit reports back a snapshot and whether they are newer
|
|
706
826
|
than relevant source/test files. No discoverable source is explicitly
|
|
707
827
|
uncorrelated-but-usable to preserve synthetic/report-only workflows.
|
|
@@ -724,7 +844,10 @@ def _test_evidence_provenance(repo_path, repo_type):
|
|
|
724
844
|
"mtime_ns": mtime_ns,
|
|
725
845
|
"mtime": datetime.fromtimestamp(
|
|
726
846
|
mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
|
|
727
|
-
"sha256":
|
|
847
|
+
"sha256": _sha256_evidence(path),
|
|
848
|
+
# the marker says HOW the hash above was taken, so a reader never has to guess
|
|
849
|
+
# (and 1.93.0-and-older records, which have no marker, keep their compatibility)
|
|
850
|
+
"sha256_eol": "lf",
|
|
728
851
|
})
|
|
729
852
|
if not reports:
|
|
730
853
|
status = "not-applicable" if repo_type == "flutter" else "missing"
|
|
@@ -745,15 +868,29 @@ def _test_evidence_provenance(repo_path, repo_type):
|
|
|
745
868
|
"tolerance_ns": _JUNIT_FRESHNESS_TOLERANCE_NS,
|
|
746
869
|
}
|
|
747
870
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
871
|
+
# ADR-039: the clock verdict first (unchanged, tolerance included), then rule (b) for the
|
|
872
|
+
# reports the clock rejects. `fresh_by` says WHICH rule answered for each report, so a
|
|
873
|
+
# board that reads fresh can always be asked why.
|
|
874
|
+
content_state = _content_state(repo_path, repo_type, last_snapshot)
|
|
875
|
+
stale_reports = []
|
|
876
|
+
for report in reports:
|
|
877
|
+
clock_fresh = (newest["mtime_ns"]
|
|
878
|
+
<= report["mtime_ns"] + _JUNIT_FRESHNESS_TOLERANCE_NS)
|
|
879
|
+
how = _report_fresh(repo_path, os.path.join(repo_path, report["path"]),
|
|
880
|
+
clock_fresh, content_state)
|
|
881
|
+
report["fresh_by"] = how or "stale"
|
|
882
|
+
if how is None:
|
|
883
|
+
stale_reports.append(report)
|
|
884
|
+
by_content = [r["path"] for r in reports if r["fresh_by"] == "content"]
|
|
752
885
|
if stale_reports:
|
|
753
886
|
paths = ", ".join(report["path"] for report in stale_reports)
|
|
754
887
|
reason = (f"source/test {newest['path']} is newer than JUnit report(s) "
|
|
755
888
|
f"{paths}")
|
|
756
889
|
status = "stale"
|
|
890
|
+
elif by_content:
|
|
891
|
+
reason = ("content unchanged since %s: %s"
|
|
892
|
+
% (content_state["commit"][:8], ", ".join(by_content)))
|
|
893
|
+
status = "fresh"
|
|
757
894
|
else:
|
|
758
895
|
reason = "selected JUnit report(s) are current relative to source/test files"
|
|
759
896
|
status = "fresh"
|
|
@@ -804,7 +941,7 @@ def _ac_tag_ids(name):
|
|
|
804
941
|
return ids
|
|
805
942
|
|
|
806
943
|
|
|
807
|
-
def _ac_tags(repo_path, repo_type):
|
|
944
|
+
def _ac_tags(repo_path, repo_type, last_snapshot=None):
|
|
808
945
|
"""Tags AC-n leidos de los NOMBRES de testcase en los reportes JUnit que el
|
|
809
946
|
engine ya ingiere. Devuelve (tags, stale) donde tags = {'AC-n': {'green': x,
|
|
810
947
|
'red': y}} (o 'AC-FAM-n' para la forma con familia, ADR-036 / kit 1.87.0)
|
|
@@ -822,14 +959,21 @@ def _ac_tags(repo_path, repo_type):
|
|
|
822
959
|
tags = {}
|
|
823
960
|
stale = []
|
|
824
961
|
newest_src = _source_newest_mtime(repo_path)
|
|
962
|
+
# ADR-039 (kit 1.93.0): rule (b) applies HERE too, not only in the snapshot record. A
|
|
963
|
+
# report the clock rejects but whose content is unchanged since the snapshot commit is
|
|
964
|
+
# FRESH, and discarding it here while calling it fresh there would be two derivations of
|
|
965
|
+
# one fact, free to disagree -- the 1.48.1 mirador sin.
|
|
966
|
+
content_state = _content_state(repo_path, repo_type, last_snapshot)
|
|
825
967
|
for f in _junit_files_for(repo_path, repo_type):
|
|
968
|
+
clock_fresh = True
|
|
826
969
|
if newest_src > 0.0:
|
|
827
970
|
try:
|
|
828
|
-
|
|
829
|
-
stale.append(f)
|
|
830
|
-
continue
|
|
971
|
+
clock_fresh = os.path.getmtime(f) >= newest_src
|
|
831
972
|
except OSError:
|
|
832
973
|
pass
|
|
974
|
+
if _report_fresh(repo_path, f, clock_fresh, content_state) is None:
|
|
975
|
+
stale.append(f)
|
|
976
|
+
continue
|
|
833
977
|
try:
|
|
834
978
|
root = _parse_xml(f).getroot()
|
|
835
979
|
except (ET.ParseError, OSError):
|
|
@@ -873,7 +1017,11 @@ def _sum_ac_tags(ledger):
|
|
|
873
1017
|
ac_tags = {}
|
|
874
1018
|
stale_reports = []
|
|
875
1019
|
for rcfg in (ledger.get("config") or {}).get("repos", []):
|
|
876
|
-
|
|
1020
|
+
# the LAST snapshot of this repo is what rule (b) compares against (ADR-039): the
|
|
1021
|
+
# commit the evidence was measured at and the hash each report carried at ingest.
|
|
1022
|
+
snaps = ((ledger.get("repos") or {}).get(rcfg.get("name")) or {}).get("snapshots") or []
|
|
1023
|
+
rtags, rstale = _ac_tags(rcfg.get("path", "."), rcfg.get("type", "maven"),
|
|
1024
|
+
snaps[-1] if snaps else None)
|
|
877
1025
|
for cid, v in rtags.items():
|
|
878
1026
|
d = ac_tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
|
|
879
1027
|
d["green"] += v["green"]
|
|
@@ -944,7 +1092,7 @@ def junit_test_count(repo_path, extra_files=None):
|
|
|
944
1092
|
"passed": executed - failures - errors, "report_found": bool(files)}
|
|
945
1093
|
|
|
946
1094
|
|
|
947
|
-
def test_count(repo_path, repo_type):
|
|
1095
|
+
def test_count(repo_path, repo_type, last_snapshot=None):
|
|
948
1096
|
if repo_type == "ant":
|
|
949
1097
|
result = ant_test_count(repo_path)
|
|
950
1098
|
elif repo_type == "maven":
|
|
@@ -963,7 +1111,7 @@ def test_count(repo_path, repo_type):
|
|
|
963
1111
|
result = junit_test_count(repo_path)
|
|
964
1112
|
else:
|
|
965
1113
|
result = flutter_test_count(repo_path)
|
|
966
|
-
reports, freshness = _test_evidence_provenance(repo_path, repo_type)
|
|
1114
|
+
reports, freshness = _test_evidence_provenance(repo_path, repo_type, last_snapshot)
|
|
967
1115
|
result["reports"] = reports
|
|
968
1116
|
result["freshness"] = freshness
|
|
969
1117
|
return result
|
|
@@ -1989,10 +2137,14 @@ def _snapshot(ledger, name):
|
|
|
1989
2137
|
cfg = _repo_cfg(ledger, name) if name != "integration" else {"path": ".", "type": "maven"}
|
|
1990
2138
|
path = cfg["path"]
|
|
1991
2139
|
rtype = cfg["type"]
|
|
2140
|
+
# the PREVIOUS snapshot, read before this one is appended: rule (b) asks whether the
|
|
2141
|
+
# reports are still the files THAT run ingested, at a commit with no source change since
|
|
2142
|
+
# (ADR-039). Comparing a snapshot against itself would be circular.
|
|
2143
|
+
prev = (node.get("snapshots") or [])[-1:] or [None]
|
|
1992
2144
|
snap = {
|
|
1993
2145
|
"at": _now(),
|
|
1994
2146
|
"coverage": coverage(path, rtype),
|
|
1995
|
-
"tests": test_count(path, rtype),
|
|
2147
|
+
"tests": test_count(path, rtype, prev[0]),
|
|
1996
2148
|
"loc": count_loc(path, rtype),
|
|
1997
2149
|
"origin": _evidence_origin(path),
|
|
1998
2150
|
}
|
|
@@ -5157,6 +5309,59 @@ def _sha256_file(path):
|
|
|
5157
5309
|
return None
|
|
5158
5310
|
|
|
5159
5311
|
|
|
5312
|
+
# kit 1.93.1: the hash of a TEXT evidence file, EOL-NORMALIZED (CRLF -> LF before hashing).
|
|
5313
|
+
#
|
|
5314
|
+
# A JUnit report is text, and a version control system is allowed to rewrite its line endings
|
|
5315
|
+
# on checkout -- `* text=auto eol=lf` is the recommended `.gitattributes` and the kit's own.
|
|
5316
|
+
# The suite that produced the report on Windows wrote CRLF, the repository stores LF, and a
|
|
5317
|
+
# clean checkout therefore yields a file that is byte-different and semantically identical.
|
|
5318
|
+
# The exact-byte hash read that as `evidence altered after ingest`: 1.93.0 shipped with the
|
|
5319
|
+
# limit merely NAMED in SPEC 4, and the release machine's own board hit it the same day.
|
|
5320
|
+
# Normalizing the ONE difference git is allowed to introduce keeps the guarantee that matters
|
|
5321
|
+
# -- any other changed byte (a swapped log, an edited count, a different run) still fails.
|
|
5322
|
+
#
|
|
5323
|
+
# `_sha256_file` is deliberately left alone: compile-validate hashes MANIFEST UNITS, where the
|
|
5324
|
+
# exact bytes ARE the claim, and a manifest that tolerated a rewrite would be tolerating the
|
|
5325
|
+
# thing it exists to detect.
|
|
5326
|
+
def _sha256_evidence(path):
|
|
5327
|
+
try:
|
|
5328
|
+
with open(path, "rb") as fh:
|
|
5329
|
+
data = fh.read()
|
|
5330
|
+
except OSError:
|
|
5331
|
+
return None
|
|
5332
|
+
return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()
|
|
5333
|
+
|
|
5334
|
+
|
|
5335
|
+
def _evidence_hash_matches(path, record):
|
|
5336
|
+
"""Does the file on disk still hash to what the RECORD carries? True / False, or None when
|
|
5337
|
+
the record carries no hash at all (the caller decides: UNMEASURED, never a pass).
|
|
5338
|
+
|
|
5339
|
+
Records written by 1.93.1 and later carry `sha256_eol: "lf"` and are compared NORMALIZED,
|
|
5340
|
+
full stop. A record written BEFORE that marker hashed the file's exact bytes, whatever
|
|
5341
|
+
line endings the machine that ran the suite happened to write -- and 1.93.0's own release
|
|
5342
|
+
record is the proof: the suite wrote CRLF on Windows, git stored LF, and the LF checkout
|
|
5343
|
+
matched neither the recorded hash nor its normalized form, because the recording was of the
|
|
5344
|
+
CRLF RENDERING of the same text. So a pre-marker record is compared against all three
|
|
5345
|
+
renderings of the bytes on disk: normalized, exact, and CRLF. The door is narrow on purpose
|
|
5346
|
+
-- it opens only for records that predate the marker, and it admits only the line-ending
|
|
5347
|
+
renderings of THIS file's text: a report whose content really changed matches none of them."""
|
|
5348
|
+
want = record.get("sha256") if isinstance(record, dict) else None
|
|
5349
|
+
if not want:
|
|
5350
|
+
return None
|
|
5351
|
+
if _sha256_evidence(path) == want:
|
|
5352
|
+
return True
|
|
5353
|
+
if record.get("sha256_eol") == "lf":
|
|
5354
|
+
return False
|
|
5355
|
+
try:
|
|
5356
|
+
with open(path, "rb") as fh:
|
|
5357
|
+
data = fh.read()
|
|
5358
|
+
except OSError:
|
|
5359
|
+
return False
|
|
5360
|
+
lf = data.replace(b"\r\n", b"\n")
|
|
5361
|
+
return want in (hashlib.sha256(data).hexdigest(),
|
|
5362
|
+
hashlib.sha256(lf.replace(b"\n", b"\r\n")).hexdigest())
|
|
5363
|
+
|
|
5364
|
+
|
|
5160
5365
|
def _contained_unit(base, unit):
|
|
5161
5366
|
"""A compilation's units must be RELATIVE paths CONTAINED within the compilation
|
|
5162
5367
|
directory: the manifest references what was compiled, and what was compiled lives with
|
|
@@ -8710,7 +8915,12 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8710
8915
|
monorepo sibling's edit is not this repo's dirt -- ignoring the ledger itself and
|
|
8711
8916
|
the report files the last snapshot names (those two are the seal's own footprint,
|
|
8712
8917
|
exactly as the reference `sh` package exempts `EVIDENCIA.md` and the logs it hashes);
|
|
8713
|
-
2.
|
|
8918
|
+
2. has the CODE moved since the commit that snapshot was taken at (`origin.commit`,
|
|
8919
|
+
ADR-007)? Amended in 1.93.0 (ADR-039): HEAD may differ from that commit by files
|
|
8920
|
+
outside `_SRC_EXT` and outside the named report set -- docs, changelogs, the ledger
|
|
8921
|
+
that carries this very snapshot -- and the seal then holds and carries a `note`
|
|
8922
|
+
naming what moved (capped at five paths). A source-relevant difference is still a
|
|
8923
|
+
break, named by its first path;
|
|
8714
8924
|
3. does every report the snapshot names still exist and still hash to what was
|
|
8715
8925
|
recorded at ingest (`sha256`, added in 1.92.0).
|
|
8716
8926
|
|
|
@@ -8725,13 +8935,14 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8725
8935
|
"checked at" would be a second wall clock inside a payload whose only other one is
|
|
8726
8936
|
`generated_at` -- and two consecutive `top --json` runs must differ in nothing else
|
|
8727
8937
|
(AC-T-24 measures exactly that, and caught this before it shipped)."""
|
|
8728
|
-
out = {"ok": None, "reasons": [], "commit": None, "repo": None}
|
|
8938
|
+
out = {"ok": None, "reasons": [], "commit": None, "repo": None, "note": None}
|
|
8729
8939
|
repos = (ledger.get("config", {}) or {}).get("repos") or []
|
|
8730
8940
|
if not repos:
|
|
8731
8941
|
out["reasons"].append("no repo configured — seal UNMEASURED")
|
|
8732
8942
|
return out
|
|
8733
8943
|
name = repos[0].get("name")
|
|
8734
8944
|
path = repos[0].get("path", ".")
|
|
8945
|
+
rtype = repos[0].get("type")
|
|
8735
8946
|
out["repo"] = _top_clean(name) if name else None
|
|
8736
8947
|
|
|
8737
8948
|
top = _seal_git(path, "rev-parse", "--show-toplevel")
|
|
@@ -8767,13 +8978,6 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8767
8978
|
snap = snaps[-1]
|
|
8768
8979
|
|
|
8769
8980
|
failures, unmeasured = [], []
|
|
8770
|
-
snap_commit = (snap.get("origin") or {}).get("commit")
|
|
8771
|
-
if not snap_commit:
|
|
8772
|
-
unmeasured.append("snapshot recorded no commit — seal UNMEASURED")
|
|
8773
|
-
elif snap_commit != head_sha:
|
|
8774
|
-
failures.append("stale seal: snapshot at %s, HEAD is %s"
|
|
8775
|
-
% (snap_commit[:8], head_sha[:8]))
|
|
8776
|
-
|
|
8777
8981
|
reports = [r for r in ((snap.get("tests") or {}).get("reports") or [])
|
|
8778
8982
|
if isinstance(r, dict) and r.get("path")]
|
|
8779
8983
|
exempt = set()
|
|
@@ -8781,6 +8985,48 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8781
8985
|
rel = _seal_rel(work_tree, candidate)
|
|
8782
8986
|
if rel:
|
|
8783
8987
|
exempt.add(rel)
|
|
8988
|
+
|
|
8989
|
+
snap_commit = (snap.get("origin") or {}).get("commit")
|
|
8990
|
+
if not snap_commit:
|
|
8991
|
+
unmeasured.append("snapshot recorded no commit — seal UNMEASURED")
|
|
8992
|
+
elif snap_commit != head_sha:
|
|
8993
|
+
# AMENDED in 1.93.0 (ADR-039): `HEAD == commit` was too strict in the one repo that
|
|
8994
|
+
# applies the method to itself -- the ledger lives INSIDE the commit that carries it,
|
|
8995
|
+
# so the release commit is always one ahead of the snapshot it publishes and the board
|
|
8996
|
+
# read `stale seal` forever on the machine that released. What the seal actually
|
|
8997
|
+
# promises is that the CODE has not moved, so a HEAD that differs only by files outside
|
|
8998
|
+
# `_SRC_EXT` and outside the named report set (docs, changelogs, the ledger itself) is
|
|
8999
|
+
# sealed WITH A NOTE that says exactly what moved. Anything source-relevant is still a
|
|
9000
|
+
# break, and it names the first offending path instead of two opaque hashes.
|
|
9001
|
+
diff = _seal_git(path, "-c", "core.quotepath=false", "diff", "--name-only",
|
|
9002
|
+
snap_commit, head_sha, "--", ".")
|
|
9003
|
+
if diff is None:
|
|
9004
|
+
# the commit is unreachable (shallow clone, rewritten history): we cannot SEE what
|
|
9005
|
+
# changed, so the strict verdict stands. Fail-closed, as before.
|
|
9006
|
+
failures.append("stale seal: snapshot at %s, HEAD is %s"
|
|
9007
|
+
% (snap_commit[:8], head_sha[:8]))
|
|
9008
|
+
else:
|
|
9009
|
+
moved = sorted(set(_git_path_list(diff.stdout)))
|
|
9010
|
+
# the REPORTS are deliberately NOT re-added here (blind review, before 1.93.0
|
|
9011
|
+
# shipped). The release ritual is: commit the code, run the suite, `snapshot` at that
|
|
9012
|
+
# commit, then commit the ledger AND the JUnit it names -- so the report is always in
|
|
9013
|
+
# the X..X+1 diff and a seal that counted it could never close on the machine that
|
|
9014
|
+
# released. Nothing is given away: the hash check below proves the file on disk is
|
|
9015
|
+
# byte-for-byte the one that was ingested, which is a stronger statement than "this
|
|
9016
|
+
# path did not appear in a diff".
|
|
9017
|
+
relevant = sorted(set(_src_relevant(moved, rtype)))
|
|
9018
|
+
if relevant:
|
|
9019
|
+
failures.append("stale seal: source changed since snapshot %s: %s"
|
|
9020
|
+
% (snap_commit[:8], relevant[0]))
|
|
9021
|
+
elif moved:
|
|
9022
|
+
shown = ", ".join(moved[:5])
|
|
9023
|
+
if len(moved) > 5:
|
|
9024
|
+
shown += " (+%d)" % (len(moved) - 5)
|
|
9025
|
+
out["note"] = ("HEAD %s differs from snapshot %s by non-source files only: %s"
|
|
9026
|
+
% (head_sha[:8], snap_commit[:8], shown))
|
|
9027
|
+
else:
|
|
9028
|
+
out["note"] = ("HEAD %s differs from snapshot %s: no file changed in the "
|
|
9029
|
+
"repo subtree" % (head_sha[:8], snap_commit[:8]))
|
|
8784
9030
|
# `core.quotepath=false`: without it git C-quotes any non-ASCII path, so a report named
|
|
8785
9031
|
# `junit-acción.xml` would appear in the reason as `junit-acción.xml` -- a reason
|
|
8786
9032
|
# nobody can act on, and an exemption that cannot match. Set on the command, never in the
|
|
@@ -8801,7 +9047,7 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8801
9047
|
elif not r.get("sha256"):
|
|
8802
9048
|
unmeasured.append("evidence hash unmeasured: %s — no hash recorded at ingest "
|
|
8803
9049
|
"(older snapshot, or the file was unreadable)" % rel)
|
|
8804
|
-
elif
|
|
9050
|
+
elif _evidence_hash_matches(full, r) is not True:
|
|
8805
9051
|
failures.append("evidence altered after ingest: %s" % rel)
|
|
8806
9052
|
|
|
8807
9053
|
out["reasons"] = failures + unmeasured
|
|
@@ -8838,6 +9084,8 @@ def cmd_check_terminado(args):
|
|
|
8838
9084
|
where = " at %s" % sealed["commit"][:8] if sealed.get("commit") else ""
|
|
8839
9085
|
print("[qa_ledger] check-terminado: %s%s (repo %s)"
|
|
8840
9086
|
% (verdict, where, sealed.get("repo") or "?"))
|
|
9087
|
+
if sealed.get("note"):
|
|
9088
|
+
print(" - %s" % sealed["note"])
|
|
8841
9089
|
for reason in sealed.get("reasons") or []:
|
|
8842
9090
|
print(" - %s" % reason)
|
|
8843
9091
|
if ok is not True:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "uscha",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.93.1",
|
|
5
5
|
"displayName": "Uscha",
|
|
6
6
|
"description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 53 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
|
|
7
7
|
"author": {
|
package/uscha-kit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# uscha-kit
|
|
2
2
|
|
|
3
|
-
**Kit version:** v1.
|
|
3
|
+
**Kit version:** v1.93.1 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
|
|
4
4
|
|
|
5
5
|
Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
|
|
6
6
|
**Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
|
package/uscha-kit/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
uscha-kit 1.
|
|
1
|
+
uscha-kit 1.93.1
|
|
@@ -466,8 +466,10 @@ python3 $QL check-terminado # 0 = sealed · 1 = broken · 2 = UNMEASURE
|
|
|
466
466
|
```
|
|
467
467
|
|
|
468
468
|
It recomputes, from the ledger and the tree, whether the recorded evidence still belongs to
|
|
469
|
-
the code on disk: the repo subtree clean,
|
|
470
|
-
|
|
469
|
+
the code on disk: the repo subtree clean, no source-relevant change since the last
|
|
470
|
+
snapshot's commit (a non-source difference — docs, the ledger, the reports themselves —
|
|
471
|
+
seals with a `note` naming what moved; ADR-039), every ingested report still hashing to
|
|
472
|
+
what was recorded. **Exit 1** — do not declare TERMINADO:
|
|
471
473
|
re-snapshot on the CURRENT state (`snapshot --repo <REPO> --phase post`) and record why the
|
|
472
474
|
seal broke. **Exit 2** — the seal is UNMEASURED (no git, or no snapshot recorded): say so
|
|
473
475
|
plainly; an answer nobody could measure is not a TERMINADO either.
|
|
@@ -556,6 +558,12 @@ up as `narrated_only` and does NOT close (measured beats narrated, per criterion
|
|
|
556
558
|
A JUnit report older than the repo's source code is treated as STALE (the code changed
|
|
557
559
|
after the tests ran) and is DISCARDED — a criterion backed only by stale reports stays
|
|
558
560
|
UNMEASURED, never falsely closed or vetoed (kit 1.31.0; surfaced as `stale_reports`).
|
|
561
|
+
Since 1.93.0 (ADR-039) that clock rule is not the only one: a report the clock rejects is still
|
|
562
|
+
FRESH when its `sha256` matches what the last `snapshot` recorded for it AND git shows no
|
|
563
|
+
source-relevant change since that snapshot's commit — so a clone, a `git worktree add`, a merge
|
|
564
|
+
or a CI checkout, which re-date every file without changing a byte, no longer un-measure green
|
|
565
|
+
evidence. Either rule suffices; with no git, no recorded commit or no recorded hash the clock
|
|
566
|
+
rule decides alone, exactly as before.
|
|
559
567
|
So: when you write the tests for a criterion, put its AC-n in the test name; run
|
|
560
568
|
`spec-check --acceptance ACCEPTANCE.md` up front (zero traceable criteria / duplicate
|
|
561
569
|
IDs block as structural FACTS). Files without IDs fall back to the checkbox ratio
|
|
@@ -642,7 +642,7 @@ _SRC_EXT = {
|
|
|
642
642
|
".java", ".kt", ".kts", ".scala", ".groovy", ".py", ".js", ".jsx",
|
|
643
643
|
".ts", ".tsx", ".mjs", ".cjs", ".go", ".rs", ".cs", ".vb", ".fs",
|
|
644
644
|
".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".swift", ".m", ".mm",
|
|
645
|
-
".rb", ".php", ".dart", ".gradle",
|
|
645
|
+
".rb", ".php", ".dart", ".gradle", ".hh", ".hxx",
|
|
646
646
|
}
|
|
647
647
|
_SRC_SKIP_DIRS = SKIP_DIRS | {"reports", "Pods", ".vs"}
|
|
648
648
|
|
|
@@ -701,7 +701,127 @@ def _source_newest_mtime(repo_path):
|
|
|
701
701
|
return newest["mtime_ns"] / 1_000_000_000 if newest else 0.0
|
|
702
702
|
|
|
703
703
|
|
|
704
|
-
|
|
704
|
+
# kit 1.93.0 (ADR-039): the clock is not the only honest freshness rule. A fresh clone, a
|
|
705
|
+
# `git worktree add`, a merge or a CI checkout re-date every source file without changing a
|
|
706
|
+
# byte of source, and rule (a) then throws away evidence that IS current -- the day INV-T1
|
|
707
|
+
# shipped, the release machine read 0/195 for exactly that reason. Rule (b) asks the question
|
|
708
|
+
# a timestamp cannot: has any SOURCE file changed since the commit the last snapshot was
|
|
709
|
+
# measured at, and is the report still the exact file that was ingested? Either rule
|
|
710
|
+
# suffices; both are measured. No git, no recorded commit, no recorded hash -> (a) alone,
|
|
711
|
+
# byte-identical to 1.92.0.
|
|
712
|
+
def _git_path_list(text):
|
|
713
|
+
"""Paths from `git diff --name-only`: C-quotes stripped, forward slashes. An escape
|
|
714
|
+
inside a quoted path is left as-is -- that can only fail to MATCH, which withholds
|
|
715
|
+
freshness rather than granting it (the safe direction, same posture as `_porcelain_paths`)."""
|
|
716
|
+
out = []
|
|
717
|
+
for line in (text or "").splitlines():
|
|
718
|
+
p = line.strip()
|
|
719
|
+
if len(p) >= 2 and p[0] == '"' and p[-1] == '"':
|
|
720
|
+
p = p[1:-1]
|
|
721
|
+
if p:
|
|
722
|
+
out.append(p.replace("\\", "/"))
|
|
723
|
+
return out
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# What rule (b) and the seal treat as "a change that invalidates a test run", BEYOND the source
|
|
727
|
+
# code itself: the build and harness files that decide WHAT the suite runs and HOW. A commit that
|
|
728
|
+
# rewrites `smoke-engine.sh`, a `pom.xml` or a CI workflow changes the meaning of a green report
|
|
729
|
+
# just as surely as editing the code under test, and a seal that tolerated it would be tolerating
|
|
730
|
+
# the one edit a green board cannot survive. Deliberately NOT here, and named in ADR-039 and
|
|
731
|
+
# SPEC 4: `.md`, `.json`, `.xml`, `.txt` -- docs, changelogs, the ledger and the JUnit reports
|
|
732
|
+
# themselves are non-source BY CONSTRUCTION, which is the whole point of the tolerant seal.
|
|
733
|
+
# (`package.json` and `pyproject.toml` are the named exceptions: they carry the test command.)
|
|
734
|
+
_HARNESS_EXT = {".sh", ".bash", ".ps1", ".yml", ".yaml", ".toml", ".sql", ".tf",
|
|
735
|
+
".gradle", ".cmake"}
|
|
736
|
+
_HARNESS_NAMES = {"makefile", "pom.xml", "build.gradle", "package.json",
|
|
737
|
+
"pyproject.toml", "setup.py", "cargo.toml", "go.mod"}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _src_relevant(paths, repo_type=None):
|
|
741
|
+
"""Only the paths that can invalidate a test run: the engine's own source-extension set --
|
|
742
|
+
a true SUPERSET of what rule (a) looked at, because provenance narrows the clock rule to
|
|
743
|
+
`SOURCE_EXT[repo_type]` and that set is unioned in here rather than assumed to be inside
|
|
744
|
+
`_SRC_EXT` (`.hh`/`.hxx` were in one and not the other until 1.93.0) -- plus the build and
|
|
745
|
+
harness files above. The same generated/build/report/vendor trees `_newest_source` prunes
|
|
746
|
+
are pruned here. ONE definition for rule (b) and for the seal (ADR-039): widening it widens
|
|
747
|
+
both at once, never one."""
|
|
748
|
+
allowed = _SRC_EXT | SOURCE_EXT.get(repo_type or "", set()) | _HARNESS_EXT
|
|
749
|
+
out = []
|
|
750
|
+
for path in paths:
|
|
751
|
+
parts = path.split("/")
|
|
752
|
+
if any(d in _SRC_SKIP_DIRS or d.startswith("cmake-build-") for d in parts[:-1]):
|
|
753
|
+
continue
|
|
754
|
+
name = parts[-1].lower()
|
|
755
|
+
if os.path.splitext(name)[1] in allowed or name in _HARNESS_NAMES:
|
|
756
|
+
out.append(path)
|
|
757
|
+
return out
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _content_state(repo_path, repo_type, last_snapshot):
|
|
761
|
+
"""What rule (b) needs, measured ONCE per repo instead of once per report: the commit the
|
|
762
|
+
last snapshot was taken at (`origin.commit`, ADR-007), the sha256 that snapshot recorded
|
|
763
|
+
per report path (ADR-038), and every source-relevant path that changed between that commit
|
|
764
|
+
and the tree on disk -- the committed diff AND the working tree, because a source edit that
|
|
765
|
+
is not committed yet is exactly as invalidating as one that is.
|
|
766
|
+
|
|
767
|
+
`None` whenever the question cannot be answered: no snapshot, no recorded commit, no
|
|
768
|
+
recorded hash (a pre-1.92.0 ledger), no git. Absence leaves rule (a) alone; it never
|
|
769
|
+
grants freshness."""
|
|
770
|
+
snap = last_snapshot if isinstance(last_snapshot, dict) else {}
|
|
771
|
+
commit = (snap.get("origin") or {}).get("commit")
|
|
772
|
+
if not commit:
|
|
773
|
+
return None
|
|
774
|
+
# THE BASELINE MUST NOT LAUNDER ITSELF (fixed before 1.93.0 shipped, found in blind review).
|
|
775
|
+
# `snapshot` records the tree AS IT IS: taken on a repo whose code moved and whose tests were
|
|
776
|
+
# never re-run, it faithfully writes `freshness: stale` -- and then, at read time, that same
|
|
777
|
+
# record would say "the report hashes to what I ingested, and nothing changed since MY commit"
|
|
778
|
+
# (its commit is HEAD, its hash was taken over that very file) and turn its own UNMEASURED
|
|
779
|
+
# verdict into a GREEN. A snapshot can only anchor evidence it judged CURRENT: a stale
|
|
780
|
+
# verdict, or a report the record itself marked stale, is no anchor at all.
|
|
781
|
+
if ((snap.get("tests") or {}).get("freshness") or {}).get("status") == "stale":
|
|
782
|
+
return None
|
|
783
|
+
# the RECORD, not just the hash string: `_evidence_hash_matches` needs the `sha256_eol`
|
|
784
|
+
# marker beside it to know whether the compatibility comparison is still on offer
|
|
785
|
+
hashes = {r["path"]: r
|
|
786
|
+
for r in ((snap.get("tests") or {}).get("reports") or [])
|
|
787
|
+
if isinstance(r, dict) and r.get("path") and r.get("sha256")
|
|
788
|
+
and r.get("fresh_by") != "stale"}
|
|
789
|
+
if not hashes:
|
|
790
|
+
return None
|
|
791
|
+
diff = _seal_git(repo_path, "-c", "core.quotepath=false", "diff", "--name-only",
|
|
792
|
+
commit, "HEAD", "--", ".")
|
|
793
|
+
st = _seal_git(repo_path, "-c", "core.quotepath=false", "status", "--porcelain",
|
|
794
|
+
"-uall", "--", ".")
|
|
795
|
+
if diff is None or st is None:
|
|
796
|
+
return None
|
|
797
|
+
changed = _src_relevant(_git_path_list(diff.stdout) + _porcelain_paths(st.stdout),
|
|
798
|
+
repo_type)
|
|
799
|
+
return {"commit": commit, "hashes": hashes, "src_changed": sorted(set(changed))}
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _report_fresh(repo_path, report_path, clock_fresh, content_state):
|
|
803
|
+
"""'clock' | 'content' | None (stale) for ONE report -- the single derivation both the
|
|
804
|
+
snapshot record (`_test_evidence_provenance`) and the tag ingest (`_ac_tags`) read, so the
|
|
805
|
+
two surfaces cannot disagree about which report is current.
|
|
806
|
+
|
|
807
|
+
The CLOCK verdict is supplied by the caller on purpose: the two callers have always applied
|
|
808
|
+
it with different tolerances, and both stay byte-identical to 1.92.0 rather than being
|
|
809
|
+
quietly unified here."""
|
|
810
|
+
if clock_fresh:
|
|
811
|
+
return "clock"
|
|
812
|
+
if not content_state or content_state["src_changed"]:
|
|
813
|
+
return None
|
|
814
|
+
# the SAME relpath form the snapshot recorded the report under. No realpath on either
|
|
815
|
+
# side: both come from the same `repo_path` string, so the 8.3 mismatch of 2026-08-02
|
|
816
|
+
# cannot arise here, while normalizing one side would stop matching the recorded key.
|
|
817
|
+
rel = os.path.relpath(report_path, repo_path).replace("\\", "/")
|
|
818
|
+
recorded = content_state["hashes"].get(rel)
|
|
819
|
+
if _evidence_hash_matches(report_path, recorded) is not True:
|
|
820
|
+
return None
|
|
821
|
+
return "content"
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _test_evidence_provenance(repo_path, repo_type, last_snapshot=None):
|
|
705
825
|
"""Explain which JUnit reports back a snapshot and whether they are newer
|
|
706
826
|
than relevant source/test files. No discoverable source is explicitly
|
|
707
827
|
uncorrelated-but-usable to preserve synthetic/report-only workflows.
|
|
@@ -724,7 +844,10 @@ def _test_evidence_provenance(repo_path, repo_type):
|
|
|
724
844
|
"mtime_ns": mtime_ns,
|
|
725
845
|
"mtime": datetime.fromtimestamp(
|
|
726
846
|
mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
|
|
727
|
-
"sha256":
|
|
847
|
+
"sha256": _sha256_evidence(path),
|
|
848
|
+
# the marker says HOW the hash above was taken, so a reader never has to guess
|
|
849
|
+
# (and 1.93.0-and-older records, which have no marker, keep their compatibility)
|
|
850
|
+
"sha256_eol": "lf",
|
|
728
851
|
})
|
|
729
852
|
if not reports:
|
|
730
853
|
status = "not-applicable" if repo_type == "flutter" else "missing"
|
|
@@ -745,15 +868,29 @@ def _test_evidence_provenance(repo_path, repo_type):
|
|
|
745
868
|
"tolerance_ns": _JUNIT_FRESHNESS_TOLERANCE_NS,
|
|
746
869
|
}
|
|
747
870
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
871
|
+
# ADR-039: the clock verdict first (unchanged, tolerance included), then rule (b) for the
|
|
872
|
+
# reports the clock rejects. `fresh_by` says WHICH rule answered for each report, so a
|
|
873
|
+
# board that reads fresh can always be asked why.
|
|
874
|
+
content_state = _content_state(repo_path, repo_type, last_snapshot)
|
|
875
|
+
stale_reports = []
|
|
876
|
+
for report in reports:
|
|
877
|
+
clock_fresh = (newest["mtime_ns"]
|
|
878
|
+
<= report["mtime_ns"] + _JUNIT_FRESHNESS_TOLERANCE_NS)
|
|
879
|
+
how = _report_fresh(repo_path, os.path.join(repo_path, report["path"]),
|
|
880
|
+
clock_fresh, content_state)
|
|
881
|
+
report["fresh_by"] = how or "stale"
|
|
882
|
+
if how is None:
|
|
883
|
+
stale_reports.append(report)
|
|
884
|
+
by_content = [r["path"] for r in reports if r["fresh_by"] == "content"]
|
|
752
885
|
if stale_reports:
|
|
753
886
|
paths = ", ".join(report["path"] for report in stale_reports)
|
|
754
887
|
reason = (f"source/test {newest['path']} is newer than JUnit report(s) "
|
|
755
888
|
f"{paths}")
|
|
756
889
|
status = "stale"
|
|
890
|
+
elif by_content:
|
|
891
|
+
reason = ("content unchanged since %s: %s"
|
|
892
|
+
% (content_state["commit"][:8], ", ".join(by_content)))
|
|
893
|
+
status = "fresh"
|
|
757
894
|
else:
|
|
758
895
|
reason = "selected JUnit report(s) are current relative to source/test files"
|
|
759
896
|
status = "fresh"
|
|
@@ -804,7 +941,7 @@ def _ac_tag_ids(name):
|
|
|
804
941
|
return ids
|
|
805
942
|
|
|
806
943
|
|
|
807
|
-
def _ac_tags(repo_path, repo_type):
|
|
944
|
+
def _ac_tags(repo_path, repo_type, last_snapshot=None):
|
|
808
945
|
"""Tags AC-n leidos de los NOMBRES de testcase en los reportes JUnit que el
|
|
809
946
|
engine ya ingiere. Devuelve (tags, stale) donde tags = {'AC-n': {'green': x,
|
|
810
947
|
'red': y}} (o 'AC-FAM-n' para la forma con familia, ADR-036 / kit 1.87.0)
|
|
@@ -822,14 +959,21 @@ def _ac_tags(repo_path, repo_type):
|
|
|
822
959
|
tags = {}
|
|
823
960
|
stale = []
|
|
824
961
|
newest_src = _source_newest_mtime(repo_path)
|
|
962
|
+
# ADR-039 (kit 1.93.0): rule (b) applies HERE too, not only in the snapshot record. A
|
|
963
|
+
# report the clock rejects but whose content is unchanged since the snapshot commit is
|
|
964
|
+
# FRESH, and discarding it here while calling it fresh there would be two derivations of
|
|
965
|
+
# one fact, free to disagree -- the 1.48.1 mirador sin.
|
|
966
|
+
content_state = _content_state(repo_path, repo_type, last_snapshot)
|
|
825
967
|
for f in _junit_files_for(repo_path, repo_type):
|
|
968
|
+
clock_fresh = True
|
|
826
969
|
if newest_src > 0.0:
|
|
827
970
|
try:
|
|
828
|
-
|
|
829
|
-
stale.append(f)
|
|
830
|
-
continue
|
|
971
|
+
clock_fresh = os.path.getmtime(f) >= newest_src
|
|
831
972
|
except OSError:
|
|
832
973
|
pass
|
|
974
|
+
if _report_fresh(repo_path, f, clock_fresh, content_state) is None:
|
|
975
|
+
stale.append(f)
|
|
976
|
+
continue
|
|
833
977
|
try:
|
|
834
978
|
root = _parse_xml(f).getroot()
|
|
835
979
|
except (ET.ParseError, OSError):
|
|
@@ -873,7 +1017,11 @@ def _sum_ac_tags(ledger):
|
|
|
873
1017
|
ac_tags = {}
|
|
874
1018
|
stale_reports = []
|
|
875
1019
|
for rcfg in (ledger.get("config") or {}).get("repos", []):
|
|
876
|
-
|
|
1020
|
+
# the LAST snapshot of this repo is what rule (b) compares against (ADR-039): the
|
|
1021
|
+
# commit the evidence was measured at and the hash each report carried at ingest.
|
|
1022
|
+
snaps = ((ledger.get("repos") or {}).get(rcfg.get("name")) or {}).get("snapshots") or []
|
|
1023
|
+
rtags, rstale = _ac_tags(rcfg.get("path", "."), rcfg.get("type", "maven"),
|
|
1024
|
+
snaps[-1] if snaps else None)
|
|
877
1025
|
for cid, v in rtags.items():
|
|
878
1026
|
d = ac_tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
|
|
879
1027
|
d["green"] += v["green"]
|
|
@@ -944,7 +1092,7 @@ def junit_test_count(repo_path, extra_files=None):
|
|
|
944
1092
|
"passed": executed - failures - errors, "report_found": bool(files)}
|
|
945
1093
|
|
|
946
1094
|
|
|
947
|
-
def test_count(repo_path, repo_type):
|
|
1095
|
+
def test_count(repo_path, repo_type, last_snapshot=None):
|
|
948
1096
|
if repo_type == "ant":
|
|
949
1097
|
result = ant_test_count(repo_path)
|
|
950
1098
|
elif repo_type == "maven":
|
|
@@ -963,7 +1111,7 @@ def test_count(repo_path, repo_type):
|
|
|
963
1111
|
result = junit_test_count(repo_path)
|
|
964
1112
|
else:
|
|
965
1113
|
result = flutter_test_count(repo_path)
|
|
966
|
-
reports, freshness = _test_evidence_provenance(repo_path, repo_type)
|
|
1114
|
+
reports, freshness = _test_evidence_provenance(repo_path, repo_type, last_snapshot)
|
|
967
1115
|
result["reports"] = reports
|
|
968
1116
|
result["freshness"] = freshness
|
|
969
1117
|
return result
|
|
@@ -1989,10 +2137,14 @@ def _snapshot(ledger, name):
|
|
|
1989
2137
|
cfg = _repo_cfg(ledger, name) if name != "integration" else {"path": ".", "type": "maven"}
|
|
1990
2138
|
path = cfg["path"]
|
|
1991
2139
|
rtype = cfg["type"]
|
|
2140
|
+
# the PREVIOUS snapshot, read before this one is appended: rule (b) asks whether the
|
|
2141
|
+
# reports are still the files THAT run ingested, at a commit with no source change since
|
|
2142
|
+
# (ADR-039). Comparing a snapshot against itself would be circular.
|
|
2143
|
+
prev = (node.get("snapshots") or [])[-1:] or [None]
|
|
1992
2144
|
snap = {
|
|
1993
2145
|
"at": _now(),
|
|
1994
2146
|
"coverage": coverage(path, rtype),
|
|
1995
|
-
"tests": test_count(path, rtype),
|
|
2147
|
+
"tests": test_count(path, rtype, prev[0]),
|
|
1996
2148
|
"loc": count_loc(path, rtype),
|
|
1997
2149
|
"origin": _evidence_origin(path),
|
|
1998
2150
|
}
|
|
@@ -5157,6 +5309,59 @@ def _sha256_file(path):
|
|
|
5157
5309
|
return None
|
|
5158
5310
|
|
|
5159
5311
|
|
|
5312
|
+
# kit 1.93.1: the hash of a TEXT evidence file, EOL-NORMALIZED (CRLF -> LF before hashing).
|
|
5313
|
+
#
|
|
5314
|
+
# A JUnit report is text, and a version control system is allowed to rewrite its line endings
|
|
5315
|
+
# on checkout -- `* text=auto eol=lf` is the recommended `.gitattributes` and the kit's own.
|
|
5316
|
+
# The suite that produced the report on Windows wrote CRLF, the repository stores LF, and a
|
|
5317
|
+
# clean checkout therefore yields a file that is byte-different and semantically identical.
|
|
5318
|
+
# The exact-byte hash read that as `evidence altered after ingest`: 1.93.0 shipped with the
|
|
5319
|
+
# limit merely NAMED in SPEC 4, and the release machine's own board hit it the same day.
|
|
5320
|
+
# Normalizing the ONE difference git is allowed to introduce keeps the guarantee that matters
|
|
5321
|
+
# -- any other changed byte (a swapped log, an edited count, a different run) still fails.
|
|
5322
|
+
#
|
|
5323
|
+
# `_sha256_file` is deliberately left alone: compile-validate hashes MANIFEST UNITS, where the
|
|
5324
|
+
# exact bytes ARE the claim, and a manifest that tolerated a rewrite would be tolerating the
|
|
5325
|
+
# thing it exists to detect.
|
|
5326
|
+
def _sha256_evidence(path):
|
|
5327
|
+
try:
|
|
5328
|
+
with open(path, "rb") as fh:
|
|
5329
|
+
data = fh.read()
|
|
5330
|
+
except OSError:
|
|
5331
|
+
return None
|
|
5332
|
+
return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()
|
|
5333
|
+
|
|
5334
|
+
|
|
5335
|
+
def _evidence_hash_matches(path, record):
|
|
5336
|
+
"""Does the file on disk still hash to what the RECORD carries? True / False, or None when
|
|
5337
|
+
the record carries no hash at all (the caller decides: UNMEASURED, never a pass).
|
|
5338
|
+
|
|
5339
|
+
Records written by 1.93.1 and later carry `sha256_eol: "lf"` and are compared NORMALIZED,
|
|
5340
|
+
full stop. A record written BEFORE that marker hashed the file's exact bytes, whatever
|
|
5341
|
+
line endings the machine that ran the suite happened to write -- and 1.93.0's own release
|
|
5342
|
+
record is the proof: the suite wrote CRLF on Windows, git stored LF, and the LF checkout
|
|
5343
|
+
matched neither the recorded hash nor its normalized form, because the recording was of the
|
|
5344
|
+
CRLF RENDERING of the same text. So a pre-marker record is compared against all three
|
|
5345
|
+
renderings of the bytes on disk: normalized, exact, and CRLF. The door is narrow on purpose
|
|
5346
|
+
-- it opens only for records that predate the marker, and it admits only the line-ending
|
|
5347
|
+
renderings of THIS file's text: a report whose content really changed matches none of them."""
|
|
5348
|
+
want = record.get("sha256") if isinstance(record, dict) else None
|
|
5349
|
+
if not want:
|
|
5350
|
+
return None
|
|
5351
|
+
if _sha256_evidence(path) == want:
|
|
5352
|
+
return True
|
|
5353
|
+
if record.get("sha256_eol") == "lf":
|
|
5354
|
+
return False
|
|
5355
|
+
try:
|
|
5356
|
+
with open(path, "rb") as fh:
|
|
5357
|
+
data = fh.read()
|
|
5358
|
+
except OSError:
|
|
5359
|
+
return False
|
|
5360
|
+
lf = data.replace(b"\r\n", b"\n")
|
|
5361
|
+
return want in (hashlib.sha256(data).hexdigest(),
|
|
5362
|
+
hashlib.sha256(lf.replace(b"\n", b"\r\n")).hexdigest())
|
|
5363
|
+
|
|
5364
|
+
|
|
5160
5365
|
def _contained_unit(base, unit):
|
|
5161
5366
|
"""A compilation's units must be RELATIVE paths CONTAINED within the compilation
|
|
5162
5367
|
directory: the manifest references what was compiled, and what was compiled lives with
|
|
@@ -8710,7 +8915,12 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8710
8915
|
monorepo sibling's edit is not this repo's dirt -- ignoring the ledger itself and
|
|
8711
8916
|
the report files the last snapshot names (those two are the seal's own footprint,
|
|
8712
8917
|
exactly as the reference `sh` package exempts `EVIDENCIA.md` and the logs it hashes);
|
|
8713
|
-
2.
|
|
8918
|
+
2. has the CODE moved since the commit that snapshot was taken at (`origin.commit`,
|
|
8919
|
+
ADR-007)? Amended in 1.93.0 (ADR-039): HEAD may differ from that commit by files
|
|
8920
|
+
outside `_SRC_EXT` and outside the named report set -- docs, changelogs, the ledger
|
|
8921
|
+
that carries this very snapshot -- and the seal then holds and carries a `note`
|
|
8922
|
+
naming what moved (capped at five paths). A source-relevant difference is still a
|
|
8923
|
+
break, named by its first path;
|
|
8714
8924
|
3. does every report the snapshot names still exist and still hash to what was
|
|
8715
8925
|
recorded at ingest (`sha256`, added in 1.92.0).
|
|
8716
8926
|
|
|
@@ -8725,13 +8935,14 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8725
8935
|
"checked at" would be a second wall clock inside a payload whose only other one is
|
|
8726
8936
|
`generated_at` -- and two consecutive `top --json` runs must differ in nothing else
|
|
8727
8937
|
(AC-T-24 measures exactly that, and caught this before it shipped)."""
|
|
8728
|
-
out = {"ok": None, "reasons": [], "commit": None, "repo": None}
|
|
8938
|
+
out = {"ok": None, "reasons": [], "commit": None, "repo": None, "note": None}
|
|
8729
8939
|
repos = (ledger.get("config", {}) or {}).get("repos") or []
|
|
8730
8940
|
if not repos:
|
|
8731
8941
|
out["reasons"].append("no repo configured — seal UNMEASURED")
|
|
8732
8942
|
return out
|
|
8733
8943
|
name = repos[0].get("name")
|
|
8734
8944
|
path = repos[0].get("path", ".")
|
|
8945
|
+
rtype = repos[0].get("type")
|
|
8735
8946
|
out["repo"] = _top_clean(name) if name else None
|
|
8736
8947
|
|
|
8737
8948
|
top = _seal_git(path, "rev-parse", "--show-toplevel")
|
|
@@ -8767,13 +8978,6 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8767
8978
|
snap = snaps[-1]
|
|
8768
8979
|
|
|
8769
8980
|
failures, unmeasured = [], []
|
|
8770
|
-
snap_commit = (snap.get("origin") or {}).get("commit")
|
|
8771
|
-
if not snap_commit:
|
|
8772
|
-
unmeasured.append("snapshot recorded no commit — seal UNMEASURED")
|
|
8773
|
-
elif snap_commit != head_sha:
|
|
8774
|
-
failures.append("stale seal: snapshot at %s, HEAD is %s"
|
|
8775
|
-
% (snap_commit[:8], head_sha[:8]))
|
|
8776
|
-
|
|
8777
8981
|
reports = [r for r in ((snap.get("tests") or {}).get("reports") or [])
|
|
8778
8982
|
if isinstance(r, dict) and r.get("path")]
|
|
8779
8983
|
exempt = set()
|
|
@@ -8781,6 +8985,48 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8781
8985
|
rel = _seal_rel(work_tree, candidate)
|
|
8782
8986
|
if rel:
|
|
8783
8987
|
exempt.add(rel)
|
|
8988
|
+
|
|
8989
|
+
snap_commit = (snap.get("origin") or {}).get("commit")
|
|
8990
|
+
if not snap_commit:
|
|
8991
|
+
unmeasured.append("snapshot recorded no commit — seal UNMEASURED")
|
|
8992
|
+
elif snap_commit != head_sha:
|
|
8993
|
+
# AMENDED in 1.93.0 (ADR-039): `HEAD == commit` was too strict in the one repo that
|
|
8994
|
+
# applies the method to itself -- the ledger lives INSIDE the commit that carries it,
|
|
8995
|
+
# so the release commit is always one ahead of the snapshot it publishes and the board
|
|
8996
|
+
# read `stale seal` forever on the machine that released. What the seal actually
|
|
8997
|
+
# promises is that the CODE has not moved, so a HEAD that differs only by files outside
|
|
8998
|
+
# `_SRC_EXT` and outside the named report set (docs, changelogs, the ledger itself) is
|
|
8999
|
+
# sealed WITH A NOTE that says exactly what moved. Anything source-relevant is still a
|
|
9000
|
+
# break, and it names the first offending path instead of two opaque hashes.
|
|
9001
|
+
diff = _seal_git(path, "-c", "core.quotepath=false", "diff", "--name-only",
|
|
9002
|
+
snap_commit, head_sha, "--", ".")
|
|
9003
|
+
if diff is None:
|
|
9004
|
+
# the commit is unreachable (shallow clone, rewritten history): we cannot SEE what
|
|
9005
|
+
# changed, so the strict verdict stands. Fail-closed, as before.
|
|
9006
|
+
failures.append("stale seal: snapshot at %s, HEAD is %s"
|
|
9007
|
+
% (snap_commit[:8], head_sha[:8]))
|
|
9008
|
+
else:
|
|
9009
|
+
moved = sorted(set(_git_path_list(diff.stdout)))
|
|
9010
|
+
# the REPORTS are deliberately NOT re-added here (blind review, before 1.93.0
|
|
9011
|
+
# shipped). The release ritual is: commit the code, run the suite, `snapshot` at that
|
|
9012
|
+
# commit, then commit the ledger AND the JUnit it names -- so the report is always in
|
|
9013
|
+
# the X..X+1 diff and a seal that counted it could never close on the machine that
|
|
9014
|
+
# released. Nothing is given away: the hash check below proves the file on disk is
|
|
9015
|
+
# byte-for-byte the one that was ingested, which is a stronger statement than "this
|
|
9016
|
+
# path did not appear in a diff".
|
|
9017
|
+
relevant = sorted(set(_src_relevant(moved, rtype)))
|
|
9018
|
+
if relevant:
|
|
9019
|
+
failures.append("stale seal: source changed since snapshot %s: %s"
|
|
9020
|
+
% (snap_commit[:8], relevant[0]))
|
|
9021
|
+
elif moved:
|
|
9022
|
+
shown = ", ".join(moved[:5])
|
|
9023
|
+
if len(moved) > 5:
|
|
9024
|
+
shown += " (+%d)" % (len(moved) - 5)
|
|
9025
|
+
out["note"] = ("HEAD %s differs from snapshot %s by non-source files only: %s"
|
|
9026
|
+
% (head_sha[:8], snap_commit[:8], shown))
|
|
9027
|
+
else:
|
|
9028
|
+
out["note"] = ("HEAD %s differs from snapshot %s: no file changed in the "
|
|
9029
|
+
"repo subtree" % (head_sha[:8], snap_commit[:8]))
|
|
8784
9030
|
# `core.quotepath=false`: without it git C-quotes any non-ASCII path, so a report named
|
|
8785
9031
|
# `junit-acción.xml` would appear in the reason as `junit-acción.xml` -- a reason
|
|
8786
9032
|
# nobody can act on, and an exemption that cannot match. Set on the command, never in the
|
|
@@ -8801,7 +9047,7 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8801
9047
|
elif not r.get("sha256"):
|
|
8802
9048
|
unmeasured.append("evidence hash unmeasured: %s — no hash recorded at ingest "
|
|
8803
9049
|
"(older snapshot, or the file was unreadable)" % rel)
|
|
8804
|
-
elif
|
|
9050
|
+
elif _evidence_hash_matches(full, r) is not True:
|
|
8805
9051
|
failures.append("evidence altered after ingest: %s" % rel)
|
|
8806
9052
|
|
|
8807
9053
|
out["reasons"] = failures + unmeasured
|
|
@@ -8838,6 +9084,8 @@ def cmd_check_terminado(args):
|
|
|
8838
9084
|
where = " at %s" % sealed["commit"][:8] if sealed.get("commit") else ""
|
|
8839
9085
|
print("[qa_ledger] check-terminado: %s%s (repo %s)"
|
|
8840
9086
|
% (verdict, where, sealed.get("repo") or "?"))
|
|
9087
|
+
if sealed.get("note"):
|
|
9088
|
+
print(" - %s" % sealed["note"])
|
|
8841
9089
|
for reason in sealed.get("reasons") or []:
|
|
8842
9090
|
print(" - %s" % reason)
|
|
8843
9091
|
if ok is not True:
|