@andresmassello/uscha 1.91.0 → 1.93.0

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 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.91.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v1.93.0** <!-- 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.91.0, 52 subcommands, all measured):**
88
+ **What each arrow is, in the engine (kit 1.93.0, 53 subcommands, all measured):**
89
89
 
90
90
  | Leg | Subcommands | What it establishes |
91
91
  |---|---|---|
@@ -145,7 +145,7 @@ and see which file, which test, and when.
145
145
  | `/uscha-mirador` | Bird's-eye HTML dashboard: readiness, trail, acceptance, loops |
146
146
  | `/uscha-status` | One-line progress readout, in chat |
147
147
 
148
- **A measurement engine** (`qa_ledger.py`, 52 subcommands, Python stdlib) that ingests
148
+ **A measurement engine** (`qa_ledger.py`, 53 subcommands, Python stdlib) that ingests
149
149
  evidence from **11 language stacks** — maven, gradle, ant, python, node, go, rust, dotnet,
150
150
  cpp, swift, flutter — and computes a readiness score with hard caps and visible provenance.
151
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.91.0",
3
+ "version": "1.93.0",
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",
@@ -459,6 +459,20 @@ BLOCKER/CRITICAL + no open escalation), never self-declared — if it exits 1, t
459
459
  output lists exactly which facts are missing; do NOT open the PR, close the gap.
460
460
  A `spike/*` branch NEVER passes this gate (kit 1.19.0): spike code is disposable
461
461
  by contract — its only legitimate output is an ADR with lessons, never a merge.
462
+ - **Before declaring TERMINADO, run the seal (kit 1.92.0, INV-T1 / ADR-038):**
463
+
464
+ ```bash
465
+ python3 $QL check-terminado # 0 = sealed · 1 = broken · 2 = UNMEASURED
466
+ ```
467
+
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, 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:
473
+ re-snapshot on the CURRENT state (`snapshot --repo <REPO> --phase post`) and record why the
474
+ seal broke. **Exit 2** — the seal is UNMEASURED (no git, or no snapshot recorded): say so
475
+ plainly; an answer nobody could measure is not a TERMINADO either.
462
476
  - Ensure conventional-commit history is clean.
463
477
  - Open the PR(s). Confirm CI is green.
464
478
  - **STOP.** Present the PR link(s) and wait for the human to merge.
@@ -544,6 +558,12 @@ up as `narrated_only` and does NOT close (measured beats narrated, per criterion
544
558
  A JUnit report older than the repo's source code is treated as STALE (the code changed
545
559
  after the tests ran) and is DISCARDED — a criterion backed only by stale reports stays
546
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.
547
567
  So: when you write the tests for a criterion, put its AC-n in the test name; run
