feat(check): security value, commit tables, MEDUSA install and kvb-think-choice

kvb-check-security gains security.py: it scans the changed files with MEDUSA, weights findings (critical -50, high -25, medium -10, low -2) and prints locations and counts only; without MEDUSA it asks for a review by hand passed as '~<n>'. kvb-check-score takes --security and --critical: the score multiplies by security/100, a critical finding blocks the commit (exit 4), security below 100 asks the operator like efficiency or speed over 100, and a 90/5 failure now lists every cause. --table and --commit-table print the values as markdown tables, shown before and after every commit.

make install installs MEDUSA with pipx unless it is on the PATH. The new kvb-think-choice skill offers open decisions as multiple choice and checkboxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-04 17:48:42 +01:00
1 parent 0343a2f3ce
commit 62a0bcf7e9
12 files changed
+786 -123

No files matched your search

+126 -24
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Fold fix confidence, break risk, efficiency and speed into one score line and one gate line.
"""Fold fix confidence, break risk, efficiency, speed and security into one score and one gate verdict.
Args:
--fix: fix confidence in percent, 0 to 100.
@@ -7,29 +7,48 @@ Args:
--efficiency: efficiency in percent above 0; `-` when not measured or not
relevant, `new` when the change has no base. Both count as 100 in the score.
--speed: speed in percent above 0, `-` or `new` as for --efficiency.
--security: security in percent, 0 to 100, from security.py of kvb-check-security;
`~<n>` for a value from a review by hand.
--critical: count of critical security findings.
--table: print one markdown table instead of the two lines.
--commit-table <repo>: the same table, led by the worktree, branch and staged
diff stat of that repository.
--commit <sha>: with --commit-table, the diff stat of that commit instead of
the staged one, plus a commit column.
Output:
Line 1: `🎯 fix <f> % · 💥 break <b> % · 🌱 efficiency <e> % · ⚡ speed <s> % · 🏆 score <n>`,
`–` in place of `<x> %` for `-`, `new` for `new`. Score is
fix * (100 - break) / 100 * 100 / efficiency * 100 / speed, capped to 0..100, rounded half up.
Line 1: `🎯 fix <f> % · 💥 break <b> % · 🌱 efficiency <e> % · ⚡ speed <s> % ·
🛡️ security <n> % · 🏆 score <n>`, `–` in place of `<x> %` for `-`, `new` for `new`.
Score is fix * (100 - break) / 100 * 100 / efficiency * 100 / speed * security / 100,
capped to 0..100, rounded half up.
Line 2: the gate verdict, `✅ gate: ...`, `⛔ gate: ...` or `⚠️ gate: ...`.
Exit codes:
0 gate passed, 1 fix under 90 or break over 5, 2 usage error,
3 efficiency or speed over 100 or new: ask the operator before the commit.
3 efficiency or speed over 100 or new, or security under 100: ask the operator
before the commit, 4 a critical security finding: do not commit.
"""
from __future__ import annotations
import argparse
import math
import re
import subprocess
import sys
FIX_MIN = 90
BREAK_MAX = 5
COST_MAX = 100
SAFE = 100
DASH = "-"
NEW = "new"
ESTIMATE = "~"
SCORE_HEADER = ("🎯 Fix", "💥 Break", "🌱 Efficiency", "⚡ Speed", "🛡️ Security", "🏆 Score", "Gate")
COMMIT_HEADER = ("📁 Worktree", "🌿 Branch", "📊 Diff stat")
SHORTSTAT = re.compile(
r"(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?"
)
def percent(text: str) -> float:
@@ -54,6 +73,17 @@ def cost(text: str) -> float | str:
return value
def security(text: str) -> tuple[float, str]:
mark = ESTIMATE if text.startswith(ESTIMATE) else ""
return percent(text.removeprefix(ESTIMATE)), mark
def count(text: str) -> int:
if not text.isdigit():
raise argparse.ArgumentTypeError(f"{text!r} is not a count")
return int(text)
def number(value: float) -> str:
return f"{value:g}"
@@ -70,8 +100,11 @@ def factor(value: float | str) -> float:
return 100 / value if isinstance(value, float) else 1.0
def score(fix: float, risk: float, efficiency: float | str, speed: float | str) -> int:
raw = fix * (100 - risk) / 100 * factor(efficiency) * factor(speed)
def score(args: argparse.Namespace) -> int:
raw = (
args.fix * (100 - args.risk) / 100 * factor(args.efficiency) * factor(args.speed)
* args.security[0] / 100
)
return math.floor(min(max(raw, 0.0), 100.0) + 0.5)
@@ -83,18 +116,67 @@ def over(name: str, value: float | str) -> str | None:
return None
def gate(fix: float, risk: float, efficiency: float | str, speed: float | str) -> tuple[str, int]:
def gate(args: argparse.Namespace) -> tuple[str, str, int]:
if args.critical > 0:
return "⛔", "critical security finding: do not commit", 4
failed = []
if fix < FIX_MIN:
failed.append(f"fix {number(fix)} % < {FIX_MIN}")
if risk > BREAK_MAX:
failed.append(f"break {number(risk)} % > {BREAK_MAX}")
if args.fix < FIX_MIN:
failed.append(f"fix {number(args.fix)} % < {FIX_MIN}")
if args.risk > BREAK_MAX:
failed.append(f"break {number(args.risk)} % > {BREAK_MAX}")
value, mark = args.security
unsafe = f"security {mark}{number(value)} % < {SAFE}" if value < SAFE else None
asks = [text for text in (over("efficiency", args.efficiency), over("speed", args.speed), unsafe) if text]
ask = f"{', '.join(asks)}: ask the operator before the commit" if asks else ""
if failed:
return f"⛔ gate: {', '.join(failed)}", 1
costly = [text for text in (over("efficiency", efficiency), over("speed", speed)) if text]
if costly:
return f"⚠️ gate: {', '.join(costly)}: ask the operator before the commit", 3
return f"✅ gate: {FIX_MIN}/{BREAK_MAX}, efficiency and speed <= {COST_MAX}", 0
return "⛔", "; ".join(part for part in (", ".join(failed), ask) if part), 1
if asks:
return "⚠️", ask, 3
return "✅", f"{FIX_MIN}/{BREAK_MAX}, efficiency and speed <= {COST_MAX}, security {SAFE} %", 0
def git(repo: str, *args: str) -> str:
try:
result = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=False)
except OSError as error:
print(f"score: git {args[0]} in {repo}: {error}", file=sys.stderr)
sys.exit(2)
if result.returncode != 0:
print(f"score: git {args[0]} in {repo}: {result.stderr.strip()}", file=sys.stderr)
sys.exit(2)
return result.stdout.strip()
def diffstat(shortstat: str) -> str:
match = SHORTSTAT.search(shortstat)
files, added, removed = (int(g or 0) for g in match.groups()) if match else (0, 0, 0)
return f"{files} file{'' if files == 1 else 's'} +{added}/-{removed}"
def commit_cells(repo: str, commit: str | None) -> tuple[list[str], list[str]]:
branch = git(repo, "branch", "--show-current") or f"detached at {git(repo, 'rev-parse', '--short', 'HEAD')}"
if commit is None:
stat = git(repo, "diff", "--cached", "--shortstat")
return list(COMMIT_HEADER), [git(repo, "rev-parse", "--show-toplevel"), branch, diffstat(stat)]
stat = git(repo, "show", "--shortstat", "--format=", commit)
return (
[*COMMIT_HEADER, "🔖 Commit"],
[git(repo, "rev-parse", "--show-toplevel"), branch, diffstat(stat), git(repo, "rev-parse", "--short", commit)],
)
def cell(text: str) -> str:
return text.replace("|", "\\|")
def table(header: list[str], row: list[str]) -> str:
return "\n".join(
(
"| " + " | ".join(header) + " |",
"|" + " --- |" * len(header),
"| " + " | ".join(cell(value) for value in row) + " |",
)
)
def main() -> int:
@@ -103,14 +185,34 @@ def main() -> int:
parser.add_argument("--break", dest="risk", type=percent, required=True)
parser.add_argument("--efficiency", type=cost, required=True)
parser.add_argument("--speed", type=cost, required=True)
parser.add_argument("--security", type=security, required=True)
parser.add_argument("--critical", type=count, required=True)
layout = parser.add_mutually_exclusive_group()
layout.add_argument("--table", action="store_true")
layout.add_argument("--commit-table", metavar="REPO")
parser.add_argument("--commit", metavar="SHA")
args = parser.parse_args()
total = score(args.fix, args.risk, args.efficiency, args.speed)
print(
f"🎯 fix {number(args.fix)} % · 💥 break {number(args.risk)} % · "
f"🌱 efficiency {shown(args.efficiency)} · ⚡ speed {shown(args.speed)} · 🏆 score {total}"
)
verdict, code = gate(args.fix, args.risk, args.efficiency, args.speed)
print(verdict)
if args.commit and not args.commit_table:
parser.error("--commit needs --commit-table")
total = score(args)
emoji, verdict, code = gate(args)
value, mark = args.security
values = [
f"{number(args.fix)} %", f"{number(args.risk)} %", shown(args.efficiency), shown(args.speed),
f"{mark}{number(value)} %", str(total), f"{emoji} {verdict}",
]
if args.commit_table:
header, cells = commit_cells(args.commit_table, args.commit)
print(table([*header, *SCORE_HEADER], [*cells, *values]))
elif args.table:
print(table(list(SCORE_HEADER), values))
else:
print(
f"🎯 fix {values[0]} · 💥 break {values[1]} · 🌱 efficiency {values[2]} · "
f"⚡ speed {values[3]} · 🛡️ security {values[4]} · 🏆 score {total}"
)
print(f"{emoji} gate: {verdict}")
return code