@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.
- package/README.md +6 -6
- package/bin/uscha.js +19 -7
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +2 -2
- package/uscha-kit/.claude/skills/uscha-devloop/SKILL.md +3 -1
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +729 -155
- package/uscha-kit/.claude/skills/uscha-mirador/SKILL.md +22 -7
- package/uscha-kit/.claude/skills/uscha-mirador/mirador-render.py +44 -5
- package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.ps1 +1 -1
- package/uscha-kit/.claude/skills/uscha-mirador/mirador-watch.sh +1 -1
- package/uscha-kit/.claude/skills/uscha-mirador/mirador.template.html +666 -586
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/CHANGELOG-1.41.0.md +18 -0
- package/uscha-kit/CHANGELOG-1.41.1.md +53 -0
- package/uscha-kit/CHANGELOG-1.41.2.md +34 -0
- package/uscha-kit/CHANGELOG-1.41.3.md +30 -0
- package/uscha-kit/CHANGELOG-1.42.0.md +41 -0
- package/uscha-kit/CHANGELOG-1.43.0.md +37 -0
- package/uscha-kit/INSTALL.md +120 -101
- package/uscha-kit/README.md +24 -13
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/WORKBENCH.md +19 -5
- package/uscha-kit/hooks/block-approved-writes.py +25 -0
- package/uscha-kit/install-uscha.py +534 -267
- package/uscha-kit/skills/uscha-adr-refine/SKILL.md +2 -2
- package/uscha-kit/skills/uscha-devloop/SKILL.md +3 -1
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +729 -155
- package/uscha-kit/skills/uscha-mirador/SKILL.md +22 -7
- package/uscha-kit/skills/uscha-mirador/mirador-render.py +44 -5
- package/uscha-kit/skills/uscha-mirador/mirador-watch.ps1 +1 -1
- package/uscha-kit/skills/uscha-mirador/mirador-watch.sh +1 -1
- package/uscha-kit/skills/uscha-mirador/mirador.template.html +666 -586
- package/uscha-kit/templates/CONSTITUTION.md +4 -4
- package/uscha-kit/templates/docs/adr/README.md +19 -19
- package/uscha-kit/tests/ledger-integrity-regressions.py +136 -0
- package/uscha-kit/tests/smoke-engine.sh +1312 -29
- package/uscha-kit/uscha.config.json +1 -1
- 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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
tests +=
|
|
344
|
-
failures +=
|
|
345
|
-
errors +=
|
|
346
|
-
skipped +=
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
964
|
+
line, granularity)
|
|
785
965
|
else:
|
|
786
|
-
fid = _mk_id(tool, rule, fname,
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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,
|
|
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
|
-
|
|
816
|
-
|
|
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
|
-
|
|
824
|
-
|
|
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
|
-
|
|
1060
|
+
except (OSError, ValueError) as exc:
|
|
1061
|
+
_invalid_ruff(path, exc)
|
|
862
1062
|
if not isinstance(data, list):
|
|
863
|
-
|
|
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
|
-
|
|
1066
|
+
_invalid_ruff(path, f"finding at index {index} must be an object")
|
|
867
1067
|
code = item.get("code")
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
1154
|
+
except (OSError, ValueError) as exc:
|
|
1155
|
+
_invalid_static_report(path, "ESLint JSON", exc)
|
|
944
1156
|
if not isinstance(data, list):
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1202
|
+
sev = "HIGH" if severity == 2 else "MEDIUM"
|
|
958
1203
|
rule = "unused-directive"
|
|
959
1204
|
else:
|
|
960
|
-
sev = "HIGH" if
|
|
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,
|
|
964
|
-
|
|
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`
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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"
|
|
1019
|
-
except OSError:
|
|
1020
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1365
|
+
except (OSError, ValueError) as exc:
|
|
1366
|
+
_invalid_static_report(path, "SARIF JSON", exc)
|
|
1088
1367
|
if not isinstance(data, dict):
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
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"
|
|
1102
|
-
if
|
|
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
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1832
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2496
|
-
return
|
|
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":
|
|
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
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
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=
|
|
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=
|
|
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",
|