548
568
  `spec-check --acceptance ACCEPTANCE.md` up front (zero traceable criteria / duplicate
549
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,10 +701,135 @@ 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
- def _test_evidence_provenance(repo_path, repo_type):
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
+ hashes = {r["path"]: r["sha256"]
784
+ for r in ((snap.get("tests") or {}).get("reports") or [])
785
+ if isinstance(r, dict) and r.get("path") and r.get("sha256")
786
+ and r.get("fresh_by") != "stale"}
787
+ if not hashes:
788
+ return None
789
+ diff = _seal_git(repo_path, "-c", "core.quotepath=false", "diff", "--name-only",
790
+ commit, "HEAD", "--", ".")
791
+ st = _seal_git(repo_path, "-c", "core.quotepath=false", "status", "--porcelain",
792
+ "-uall", "--", ".")
793
+ if diff is None or st is None:
794
+ return None
795
+ changed = _src_relevant(_git_path_list(diff.stdout) + _porcelain_paths(st.stdout),
796
+ repo_type)
797
+ return {"commit": commit, "hashes": hashes, "src_changed": sorted(set(changed))}
798
+
799
+
800
+ def _report_fresh(repo_path, report_path, clock_fresh, content_state):
801
+ """'clock' | 'content' | None (stale) for ONE report -- the single derivation both the
802
+ snapshot record (`_test_evidence_provenance`) and the tag ingest (`_ac_tags`) read, so the
803
+ two surfaces cannot disagree about which report is current.
804
+
805
+ The CLOCK verdict is supplied by the caller on purpose: the two callers have always applied
806
+ it with different tolerances, and both stay byte-identical to 1.92.0 rather than being
807
+ quietly unified here."""
808
+ if clock_fresh:
809
+ return "clock"
810
+ if not content_state or content_state["src_changed"]:
811
+ return None
812
+ # the SAME relpath form the snapshot recorded the report under. No realpath on either
813
+ # side: both come from the same `repo_path` string, so the 8.3 mismatch of 2026-08-02
814
+ # cannot arise here, while normalizing one side would stop matching the recorded key.
815
+ rel = os.path.relpath(report_path, repo_path).replace("\\", "/")
816
+ recorded = content_state["hashes"].get(rel)
817
+ if not recorded or _sha256_file(report_path) != recorded:
818
+ return None
819
+ return "content"
820
+
821
+
822
+ def _test_evidence_provenance(repo_path, repo_type, last_snapshot=None):
705
823
  """Explain which JUnit reports back a snapshot and whether they are newer
706
824
  than relevant source/test files. No discoverable source is explicitly
707
- uncorrelated-but-usable to preserve synthetic/report-only workflows."""
825
+ uncorrelated-but-usable to preserve synthetic/report-only workflows.
826
+
827
+ Since 1.92.0 (ADR-038) each report also carries its CONTENT hash. Path and mtime
828
+ answer "which file, and was it written after the source"; they cannot answer "is
829
+ this still the file that was ingested" -- a log swapped or edited after the run
830
+ keeps its name and can keep its date. The hash is taken over the same files this
831
+ function already selects and the parser already reads: no new file is opened, and
832
+ `None` (unreadable) is recorded as absence, never as a match."""
708
833
  files = _junit_files_for(repo_path, repo_type)
709
834
  reports = []
710
835
  for path in files:
@@ -717,6 +842,7 @@ def _test_evidence_provenance(repo_path, repo_type):
717
842
  "mtime_ns": mtime_ns,
718
843
  "mtime": datetime.fromtimestamp(
719
844
  mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
845
+ "sha256": _sha256_file(path),
720
846
  })
721
847
  if not reports:
722
848
  status = "not-applicable" if repo_type == "flutter" else "missing"
@@ -737,15 +863,29 @@ def _test_evidence_provenance(repo_path, repo_type):
737
863
  "tolerance_ns": _JUNIT_FRESHNESS_TOLERANCE_NS,
738
864
  }
739
865
 
740
- stale_reports = [
741
- report for report in reports
742
- if newest["mtime_ns"] > report["mtime_ns"] + _JUNIT_FRESHNESS_TOLERANCE_NS
743
- ]
866
+ # ADR-039: the clock verdict first (unchanged, tolerance included), then rule (b) for the
867
+ # reports the clock rejects. `fresh_by` says WHICH rule answered for each report, so a
868
+ # board that reads fresh can always be asked why.
869
+ content_state = _content_state(repo_path, repo_type, last_snapshot)
870
+ stale_reports = []
871
+ for report in reports:
872
+ clock_fresh = (newest["mtime_ns"]
873
+ <= report["mtime_ns"] + _JUNIT_FRESHNESS_TOLERANCE_NS)
874
+ how = _report_fresh(repo_path, os.path.join(repo_path, report["path"]),
875
+ clock_fresh, content_state)
876
+ report["fresh_by"] = how or "stale"
877
+ if how is None:
878
+ stale_reports.append(report)
879
+ by_content = [r["path"] for r in reports if r["fresh_by"] == "content"]
744
880
  if stale_reports:
745
881
  paths = ", ".join(report["path"] for report in stale_reports)
746
882
  reason = (f"source/test {newest['path']} is newer than JUnit report(s) "
747
883
  f"{paths}")
748
884
  status = "stale"
885
+ elif by_content:
886
+ reason = ("content unchanged since %s: %s"
887
+ % (content_state["commit"][:8], ", ".join(by_content)))
888
+ status = "fresh"
749
889
  else:
750
890
  reason = "selected JUnit report(s) are current relative to source/test files"
751
891
  status = "fresh"
@@ -796,7 +936,7 @@ def _ac_tag_ids(name):
796
936
  return ids
797
937
 
798
938
 
799
- def _ac_tags(repo_path, repo_type):
939
+ def _ac_tags(repo_path, repo_type, last_snapshot=None):
800
940
  """Tags AC-n leidos de los NOMBRES de testcase en los reportes JUnit que el
801
941
  engine ya ingiere. Devuelve (tags, stale) donde tags = {'AC-n': {'green': x,
802
942
  'red': y}} (o 'AC-FAM-n' para la forma con familia, ADR-036 / kit 1.87.0)
@@ -814,14 +954,21 @@ def _ac_tags(repo_path, repo_type):
814
954
  tags = {}
815
955
  stale = []
816
956
  newest_src = _source_newest_mtime(repo_path)
957
+ # ADR-039 (kit 1.93.0): rule (b) applies HERE too, not only in the snapshot record. A
958
+ # report the clock rejects but whose content is unchanged since the snapshot commit is
959
+ # FRESH, and discarding it here while calling it fresh there would be two derivations of
960
+ # one fact, free to disagree -- the 1.48.1 mirador sin.
961
+ content_state = _content_state(repo_path, repo_type, last_snapshot)
817
962
  for f in _junit_files_for(repo_path, repo_type):
963
+ clock_fresh = True
818
964
  if newest_src > 0.0:
819
965
  try:
820
- if os.path.getmtime(f) < newest_src:
821
- stale.append(f)
822
- continue
966
+ clock_fresh = os.path.getmtime(f) >= newest_src
823
967
  except OSError:
824
968
  pass
969
+ if _report_fresh(repo_path, f, clock_fresh, content_state) is None:
970
+ stale.append(f)
971
+ continue
825
972
  try:
826
973
  root = _parse_xml(f).getroot()
827
974
  except (ET.ParseError, OSError):
@@ -865,7 +1012,11 @@ def _sum_ac_tags(ledger):
865
1012
  ac_tags = {}
866
1013
  stale_reports = []
867
1014
  for rcfg in (ledger.get("config") or {}).get("repos", []):
868
- rtags, rstale = _ac_tags(rcfg.get("path", "."), rcfg.get("type", "maven"))
1015
+ # the LAST snapshot of this repo is what rule (b) compares against (ADR-039): the
1016
+ # commit the evidence was measured at and the hash each report carried at ingest.
1017
+ snaps = ((ledger.get("repos") or {}).get(rcfg.get("name")) or {}).get("snapshots") or []
1018
+ rtags, rstale = _ac_tags(rcfg.get("path", "."), rcfg.get("type", "maven"),
1019
+ snaps[-1] if snaps else None)
869
1020
  for cid, v in rtags.items():
870
1021
  d = ac_tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
871
1022
  d["green"] += v["green"]
@@ -936,7 +1087,7 @@ def junit_test_count(repo_path, extra_files=None):
936
1087
  "passed": executed - failures - errors, "report_found": bool(files)}
