Row 1's M was a curated list of 13 in the gate's OWN b_gate.json. It is now DERIVED: run_path_calls reads what run.py::run_project actually calls (41 po-functions, re-counted here against PM's 39 — the two that differ are generate_via_llm and fresh_workflow, held out because they need a chat client). A call that is neither a declared step nor named-with-a-reason counts in the denominator WITHOUT a door, so the number cannot shrink in silence: removing a step from the contract now leaves N unchanged and names the orphan. The four outbox writers the run path uses and nobody had declared (write_prepass, write_parse_failures, write_proposal_reviews, write_debate_tools) are steps now; 28 calls are held out, each with its reason printed under the row. A door must be REGISTERED and PROVEN. entry["kind"] is read (it was read 0 times before) and must be one of three kinds the gate has code to verify: console-script in pyproject, module-main with its own __main__ guard, subcommand registered in the module's argparse. On top of that every step needs a named probe that calls the door and reads the artefact. The MCP sentence is struck from EXTERNAL_DOOR: it claimed a capability with no code behind it. Row 3 now scans the repo manifest (git ls-files, or the tree itself in an extract), not a hand list of 11 roots: 512 published files instead of 433, so main.py, examples/, spikes/, contexts/, CLAUDE.md and llms.txt are inside the guard for the first time. Three new patterns catch the indirect invocations that walked past the old six — absolute path, list in a variable, constant, shell string — 5 of 5 of PM's variants are refused now, with 0 false positives measured over the whole surface. An empty surface is IKKE MAALT, not GREEN: the row demands a sentinel file and prints the file count and the manifest it read. Rows 4, 5 and 6 get sources for their denominators. Row 4 counts only checks whose named source symbol resolves in the code. Row 5 counts each structural requirement on its own (2 profile members + factory + seam + probe = 5) instead of collapsing three into one unit. Row 6's N comes from the artefacts the contract names, and the attestation is VALIDATED: it must name the contract's runbook, carry its sha256, say who ran it, and bear a real ISO date that is not in the future — v1_gate's own date rule, reused, BOM tolerated as there. Measured in a scratch clone (/tmp/claude-po/bgate-mut): 12 of 12 mutants felled, control 65 of 65. All six of PM's broken attacks reproduced as refused, with the rc-0 control green. No row got greener: 3 of 17 (was 3 of 13), 0 of 2, GREEN, 0 of 3, 4 of 5 (was 1 of 2), IKKE MAALT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1012 lines
40 KiB
Python
1012 lines
40 KiB
Python
"""B-gaten: hvor langt po er fra å være en verktøykasse Claude Code kan drive, rad for rad.
|
|
|
|
Rammen er operatørbeslutningen 19.09.2026: i utvikling og test LEDER Claude Code, og po er
|
|
verktøykassen som brukes — som ethvert annet verktøy. po kaller ALDRI den veien tilbake. I
|
|
produksjon er det Microsoft Foundry som kjører agentene, og Claude Code er ikke inne i bildet.
|
|
«Bryteren» er derfor ikke et flagg som bytter modell bak samme orkestrator: det er to måter å
|
|
drive SAMME verktøykasse på — utenfra, eller av MAF-agentene mot Foundry.
|
|
|
|
Én kommando, deterministisk, offline: intet modellkall, intet nett, ingen kvote. Exit 0 kun når
|
|
hver fellende rad er grønn; 1 ellers; 2 ved feil bruk.
|
|
|
|
Gaten definerer KONTRAKTEN en senere kapabilitet skal leveres inn i, ikke kapabiliteten. Hver
|
|
nevner hentes fra kilden utenfor det som måles: stegene telles i kjørestien (et steg uten symbol
|
|
eller uten kallsted er ikke et steg), rollene leses av ``workflow``-modulens egen liste, og
|
|
vaktene på rad 3 bærer hver sin kjent-positive og kjent-negative prøve — en vakt som ikke kan
|
|
treffe, måler ikke null, den måler ingenting.
|
|
|
|
Mønstrene på rad 3 ligger base64-kodet i ``b_gate.json`` med vilje: flaten gaten leser er den
|
|
samme flaten gaten selv bor i, og en kontrakt skrevet i klartekst ville registrert seg som sitt
|
|
eget funn. Kodingen gjør at raden slipper unntakslister — og en rad uten unntak er en rad ingen
|
|
kan skru av ved å legge en fil på lista.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from datetime import datetime, timezone
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from portfolio_optimiser.evals.v1_gate import (
|
|
GREEN,
|
|
NOT_MEASURED,
|
|
RED,
|
|
Row,
|
|
_parse_given,
|
|
exit_code,
|
|
run_probes,
|
|
)
|
|
|
|
__all__ = [
|
|
"GREEN",
|
|
"NOT_MEASURED",
|
|
"RED",
|
|
"Row",
|
|
"evaluate",
|
|
"exit_code",
|
|
"main",
|
|
"render",
|
|
]
|
|
|
|
_DATA = Path(__file__).with_name("b_gate.json")
|
|
_PACKAGE_SRC = Path(__file__).resolve().parents[1]
|
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
ProbeRunner = Callable[[Sequence[str]], Mapping[str, str]]
|
|
|
|
#: Navnene som gjør et steg avhengig av en modell. Treffer ett av dem på veien fra inngangen til
|
|
#: steget, er steget kallbart — men ikke uten en chatklient, og da ikke som verktøy i B.
|
|
CHAT_CLIENT_NAMES = frozenset(
|
|
{
|
|
"create_chat_client",
|
|
"client_factory",
|
|
"BaseChatClient",
|
|
"ChatBackend",
|
|
"get_backend",
|
|
"_default_factory",
|
|
}
|
|
)
|
|
|
|
#: Rad 1: hvilken dør som teller, og hvorfor den ble valgt framfor den andre.
|
|
EXTERNAL_DOOR = (
|
|
"valgt kommandolinja som dør UT av verktøykassen, fordi operatørens ord er «som alle andre "
|
|
"tools Claude Code kan bruke» — en kommando er nettopp det. Gaten teller tre arter den kan "
|
|
"VERIFISERE mot kilden: console-script (registrert i pyproject), module-main (kjørbar med "
|
|
"python -m, med sin egen __main__-vakt) og subcommand (et navn registrert i modulens egen "
|
|
"argparse). En art gaten ikke kan verifisere er ikke en dør her — og et navn er uansett ikke "
|
|
"nok: hvert steg må ha en navngitt probe som kaller døren og leser artefaktet den skriver"
|
|
)
|
|
|
|
#: Inngangsartene rad 1 kan VERIFISERE mot kilden. En art utenfor lista teller aldri som en dør:
|
|
#: gaten skal ikke kunne bli grønn av en påstand den ikke har kode for å etterprøve.
|
|
ENTRY_KINDS: tuple[str, ...] = ("console-script", "module-main", "subcommand")
|
|
|
|
#: Rad 2: det ene kravet som skiller en ekte felles vei fra to veier som ser like ut.
|
|
BYTE_IDENTICAL_RULE = (
|
|
"et forslag gitt UTENFRA skal gjennom NØYAKTIG samme validator, samme avvisningsstadier og "
|
|
"samme verdict_id-minting som når en MAF-agent leverte det. Beviset er byte-identiske "
|
|
"proposal- og outcome-artefakter fra samme forslags-JSON inn begge veier — at filene FINNES "
|
|
"begge veier beviser ingenting, for to ulike veier skriver gjerne samme filnavn"
|
|
)
|
|
|
|
#: Rad 4: hva budsjettvernet ER i B. BudgetMiddleware er fail-closed på manglende usage, og i B
|
|
#: finnes ingen usage i po i det hele tatt — tokenene brukes i Claude Code-økten.
|
|
BUDGET_IN_B = (
|
|
"budsjett i B: valgt at po IKKE har et token-tak i verktøykasse-modus, fordi et tak po ikke "
|
|
"kan måle forbruk mot er et tak som ikke finnes. BudgetMiddleware er fail-closed på manglende "
|
|
"usage og konstrueres aldri uten chatklient; å beholde det her ville gjort fail-closed til "
|
|
"fail-open i praksis. Taket i B er Claude Code-øktens eget forbruk, som po verken ser eller "
|
|
"styrer, og som gaten derfor ikke påstår noe om. Foundry-veien beholder taket uendret"
|
|
)
|
|
|
|
#: Rad 6: hva gaten aldri gjør, og hvorfor raden ellers ville vært en sløyfe.
|
|
RUNBOOK_RULE = (
|
|
"rad 6 regner gaten aldri ut: en kjørebok kan være komplett på papiret og likevel ikke ha "
|
|
"drevet én analyse. Skrittet til grønt er operatørens egen bekreftelse på at han kjørte den "
|
|
"og at den virket — en fil gaten ALDRI skriver selv, like lite som attesteringen på v1-gaten"
|
|
)
|
|
|
|
CONTRACT = """\
|
|
Radene, og hva som flytter hver av dem:
|
|
|
|
1 verktøykasse hvert deterministiske steg i kjørestien, kallbart UTENFRA uten chatklient
|
|
2 roller proposer og checker kan leveres utenfra og dømmes like hardt
|
|
3 ingen-claude-vei null treff på en vei fra po til Claude i alt som publiseres
|
|
4 ingen-modellkall hele løpet kjørt med chatklienten satt til å reise, nett stengt
|
|
5 foundry Foundry-veien urørt, og samme artefaktfamilie fra begge modi
|
|
6 kjørebok en fersk økt kan drive en analyse etter den — bekreftet av operatøren
|
|
|
|
Nevnerne: steg og roller telles i KILDEN (src/), mønstrene på rad 3 i kontrakten, og probene på
|
|
rad 2, 4 og 5 er navngitte tester som kjøres i en barne-pytest med --runxfail. En test som ikke
|
|
finnes er RØD, aldri grønn og aldri hoppet over.
|
|
"""
|
|
|
|
|
|
def load_config(path: Path = _DATA) -> dict[str, Any]:
|
|
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
return data
|
|
|
|
|
|
def decode(value: str) -> str:
|
|
"""Base64 fra kontrakten til teksten den beskriver. Feil koding er en feil, ikke en tom vakt."""
|
|
try:
|
|
return base64.b64decode(value.encode("ascii"), validate=True).decode("utf-8")
|
|
except (binascii.Error, UnicodeDecodeError, ValueError) as exc: # pragma: no cover - kontrakt
|
|
raise ValueError(f"ugyldig base64 i kontrakten: {value!r}") from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Rad 1 — verktøykassen er komplett
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Step:
|
|
id: str
|
|
label: str
|
|
resolved: bool
|
|
called: bool
|
|
external: bool
|
|
why: str
|
|
|
|
|
|
def _tree(src: Path, module: str) -> ast.AST | None:
|
|
path = src / module
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
return ast.parse(path.read_text(encoding="utf-8"))
|
|
except (SyntaxError, UnicodeDecodeError): # pragma: no cover - en ulesbar modul er ikke kilde
|
|
return None
|
|
|
|
|
|
def _defined(tree: ast.AST, symbol: str) -> bool:
|
|
for node in getattr(tree, "body", []):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
if node.name == symbol:
|
|
return True
|
|
if isinstance(node, ast.Assign) and any(
|
|
isinstance(t, ast.Name) and t.id == symbol for t in node.targets
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _call_names(node: ast.AST) -> set[str]:
|
|
names: set[str] = set()
|
|
for child in ast.walk(node):
|
|
if isinstance(child, ast.Call):
|
|
func = child.func
|
|
if isinstance(func, ast.Name):
|
|
names.add(func.id)
|
|
elif isinstance(func, ast.Attribute):
|
|
names.add(func.attr)
|
|
return names
|
|
|
|
|
|
def _scopes(tree: ast.AST, name: str) -> list[ast.AST]:
|
|
return [
|
|
n
|
|
for n in ast.walk(tree)
|
|
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and n.name == name
|
|
]
|
|
|
|
|
|
def calls_symbol(src: Path, module: str, scope: str, symbol: str) -> bool:
|
|
"""Steget har et kallsted: ``scope`` i ``module`` kaller det faktisk."""
|
|
tree = _tree(src, module)
|
|
if tree is None:
|
|
return False
|
|
return any(symbol in _call_names(s) for s in _scopes(tree, scope))
|
|
|
|
|
|
def _functions(tree: ast.AST) -> dict[str, ast.AST]:
|
|
return {
|
|
n.name: n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
}
|
|
|
|
|
|
def _reachable(functions: Mapping[str, ast.AST], start: str) -> set[str]:
|
|
seen: set[str] = set()
|
|
stack = [start]
|
|
while stack:
|
|
current = stack.pop()
|
|
if current in seen:
|
|
continue
|
|
seen.add(current)
|
|
stack += [n for n in _call_names(functions[current]) if n in functions and n not in seen]
|
|
return seen
|
|
|
|
|
|
def _chat_client_names(functions: Mapping[str, ast.AST], reached: Sequence[str]) -> set[str]:
|
|
found: set[str] = set()
|
|
for name in reached:
|
|
for node in ast.walk(functions[name]):
|
|
if isinstance(node, ast.Name) and node.id in CHAT_CLIENT_NAMES:
|
|
found.add(node.id)
|
|
elif isinstance(node, ast.Attribute) and node.attr in CHAT_CLIENT_NAMES:
|
|
found.add(node.attr)
|
|
return found
|
|
|
|
|
|
def entry_reaches(src: Path, entry: Mapping[str, Any], symbol: str) -> tuple[bool, str]:
|
|
"""(steget er kallbart utenfra uten chatklient, grunnen når det ikke er det).
|
|
|
|
Kjent grense: chatklient-sjekken leser HELE det ``main`` når i sin egen modul, ikke bare den
|
|
ene veien ned til steget. En inngang som bygger en chatklient i en annen gren teller derfor
|
|
som avhengig. Det er med vilje strengere enn nødvendig: en dør som noen ganger drar en modell
|
|
med seg er ikke en dør Claude Code kan bruke uten å vite når."""
|
|
scope = str(entry.get("scope", "main"))
|
|
tree = _tree(src, str(entry["module"]))
|
|
if tree is None:
|
|
return False, f"inngangsmodulen {entry['module']} finnes ikke"
|
|
functions = _functions(tree)
|
|
if scope not in functions:
|
|
return False, f"{entry['module']} har ingen {scope}()"
|
|
reached = _reachable(functions, scope)
|
|
called = {n for name in reached for n in _call_names(functions[name])}
|
|
if symbol not in called:
|
|
return False, f"{scope}() i {entry['module']} når aldri {symbol}"
|
|
chat = _chat_client_names(functions, sorted(reached))
|
|
if chat:
|
|
return False, f"veien fra {scope}() krever chatklient ({', '.join(sorted(chat))})"
|
|
return True, ""
|
|
|
|
|
|
def console_scripts(repo_root: Path) -> dict[str, str]:
|
|
"""``[project.scripts]`` i pyproject.toml, lest som tekst.
|
|
|
|
Lest som tekst og ikke med ``tomllib``, fordi pakken støtter Python 3.10, der ``tomllib``
|
|
ikke finnes — og en gate som krever en nyere tolk enn produktet er en gate som ikke kjører
|
|
der produktet gjør."""
|
|
path = repo_root / "pyproject.toml"
|
|
if not path.is_file():
|
|
return {}
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError): # pragma: no cover - et ulesbart manifest er ingen dør
|
|
return {}
|
|
scripts: dict[str, str] = {}
|
|
inside = False
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith("["):
|
|
inside = stripped == "[project.scripts]"
|
|
continue
|
|
if not inside or "=" not in stripped or stripped.startswith("#"):
|
|
continue
|
|
name, _, target = stripped.partition("=")
|
|
scripts[name.strip().strip("\"'")] = target.strip().strip("\"'")
|
|
return scripts
|
|
|
|
|
|
def _dotted(package: str, module: str) -> str:
|
|
return f"{package}." + str(module).removesuffix(".py").replace("/", ".")
|
|
|
|
|
|
def _has_dunder_main(tree: ast.AST, scope: str) -> bool:
|
|
"""``if __name__ == "__main__":`` som faktisk kaller ``scope`` — en modul kjørbar med -m."""
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.If):
|
|
continue
|
|
test = node.test
|
|
if not isinstance(test, ast.Compare) or not isinstance(test.left, ast.Name):
|
|
continue
|
|
if test.left.id != "__name__":
|
|
continue
|
|
if not any(isinstance(c, ast.Constant) and c.value == "__main__" for c in test.comparators):
|
|
continue
|
|
if any(scope in _call_names(child) for child in node.body):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _registers_subcommand(tree: ast.AST, command: str) -> bool:
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
continue
|
|
if node.func.attr != "add_parser":
|
|
continue
|
|
if any(isinstance(a, ast.Constant) and a.value == command for a in node.args):
|
|
return True
|
|
return False
|
|
|
|
|
|
def registered_entry(
|
|
src: Path, repo_root: Path, package: str, entry: Mapping[str, Any]
|
|
) -> tuple[bool, str]:
|
|
"""(inngangen er REGISTRERT, grunnen når den ikke er det).
|
|
|
|
Målt 19.09.2026: en modul med ``def main(): return <symbol>()`` og ingenting annet tok rad 1
|
|
fra 3 til 4 av 13. Et navn er ikke en dør. Døren må være registrert et sted utenfor modulen
|
|
selv — i pakkemanifestet, i en ``__main__``-vakt, eller i en argparse-underkommando — og
|
|
arten må være en gaten har kode for å etterprøve."""
|
|
kind = str(entry.get("kind", ""))
|
|
if kind not in ENTRY_KINDS:
|
|
return False, f"inngangsarten {kind!r} er ikke en gaten kan verifisere"
|
|
module = str(entry["module"])
|
|
scope = str(entry.get("scope", "main"))
|
|
tree = _tree(src, module)
|
|
if tree is None:
|
|
return False, f"inngangsmodulen {module} finnes ikke"
|
|
if kind == "console-script":
|
|
target = f"{_dotted(package, module)}:{scope}"
|
|
if target not in console_scripts(repo_root).values():
|
|
return False, f"{target} er ikke registrert i pyproject [project.scripts]"
|
|
return True, ""
|
|
if kind == "module-main":
|
|
if not _has_dunder_main(tree, scope):
|
|
return False, f"{module} har ingen __main__-vakt som kaller {scope}()"
|
|
return True, ""
|
|
command = str(entry.get("command", ""))
|
|
if not command:
|
|
return False, f"{module} navngir ingen underkommando"
|
|
if not _registers_subcommand(tree, command):
|
|
return False, f"{module} registrerer ingen underkommando {command!r}"
|
|
return True, ""
|
|
|
|
|
|
def run_path_calls(src: Path, spec: Mapping[str, Any]) -> dict[str, str]:
|
|
"""``symbol -> modulen det kom fra``: po-funksjonene kjørestien FAKTISK kaller.
|
|
|
|
Dette er rad 1s nevner, og den leses av KILDEN. Regelen: et navn importert fra en
|
|
undermodul av pakken og kalt i scopet, eller et attributt på en modul importert som
|
|
``from <pakke> import X``. Klasser og unntak (stor forbokstav) er ikke steg, og private
|
|
hjelpere i driveren heller ikke. Et kall som verken er erklært som steg eller navngitt som
|
|
holdt utenfor, teller med i nevneren UTEN en dør — nevneren kan derfor ikke krympe stille,
|
|
som en kuratert liste i gatens egen datafil kunne."""
|
|
tree = _tree(src, str(spec["module"]))
|
|
if tree is None:
|
|
return {}
|
|
package = str(spec.get("package", "portfolio_optimiser"))
|
|
symbols: dict[str, str] = {}
|
|
aliases: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.ImportFrom):
|
|
continue
|
|
module = node.module or ""
|
|
if module == package:
|
|
aliases |= {a.asname or a.name for a in node.names}
|
|
elif module.startswith(f"{package}."):
|
|
for alias in node.names:
|
|
symbols[alias.asname or alias.name] = module.split(".")[-1] + ".py"
|
|
found: dict[str, str] = {}
|
|
for scope in _scopes(tree, str(spec["scope"])):
|
|
for call in ast.walk(scope):
|
|
if not isinstance(call, ast.Call):
|
|
continue
|
|
func = call.func
|
|
if isinstance(func, ast.Name) and func.id in symbols and func.id[:1].islower():
|
|
found[func.id] = symbols[func.id]
|
|
elif (
|
|
isinstance(func, ast.Attribute)
|
|
and isinstance(func.value, ast.Name)
|
|
and func.value.id in aliases
|
|
and func.attr[:1].islower()
|
|
):
|
|
found[func.attr] = f"{func.value.id}.py"
|
|
return found
|
|
|
|
|
|
def measure_steps(
|
|
steps: Sequence[Mapping[str, Any]],
|
|
src: Path,
|
|
outcomes: Mapping[str, str] | None = None,
|
|
repo_root: Path = _REPO_ROOT,
|
|
package: str = "portfolio_optimiser",
|
|
) -> list[Step]:
|
|
measured: list[Step] = []
|
|
for step in steps:
|
|
tree = _tree(src, str(step["module"]))
|
|
resolved = tree is not None and _defined(tree, str(step["symbol"]))
|
|
called = calls_symbol(
|
|
src, str(step["driver"]["module"]), str(step["driver"]["scope"]), str(step["symbol"])
|
|
)
|
|
if not resolved:
|
|
why = f"symbolet {step['symbol']} finnes ikke i {step['module']}"
|
|
elif not called:
|
|
why = f"{step['driver']['module']}::{step['driver']['scope']} kaller ikke {step['symbol']}"
|
|
else:
|
|
ok, why = entry_reaches(src, step["entry"], str(step["symbol"]))
|
|
if ok:
|
|
ok, why = registered_entry(src, repo_root, package, step["entry"])
|
|
if ok:
|
|
probes = list(step.get("probe", ()))
|
|
verdict = _probe_verdict(probes, outcomes or {})
|
|
why = (
|
|
"ingen atferdsprobe registrert — et navn er ikke en atferd"
|
|
if not probes
|
|
else (f"atferdsprobe: {verdict}" if verdict else "")
|
|
)
|
|
external = resolved and called and not why
|
|
measured.append(Step(str(step["id"]), str(step["label"]), resolved, called, external, why))
|
|
return measured
|
|
|
|
|
|
def score_toolbox(
|
|
steps: Sequence[Mapping[str, Any]],
|
|
src: Path,
|
|
run_path: Mapping[str, Any] | None = None,
|
|
outcomes: Mapping[str, str] | None = None,
|
|
repo_root: Path = _REPO_ROOT,
|
|
) -> Row:
|
|
package = str((run_path or {}).get("package", "portfolio_optimiser"))
|
|
measured = measure_steps(steps, src, outcomes, repo_root, package)
|
|
in_path = [s for s in measured if s.resolved and s.called]
|
|
declared = {str(s["symbol"]) for s in steps}
|
|
held = {
|
|
str(h["symbol"]): str(h.get("reason", "")) for h in (run_path or {}).get("held_out", ())
|
|
}
|
|
calls = run_path_calls(src, run_path) if run_path else {}
|
|
undeclared = sorted(name for name in calls if name not in declared and name not in held)
|
|
n = len(in_path) + len(undeclared)
|
|
k = sum(1 for s in in_path if s.external)
|
|
exceptions = tuple(f"{s.id}: {s.why}" for s in measured if not s.external) + tuple(
|
|
f"udeklarert: {name} kalles i {calls[name]} fra kjørestien uten å være et steg"
|
|
for name in undeclared
|
|
)
|
|
diagnostics = tuple(
|
|
f"holdt utenfor: {symbol} — {reason or 'UTEN GRUNN'}"
|
|
for symbol, reason in sorted(held.items())
|
|
) + tuple(
|
|
f"holdt utenfor: {symbol} kalles ikke lenger i kjørestien (foreldet unntak)"
|
|
for symbol in sorted(held)
|
|
if calls and symbol not in calls
|
|
)
|
|
return Row(
|
|
"verktøykasse",
|
|
"1 steg i kjørestien kallbare utenfra",
|
|
k,
|
|
n,
|
|
GREEN if n and k == n else RED,
|
|
f"{k} av {n} steg har en dør ut; {len(held)} kall holdt utenfor med grunn, "
|
|
f"{len(measured) - len(in_path)} erklærte steg er ikke i kjørestien",
|
|
exceptions=exceptions,
|
|
diagnostics=diagnostics,
|
|
attests=(EXTERNAL_DOOR,),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Rad 2 — det modellen leverte, kan leveres utenfra
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def run_roles(src: Path, source: Mapping[str, Any]) -> tuple[str, ...]:
|
|
"""Rollene en vanlig kjøring spør, lest av KILDEN — aldri av en liste i denne fila."""
|
|
tree = _tree(src, str(source["module"]))
|
|
if tree is None:
|
|
return ()
|
|
for node in getattr(tree, "body", []):
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
if not any(isinstance(t, ast.Name) and t.id == source["symbol"] for t in node.targets):
|
|
continue
|
|
value = node.value
|
|
if isinstance(value, (ast.Tuple, ast.List)):
|
|
return tuple(
|
|
e.value
|
|
for e in value.elts
|
|
if isinstance(e, ast.Constant) and isinstance(e.value, str)
|
|
)
|
|
return ()
|
|
|
|
|
|
def _probe_verdict(nodeids: Sequence[str], outcomes: Mapping[str, str]) -> str:
|
|
if not nodeids:
|
|
return "ingen probe registrert"
|
|
bad = [
|
|
f"{n.split('::')[-1]}={outcomes.get(n, 'missing')}"
|
|
for n in nodeids
|
|
if outcomes.get(n) != "passed"
|
|
]
|
|
return "; ".join(bad)
|
|
|
|
|
|
def score_roles(roles: Mapping[str, Any], outcomes: Mapping[str, str], src: Path) -> Row:
|
|
names = run_roles(src, roles["source"])
|
|
evidence = roles["evidence"]
|
|
verdicts = {name: _probe_verdict(list(evidence.get(name, ())), outcomes) for name in names}
|
|
k = sum(1 for why in verdicts.values() if not why)
|
|
n = len(names)
|
|
exceptions = tuple(f"{name}: {why}" for name, why in verdicts.items() if why)
|
|
green = ", ".join(name for name, why in verdicts.items() if not why) or "ingen"
|
|
return Row(
|
|
"roller",
|
|
"2 roller som kan leveres utenfra",
|
|
k,
|
|
n,
|
|
GREEN if n and k == n else RED,
|
|
f"grønne: {green}",
|
|
exceptions=exceptions,
|
|
attests=(BYTE_IDENTICAL_RULE,),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Rad 3 — po har ingen vei til Claude
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
_FENCE = re.compile(r"^\s*(```|~~~)")
|
|
|
|
#: Rad 3: hva vakten IKKE rekker, sagt av raden selv. Fire av PMs fem indirekte kall-varianter
|
|
#: slapp forbi før 19.09; disse to gjør det fortsatt, og de er linje-lokale grenser i en
|
|
#: tekstvakt, ikke noe en regex kan lukke.
|
|
NO_CLAUDE_LIMIT = (
|
|
"rad 3 er en TEKSTVAKT over hele det publiserte manifestet, ikke en dataflyt-analyse: den "
|
|
"feller et programnavn skrevet ut, en absolutt sti, en liste lagt i en variabel, en "
|
|
"konstant og en shell-streng. To veier står igjen og kan ikke felles linje for linje — et "
|
|
"kommandonavn satt sammen av deler ved kjøretid, og et navn lest ut av en miljøvariabel. "
|
|
"Raden påstår derfor ikke at ingen vei finnes; den påstår at ingen SKREVET vei finnes, over "
|
|
"en nevner den oppgir i filer"
|
|
)
|
|
|
|
|
|
def published_files(root: Path, manifest: Mapping[str, Any]) -> tuple[list[Path], str]:
|
|
"""(filene som PUBLISERES, hvor lista kom fra).
|
|
|
|
Kilden er repo-manifestet, ikke en håndliste: ``git ls-files`` når det finnes en ``.git``,
|
|
og ellers filtreet selv — et rent uttrekk (``git archive | tar -x``) ER nøyaktig det
|
|
publiserte, og har ingen git-metadata å spørre. Håndlista den erstattet utelot ``main.py``,
|
|
``examples/``, ``spikes/`` og ``contexts/``: 433 av 512 filer ble skannet, og alle seks
|
|
vaktenes kjent-positive kunne plantes i ``main.py`` uten at raden merket det.
|
|
|
|
Ingen suffiks-allowlist heller: en fil som lar seg lese som tekst kan bære en kommandolinje
|
|
uansett hva den heter. Det som ikke lar seg avkode, telles og oppgis."""
|
|
skip = set(manifest.get("skip_dirs", (".git", "__pycache__")))
|
|
if (root / ".git").exists():
|
|
try:
|
|
listing = subprocess.run(
|
|
["git", "ls-files", "-z"],
|
|
cwd=root,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout
|
|
except (OSError, subprocess.CalledProcessError): # pragma: no cover - git kan mangle
|
|
listing = None
|
|
if listing is not None:
|
|
found = [root / rel for rel in listing.split("\0") if rel]
|
|
return [p for p in found if p.is_file() and not p.is_symlink()], "git-manifestet"
|
|
walked = [
|
|
path
|
|
for path in sorted(root.rglob("*"))
|
|
if path.is_file()
|
|
and not path.is_symlink()
|
|
and not (skip & set(path.relative_to(root).parts))
|
|
]
|
|
return walked, "filtreet (uttrekk uten git-metadata)"
|
|
|
|
|
|
def scannable_lines(path: Path, text: str) -> list[tuple[int, str]]:
|
|
"""Linjene som kan bære en KJØRBAR kommando.
|
|
|
|
I markdown er det linjene inne i en kodeblokk: en omtale av et pakkenavn i brødtekst er en
|
|
vurdering noen skrev ned, ikke en vei noen kan gå."""
|
|
lines = text.splitlines()
|
|
if path.suffix != ".md":
|
|
return list(enumerate(lines, 1))
|
|
inside = False
|
|
kept: list[tuple[int, str]] = []
|
|
for number, line in enumerate(lines, 1):
|
|
if _FENCE.match(line):
|
|
inside = not inside
|
|
continue
|
|
if inside:
|
|
kept.append((number, line))
|
|
return kept
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Pattern:
|
|
id: str
|
|
label: str
|
|
valid: bool
|
|
why: str
|
|
hits: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Surface:
|
|
files: int
|
|
unreadable: int
|
|
source: str
|
|
sentinel: bool
|
|
|
|
|
|
def measure_patterns(config: Mapping[str, Any], root: Path) -> tuple[list[Pattern], Surface]:
|
|
manifest = config.get("manifest", {})
|
|
files, source = published_files(root, manifest)
|
|
sentinel = str(manifest.get("sentinel", ""))
|
|
scanned: list[tuple[Path, list[tuple[int, str]]]] = []
|
|
unreadable = 0
|
|
for path in files:
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except (UnicodeDecodeError, OSError):
|
|
unreadable += 1
|
|
continue
|
|
scanned.append((path, scannable_lines(path, text)))
|
|
measured: list[Pattern] = []
|
|
for spec in config["patterns"]:
|
|
rx = re.compile(decode(str(spec["pattern_b64"])))
|
|
positive = decode(str(spec["known_positive_b64"]))
|
|
negative = decode(str(spec["known_negative_b64"]))
|
|
if not rx.search(positive):
|
|
why = "kjent-positiv treffer ikke — vakten måler ingenting"
|
|
elif rx.search(negative):
|
|
why = "kjent-negativ treffer — vakten skiller ikke"
|
|
else:
|
|
why = ""
|
|
hits = tuple(
|
|
f"{path.relative_to(root)}:{number}"
|
|
for path, lines in scanned
|
|
for number, line in lines
|
|
if rx.search(line)
|
|
)
|
|
measured.append(Pattern(str(spec["id"]), str(spec["label"]), not why, why, hits))
|
|
present = bool(sentinel) and any(str(p.relative_to(root)) == sentinel for p in files)
|
|
return measured, Surface(len(files), unreadable, source, present)
|
|
|
|
|
|
def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
|
|
measured, surface = measure_patterns(config, root)
|
|
n = len(measured)
|
|
k = sum(1 for p in measured if p.valid and not p.hits)
|
|
exceptions: list[str] = []
|
|
for pattern in measured:
|
|
if not pattern.valid:
|
|
exceptions.append(f"{pattern.id}: {pattern.why}")
|
|
elif pattern.hits:
|
|
shown = ", ".join(pattern.hits[:3])
|
|
exceptions.append(f"{pattern.id}: {len(pattern.hits)} treff — {shown}")
|
|
sentinel = str(config.get("manifest", {}).get("sentinel", ""))
|
|
if not surface.sentinel:
|
|
exceptions.append(
|
|
f"flaten mangler sin sentinel ({sentinel}) — {surface.files} filer sett, "
|
|
"et fravær uten nevner er ikke et bevis"
|
|
)
|
|
status = NOT_MEASURED if not surface.sentinel else GREEN if n and k == n else RED
|
|
return Row(
|
|
"ingen-claude-vei",
|
|
"3 vakter mot en vei fra po til Claude",
|
|
k,
|
|
n,
|
|
status,
|
|
f"{k} av {n} vakter måler og gir null treff over {surface.files} publiserte filer "
|
|
f"({surface.source}; {surface.unreadable} ulesbare)",
|
|
exceptions=tuple(exceptions),
|
|
attests=(NO_CLAUDE_LIMIT,),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Rad 4 — ingen modellkall i verktøykasse-modus
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def score_no_model_calls(
|
|
config: Mapping[str, Any], outcomes: Mapping[str, str], src: Path | None = None
|
|
) -> Row:
|
|
"""Rad 4s nevner er sjekkene hvis NAVNGITTE kilde faktisk finnes i koden.
|
|
|
|
M = 3 var en konstant i gatens egen konfig. Nå bærer hver sjekk et symbol i kilden den
|
|
handler om — kjøringen selv, rundebinderen, og v1-gatens ``FORM_OK``. Forsvinner symbolet,
|
|
faller sjekken ut av nevneren OG navngis, i stedet for å bli en grønn rad over ingenting."""
|
|
source_root = src if src is not None else _PACKAGE_SRC
|
|
counted: dict[str, str] = {}
|
|
exceptions: list[str] = []
|
|
for name, check in config["checks"].items():
|
|
source = check["source"]
|
|
tree = _tree(source_root, str(source["module"]))
|
|
if tree is None or not _defined(tree, str(source["symbol"])):
|
|
exceptions.append(
|
|
f"{name}: kilden {source['module']}::{source['symbol']} finnes ikke — "
|
|
"sjekken teller ikke med i nevneren"
|
|
)
|
|
continue
|
|
counted[name] = _probe_verdict(list(check.get("probe", ())), outcomes)
|
|
k = sum(1 for why in counted.values() if not why)
|
|
n = len(counted)
|
|
exceptions += [f"{name}: {why}" for name, why in counted.items() if why]
|
|
return Row(
|
|
"ingen-modellkall",
|
|
"4 løpet drevet uten et eneste modellkall",
|
|
k,
|
|
n,
|
|
GREEN if n and k == n else RED,
|
|
f"{k} av {n} sjekker har en bestått probe (nevneren er sjekker med en kilde i koden)",
|
|
exceptions=tuple(exceptions),
|
|
attests=(BUDGET_IN_B,),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Rad 5 — Foundry-veien urørt
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def foundry_intact(config: Mapping[str, Any], src: Path) -> tuple[bool, tuple[str, ...]]:
|
|
"""(Foundry-veien står slik den sto, hva som mangler når den ikke gjør det)."""
|
|
missing: list[str] = []
|
|
profile = config["profile"]
|
|
tree = _tree(src, str(profile["module"]))
|
|
members: set[str] = set()
|
|
if tree is not None:
|
|
for scope in _scopes(tree, str(profile["scope"])):
|
|
for node in ast.walk(scope):
|
|
if isinstance(node, ast.Assign):
|
|
members |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
|
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
members.add(node.target.id)
|
|
missing += [
|
|
f"{profile['scope']}.{m} borte fra {profile['module']}"
|
|
for m in profile["members"]
|
|
if m not in members
|
|
]
|
|
|
|
factory = config["factory"]
|
|
tree = _tree(src, str(factory["module"]))
|
|
if tree is None or not _defined(tree, str(factory["symbol"])):
|
|
missing.append(f"{factory['symbol']} borte fra {factory['module']}")
|
|
|
|
seam = config["seam"]
|
|
tree = _tree(src, str(seam["module"]))
|
|
found = False
|
|
if tree is not None:
|
|
for scope in _scopes(tree, str(seam["scope"])):
|
|
args = getattr(scope, "args", None)
|
|
if args is None:
|
|
continue
|
|
names = {a.arg for a in [*args.args, *args.posonlyargs, *args.kwonlyargs]}
|
|
if seam["parameter"] in names:
|
|
found = True
|
|
if not found:
|
|
missing.append(f"{seam['scope']}() tar ikke lenger {seam['parameter']}")
|
|
return not missing, tuple(missing)
|
|
|
|
|
|
def score_foundry(config: Mapping[str, Any], outcomes: Mapping[str, str], src: Path) -> Row:
|
|
"""Hvert strukturelle krav telles for seg, og hvert av dem er et symbol i kilden.
|
|
|
|
M = 2 blandet profil-medlemmene, fabrikken og injeksjonssømmen til ÉN tellende enhet: tre
|
|
krav kunne falle uten at tallet endret seg med mer enn én. Nevneren er nå medlemmene
|
|
kontrakten navngir, pluss fabrikken, pluss sømmen, pluss probene."""
|
|
_intact, missing = foundry_intact(config, src)
|
|
structural = len(config["profile"]["members"]) + 2
|
|
verdicts = {
|
|
name: _probe_verdict(list(ids), outcomes) for name, ids in config["evidence"].items()
|
|
}
|
|
k = structural - len(missing) + sum(1 for why in verdicts.values() if not why)
|
|
n = structural + len(verdicts)
|
|
exceptions = [f"urørt: {m}" for m in missing]
|
|
exceptions += [f"{name}: {why}" for name, why in verdicts.items() if why]
|
|
return Row(
|
|
"foundry",
|
|
"5 Foundry-veien urørt og samme artefaktfamilie",
|
|
k,
|
|
n,
|
|
GREEN if k == n else RED,
|
|
f"{structural - len(missing)} av {structural} strukturkrav står"
|
|
+ ("" if not missing else " — Foundry-veien er rørt"),
|
|
exceptions=tuple(exceptions),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Rad 6 — kjøreboka finnes
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RunbookAttestation:
|
|
present: bool
|
|
ok: bool
|
|
why: str
|
|
|
|
|
|
def read_runbook_attestation(
|
|
path: Path,
|
|
keys: Sequence[str],
|
|
runbook: Path,
|
|
runbook_name: str,
|
|
now: datetime | None = None,
|
|
) -> RunbookAttestation:
|
|
"""Operatørens bekreftelse på kjøreboka — VALIDERT, ikke bare til stede.
|
|
|
|
Målt 19.09.2026: den gamle parseren godtok ``kjørebok: x``, datoen ``x``, en framtidsdato og
|
|
en peker til en helt annen fil — alle tre ga 2 av 2 GRØNN. Fire ting må stemme nå: fila
|
|
navngir kontraktens kjørebok, sjekksummen er kjørebokas egen (en attestering av en annen
|
|
versjon er ikke en attestering av denne), noen står som den som kjørte den, og datoen er en
|
|
ekte ISO-dato som ikke ligger i framtiden. Datoregelen er v1-gatens egen (``_parse_given``),
|
|
gjenbrukt og ikke skrevet om: en dato med klokkeslett må ha tidssone.
|
|
|
|
BOM tåles (``utf-8-sig``), som på v1-gaten: en editor som skriver den er ikke personens feil."""
|
|
if path.is_symlink():
|
|
return RunbookAttestation(False, False, f"{path.name} er en symlenke, ikke en fil")
|
|
if path.is_dir():
|
|
return RunbookAttestation(False, False, f"{path.name} er en katalog, ikke en fil")
|
|
if not path.is_file():
|
|
return RunbookAttestation(False, False, f"{path} finnes ikke")
|
|
try:
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
except (UnicodeDecodeError, OSError):
|
|
return RunbookAttestation(True, False, f"{path.name} er ikke UTF-8")
|
|
seen: dict[str, str] = {}
|
|
for line in text.splitlines():
|
|
key, sep, value = line.partition(":")
|
|
if not sep:
|
|
continue
|
|
key = key.strip()
|
|
if key not in keys:
|
|
continue
|
|
if key in seen:
|
|
return RunbookAttestation(True, False, f"nøkkelen «{key}» står to ganger")
|
|
seen[key] = value.strip()
|
|
missing = [k for k in keys if not seen.get(k)]
|
|
if missing:
|
|
return RunbookAttestation(True, False, f"mangler {', '.join(missing)}")
|
|
if seen["kjørebok"] != runbook_name:
|
|
return RunbookAttestation(
|
|
True,
|
|
False,
|
|
f"attesterer kjørebok {seen['kjørebok']!r}, ikke kontraktens {runbook_name!r}",
|
|
)
|
|
if not runbook.is_file():
|
|
return RunbookAttestation(True, False, f"kjørebok {runbook_name} finnes ikke å sjekksumme")
|
|
digest = hashlib.sha256(runbook.read_bytes()).hexdigest()
|
|
if seen["sjekksum"].casefold() != digest:
|
|
return RunbookAttestation(
|
|
True,
|
|
False,
|
|
f"sjekksum {seen['sjekksum']!r} er ikke kjørebokas ({digest[:12]}…) — attesteringen "
|
|
"gjelder en annen versjon",
|
|
)
|
|
given, why = _parse_given(seen["dato"])
|
|
if given is None:
|
|
return RunbookAttestation(True, False, why)
|
|
clock = now if now is not None else datetime.now(tz=timezone.utc)
|
|
future = (
|
|
given > clock
|
|
if isinstance(given, datetime)
|
|
else given > clock.astimezone(timezone.utc).date()
|
|
)
|
|
if future:
|
|
return RunbookAttestation(
|
|
True,
|
|
False,
|
|
f"attestert {given.isoformat()}, i framtiden (nå: {clock.isoformat(timespec='seconds')})",
|
|
)
|
|
return RunbookAttestation(True, True, "")
|
|
|
|
|
|
def score_runbook(
|
|
config: Mapping[str, Any],
|
|
repo_root: Path,
|
|
attest: Path | None = None,
|
|
now: datetime | None = None,
|
|
) -> Row:
|
|
runbook = repo_root / str(config["path"])
|
|
has_runbook = runbook.is_file() and bool(runbook.read_text(encoding="utf-8").strip())
|
|
attest_path = attest if attest is not None else repo_root / str(config["attestation"])
|
|
attestation = read_runbook_attestation(
|
|
attest_path, list(config["keys"]), runbook, str(config["path"]), now
|
|
)
|
|
k = int(has_runbook) + int(attestation.ok)
|
|
n = len(config["artefacts"])
|
|
exceptions: list[str] = []
|
|
if not has_runbook:
|
|
exceptions.append(f"kjørebok: {config['path']} finnes ikke")
|
|
if not attestation.ok:
|
|
exceptions.append(f"attestering: {attestation.why}")
|
|
return Row(
|
|
"kjørebok",
|
|
"6 kjøreboka finnes og er kjørt",
|
|
k,
|
|
n,
|
|
GREEN if k == n else NOT_MEASURED,
|
|
"kjøreboka og operatørens bekreftelse står" if k == n else "venter på operatøren",
|
|
exceptions=tuple(exceptions),
|
|
attests=(RUNBOOK_RULE,),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Hele gaten
|
|
# ---------------------------------------------------------------------------------------------
|
|
|
|
|
|
def probe_nodeids(config: Mapping[str, Any]) -> list[str]:
|
|
ids: list[str] = [n for step in config["steps"] for n in step.get("probe", ())]
|
|
ids += [n for nodeids in config["roles"]["evidence"].values() for n in nodeids]
|
|
ids += [
|
|
n for check in config["no_model_calls"]["checks"].values() for n in check.get("probe", ())
|
|
]
|
|
ids += [n for nodeids in config["foundry"]["evidence"].values() for n in nodeids]
|
|
return ids
|
|
|
|
|
|
def evaluate(
|
|
*,
|
|
config: Mapping[str, Any],
|
|
repo_root: Path = _REPO_ROOT,
|
|
src: Path = _PACKAGE_SRC,
|
|
probe_runner: ProbeRunner | None = None,
|
|
attest: Path | None = None,
|
|
now: datetime | None = None,
|
|
) -> list[Row]:
|
|
runner = probe_runner or (lambda ids: run_probes(ids, repo_root))
|
|
outcomes = runner(probe_nodeids(config))
|
|
return [
|
|
score_toolbox(config["steps"], src, config.get("run_path"), outcomes, repo_root),
|
|
score_roles(config["roles"], outcomes, src),
|
|
score_no_claude_path(config["no_claude_path"], repo_root),
|
|
score_no_model_calls(config["no_model_calls"], outcomes, src),
|
|
score_foundry(config["foundry"], outcomes, src),
|
|
score_runbook(config["runbook"], repo_root, attest, now),
|
|
]
|
|
|
|
|
|
def render(rows: Sequence[Row]) -> str:
|
|
out = ["rad | k av N | status | grunn"]
|
|
out += [r.line() for r in rows]
|
|
out.append("")
|
|
for row in rows:
|
|
for attest in row.attests:
|
|
out.append(f" [{row.title.split()[0]}] {attest}.")
|
|
out.append("")
|
|
for row in rows:
|
|
for note in row.diagnostics:
|
|
out.append(f" [{row.title.split()[0]}] {note}")
|
|
if any(r.diagnostics for r in rows):
|
|
out.append("")
|
|
out.append("Unntak fra 100 %:")
|
|
for row in rows:
|
|
for exception in row.exceptions:
|
|
out.append(f" [{row.title.split()[0]}] {exception}")
|
|
return "\n".join(out)
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="python -m portfolio_optimiser.evals.b_gate",
|
|
description="Hvor langt po er fra å være en verktøykasse Claude Code kan drive, rad for "
|
|
"rad. Exit 0 kun når hver fellende rad er grønn, 1 ellers, 2 ved feil bruk. Ingen "
|
|
"modellkall, intet nett.",
|
|
epilog=CONTRACT,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--attest",
|
|
default=None,
|
|
help="operatørens bekreftelse på kjøreboka (default: den gitignorerte stien i kontrakten)",
|
|
)
|
|
parser.add_argument("--json", action="store_true", help="maskinlesbar output")
|
|
args = parser.parse_args(argv)
|
|
if args.attest is not None and not Path(args.attest).is_file():
|
|
parser.error(f"--attest {args.attest} finnes ikke — gaten skriver den aldri selv")
|
|
rows = evaluate(config=load_config(), attest=Path(args.attest) if args.attest else None)
|
|
code = exit_code(rows)
|
|
if args.json:
|
|
print(
|
|
json.dumps(
|
|
{"exit": code, "rows": [asdict(r) for r in rows]}, ensure_ascii=False, indent=2
|
|
)
|
|
)
|
|
else:
|
|
print(render(rows))
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover - dekket av en subprosess-test
|
|
raise SystemExit(main())
|