@andresmassello/uscha 1.89.0 → 1.90.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.
Files changed (35) hide show
  1. package/README.md +11 -7
  2. package/package.json +1 -1
  3. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +144 -18
  4. package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +90 -19
  5. package/uscha-kit/.claude-plugin/plugin.json +1 -1
  6. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  7. package/uscha-kit/README.md +1 -1
  8. package/uscha-kit/VERSION +1 -1
  9. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +144 -18
  10. package/uscha-kit/skills/uscha-devloop/uscha_top.py +90 -19
  11. package/uscha-kit/uscha.config.json +1 -1
  12. package/uscha-kit/reports/junit/.bench-cases.json +0 -1
  13. package/uscha-kit/reports/junit/.bench-curate-cases.json +0 -1
  14. package/uscha-kit/reports/junit/.bootstrap-cases.json +0 -1
  15. package/uscha-kit/reports/junit/.cleanroom-cases.json +0 -1
  16. package/uscha-kit/reports/junit/.compile-cases.json +0 -1
  17. package/uscha-kit/reports/junit/.curation-cases.json +0 -1
  18. package/uscha-kit/reports/junit/.delta-cases.json +0 -1
  19. package/uscha-kit/reports/junit/.fa-cases.json +0 -1
  20. package/uscha-kit/reports/junit/.facts-cases.json +0 -1
  21. package/uscha-kit/reports/junit/.fastpath-cases.json +0 -1
  22. package/uscha-kit/reports/junit/.fidelity-cases.json +0 -1
  23. package/uscha-kit/reports/junit/.goldencov-cases.json +0 -1
  24. package/uscha-kit/reports/junit/.ir-cases.json +0 -1
  25. package/uscha-kit/reports/junit/.js-cases.json +0 -1
  26. package/uscha-kit/reports/junit/.lang-cases.json +0 -1
  27. package/uscha-kit/reports/junit/.lang3-cases.json +0 -1
  28. package/uscha-kit/reports/junit/.multi-cases.json +0 -1
  29. package/uscha-kit/reports/junit/.oracle-cases.json +0 -1
  30. package/uscha-kit/reports/junit/.origin-cases.json +0 -1
  31. package/uscha-kit/reports/junit/.r2-cases.json +0 -1
  32. package/uscha-kit/reports/junit/.rt-cases.json +0 -1
  33. package/uscha-kit/reports/junit/.sched-cases.json +0 -1
  34. package/uscha-kit/reports/junit/.specdrift-cases.json +0 -1
  35. package/uscha-kit/reports/junit/.top-cases.json +0 -1
package/README.md CHANGED
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
40
40
  runtime dependencies). The npm package is a thin router; the canonical installer is
41
41
  `uscha-kit/install-uscha.py`.
42
42
 
43
- **Kit v1.89.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
43
+ **Kit v1.90.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
 
@@ -82,10 +82,10 @@ automatic tool can perform: a human verdict.
82
82
  system (= 100% drift)
83
83
 
84
84
  round trip · bench-roundtrip — how much of the asset the reverse organs re-anchor
85
- from the compiled code: 0.062 measured (12 archetypes) — names, not yet semantics
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.89.0, 52 subcommands, all measured):**
88
+ **What each arrow is, in the engine (kit 1.90.0, 52 subcommands, all measured):**
89
89
 
90
90
  | Leg | Subcommands | What it establishes |
91
91
  |---|---|---|
@@ -94,13 +94,17 @@ automatic tool can perform: a human verdict.
94
94
  | Forward, is it the *same* system? | `bootstrap-oracle`, `bootstrap-variance`, `bench` | a withheld oracle judges blind compilations — **12 archetypes, 9 PASS · 3 PARTIAL**, three models, JS included (M4/M5, ADR-017/018/028/029) |
95
95
  | Reverse, facts | `discover`, `golden-diff` (+ the `/uscha-characterize` skill) | system map + mechanically captured golden; typed candidate observations with evidence class (M1, ADR-013) |
96
96
  | Reverse, the human gate | `curate`, `promote`, `curation-check`, `bench-curate` | one verdict per candidate, append-only ledger verified against git; unjudged → `pr-ready` blocked naming it (ADR-009/010, INV-CURATION-01) |
97
- | Fidelity, honestly | `fidelity`, `roundtrip`, `bench-roundtrip`, `bench-r2` | per-compiler fidelity vector, id-level round trip, recoverability **0.062**, and the **noise floor** under every variance claim (ADR-014/022/027/030) |
97
+ | Fidelity, honestly | `fidelity`, `roundtrip`, `bench-roundtrip`, `bench-r2` | per-compiler fidelity vector, id-level round trip, recoverability **0.828**, and the **noise floor** under every variance claim (ADR-014/022/027/030) |
98
98
 
99
99
  **Read the numbers the way the repo does.** 9 of 12 archetypes regenerate to the same system
100
- under an oracle the compilers never saw — that is the closed loop working. 0.062 is the mean
100
+ under an oracle the compilers never saw — that is the closed loop working. 0.828 is the mean
101
101
  *recoverability* of the asset from compiled code counting only static and behavioural
102
- footing, with the behaviour dimension still `UNMEASURED` it says the reverse organs anchor
103
- **names, not yet semantics**, and it is published rather than smoothed. And `bench-r2` measured
102
+ footing. It read **0.062** until 1.90.0, with the behaviour dimension `UNMEASURED`, because no
103
+ oracle case carried an AC tag a named absence, not a zero. The 12 bench oracles are now
104
+ curated per case (`ORACLE-TAGS-CURATED.json`, human-authored; payloads and expectations
105
+ untouched), so the dimension is measured and the number says the reverse organs anchor **names
106
+ and behaviour**. Both numbers are published rather than smoothed, and what moved between them
107
+ was the tagging, not the code under test. And `bench-r2` measured
104
108
  that same-model reruns differ structurally about as much as different models do (aggregate
105
109
  `NOISY`) — so one earlier variance narrative was **retracted**. Every claim above is a subcommand
106
110
  you can run; every unmeasured part is labeled. That honesty is the method applied to itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andresmassello/uscha",
3
- "version": "1.89.0",
3
+ "version": "1.90.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",
@@ -3423,6 +3423,15 @@ def cmd_golden_coverage(args):
3423
3423
 
3424
3424
  env = dict(os.environ)
3425
3425
  env["COVERAGE_PROCESS_START"] = rc
3426
+ # COVERAGE_FILE is set EXPLICITLY, not merely inherited. coverage.py resolves its
3427
+ # data_file with the environment variable AFTER the rc file, so an inherited
3428
+ # COVERAGE_FILE (the shape the kit's own suite has under USCHA_COVERAGE=1) silently
3429
+ # overrode the isolated data_file above and the capture landed in the caller's shared
3430
+ # file -- the map then read as "covers nothing" and AC-GM-08 went red under coverage
3431
+ # while passing plain. Writing it here pins the child to OUR file whether or not the
3432
+ # caller has one; the parent's own Coverage(data_file=...) already wins over the
3433
+ # environment because constructor arguments are applied last.
3434
+ env["COVERAGE_FILE"] = data_file
3426
3435
  env["PYTHONPATH"] = tmp + os.pathsep + env.get("PYTHONPATH", "")
3427
3436
  env["PYTHONIOENCODING"] = "utf-8"