937
1088
 
938
1089
 
939
- def test_count(repo_path, repo_type):
1090
+ def test_count(repo_path, repo_type, last_snapshot=None):
940
1091
  if repo_type == "ant":
941
1092
  result = ant_test_count(repo_path)
942
1093
  elif repo_type == "maven":
@@ -955,7 +1106,7 @@ def test_count(repo_path, repo_type):
955
1106
  result = junit_test_count(repo_path)
956
1107
  else:
957
1108
  result = flutter_test_count(repo_path)
958
- reports, freshness = _test_evidence_provenance(repo_path, repo_type)
1109
+ reports, freshness = _test_evidence_provenance(repo_path, repo_type, last_snapshot)
959
1110
  result["reports"] = reports
960
1111
  result["freshness"] = freshness
961
1112
  return result
@@ -1981,10 +2132,14 @@ def _snapshot(ledger, name):
1981
2132
  cfg = _repo_cfg(ledger, name) if name != "integration" else {"path": ".", "type": "maven"}
1982
2133
  path = cfg["path"]
1983
2134
  rtype = cfg["type"]
2135
+ # the PREVIOUS snapshot, read before this one is appended: rule (b) asks whether the
2136
+ # reports are still the files THAT run ingested, at a commit with no source change since
2137
+ # (ADR-039). Comparing a snapshot against itself would be circular.
2138
+ prev = (node.get("snapshots") or [])[-1:] or [None]
1984
2139
  snap = {
1985
2140
  "at": _now(),
1986
2141
  "coverage": coverage(path, rtype),
1987
- "tests": test_count(path, rtype),
2142
+ "tests": test_count(path, rtype, prev[0]),
1988
2143
  "loc": count_loc(path, rtype),
1989
2144
  "origin": _evidence_origin(path),
1990
2145
  }
