"""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 ()`` 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 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())