3428
3437
  r = subprocess.run([sys.executable, harness], cwd=root, env=env,
@@ -5468,6 +5477,47 @@ def _static_surface_for(cd, unit):
5468
5477
  return so, []
5469
5478
 
5470
5479
 
5480
+ def _judged_env():
5481
+ """The environment a JUDGED program gets: this process's, minus the hooks that would make
5482
+ a third party instrument it, plus the one setting that keeps it from leaving anything
5483
+ behind in the tree it is judged from.
5484
+
5485
+ The withheld oracle measures a compiled implementation by its exit code and its stdout. A
5486
+ measurement that changes what it measures is a broken measurement, and coverage.py's
5487
+ documented subprocess hook does exactly that: with `COVERAGE_PROCESS_START` in the
5488
+ environment, coverage.py's own `.pth` (`a1_coverage.pth`, shipped in site-packages) starts
5489
+ a full Coverage in EVERY python process, at interpreter start-up, before any of that
5490
+ process's own code runs. The oracle's children are fixture programs that sit outside every
5491
+ `--source` root, so what they record is empty by construction -- pure cost, no measurement.
5492
+
5493
+ That cost is not theoretical (1.90.0). Running this repo's own smoke suite under
5494
+ `USCHA_COVERAGE=1` spawned ~14,000 such children; `coverage combine` reported
5495
+ "Combined 600 files, skipped 13702" -- 96% of them recorded nothing -- and late in the run
5496
+ Windows began refusing to create processes: seven bench archetypes flipped to FAIL because
5497
+ an oracle child could not start, and a py3.8 interpreter came back 3221225794
5498
+ (0xC0000142, STATUS_DLL_INIT_FAILED) with an empty stderr. Nothing was wrong with the
5499
+ engine or the fixtures; the instrument was crushing the machine it measured on.
5500
+
5501
+ Only the two variables that START coverage in a child are dropped. `COVERAGE_FILE` is
5502
+ left alone: it names a data file, it does not turn anything on, and a caller that set it
5503
+ means it.
5504
+
5505
+ `PYTHONDONTWRITEBYTECODE` is SET, and that one is not about coverage (1.90.0, ADR-030
5506
+ amended). A multi-unit compilation imports its sibling module, so running it writes
5507
+ `__pycache__/*.pyc` INTO the fixture directory -- and the round trip's own criterion is
5508
+ that the instrument regenerates nothing under the bench tree (AC-RT-01 snapshots the file
5509
+ list before and after). It only became reachable when the behaviour dimension started
5510
+ running cases: measured on a clean tree, `bench-roundtrip` left three `.pyc` files behind.
5511
+ In the suite the earlier `bench` block had already warmed the cache, so the red would have
5512
+ waited for a fresh CI clone to appear -- an ordering dependency, which is the kind of
5513
+ green worth nothing. Judging a program must not modify what is being judged."""
5514
+ env = dict(os.environ)
5515
+ for key in ("COVERAGE_PROCESS_START", "COVERAGE_PROCESS_CONFIG"):
5516
+ env.pop(key, None)
5517
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
5518
+ return env
5519
+
5520
+
5471
5521
  def _impl_interpreter(impl_path):
5472
5522
  """Resolve the interpreter argv prefix for one implementation file by extension (ADR-028):
5473
5523
  `.py` runs under this same Python (unchanged); `.js` runs under `node`, resolved from PATH.
@@ -5508,7 +5558,7 @@ def _run_oracle_case(impl_path, case):
5508
5558
  # sibling modules by bare name; single-unit impls are unaffected by their cwd
5509
5559
  r = subprocess.run(interp + [os.path.abspath(impl_path)], input=stdin,
5510
5560
  capture_output=True, text=True, encoding="utf-8", errors="replace",
5511
- timeout=_BOOTSTRAP_CASE_TIMEOUT,
5561
+ timeout=_BOOTSTRAP_CASE_TIMEOUT, env=_judged_env(),
5512
5562
  cwd=os.path.dirname(os.path.abspath(impl_path)) or None)
5513
5563
  got, out, err = r.returncode, r.stdout, None
5514
5564
  except subprocess.TimeoutExpired:
@@ -6307,6 +6357,41 @@ def _rt_ids_in(text):
6307
6357
  return out
6308
6358
 
6309
6359
 
6360
+ def _rt_ac_key(cid):
6361
+ """ONE spelling for comparing an AC id, so the round-trip's footings cannot disagree over
6362
+ punctuation (ADR-030, amended 1.90.0).
6363
+
6364
+ The three footings read ids from three places that spell them differently: an IR node id is
6365
+ written the way a human wrote the acceptance line and keeps its padding (`AC-DD-07`), a
6366
+ source comment or a curated oracle tag may write `AC-DD-7` or `AC_DD_07`, and `_rt_ids_in`
6367
+ only upper-cases what it finds. Comparing those raw was a match that depended on a zero.
6368
+ The normal form is ADR-036's OWN grammar (`_ac_tag_ids`, family + integer, padding and
6369
+ separator dropped) -- reused rather than reimplemented, because a second grammar for the
6370
+ same id is the drift this function exists to prevent. It is applied to BOTH sides of the
6371
+ behaviour comparison and nowhere else: `per_node["id"]`, the static footing and the
6372
+ manifest footing keep the IR's own spelling, so no report changes shape. A non-AC id
6373
+ (INV-*, ADR-*) has no behaviour footing at all and falls through upper-cased."""
6374
+ ids = _ac_tag_ids(str(cid or ""))
6375
+ return ids[0] if ids else str(cid or "").upper().replace("_", "-")
6376
+
6377
+
6378
+ def _rt_case_tags(case):
6379
+ """Every AC id ONE withheld-oracle case is tagged with, normalised (ADR-030, amended
6380
+ 1.90.0): the ids literally referenced in its `name`, plus the ids in its curated `ac` list.
6381
+
6382
+ `ac` is a human-curated field on the case (provenance:
6383
+ `tests/fixtures/diamond-bench/ORACLE-TAGS-CURATED.json`) -- the payload and the expectations
6384
+ are untouched by it, so tagging cannot change what a case MEASURES, only what the id map
6385
+ says it measures. Absence stays absence: a missing key, a null, a non-list, or a list with
6386
+ nothing usable in it yields no tags, and an entry whose cases carry none is still
6387
+ behaviour-UNMEASURED rather than behaviour-zero."""
6388
+ out = set(_rt_ids_in(case.get("name", "")))
6389
+ raw = case.get("ac")
6390
+ if isinstance(raw, (list, tuple)):
6391
+ out |= {t for t in (str(x).strip() for x in raw if isinstance(x, str)) if t}
6392
+ return {_rt_ac_key(t) for t in out}
6393
+
6394
+
6310
6395
  def _rt_read_source(cd, unit):
6311
6396
  try:
6312
6397
  with open(os.path.join(cd, unit.replace("/", os.sep)), encoding="utf-8",
@@ -6321,8 +6406,9 @@ def _rt_compilation(entry_dir, cd, ir_graph, cases):
6321
6406
  IR node, whether the mechanical reverse organs find footing for it in the artifact --
6322
6407
  (a) static: a source unit's text or a static observation literally references the id;
6323
6408
  (b) manifest: the compiler's validated trace manifest maps the node to a unit that exists;
6324
- (c) behaviour: for AC nodes, at least one withheld-oracle case tagged with the id passes
6325
- (UNMEASURED when no case carries any AC tag). It regenerates NOTHING: no IR', no spec --
6409
+ (c) behaviour: for AC nodes, at least one withheld-oracle case tagged with the id passes --
6410
+ tagged in the case NAME or in its curated `ac` list (ADR-030 amended 1.90.0), and
6411
+ UNMEASURED when no case carries any tag at all. It regenerates NOTHING: no IR', no spec --
6326
6412
  a coverage over the human-authored IR, plus the list of nodes nothing anchors."""
6327
6413
  cj = os.path.join(cd, "COMPILATION.json")
6328
6414
  try:
@@ -6347,26 +6433,40 @@ def _rt_compilation(entry_dir, cd, ir_graph, cases):
6347
6433
  if e.get("unit") in units:
6348
6434
  for nid in e.get("implements") or []:
6349
6435
  manifest_ids.add(str(nid).upper())
6350
- # (c) behaviour footing: oracle cases whose NAME carries an AC id (payload tags are not a
6351
- # convention the bench oracles use today -- absence named, not faked)
6436
+ # (c) behaviour footing: the oracle cases an id is tagged with -- in the case NAME, and
6437
+ # since 1.90.0 in the case's curated `ac` list as well (ADR-030 amended). Before the
6438
+ # curation there was no tag anywhere in this fixture and the whole dimension read
6439
+ # UNMEASURED: a named absence, which is what it was, and it is now measured instead.
6352
6440
  tagged = {}