@@ -8639,6 +8794,248 @@ def _top_spec_diff(ledger):
8639
8794
  "source": "spec-drift"}
8640
8795
 
8641
8796
 
8797
+ SEAL_NO_GIT = "no git work tree — seal UNMEASURED"
8798
+ SEAL_NO_COMMIT = "git repo without commits — seal UNMEASURED"
8799
+
8800
+
8801
+ def _seal_git(repo_path, *argv):
8802
+ """One git read for the seal, or None. Same OSError posture as `_evidence_origin`:
8803
+ git absent, or a repo path that does not exist, is an ordinary state of the world and
8804
+ must degrade to UNMEASURED, never take down the command it only annotates."""
8805
+ try:
8806
+ r = subprocess.run(["git"] + list(argv), cwd=repo_path, capture_output=True,
8807
+ text=True, encoding="utf-8", errors="replace")
8808
+ except OSError:
8809
+ return None
8810
+ return r if r.returncode == 0 else None
8811
+
8812
+
8813
+ def _seal_rel(work_tree, path):
8814
+ """`path` as git names it: relative to the work tree root, forward slashes, or None
8815
+ when it falls outside the tree.
8816
+
8817
+ BOTH sides go through `realpath` first. On Windows one API answers with an 8.3 short
8818
+ name (`RUNNER~1`) and another with the long one, and `relpath` between the two yields
8819
+ `..\\..` for a file plainly inside the tree — the CI-only failure paid for on
8820
+ 2026-08-02. A None here can only WITHHOLD an exemption, so the seal fails closed."""
8821
+ try:
8822
+ rel = os.path.relpath(os.path.realpath(path), os.path.realpath(work_tree))
8823
+ except (OSError, ValueError):
8824
+ return None
8825
+ rel = rel.replace("\\", "/")
8826
+ return None if rel == ".." or rel.startswith("../") else rel
8827
+
8828
+
8829
+ def _porcelain_paths(text):
8830
+ """Every path named by `git status --porcelain -uall`, rename destinations included.
8831
+
8832
+ A path with special characters comes back C-quoted; the quotes are stripped and any
8833
+ escape inside is left as-is. That can only fail to MATCH an exemption, which leaves the
8834
+ seal broken — the safe direction: an unrecognized change is dirt, never a pass."""
8835
+ out = []
8836
+ for line in (text or "").splitlines():
8837
+ if len(line) < 4:
8838
+ continue
8839
+ for part in line[3:].split(" -> "):
8840
+ part = part.strip()
8841
+ if len(part) >= 2 and part[0] == '"' and part[-1] == '"':
8842
+ part = part[1:-1]
8843
+ if part:
8844
+ out.append(part.replace("\\", "/"))
8845
+ return out
8846
+
8847
+
8848
+ def _sealed_state(ledger, ledger_path):
8849
+ """INV-T1 (ADR-038): is the recorded evidence bound to the code state on disk RIGHT NOW?
8850
+
8851
+ Derived at read time, never written: nothing here creates a file, and re-deriving it is
8852
+ the only way it can be trusted — a stored verdict is a claim about a tree that has moved
8853
+ on since. Three questions, all answerable from what the ledger already carries:
8854
+
8855
+ 1. is the TRACKED REPO'S SUBTREE clean -- `git status ... -- .` inside the configured
8856
+ repo path, the same per-path scoping `_evidence_origin` uses (ADR-007), so a
8857
+ monorepo sibling's edit is not this repo's dirt -- ignoring the ledger itself and
8858
+ the report files the last snapshot names (those two are the seal's own footprint,
8859
+ exactly as the reference `sh` package exempts `EVIDENCIA.md` and the logs it hashes);
8860
+ 2. has the CODE moved since the commit that snapshot was taken at (`origin.commit`,
8861
+ ADR-007)? Amended in 1.93.0 (ADR-039): HEAD may differ from that commit by files
8862
+ outside `_SRC_EXT` and outside the named report set -- docs, changelogs, the ledger
8863
+ that carries this very snapshot -- and the seal then holds and carries a `note`
8864
+ naming what moved (capped at five paths). A source-relevant difference is still a
8865
+ break, named by its first path;
8866
+ 3. does every report the snapshot names still exist and still hash to what was
8867
+ recorded at ingest (`sha256`, added in 1.92.0).
8868
+
8869
+ Three verdicts, never two: `True` sealed, `False` a MEASURED break (the reasons say
8870
+ which), `None` UNMEASURED — no git work tree, or a snapshot old enough to predate the
8871
+ content hash. A measured break outranks an unmeasured check (fail-closed); an unmeasured
8872
+ check never reads as a pass (INV-TOP-05). The repo is the FIRST configured one, the same
8873
+ choice `_top_spec_pin` and `_top_repos` make, and it is named in the `repo` member so
8874
+ every reason below is read against it.
8875
+
8876
+ The block carries NO timestamp of its own. It is recomputed on every read, so a
8877
+ "checked at" would be a second wall clock inside a payload whose only other one is
8878
+ `generated_at` -- and two consecutive `top --json` runs must differ in nothing else
8879
+ (AC-T-24 measures exactly that, and caught this before it shipped)."""
8880
+ out = {"ok": None, "reasons": [], "commit": None, "repo": None, "note": None}
8881
+ repos = (ledger.get("config", {}) or {}).get("repos") or []
8882
+ if not repos:
8883
+ out["reasons"].append("no repo configured — seal UNMEASURED")
8884
+ return out
8885
+ name = repos[0].get("name")
8886
+ path = repos[0].get("path", ".")
8887
+ rtype = repos[0].get("type")
8888
+ out["repo"] = _top_clean(name) if name else None
8889
+
8890
+ top = _seal_git(path, "rev-parse", "--show-toplevel")
8891
+ if top is None or not top.stdout.strip():
8892
+ out["reasons"].append(SEAL_NO_GIT)
8893
+ return out
8894
+ head = _seal_git(path, "rev-parse", "HEAD")
8895
+ if head is None or not head.stdout.strip():
8896
+ # a git tree with no commit yet: `rev-parse HEAD` fails on an unborn branch. It is a
8897
+ # DIFFERENT absence from "not a work tree" and the reason says so -- the verdict is
8898
+ # the same UNMEASURED, but a reason that misnames the cause sends the reader to the
8899
+ # wrong fix.
8900
+ out["reasons"].append(SEAL_NO_COMMIT)
8901
+ return out
8902
+ head_sha = head.stdout.strip()
8903
+ work_tree = top.stdout.strip()
8904
+ out["commit"] = head_sha
8905
+
8906
+ snaps = ((ledger.get("repos") or {}).get(name) or {}).get("snapshots") or []
8907
+ if not snaps:
8908
+ # UNMEASURED, not broken. The reference `sh` package calls a missing EVIDENCIA.md a
8909
+ # rejection, and that is right for a file whose only job is to be the seal -- but a
8910
+ # snapshot is the INGEST record, and with none recorded there is nothing to compare
8911
+ # the tree against: not "the evidence is stale", not "the evidence was altered",
8912
+ # simply no anchor. Calling that a break would also make the seal non-deterministic
8913
+ # for a board whose evidence is read live from reports (the `top` fixtures are
8914
+ # exactly that), and INV-TOP-05 already fixes the posture: absence renders as
8915
+ # absence. The teeth stay where they bite -- `check-terminado` exits 2, so a hook or
8916
+ # a human gating on exit 0 still refuses. The LIMIT this leaves is stated out loud in
8917
+ # SPEC §4: a board at 100% with no snapshot at all carries no seal marker.
8918
+ out["reasons"].append("no snapshot recorded yet")
8919
+ return out
8920
+ snap = snaps[-1]
8921
+
8922
+ failures, unmeasured = [], []
8923
+ reports = [r for r in ((snap.get("tests") or {}).get("reports") or [])
8924
+ if isinstance(r, dict) and r.get("path")]
8925
+ exempt = set()
8926
+ for candidate in [ledger_path] + [os.path.join(path, r["path"]) for r in reports]:
8927
+ rel = _seal_rel(work_tree, candidate)
8928
+ if rel:
8929
+ exempt.add(rel)
8930
+
8931
+ snap_commit = (snap.get("origin") or {}).get("commit")
8932
+ if not snap_commit:
8933
+ unmeasured.append("snapshot recorded no commit — seal UNMEASURED")
8934
+ elif snap_commit != head_sha:
8935
+ # AMENDED in 1.93.0 (ADR-039): `HEAD == commit` was too strict in the one repo that
8936
+ # applies the method to itself -- the ledger lives INSIDE the commit that carries it,
8937
+ # so the release commit is always one ahead of the snapshot it publishes and the board
8938
+ # read `stale seal` forever on the machine that released. What the seal actually
8939
+ # promises is that the CODE has not moved, so a HEAD that differs only by files outside
8940
+ # `_SRC_EXT` and outside the named report set (docs, changelogs, the ledger itself) is
8941
+ # sealed WITH A NOTE that says exactly what moved. Anything source-relevant is still a
8942
+ # break, and it names the first offending path instead of two opaque hashes.
8943
+ diff = _seal_git(path, "-c", "core.quotepath=false", "diff", "--name-only",
8944
+ snap_commit, head_sha, "--", ".")
8945
+ if diff is None:
8946
+ # the commit is unreachable (shallow clone, rewritten history): we cannot SEE what
8947
+ # changed, so the strict verdict stands. Fail-closed, as before.
8948
+ failures.append("stale seal: snapshot at %s, HEAD is %s"
8949
+ % (snap_commit[:8], head_sha[:8]))
8950
+ else:
8951
+ moved = sorted(set(_git_path_list(diff.stdout)))
8952
+ # the REPORTS are deliberately NOT re-added here (blind review, before 1.93.0
8953
+ # shipped). The release ritual is: commit the code, run the suite, `snapshot` at that
8954
+ # commit, then commit the ledger AND the JUnit it names -- so the report is always in
8955
+ # the X..X+1 diff and a seal that counted it could never close on the machine that
8956
+ # released. Nothing is given away: the hash check below proves the file on disk is
8957
+ # byte-for-byte the one that was ingested, which is a stronger statement than "this
8958
+ # path did not appear in a diff".
8959
+ relevant = sorted(set(_src_relevant(moved, rtype)))
8960
+ if relevant:
8961
+ failures.append("stale seal: source changed since snapshot %s: %s"
8962
+ % (snap_commit[:8], relevant[0]))
8963
+ elif moved:
8964
+ shown = ", ".join(moved[:5])
8965
+ if len(moved) > 5:
8966
+ shown += " (+%d)" % (len(moved) - 5)
8967
+ out["note"] = ("HEAD %s differs from snapshot %s by non-source files only: %s"
8968
+ % (head_sha[:8], snap_commit[:8], shown))
8969
+ else:
8970
+ out["note"] = ("HEAD %s differs from snapshot %s: no file changed in the "
8971
+ "repo subtree" % (head_sha[:8], snap_commit[:8]))
8972
+ # `core.quotepath=false`: without it git C-quotes any non-ASCII path, so a report named
8973
+ # `junit-acción.xml` would appear in the reason as `junit-acción.xml` -- a reason
8974
+ # nobody can act on, and an exemption that cannot match. Set on the command, never in the
8975
+ # user's config: the engine reads git, it does not configure it.
8976
+ st = _seal_git(path, "-c", "core.quotepath=false",
8977
+ "status", "--porcelain", "-uall", "--", ".")
8978
+ if st is None:
8979
+ unmeasured.append("repo subtree state unreadable — seal UNMEASURED")
8980
+ else:
8981
+ dirty = sorted(set(_porcelain_paths(st.stdout)) - exempt)
8982
+ if dirty:
8983
+ failures.append("repo subtree dirty: changes no snapshot covers (%s)" % dirty[0])
8984
+
8985
+ for r in reports:
8986
+ rel, full = r["path"], os.path.join(path, r["path"])
8987
+ if not os.path.isfile(full):
8988
+ failures.append("evidence missing: %s" % rel)
8989
+ elif not r.get("sha256"):
8990
+ unmeasured.append("evidence hash unmeasured: %s — no hash recorded at ingest "
8991
+ "(older snapshot, or the file was unreadable)" % rel)
8992
+ elif _sha256_file(full) != r["sha256"]:
8993
+ failures.append("evidence altered after ingest: %s" % rel)
8994
+
8995
+ out["reasons"] = failures + unmeasured
8996
+ out["ok"] = False if failures else (None if unmeasured else True)
8997
+ return out
8998
+
8999
+
9000
+ def cmd_check_terminado(args):
9001
+ """The enforcement side of INV-T1: the SAME `_sealed_state` derivation `top --json`
9002
+ publishes, with an exit code a hook or a human can act on. It measures the tree and
9003
+ prints; it writes nothing and it decides nothing else.
9004
+
9005
+ 0 = sealed · 1 = a measured break · 2 = UNMEASURED (no git work tree, no configured
9006
+ repo, a snapshot with no recorded hash -- or no readable ledger at all). 2 is the
9007
+ reference script's error class: "I could not answer" is not "yes".
9008
+
9009
+ A missing or corrupt ledger is UNMEASURED, not a break: `_load` exits 1 by design, and 1
9010
+ here means "I checked and the seal is broken". Reporting "not sealed" for a file the
9011
+ command never managed to read would be a verdict on evidence nobody looked at -- the
9012
+ exact failure this command exists to catch."""
9013
+ try:
9014
+ ledger = _load(args.ledger)
9015
+ except SystemExit as exc:
9016
+ message = exc.code if isinstance(exc.code, str) else None
9017
+ print(message or "[qa_ledger] check-terminado: ledger '%s' unreadable" % args.ledger)
9018
+ print("[qa_ledger] check-terminado: UNMEASURED — no readable ledger, no seal.")
9019
+ sys.exit(2)
9020
+ sealed = _sealed_state(ledger, args.ledger)
9021
+ ok = sealed.get("ok")
9022
+ if getattr(args, "json", False):
9023
+ print(json.dumps(sealed, indent=2, ensure_ascii=False))
9024
+ else:
9025
+ verdict = "SEALED" if ok is True else ("UNSEALED" if ok is False else "UNMEASURED")
9026
+ where = " at %s" % sealed["commit"][:8] if sealed.get("commit") else ""
9027
+ print("[qa_ledger] check-terminado: %s%s (repo %s)"
9028
+ % (verdict, where, sealed.get("repo") or "?"))
9029
+ if sealed.get("note"):
9030
+ print(" - %s" % sealed["note"])
9031
+ for reason in sealed.get("reasons") or []:
9032
+ print(" - %s" % reason)
9033
+ if ok is not True:
9034
+ print(" TERMINADO is not enabled: re-run the evidence and `snapshot` "
9035
+ "on the current state.")
9036
+ sys.exit(0 if ok is True else (1 if ok is False else 2))
9037
+
9038
+
8642
9039
  def cmd_top(args):
