@andresmassello/uscha 1.86.0 → 1.87.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 +2 -2
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +4 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +162 -56
- package/uscha-kit/.claude/skills/uscha-devloop/uscha_top.py +3 -1
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +1 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/install-uscha.py +9 -27
- package/uscha-kit/reports/junit/.fa-cases.json +1 -0
- package/uscha-kit/reports/junit/.top-cases.json +1 -1
- package/uscha-kit/skills/uscha-devloop/SKILL.md +4 -1
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +162 -56
- package/uscha-kit/skills/uscha-devloop/uscha_top.py +3 -1
- package/uscha-kit/templates/scripts/uscha_progress.py +8 -2
- package/uscha-kit/uscha.config.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
|
|
|
40
40
|
runtime dependencies). The npm package is a thin router; the canonical installer is
|
|
41
41
|
`uscha-kit/install-uscha.py`.
|
|
42
42
|
|
|
43
|
-
**Kit v1.
|
|
43
|
+
**Kit v1.87.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
|
|
44
44
|
[changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG.md)
|
|
45
45
|
(the per-release changelogs live in the repo, not in the npm tarball)
|
|
46
46
|
|
|
@@ -85,7 +85,7 @@ automatic tool can perform: a human verdict.
|
|
|
85
85
|
from the compiled code: 0.062 measured (12 archetypes) — names, not yet semantics
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
**What each arrow is, in the engine (kit 1.
|
|
88
|
+
**What each arrow is, in the engine (kit 1.87.0, 52 subcommands, all measured):**
|
|
89
89
|
|
|
90
90
|
| Leg | Subcommands | What it establishes |
|
|
91
91
|
|---|---|---|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andresmassello/uscha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.87.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",
|
|
@@ -535,7 +535,10 @@ once logged it caps readiness ≤65 and blocks convergence until resolved with
|
|
|
535
535
|
criterion carries a stable ID: `- [ ] AC-01 — when X then Y`. A criterion counts as
|
|
536
536
|
CLOSED only when ≥1 GREEN testcase whose name carries the tag (`test_ac1_x`,
|
|
537
537
|
`testAC01X`, `"AC-01: ..."` — IDs normalize by number, `AC-01 == AC_1 == ac1`) exists
|
|
538
|
-
in the ingested JUnit reports AND no tagged testcase is red.
|
|
538
|
+
in the ingested JUnit reports AND no tagged testcase is red. Since kit 1.87.0 (ADR-036)
|
|
539
|
+
a FAMILY prefix is read the same way: `- [ ] AC-BC-07 — ...` closes on `AC-BC-07_x`,
|
|
540
|
+
`test_ac_bc_7_y` or `AC_BC_7` (normalized to `AC-BC-7`; the family needs a separator on
|
|
541
|
+
both sides — camelCase `testACBC07` is NOT a tag, and `AC-7-x` is still the bare `AC-7`). The checkbox is the
|
|
539
542
|
NARRATIVE; the testcase is the FACT — a checked box without a green tagged test shows
|
|
540
543
|
up as `narrated_only` and does NOT close (measured beats narrated, per criterion).
|
|
541
544
|
A JUnit report older than the repo's source code is treated as STALE (the code changed
|
|
@@ -142,7 +142,7 @@ def _integrity_hash(data):
|
|
|
142
142
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
143
143
|
|
|
144
144
|
|
|
145
|
-
def _load(path):
|
|
145
|
+
def _load(path, what="ledger", flag="--ledger"):
|
|
146
146
|
"""Carga blindada (kit 1.13.0, Topic 34: los recursos compartidos mutables
|
|
147
147
|
incluyen ARCHIVOS). JSON corrupto/truncado = hecho bloqueante con mensaje
|
|
148
148
|
de recuperacion, no un traceback. Si el archivo trae campo integrity
|
|
@@ -153,6 +153,12 @@ def _load(path):
|
|
|
153
153
|
try:
|
|
154
154
|
with open(path, "r", encoding="utf-8") as fh:
|
|
155
155
|
data = json.load(fh)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
# what/flag name the file kind and the flag that points at it (1.86.1 re-judge: a
|
|
158
|
+
# hardcoded "ledger ... --ledger" misled the very first command of a fresh clone,
|
|
159
|
+
# init --config, and rebuild --baseline).
|
|
160
|
+
hint = " -- run the dev loop first, or pass --ledger" if what == "ledger" else f" -- pass {flag}"
|
|
161
|
+
raise SystemExit(f"[qa_ledger] {what} '{path}' not found here{hint}")
|
|
156
162
|
except json.JSONDecodeError as exc:
|
|
157
163
|
raise SystemExit(
|
|
158
164
|
f"[qa_ledger] {path} corrupto (JSON invalido: {exc}). Es un artefacto "
|
|
@@ -758,11 +764,43 @@ def _test_evidence_provenance(repo_path, repo_type):
|
|
|
758
764
|
_AC_TAG = re.compile(
|
|
759
765
|
r"(?:(?<![A-Za-z0-9])[Aa][Cc]|(?<=[a-z])AC)[-_]?0*(\d+)(?!\d)")
|
|
760
766
|
|
|
767
|
+
# kit 1.87.0 (ADR-036): the FAMILY grammar 'AC-<FAMILY>-<n>' (AC-BC-07, AC-T-24,
|
|
768
|
+
# ac_dd_3). Same explicit boundaries as the bare form, plus a MANDATORY separator
|
|
769
|
+
# on BOTH sides of the family — 'AC-BC-07', 'ac_bc_7', 'AC_T_1'. camelCase families
|
|
770
|
+
# are deliberately NOT supported: 'testACBC07' has no honest split into family +
|
|
771
|
+
# number and stays unmatched.
|
|
772
|
+
# Kept as its OWN pattern so the bare pattern above (and every tag it already
|
|
773
|
+
# produced) stays byte-identical. The two are disjoint by construction: a family
|
|
774
|
+
# must start with a LETTER, so 'AC-01' can never match this one, and the digits of
|
|
775
|
+
# 'AC-BC-07' do not follow 'AC', so it can never match the bare one.
|
|
776
|
+
_AC_TAG_FAM = re.compile(
|
|
777
|
+
r"(?:(?<![A-Za-z0-9])[Aa][Cc]|(?<=[a-z])AC)"
|
|
778
|
+
r"[-_]([A-Za-z][A-Za-z0-9]*)[-_]0*(\d+)(?!\d)")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _ac_canon(family, num):
|
|
782
|
+
"""Canonical criterion id for both grammars (ADR-036): 'AC-<int>' for the bare
|
|
783
|
+
form (AC-01 == AC_1 == ac1) and 'AC-<FAMILY>-<int>' for the family form
|
|
784
|
+
(AC-T-01 == AC-T-1 == ac_t_1) — python/go test names cannot carry '-', so the
|
|
785
|
+
separator and the zero padding are never part of the identity."""
|
|
786
|
+
if family:
|
|
787
|
+
return "AC-%s-%d" % (family.upper(), int(num))
|
|
788
|
+
return "AC-%d" % int(num)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _ac_tag_ids(name):
|
|
792
|
+
"""Every criterion id a testcase NAME tags, normalized. Family form first, then
|
|
793
|
+
bare; the two patterns are disjoint, so the order fixes only the output order."""
|
|
794
|
+
ids = [_ac_canon(fam, num) for fam, num in _AC_TAG_FAM.findall(name)]
|
|
795
|
+
ids.extend(_ac_canon(None, num) for num in _AC_TAG.findall(name))
|
|
796
|
+
return ids
|
|
797
|
+
|
|
761
798
|
|
|
762
799
|
def _ac_tags(repo_path, repo_type):
|
|
763
800
|
"""Tags AC-n leidos de los NOMBRES de testcase en los reportes JUnit que el
|
|
764
801
|
engine ya ingiere. Devuelve (tags, stale) donde tags = {'AC-n': {'green': x,
|
|
765
|
-
'red': y}}
|
|
802
|
+
'red': y}} (o 'AC-FAM-n' para la forma con familia, ADR-036 / kit 1.87.0)
|
|
803
|
+
y stale = [rutas de reportes descartados por viejos]. Un criterio
|
|
766
804
|
cierra MEDIDO solo con >=1 testcase verde y 0 rojos (evidencia roja veta:
|
|
767
805
|
fail-closed). Testcases skipped no cuentan para ningun lado.
|
|
768
806
|
|
|
@@ -806,9 +844,8 @@ def _ac_tags(repo_path, repo_type):
|
|
|
806
844
|
# cuyo nombre matchea 'ACn' por coincidencia (test_ac3_flow.py) no
|
|
807
845
|
# debe taggear los OTROS tests del mismo archivo/clase.
|
|
808
846
|
blob = tc.get("name") or ""
|
|
809
|
-
for
|
|
810
|
-
d = tags.setdefault(
|
|
811
|
-
{"green": 0, "red": 0, "cases": []})
|
|
847
|
+
for cid in _ac_tag_ids(blob):
|
|
848
|
+
d = tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
|
|
812
849
|
d[status] += 1
|
|
813
850
|
# RECEIPT (kit 1.50.0): keep WHICH testcase in WHICH report backed the
|
|
814
851
|
# verdict -- the name and path were always in scope here and were being
|
|
@@ -820,6 +857,24 @@ def _ac_tags(repo_path, repo_type):
|
|
|
820
857
|
return tags, stale
|
|
821
858
|
|
|
822
859
|
|
|
860
|
+
def _sum_ac_tags(ledger):
|
|
861
|
+
"""One derivation of "how many green/red JUnit testcases does AC-n have, summed across
|
|
862
|
+
every configured repo" -- shared by cmd_readiness (which also keeps up to 8 example case
|
|
863
|
+
receipts per AC and the combined stale-report list) and cmd_top (which only needs
|
|
864
|
+
green/red). One place, so the two readouts of the same fact cannot silently disagree."""
|
|
865
|
+
ac_tags = {}
|
|
866
|
+
stale_reports = []
|
|
867
|
+
for rcfg in (ledger.get("config") or {}).get("repos", []):
|
|
868
|
+
rtags, rstale = _ac_tags(rcfg.get("path", "."), rcfg.get("type", "maven"))
|
|
869
|
+
for cid, v in rtags.items():
|
|
870
|
+
d = ac_tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
|
|
871
|
+
d["green"] += v["green"]
|
|
872
|
+
d["red"] += v["red"]
|
|
873
|
+
d["cases"] = (d["cases"] + v.get("cases", []))[:8]
|
|
874
|
+
stale_reports.extend(rstale)
|
|
875
|
+
return ac_tags, sorted(set(stale_reports))
|
|
876
|
+
|
|
877
|
+
|
|
823
878
|
def junit_test_count(repo_path, extra_files=None):
|
|
824
879
|
"""JUnit-family XML: `pytest --junitxml=...` (python) and jest-junit /
|
|
825
880
|
vitest --reporter=junit (node). Modern emitters WRAP the root:
|
|
@@ -1832,7 +1887,7 @@ def _validate_log_step_counts(args):
|
|
|
1832
1887
|
|
|
1833
1888
|
|
|
1834
1889
|
def cmd_init(args):
|
|
1835
|
-
cfg = _load(args.config)
|
|
1890
|
+
cfg = _load(args.config, what="config", flag="--config")
|
|
1836
1891
|
_validate_init_config(cfg)
|
|
1837
1892
|
defaults = cfg.get("defaults", {})
|
|
1838
1893
|
ledger = {
|
|
@@ -4189,15 +4244,20 @@ def _canonical_ids(repo_path, acceptance_file):
|
|
|
4189
4244
|
except Exception:
|
|
4190
4245
|
return {}
|
|
4191
4246
|
for it in items or []:
|
|
4192
|
-
if it.get("id"):
|
|
4193
|
-
|
|
4247
|
+
if it.get("id"):
|
|
4248
|
+
# normalized id of EITHER grammar (ADR-036): "AC-<n>" or "AC-<FAMILY>-<n>". Keyed by
|
|
4249
|
+
# the canonical id itself; a bare and a family id can never collide.
|
|
4250
|
+
ids[it["id"]] = it["id"]
|
|
4194
4251
|
return ids
|
|
4195
4252
|
|
|
4196
4253
|
|
|
4197
4254
|
def _match_canonical(statement, canon_ids):
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4255
|
+
"""The canonical id an observation's statement names, if any -- read with the same
|
|
4256
|
+
grammar as ACCEPTANCE.md and the JUnit tags (ADR-036), so a statement that mentions
|
|
4257
|
+
'AC-DD-07' anchors the criterion AC-DD-7 exactly as one that mentions 'AC-7' anchors AC-7."""
|
|
4258
|
+
for cid in _ac_tag_ids(statement or ""):
|
|
4259
|
+
if cid in canon_ids:
|
|
4260
|
+
return canon_ids[cid]
|
|
4201
4261
|
return None
|
|
4202
4262
|
|
|
4203
4263
|
|
|
@@ -6892,6 +6952,9 @@ def cmd_facts(args):
|
|
|
6892
6952
|
except OSError as exc:
|
|
6893
6953
|
problems.append((path, 0, "file", "unreadable: %s" % exc, ""))
|
|
6894
6954
|
continue
|
|
6955
|
+
in_table = False
|
|
6956
|
+
table_names = []
|
|
6957
|
+
table_start = 0
|
|
6895
6958
|
for n, line in enumerate(lines, 1):
|
|
6896
6959
|
# an HTML comment is not a published claim -- the first live run flagged a
|
|
6897
6960
|
# section marker (a comment reading "2 Skills") as a drifted count
|
|
@@ -6905,6 +6968,28 @@ def cmd_facts(args):
|
|
|
6905
6968
|
actual = _fact_value(facts, key)
|
|
6906
6969
|
if claimed != actual:
|
|
6907
6970
|
problems.append((path, n, key, claimed, actual))
|
|
6971
|
+
# the parser-surface table (Subcommand/Subcomando header, one `<td class="t">`
|
|
6972
|
+
# row per subcommand) is a claim too, just not a numeric one -- a row can go
|
|
6973
|
+
# missing while the count beside it stays correct (the `top` row did, once).
|
|
6974
|
+
if not in_table:
|
|
6975
|
+
if re.search(r"<th>Sub ?comm?ando?s?</th>", line, re.I):
|
|
6976
|
+
in_table, table_names, table_start = True, [], n
|
|
6977
|
+
continue
|
|
6978
|
+
if "</table>" in line:
|
|
6979
|
+
in_table = False
|
|
6980
|
+
want = set(facts["subcommands"]["list"])
|
|
6981
|
+
got = set(table_names)
|
|
6982
|
+
for name in sorted(want - got):
|
|
6983
|
+
problems.append((path, table_start,
|
|
6984
|
+
"subcommand table: %s" % name,
|
|
6985
|
+
"absent", "present (engine subcommand list)"))
|
|
6986
|
+
for name in sorted(got - want):
|
|
6987
|
+
problems.append((path, table_start,
|
|
6988
|
+
"subcommand table: %s" % name,
|
|
6989
|
+
"present", "absent (not an engine subcommand)"))
|
|
6990
|
+
continue
|
|
6991
|
+
for m in re.finditer(r'<td class="t">([^<]+)</td>', line):
|
|
6992
|
+
table_names.append(m.group(1).strip())
|
|
6908
6993
|
if problems:
|
|
6909
6994
|
print("FACTUAL DRIFT: %d claim(s) disagree with the derived facts"
|
|
6910
6995
|
% len(problems))
|
|
@@ -7126,12 +7211,34 @@ def _band(score):
|
|
|
7126
7211
|
|
|
7127
7212
|
_AC_ID = re.compile(r"(?i)^[*_`]*\s*AC[-_]?0*(\d+)\b[*_`]*[\s.:—–·-]*")
|
|
7128
7213
|
|
|
7214
|
+
# kit 1.87.0 (ADR-036): the family grammar, same tolerated wrappers/separators.
|
|
7215
|
+
# A family starts with a LETTER, so a numeric "family" is not one: '- [ ] AC-7-x'
|
|
7216
|
+
# falls through to the bare pattern and reads as AC-7 followed by the text 'x' (the
|
|
7217
|
+
# trailing-separator class eats the hyphen, exactly as before ADR-036).
|
|
7218
|
+
_AC_ID_FAM = re.compile(
|
|
7219
|
+
r"(?i)^[*_`]*\s*AC[-_]([A-Za-z][A-Za-z0-9]*)[-_]0*(\d+)\b[*_`]*[\s.:—–·-]*")
|
|
7220
|
+
|
|
7221
|
+
|
|
7222
|
+
def _ac_id_of(body):
|
|
7223
|
+
"""(canonical id, end offset) of the leading AC id of a checkbox body, or
|
|
7224
|
+
(None, 0). The FAMILY form is tried first; the bare form is the fallback, so
|
|
7225
|
+
every id the engine read before ADR-036 still reads exactly the same."""
|
|
7226
|
+
m = _AC_ID_FAM.match(body)
|
|
7227
|
+
if m:
|
|
7228
|
+
return _ac_canon(m.group(1), m.group(2)), m.end()
|
|
7229
|
+
m = _AC_ID.match(body)
|
|
7230
|
+
if m:
|
|
7231
|
+
return _ac_canon(None, m.group(1)), m.end()
|
|
7232
|
+
return None, 0
|
|
7233
|
+
|
|
7129
7234
|
|
|
7130
7235
|
def _parse_acceptance_items(path, section=None):
|
|
7131
7236
|
"""Checkboxes markdown de ACCEPTANCE, con ID trazable opcional por criterio
|
|
7132
7237
|
('- [ ] AC-01 — cuando X entonces Y'). Los IDs se normalizan por numero
|
|
7133
|
-
(AC-01 == AC_1 == ac1 — los nombres de test de python/go no admiten '-')
|
|
7134
|
-
|
|
7238
|
+
(AC-01 == AC_1 == ac1 — los nombres de test de python/go no admiten '-') y,
|
|
7239
|
+
desde kit 1.87.0 (ADR-036), tambien por FAMILIA ('- [ ] AC-BC-07 — ...',
|
|
7240
|
+
AC-T-01 == AC-T-1 == ac_t_1). Devuelve (items, found);
|
|
7241
|
+
item = {'id': 'AC-n'|'AC-FAM-n'|None, 'checked', 'text'}."""
|
|
7135
7242
|
if not path or not os.path.exists(path):
|
|
7136
7243
|
return [], False
|
|
7137
7244
|
items = []
|
|
@@ -7154,10 +7261,10 @@ def _parse_acceptance_items(path, section=None):
|
|
|
7154
7261
|
else:
|
|
7155
7262
|
continue
|
|
7156
7263
|
body = s[5:].strip()
|
|
7157
|
-
|
|
7158
|
-
items.append({"id":
|
|
7264
|
+
cid, end = _ac_id_of(body)
|
|
7265
|
+
items.append({"id": cid,
|
|
7159
7266
|
"checked": checked,
|
|
7160
|
-
"text": body[
|
|
7267
|
+
"text": body[end:].strip()})
|
|
7161
7268
|
except OSError:
|
|
7162
7269
|
return [], False
|
|
7163
7270
|
return items, True
|
|
@@ -8108,12 +8215,21 @@ def _top_pct(done, total):
|
|
|
8108
8215
|
return 99 if (done < total and pct >= 100) else pct
|
|
8109
8216
|
|
|
8110
8217
|
|
|
8111
|
-
def
|
|
8112
|
-
"""Stable
|
|
8218
|
+
def _top_ac_key(cid):
|
|
8219
|
+
"""Stable order for the normalized ids _parse_acceptance_items emits: the BARE
|
|
8220
|
+
'AC-<n>' criteria first by number, then each letter FAMILY alphabetically and by
|
|
8221
|
+
number inside it (ADR-036). An id in neither shape sorts last instead of raising —
|
|
8222
|
+
the board must still render when the acceptance file carries something unexpected."""
|
|
8223
|
+
parts = str(cid).split("-")
|
|
8224
|
+
if len(parts) >= 3:
|
|
8225
|
+
try:
|
|
8226
|
+
return (1, parts[1].upper(), int(parts[2]))
|
|
8227
|
+
except ValueError:
|
|
8228
|
+
return (2, str(cid), 0)
|
|
8113
8229
|
try:
|
|
8114
|
-
return
|
|
8230
|
+
return (0, "", int(parts[1]))
|
|
8115
8231
|
except (IndexError, ValueError):
|
|
8116
|
-
return 0
|
|
8232
|
+
return (2, str(cid), 0)
|
|
8117
8233
|
|
|
8118
8234
|
|
|
8119
8235
|
def cmd_top(args):
|
|
@@ -8133,16 +8249,10 @@ def cmd_top(args):
|
|
|
8133
8249
|
acc_path = args.acceptance or (cfg.get("defaults") or {}).get("acceptance_file")
|
|
8134
8250
|
acc_items, _acc_found = _parse_acceptance_items(acc_path, args.section)
|
|
8135
8251
|
|
|
8136
|
-
# measured evidence: the SAME helper readiness closes criteria with (_ac_tags
|
|
8137
|
-
# across repos exactly as cmd_readiness sums it. Re-deriving it
|
|
8138
|
-
# mirador sin (two derivations of one number, free to disagree).
|
|
8139
|
-
ac_tags =
|
|
8140
|
-
for rcfg in cfg.get("repos", []):
|
|
8141
|
-
rtags, _stale = _ac_tags(rcfg.get("path", "."), rcfg.get("type", "maven"))
|
|
8142
|
-
for cid, v in rtags.items():
|
|
8143
|
-
d = ac_tags.setdefault(cid, {"green": 0, "red": 0})
|
|
8144
|
-
d["green"] += v.get("green", 0)
|
|
8145
|
-
d["red"] += v.get("red", 0)
|
|
8252
|
+
# measured evidence: the SAME helper readiness closes criteria with (_ac_tags via
|
|
8253
|
+
# _sum_ac_tags), summed across repos exactly as cmd_readiness sums it. Re-deriving it
|
|
8254
|
+
# here would be the 1.48.1 mirador sin (two derivations of one number, free to disagree).
|
|
8255
|
+
ac_tags, _stale = _sum_ac_tags(ledger)
|
|
8146
8256
|
|
|
8147
8257
|
# quarantine: UNCURATED observations whose statement literally names an AC id. The link
|
|
8148
8258
|
# is `canonical_match`, a heuristic TEXT match (_match_canonical) -- not a designed
|
|
@@ -8191,7 +8301,7 @@ def cmd_top(args):
|
|
|
8191
8301
|
seen.add(it["id"])
|
|
8192
8302
|
ids.append(it["id"])
|
|
8193
8303
|
obligations = []
|
|
8194
|
-
for cid in sorted(ids, key=
|
|
8304
|
+
for cid in sorted(ids, key=_top_ac_key):
|
|
8195
8305
|
tag, obs = ac_tags.get(cid), quarantine.get(cid)
|
|
8196
8306
|
# red evidence VETOES (fail-closed, the same rule _ac_closed applies). Measured
|
|
8197
8307
|
# evidence outranks the lateral QUARANTINE rung so the four buckets partition the
|
|
@@ -8213,7 +8323,10 @@ def cmd_top(args):
|
|
|
8213
8323
|
"cases_pass": (tag or {}).get("green", 0),
|
|
8214
8324
|
"cases_total": (tag or {}).get("green", 0) + (tag or {}).get("red", 0),
|
|
8215
8325
|
"trace": [], # no general AC->implementation map yet (ADR-035/5)
|
|
8216
|
-
|
|
8326
|
+
# red/green evidence outranks the lateral QUARANTINE rung (the state ladder just
|
|
8327
|
+
# above), so an OBS that merely NAMES this AC must not travel into the JSON once
|
|
8328
|
+
# the criterion is already measured -- only a state of QUARANTINE ever carries it.
|
|
8329
|
+
"quarantine_obs": obs if state == "QUARANTINE" else None,
|
|
8217
8330
|
"ac": None, # contract slot with no second honest meaning here
|
|
8218
8331
|
"age_hours": None}) # no first-seen timestamp exists (ADR-035/1)
|
|
8219
8332
|
|
|
@@ -8291,18 +8404,7 @@ def cmd_readiness(args):
|
|
|
8291
8404
|
# cierra solo con >=1 testcase verde taggeado en los reportes JUnit ya
|
|
8292
8405
|
# ingeridos (y 0 rojos). El checkbox es RELATO; el testcase es HECHO.
|
|
8293
8406
|
ac_ids = [i for i in acc_items if i["id"]]
|
|
8294
|
-
ac_tags =
|
|
8295
|
-
stale_reports = []
|
|
8296
|
-
for rcfg in ledger["config"].get("repos", []):
|
|
8297
|
-
rtags, rstale = _ac_tags(rcfg.get("path", "."),
|
|
8298
|
-
rcfg.get("type", "maven"))
|
|
8299
|
-
for cid, v in rtags.items():
|
|
8300
|
-
d = ac_tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
|
|
8301
|
-
d["green"] += v["green"]
|
|
8302
|
-
d["red"] += v["red"]
|
|
8303
|
-
d["cases"] = (d["cases"] + v.get("cases", []))[:8]
|
|
8304
|
-
stale_reports.extend(rstale)
|
|
8305
|
-
stale_reports = sorted(set(stale_reports))
|
|
8407
|
+
ac_tags, stale_reports = _sum_ac_tags(ledger)
|
|
8306
8408
|
|
|
8307
8409
|
def _ac_closed(cid):
|
|
8308
8410
|
d = ac_tags.get(cid)
|
|
@@ -8311,13 +8413,13 @@ def cmd_readiness(args):
|
|
|
8311
8413
|
# IDs duplicados (ACCEPTANCE mal numerado) cuentan UNA sola vez — si no,
|
|
8312
8414
|
# un solo test verde cierra "medido" tantos criterios como copias del ID.
|
|
8313
8415
|
id_list = [i["id"] for i in ac_ids]
|
|
8314
|
-
dupe_ids = sorted({cid for cid in id_list if id_list.count(cid) > 1})
|
|
8315
|
-
unique_ids = sorted(set(id_list))
|
|
8416
|
+
dupe_ids = sorted({cid for cid in id_list if id_list.count(cid) > 1}, key=_top_ac_key)
|
|
8417
|
+
unique_ids = sorted(set(id_list), key=_top_ac_key)
|
|
8316
8418
|
measured_closed = [cid for cid in unique_ids if _ac_closed(cid)]
|
|
8317
8419
|
narrated_only = sorted({i["id"] for i in ac_ids
|
|
8318
|
-
if i["checked"] and not _ac_closed(i["id"])})
|
|
8420
|
+
if i["checked"] and not _ac_closed(i["id"])}, key=_top_ac_key)
|
|
8319
8421
|
measured_unchecked = sorted({i["id"] for i in ac_ids
|
|
8320
|
-
if not i["checked"] and _ac_closed(i["id"])})
|
|
8422
|
+
if not i["checked"] and _ac_closed(i["id"])}, key=_top_ac_key)
|
|
8321
8423
|
ac_untagged = total - len(ac_ids)
|
|
8322
8424
|
acc_traceable = bool(ac_ids)
|
|
8323
8425
|
if acc_traceable:
|
|
@@ -8592,8 +8694,9 @@ def cmd_readiness(args):
|
|
|
8592
8694
|
f"(--section {args.section!r} matched nothing in the file?) — "
|
|
8593
8695
|
f"adr/acceptance dimensions at 0")
|
|
8594
8696
|
if acc_found and total and not acc_traceable:
|
|
8595
|
-
print(" ! acceptance has no traceable IDs ('- [ ] AC-01 — ...'
|
|
8596
|
-
"acceptance dimension falls back to the
|
|
8697
|
+
print(" ! acceptance has no traceable IDs ('- [ ] AC-01 — ...' or "
|
|
8698
|
+
"'- [ ] AC-BC-01 — ...') — the acceptance dimension falls back to the "
|
|
8699
|
+
"checkbox ratio (NARRATED, not measured)")
|
|
8597
8700
|
if dupe_ids:
|
|
8598
8701
|
print(f" ! duplicate IDs in acceptance (normalized): {', '.join(dupe_ids)} "
|
|
8599
8702
|
f"— each ID counts ONCE in the acceptance dimension")
|
|
@@ -8827,7 +8930,7 @@ def cmd_rebuild(args):
|
|
|
8827
8930
|
|
|
8828
8931
|
|
|
8829
8932
|
def _rebuild_baseline(args):
|
|
8830
|
-
cfg = _load(args.config)
|
|
8933
|
+
cfg = _load(args.config, what="config", flag="--config")
|
|
8831
8934
|
defaults = cfg.get("defaults", {})
|
|
8832
8935
|
acc_default = args.acceptance or defaults.get("acceptance_file")
|
|
8833
8936
|
tol = (args.coverage_tolerance if args.coverage_tolerance is not None
|
|
@@ -8853,7 +8956,7 @@ def _rebuild_baseline(args):
|
|
|
8853
8956
|
|
|
8854
8957
|
|
|
8855
8958
|
def _rebuild_compare(args):
|
|
8856
|
-
base = _load(args.baseline)
|
|
8959
|
+
base = _load(args.baseline, what="baseline", flag="--baseline")
|
|
8857
8960
|
tol = base.get("coverage_tolerance", DEFAULT_COVERAGE_TOLERANCE)
|
|
8858
8961
|
acc_path = args.acceptance or base.get("acceptance_file")
|
|
8859
8962
|
section = args.section if args.section is not None else base.get("section")
|
|
@@ -10067,8 +10170,9 @@ def _spec_check_text(text):
|
|
|
10067
10170
|
def _acceptance_traceability(path):
|
|
10068
10171
|
"""Trazabilidad del ACCEPTANCE (kit 1.10.0): estructura = FACT.
|
|
10069
10172
|
Bloquea: archivo ausente, cero criterios, CERO criterios con AC-ID, IDs
|
|
10070
|
-
duplicados (tras normalizar: AC-01 == AC-1
|
|
10071
|
-
sin ID (no podran cerrar
|
|
10173
|
+
duplicados (tras normalizar: AC-01 == AC-1, y desde kit 1.87.0 / ADR-036
|
|
10174
|
+
AC-BC-07 == AC-BC-7). Aconseja: criterios sueltos sin ID (no podran cerrar
|
|
10175
|
+
MEDIDO)."""
|
|
10072
10176
|
blockers, advisory = [], []
|
|
10073
10177
|
items, found = _parse_acceptance_items(path)
|
|
10074
10178
|
if not found:
|
|
@@ -10080,7 +10184,8 @@ def _acceptance_traceability(path):
|
|
|
10080
10184
|
ids = [i["id"] for i in items if i["id"]]
|
|
10081
10185
|
if not ids:
|
|
10082
10186
|
blockers.append("cero criterios trazables — cada criterio lleva ID "
|
|
10083
|
-
"estable: '- [ ] AC-01 — cuando X entonces Y'"
|
|
10187
|
+
"estable: '- [ ] AC-01 — cuando X entonces Y' "
|
|
10188
|
+
"(o con familia: '- [ ] AC-BC-01 — ...')")
|
|
10084
10189
|
return blockers, advisory
|
|
10085
10190
|
dupes = sorted({x for x in ids if ids.count(x) > 1})
|
|
10086
10191
|
if dupes:
|
|
@@ -10800,7 +10905,8 @@ def cmd_doctor(args):
|
|
|
10800
10905
|
elif items:
|
|
10801
10906
|
warn(f"ACCEPTANCE without traceable AC-IDs ({len(items)} criterion(s))",
|
|
10802
10907
|
"generate it with /uscha-discovery or /uscha-adr-refine "
|
|
10803
|
-
"(format '- [ ] AC-01 - ...'
|
|
10908
|
+
"(format '- [ ] AC-01 - ...', or with a family "
|
|
10909
|
+
"'- [ ] AC-BC-01 - ...') - without IDs the dominant readiness "
|
|
10804
10910
|
"dimension falls back to the checkbox ratio")
|
|
10805
10911
|
else:
|
|
10806
10912
|
warn(f"ACCEPTANCE {acc} has no criteria (zero checkboxes)")
|
|
@@ -27,7 +27,6 @@ import subprocess
|
|
|
27
27
|
import sys
|
|
28
28
|
|
|
29
29
|
DEFAULT_LEDGER = "QA-LEDGER.json"
|
|
30
|
-
DEFAULT_SIZE = (100, 32)
|
|
31
30
|
FALLBACK_SIZE = (100, 32)
|
|
32
31
|
|
|
33
32
|
# Lines the board always spends on chrome: the title, 3 rules, 4 KPI lines, the table
|
|
@@ -257,6 +256,9 @@ def load_state(state_path=None, ledger=DEFAULT_LEDGER, engine=None):
|
|
|
257
256
|
if state_path:
|
|
258
257
|
with open(state_path, "r", encoding="utf-8") as fh:
|
|
259
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)
|
|
260
262
|
eng = engine or engine_path()
|
|
261
263
|
if not eng:
|
|
262
264
|
raise RuntimeError("qa_ledger.py not found next to uscha_top.py")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "uscha",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.87.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": {
|
package/uscha-kit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# uscha-kit
|
|
2
2
|
|
|
3
|
-
**Kit version:** v1.
|
|
3
|
+
**Kit version:** v1.87.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.
|
|
1
|
+
uscha-kit 1.87.0
|
|
@@ -758,10 +758,12 @@ def _open_best_effort(path):
|
|
|
758
758
|
pass # the renderer already printed the absolute path
|
|
759
759
|
|
|
760
760
|
|
|
761
|
-
def
|
|
762
|
-
"""
|
|
763
|
-
|
|
764
|
-
|
|
761
|
+
def _kit_script_path(skill_dir, filename):
|
|
762
|
+
"""A script inside this kit, in either skill-tree layout (`skills/<skill_dir>/` or
|
|
763
|
+
`.claude/skills/<skill_dir>/`) -- the one resolution every sibling script lookup in this
|
|
764
|
+
file shares, and the same both-layouts precedent `uscha_top.py::engine_path()` uses on
|
|
765
|
+
its own side of the lookup."""
|
|
766
|
+
for rel in (("skills", skill_dir, filename), (".claude", "skills", skill_dir, filename)):
|
|
765
767
|
candidate = KIT_ROOT.joinpath(*rel)
|
|
766
768
|
if candidate.is_file():
|
|
767
769
|
return candidate
|
|
@@ -772,7 +774,7 @@ def cmd_mirador(args):
|
|
|
772
774
|
"""`uscha mirador` — one command to render + open the project's dashboard.
|
|
773
775
|
No paths, no python: the renderer self-resolves its engine/template siblings, and the
|
|
774
776
|
ledger defaults to the QA-LEDGER.json convention in the current directory."""
|
|
775
|
-
render =
|
|
777
|
+
render = _kit_script_path("uscha-mirador", "mirador-render.py")
|
|
776
778
|
if render is None:
|
|
777
779
|
print("[uscha mirador] mirador-render.py not found in the kit", file=sys.stderr)
|
|
778
780
|
raise SystemExit(1)
|
|
@@ -807,26 +809,6 @@ def cmd_mirador(args):
|
|
|
807
809
|
print("\n[uscha mirador] stopped")
|
|
808
810
|
|
|
809
811
|
|
|
810
|
-
def _uscha_top_path():
|
|
811
|
-
"""The `uscha top` renderer inside this kit (either skill-tree layout)."""
|
|
812
|
-
for rel in (("skills", "uscha-devloop", "uscha_top.py"),
|
|
813
|
-
(".claude", "skills", "uscha-devloop", "uscha_top.py")):
|
|
814
|
-
candidate = KIT_ROOT.joinpath(*rel)
|
|
815
|
-
if candidate.is_file():
|
|
816
|
-
return candidate
|
|
817
|
-
return None
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
def _qa_ledger_path():
|
|
821
|
-
"""The engine inside this kit (either skill-tree layout)."""
|
|
822
|
-
for rel in (("skills", "uscha-devloop", "qa_ledger.py"),
|
|
823
|
-
(".claude", "skills", "uscha-devloop", "qa_ledger.py")):
|
|
824
|
-
candidate = KIT_ROOT.joinpath(*rel)
|
|
825
|
-
if candidate.is_file():
|
|
826
|
-
return candidate
|
|
827
|
-
return None
|
|
828
|
-
|
|
829
|
-
|
|
830
812
|
def cmd_top(args):
|
|
831
813
|
"""`uscha top` — the live terminal board of the project's ledger (ADR-031).
|
|
832
814
|
Wired exactly like `mirador`: resolve the sibling script inside the kit and exec it with
|
|
@@ -837,7 +819,7 @@ def cmd_top(args):
|
|
|
837
819
|
"--ledger" % args.ledger, file=sys.stderr)
|
|
838
820
|
raise SystemExit(1)
|
|
839
821
|
if args.json:
|
|
840
|
-
engine =
|
|
822
|
+
engine = _kit_script_path("uscha-devloop", "qa_ledger.py")
|
|
841
823
|
if engine is None:
|
|
842
824
|
print("[uscha top] qa_ledger.py not found in the kit", file=sys.stderr)
|
|
843
825
|
raise SystemExit(1)
|
|
@@ -846,7 +828,7 @@ def cmd_top(args):
|
|
|
846
828
|
if rc:
|
|
847
829
|
raise SystemExit(rc)
|
|
848
830
|
return
|
|
849
|
-
renderer =
|
|
831
|
+
renderer = _kit_script_path("uscha-devloop", "uscha_top.py")
|
|
850
832
|
if renderer is None:
|
|
851
833
|
print("[uscha top] uscha_top.py not found in the kit", file=sys.stderr)
|
|
852
834
|
raise SystemExit(1)
|
|
@@ -0,0 +1 @@
|
|
|
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 +1 @@
|
|
|
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, "AC-T-19": 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}
|
|
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, "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}
|
|
@@ -535,7 +535,10 @@ once logged it caps readiness ≤65 and blocks convergence until resolved with
|
|
|
535
535
|
criterion carries a stable ID: `- [ ] AC-01 — when X then Y`. A criterion counts as
|
|
536
536
|
CLOSED only when ≥1 GREEN testcase whose name carries the tag (`test_ac1_x`,
|
|
537
537
|
`testAC01X`, `"AC-01: ..."` — IDs normalize by number, `AC-01 == AC_1 == ac1`) exists
|
|
538
|
-
in the ingested JUnit reports AND no tagged testcase is red.
|
|
538
|
+
in the ingested JUnit reports AND no tagged testcase is red. Since kit 1.87.0 (ADR-036)
|
|
539
|
+
a FAMILY prefix is read the same way: `- [ ] AC-BC-07 — ...` closes on `AC-BC-07_x`,
|
|
540
|
+
`test_ac_bc_7_y` or `AC_BC_7` (normalized to `AC-BC-7`; the family needs a separator on
|
|
541
|
+
both sides — camelCase `testACBC07` is NOT a tag, and `AC-7-x` is still the bare `AC-7`). The checkbox is the
|
|
539
542
|
NARRATIVE; the testcase is the FACT — a checked box without a green tagged test shows
|
|
540
543
|
up as `narrated_only` and does NOT close (measured beats narrated, per criterion).
|
|
541
544
|
A JUnit report older than the repo's source code is treated as STALE (the code changed
|
|
@@ -142,7 +142,7 @@ def _integrity_hash(data):
|
|
|
142
142
|
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
143
143
|
|
|
144
144
|
|
|
145
|
-
def _load(path):
|
|
145
|
+
def _load(path, what="ledger", flag="--ledger"):
|
|
146
146
|
"""Carga blindada (kit 1.13.0, Topic 34: los recursos compartidos mutables
|
|
147
147
|
incluyen ARCHIVOS). JSON corrupto/truncado = hecho bloqueante con mensaje
|
|
148
148
|
de recuperacion, no un traceback. Si el archivo trae campo integrity
|
|
@@ -153,6 +153,12 @@ def _load(path):
|
|
|
153
153
|
try:
|
|
154
154
|
with open(path, "r", encoding="utf-8") as fh:
|
|
155
155
|
data = json.load(fh)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
# what/flag name the file kind and the flag that points at it (1.86.1 re-judge: a
|
|
158
|
+
# hardcoded "ledger ... --ledger" misled the very first command of a fresh clone,
|
|
159
|
+
# init --config, and rebuild --baseline).
|
|
160
|
+
hint = " -- run the dev loop first, or pass --ledger" if what == "ledger" else f" -- pass {flag}"
|
|
161
|
+
raise SystemExit(f"[qa_ledger] {what} '{path}' not found here{hint}")
|
|
156
162
|
except json.JSONDecodeError as exc:
|
|
157
163
|
raise SystemExit(
|
|
158
164
|
f"[qa_ledger] {path} corrupto (JSON invalido: {exc}). Es un artefacto "
|
|
@@ -758,11 +764,43 @@ def _test_evidence_provenance(repo_path, repo_type):
|
|
|
758
764
|
_AC_TAG = re.compile(
|
|
759
765
|
r"(?:(?<![A-Za-z0-9])[Aa][Cc]|(?<=[a-z])AC)[-_]?0*(\d+)(?!\d)")
|
|
760
766
|
|
|
767
|
+
# kit 1.87.0 (ADR-036): the FAMILY grammar 'AC-<FAMILY>-<n>' (AC-BC-07, AC-T-24,
|
|
768
|
+
# ac_dd_3). Same explicit boundaries as the bare form, plus a MANDATORY separator
|
|
769
|
+
# on BOTH sides of the family — 'AC-BC-07', 'ac_bc_7', 'AC_T_1'. camelCase families
|
|
770
|
+
# are deliberately NOT supported: 'testACBC07' has no honest split into family +
|
|
771
|
+
# number and stays unmatched.
|
|
772
|
+
# Kept as its OWN pattern so the bare pattern above (and every tag it already
|
|
773
|
+
# produced) stays byte-identical. The two are disjoint by construction: a family
|
|
774
|
+
# must start with a LETTER, so 'AC-01' can never match this one, and the digits of
|
|
775
|
+
# 'AC-BC-07' do not follow 'AC', so it can never match the bare one.
|
|
776
|
+
_AC_TAG_FAM = re.compile(
|
|
777
|
+
r"(?:(?<![A-Za-z0-9])[Aa][Cc]|(?<=[a-z])AC)"
|
|
778
|
+
r"[-_]([A-Za-z][A-Za-z0-9]*)[-_]0*(\d+)(?!\d)")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _ac_canon(family, num):
|
|
782
|
+
"""Canonical criterion id for both grammars (ADR-036): 'AC-<int>' for the bare
|
|
783
|
+
form (AC-01 == AC_1 == ac1) and 'AC-<FAMILY>-<int>' for the family form
|
|
784
|
+
(AC-T-01 == AC-T-1 == ac_t_1) — python/go test names cannot carry '-', so the
|
|
785
|
+
separator and the zero padding are never part of the identity."""
|
|
786
|
+
if family:
|
|
787
|
+
return "AC-%s-%d" % (family.upper(), int(num))
|
|
788
|
+
return "AC-%d" % int(num)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _ac_tag_ids(name):
|
|
792
|
+
"""Every criterion id a testcase NAME tags, normalized. Family form first, then
|
|
793
|
+
bare; the two patterns are disjoint, so the order fixes only the output order."""
|
|
794
|
+
ids = [_ac_canon(fam, num) for fam, num in _AC_TAG_FAM.findall(name)]
|
|
795
|
+
ids.extend(_ac_canon(None, num) for num in _AC_TAG.findall(name))
|
|
796
|
+
return ids
|
|
797
|
+
|
|
761
798
|
|
|
762
799
|
def _ac_tags(repo_path, repo_type):
|
|
763
800
|
"""Tags AC-n leidos de los NOMBRES de testcase en los reportes JUnit que el
|
|
764
801
|
engine ya ingiere. Devuelve (tags, stale) donde tags = {'AC-n': {'green': x,
|
|
765
|
-
'red': y}}
|
|
802
|
+
'red': y}} (o 'AC-FAM-n' para la forma con familia, ADR-036 / kit 1.87.0)
|
|
803
|
+
y stale = [rutas de reportes descartados por viejos]. Un criterio
|
|
766
804
|
cierra MEDIDO solo con >=1 testcase verde y 0 rojos (evidencia roja veta:
|
|
767
805
|
fail-closed). Testcases skipped no cuentan para ningun lado.
|
|
768
806
|
|
|
@@ -806,9 +844,8 @@ def _ac_tags(repo_path, repo_type):
|
|
|
806
844
|
# cuyo nombre matchea 'ACn' por coincidencia (test_ac3_flow.py) no
|
|
807
845
|
# debe taggear los OTROS tests del mismo archivo/clase.
|
|
808
846
|
blob = tc.get("name") or ""
|
|
809
|
-
for
|
|
810
|
-
d = tags.setdefault(
|
|
811
|
-
{"green": 0, "red": 0, "cases": []})
|
|
847
|
+
for cid in _ac_tag_ids(blob):
|
|
848
|
+
d = tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
|
|
812
849
|
d[status] += 1
|
|
813
850
|
# RECEIPT (kit 1.50.0): keep WHICH testcase in WHICH report backed the
|
|
814
851
|
# verdict -- the name and path were always in scope here and were being
|
|
@@ -820,6 +857,24 @@ def _ac_tags(repo_path, repo_type):
|
|
|
820
857
|
return tags, stale
|
|
821
858
|
|
|
822
859
|
|
|
860
|
+
def _sum_ac_tags(ledger):
|
|
861
|
+
"""One derivation of "how many green/red JUnit testcases does AC-n have, summed across
|
|
862
|
+
every configured repo" -- shared by cmd_readiness (which also keeps up to 8 example case
|
|
863
|
+
receipts per AC and the combined stale-report list) and cmd_top (which only needs
|
|
864
|
+
green/red). One place, so the two readouts of the same fact cannot silently disagree."""
|
|
865
|
+
ac_tags = {}
|
|
866
|
+
stale_reports = []
|
|
867
|
+
for rcfg in (ledger.get("config") or {}).get("repos", []):
|
|
868
|
+
rtags, rstale = _ac_tags(rcfg.get("path", "."), rcfg.get("type", "maven"))
|
|
869
|
+
for cid, v in rtags.items():
|
|
870
|
+
d = ac_tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
|
|
871
|
+
d["green"] += v["green"]
|
|
872
|
+
d["red"] += v["red"]
|
|
873
|
+
d["cases"] = (d["cases"] + v.get("cases", []))[:8]
|
|
874
|
+
stale_reports.extend(rstale)
|
|
875
|
+
return ac_tags, sorted(set(stale_reports))
|
|
876
|
+
|
|
877
|
+
|
|
823
878
|
def junit_test_count(repo_path, extra_files=None):
|
|
824
879
|
"""JUnit-family XML: `pytest --junitxml=...` (python) and jest-junit /
|
|
825
880
|
vitest --reporter=junit (node). Modern emitters WRAP the root:
|
|
@@ -1832,7 +1887,7 @@ def _validate_log_step_counts(args):
|
|
|
1832
1887
|
|
|
1833
1888
|
|
|
1834
1889
|
def cmd_init(args):
|
|
1835
|
-
cfg = _load(args.config)
|
|
1890
|
+
cfg = _load(args.config, what="config", flag="--config")
|
|
1836
1891
|
_validate_init_config(cfg)
|
|
1837
1892
|
defaults = cfg.get("defaults", {})
|
|
1838
1893
|
ledger = {
|
|
@@ -4189,15 +4244,20 @@ def _canonical_ids(repo_path, acceptance_file):
|
|
|
4189
4244
|
except Exception:
|
|
4190
4245
|
return {}
|
|
4191
4246
|
for it in items or []:
|
|
4192
|
-
if it.get("id"):
|
|
4193
|
-
|
|
4247
|
+
if it.get("id"):
|
|
4248
|
+
# normalized id of EITHER grammar (ADR-036): "AC-<n>" or "AC-<FAMILY>-<n>". Keyed by
|
|
4249
|
+
# the canonical id itself; a bare and a family id can never collide.
|
|
4250
|
+
ids[it["id"]] = it["id"]
|
|
4194
4251
|
return ids
|
|
4195
4252
|
|
|
4196
4253
|
|
|
4197
4254
|
def _match_canonical(statement, canon_ids):
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4255
|
+
"""The canonical id an observation's statement names, if any -- read with the same
|
|
4256
|
+
grammar as ACCEPTANCE.md and the JUnit tags (ADR-036), so a statement that mentions
|
|
4257
|
+
'AC-DD-07' anchors the criterion AC-DD-7 exactly as one that mentions 'AC-7' anchors AC-7."""
|
|
4258
|
+
for cid in _ac_tag_ids(statement or ""):
|
|
4259
|
+
if cid in canon_ids:
|
|
4260
|
+
return canon_ids[cid]
|
|
4201
4261
|
return None
|
|
4202
4262
|
|
|
4203
4263
|
|
|
@@ -6892,6 +6952,9 @@ def cmd_facts(args):
|
|
|
6892
6952
|
except OSError as exc:
|
|
6893
6953
|
problems.append((path, 0, "file", "unreadable: %s" % exc, ""))
|
|
6894
6954
|
continue
|
|
6955
|
+
in_table = False
|
|
6956
|
+
table_names = []
|
|
6957
|
+
table_start = 0
|
|
6895
6958
|
for n, line in enumerate(lines, 1):
|
|
6896
6959
|
# an HTML comment is not a published claim -- the first live run flagged a
|
|
6897
6960
|
# section marker (a comment reading "2 Skills") as a drifted count
|
|
@@ -6905,6 +6968,28 @@ def cmd_facts(args):
|
|
|
6905
6968
|
actual = _fact_value(facts, key)
|
|
6906
6969
|
if claimed != actual:
|
|
6907
6970
|
problems.append((path, n, key, claimed, actual))
|
|
6971
|
+
# the parser-surface table (Subcommand/Subcomando header, one `<td class="t">`
|
|
6972
|
+
# row per subcommand) is a claim too, just not a numeric one -- a row can go
|
|
6973
|
+
# missing while the count beside it stays correct (the `top` row did, once).
|
|
6974
|
+
if not in_table:
|
|
6975
|
+
if re.search(r"<th>Sub ?comm?ando?s?</th>", line, re.I):
|
|
6976
|
+
in_table, table_names, table_start = True, [], n
|
|
6977
|
+
continue
|
|
6978
|
+
if "</table>" in line:
|
|
6979
|
+
in_table = False
|
|
6980
|
+
want = set(facts["subcommands"]["list"])
|
|
6981
|
+
got = set(table_names)
|
|
6982
|
+
for name in sorted(want - got):
|
|
6983
|
+
problems.append((path, table_start,
|
|
6984
|
+
"subcommand table: %s" % name,
|
|
6985
|
+
"absent", "present (engine subcommand list)"))
|
|
6986
|
+
for name in sorted(got - want):
|
|
6987
|
+
problems.append((path, table_start,
|
|
6988
|
+
"subcommand table: %s" % name,
|
|
6989
|
+
"present", "absent (not an engine subcommand)"))
|
|
6990
|
+
continue
|
|
6991
|
+
for m in re.finditer(r'<td class="t">([^<]+)</td>', line):
|
|
6992
|
+
table_names.append(m.group(1).strip())
|
|
6908
6993
|
if problems:
|
|
6909
6994
|
print("FACTUAL DRIFT: %d claim(s) disagree with the derived facts"
|
|
6910
6995
|
% len(problems))
|
|
@@ -7126,12 +7211,34 @@ def _band(score):
|
|
|
7126
7211
|
|
|
7127
7212
|
_AC_ID = re.compile(r"(?i)^[*_`]*\s*AC[-_]?0*(\d+)\b[*_`]*[\s.:—–·-]*")
|
|
7128
7213
|
|
|
7214
|
+
# kit 1.87.0 (ADR-036): the family grammar, same tolerated wrappers/separators.
|
|
7215
|
+
# A family starts with a LETTER, so a numeric "family" is not one: '- [ ] AC-7-x'
|
|
7216
|
+
# falls through to the bare pattern and reads as AC-7 followed by the text 'x' (the
|
|
7217
|
+
# trailing-separator class eats the hyphen, exactly as before ADR-036).
|
|
7218
|
+
_AC_ID_FAM = re.compile(
|
|
7219
|
+
r"(?i)^[*_`]*\s*AC[-_]([A-Za-z][A-Za-z0-9]*)[-_]0*(\d+)\b[*_`]*[\s.:—–·-]*")
|
|
7220
|
+
|
|
7221
|
+
|
|
7222
|
+
def _ac_id_of(body):
|
|
7223
|
+
"""(canonical id, end offset) of the leading AC id of a checkbox body, or
|
|
7224
|
+
(None, 0). The FAMILY form is tried first; the bare form is the fallback, so
|
|
7225
|
+
every id the engine read before ADR-036 still reads exactly the same."""
|
|
7226
|
+
m = _AC_ID_FAM.match(body)
|
|
7227
|
+
if m:
|
|
7228
|
+
return _ac_canon(m.group(1), m.group(2)), m.end()
|
|
7229
|
+
m = _AC_ID.match(body)
|
|
7230
|
+
if m:
|
|
7231
|
+
return _ac_canon(None, m.group(1)), m.end()
|
|
7232
|
+
return None, 0
|
|
7233
|
+
|
|
7129
7234
|
|
|
7130
7235
|
def _parse_acceptance_items(path, section=None):
|
|
7131
7236
|
"""Checkboxes markdown de ACCEPTANCE, con ID trazable opcional por criterio
|
|
7132
7237
|
('- [ ] AC-01 — cuando X entonces Y'). Los IDs se normalizan por numero
|
|
7133
|
-
(AC-01 == AC_1 == ac1 — los nombres de test de python/go no admiten '-')
|
|
7134
|
-
|
|
7238
|
+
(AC-01 == AC_1 == ac1 — los nombres de test de python/go no admiten '-') y,
|
|
7239
|
+
desde kit 1.87.0 (ADR-036), tambien por FAMILIA ('- [ ] AC-BC-07 — ...',
|
|
7240
|
+
AC-T-01 == AC-T-1 == ac_t_1). Devuelve (items, found);
|
|
7241
|
+
item = {'id': 'AC-n'|'AC-FAM-n'|None, 'checked', 'text'}."""
|
|
7135
7242
|
if not path or not os.path.exists(path):
|
|
7136
7243
|
return [], False
|
|
7137
7244
|
items = []
|
|
@@ -7154,10 +7261,10 @@ def _parse_acceptance_items(path, section=None):
|
|
|
7154
7261
|
else:
|
|
7155
7262
|
continue
|
|
7156
7263
|
body = s[5:].strip()
|
|
7157
|
-
|
|
7158
|
-
items.append({"id":
|
|
7264
|
+
cid, end = _ac_id_of(body)
|
|
7265
|
+
items.append({"id": cid,
|
|
7159
7266
|
"checked": checked,
|
|
7160
|
-
"text": body[
|
|
7267
|
+
"text": body[end:].strip()})
|
|
7161
7268
|
except OSError:
|
|
7162
7269
|
return [], False
|
|
7163
7270
|
return items, True
|
|
@@ -8108,12 +8215,21 @@ def _top_pct(done, total):
|
|
|
8108
8215
|
return 99 if (done < total and pct >= 100) else pct
|
|
8109
8216
|
|
|
8110
8217
|
|
|
8111
|
-
def
|
|
8112
|
-
"""Stable
|
|
8218
|
+
def _top_ac_key(cid):
|
|
8219
|
+
"""Stable order for the normalized ids _parse_acceptance_items emits: the BARE
|
|
8220
|
+
'AC-<n>' criteria first by number, then each letter FAMILY alphabetically and by
|
|
8221
|
+
number inside it (ADR-036). An id in neither shape sorts last instead of raising —
|
|
8222
|
+
the board must still render when the acceptance file carries something unexpected."""
|
|
8223
|
+
parts = str(cid).split("-")
|
|
8224
|
+
if len(parts) >= 3:
|
|
8225
|
+
try:
|
|
8226
|
+
return (1, parts[1].upper(), int(parts[2]))
|
|
8227
|
+
except ValueError:
|
|
8228
|
+
return (2, str(cid), 0)
|
|
8113
8229
|
try:
|
|
8114
|
-
return
|
|
8230
|
+
return (0, "", int(parts[1]))
|
|
8115
8231
|
except (IndexError, ValueError):
|
|
8116
|
-
return 0
|
|
8232
|
+
return (2, str(cid), 0)
|
|
8117
8233
|
|
|
8118
8234
|
|
|
8119
8235
|
def cmd_top(args):
|
|
@@ -8133,16 +8249,10 @@ def cmd_top(args):
|
|
|
8133
8249
|
acc_path = args.acceptance or (cfg.get("defaults") or {}).get("acceptance_file")
|
|
8134
8250
|
acc_items, _acc_found = _parse_acceptance_items(acc_path, args.section)
|
|
8135
8251
|
|
|
8136
|
-
# measured evidence: the SAME helper readiness closes criteria with (_ac_tags
|
|
8137
|
-
# across repos exactly as cmd_readiness sums it. Re-deriving it
|
|
8138
|
-
# mirador sin (two derivations of one number, free to disagree).
|
|
8139
|
-
ac_tags =
|
|
8140
|
-
for rcfg in cfg.get("repos", []):
|
|
8141
|
-
rtags, _stale = _ac_tags(rcfg.get("path", "."), rcfg.get("type", "maven"))
|
|
8142
|
-
for cid, v in rtags.items():
|
|
8143
|
-
d = ac_tags.setdefault(cid, {"green": 0, "red": 0})
|
|
8144
|
-
d["green"] += v.get("green", 0)
|
|
8145
|
-
d["red"] += v.get("red", 0)
|
|
8252
|
+
# measured evidence: the SAME helper readiness closes criteria with (_ac_tags via
|
|
8253
|
+
# _sum_ac_tags), summed across repos exactly as cmd_readiness sums it. Re-deriving it
|
|
8254
|
+
# here would be the 1.48.1 mirador sin (two derivations of one number, free to disagree).
|
|
8255
|
+
ac_tags, _stale = _sum_ac_tags(ledger)
|
|
8146
8256
|
|
|
8147
8257
|
# quarantine: UNCURATED observations whose statement literally names an AC id. The link
|
|
8148
8258
|
# is `canonical_match`, a heuristic TEXT match (_match_canonical) -- not a designed
|
|
@@ -8191,7 +8301,7 @@ def cmd_top(args):
|
|
|
8191
8301
|
seen.add(it["id"])
|
|
8192
8302
|
ids.append(it["id"])
|
|
8193
8303
|
obligations = []
|
|
8194
|
-
for cid in sorted(ids, key=
|
|
8304
|
+
for cid in sorted(ids, key=_top_ac_key):
|
|
8195
8305
|
tag, obs = ac_tags.get(cid), quarantine.get(cid)
|
|
8196
8306
|
# red evidence VETOES (fail-closed, the same rule _ac_closed applies). Measured
|
|
8197
8307
|
# evidence outranks the lateral QUARANTINE rung so the four buckets partition the
|
|
@@ -8213,7 +8323,10 @@ def cmd_top(args):
|
|
|
8213
8323
|
"cases_pass": (tag or {}).get("green", 0),
|
|
8214
8324
|
"cases_total": (tag or {}).get("green", 0) + (tag or {}).get("red", 0),
|
|
8215
8325
|
"trace": [], # no general AC->implementation map yet (ADR-035/5)
|
|
8216
|
-
|
|
8326
|
+
# red/green evidence outranks the lateral QUARANTINE rung (the state ladder just
|
|
8327
|
+
# above), so an OBS that merely NAMES this AC must not travel into the JSON once
|
|
8328
|
+
# the criterion is already measured -- only a state of QUARANTINE ever carries it.
|
|
8329
|
+
"quarantine_obs": obs if state == "QUARANTINE" else None,
|
|
8217
8330
|
"ac": None, # contract slot with no second honest meaning here
|
|
8218
8331
|
"age_hours": None}) # no first-seen timestamp exists (ADR-035/1)
|
|
8219
8332
|
|
|
@@ -8291,18 +8404,7 @@ def cmd_readiness(args):
|
|
|
8291
8404
|
# cierra solo con >=1 testcase verde taggeado en los reportes JUnit ya
|
|
8292
8405
|
# ingeridos (y 0 rojos). El checkbox es RELATO; el testcase es HECHO.
|
|
8293
8406
|
ac_ids = [i for i in acc_items if i["id"]]
|
|
8294
|
-
ac_tags =
|
|
8295
|
-
stale_reports = []
|
|
8296
|
-
for rcfg in ledger["config"].get("repos", []):
|
|
8297
|
-
rtags, rstale = _ac_tags(rcfg.get("path", "."),
|
|
8298
|
-
rcfg.get("type", "maven"))
|
|
8299
|
-
for cid, v in rtags.items():
|
|
8300
|
-
d = ac_tags.setdefault(cid, {"green": 0, "red": 0, "cases": []})
|
|
8301
|
-
d["green"] += v["green"]
|
|
8302
|
-
d["red"] += v["red"]
|
|
8303
|
-
d["cases"] = (d["cases"] + v.get("cases", []))[:8]
|
|
8304
|
-
stale_reports.extend(rstale)
|
|
8305
|
-
stale_reports = sorted(set(stale_reports))
|
|
8407
|
+
ac_tags, stale_reports = _sum_ac_tags(ledger)
|
|
8306
8408
|
|
|
8307
8409
|
def _ac_closed(cid):
|
|
8308
8410
|
d = ac_tags.get(cid)
|
|
@@ -8311,13 +8413,13 @@ def cmd_readiness(args):
|
|
|
8311
8413
|
# IDs duplicados (ACCEPTANCE mal numerado) cuentan UNA sola vez — si no,
|
|
8312
8414
|
# un solo test verde cierra "medido" tantos criterios como copias del ID.
|
|
8313
8415
|
id_list = [i["id"] for i in ac_ids]
|
|
8314
|
-
dupe_ids = sorted({cid for cid in id_list if id_list.count(cid) > 1})
|
|
8315
|
-
unique_ids = sorted(set(id_list))
|
|
8416
|
+
dupe_ids = sorted({cid for cid in id_list if id_list.count(cid) > 1}, key=_top_ac_key)
|
|
8417
|
+
unique_ids = sorted(set(id_list), key=_top_ac_key)
|
|
8316
8418
|
measured_closed = [cid for cid in unique_ids if _ac_closed(cid)]
|
|
8317
8419
|
narrated_only = sorted({i["id"] for i in ac_ids
|
|
8318
|
-
if i["checked"] and not _ac_closed(i["id"])})
|
|
8420
|
+
if i["checked"] and not _ac_closed(i["id"])}, key=_top_ac_key)
|
|
8319
8421
|
measured_unchecked = sorted({i["id"] for i in ac_ids
|
|
8320
|
-
if not i["checked"] and _ac_closed(i["id"])})
|
|
8422
|
+
if not i["checked"] and _ac_closed(i["id"])}, key=_top_ac_key)
|
|
8321
8423
|
ac_untagged = total - len(ac_ids)
|
|
8322
8424
|
acc_traceable = bool(ac_ids)
|
|
8323
8425
|
if acc_traceable:
|
|
@@ -8592,8 +8694,9 @@ def cmd_readiness(args):
|
|
|
8592
8694
|
f"(--section {args.section!r} matched nothing in the file?) — "
|
|
8593
8695
|
f"adr/acceptance dimensions at 0")
|
|
8594
8696
|
if acc_found and total and not acc_traceable:
|
|
8595
|
-
print(" ! acceptance has no traceable IDs ('- [ ] AC-01 — ...'
|
|
8596
|
-
"acceptance dimension falls back to the
|
|
8697
|
+
print(" ! acceptance has no traceable IDs ('- [ ] AC-01 — ...' or "
|
|
8698
|
+
"'- [ ] AC-BC-01 — ...') — the acceptance dimension falls back to the "
|
|
8699
|
+
"checkbox ratio (NARRATED, not measured)")
|
|
8597
8700
|
if dupe_ids:
|
|
8598
8701
|
print(f" ! duplicate IDs in acceptance (normalized): {', '.join(dupe_ids)} "
|
|
8599
8702
|
f"— each ID counts ONCE in the acceptance dimension")
|
|
@@ -8827,7 +8930,7 @@ def cmd_rebuild(args):
|
|
|
8827
8930
|
|
|
8828
8931
|
|
|
8829
8932
|
def _rebuild_baseline(args):
|
|
8830
|
-
cfg = _load(args.config)
|
|
8933
|
+
cfg = _load(args.config, what="config", flag="--config")
|
|
8831
8934
|
defaults = cfg.get("defaults", {})
|
|
8832
8935
|
acc_default = args.acceptance or defaults.get("acceptance_file")
|
|
8833
8936
|
tol = (args.coverage_tolerance if args.coverage_tolerance is not None
|
|
@@ -8853,7 +8956,7 @@ def _rebuild_baseline(args):
|
|
|
8853
8956
|
|
|
8854
8957
|
|
|
8855
8958
|
def _rebuild_compare(args):
|
|
8856
|
-
base = _load(args.baseline)
|
|
8959
|
+
base = _load(args.baseline, what="baseline", flag="--baseline")
|
|
8857
8960
|
tol = base.get("coverage_tolerance", DEFAULT_COVERAGE_TOLERANCE)
|
|
8858
8961
|
acc_path = args.acceptance or base.get("acceptance_file")
|
|
8859
8962
|
section = args.section if args.section is not None else base.get("section")
|
|
@@ -10067,8 +10170,9 @@ def _spec_check_text(text):
|
|
|
10067
10170
|
def _acceptance_traceability(path):
|
|
10068
10171
|
"""Trazabilidad del ACCEPTANCE (kit 1.10.0): estructura = FACT.
|
|
10069
10172
|
Bloquea: archivo ausente, cero criterios, CERO criterios con AC-ID, IDs
|
|
10070
|
-
duplicados (tras normalizar: AC-01 == AC-1
|
|
10071
|
-
sin ID (no podran cerrar
|
|
10173
|
+
duplicados (tras normalizar: AC-01 == AC-1, y desde kit 1.87.0 / ADR-036
|
|
10174
|
+
AC-BC-07 == AC-BC-7). Aconseja: criterios sueltos sin ID (no podran cerrar
|
|
10175
|
+
MEDIDO)."""
|
|
10072
10176
|
blockers, advisory = [], []
|
|
10073
10177
|
items, found = _parse_acceptance_items(path)
|
|
10074
10178
|
if not found:
|
|
@@ -10080,7 +10184,8 @@ def _acceptance_traceability(path):
|
|
|
10080
10184
|
ids = [i["id"] for i in items if i["id"]]
|
|
10081
10185
|
if not ids:
|
|
10082
10186
|
blockers.append("cero criterios trazables — cada criterio lleva ID "
|
|
10083
|
-
"estable: '- [ ] AC-01 — cuando X entonces Y'"
|
|
10187
|
+
"estable: '- [ ] AC-01 — cuando X entonces Y' "
|
|
10188
|
+
"(o con familia: '- [ ] AC-BC-01 — ...')")
|
|
10084
10189
|
return blockers, advisory
|
|
10085
10190
|
dupes = sorted({x for x in ids if ids.count(x) > 1})
|
|
10086
10191
|
if dupes:
|
|
@@ -10800,7 +10905,8 @@ def cmd_doctor(args):
|
|
|
10800
10905
|
elif items:
|
|
10801
10906
|
warn(f"ACCEPTANCE without traceable AC-IDs ({len(items)} criterion(s))",
|
|
10802
10907
|
"generate it with /uscha-discovery or /uscha-adr-refine "
|
|
10803
|
-
"(format '- [ ] AC-01 - ...'
|
|
10908
|
+
"(format '- [ ] AC-01 - ...', or with a family "
|
|
10909
|
+
"'- [ ] AC-BC-01 - ...') - without IDs the dominant readiness "
|
|
10804
10910
|
"dimension falls back to the checkbox ratio")
|
|
10805
10911
|
else:
|
|
10806
10912
|
warn(f"ACCEPTANCE {acc} has no criteria (zero checkboxes)")
|
|
@@ -27,7 +27,6 @@ import subprocess
|
|
|
27
27
|
import sys
|
|
28
28
|
|
|
29
29
|
DEFAULT_LEDGER = "QA-LEDGER.json"
|
|
30
|
-
DEFAULT_SIZE = (100, 32)
|
|
31
30
|
FALLBACK_SIZE = (100, 32)
|
|
32
31
|
|
|
33
32
|
# Lines the board always spends on chrome: the title, 3 rules, 4 KPI lines, the table
|
|
@@ -257,6 +256,9 @@ def load_state(state_path=None, ledger=DEFAULT_LEDGER, engine=None):
|
|
|
257
256
|
if state_path:
|
|
258
257
|
with open(state_path, "r", encoding="utf-8") as fh:
|
|
259
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)
|
|
260
262
|
eng = engine or engine_path()
|
|
261
263
|
if not eng:
|
|
262
264
|
raise RuntimeError("qa_ledger.py not found next to uscha_top.py")
|
|
@@ -20,7 +20,12 @@ CONFIG = ROOT / "uscha.config.json"
|
|
|
20
20
|
OUT = ROOT / ".claude" / "uscha-progress.json"
|
|
21
21
|
|
|
22
22
|
# tolerant of bold/plain AC ids -- mirrors qa_ledger.py:_AC_ID (AC-01, **AC-01**, `AC_1`)
|
|
23
|
-
|
|
23
|
+
# and, since kit 1.87.0 (ADR-036), the FAMILY form too (AC-BC-07, AC-T-24, `ac_dd_3`).
|
|
24
|
+
# A family starts with a LETTER, so 'AC-7-x' stays the bare AC-7 followed by text --
|
|
25
|
+
# the same fallback order the engine applies. The statusline must never count a
|
|
26
|
+
# criterion the ledger cannot see, nor miss one it can.
|
|
27
|
+
_AC_CORE = r"AC(?:[-_][A-Za-z][A-Za-z0-9]*[-_]|[-_]?)\d+"
|
|
28
|
+
_AC = r"[*_`]*\s*" + _AC_CORE + r"[*_`]*"
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
def _statusline_repo(cfg):
|
|
@@ -91,7 +96,8 @@ def _acceptance(state, cfg):
|
|
|
91
96
|
# a narrated number with the same face as a measured one is the exact dishonesty
|
|
92
97
|
# this kit exists to remove (kit 1.48.1).
|
|
93
98
|
state["acceptance_source"] = "narrated"
|
|
94
|
-
m = re.search(r"- \[ \]\s*[*_`]*\s*(
|
|
99
|
+
m = re.search(r"- \[ \]\s*[*_`]*\s*(" + _AC_CORE + r")[*_`]*\s*[—–-]\s*(.+)",
|
|
100
|
+
text, re.IGNORECASE)
|
|
95
101
|
if m:
|
|
96
102
|
nxt = re.sub(r"[*_`]", "", m.group(2)).strip()
|
|
97
103
|
state["next"] = f"{m.group(1)}: {nxt[:44]}"
|