@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 +3 -3
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +20 -0
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +433 -16
- package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +58 -4
- package/uscha-kit/.claude-plugin/plugin.json +2 -2
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +2 -2
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/skills/uscha-devloop/SKILL.md +20 -0
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +433 -16
- package/uscha-kit/skills/uscha-devloop/uscha_top.py +58 -4
- package/uscha-kit/templates/esceptico-prompt.md +65 -0
- package/uscha-kit/uscha.config.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)")
|
|
@@ -131,6 +131,17 @@ ACTIONS = {
|
|
|
131
131
|
"TAGGED": "machine: run the case",
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
# INV-TOP-06 (ADR-038): what a row that is green ON PAPER is actually waiting on when the
|
|
135
|
+
# seal is broken. Presentation, like ACTIONS above: the engine says WHAT broke (the reason),
|
|
136
|
+
# this says what the reader does about it, and the mapping is by reason prefix so a new
|
|
137
|
+
# reason class degrades to the generic line instead of to silence.
|
|
138
|
+
SEAL_ACTIONS = (
|
|
139
|
+
("stale seal", "seal: snapshot at HEAD"),
|
|
140
|
+
("repo subtree dirty", "seal: commit or discard, then snapshot"),
|
|
141
|
+
("evidence altered", "seal: re-run the suite, then snapshot"),
|
|
142
|
+
("evidence missing", "seal: re-run the suite, then snapshot"),
|
|
143
|
+
)
|
|
144
|
+
|
|
134
145
|
|
|
135
146
|
# --------------------------------------------------------------------------- #
|
|
136
147
|
# pure rendering #
|
|
@@ -216,7 +227,15 @@ def _pct_line(terminado):
|
|
|
216
227
|
"""INV-TOP-01: the DONE bar carries an explicit `N unmeasured` suffix whenever anything
|
|
217
228
|
is unmeasured, and the engine has already capped the percentage below 100 while any
|
|
218
229
|
obligation sits outside MEASURED_PASS -- the renderer republishes that fact, it never
|
|
219
|
-
recomputes it (AC-T-01, AC-T-04, AC-T-23).
|
|
230
|
+
recomputes it (AC-T-01, AC-T-04, AC-T-23).
|
|
231
|
+
|
|
232
|
+
INV-TOP-06 (ADR-038) rides on the same line: when the engine's seal is MEASURED broken
|
|
233
|
+
(`terminado.sealed.ok is False`) the bar says so and names the first reason -- and the
|
|
234
|
+
percentage beside it is already capped below 100, in the engine, for the same reason the
|
|
235
|
+
unmeasured cap is (single derivation, AC-T-24). An UNMEASURED seal (`ok is null`: no git
|
|
236
|
+
work tree, the state of every frozen fixture) adds NOTHING here: the seal is shown only
|
|
237
|
+
when it is measured, and decorating a header with the absence of a measurement would
|
|
238
|
+
turn INV-TOP-05's `—` into noise on every board."""
|
|
220
239
|
done = terminado.get("done")
|
|
221
240
|
total = terminado.get("total")
|
|
222
241
|
pct = terminado.get("pct")
|
|
@@ -224,9 +243,37 @@ def _pct_line(terminado):
|
|
|
224
243
|
line = "DONE %s/%s (%s%%)" % (_num(done), _num(total), _num(pct))
|
|
225
244
|
if unm:
|
|
226
245
|
line += " %s %d unmeasured" % (MID, unm)
|
|
246
|
+
# the state is a FILE a human can hand us (`--state`), so `sealed` is guarded by TYPE and
|
|
247
|
+
# not merely by truthiness: a string there would answer `.get` with an AttributeError, and a
|
|
248
|
+
# `reasons` that is a string is iterable -- the frame would name its first CHARACTER as the
|
|
249
|
+
# reason. Same guards `_top_spec_diff` applies on the engine side, for the same reason.
|
|
250
|
+
seal = terminado.get("sealed")
|
|
251
|
+
seal = seal if isinstance(seal, dict) else {}
|
|
252
|
+
if seal.get("ok") is False:
|
|
253
|
+
raw = seal.get("reasons")
|
|
254
|
+
reasons = [r for r in raw if isinstance(r, str) and r] if isinstance(raw, list) else []
|
|
255
|
+
line += " %s unsealed (%s)" % (MID, _safe(reasons[0]) if reasons
|
|
256
|
+
else "no reason recorded")
|
|
227
257
|
return line
|
|
228
258
|
|
|
229
259
|
|
|
260
|
+
def _seal_action(sealed):
|
|
261
|
+
"""The ACTION cell of a row that is green on paper while the seal is broken. Empty
|
|
262
|
+
whenever the seal is not MEASURED broken -- an unmeasured seal changes no row, and a
|
|
263
|
+
`sealed` of the wrong TYPE reads as no seal at all rather than raising mid-frame."""
|
|
264
|
+
seal = sealed if isinstance(sealed, dict) else {}
|
|
265
|
+
if seal.get("ok") is not False:
|
|
266
|
+
return ""
|
|
267
|
+
raw = seal.get("reasons")
|
|
268
|
+
for reason in (raw if isinstance(raw, list) else []):
|
|
269
|
+
if not isinstance(reason, str):
|
|
270
|
+
continue
|
|
271
|
+
for prefix, action in SEAL_ACTIONS:
|
|
272
|
+
if reason.startswith(prefix):
|
|
273
|
+
return action
|
|
274
|
+
return "seal: re-snapshot the current state"
|
|
275
|
+
|
|
276
|
+
|
|
230
277
|
def _burnup_line(burnup, cols):
|
|
231
278
|
"""The score trend, labelled as a score trend. v0.1 has no obligation-count history
|
|
232
279
|
(ADR-035/2), so calling this a burn-up of closed obligations would be a lie the label
|
|
@@ -264,15 +311,21 @@ def _cases_text(ob):
|
|
|
264
311
|
return "%s/%s" % (_num(ob.get("cases_pass")), total)
|
|
265
312
|
|
|
266
313
|
|
|
267
|
-
def _row(ob, selected):
|
|
314
|
+
def _row(ob, selected, seal_action=""):
|
|
268
315
|
# the three left columns are cut and padded in COLUMNS: an id or state carrying wide
|
|
269
316
|
# characters used to eat its neighbour's field and walk every column after it.
|
|
270
317
|
gutter = "> " if selected else " "
|
|
318
|
+
action = ACTIONS.get(ob.get("state"), DASH)
|
|
319
|
+
# INV-TOP-06: only the rows that CLAIM to be done change, and only while the seal is
|
|
320
|
+
# measured broken. A failing or unmeasured row already names its own debtor; telling it
|
|
321
|
+
# about the seal too would bury the thing it is actually waiting for.
|
|
322
|
+
if seal_action and ob.get("state") == "MEASURED_PASS":
|
|
323
|
+
action = seal_action
|
|
271
324
|
return "%s%s%s%s%7s%5s %s" % (
|
|
272
325
|
gutter, _pad(_cut(_safe(ob.get("id") or "?"), 8), 8),
|
|
273
326
|
_pad(_cut(_safe(ob.get("gate") or DASH), 8), 9),
|
|
274
327
|
_pad(_cut(_safe(ob.get("state") or "?"), 14), 15), _cases_text(ob),
|
|
275
|
-
_num(ob.get("age_hours")),
|
|
328
|
+
_num(ob.get("age_hours")), action)
|
|
276
329
|
|
|
277
330
|
|
|
278
331
|
def _safe(text):
|
|
@@ -382,8 +435,9 @@ def _render_board(state, size, sel, plain, status=""):
|
|
|
382
435
|
top = max(0, top)
|
|
383
436
|
|
|
384
437
|
table = []
|
|
438
|
+
seal_action = _seal_action(terminado.get("sealed"))
|
|
385
439
|
for i, ob in enumerate(obligations[top:top + body], start=top):
|
|
386
|
-
line = _fit(_row(ob, i == sel), cols)
|
|
440
|
+
line = _fit(_row(ob, i == sel, seal_action), cols)
|
|
387
441
|
table.append(line if plain else _colorize(line, ob.get("state")))
|
|
388
442
|
hidden = len(obligations) - len(table)
|
|
389
443
|
if hidden > 0:
|