@andresmassello/uscha 1.85.1 → 1.86.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.
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ uscha_top.py — the terminal projection of the QA ledger (`uscha top`, ADR-031/034).
4
+
5
+ It DERIVES NOTHING. Every state, cardinality, median and percentage on screen is read from
6
+ one read-only engine call, `qa_ledger.py top --json` (ADR-032), and `render()` is a pure
7
+ function of that object: same JSON + same size -> byte-identical lines, no clock, no files,
8
+ no subprocess, no environment. That purity is what lets the golden frames under
9
+ tests/fixtures/uscha-top/golden/ be the oracle: a renderer that quietly shows 100% while a
10
+ criterion is unmeasured fails a snapshot, not a code review (ADR-034).
11
+
12
+ Truth-pass (INV-TOP-05): a field the engine emits as null renders as an em dash, never as a
13
+ zero and never as a guess. In v0.1 that is ETA, every AGE, drift, and the trace column --
14
+ each with its deferred wiring recorded in ADR-035.
15
+
16
+ M1 scope: the read-only BOARD. The live feed (M2) and VERDICTS mode (M3) are not wired; the
17
+ panes that will hold them are labelled as such rather than faked.
18
+
19
+ Stdlib only. Python 3.8+. Runnable directly or via `python -m uscha_top`.
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+
29
+ DEFAULT_LEDGER = "QA-LEDGER.json"
30
+ FALLBACK_SIZE = (100, 32)
31
+
32
+ # Lines the board always spends on chrome: the title, 3 rules, 4 KPI lines, the table
33
+ # header, the feed label and the key hint. Everything else is table rows + feed.
34
+ CHROME_LINES = 11
35
+ FEED_MAX = 3
36
+ BURNUP_MAX = 24
37
+
38
+ # ANSI SGR by obligation state. TRACED and TAGGED deliberately share the UNMEASURED gray:
39
+ # the v0.1 engine has no source for either rung (ADR-032), so they must read as "not
40
+ # measured", never as PASS (INV-TOP-02, AC-T-08).
41
+ PALETTE = {
42
+ "MEASURED_PASS": "32",
43
+ "MEASURED_FAIL": "31",
44
+ "QUARANTINE": "33",
45
+ "UNMEASURED": "90",
46
+ "TRACED": "90",
47
+ "TAGGED": "90",
48
+ }
49
+ RESET = "\x1b[0m"
50
+ DASH = "—" # the honest "no source" marker (INV-TOP-05)
51
+ MID = "·"
52
+ RULE = "─"
53
+ BLOCKS = "▁▂▃▄▅▆▇█"
54
+
55
+ # What the reader is expected to DO about a row. Presentation, not a KPI: no number here.
56
+ ACTIONS = {
57
+ "MEASURED_PASS": DASH,
58
+ "MEASURED_FAIL": "machine: fix, then rerun",
59
+ "QUARANTINE": "you: record a verdict",
60
+ "UNMEASURED": "you: tag a test",
61
+ "TRACED": "you: tag a test",
62
+ "TAGGED": "machine: run the case",
63
+ }
64
+
65
+
66
+ # --------------------------------------------------------------------------- #
67
+ # pure rendering #
68
+ # --------------------------------------------------------------------------- #
69
+ def _fit(text, cols):
70
+ """One line, never wider than the terminal. Wrapping would break the frame's row
71
+ accounting, so an over-long line is cut and marked."""
72
+ if cols <= 0:
73
+ return ""
74
+ if len(text) <= cols:
75
+ return text
76
+ return text[:cols - 1] + "…" if cols > 1 else text[:cols]
77
+
78
+
79
+ def _spread(left, right, cols):
80
+ """left ... right on one line, right-aligned, degrading to just `left` when tight."""
81
+ if len(left) + len(right) + 1 > cols:
82
+ return _fit(left, cols)
83
+ return left + " " * (cols - len(left) - len(right)) + right
84
+
85
+
86
+ def _num(value):
87
+ """A number the engine emitted, or the em dash when it emitted null."""
88
+ return DASH if value is None else str(value)
89
+
90
+
91
+ def _pct_line(terminado):
92
+ """INV-TOP-01: the DONE bar carries an explicit `N unmeasured` suffix whenever anything
93
+ is unmeasured, and the engine has already capped the percentage below 100 while any
94
+ obligation sits outside MEASURED_PASS -- the renderer republishes that fact, it never
95
+ recomputes it (AC-T-01, AC-T-04, AC-T-23)."""
96
+ done = terminado.get("done")
97
+ total = terminado.get("total")
98
+ pct = terminado.get("pct")
99
+ unm = terminado.get("unmeasured") or 0
100
+ line = "DONE %s/%s (%s%%)" % (_num(done), _num(total), _num(pct))
101
+ if unm:
102
+ line += " %s %d unmeasured" % (MID, unm)
103
+ return line
104
+
105
+
106
+ def _burnup_line(burnup, cols):
107
+ """The score trend, labelled as a score trend. v0.1 has no obligation-count history
108
+ (ADR-035/2), so calling this a burn-up of closed obligations would be a lie the label
109
+ exists to prevent."""
110
+ points = [p for p in (burnup or {}).get("weeks") or [] if isinstance(p, (int, float))]
111
+ label = "score trend "
112
+ note = " (readiness score, not closed obligations)"
113
+ if not points:
114
+ return label + DASH + " (no `readiness --record` history yet)"
115
+ room = max(4, min(BURNUP_MAX, cols - len(label) - len(note)))
116
+ bars = "".join(BLOCKS[min(len(BLOCKS) - 1, max(0, int(p) * len(BLOCKS) // 101))]
117
+ for p in points[-room:])
118
+ return label + bars + note
119
+
120
+
121
+ def _spec_pin_text(spec_pin):
122
+ """git HEAD, labelled for what it is. There is no pinned-spec concept in the engine yet
123
+ (ADR-035/4): an unverified sha must SAY it is unverified, and a non-git tree shows the
124
+ em dash rather than a fabricated pin (AC-T-06, INV-TOP-05)."""
125
+ if not spec_pin or not spec_pin.get("sha"):
126
+ return "spec_pin " + DASH
127
+ mark = ("clean-room verified" if spec_pin.get("clean_room_verified")
128
+ else "not clean-room verified")
129
+ return "spec_pin %s (%s)" % (spec_pin["sha"], mark)
130
+
131
+
132
+ def _cases_text(ob):
133
+ total = ob.get("cases_total") or 0
134
+ if not total:
135
+ return DASH # no ingested case names this criterion: no count to show
136
+ return "%s/%s" % (_num(ob.get("cases_pass")), total)
137
+
138
+
139
+ def _row(ob, selected):
140
+ gutter = "> " if selected else " "
141
+ return "%s%-8s%-9s%-15s%7s%5s %s" % (
142
+ gutter, str(ob.get("id") or "?")[:8], str(ob.get("gate") or DASH)[:8],
143
+ str(ob.get("state") or "?")[:14], _cases_text(ob),
144
+ _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
145
+
146
+
147
+ def _colorize(line, state):
148
+ code = PALETTE.get(state)
149
+ if not code or state not in line:
150
+ return line
151
+ return line.replace(state, "\x1b[%sm%s%s" % (code, state, RESET), 1)
152
+
153
+
154
+ def render(state, size, sel=0, plain=True):
155
+ """The whole board as a list of exactly `rows` lines, none wider than `cols`.
156
+
157
+ PURE (ADR-034): no I/O, no clock, no randomness, no environment. `state` is the parsed
158
+ `qa_ledger.py top --json` object; `size` is (cols, rows); `sel` is the highlighted row.
159
+ plain=True emits no escape sequences at all -- the mode `--once`, CI and the golden
160
+ frames use, so a snapshot compares text and not terminal control codes.
161
+ """
162
+ cols, rows = size
163
+ cols = max(20, int(cols))
164
+ rows = max(CHROME_LINES + 1, int(rows))
165
+ obligations = state.get("obligations") or []
166
+ terminado = state.get("terminado") or {}
167
+ debtors = state.get("debtors") or {}
168
+ honesty = state.get("honesty") or {}
169
+
170
+ out = []
171
+ out.append(_spread("uscha top %s %s" % (MID, state.get("project") or "(unnamed project)"),
172
+ "step #%s" % _num(state.get("step")), cols))
173
+ out.append(RULE * cols)
174
+ out.append(_pct_line(terminado))
175
+ out.append("machine owes %s %s you owe %s %s untagged %s %s ETA %s"
176
+ % (_num(debtors.get("machine")), MID, _num(debtors.get("you")), MID,
177
+ _num(debtors.get("untagged")), MID, _num(state.get("eta_min"))))
178
+ # honesty travels BESIDE done on purpose (INV-TOP-04): a thin denominator has to be
179
+ # visible at the same glance as the number it flatters.
180
+ out.append("honesty %s/%s (%s%%) measured %s %s"
181
+ % (_num(honesty.get("measured")), _num(honesty.get("total")),
182
+ _num(honesty.get("pct")), MID, _spec_pin_text(state.get("spec_pin"))))
183
+ out.append(_burnup_line(state.get("burnup"), cols))
184
+ out.append(RULE * cols)
185
+ out.append(" %-8s%-9s%-15s%7s%5s %s"
186
+ % ("ID", "GATE", "STATE", "CASES", "AGE", "ACTION"))
187
+
188
+ # Budget: the table is served FIRST and the feed gets only what is left over, so a short
189
+ # terminal shortens the feed and never the board (AC-T-21).
190
+ avail = rows - CHROME_LINES
191
+ want = len(obligations) or 1
192
+ feed_n = min(FEED_MAX, max(0, avail - want))
193
+ table_n = max(1, min(want, avail - feed_n))
194
+ body = table_n - 1 if len(obligations) > table_n else table_n
195
+ body = max(1, body)
196
+ top = 0
197
+ if sel >= body:
198
+ top = min(sel - body + 1, max(0, len(obligations) - body))
199
+ top = max(0, top)
200
+
201
+ table = []
202
+ for i, ob in enumerate(obligations[top:top + body], start=top):
203
+ line = _fit(_row(ob, i == sel), cols)
204
+ table.append(line if plain else _colorize(line, ob.get("state")))
205
+ hidden = len(obligations) - len(table)
206
+ if hidden > 0:
207
+ table.append(_fit(" %s %d more obligation(s) not shown (j/k to move)"
208
+ % (DASH, hidden), cols))
209
+ if not obligations:
210
+ table.append(_fit(" no tagged criterion in the acceptance file "
211
+ "(nothing to measure yet)", cols))
212
+ out.extend(table[:max(1, table_n)])
213
+
214
+ pad = avail - len(table[:max(1, table_n)]) - feed_n
215
+ out.extend([""] * max(0, pad))
216
+ out.append(RULE * cols)
217
+ events = state.get("events_tail") or []
218
+ out.append("feed %s the live event tail is M2; nothing is invented here" % MID)
219
+ for i in range(feed_n):
220
+ if i < len(events):
221
+ ev = events[i]
222
+ out.append(_fit(" %s %s" % (ev.get("ts") or DASH, ev.get("text") or ""), cols))
223
+ else:
224
+ out.append("")
225
+ out.append("[j/k] move %s [r] reload %s [q] quit %s [v] verdicts (M3) %s "
226
+ "[d]/[o] phase 2" % (MID, MID, MID, MID))
227
+ out = [_fit(line, cols) for line in out]
228
+ # exactly `rows` lines: a frame that drifts in height is a frame no snapshot can pin
229
+ out = out[:rows] + [""] * max(0, rows - len(out))
230
+ return out
231
+
232
+
233
+ # --------------------------------------------------------------------------- #
234
+ # state loading (the ONE read boundary -- it shells out, it never re-derives) #
235
+ # --------------------------------------------------------------------------- #
236
+ def engine_path():
237
+ """qa_ledger.py inside this kit, in either skill-tree layout -- the same both-layouts
238
+ resolution `install-uscha.py` uses for the mirador renderer."""
239
+ here = os.path.dirname(os.path.realpath(__file__))
240
+ local = os.path.join(here, "qa_ledger.py")
241
+ if os.path.isfile(local):
242
+ return local
243
+ kit = os.path.realpath(os.path.join(here, "..", "..", ".."))
244
+ for rel in (("skills", "uscha-devloop", "qa_ledger.py"),
245
+ (".claude", "skills", "uscha-devloop", "qa_ledger.py")):
246
+ cand = os.path.join(kit, *rel)
247
+ if os.path.isfile(cand):
248
+ return cand
249
+ return None
250
+
251
+
252
+ def load_state(state_path=None, ledger=DEFAULT_LEDGER, engine=None):
253
+ """The board's state: either a FROZEN `top --json` object (a file -- what the golden
254
+ frames render from, no engine call) or one read-only engine call. Raises RuntimeError
255
+ with the engine's own message rather than inventing an empty board."""
256
+ if state_path:
257
+ with open(state_path, "r", encoding="utf-8") as fh:
258
+ return json.load(fh)
259
+ if not os.path.isfile(ledger):
260
+ raise RuntimeError("ledger '%s' not found here -- run the dev loop first, or pass "
261
+ "--ledger" % ledger)
262
+ eng = engine or engine_path()
263
+ if not eng:
264
+ raise RuntimeError("qa_ledger.py not found next to uscha_top.py")
265
+ proc = subprocess.run([sys.executable, eng, "top", "--json", "--ledger", ledger],
266
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
267
+ if proc.returncode != 0:
268
+ raise RuntimeError((proc.stderr or b"").decode("utf-8", "replace").strip()
269
+ or "qa_ledger.py top failed")
270
+ return json.loads(proc.stdout.decode("utf-8"))
271
+
272
+
273
+ # --------------------------------------------------------------------------- #
274
+ # terminal plumbing (mockable, and NOT what the golden frames test) #
275
+ # --------------------------------------------------------------------------- #
276
+ def enable_vt():
277
+ """Windows is first-class (ADR-031): modern conhost and Windows Terminal handle VT, and
278
+ legacy conhost needs ENABLE_VIRTUAL_TERMINAL_PROCESSING switched on through ctypes.
279
+ Returns False when it cannot be enabled -- the caller then prints a plain frame instead
280
+ of spraying raw escapes at a terminal that would show them literally (AC-T-22)."""
281
+ if os.name != "nt":
282
+ return True
283
+ try:
284
+ import ctypes
285
+ kernel32 = ctypes.windll.kernel32
286
+ handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
287
+ mode = ctypes.c_uint32()
288
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
289
+ return False
290
+ return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
291
+ except Exception:
292
+ return False
293
+
294
+
295
+ def read_key():
296
+ """One keypress, per platform, detected at runtime. Isolated in one function so the
297
+ dispatch above it can be driven by a scripted sequence in tests -- the driver itself is
298
+ not what is under test (ADR-034)."""
299
+ if os.name == "nt":
300
+ import msvcrt
301
+ ch = msvcrt.getch()
302
+ if ch in (b"\x00", b"\xe0"): # arrow keys arrive as a 2-byte pair
303
+ return {b"H": "k", b"P": "j"}.get(msvcrt.getch(), "")
304
+ return ch.decode("utf-8", "replace")
305
+ import termios
306
+ import tty
307
+ fd = sys.stdin.fileno()
308
+ saved = termios.tcgetattr(fd)
309
+ try:
310
+ tty.setraw(fd)
311
+ return sys.stdin.read(1)
312
+ finally:
313
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
314
+
315
+
316
+ def dispatch(key, sel, count):
317
+ """Key -> (new selection, quit?, reload?). Pure, so the keymap is testable without a
318
+ terminal: the driver below is not what is under test, this dispatch is (ADR-034)."""
319
+ if key in ("q", "Q", "\x03", "\x1b"):
320
+ return sel, True, False
321
+ if key == "j":
322
+ return min(sel + 1, max(0, count - 1)), False, False
323
+ if key == "k":
324
+ return max(0, sel - 1), False, False
325
+ if key == "r":
326
+ return sel, False, True
327
+ return sel, False, False
328
+
329
+
330
+ def terminal_size(cols=None, rows=None):
331
+ if cols and rows:
332
+ return int(cols), int(rows)
333
+ size = shutil.get_terminal_size(FALLBACK_SIZE)
334
+ return int(cols or size.columns), int(rows or size.lines)
335
+
336
+
337
+ def _print_frame(lines):
338
+ sys.stdout.write("\n".join(lines) + "\n")
339
+ sys.stdout.flush()
340
+
341
+
342
+ def _loop(state, args):
343
+ sel = 0
344
+ sys.stdout.write("\x1b[?25l")
345
+ try:
346
+ while True:
347
+ frame = render(state, terminal_size(args.cols, args.rows), sel=sel, plain=False)
348
+ sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(frame))
349
+ sys.stdout.flush()
350
+ sel, quit_now, reload_now = dispatch(
351
+ read_key(), sel, len(state.get("obligations") or []))
352
+ if quit_now:
353
+ return 0
354
+ if reload_now:
355
+ state = load_state(args.state, args.ledger)
356
+ except KeyboardInterrupt:
357
+ return 0
358
+ finally:
359
+ sys.stdout.write("\x1b[?25h\x1b[0m\n")
360
+ sys.stdout.flush()
361
+
362
+
363
+ def build_parser():
364
+ parser = argparse.ArgumentParser(
365
+ prog="uscha top",
366
+ description="terminal projection of the QA ledger (read-only board, ADR-031/034)")
367
+ parser.add_argument("--ledger", default=DEFAULT_LEDGER,
368
+ help="ledger the engine reads (default: %s)" % DEFAULT_LEDGER)
369
+ parser.add_argument("--state", default=None,
370
+ help="render a FROZEN `top --json` file instead of calling the "
371
+ "engine (the golden-frame path)")
372
+ parser.add_argument("--once", action="store_true",
373
+ help="print one plain frame and exit (implied without a TTY)")
374
+ parser.add_argument("--plain", action="store_true",
375
+ help="never emit escape sequences")
376
+ parser.add_argument("--refresh", type=float, default=2.0,
377
+ help="reserved for the M2 timed poll; M1 re-reads on the `r` key")
378
+ parser.add_argument("--cols", type=int, default=None)
379
+ parser.add_argument("--rows", type=int, default=None)
380
+ return parser
381
+
382
+
383
+ def main(argv=None):
384
+ for stream in (sys.stdout, sys.stderr):
385
+ try:
386
+ stream.reconfigure(encoding="utf-8")
387
+ except Exception:
388
+ pass
389
+ args = build_parser().parse_args(argv)
390
+ try:
391
+ state = load_state(args.state, args.ledger)
392
+ except (OSError, ValueError, RuntimeError) as exc:
393
+ sys.stderr.write("[uscha top] %s\n" % exc)
394
+ return 1
395
+ size = terminal_size(args.cols, args.rows)
396
+ # no TTY (pipe, CI, redirect) behaves as --once, and legacy conhost that refuses VT
397
+ # degrades the same way rather than printing escapes nobody can read (AC-T-20/22).
398
+ if args.once or args.plain or not sys.stdout.isatty() or not enable_vt():
399
+ _print_frame(render(state, size, sel=0, plain=True))
400
+ return 0
401
+ return _loop(state, args)
402
+
403
+
404
+ if __name__ == "__main__":
405
+ sys.exit(main())
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.85.1",
2
+ "version": "1.86.1",
3
3
  "project": null,
4
4
  "defaults": {
5
5
  "coverage_threshold": 60,