@andresmassello/uscha 1.40.2 → 1.43.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 (39) hide show
  1. package/README.md +6 -6
  2. package/bin/uscha.js +19 -7
  3. package/package.json +1 -1
  4. package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +2 -2
  5. package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +3 -1
  6. package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +729 -155
  7. package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +22 -7
  8. package/uscha-kit/.claude/skills/uscha-mirador/mirador-render.py +44 -5
  9. package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.ps1 +1 -1
  10. package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.sh +1 -1
  11. package/uscha-kit/.claude/skills/uscha-mirador/mirador.template.html +666 -586
  12. package/uscha-kit/.claude-plugin/plugin.json +1 -1
  13. package/uscha-kit/.codex-plugin/plugin.json +1 -1
  14. package/uscha-kit/CHANGELOG-1.41.0.md +18 -0
  15. package/uscha-kit/CHANGELOG-1.41.1.md +53 -0
  16. package/uscha-kit/CHANGELOG-1.41.2.md +34 -0
  17. package/uscha-kit/CHANGELOG-1.41.3.md +30 -0
  18. package/uscha-kit/CHANGELOG-1.42.0.md +41 -0
  19. package/uscha-kit/CHANGELOG-1.43.0.md +37 -0
  20. package/uscha-kit/INSTALL.md +120 -101
  21. package/uscha-kit/README.md +24 -13
  22. package/uscha-kit/VERSION +1 -1
  23. package/uscha-kit/WORKBENCH.md +19 -5
  24. package/uscha-kit/hooks/block-approved-writes.py +25 -0
  25. package/uscha-kit/install-uscha.py +534 -267
  26. package/uscha-kit/skills/uscha-adr-refine/SKILL.md +2 -2
  27. package/uscha-kit/skills/uscha-devloop/SKILL.md +3 -1
  28. package/uscha-kit/skills/uscha-devloop/qa_ledger.py +729 -155
  29. package/uscha-kit/skills/uscha-mirador/SKILL.md +22 -7
  30. package/uscha-kit/skills/uscha-mirador/mirador-render.py +44 -5
  31. package/uscha-kit/skills/uscha-mirador/mirador-watch.ps1 +1 -1
  32. package/uscha-kit/skills/uscha-mirador/mirador-watch.sh +1 -1
  33. package/uscha-kit/skills/uscha-mirador/mirador.template.html +666 -586
  34. package/uscha-kit/templates/CONSTITUTION.md +4 -4
  35. package/uscha-kit/templates/docs/adr/README.md +19 -19
  36. package/uscha-kit/tests/ledger-integrity-regressions.py +136 -0
  37. package/uscha-kit/tests/smoke-engine.sh +1312 -29
  38. package/uscha-kit/uscha.config.json +1 -1
  39. package/uscha-kit/workbench-doctor.sh +47 -3
@@ -55,10 +55,12 @@ import argparse
55
55
  import glob
56
56
  import hashlib
57
57
  import json
58
+ import math
58
59
  import os
59
60
  import re
60
61
  import shutil
61
62
  import sys
63
+ import unicodedata
62
64
  import xml.etree.ElementTree as ET
63
65
  from datetime import datetime, timezone
64
66
 
@@ -326,6 +328,75 @@ def coverage(repo_path, repo_type):
326
328
  # --------------------------------------------------------------------------- #
327
329
  # measurement: test counts
328
330
  # --------------------------------------------------------------------------- #
331
+ def _invalid_junit(path, detail):
332
+ print(f"[qa_ledger] invalid JUnit XML report '{path}': {detail}",
333
+ file=sys.stderr)
334
+ raise SystemExit(2)
335
+
336
+
337
+ def _parse_junit_xml(path):
338
+ try:
339
+ root = ET.parse(path).getroot()
340
+ except (ET.ParseError, OSError) as exc:
341
+ _invalid_junit(path, exc)
342
+ root_kind = _local(root.tag)
343
+ if root_kind not in ("testsuite", "testsuites"):
344
+ _invalid_junit(path, "expected <testsuite> or <testsuites> root")
345
+ if root_kind == "testsuites":
346
+ children = list(root)
347
+ if children and any(_local(child.tag) != "testsuite"
348
+ for child in children):
349
+ _invalid_junit(path, "<testsuites> may contain only <testsuite> children")
350
+
351
+ return root
352
+
353
+
354
+ def _junit_int(element, name, path):
355
+ try:
356
+ value = int(element.get(name, 0))
357
+ except (TypeError, ValueError):
358
+ _invalid_junit(path, f"attribute '{name}' must be an integer")
359
+ if value < 0:
360
+ _invalid_junit(path, f"attribute '{name}' must be non-negative")
361
+ return value
362
+
363
+
364
+ def _junit_counts(element, path):
365
+ counts = tuple(_junit_int(element, name, path)
366
+ for name in ("tests", "failures", "errors", "skipped"))
367
+ tests, failures, errors, skipped = counts
368
+ # kit 1.41.1 (adversarial-review fix): a <testsuite>'s summary ATTRIBUTES are
369
+ # self-declared and can hide real outcomes -- e.g. failures="0" on a suite that
370
+ # actually contains a <testcase> with a <failure>/<error> element. Honor the real
371
+ # child ELEMENTS, fail-closed (take the worse): present failure/error evidence can
372
+ # never be attribute-declared away. (Attribute-only summary suites with no
373
+ # <testcase> elements keep their declared counts -- the form many emitters use.)
374
+ if _local(element.tag) == "testsuite":
375
+ el_fail = el_err = 0
376
+ for tc in element:
377
+ if _local(tc.tag) != "testcase":
378
+ continue
379
+ kinds = {_local(ch.tag) for ch in tc}
380
+ if "failure" in kinds:
381
+ el_fail += 1
382
+ elif "error" in kinds:
383
+ el_err += 1
384
+ failures = max(failures, el_fail)
385
+ errors = max(errors, el_err)
386
+ if skipped > tests:
387
+ _invalid_junit(path, "attribute 'skipped' cannot exceed 'tests'")
388
+ build_error_only = (
389
+ _local(element.tag) == "testsuites"
390
+ and not list(element)
391
+ and failures == 0
392
+ and errors > 0
393
+ )
394
+ if failures + errors > tests - skipped and not build_error_only:
395
+ _invalid_junit(
396
+ path, "'failures' + 'errors' cannot exceed executed tests")
397
+ return (tests, failures, errors, skipped)
398
+
399
+
329
400
  def _perclass_xml_count(patterns):
330
401
  """Sum per-class JUnit XML files (surefire/failsafe/gradle test-results):
331
402
  each file's root is a <testsuite> carrying the counters."""
@@ -336,14 +407,14 @@ def _perclass_xml_count(patterns):
336
407
  if f in seen:
337
408
  continue
338
409
  seen.add(f)
339
- try:
340
- root = ET.parse(f).getroot()
341
- except ET.ParseError:
342
- continue
343
- tests += int(root.get("tests", 0))
344
- failures += int(root.get("failures", 0))
345
- errors += int(root.get("errors", 0))
346
- skipped += int(root.get("skipped", 0))
410
+ root = _parse_junit_xml(f)
411
+ if _local(root.tag) != "testsuite":
412
+ _invalid_junit(f, "per-class report requires a <testsuite> root")
413
+ t, fl, er, sk = _junit_counts(root, f)
414
+ tests += t
415
+ failures += fl
416
+ errors += er
417
+ skipped += sk
347
418
  executed = tests - skipped