6353
- for case in cases:
6354
- for cid in _rt_ids_in(case.get("name", "")):
6355
- tagged.setdefault(cid, []).append(case)
6356
- behaviour_measured = bool(tagged)
6441
+ for i, case in enumerate(cases):
6442
+ for cid in _rt_case_tags(case):
6443
+ tagged.setdefault(cid, []).append(i)
6444
+ # MEASURED only when at least one tag names an AC node of THIS entry's IR (1.90.0 review):
6445
+ # a typo, a stray ADR/INV id or a tag from another archetype anchors nothing, and a tag
6446
+ # that anchors nothing must leave the dimension a named absence, never a measured zero.
6447
+ ir_ac_keys = {_rt_ac_key(n) for n in node_ids if str(n).upper().startswith("AC")}
6448
+ behaviour_measured = any(_rt_ac_key(k) in ir_ac_keys for k in tagged)
6357
6449
  behaviour_ids = set()
6358
6450
  entry_unit = _entry_unit(c.get("source") or [])
6359
6451
  impl = os.path.join(cd, entry_unit.replace("/", os.sep)) if entry_unit else None
6360
6452
  if behaviour_measured and impl and os.path.isfile(impl):
6361
- for cid, cs in tagged.items():
6362
- if any(_run_oracle_case(impl, case).get("ok") for case in cs):
6453
+ # one RUN per case, not one per tag: a case tagged with three ids used to be executed
6454
+ # three times, and running a judged program more often than the oracle asked is both
6455
+ # slower and a different experiment
6456
+ verdicts = {}
6457
+ for cid, idxs in tagged.items():
6458
+ for i in idxs:
6459
+ if i not in verdicts:
6460
+ verdicts[i] = bool(_run_oracle_case(impl, cases[i]).get("ok"))
6461
+ if any(verdicts[i] for i in idxs):
6363
6462
  behaviour_ids.add(cid)
6364
6463
  per_node = []
6365
6464
  anchored = 0
6366
6465
  for nid in node_ids:
6367
6466
  a_s = nid in static_ids
6368
6467
  a_m = nid in manifest_ids
6369
- a_b = (nid in behaviour_ids) if (behaviour_measured and nid.startswith("AC-")) else None
6468
+ a_b = ((_rt_ac_key(nid) in behaviour_ids)
6469
+ if (behaviour_measured and nid.startswith("AC-")) else None)
6370
6470
  anchored_any = a_s or a_m or bool(a_b)
6371
6471
  # the MEASURED footing excludes the manifest: the manifest is what the compiler
6372
6472
  # CLAIMED (and the prompt handed it the ids), so counting it as recovered would be
@@ -6423,6 +6523,26 @@ def _rt_entry(entry_dir, name):
6423
6523
  return rec
6424
6524
 
6425
6525
 
6526
+ def _rt_behaviour_note(agg):
6527
+ """The report's closing sentence about the behaviour dimension, DERIVED from the aggregate
6528
+ instead of asserted (1.90.0).
6529
+
6530
+ It used to end 'which today is every entry' -- true when written, false the moment the
6531
+ diamond-bench oracles were curated with per-case `ac` tags, and nothing would have caught
6532
+ it: a generated document is exactly where a hardcoded claim rots unseen. Three honest
6533
+ states, one of them chosen by the number beside it."""
6534
+ measured, total = agg.get("behaviour_measured") or 0, agg.get("measured") or 0
6535
+ if not total or not measured:
6536
+ return ("which today is every entry: the honest state of reverse discovery is that it "
6537
+ "anchors names, not semantics, until oracles carry per-AC tags")
6538
+ if measured == total:
6539
+ return ("which today is no entry: every oracle here carries per-AC tags, so this "
6540
+ "dimension is measured rather than named absent, and what it attributes is "
6541
+ "behaviour and not merely a name")
6542
+ return ("which today is %d of the %d measured entries -- the rest carry per-AC tags and "
6543
+ "are attributed by behaviour" % (total - measured, total))
6544
+
6545
+
6426
6546
  def _render_rt_md(recs, agg):
6427
6547
  lines = ["<!-- GENERATED by qa_ledger.py bench-roundtrip (ADR-030) -- measured run; do not hand-edit. -->",
6428
6548
  "", "# DIAMOND-ROUNDTRIP -- how much of the pinned IR the reverse organs can anchor in each compiled artifact", "",
@@ -6460,10 +6580,8 @@ def _render_rt_md(recs, agg):
6460
6580
  "", "*The manifest dimension is what the compiler CLAIMED (validated for shape, "
6461
6581
  "not truth) and is excluded from recoverability; the static dimension is what the "
6462
6582
  "artifact literally names; the behaviour dimension is what the withheld oracle can "
6463
- "attribute per AC -- and it is UNMEASURED wherever oracle cases are not tagged with "
6464
- "AC ids, which today is every entry: the honest state of reverse discovery is that "
6465
- "it anchors names, not semantics, until oracles carry per-AC tags. None of the three "
6466
- "is a spec.*", ""]
6583
+ "attribute per AC -- UNMEASURED wherever the entry's oracle cases carry no AC tag, "
6584
+ "%s. None of the three is a spec.*" % _rt_behaviour_note(agg), ""]
6467
6585
  return "\n".join(lines)
6468
6586
 
6469
6587
 
@@ -8288,9 +8406,17 @@ def _top_clean(text):
8288
8406
  observation statement, a tool name) -- human and CLI input -- so an ESC or a C0 byte
8289
8407
  inside one would be a control sequence the board obeys instead of prints. It dies HERE,
8290
8408
  in the engine, and the renderer drops it again on the way out: two cheap guards over one
8291
- attack surface. What is filtered is exactly C0 and DEL (ADR-032)."""
8409
+ attack surface.
8410
+
8411
+ Filtered (1.90.0): C0 and DEL, the C1 range U+0080-U+009F (a terminal decoding the text
8412
+ as latin-1 reads those as CSI/OSC introducers, so they are the same attack in one byte
8413
+ less), and every Unicode format character (category `Cf`) -- U+200B and friends occupy no
8414
+ column but do occupy a codepoint, and U+202E reverses the rest of the line. A character
8415
+ that cannot be seen must not be able to move what is."""
8292
8416
  txt = "".join(" " if c in ("\t", "\n", "\r") else c for c in str(text))
8293
- txt = "".join(c for c in txt if ord(c) >= 32 and ord(c) != 127)
8417
+ txt = "".join(c for c in txt
8418
+ if ord(c) >= 32 and ord(c) != 127 and not 0x80 <= ord(c) <= 0x9F
8419
+ and unicodedata.category(c) != "Cf")
8294
8420
  return " ".join(txt.split())
8295
8421
 
8296
8422
 
@@ -30,6 +30,7 @@ import shutil
30
30
  import subprocess
31
31
  import sys
32
32
  import time
33
+ import unicodedata
33
34
 
34
35
  DEFAULT_LEDGER = "QA-LEDGER.json"
35
36
  FALLBACK_SIZE = (100, 32)