8643
9040
  """`uscha top` — the WHOLE projection of the ledger as one read-only JSON (ADR-032).
8644
9041
 
@@ -8744,6 +9141,15 @@ def cmd_top(args):
8744
9141
  done, fail, quar = _n("MEASURED_PASS"), _n("MEASURED_FAIL"), _n("QUARANTINE")
8745
9142
  unmeasured = _n("UNMEASURED") + _n("TRACED")
8746
9143
  pct = _top_pct(done, total)
9144
+ # INV-TOP-06 (ADR-038): DONE never publishes 100% while the seal is MEASURED broken --
9145
+ # every criterion green against evidence that no longer belongs to this code state is
9146
+ # the same lie INV-TOP-01 forbids one row earlier. The cap lives HERE, beside the
9147
+ # rounding cap, so no renderer is the place it happens. An UNMEASURED seal (`ok is
9148
+ # None` -- no git) does NOT cap: absence of measurement is not evidence of a break, and
9149
+ # capping on it would put an unearned 99 on every non-git tree.
9150
+ sealed = _sealed_state(ledger, args.ledger)
9151
+ if sealed.get("ok") is False and pct >= 100:
9152
+ pct = 99
8747
9153
  measured = done + fail
8748
9154
  out = {
8749
9155
  "schema": TOP_SCHEMA,
@@ -8760,7 +9166,10 @@ def cmd_top(args):
8760
9166
  "events_tail": _top_events(ledger),
8761
9167
  "counts": {"measured_pass": done, "measured_fail": fail, "quarantine": quar,
8762
9168
  "unmeasured": _n("UNMEASURED"), "traced": 0, "tagged": 0, "total": total},
8763
- "terminado": {"done": done, "total": total, "pct": pct, "unmeasured": unmeasured},
9169
+ # `sealed` is DERIVED at read time from the ledger plus the tree (ADR-038); it is
9170
+ # never stored, so it cannot go stale the way the claim it guards can.
9171
+ "terminado": {"done": done, "total": total, "pct": pct, "unmeasured": unmeasured,
9172
+ "sealed": sealed},
8764
9173
  "debtors": {"machine": fail, "you": quar, "untagged": unmeasured},
8765
9174
  "honesty": {"measured": measured, "total": total,
8766
9175
  "pct": _top_pct(measured, total)},
@@ -11952,6 +12361,14 @@ def build_parser():
11952
12361
  ptop.add_argument("--json", action="store_true")
11953
12362
  ptop.set_defaults(func=cmd_top)
11954
12363
 
12364
+ pct = sub.add_parser("check-terminado",
12365
+ help="INV-T1 (ADR-038): is TERMINADO sealed to the code state on "
12366
+ "disk? Same derivation `top --json` publishes. Exit 0 sealed, "
12367
+ "1 broken, 2 UNMEASURED")
12368
+ add_ledger(pct)
12369
+ pct.add_argument("--json", action="store_true")
12370
+ pct.set_defaults(func=cmd_check_terminado)
12371
+
11955
12372
  pb = sub.add_parser("rebuild",
11956
12373
  help="rebuild test: is the SPEC complete enough to "
11957
12374
  "regenerate the system? (completeness, not correctness)")