348
419
  return {"total": tests, "executed": executed, "failures": failures,
349
420
  "errors": errors, "skipped": skipped, "passed": executed - failures - errors,
@@ -440,32 +511,101 @@ _SRC_EXT = {
440
511
  ".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".swift", ".m", ".mm",
441
512
  ".rb", ".php", ".dart", ".gradle",
442
513
  }
443
- _SRC_SKIP_DIRS = {
444
- ".git", "target", "build", "reports", "node_modules", "dist", ".gradle",
445
- "venv", ".venv", "__pycache__", ".idea", "bin", "obj", "Pods",
446
- ".dart_tool", "coverage", "out", ".vs",
447
- }
514
+ _SRC_SKIP_DIRS = SKIP_DIRS | {"reports", "Pods", ".vs"}
448
515
 
516
+ # One second admits same-run writes on filesystems with coarse timestamp
517
+ # resolution, without letting genuinely older reports mask later source edits.
518
+ _JUNIT_FRESHNESS_TOLERANCE_NS = 1_000_000_000
449
519
 
450
- def _source_newest_mtime(repo_path):
451
- """Newest mtime among source files under repo_path, skipping build/vendor/
452
- report dirs. Reference for JUnit report freshness: a report older than this
453
- means the code changed after the tests ran. Returns 0.0 if no source file
454
- is found (cannot correlate -> never flags stale, avoids false positives)."""
455
- newest = 0.0
520
+
521
+ def _newest_source(repo_path, extensions=None):
522
+ """Newest relevant source/test file, excluding the same generated, build,
523
+ report, VCS, dependency, and vendor-like trees used by repo adapters."""
524
+ newest = None
525
+ allowed = extensions or _SRC_EXT
456
526
  for root, dirs, files in os.walk(repo_path):
457
- dirs[:] = [d for d in dirs if d not in _SRC_SKIP_DIRS]
527
+ dirs[:] = [d for d in dirs if d not in _SRC_SKIP_DIRS
528
+ and not d.startswith("cmake-build-")]
458
529
  for fn in files:
459
- if os.path.splitext(fn)[1].lower() in _SRC_EXT:
460
- try:
461
- m = os.path.getmtime(os.path.join(root, fn))
462
- except OSError:
463
- continue
464
- if m > newest:
465
- newest = m
530
+ if os.path.splitext(fn)[1].lower() not in allowed:
531
+ continue
532
+ path = os.path.join(root, fn)
533
+ try:
534
+ mtime_ns = os.stat(path).st_mtime_ns
535
+ except OSError:
536
+ continue
537
+ if newest is None or mtime_ns > newest["mtime_ns"]:
538
+ newest = {
539
+ "path": os.path.relpath(path, repo_path).replace("\\", "/"),
540
+ "mtime_ns": mtime_ns,
541
+ "mtime": datetime.fromtimestamp(
542
+ mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
543
+ }
466
544
  return newest
467
545
 
468
546
 
547
+ def _source_newest_mtime(repo_path):
548
+ """Compatibility helper for AC evidence freshness."""
549
+ newest = _newest_source(repo_path)
550
+ return newest["mtime_ns"] / 1_000_000_000 if newest else 0.0
551
+
552
+
553
+ def _test_evidence_provenance(repo_path, repo_type):
554
+ """Explain which JUnit reports back a snapshot and whether they are newer
555
+ than relevant source/test files. No discoverable source is explicitly
556
+ uncorrelated-but-usable to preserve synthetic/report-only workflows."""
557
+ files = _junit_files_for(repo_path, repo_type)
558
+ reports = []
559
+ for path in files:
560
+ try:
561
+ mtime_ns = os.stat(path).st_mtime_ns
562
+ except OSError:
563
+ continue
564
+ reports.append({
565
+ "path": os.path.relpath(path, repo_path).replace("\\", "/"),
566
+ "mtime_ns": mtime_ns,
567
+ "mtime": datetime.fromtimestamp(
568
+ mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
569
+ })
570
+ if not reports:
571
+ status = "not-applicable" if repo_type == "flutter" else "missing"
572
+ reason = ("approximate static test discovery has no JUnit report"
573
+ if repo_type == "flutter"
574
+ else "no selected JUnit report")
575
+ return reports, {"status": status, "reason": reason,
576
+ "tolerance_ns": _JUNIT_FRESHNESS_TOLERANCE_NS}
577
+
578
+ newest = _newest_source(
579
+ repo_path, SOURCE_EXT.get(repo_type, SOURCE_EXT["generic"]))
580
+ if newest is None:
581
+ return reports, {
582
+ "status": "unknown-no-sources",
583
+ "reason": ("no discoverable source/test files; report-only evidence "
584
+ "remains usable for compatibility"),
585
+ "newest_source": None,
586
+ "tolerance_ns": _JUNIT_FRESHNESS_TOLERANCE_NS,
587
+ }
588
+
589
+ stale_reports = [
590
+ report for report in reports
591
+ if newest["mtime_ns"] > report["mtime_ns"] + _JUNIT_FRESHNESS_TOLERANCE_NS
592
+ ]
593
+ if stale_reports:
594
+ paths = ", ".join(report["path"] for report in stale_reports)
595
+ reason = (f"source/test {newest['path']} is newer than JUnit report(s) "
596
+ f"{paths}")
597
+ status = "stale"
598
+ else:
599
+ reason = "selected JUnit report(s) are current relative to source/test files"
600
+ status = "fresh"
601
+ return reports, {
602
+ "status": status,
603
+ "reason": reason,
604
+ "newest_source": newest,
605
+ "tolerance_ns": _JUNIT_FRESHNESS_TOLERANCE_NS,
606
+ }
607
+
608
+
469
609
  # tolerante a los limites de naming de cada lenguaje: test_ac1_x (python/go,
470
610
  # sin '-'), testAC01X (java camelCase), "AC-01: ..." (nombres libres).
471
611
  # OJO: \b no sirve — '_' es word character y test_ac1 quedaria invisible;
@@ -546,28 +686,38 @@ def junit_test_count(repo_path, extra_files=None):
546
686
  files.append(f)
547
687
  tests = failures = errors = skipped = 0
548
688
  for f in files:
549
- try:
550
- root = ET.parse(f).getroot()
551
- except (ET.ParseError, OSError):
552
- continue
689
+ root = _parse_junit_xml(f)
553
690
  if _local(root.tag) == "testsuite":
554
691
  suites = [root]
555
692
  else:
556
693
  suites = list(_iter_local(root, "testsuite"))
557
694
  t = fl = er = sk = 0
558
695
  for s in suites:
559
- t += int(s.get("tests", 0))
560
- fl += int(s.get("failures", 0))
561
- er += int(s.get("errors", 0))
562
- sk += int(s.get("skipped", 0))
696
+ suite_t, suite_fl, suite_er, suite_sk = _junit_counts(s, f)
697
+ t += suite_t
698
+ fl += suite_fl
699
+ er += suite_er
700
+ sk += suite_sk
563
701
  if _local(root.tag) == "testsuites":
564
702
  # gotestsum puts `errors` ONLY on the <testsuites> root (a package
565
703
  # that fails to BUILD has root errors>0 and no suite) — take the max
566
704
  # of root attrs vs child sums so a broken build never reads green.
567
- t = max(t, int(root.get("tests", 0)))
568
- fl = max(fl, int(root.get("failures", 0)))
569
- er = max(er, int(root.get("errors", 0)))
570
- sk = max(sk, int(root.get("skipped", 0)))
705
+ root_t, root_fl, root_er, root_sk = _junit_counts(root, f)
706
+ child_counts = (t, fl, er, sk)
707
+ root_counts = (root_t, root_fl, root_er, root_sk)
708
+ for name, root_count, child_count in zip(
709
+ ("tests", "failures", "errors", "skipped"),
710
+ root_counts, child_counts):
711
+ if name in root.attrib and root_count < child_count:
712
+ _invalid_junit(
713
+ f, f"root '{name}' cannot be less than child suite total")
714
+ t = max(t, root_t)
715
+ fl = max(fl, root_fl)
716
+ er = max(er, root_er)
717
+ sk = max(sk, root_sk)
718
+ if suites and fl + er > t - sk:
719
+ _invalid_junit(
720
+ f, "combined root/child outcomes exceed executed tests")
571
721
  tests += t
572
722
  failures += fl
573
723
  errors += er
@@ -580,20 +730,25 @@ def junit_test_count(repo_path, extra_files=None):
580
730
 
581
731
  def test_count(repo_path, repo_type):
582
732
  if repo_type == "maven":
583
- return maven_test_count(repo_path)
584
- if repo_type == "gradle":
585
- return gradle_test_count(repo_path)
586
- if repo_type == "swift":
733
+ result = maven_test_count(repo_path)
734
+ elif repo_type == "gradle":
735
+ result = gradle_test_count(repo_path)
736
+ elif repo_type == "swift":
587
737
  # SwiftPM writes Swift Testing results to a SEPARATE file next to the
588
738
  # XCTest one — both must count or a Swift-6 package reads tests=0.
589
- return junit_test_count(repo_path, extra_files=[
739
+ result = junit_test_count(repo_path, extra_files=[
590
740
  os.path.join(repo_path, "reports", "junit-swift-testing.xml"),
591
741
  os.path.join(repo_path, "junit-swift-testing.xml")])
592
- if repo_type in ("python", "node", "go", "rust", "dotnet", "cpp"):
742
+ elif repo_type in ("python", "node", "go", "rust", "dotnet", "cpp"):
593
743
  # go: gotestsum · rust: cargo-nextest · dotnet: JUnit logger ·
594
744
  # cpp: ctest --output-junit / gtest
595
- return junit_test_count(repo_path)
596
- return flutter_test_count(repo_path)
745
+ result = junit_test_count(repo_path)
746
+ else:
747
+ result = flutter_test_count(repo_path)
748
+ reports, freshness = _test_evidence_provenance(repo_path, repo_type)
749
+ result["reports"] = reports
750
+ result["freshness"] = freshness
751
+ return result
597
752
 
598
753
 
599
754
  # --------------------------------------------------------------------------- #
@@ -760,6 +915,23 @@ def _find_all(base, patterns, explicit):
760
915
  return sorted(set(found))
761
916
 
762
917
 
918
+ def _invalid_static_report(path, label, detail):
919
+ print(f"[qa_ledger] invalid {label} report '{path}': {detail}",
920
+ file=sys.stderr)
921
+ raise SystemExit(2)
922
+
923
+
924
+ def _parse_static_xml(path, label, root_name):
925
+ try:
926
+ root = ET.parse(path).getroot()
927
+ except (ET.ParseError, OSError) as exc:
928
+ _invalid_static_report(path, label, exc)
929
+ if _local(root.tag) != root_name:
930
+ _invalid_static_report(
931
+ path, label, f"expected <{root_name}> root, got <{_local(root.tag)}>")
932
+ return root
933
+
934
+
763
935
  def parse_checkstyle(path, granularity, tool="checkstyle", base=None):
764
936
  """Checkstyle-format XML. Also emitted by golangci-lint (v2:
765
937
  --output.checkstyle.path=...; v1: --out-format checkstyle), detekt and
@@ -770,40 +942,55 @@ def parse_checkstyle(path, granularity, tool="checkstyle", base=None):
770
942
  paths (detekt and SwiftLint print absolute BY DEFAULT) are relativized
771
943
  against the repo first, same discipline as eslint/tsc/clang-tidy."""
772
944
  out = []
773
- try:
774
- root = ET.parse(path).getroot()
775
- except (ET.ParseError, OSError):
776
- return out
777
- for f in _iter_local(root, "file"):
778
- fname = f.get("name", "?")
779
- for e in _iter_local(f, "error"):
945
+ root = _parse_static_xml(path, f"{tool} Checkstyle XML", "checkstyle")
946
+ for file_index, f in enumerate(_iter_local(root, "file")):
947
+ fname = f.get("name")
948
+ if not fname:
949
+ _invalid_static_report(
950
+ path, f"{tool} Checkstyle XML",
951
+ f"file at index {file_index} is missing required name")
952
+ for error_index, e in enumerate(_iter_local(f, "error")):
953
+ line = e.get("line", "0")
954
+ try:
955
+ int(line)
956
+ except (TypeError, ValueError):
957
+ _invalid_static_report(
958
+ path, f"{tool} Checkstyle XML",
959
+ f"error at {file_index}:{error_index} has invalid line")
780
960
  sev = CHECKSTYLE_SEVERITY.get((e.get("severity") or "warning").lower(), "MEDIUM")
781
961
  rule = (e.get("source") or "?").split(".")[-1]
782
962
  if tool != "checkstyle":
783
963
  fid = _mk_id_rel(tool, rule, _node_rel(fname, base),
784
- e.get("line", "0"), granularity)
964
+ line, granularity)
785
965
  else:
786
- fid = _mk_id(tool, rule, fname, e.get("line", "0"), granularity)
966
+ fid = _mk_id(tool, rule, fname, line, granularity)
787
967
  out.append((fid, sev, tool))
788
968
  return out
789
969
 
790
970
 
791
971
  def parse_pmd(path, granularity):
792
972
  out = []
793
- try:
794
- root = ET.parse(path).getroot()
795
- except (ET.ParseError, OSError):
796
- return out
797
- for f in _iter_local(root, "file"):
798
- fname = f.get("name", "?")
799
- for v in _iter_local(f, "violation"):
973
+ root = _parse_static_xml(path, "PMD XML", "pmd")
974
+ for file_index, f in enumerate(_iter_local(root, "file")):
975
+ fname = f.get("name")
976
+ if not fname:
977
+ _invalid_static_report(
978
+ path, "PMD XML", f"file at index {file_index} is missing required name")
979
+ for violation_index, v in enumerate(_iter_local(f, "violation")):
800
980
  try:
801
981
  pr = int(v.get("priority", "3"))
802
- except ValueError:
803
- pr = 3
804
- sev = PMD_PRIORITY.get(pr, "MEDIUM")
982
+ beginline = int(v.get("beginline", "0"))
983
+ except (TypeError, ValueError):
984
+ _invalid_static_report(
985
+ path, "PMD XML",
986
+ f"violation at {file_index}:{violation_index} has invalid numeric field")
987
+ if pr not in PMD_PRIORITY:
988
+ _invalid_static_report(
989
+ path, "PMD XML",
990
+ f"violation at {file_index}:{violation_index} has invalid priority")
991
+ sev = PMD_PRIORITY[pr]
805
992
  rule = v.get("rule", "?")
806
- out.append((_mk_id("pmd", rule, fname, v.get("beginline", "0"), granularity),
993
+ out.append((_mk_id("pmd", rule, fname, beginline, granularity),
807
994
  sev, "pmd"))
808
995
  return out
809
996
 
@@ -812,18 +999,25 @@ def parse_spotbugs(path, granularity):
812
999
  """SpotBugs report. FindSecBugs findings (category SECURITY) are split out
813
1000
  under tool 'findsecbugs' and floored to HIGH severity."""
814
1001
  out = []
815
- try:
816
- root = ET.parse(path).getroot()
817
- except (ET.ParseError, OSError):
818
- return out
819
- for b in _iter_local(root, "BugInstance"):
1002
+ root = _parse_static_xml(path, "SpotBugs XML", "BugCollection")
1003
+ for bug_index, b in enumerate(_iter_local(root, "BugInstance")):
820
1004
  try:
821
1005
  pr = int(b.get("priority", "2"))
822
- except ValueError:
823
- pr = 2
824
- sev = SPOTBUGS_PRIORITY.get(pr, "MEDIUM")
1006
+ except (TypeError, ValueError):
1007
+ _invalid_static_report(
1008
+ path, "SpotBugs XML",
1009
+ f"BugInstance at index {bug_index} has invalid priority")
1010
+ if pr not in SPOTBUGS_PRIORITY:
1011
+ _invalid_static_report(
1012
+ path, "SpotBugs XML",
1013
+ f"BugInstance at index {bug_index} has invalid priority")
1014
+ sev = SPOTBUGS_PRIORITY[pr]
825
1015
  cat = (b.get("category") or "").upper()
826
- btype = b.get("type", "?")
1016
+ btype = b.get("type")
1017
+ if not btype:
1018
+ _invalid_static_report(
1019
+ path, "SpotBugs XML",
1020
+ f"BugInstance at index {bug_index} is missing required type")
827
1021
  sl = next((c for c in b if _local(c.tag) == "SourceLine"), None)
828
1022
  if sl is not None:
829
1023
  fname = sl.get("sourcepath") or sl.get("classname") or "?"
@@ -851,22 +1045,40 @@ def _ruff_severity(code):
851
1045
  return "LOW"
852
1046
 
853
1047
 
1048
+ def _invalid_ruff(path, detail):
1049
+ print(f"[qa_ledger] invalid Ruff JSON report '{path}': {detail}",
1050
+ file=sys.stderr)
1051
+ raise SystemExit(2)
1052
+
1053
+
854
1054
  def parse_ruff(path, granularity):
855
1055
  """`ruff check --output-format=json` — a JSON array of finding objects."""
856
1056
  out = []
857
1057
  try:
858
1058
  with open(path, "r", encoding="utf-8", errors="replace") as fh:
859
1059
  data = json.load(fh)
860
- except (OSError, ValueError):
861
- return out
1060
+ except (OSError, ValueError) as exc:
1061
+ _invalid_ruff(path, exc)
862
1062
  if not isinstance(data, list):
863
- return out
864
- for item in data:
1063
+ _invalid_ruff(path, "expected a JSON array of findings")
1064
+ for index, item in enumerate(data):
865
1065
  if not isinstance(item, dict):
866
- continue
1066
+ _invalid_ruff(path, f"finding at index {index} must be an object")
867
1067
  code = item.get("code")
868
- fname = item.get("filename") or "?"
869
- line = (item.get("location") or {}).get("row", 0)
1068
+ if code is not None and not isinstance(code, str):
1069
+ _invalid_ruff(path, f"finding at index {index} has invalid code")
1070
+ fname = item.get("filename")
1071
+ if fname is not None and not isinstance(fname, str):
1072
+ _invalid_ruff(path, f"finding at index {index} has invalid filename")
1073
+ location = item.get("location")
1074
+ if location is None:
1075
+ location = {}
1076
+ if not isinstance(location, dict):
1077
+ _invalid_ruff(path, f"finding at index {index} has invalid location")
1078
+ line = location.get("row", 0)
1079
+ if isinstance(line, bool) or not isinstance(line, int):
1080
+ _invalid_ruff(path, f"finding at index {index} has invalid location row")
1081
+ fname = fname or "?"
870
1082
  # modern ruff (>=0.5) emits "code": null for SYNTAX errors — real breakage,
871
1083
  # always HIGH (the legacy E9* branch only covers older ruff versions).
872
1084
  sev = "HIGH" if code is None else _ruff_severity(code)
@@ -930,7 +1142,7 @@ def _node_rel(fname, base):
930
1142
 
931
1143
 
932
1144
  def parse_eslint(path, granularity, base=None):
933
- """`eslint --format json` — array of {filePath, messages:[{ruleId, severity,
1145
+ """ESLint JSON format — array of {filePath, messages:[{ruleId, severity,
934
1146
  line, fatal}]}. fatal:true (parse error) -> HIGH always. ruleId null WITHOUT
935
1147
  fatal (e.g. unused eslint-disable directives in ESLint 9) follows the message
936
1148
  severity — never a false blocker. severity 2 -> HIGH, 1 -> MEDIUM; rules from
@@ -939,32 +1151,64 @@ def parse_eslint(path, granularity, base=None):
939
1151
  try:
940
1152
  with open(path, "r", encoding="utf-8", errors="replace") as fh:
941
1153
  data = json.load(fh)
942
- except (OSError, ValueError):
943
- return out
1154
+ except (OSError, ValueError) as exc:
1155
+ _invalid_static_report(path, "ESLint JSON", exc)
944
1156
  if not isinstance(data, list):
945
- return out
946
- for entry in data:
1157
+ _invalid_static_report(path, "ESLint JSON",
1158
+ "expected a JSON array of file results")
1159
+ for entry_index, entry in enumerate(data):
947
1160
  if not isinstance(entry, dict):
948
- continue
949
- fname = _node_rel(entry.get("filePath"), base)
950
- for msg in entry.get("messages") or []:
1161
+ _invalid_static_report(
1162
+ path, "ESLint JSON", f"file result at index {entry_index} must be an object")
1163
+ file_path = entry.get("filePath")
1164
+ if file_path is not None and not isinstance(file_path, str):
1165
+ _invalid_static_report(
1166
+ path, "ESLint JSON", f"file result at index {entry_index} has invalid filePath")
1167
+ messages = entry.get("messages")
1168
+ if not isinstance(messages, list):
1169
+ _invalid_static_report(
1170
+ path, "ESLint JSON", f"file result at index {entry_index} has invalid messages")
1171
+ fname = _node_rel(file_path, base)
1172
+ for message_index, msg in enumerate(messages):
951
1173
  if not isinstance(msg, dict):
952
- continue
1174
+ _invalid_static_report(
1175
+ path, "ESLint JSON",
1176
+ f"message at {entry_index}:{message_index} must be an object")
953
1177
  rule = msg.get("ruleId")
954
- if msg.get("fatal"):
1178
+ if rule is not None and not isinstance(rule, str):
1179
+ _invalid_static_report(
1180
+ path, "ESLint JSON",
1181
+ f"message at {entry_index}:{message_index} has invalid ruleId")
1182
+ severity = msg.get("severity")
1183
+ if isinstance(severity, bool) or not isinstance(severity, int):
1184
+ _invalid_static_report(
1185
+ path, "ESLint JSON",
1186
+ f"message at {entry_index}:{message_index} has invalid severity")
1187
+ fatal = msg.get("fatal", False)
1188
+ if not isinstance(fatal, bool):
1189
+ _invalid_static_report(
1190
+ path, "ESLint JSON",
1191
+ f"message at {entry_index}:{message_index} has invalid fatal")
1192
+ line = msg.get("line", 0)
1193
+ if line is None:
1194
+ line = 0
1195
+ if isinstance(line, bool) or not isinstance(line, int):
1196
+ _invalid_static_report(
1197
+ path, "ESLint JSON",
1198
+ f"message at {entry_index}:{message_index} has invalid line")
1199
+ if fatal:
955
1200
  sev, rule = "HIGH", "syntax-error"
956
1201
  elif rule is None:
957
- sev = "HIGH" if msg.get("severity") == 2 else "MEDIUM"
1202
+ sev = "HIGH" if severity == 2 else "MEDIUM"
958
1203
  rule = "unused-directive"
959
1204
  else:
960
- sev = "HIGH" if msg.get("severity") == 2 else "MEDIUM"
1205
+ sev = "HIGH" if severity == 2 else "MEDIUM"
961
1206
  if rule.startswith("security/"):
962
1207
  sev = _bump(sev, "HIGH")
963
- out.append((_mk_id_rel("eslint", rule, fname, msg.get("line", 0),
964
- granularity), sev, "eslint"))
1208
+ out.append((_mk_id_rel("eslint", rule, fname, line, granularity),
1209
+ sev, "eslint"))
965
1210
  return out
966
1211
 
967
-
968
1212
  _TSC_LINE = re.compile(
969
1213
  r"^(?P<file>(?:[A-Za-z]:)?[^(\n]+\.(?:ts|tsx|js|jsx|mts|cts))"
970
1214
  r"\((?P<line>\d+),\d+\):\s*(?P<kind>error|warning)\s+(?P<code>TS\d+):")
@@ -1002,49 +1246,85 @@ def parse_tsc(path, granularity, base=None):
1002
1246
 
1003
1247
 
1004
1248
  def parse_clippy(path, granularity):
1005
- """`cargo clippy --message-format=json` JSON Lines; each compiler-message
1006
- carries message.level (error/warning), message.code.code (e.g. 'clippy::x';
1007
- null for plain rustc compile errors real breakage, HIGH always) and spans.
1008
- error -> HIGH, warning -> MEDIUM; note/help lines are skipped.
1009
- Diagnostics WITHOUT a primary span are rustc's end-of-run summaries
1010
- ('N warnings emitted', 'aborting due to ...') real compile errors always
1011
- carry a span, so span-less lines are skipped BEFORE the code-null check
1012
- (else every warning run grows a phantom HIGH 'compile-error' and the gate
1013
- can never converge). Repeats across compilation targets (lib/bin/test)
1249
+ """`cargo clippy --message-format=json` ? Cargo JSON Lines.
1250
+
1251
+ Each nonblank line must be a UTF-8 JSON object with a string ``reason``.
1252
+ Cargo permits an empty output and non-diagnostic records (including
1253
+ ``build-finished``); compiler-message diagnostics may legitimately have no
1254
+ primary span for end-of-run summaries, so those remain clean noise. Unlike
1255
+ permissive text formats, malformed JSON or an invalid diagnostic shape is
1256
+ unambiguously bad structured evidence and must reject ingest before the
1257
+ ledger is mutated. Error -> HIGH, warning -> MEDIUM; code:null with a
1258
+ primary span is a real rustc compile error -> HIGH. Repeats across targets
1014
1259
  are deduped by finding ID."""
1015
1260
  out = []
1016
1261
  seen = set()
1017
1262
  try:
1018
- fh = open(path, "r", encoding="utf-8", errors="replace")
1019
- except OSError:
1020
- return out
1263
+ fh = open(path, "r", encoding="utf-8")
1264
+ except (OSError, UnicodeError) as exc:
1265
+ _invalid_static_report(path, "Clippy JSONL", exc)
1021
1266
  with fh:
1022
- for raw in fh:
1267
+ for line_no, raw in enumerate(fh, 1):
1023
1268
  raw = raw.strip()
1024
1269
  if not raw:
1025
1270
  continue
1026
1271
  try:
1027
1272
  obj = json.loads(raw)
1028
- except ValueError:
1029
- continue
1030
- if not isinstance(obj, dict) or obj.get("reason") != "compiler-message":
1273
+ except ValueError as exc:
1274
+ _invalid_static_report(path, "Clippy JSONL",
1275
+ f"line {line_no} is not valid JSON: {exc}")
1276
+ if not isinstance(obj, dict):
1277
+ _invalid_static_report(path, "Clippy JSONL",
1278
+ f"line {line_no} must be a JSON object")
1279
+ reason = obj.get("reason")
1280
+ if not isinstance(reason, str):
1281
+ _invalid_static_report(path, "Clippy JSONL",
1282
+ f"line {line_no} has no string reason")
1283
+ if reason != "compiler-message":
1031
1284
  continue
1032
- msg = obj.get("message") or {}
1285
+
1286
+ msg = obj.get("message")
1287
+ where = f"compiler message at line {line_no}"
1288
+ if not isinstance(msg, dict):
1289
+ _invalid_static_report(path, "Clippy JSONL", f"{where} must be an object")
1290
+ if not isinstance(msg.get("message"), str):
1291
+ _invalid_static_report(path, "Clippy JSONL", f"{where} has invalid message")
1033
1292
  level = msg.get("level")
1293
+ if not isinstance(level, str):
1294
+ _invalid_static_report(path, "Clippy JSONL", f"{where} has invalid level")
1295
+ if "code" not in msg:
1296
+ _invalid_static_report(path, "Clippy JSONL", f"{where} is missing code")
1297
+ code_data = msg["code"]
1298
+ if code_data is not None:
1299
+ if not isinstance(code_data, dict) or not isinstance(code_data.get("code"), str):
1300
+ _invalid_static_report(path, "Clippy JSONL", f"{where} has invalid code")
1301
+ spans = msg.get("spans")
1302
+ if not isinstance(spans, list):
1303
+ _invalid_static_report(path, "Clippy JSONL", f"{where} has invalid spans")
1304
+ for span_index, candidate in enumerate(spans):
1305
+ if not isinstance(candidate, dict):
1306
+ _invalid_static_report(
1307
+ path, "Clippy JSONL", f"{where} has non-object span {span_index}")
1308
+ if not isinstance(candidate.get("is_primary"), bool):
1309
+ _invalid_static_report(
1310
+ path, "Clippy JSONL", f"{where} has invalid is_primary in span {span_index}")
1311
+
1034
1312
  if level not in ("error", "warning"):
1035
1313
  continue
1036
- span = next((s for s in (msg.get("spans") or [])
1037
- if isinstance(s, dict) and s.get("is_primary")), None)
1314
+ span = next((candidate for candidate in spans if candidate["is_primary"]), None)
1038
1315
  if span is None:
1039
1316
  continue # span-less = summary diagnostic, not a finding
1040
- code = (msg.get("code") or {}).get("code") if msg.get("code") else None
1041
- if code is None:
1317
+ fname = span.get("file_name")
1318
+ line = span.get("line_start")
1319
+ if not isinstance(fname, str) or not fname:
1320
+ _invalid_static_report(path, "Clippy JSONL", f"{where} has invalid primary file_name")
1321
+ if isinstance(line, bool) or not isinstance(line, int) or line < 1:
1322
+ _invalid_static_report(path, "Clippy JSONL", f"{where} has invalid primary line_start")
1323
+ if code_data is None:
1042
1324
  sev, rule = "HIGH", "compile-error"
1043
1325
  else:
1044
1326
  sev = "HIGH" if level == "error" else "MEDIUM"
1045
- rule = code
1046
- fname = span.get("file_name") or "?"
1047
- line = span.get("line_start", 0)
1327
+ rule = code_data["code"]
1048
1328
  fid = _mk_id_rel("clippy", rule, fname, line, granularity)
1049
1329
  if fid in seen:
1050
1330
  continue
@@ -1052,7 +1332,6 @@ def parse_clippy(path, granularity):
1052
1332
  out.append((fid, sev, "clippy"))
1053
1333
  return out
1054
1334
 
1055
-
1056
1335
  def _sarif_rel(uri, base):
1057
1336
  """SARIF uris arrive as file:///C:/repo/src/A.cs (Roslyn emits absolute,
1058
1337
  percent-encoded) — strip scheme, unquote, and relativize against the repo
@@ -1083,39 +1362,81 @@ def parse_sarif(path, granularity, tool="sarif", base=None):
1083
1362
  try:
1084
1363
  with open(path, "r", encoding="utf-8", errors="replace") as fh:
1085
1364
  data = json.load(fh)
1086
- except (OSError, ValueError):
1087
- return out
1365
+ except (OSError, ValueError) as exc:
1366
+ _invalid_static_report(path, "SARIF JSON", exc)
1088
1367
  if not isinstance(data, dict):
1089
- return out
1090
- for run in data.get("runs") or []:
1368
+ _invalid_static_report(path, "SARIF JSON", "expected a JSON object")
1369
+ runs = data.get("runs")
1370
+ if not isinstance(runs, list):
1371
+ _invalid_static_report(path, "SARIF JSON", "expected runs to be an array")
1372
+ for run_index, run in enumerate(runs):
1091
1373
  if not isinstance(run, dict):
1092
- continue
1093
- for res in run.get("results") or []:
1374
+ _invalid_static_report(
1375
+ path, "SARIF JSON", f"run at index {run_index} must be an object")
1376
+ results = run.get("results", [])
1377
+ if not isinstance(results, list):
1378
+ _invalid_static_report(
1379
+ path, "SARIF JSON", f"run at index {run_index} has invalid results")
1380
+ for result_index, res in enumerate(results):
1381
+ where = f"result at {run_index}:{result_index}"
1094
1382
  if not isinstance(res, dict):
1095
- continue
1383
+ _invalid_static_report(path, "SARIF JSON", f"{where} must be an object")
1384
+ for suppression_key in ("suppressions", "suppressionStates"):
1385
+ suppressions = res.get(suppression_key)
1386
+ if suppressions is not None and not isinstance(suppressions, list):
1387
+ _invalid_static_report(
1388
+ path, "SARIF JSON", f"{where} has invalid {suppression_key}")
1096
1389
  if res.get("suppressions") or res.get("suppressionStates"):
1097
1390
  continue
1098
- sev = sev_map.get((res.get("level") or "warning").lower(), "MEDIUM")
1099
- rule = res.get("ruleId") or "?"
1391
+ level = res.get("level", "warning")
1392
+ if not isinstance(level, str):
1393
+ _invalid_static_report(path, "SARIF JSON", f"{where} has invalid level")
1394
+ rule = res.get("ruleId", "?")
1395
+ if rule is None:
1396
+ rule = "?"
1397
+ if not isinstance(rule, str):
1398
+ _invalid_static_report(path, "SARIF JSON", f"{where} has invalid ruleId")
1399
+ sev = sev_map.get(level.lower(), "MEDIUM")
1100
1400
  fname, line = "?", 0
1101
- locs = res.get("locations") or []
1102
- if locs and isinstance(locs[0], dict):
1401
+ locs = res.get("locations", [])
1402
+ if not isinstance(locs, list):
1403
+ _invalid_static_report(path, "SARIF JSON", f"{where} has invalid locations")
1404
+ if locs:
1103
1405
  loc = locs[0]
1104
- phys = loc.get("physicalLocation") or {}
1105
- art = phys.get("artifactLocation") or {}
1406
+ if not isinstance(loc, dict):
1407
+ _invalid_static_report(
1408
+ path, "SARIF JSON", f"{where} has a non-object location")
1409
+ phys = loc.get("physicalLocation", {})
1410
+ if not isinstance(phys, dict):
1411
+ _invalid_static_report(
1412
+ path, "SARIF JSON", f"{where} has invalid physicalLocation")
1413
+ art = phys.get("artifactLocation", {})
1414
+ region = phys.get("region", {})
1415
+ if not isinstance(art, dict) or not isinstance(region, dict):
1416
+ _invalid_static_report(
1417
+ path, "SARIF JSON", f"{where} has invalid physical location fields")
1106
1418
  uri = art.get("uri")
1107
- region = phys.get("region") or {}
1108
- if not uri: # SARIF v1 fallback
1419
+ if not uri:
1109
1420
  v1 = loc.get("resultFile") or loc.get("analysisTarget") or {}
1421
+ if not isinstance(v1, dict):
1422
+ _invalid_static_report(
1423
+ path, "SARIF JSON", f"{where} has invalid SARIF v1 location")
1110
1424
  uri = v1.get("uri")
1111
1425
  region = v1.get("region") or {}
1426
+ if not isinstance(region, dict):
1427
+ _invalid_static_report(
1428
+ path, "SARIF JSON", f"{where} has invalid SARIF v1 region")
1429
+ if uri is not None and not isinstance(uri, str):
1430
+ _invalid_static_report(path, "SARIF JSON", f"{where} has invalid uri")
1112
1431
  if uri:
1113
1432
  fname = _sarif_rel(uri, base)
1114
1433
  line = region.get("startLine", 0)
1434
+ if isinstance(line, bool) or not isinstance(line, int):
1435
+ _invalid_static_report(
1436
+ path, "SARIF JSON", f"{where} has invalid startLine")
1115
1437
  out.append((_mk_id_rel(tool, rule, fname, line, granularity), sev, tool))
1116
1438
  return out
1117
1439
 
1118
-
1119
1440
  _CLANG_TIDY_LINE = re.compile(
1120
1441
  r"^(?P<file>(?:[A-Za-z]:)?[^:\n]+\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx|tpp|ipp|inl|mm|cu)):"
1121
1442
  r"(?P<line>\d+):\d+:\s*(?P<kind>error|warning):\s*.*?"
@@ -1162,8 +1483,159 @@ def _prev_finding_ids(node, tool):
1162
1483
  # --------------------------------------------------------------------------- #
1163
1484
  # subcommands
1164
1485
  # --------------------------------------------------------------------------- #
1486
+ SUPPORTED_REPO_TYPES = {
1487
+ "maven", "flutter", "python", "node", "go", "rust", "dotnet", "cpp",
1488
+ "gradle", "swift",
1489
+ }
1490
+
1491
+
1492
+ def _has_text(value):
1493
+ return isinstance(value, str) and bool(value.strip())
1494
+
1495
+
1496
+ def _validate_init_config(cfg):
1497
+ """Validate only the engine's core init contract before creating a ledger."""
1498
+ if not isinstance(cfg, dict):
1499
+ raise SystemExit("[qa_ledger] invalid config: root must be an object")
1500
+ defaults = cfg.get("defaults", {})
1501
+ if not isinstance(defaults, dict):
1502
+ raise SystemExit("[qa_ledger] invalid config: defaults must be an object")
1503
+
1504
+ repos = cfg.get("repos", [])
1505
+ if not isinstance(repos, list):
1506
+ raise SystemExit("[qa_ledger] invalid config: repos must be a list")
1507
+ names = set()
1508
+ for index, repo in enumerate(repos):
1509
+ if not isinstance(repo, dict):
1510
+ raise SystemExit(
1511
+ f"[qa_ledger] invalid config: repos[{index}] must be an object")
1512
+ name = repo.get("name")
1513
+ path = repo.get("path")
1514
+ repo_type = repo.get("type")
1515
+ if not _has_text(name):
1516
+ raise SystemExit(
1517
+ f"[qa_ledger] invalid config: repos[{index}].name must be nonempty")
1518
+ if name == "integration":
1519
+ raise SystemExit(
1520
+ "[qa_ledger] invalid config: repo name 'integration' is reserved")
1521
+ if name in names:
1522
+ raise SystemExit(
1523
+ f"[qa_ledger] invalid config: duplicate repo name '{name}'")
1524
+ names.add(name)
1525
+ if not _has_text(path):
1526
+ raise SystemExit(
1527
+ f"[qa_ledger] invalid config: repo '{name}' path must be nonempty")
1528
+ if repo_type not in SUPPORTED_REPO_TYPES:
1529
+ supported = ", ".join(sorted(SUPPORTED_REPO_TYPES))
1530
+ raise SystemExit(
1531
+ f"[qa_ledger] invalid config: repo '{name}' type must be one of {supported}")
1532
+
1533
+ if "qa_tools_order" in defaults:
1534
+ order = defaults["qa_tools_order"]
1535
+ if (not isinstance(order, list) or not order
1536
+ or any(not _has_text(tool) for tool in order)
1537
+ or len(set(order)) != len(order)):
1538
+ raise SystemExit(
1539
+ "[qa_ledger] invalid config: qa_tools_order must contain unique nonempty strings")
1540
+
1541
+ if "coverage_threshold" in defaults:
1542
+ value = defaults["coverage_threshold"]
1543
+ if (isinstance(value, bool) or not isinstance(value, (int, float))
1544
+ or not math.isfinite(value) or value < 0 or value > 100):
1545
+ raise SystemExit(
1546
+ "[qa_ledger] invalid config: coverage_threshold must be between 0 and 100")
1547
+ for key in ("max_iterations", "tools_per_cycle"):
1548
+ if key in defaults:
1549
+ value = defaults[key]
1550
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
1551
+ raise SystemExit(
1552
+ f"[qa_ledger] invalid config: {key} must be a positive integer")
1553
+
1554
+ integration = cfg.get("integration", {})
1555
+ if not isinstance(integration, dict):
1556
+ raise SystemExit("[qa_ledger] invalid config: integration must be an object")
1557
+ if "enabled" in integration and not isinstance(integration["enabled"], bool):
1558
+ raise SystemExit("[qa_ledger] invalid config: integration.enabled must be boolean")
1559
+
1560
+ if "severity_gate" in defaults:
1561
+ gate = defaults["severity_gate"]
1562
+ if (not isinstance(gate, list)
1563
+ or any(not isinstance(level, str) or level not in SEVERITY_ORDER
1564
+ for level in gate)):
1565
+ raise SystemExit("[qa_ledger] invalid config: severity_gate must be a list of known severities")
1566
+
1567
+ def finite_number(value):
1568
+ return (not isinstance(value, bool) and isinstance(value, (int, float))
1569
+ and math.isfinite(value))
1570
+
1571
+ readiness_weights = defaults.get("readiness_weights", {})
1572
+ if not isinstance(readiness_weights, dict):
1573
+ raise SystemExit("[qa_ledger] invalid config: readiness_weights must be an object")
1574
+ unknown_weights = sorted(set(readiness_weights) - set(DEFAULT_WEIGHTS))
1575
+ if unknown_weights:
1576
+ raise SystemExit("[qa_ledger] invalid config: readiness_weights has unknown dimension(s): " + ", ".join(unknown_weights))
1577
+ for key, value in readiness_weights.items():
1578
+ if not finite_number(value) or value < 0:
1579
+ raise SystemExit(f"[qa_ledger] invalid config: readiness_weights.{key} must be a finite nonnegative number")
1580
+
1581
+ readiness_caps = defaults.get("readiness_caps", {})
1582
+ if not isinstance(readiness_caps, dict):
1583
+ raise SystemExit("[qa_ledger] invalid config: readiness_caps must be an object")
1584
+ unknown_caps = sorted(set(readiness_caps) - set(DEFAULT_CAPS))
1585
+ if unknown_caps:
1586
+ raise SystemExit("[qa_ledger] invalid config: readiness_caps has unknown cap(s): " + ", ".join(unknown_caps))
1587
+ for key, value in readiness_caps.items():
1588
+ if not finite_number(value) or value < 0 or value > 100:
1589
+ raise SystemExit(f"[qa_ledger] invalid config: readiness_caps.{key} must be a finite percentage between 0 and 100")
1590
+
1591
+ if "static_gate_zero_at" in defaults:
1592
+ value = defaults["static_gate_zero_at"]
1593
+ if not finite_number(value) or value <= 0:
1594
+ raise SystemExit("[qa_ledger] invalid config: static_gate_zero_at must be a positive finite number")
1595
+
1596
+ effective_weights = {**DEFAULT_WEIGHTS, **readiness_weights}
1597
+ local_dimensions = ("coverage", "static_gate", "convergence")
1598
+ if repos and sum(effective_weights[key] for key in local_dimensions) <= 0:
1599
+ raise SystemExit("[qa_ledger] invalid config: effective readiness_weights must leave positive weight for per-repo readiness")
1600
+
1601
+ global_dimensions = ["acceptance", "adr", "coverage", "static_gate", "convergence"]
1602
+ if integration.get("enabled", False):
1603
+ global_dimensions.append("integration")
1604
+ dimension_sets = [global_dimensions]
1605
+ if "readiness_weights" in defaults and "acceptance" not in readiness_weights:
1606
+ dimension_sets.append([key for key in global_dimensions if key != "acceptance"])
1607
+ if any(sum(effective_weights[key] for key in dimensions) <= 0 for dimensions in dimension_sets):
1608
+ raise SystemExit("[qa_ledger] invalid config: effective readiness_weights must sum to a positive value for every possible readiness dimension set")
1609
+
1610
+
1611
+ def _validate_iteration(node, tool, iteration):
1612
+ if isinstance(iteration, bool) or not isinstance(iteration, int) or iteration < 1:
1613
+ raise SystemExit("[qa_ledger] iteration must be >= 1")
1614
+ latest = max(
1615
+ (record.get("iteration", 0) for record in node.get("iterations", [])
1616
+ if record.get("tool") == tool),
1617
+ default=0,
1618
+ )
1619
+ if iteration < latest:
1620
+ raise SystemExit(
1621
+ f"[qa_ledger] iteration {iteration} is older than latest iteration "
1622
+ f"{latest} for {tool}")
1623
+
1624
+
1625
+ def _validate_log_step_counts(args):
1626
+ names = ("reported", "gated_reported", "fixed", "deferred",
1627
+ "suppressed", "files_changed")
1628
+ for name in names:
1629
+ value = getattr(args, name)
1630
+ if value < 0:
1631
+ raise SystemExit(f"[qa_ledger] {name.replace('_', '-')} must be nonnegative")
1632
+ if args.gated_reported > args.reported:
1633
+ raise SystemExit("[qa_ledger] gated-reported cannot exceed reported")
1634
+
1635
+
1165
1636
  def cmd_init(args):
1166
1637
  cfg = _load(args.config)
1638
+ _validate_init_config(cfg)
1167
1639
  defaults = cfg.get("defaults", {})
1168
1640
  ledger = {
1169
1641
  "schema": "dev-loop/qa-ledger@1",
@@ -1214,10 +1686,14 @@ def cmd_snapshot(args):
1214
1686
  "phase": args.phase})
1215
1687
  _save(args.ledger, ledger)
1216
1688
  cov, tests, loc = snap["coverage"], snap["tests"], snap["loc"]
1689
+ freshness = tests.get("freshness", {})
1217
1690
  print(f"[qa_ledger] snapshot {args.repo} ({args.phase}): "
1218
1691
  f"coverage={cov['pct']}% (found={cov['report_found']}), "
1219
1692
  f"tests={tests['total']} (found={tests['report_found']}), "
1693
+ f"freshness={freshness.get('status', 'unknown')}, "
1220
1694
  f"prod_loc={loc['prod_loc']}, test_loc={loc['test_loc']}")
1695
+ if freshness.get("status") == "stale":
1696
+ print(f" test evidence stale: {freshness.get('reason')}")
1221
1697
 
1222
1698
 
1223
1699
  def cmd_check_coverage(args):
@@ -1248,6 +1724,8 @@ def _to_bool(s):
1248
1724
  def cmd_log_step(args):
1249
1725
  ledger = _load(args.ledger)
1250
1726
  node = _repo_node(ledger, args.repo)
1727
+ _validate_iteration(node, args.tool, args.iteration)
1728
+ _validate_log_step_counts(args)
1251
1729
  ledger["step_counter"] += 1
1252
1730
  fp = None
1253
1731
  ids = None
@@ -1288,6 +1766,8 @@ def cmd_ingest_gate(args):
1288
1766
  recent prior run of the same linter, so it is real, not estimated."""
1289
1767
  ledger = _load(args.ledger)
1290
1768
  cfg = _repo_cfg(ledger, args.repo)
1769
+ if args.iteration < 1:
1770
+ raise SystemExit("[qa_ledger] iteration must be >= 1")
1291
1771
  base = cfg["path"]
1292
1772
  defaults = ledger["config"].get("defaults", {})
1293
1773
  gate = defaults.get("severity_gate", ["BLOCKER", "CRITICAL", "HIGH"])
@@ -1410,6 +1890,8 @@ def cmd_ingest_gate(args):
1410
1890
  by_tool.setdefault(tool, [])
1411
1891
 
1412
1892
  results = []
1893
+ for tool in by_tool:
1894
+ _validate_iteration(node, tool, args.iteration)
1413
1895
  for tool, items in sorted(by_tool.items()):
1414
1896
  ids = sorted(set(fid for fid, _ in items))
1415
1897
  gated = sum(1 for _, sev in items if _at_or_above(sev, gate))
@@ -1503,6 +1985,14 @@ def _find_by_id(rows, rid, noun):
1503
1985
  raise SystemExit("[qa_ledger] unknown %s id '%s'" % (noun, rid))
1504
1986
 
1505
1987
 
1988
+ def _require_open_resolution(row, noun):
1989
+ status = row.get("status")
1990
+ if status is None:
1991
+ raise SystemExit(f"[qa_ledger] {noun} '{row.get('id')}' has no status; legacy rows are fail-closed and cannot be resolved")
1992
+ if status != "open":
1993
+ raise SystemExit(f"[qa_ledger] {noun} '{row.get('id')}' is not open (status '{status}'); only open records can be resolved")
1994
+
1995
+
1506
1996
  def _touch_event(ledger, kind, rid, repo=None):
1507
1997
  ledger["step_counter"] = ledger.get("step_counter", 0) + 1
1508
1998
  ledger.setdefault("steps", []).append({"n": ledger["step_counter"],
@@ -1560,7 +2050,11 @@ def cmd_production_finding(args):
1560
2050
  if args.resolve:
1561
2051
  if not args.id:
1562
2052
  raise SystemExit("[qa_ledger] --resolve requires --id PF-nnn")
2053
+ if not _has_text(args.note):
2054
+ raise SystemExit(
2055
+ "[qa_ledger] production-finding --resolve requires a nonempty --note")
1563
2056
  row = _find_by_id(rows, args.id, "production finding")
2057
+ _require_open_resolution(row, "production finding")
1564
2058
  row["status"] = "resolved"
1565
2059
  row["resolved_at"] = _now()
1566
2060
  row["resolution_note"] = args.note
@@ -1593,7 +2087,11 @@ def cmd_spec_doubt(args):
1593
2087
  if args.resolve:
1594
2088
  if not args.id:
1595
2089
  raise SystemExit("[qa_ledger] --resolve requires --id SD-nnn")
2090
+ if not _has_text(args.decision):
2091
+ raise SystemExit(
2092
+ "[qa_ledger] spec-doubt --resolve requires a nonempty --decision")
1596
2093
  row = _find_by_id(rows, args.id, "spec-doubt")
2094
+ _require_open_resolution(row, "spec-doubt")
1597
2095
  row["status"] = "resolved"
1598
2096
  row["resolved_at"] = _now()
1599
2097
  row["decision"] = args.decision
@@ -1627,7 +2125,23 @@ def cmd_spec_change_request(args):
1627
2125
  if args.resolve:
1628
2126
  if not args.id:
1629
2127
  raise SystemExit("[qa_ledger] --resolve requires --id SCR-nnn")
2128
+ if args.decision not in {"accepted", "rejected", "superseded"}:
2129
+ raise SystemExit(
2130
+ "[qa_ledger] spec-change-request --resolve requires "
2131
+ "--decision accepted|rejected|superseded")
2132
+ if args.decision == "accepted" and not _has_text(args.amended):
2133
+ raise SystemExit(
2134
+ "[qa_ledger] accepted spec-change-request requires nonempty --amended")
2135
+ if (args.decision in {"rejected", "superseded"}
2136
+ and not (_has_text(args.note) or
2137
+ (args.decision == "superseded" and _has_text(args.amended)))):
2138
+ raise SystemExit(
2139
+ f"[qa_ledger] {args.decision} spec-change-request requires "
2140
+ "a nonempty --note"
2141
+ + (" or --amended replacement reference"
2142
+ if args.decision == "superseded" else ""))
1630
2143
  row = _find_by_id(rows, args.id, "spec-change-request")
2144
+ _require_open_resolution(row, "spec-change-request")
1631
2145
  row["status"] = "resolved"
1632
2146
  row["resolved_at"] = _now()
1633
2147
  row["decision"] = args.decision
@@ -1641,6 +2155,22 @@ def cmd_spec_change_request(args):
1641
2155
  if not args.repo or not args.source or not args.requested_change or not args.evidence:
1642
2156
  raise SystemExit("[qa_ledger] spec-change-request requires --repo, --source, --requested-change and --evidence")
1643
2157
  _repo_node(ledger, args.repo) # validate repo
2158
+ source_match = re.fullmatch(r"(PF|SD)-(\d+)", args.source or "")
2159
+ if not source_match:
2160
+ raise SystemExit(
2161
+ "[qa_ledger] spec-change-request --source must be an existing PF-n or SD-n")
2162
+ source_rows = (ledger.get("production_findings", [])
2163
+ if source_match.group(1) == "PF"
2164
+ else ledger.get("spec_doubts", []))
2165
+ source_row = next((item for item in source_rows
2166
+ if item.get("id") == args.source), None)
2167
+ if source_row is None:
2168
+ raise SystemExit(
2169
+ f"[qa_ledger] spec-change-request source '{args.source}' does not exist")
2170
+ if source_row.get("repo") != args.repo:
2171
+ raise SystemExit(
2172
+ f"[qa_ledger] spec-change-request source '{args.source}' belongs to "
2173
+ f"repo '{source_row.get('repo')}', not '{args.repo}'")
1644
2174
  row = {"id": _next_prefixed_id(rows, "SCR"), "at": _now(),
1645
2175
  "status": "open", "repo": args.repo, "source": args.source,
1646
2176
  "requested_change": args.requested_change, "evidence": args.evidence,
@@ -1686,6 +2216,9 @@ def cmd_log_gate(args):
1686
2216
  ledger = _load(args.ledger)
1687
2217
  node = _repo_node(ledger, args.repo)
1688
2218
  tool = f"gate:{args.kind}"
2219
+ _validate_iteration(node, tool, args.iteration)
2220
+ if args.count < 0:
2221
+ raise SystemExit("[qa_ledger] count must be nonnegative")
1689
2222
  if args.verdict == "not-run":
1690
2223
  ledger["step_counter"] += 1
1691
2224
  ledger["steps"].append({"n": ledger["step_counter"], "at": _now(),
@@ -1716,6 +2249,7 @@ def cmd_flag_blocker(args):
1716
2249
  ledger = _load(args.ledger)
1717
2250
  node = _repo_node(ledger, args.repo)
1718
2251
  tool = f"blocker:{args.kind}"
2252
+ _validate_iteration(node, tool, args.iteration)
1719
2253
  failing = not args.resolve
1720
2254
  if failing and not args.note:
1721
2255
  raise SystemExit("[qa_ledger] flag-blocker requires --note describing the breach.")
@@ -1771,6 +2305,11 @@ def _converged(node, k, qa_order=None):
1771
2305
  if (t.get("failures", 0) or 0) + (t.get("errors", 0) or 0) > 0:
1772
2306
  tests_ok = False
1773
2307
  reasons.append("snapshot shows failing tests (measured, overrides agent report)")
2308
+ freshness = t.get("freshness", {})
2309
+ if freshness.get("status") == "stale":
2310
+ tests_ok = False
2311
+ reasons.append(f"snapshot test evidence is stale: "
2312
+ f"{freshness.get('reason', 'source/test changed after report')}")
1774
2313
  if gated:
1775
2314
  reasons.append(f"agent gated={gated}")
1776
2315
  if changed:
@@ -1825,11 +2364,27 @@ def _derive_phase(ledger, name, node, k, qa_order):
1825
2364
  f"debe entrar al proximo discovery"]
1826
2365
  conv, reasons = _converged(node, k, qa_order)
1827
2366
  tests_red = _tests_red(node)
2367
+ last_tests = (node["snapshots"][-1].get("tests", {})
2368
+ if node.get("snapshots") else {})
2369
+ tests_measured_green = (
2370
+ bool(last_tests.get("report_found"))
2371
+ and last_tests.get("freshness", {}).get("status") != "stale"
2372
+ and (last_tests.get("executed", 0) or 0) > 0
2373
+ and (last_tests.get("failures", 0) or 0) == 0
2374
+ and (last_tests.get("errors", 0) or 0) == 0
2375
+ )
2376
+ if not last_tests.get("report_found"):
2377
+ reasons.append("falta evidencia medida de tests")
2378
+ elif (last_tests.get("executed", 0) or 0) <= 0 and not tests_red:
2379
+ reasons.append("la evidencia medida no contiene tests ejecutados")
1828
2380
  _go, sev = _gate_open_and_sev(node)
1829
2381
  blk = sev.get("BLOCKER", 0) + sev.get("CRITICAL", 0)
1830
- if conv and not tests_red and blk == 0:
1831
- return "pr-ready", ["ciclo de agente limpio + static gates limpios",
1832
- "tests verdes (medidos)", "0 BLOCKER/CRITICAL abiertos"]
2382
+ if conv and tests_measured_green and not tests_red and blk == 0:
2383
+ evidence = ["ciclo de agente limpio", "tests verdes (medidos)",
2384
+ "0 BLOCKER/CRITICAL abiertos"]
2385
+ if _latest_static_by_tool(node):
2386
+ evidence.insert(1, "static gates registrados sin findings gateados")
2387
+ return "pr-ready", evidence
1833
2388
  if node["iterations"]:
1834
2389
  return "qa", reasons or ["pasos de QA registrados, aun sin converger"]
1835
2390
  if node.get("snapshots"):
@@ -1952,6 +2507,7 @@ def cmd_oscillation(args):
1952
2507
 
1953
2508
  def cmd_escalate(args):
1954
2509
  ledger = _load(args.ledger)
2510
+ _repo_node(ledger, args.repo)
1955
2511
  ledger["step_counter"] += 1
1956
2512
  ledger["escalations"].append({"n": ledger["step_counter"], "at": _now(),
1957
2513
  "repo": args.repo, "reason": args.reason})
@@ -2330,7 +2886,7 @@ _MIRADOR_TITLE = {
2330
2886
  "READY": "Listo para release",
2331
2887
  "RELEASE CANDIDATE": "Casi listo para release",
2332
2888
  "IN PROGRESS": "En construccion",
2333
- "NOT READY": "Todavia no arranca",
2889
+ # NOT READY has no fixed title: it is score-aware (see cmd_dashboard, kit 1.41.2).
2334
2890
  }
2335
2891
  # los 7 invariantes de la CONSTITUTION (lista fija: el engine NO lee CONSTITUTION.md
2336
2892
  # por diseno). El status se infiere del gate persistido donde hay mapeo; los que no
@@ -2492,11 +3048,8 @@ _ADR_EXPERIMENT_REQUIRED = {
2492
3048
 
2493
3049
 
2494
3050
  def _ascii_fold(s):
2495
- # stdlib-free accent folding for the few Spanish labels the kit documents.
2496
- return (s or "").lower().replace("?", "a").replace("?", "e") \
2497
- .replace("?", "i").replace("?", "o").replace("?", "u") \
2498
- .replace("?", "n")
2499
-
3051
+ normalized = unicodedata.normalize("NFKD", (s or "").lower())
3052
+ return "".join(c for c in normalized if not unicodedata.combining(c))
2500
3053
 
2501
3054
  def _adr_key(s):
2502
3055
  s = _ascii_fold(s)
@@ -2630,8 +3183,15 @@ def cmd_dashboard(args):
2630
3183
  score = rd.get("score")
2631
3184
  band = rd.get("status")
2632
3185
  cap = rd.get("cap_reason")
3186
+ # kit 1.41.2: NOT READY (band 0-49) is NOT "hasn't started". Above 0 the project HAS
3187
+ # started but lacks enough MEASURED evidence -- do not say "no arranca" at e.g. 22.
3188
+ if band == "NOT READY":
3189
+ _title = ("Todavia sin evidencia medida" if (score or 0) < 1
3190
+ else "En construccion -- evidencia insuficiente")
3191
+ else:
3192
+ _title = _MIRADOR_TITLE.get(band)
2633
3193
  readiness = {"score": score, "band": band,
2634
- "title": _MIRADOR_TITLE.get(band),
3194
+ "title": _title,
2635
3195
  "sub": (f"cap: {cap}" if cap else None)}
2636
3196
 
2637
3197
  # subscores: coverage es numerico real; los gates persistidos (simplicity/waste/
@@ -2833,12 +3393,25 @@ def cmd_readiness(args):
2833
3393
 
2834
3394
  # integration dimension (feature-level)
2835
3395
  integ_enabled = ledger["config"].get("integration", {}).get("enabled", False)
2836
- integ_steps = [s for s in ledger["integration"]["iterations"]]
3396
+ integ_steps = [s for s in ledger["integration"]["iterations"]
3397
+ if (s.get("tests_passed") is not None
3398
+ or s.get("gated_reported", 0) > 0)]
2837
3399
  if integ_enabled:
2838
3400
  if integ_steps:
2839
- last = integ_steps[-1]
2840
- integ_dim = 1.0 if (last.get("gated_reported", 0) == 0
2841
- and last.get("tests_passed") is not False) else 0.0
3401
+ # kit 1.41.1 (adversarial-review fix): do NOT trust the single last event.
3402
+ # A trailing green test-only step must not mask an earlier FAILING integration
3403
+ # gate. Green requires (a) 0 open gated findings across the LATEST record per
3404
+ # integration tool (so a re-run that clears a gate still counts), AND (b) the
3405
+ # latest test-carrying event passed.
3406
+ integ_all = ledger["integration"]["iterations"]
3407
+ integ_latest_by_tool = {}
3408
+ for s in integ_all:
3409
+ integ_latest_by_tool[s.get("tool")] = s
3410
+ integ_open = sum((v.get("gated_reported", 0) or 0)
3411
+ for v in integ_latest_by_tool.values())
3412
+ integ_tests = [s for s in integ_all if s.get("tests_passed") is not None]
3413
+ integ_tests_ok = bool(integ_tests) and integ_tests[-1].get("tests_passed") is True
3414
+ integ_dim = 1.0 if (integ_open == 0 and integ_tests_ok) else 0.0
2842
3415
  else:
2843
3416
  integ_dim = 0.0 # required but never run
2844
3417
  else:
@@ -4524,6 +5097,7 @@ def _rubric_structure(path):
4524
5097
  def cmd_rubric_ingest(args):
4525
5098
  ledger = _load(args.ledger)
4526
5099
  node = _repo_node(ledger, args.repo)
5100
+ _validate_iteration(node, "rubric:grade", args.iteration)
4527
5101
  defaults = ledger["config"].get("defaults", {})
4528
5102
  rub_cfg = defaults.get("rubric", {}) if isinstance(defaults.get("rubric"), dict) else {}
4529
5103
  rubric_path = args.rubric or rub_cfg.get("file", "RUBRIC.md")
@@ -5188,7 +5762,7 @@ def build_parser():
5188
5762
  help='grader JSON: {"criteria":[{"id","verdict","evidence","note"}]}')
5189
5763
  pri.add_argument("--rubric", default=None,
5190
5764
  help="RUBRIC.md path (default: defaults.rubric.file or ./RUBRIC.md)")
5191
- pri.add_argument("--iteration", type=int, default=0)
5765
+ pri.add_argument("--iteration", type=int, default=1)
5192
5766
  pri.add_argument("--gate", action="store_true",
5193
5767
  help="a below-threshold score writes a GATED record: blocks "
5194
5768
  "convergence and caps readiness <=65")
@@ -5332,7 +5906,7 @@ def build_parser():
5332
5906
  pfb.add_argument("--repo", required=True)
5333
5907
  pfb.add_argument("--kind", default="constitution",
5334
5908
  help="free-text breach kind (default: constitution)")
5335
- pfb.add_argument("--iteration", type=int, default=0)
5909
+ pfb.add_argument("--iteration", type=int, default=1)
5336
5910
  pfb.add_argument("--note", default=None,
5337
5911
  help="what was breached (required unless --resolve)")
5338
5912
  pfb.add_argument("--resolve", action="store_true",