@@ -105,21 +106,71 @@ ACTIONS = {
105
106
  # --------------------------------------------------------------------------- #
106
107
  # pure rendering #
107
108
  # --------------------------------------------------------------------------- #
109
+ def _dw(text):
110
+ """Display width in TERMINAL COLUMNS, not codepoints.
111
+
112
+ `len()` counts codepoints, and the frame's whole contract is columns: a CJK project name
113
+ or a full-width event text takes two columns per codepoint, so a line `len()` called
114
+ exactly `cols` wide draws twice that and the frame every golden pins stops being a frame
115
+ (1.86.1 fresh review, LOW, deferred until a fixture existed -- `state-wide.json` is it).
116
+
117
+ Three classes, and no font metric anywhere: East Asian Wide and Fullwidth cost 2, a
118
+ combining mark costs 0 (it draws on the previous cell), everything else costs 1. East
119
+ Asian *Ambiguous* deliberately counts 1 -- that is the class the renderer's own glyphs
120
+ fall into (`…`, `·`, `─`, `│`, `▁`, `—`), so on an ASCII state `_dw` is `len` and every
121
+ frame captured before this function existed stays byte-identical."""
122
+ width = 0
123
+ for ch in str(text):
124
+ if unicodedata.combining(ch):
125
+ continue
126
+ width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
127
+ return width
128
+
129
+
130
+ def _cut(text, width):
131
+ """The longest PREFIX of `text` that fits in `width` columns, whole characters only.
132
+
133
+ A wide character is never split: half a glyph is not half a column, it is a cell the
134
+ terminal fills however it likes and a frame nobody can snapshot."""
135
+ if width <= 0:
136
+ return ""
137
+ out, used = [], 0
138
+ for ch in str(text):
139
+ w = 0 if unicodedata.combining(ch) else (
140
+ 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
141
+ if used + w > width:
142
+ break
143
+ out.append(ch)
144
+ used += w
145
+ return "".join(out)
146
+
147
+
148
+ def _pad(text, width):
149
+ """`str.ljust` measured in columns. Padding by codepoints puts a wide cell's second
150
+ column inside the next field and every column after it walks."""
151
+ return str(text) + " " * max(0, width - _dw(text))
152
+
153
+
108
154
  def _fit(text, cols):
109
155
  """One line, never wider than the terminal. Wrapping would break the frame's row
110
- accounting, so an over-long line is cut and marked."""
156
+ accounting, so an over-long line is cut and marked.
157
+
158
+ Measured and cut in COLUMNS (`_dw`/`_cut`): cutting by codepoints was the bug. Note the
159
+ cut may leave one column short rather than land exactly on `cols - 1` -- when the
160
+ character at the boundary is wide it is dropped whole, and a frame one column narrow is
161
+ correct where a frame one column wide is not."""
111
162
  if cols <= 0:
112
163
  return ""
113
- if len(text) <= cols:
164
+ if _dw(text) <= cols:
114
165
  return text
115
- return text[:cols - 1] + "…" if cols > 1 else text[:cols]
166
+ return _cut(text, cols - 1) + "…" if cols > 1 else _cut(text, cols)
116
167
 
117
168
 
118
169
  def _spread(left, right, cols):
119
170
  """left ... right on one line, right-aligned, degrading to just `left` when tight."""
120
- if len(left) + len(right) + 1 > cols:
171
+ if _dw(left) + _dw(right) + 1 > cols:
121
172
  return _fit(left, cols)
122
- return left + " " * (cols - len(left) - len(right)) + right
173
+ return left + " " * (cols - _dw(left) - _dw(right)) + right
123
174
 
124
175
 
125
176
  def _num(value):
@@ -151,7 +202,7 @@ def _burnup_line(burnup, cols):
151
202
  note = " (readiness score, not closed obligations)"
152
203
  if not points:
153
204
  return label + DASH + " (no `readiness --record` history yet)"
154
- room = max(4, min(BURNUP_MAX, cols - len(label) - len(note)))
205
+ room = max(4, min(BURNUP_MAX, cols - _dw(label) - _dw(note)))
155
206
  bars = "".join(BLOCKS[min(len(BLOCKS) - 1, max(0, int(p) * len(BLOCKS) // 101))]
156
207
  for p in points[-room:])
157
208
  return label + bars + note
@@ -180,10 +231,13 @@ def _cases_text(ob):
180
231
 
181
232
 
182
233
  def _row(ob, selected):
234
+ # the three left columns are cut and padded in COLUMNS: an id or state carrying wide
235
+ # characters used to eat its neighbour's field and walk every column after it.
183
236
  gutter = "> " if selected else " "
184
- return "%s%-8s%-9s%-15s%7s%5s %s" % (
185
- gutter, _safe(ob.get("id") or "?")[:8], _safe(ob.get("gate") or DASH)[:8],
186
- _safe(ob.get("state") or "?")[:14], _cases_text(ob),
237
+ return "%s%s%s%s%7s%5s %s" % (
238
+ gutter, _pad(_cut(_safe(ob.get("id") or "?"), 8), 8),
239
+ _pad(_cut(_safe(ob.get("gate") or DASH), 8), 9),
240
+ _pad(_cut(_safe(ob.get("state") or "?"), 14), 15), _cases_text(ob),
187
241
  _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
188
242
 
189
243
 
@@ -191,8 +245,22 @@ def _safe(text):
191
245
  """No control character reaches the terminal through the feed. The engine already
192
246
  strips them where the text is derived (`_top_event_text`); this is the second guard on
193
247
  the same surface, because the renderer also accepts a frozen state file a human wrote,
194
- and one ESC in it would be a control sequence the board obeys instead of prints."""
195
- return "".join(c for c in str(text or "") if ord(c) >= 32 and ord(c) != 127)
248
+ and one ESC in it would be a control sequence the board obeys instead of prints.
249
+
250
+ Dropped (1.90.0), matching the engine's `_top_clean` exactly: C0 and DEL, the C1 range
251
+ U+0080-U+009F (a terminal reading the stream as latin-1 takes those for CSI/OSC), and
252
+ every Unicode format character (category `Cf`) -- U+200B costs a codepoint and no column,
253
+ U+202E reverses everything after it. A character that cannot be seen must not be able to
254
+ move what is."""
255
+ out = []
256
+ for ch in str(text or ""):
257
+ code = ord(ch)
258
+ if code < 32 or code == 127 or 0x80 <= code <= 0x9F:
259
+ continue
260
+ if unicodedata.category(ch) == "Cf":
261
+ continue
262
+ out.append(ch)
263
+ return "".join(out)
196
264
 
197
265
 
198
266
  def _feed_line(ev, cols, plain):
@@ -355,14 +423,17 @@ def _wrap(text, width):
355
423
  for word in _safe(text).split():
356
424
  if not line:
357
425
  line = word
358
- elif len(line) + 1 + len(word) <= width:
426
+ elif _dw(line) + 1 + _dw(word) <= width:
359
427
  line += " " + word
360
428
  else:
361
429
  out.append(line)
362
430
  line = word
363
- while len(line) > width:
364
- out.append(line[:width])
365
- line = line[width:]
431
+ # the hard split is measured in columns too, and `_cut` never breaks a wide
432
+ # character in half -- so a wide token continues on the next line, whole.
433
+ while _dw(line) > width:
434
+ head = _cut(line, width)
435
+ out.append(head)
436
+ line = line[len(head):]
366
437
  if line:
367
438
  out.append(line)
368
439
  return out or [""]
@@ -374,10 +445,10 @@ def _obs_row(i, ob, selected, cols):
374
445
  gutter = "> " if selected else " "
375
446
  idx = "[%d]" % (i + 1) if i < VERDICT_LIST_MAX else " "
376
447
  tail = " %s %s %s pending" % (MID, _safe(ob.get("ac")) or ("AC " + DASH), MID)
377
- head = "%s%-4s%-18s" % (gutter, idx, _safe(ob.get("id"))[:18])
378
- room = max(4, cols - len(head) - len(tail))
448
+ head = "%s%-4s%s" % (gutter, idx, _pad(_cut(_safe(ob.get("id")), 18), 18))
449
+ room = max(4, cols - _dw(head) - _dw(tail))
379
450
  title = _fit(_safe(ob.get("title")) or DASH, room)
380
- return head + title.ljust(room) + tail
451
+ return head + _pad(title, room) + tail
381
452
 
382
453
 
383
454
  def _column_widths(cols):
@@ -412,7 +483,7 @@ def _pane(ob, cols, height):
412
483
  pad = max(len(left), len(right))
413
484
  left += [""] * (pad - len(left))
414
485
  right += [""] * (pad - len(right))
415
- body = head + [(" %s │ %s" % (l.ljust(lw), r)).rstrip()
486
+ body = head + [(" %s │ %s" % (_pad(l, lw), r)).rstrip()
416
487
  for l, r in zip(left, right)]
417
488
  else:
418
489
  body = (head + [" CANDIDATE"] + [" " + ln for ln in block("candidate", cols - 2)]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "uscha",
4
- "version": "1.89.0",
4
+ "version": "1.90.0",
5
5
  "displayName": "Uscha",
6
6
  "description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 52 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
7
7
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uscha",
3
- "version": "1.89.0",
3
+ "version": "1.90.0",
4
4
  "description": "Uscha spec-driven development methodology for coding agents. Includes npm/npx router.",
5
5
  "author": {
6
6
  "name": "Andres Massello",
@@ -1,6 +1,6 @@
1
1
  # uscha-kit
2
2
 
3
- **Kit version:** v1.89.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
3
+ **Kit version:** v1.90.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
4
4
 
5
5
  Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
6
6
  **Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
package/uscha-kit/VERSION CHANGED
@@ -1 +1 @@
1
- uscha-kit 1.89.0
1
+ uscha-kit 1.90.0
@@ -3423,6 +3423,15 @@ def cmd_golden_coverage(args):
3423
3423
 
3424
3424
  env = dict(os.environ)
3425
3425
  env["COVERAGE_PROCESS_START"] = rc
3426
+ # COVERAGE_FILE is set EXPLICITLY, not merely inherited. coverage.py resolves its
3427
+ # data_file with the environment variable AFTER the rc file, so an inherited
3428
+ # COVERAGE_FILE (the shape the kit's own suite has under USCHA_COVERAGE=1) silently
3429
+ # overrode the isolated data_file above and the capture landed in the caller's shared
3430
+ # file -- the map then read as "covers nothing" and AC-GM-08 went red under coverage
3431
+ # while passing plain. Writing it here pins the child to OUR file whether or not the
3432
+ # caller has one; the parent's own Coverage(data_file=...) already wins over the
3433
+ # environment because constructor arguments are applied last.
3434
+ env["COVERAGE_FILE"] = data_file
3426
3435
  env["PYTHONPATH"] = tmp + os.pathsep + env.get("PYTHONPATH", "")
3427
3436
  env["PYTHONIOENCODING"] = "utf-8"
3428
3437
  r = subprocess.run([sys.executable, harness], cwd=root, env=env,
@@ -5468,6 +5477,47 @@ def _static_surface_for(cd, unit):
5468
5477
  return so, []
5469
5478
 
5470
5479
 
5480
+ def _judged_env():
5481
+ """The environment a JUDGED program gets: this process's, minus the hooks that would make
5482
+ a third party instrument it, plus the one setting that keeps it from leaving anything
5483
+ behind in the tree it is judged from.
5484
+
5485
+ The withheld oracle measures a compiled implementation by its exit code and its stdout. A
5486
+ measurement that changes what it measures is a broken measurement, and coverage.py's
5487
+ documented subprocess hook does exactly that: with `COVERAGE_PROCESS_START` in the
5488
+ environment, coverage.py's own `.pth` (`a1_coverage.pth`, shipped in site-packages) starts
5489
+ a full Coverage in EVERY python process, at interpreter start-up, before any of that
5490
+ process's own code runs. The oracle's children are fixture programs that sit outside every
5491
+ `--source` root, so what they record is empty by construction -- pure cost, no measurement.
5492
+
5493
+ That cost is not theoretical (1.90.0). Running this repo's own smoke suite under
5494
+ `USCHA_COVERAGE=1` spawned ~14,000 such children; `coverage combine` reported
5495
+ "Combined 600 files, skipped 13702" -- 96% of them recorded nothing -- and late in the run
5496
+ Windows began refusing to create processes: seven bench archetypes flipped to FAIL because
5497
+ an oracle child could not start, and a py3.8 interpreter came back 3221225794
5498
+ (0xC0000142, STATUS_DLL_INIT_FAILED) with an empty stderr. Nothing was wrong with the
5499
+ engine or the fixtures; the instrument was crushing the machine it measured on.
5500
+
5501
+ Only the two variables that START coverage in a child are dropped. `COVERAGE_FILE` is
5502
+ left alone: it names a data file, it does not turn anything on, and a caller that set it
5503
+ means it.
5504
+
5505
+ `PYTHONDONTWRITEBYTECODE` is SET, and that one is not about coverage (1.90.0, ADR-030
5506
+ amended). A multi-unit compilation imports its sibling module, so running it writes
5507
+ `__pycache__/*.pyc` INTO the fixture directory -- and the round trip's own criterion is
5508
+ that the instrument regenerates nothing under the bench tree (AC-RT-01 snapshots the file
5509
+ list before and after). It only became reachable when the behaviour dimension started
5510
+ running cases: measured on a clean tree, `bench-roundtrip` left three `.pyc` files behind.
5511
+ In the suite the earlier `bench` block had already warmed the cache, so the red would have
5512
+ waited for a fresh CI clone to appear -- an ordering dependency, which is the kind of
5513
+ green worth nothing. Judging a program must not modify what is being judged."""
5514
+ env = dict(os.environ)
5515
+ for key in ("COVERAGE_PROCESS_START", "COVERAGE_PROCESS_CONFIG"):
5516
+ env.pop(key, None)
5517
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
5518
+ return env
5519
+
5520
+
5471
5521
  def _impl_interpreter(impl_path):
5472
5522
  """Resolve the interpreter argv prefix for one implementation file by extension (ADR-028):
5473
5523
  `.py` runs under this same Python (unchanged); `.js` runs under `node`, resolved from PATH.
@@ -5508,7 +5558,7 @@ def _run_oracle_case(impl_path, case):
5508
5558
  # sibling modules by bare name; single-unit impls are unaffected by their cwd
5509
5559
  r = subprocess.run(interp + [os.path.abspath(impl_path)], input=stdin,
5510
5560
  capture_output=True, text=True, encoding="utf-8", errors="replace",
5511
- timeout=_BOOTSTRAP_CASE_TIMEOUT,
5561
+ timeout=_BOOTSTRAP_CASE_TIMEOUT, env=_judged_env(),
5512
5562
  cwd=os.path.dirname(os.path.abspath(impl_path)) or None)
5513
5563
  got, out, err = r.returncode, r.stdout, None
5514
5564
  except subprocess.TimeoutExpired:
@@ -6307,6 +6357,41 @@ def _rt_ids_in(text):
6307
6357
  return out
6308
6358
 
6309
6359
 
6360
+ def _rt_ac_key(cid):
6361
+ """ONE spelling for comparing an AC id, so the round-trip's footings cannot disagree over
6362
+ punctuation (ADR-030, amended 1.90.0).
6363
+
6364
+ The three footings read ids from three places that spell them differently: an IR node id is
6365
+ written the way a human wrote the acceptance line and keeps its padding (`AC-DD-07`), a
6366
+ source comment or a curated oracle tag may write `AC-DD-7` or `AC_DD_07`, and `_rt_ids_in`
6367
+ only upper-cases what it finds. Comparing those raw was a match that depended on a zero.
6368
+ The normal form is ADR-036's OWN grammar (`_ac_tag_ids`, family + integer, padding and
6369
+ separator dropped) -- reused rather than reimplemented, because a second grammar for the
6370
+ same id is the drift this function exists to prevent. It is applied to BOTH sides of the
6371
+ behaviour comparison and nowhere else: `per_node["id"]`, the static footing and the
6372
+ manifest footing keep the IR's own spelling, so no report changes shape. A non-AC id
6373
+ (INV-*, ADR-*) has no behaviour footing at all and falls through upper-cased."""
6374
+ ids = _ac_tag_ids(str(cid or ""))
6375
+ return ids[0] if ids else str(cid or "").upper().replace("_", "-")
6376
+
6377
+
6378
+ def _rt_case_tags(case):
6379
+ """Every AC id ONE withheld-oracle case is tagged with, normalised (ADR-030, amended
6380
+ 1.90.0): the ids literally referenced in its `name`, plus the ids in its curated `ac` list.
6381
+
6382
+ `ac` is a human-curated field on the case (provenance:
6383
+ `tests/fixtures/diamond-bench/ORACLE-TAGS-CURATED.json`) -- the payload and the expectations
6384
+ are untouched by it, so tagging cannot change what a case MEASURES, only what the id map
6385
+ says it measures. Absence stays absence: a missing key, a null, a non-list, or a list with
6386
+ nothing usable in it yields no tags, and an entry whose cases carry none is still
6387
+ behaviour-UNMEASURED rather than behaviour-zero."""
6388
+ out = set(_rt_ids_in(case.get("name", "")))
6389
+ raw = case.get("ac")
6390
+ if isinstance(raw, (list, tuple)):
6391
+ out |= {t for t in (str(x).strip() for x in raw if isinstance(x, str)) if t}
6392
+ return {_rt_ac_key(t) for t in out}
6393
+
6394
+
6310
6395
  def _rt_read_source(cd, unit):
6311
6396
  try:
6312
6397
  with open(os.path.join(cd, unit.replace("/", os.sep)), encoding="utf-8",
@@ -6321,8 +6406,9 @@ def _rt_compilation(entry_dir, cd, ir_graph, cases):
6321
6406
  IR node, whether the mechanical reverse organs find footing for it in the artifact --
6322
6407
  (a) static: a source unit's text or a static observation literally references the id;
6323
6408
  (b) manifest: the compiler's validated trace manifest maps the node to a unit that exists;
6324
- (c) behaviour: for AC nodes, at least one withheld-oracle case tagged with the id passes
6325
- (UNMEASURED when no case carries any AC tag). It regenerates NOTHING: no IR', no spec --
6409
+ (c) behaviour: for AC nodes, at least one withheld-oracle case tagged with the id passes --
6410
+ tagged in the case NAME or in its curated `ac` list (ADR-030 amended 1.90.0), and
6411
+ UNMEASURED when no case carries any tag at all. It regenerates NOTHING: no IR', no spec --
6326
6412
  a coverage over the human-authored IR, plus the list of nodes nothing anchors."""
6327
6413
  cj = os.path.join(cd, "COMPILATION.json")
6328
6414
  try:
@@ -6347,26 +6433,40 @@ def _rt_compilation(entry_dir, cd, ir_graph, cases):
6347
6433
  if e.get("unit") in units:
6348
6434
  for nid in e.get("implements") or []:
6349
6435
  manifest_ids.add(str(nid).upper())
6350
- # (c) behaviour footing: oracle cases whose NAME carries an AC id (payload tags are not a
6351
- # convention the bench oracles use today -- absence named, not faked)
6436
+ # (c) behaviour footing: the oracle cases an id is tagged with -- in the case NAME, and
6437
+ # since 1.90.0 in the case's curated `ac` list as well (ADR-030 amended). Before the
6438
+ # curation there was no tag anywhere in this fixture and the whole dimension read
6439
+ # UNMEASURED: a named absence, which is what it was, and it is now measured instead.
6352
6440
  tagged = {}
6353
- for case in cases:
6354
- for cid in _rt_ids_in(case.get("name", "")):
6355
- tagged.setdefault(cid, []).append(case)
6356
- behaviour_measured = bool(tagged)
6441
+ for i, case in enumerate(cases):
6442
+ for cid in _rt_case_tags(case):
6443
+ tagged.setdefault(cid, []).append(i)
6444
+ # MEASURED only when at least one tag names an AC node of THIS entry's IR (1.90.0 review):
6445
+ # a typo, a stray ADR/INV id or a tag from another archetype anchors nothing, and a tag
6446
+ # that anchors nothing must leave the dimension a named absence, never a measured zero.
6447
+ ir_ac_keys = {_rt_ac_key(n) for n in node_ids if str(n).upper().startswith("AC")}
6448
+ behaviour_measured = any(_rt_ac_key(k) in ir_ac_keys for k in tagged)
6357
6449
  behaviour_ids = set()
6358
6450
  entry_unit = _entry_unit(c.get("source") or [])
6359
6451
  impl = os.path.join(cd, entry_unit.replace("/", os.sep)) if entry_unit else None
6360
6452
  if behaviour_measured and impl and os.path.isfile(impl):
6361
- for cid, cs in tagged.items():
6362
- if any(_run_oracle_case(impl, case).get("ok") for case in cs):
6453
+ # one RUN per case, not one per tag: a case tagged with three ids used to be executed
6454
+ # three times, and running a judged program more often than the oracle asked is both
6455
+ # slower and a different experiment
6456
+ verdicts = {}
6457
+ for cid, idxs in tagged.items():
6458
+ for i in idxs:
6459
+ if i not in verdicts:
6460
+ verdicts[i] = bool(_run_oracle_case(impl, cases[i]).get("ok"))
6461
+ if any(verdicts[i] for i in idxs):
6363
6462
  behaviour_ids.add(cid)
6364
6463
  per_node = []
6365
6464
  anchored = 0
6366
6465
  for nid in node_ids:
6367
6466
  a_s = nid in static_ids
6368
6467
  a_m = nid in manifest_ids
6369
- a_b = (nid in behaviour_ids) if (behaviour_measured and nid.startswith("AC-")) else None
6468
+ a_b = ((_rt_ac_key(nid) in behaviour_ids)
6469
+ if (behaviour_measured and nid.startswith("AC-")) else None)
6370
6470
  anchored_any = a_s or a_m or bool(a_b)
6371
6471
  # the MEASURED footing excludes the manifest: the manifest is what the compiler
6372
6472
  # CLAIMED (and the prompt handed it the ids), so counting it as recovered would be
@@ -6423,6 +6523,26 @@ def _rt_entry(entry_dir, name):
6423
6523
  return rec
6424
6524
 
6425
6525
 
6526
+ def _rt_behaviour_note(agg):
6527
+ """The report's closing sentence about the behaviour dimension, DERIVED from the aggregate
6528
+ instead of asserted (1.90.0).
6529
+
6530
+ It used to end 'which today is every entry' -- true when written, false the moment the
6531
+ diamond-bench oracles were curated with per-case `ac` tags, and nothing would have caught
6532
+ it: a generated document is exactly where a hardcoded claim rots unseen. Three honest
6533
+ states, one of them chosen by the number beside it."""
6534
+ measured, total = agg.get("behaviour_measured") or 0, agg.get("measured") or 0
6535
+ if not total or not measured:
6536
+ return ("which today is every entry: the honest state of reverse discovery is that it "
6537
+ "anchors names, not semantics, until oracles carry per-AC tags")
6538
+ if measured == total:
6539
+ return ("which today is no entry: every oracle here carries per-AC tags, so this "
6540
+ "dimension is measured rather than named absent, and what it attributes is "
6541
+ "behaviour and not merely a name")
6542
+ return ("which today is %d of the %d measured entries -- the rest carry per-AC tags and "
6543
+ "are attributed by behaviour" % (total - measured, total))
6544
+
6545
+
6426
6546
  def _render_rt_md(recs, agg):
6427
6547
  lines = ["<!-- GENERATED by qa_ledger.py bench-roundtrip (ADR-030) -- measured run; do not hand-edit. -->",
6428
6548
  "", "# DIAMOND-ROUNDTRIP -- how much of the pinned IR the reverse organs can anchor in each compiled artifact", "",
@@ -6460,10 +6580,8 @@ def _render_rt_md(recs, agg):
6460
6580
  "", "*The manifest dimension is what the compiler CLAIMED (validated for shape, "
6461
6581
  "not truth) and is excluded from recoverability; the static dimension is what the "
6462
6582
  "artifact literally names; the behaviour dimension is what the withheld oracle can "
6463
- "attribute per AC -- and it is UNMEASURED wherever oracle cases are not tagged with "
6464
- "AC ids, which today is every entry: the honest state of reverse discovery is that "
6465
- "it anchors names, not semantics, until oracles carry per-AC tags. None of the three "
6466
- "is a spec.*", ""]
6583
+ "attribute per AC -- UNMEASURED wherever the entry's oracle cases carry no AC tag, "
6584
+ "%s. None of the three is a spec.*" % _rt_behaviour_note(agg), ""]
6467
6585
  return "\n".join(lines)
6468
6586
 
6469
6587
 
@@ -8288,9 +8406,17 @@ def _top_clean(text):
8288
8406
  observation statement, a tool name) -- human and CLI input -- so an ESC or a C0 byte
8289
8407
  inside one would be a control sequence the board obeys instead of prints. It dies HERE,
8290
8408
  in the engine, and the renderer drops it again on the way out: two cheap guards over one
8291
- attack surface. What is filtered is exactly C0 and DEL (ADR-032)."""
8409
+ attack surface.
8410
+
8411
+ Filtered (1.90.0): C0 and DEL, the C1 range U+0080-U+009F (a terminal decoding the text
8412
+ as latin-1 reads those as CSI/OSC introducers, so they are the same attack in one byte
8413
+ less), and every Unicode format character (category `Cf`) -- U+200B and friends occupy no
8414
+ column but do occupy a codepoint, and U+202E reverses the rest of the line. A character
8415
+ that cannot be seen must not be able to move what is."""
8292
8416
  txt = "".join(" " if c in ("\t", "\n", "\r") else c for c in str(text))
8293
- txt = "".join(c for c in txt if ord(c) >= 32 and ord(c) != 127)
8417
+ txt = "".join(c for c in txt
8418
+ if ord(c) >= 32 and ord(c) != 127 and not 0x80 <= ord(c) <= 0x9F
8419
+ and unicodedata.category(c) != "Cf")
8294
8420
  return " ".join(txt.split())
8295
8421
 
8296
8422
 
@@ -30,6 +30,7 @@ import shutil
30
30
  import subprocess
31
31
  import sys
32
32
  import time
33
+ import unicodedata
33
34
 
34
35
  DEFAULT_LEDGER = "QA-LEDGER.json"
35
36
  FALLBACK_SIZE = (100, 32)
@@ -105,21 +106,71 @@ ACTIONS = {
105
106
  # --------------------------------------------------------------------------- #
106
107
  # pure rendering #
107
108
  # --------------------------------------------------------------------------- #
109
+ def _dw(text):
110
+ """Display width in TERMINAL COLUMNS, not codepoints.
111
+
112
+ `len()` counts codepoints, and the frame's whole contract is columns: a CJK project name
113
+ or a full-width event text takes two columns per codepoint, so a line `len()` called
114
+ exactly `cols` wide draws twice that and the frame every golden pins stops being a frame
115
+ (1.86.1 fresh review, LOW, deferred until a fixture existed -- `state-wide.json` is it).
116
+
117
+ Three classes, and no font metric anywhere: East Asian Wide and Fullwidth cost 2, a
118
+ combining mark costs 0 (it draws on the previous cell), everything else costs 1. East
119
+ Asian *Ambiguous* deliberately counts 1 -- that is the class the renderer's own glyphs
120
+ fall into (`…`, `·`, `─`, `│`, `▁`, `—`), so on an ASCII state `_dw` is `len` and every
121
+ frame captured before this function existed stays byte-identical."""
122
+ width = 0
123
+ for ch in str(text):
124
+ if unicodedata.combining(ch):
125
+ continue
126
+ width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
127
+ return width
128
+
129
+
130
+ def _cut(text, width):
131
+ """The longest PREFIX of `text` that fits in `width` columns, whole characters only.
132
+
133
+ A wide character is never split: half a glyph is not half a column, it is a cell the
134
+ terminal fills however it likes and a frame nobody can snapshot."""
135
+ if width <= 0:
136
+ return ""
137
+ out, used = [], 0
138
+ for ch in str(text):
139
+ w = 0 if unicodedata.combining(ch) else (
140
+ 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
141
+ if used + w > width:
142
+ break
143
+ out.append(ch)
144
+ used += w
145
+ return "".join(out)
146
+
147
+
148
+ def _pad(text, width):
149
+ """`str.ljust` measured in columns. Padding by codepoints puts a wide cell's second
150
+ column inside the next field and every column after it walks."""
151
+ return str(text) + " " * max(0, width - _dw(text))
152
+
153
+
108
154
  def _fit(text, cols):
109
155
  """One line, never wider than the terminal. Wrapping would break the frame's row
110
- accounting, so an over-long line is cut and marked."""
156
+ accounting, so an over-long line is cut and marked.
157
+
158
+ Measured and cut in COLUMNS (`_dw`/`_cut`): cutting by codepoints was the bug. Note the
159
+ cut may leave one column short rather than land exactly on `cols - 1` -- when the
160
+ character at the boundary is wide it is dropped whole, and a frame one column narrow is
161
+ correct where a frame one column wide is not."""
111
162
  if cols <= 0:
112
163
  return ""
113
- if len(text) <= cols:
164
+ if _dw(text) <= cols:
114
165
  return text
115
- return text[:cols - 1] + "…" if cols > 1 else text[:cols]
166
+ return _cut(text, cols - 1) + "…" if cols > 1 else _cut(text, cols)
116
167
 
117
168
 
118
169
  def _spread(left, right, cols):
119
170
  """left ... right on one line, right-aligned, degrading to just `left` when tight."""
120
- if len(left) + len(right) + 1 > cols:
171
+ if _dw(left) + _dw(right) + 1 > cols:
121
172
  return _fit(left, cols)
122
- return left + " " * (cols - len(left) - len(right)) + right
173
+ return left + " " * (cols - _dw(left) - _dw(right)) + right
123
174
 
124
175
 
125
176
  def _num(value):
@@ -151,7 +202,7 @@ def _burnup_line(burnup, cols):
151
202
  note = " (readiness score, not closed obligations)"
152
203
  if not points:
153
204
  return label + DASH + " (no `readiness --record` history yet)"
154
- room = max(4, min(BURNUP_MAX, cols - len(label) - len(note)))
205
+ room = max(4, min(BURNUP_MAX, cols - _dw(label) - _dw(note)))
155
206
  bars = "".join(BLOCKS[min(len(BLOCKS) - 1, max(0, int(p) * len(BLOCKS) // 101))]
156
207
  for p in points[-room:])
157
208
  return label + bars + note
@@ -180,10 +231,13 @@ def _cases_text(ob):
180
231
 
181
232
 
182
233
  def _row(ob, selected):
234
+ # the three left columns are cut and padded in COLUMNS: an id or state carrying wide
235
+ # characters used to eat its neighbour's field and walk every column after it.
183
236
  gutter = "> " if selected else " "
184
- return "%s%-8s%-9s%-15s%7s%5s %s" % (
185
- gutter, _safe(ob.get("id") or "?")[:8], _safe(ob.get("gate") or DASH)[:8],
186
- _safe(ob.get("state") or "?")[:14], _cases_text(ob),
237
+ return "%s%s%s%s%7s%5s %s" % (
238
+ gutter, _pad(_cut(_safe(ob.get("id") or "?"), 8), 8),
239
+ _pad(_cut(_safe(ob.get("gate") or DASH), 8), 9),
240
+ _pad(_cut(_safe(ob.get("state") or "?"), 14), 15), _cases_text(ob),
187
241
  _num(ob.get("age_hours")), ACTIONS.get(ob.get("state"), DASH))
188
242
 
189
243
 
@@ -191,8 +245,22 @@ def _safe(text):
191
245
  """No control character reaches the terminal through the feed. The engine already
192
246
  strips them where the text is derived (`_top_event_text`); this is the second guard on
193
247
  the same surface, because the renderer also accepts a frozen state file a human wrote,
194
- and one ESC in it would be a control sequence the board obeys instead of prints."""
195
- return "".join(c for c in str(text or "") if ord(c) >= 32 and ord(c) != 127)
248
+ and one ESC in it would be a control sequence the board obeys instead of prints.
249
+
250
+ Dropped (1.90.0), matching the engine's `_top_clean` exactly: C0 and DEL, the C1 range
251
+ U+0080-U+009F (a terminal reading the stream as latin-1 takes those for CSI/OSC), and
252
+ every Unicode format character (category `Cf`) -- U+200B costs a codepoint and no column,
253
+ U+202E reverses everything after it. A character that cannot be seen must not be able to
254
+ move what is."""
255
+ out = []
256
+ for ch in str(text or ""):
257
+ code = ord(ch)
258
+ if code < 32 or code == 127 or 0x80 <= code <= 0x9F:
259
+ continue
260
+ if unicodedata.category(ch) == "Cf":
261
+ continue
262
+ out.append(ch)
263
+ return "".join(out)
196
264
 
197
265
 
198
266
  def _feed_line(ev, cols, plain):
@@ -355,14 +423,17 @@ def _wrap(text, width):
355
423
  for word in _safe(text).split():
356
424
  if not line:
357
425
  line = word
358
- elif len(line) + 1 + len(word) <= width:
426
+ elif _dw(line) + 1 + _dw(word) <= width:
359
427
  line += " " + word
360
428
  else:
361
429
  out.append(line)
362
430
  line = word
363
- while len(line) > width:
364
- out.append(line[:width])
365
- line = line[width:]
431
+ # the hard split is measured in columns too, and `_cut` never breaks a wide
432
+ # character in half -- so a wide token continues on the next line, whole.
433
+ while _dw(line) > width:
434
+ head = _cut(line, width)
435
+ out.append(head)
436
+ line = line[len(head):]
366
437
  if line:
367
438
  out.append(line)
368
439
  return out or [""]
@@ -374,10 +445,10 @@ def _obs_row(i, ob, selected, cols):
374
445
  gutter = "> " if selected else " "
375
446
  idx = "[%d]" % (i + 1) if i < VERDICT_LIST_MAX else " "
376
447
  tail = " %s %s %s pending" % (MID, _safe(ob.get("ac")) or ("AC " + DASH), MID)
377
- head = "%s%-4s%-18s" % (gutter, idx, _safe(ob.get("id"))[:18])
378
- room = max(4, cols - len(head) - len(tail))
448
+ head = "%s%-4s%s" % (gutter, idx, _pad(_cut(_safe(ob.get("id")), 18), 18))
449
+ room = max(4, cols - _dw(head) - _dw(tail))
379
450
  title = _fit(_safe(ob.get("title")) or DASH, room)
380
- return head + title.ljust(room) + tail
451
+ return head + _pad(title, room) + tail
381
452
 
382
453
 
383
454
  def _column_widths(cols):
@@ -412,7 +483,7 @@ def _pane(ob, cols, height):
412
483
  pad = max(len(left), len(right))
413
484
  left += [""] * (pad - len(left))
414
485
  right += [""] * (pad - len(right))
415
- body = head + [(" %s │ %s" % (l.ljust(lw), r)).rstrip()
486
+ body = head + [(" %s │ %s" % (_pad(l, lw), r)).rstrip()
416
487
  for l, r in zip(left, right)]
417
488
  else:
418
489
  body = (head + [" CANDIDATE"] + [" " + ln for ln in block("candidate", cols - 2)]
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.89.0",
2
+ "version": "1.90.0",
3
3
  "project": null,
4
4
  "defaults": {
5
5
  "coverage_threshold": 60,
@@ -1 +0,0 @@
1
- {"AC-DB-01": true, "AC-DB-02": true, "AC-DB-03": true, "AC-DB-05": true, "AC-DB-04": true, "AC-DB-06": true, "AC-BG-01": true, "AC-BG-02": true, "AC-BG-03": true, "AC-BG-04": true, "AC-BG-05": true, "AC-FC-01": true, "AC-FC-02": true, "AC-FC-03": true}
@@ -1 +0,0 @@
1
- {"AC-BC-04": true, "AC-BC-01": true, "AC-BC-02": true, "AC-BC-03": true}
@@ -1 +0,0 @@
1
- {"AC-BS-01": true, "AC-BS-02": true, "AC-BS-03": true, "AC-BS-04": true, "AC-BS-05": true, "AC-BS-06": true}
@@ -1 +0,0 @@
1
- {"AC-CR-06": true, "AC-CR-02": true, "AC-CR-04": true, "AC-CR-05": true, "AC-CR-03": true, "AC-CR-01": true, "AC-CR-07": true, "AC-CR-08": true}
@@ -1 +0,0 @@
1
- {"AC-CC-01": true, "AC-CC-02": true, "AC-CC-03": true, "AC-CC-04": true, "AC-CC-05": true, "AC-CC-06": true, "AC-CC-07": true}
@@ -1 +0,0 @@
1
- {"AC-RD-07": true, "AC-RD-01": true, "AC-RD-02": true, "AC-RD-03": true, "AC-RD-06": true, "AC-RD-04": true, "AC-RD-05": true, "AC-RD-13": true}
@@ -1 +0,0 @@
1
- {"AC-DD-01": true, "AC-DD-02": true, "AC-DD-03": true, "AC-DD-04": true, "AC-DD-05": true, "AC-DD-06": true, "AC-CU-06": true, "AC-CU-01": true, "AC-CU-02": true, "AC-CU-03": true, "AC-CU-04": true, "AC-CU-05": true, "AC-DD-07": true, "review-m1": true, "review-m3": true, "review-h1": true}
@@ -1 +0,0 @@
1
- {"AC-FA-01": true, "AC-FA-02": true, "AC-FA-03": null, "AC-FA-04": true, "AC-FA-05": true, "AC-FA-06": true}
@@ -1 +0,0 @@
1
- {"AC-SF-01": true, "AC-SF-02": true, "AC-SF-03": true, "AC-SF-04": true, "AC-SF-05": true}
@@ -1 +0,0 @@
1
- {"AC-FP-01": true, "AC-FP-11": true, "AC-FP-07": true, "AC-FP-06": true, "AC-FP-05": true, "AC-FP-02": true, "AC-FP-03": true, "AC-FP-09": true, "AC-FP-10": true, "AC-FP-08": true}
@@ -1 +0,0 @@
1
- {"AC-FV-01": true, "AC-FV-02": true, "AC-FV-04": true, "AC-FV-05": true, "AC-FV-03": true, "review-h2": true, "AC-FV-06": true, "review-m4": true}
@@ -1 +0,0 @@
1
- {"AC-GM-01": true, "AC-GM-03": true, "AC-GM-05": true, "AC-GM-04": true, "AC-GM-02": true, "AC-GM-06": true, "AC-GM-07": true, "AC-GM-08": null}
@@ -1 +0,0 @@
1
- {"AC-IR-01": true, "AC-IR-02": true, "AC-IR-03": true, "AC-IR-04": true, "AC-IR-05": true, "AC-IR-06": true}
@@ -1 +0,0 @@
1
- {"AC-JS-01": true, "AC-JS-02": true, "AC-JS-03": true}
@@ -1 +0,0 @@
1
- {"AC-CL-01": true, "AC-CL-02": true, "AC-CL-03": true, "AC-CL-04": true, "AC-CL-05": true, "AC-CL-06": true, "AC-CL2-01": true, "AC-CL2-02": true, "AC-CL2-03": true, "AC-CL2-04": true}
@@ -1 +0,0 @@
1
- {"AC-CL3-01": true, "AC-CL3-02": true, "AC-CL3-03": true}
@@ -1 +0,0 @@
1
- {"AC-MU-01": true, "AC-MU-02": true, "AC-MU-03": true}
@@ -1 +0,0 @@
1
- {"AC-RD-08": true, "AC-RD-09": true, "AC-RD-10": true, "AC-RD-11": true, "AC-RD-12": true}
@@ -1 +0,0 @@
1
- {"AC-EP-01": true, "AC-EP-02": true, "AC-EP-03": true, "AC-EP-05": true, "AC-EP-04": true}
@@ -1 +0,0 @@
1
- {"AC-R2-01": true, "AC-R2-02": true, "AC-R2-03": true}
@@ -1 +0,0 @@
1
- {"AC-RT-01": true, "AC-RT-02": true, "AC-RT-03": true}
@@ -1 +0,0 @@
1
- {"AC-SH-01": true, "AC-SH-02": true, "AC-SH-03": true, "AC-LI-01": true, "AC-LI-02": true, "AC-LI-03": true}
@@ -1 +0,0 @@
1
- {"AC-SD-01": true, "AC-SD-03": true, "AC-SD-05": true, "AC-SD-02": true, "AC-SD-04": true}
@@ -1 +0,0 @@
1
- {"AC-T-01": true, "AC-T-02": true, "AC-T-03": true, "AC-T-10": true, "AC-T-04": true, "AC-T-05": true, "AC-T-06": true, "AC-T-09": true, "AC-T-24": true, "reg-quarantine-obs-null-on-measured": true, "reg-spec-pin-null-outside-worktree": true, "reg-unreachable-repo-named-not-silent": true, "reg-top-events-malformed-fields-degrade": true, "AC-T-11": true, "AC-T-19": true, "reg-empty-project-honest": true, "AC-T-23": true, "AC-T-21": true, "AC-T-08": true, "AC-T-07": true, "AC-T-18": true, "AC-T-20": true, "AC-T-22": true, "reg-ledger-not-found": true, "AC-T-12": true, "reg-top-render-state-text-cannot-widen-or-escape": true, "AC-T-13": true, "AC-T-14": true, "AC-T-15": true, "AC-T-16": true, "AC-T-17": true}