fix(b-gate): every denominator answers to a source outside the gate, and a name is no longer a door [skip-docs]
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>
This commit is contained in:
parent
332eb5965b
commit
59f35fde22
3 changed files with 734 additions and 221 deletions
|
|
@ -1,5 +1,124 @@
|
|||
{
|
||||
"outcome": "En Claude Code-økt driver en hel analyse med po som verktøykasse, uten at po gjør ett eneste modellkall.",
|
||||
"run_path": {
|
||||
"module": "run.py",
|
||||
"scope": "run_project",
|
||||
"package": "portfolio_optimiser",
|
||||
"held_out": [
|
||||
{
|
||||
"symbol": "admits",
|
||||
"reason": "dimensjonsfilteret inne i mandat-vurderingen; skriver ingen egen artefakt"
|
||||
},
|
||||
{
|
||||
"symbol": "baseline_from_project",
|
||||
"reason": "utleder grunnlaget fra prosjektobjektet inne i «kostnadsgrunnlag»"
|
||||
},
|
||||
{
|
||||
"symbol": "build_mcp_tools",
|
||||
"reason": "bygger verktøylista debatten kaller; hører til modellveien"
|
||||
},
|
||||
{
|
||||
"symbol": "bundle_citations",
|
||||
"reason": "siteringshjelper inne i «kontekst»"
|
||||
},
|
||||
{
|
||||
"symbol": "bundle_excerpt_citations",
|
||||
"reason": "siteringshjelper inne i «kontekst»"
|
||||
},
|
||||
{
|
||||
"symbol": "chunk_dict_to_citation",
|
||||
"reason": "formkonvertering inne i «kontekst»"
|
||||
},
|
||||
{
|
||||
"symbol": "classify_codes",
|
||||
"reason": "klassifiserer avvisningskodene inne i «validering»"
|
||||
},
|
||||
{
|
||||
"symbol": "criteria_block",
|
||||
"reason": "renderer mandatkriteriene inn i prompten; modellveien"
|
||||
},
|
||||
{
|
||||
"symbol": "fresh_workflow",
|
||||
"reason": "krever chatklient: bygger MAF-debatten"
|
||||
},
|
||||
{
|
||||
"symbol": "generate_via_llm",
|
||||
"reason": "krever chatklient: ER modellkallet"
|
||||
},
|
||||
{
|
||||
"symbol": "grounding_offer",
|
||||
"reason": "bygger grunnlagstilbudet til prompten; modellveien"
|
||||
},
|
||||
{
|
||||
"symbol": "load_contracts",
|
||||
"reason": "leser kontraktsfilene før kjøringen; egen dør hører i preflight"
|
||||
},
|
||||
{
|
||||
"symbol": "load_verdicts_from_dir",
|
||||
"reason": "leser tidligere dommer inn i ExpeL-konteksten; inne i «dom»"
|
||||
},
|
||||
{
|
||||
"symbol": "make_retrieval_tool",
|
||||
"reason": "pakker «kontekst» som FunctionTool for agentene; modellveien"
|
||||
},
|
||||
{
|
||||
"symbol": "navigator_tools",
|
||||
"reason": "explore-modusens verktøy; rad 2 avgrenser B til proposer og checker"
|
||||
},
|
||||
{
|
||||
"symbol": "optional_bundle_candidate_features",
|
||||
"reason": "henter valgfrie trekk inne i «verdict-id»"
|
||||
},
|
||||
{
|
||||
"symbol": "proposal_reviews_payload",
|
||||
"reason": "bygger nyttelasten «forslagsvurderinger» skriver"
|
||||
},
|
||||
{
|
||||
"symbol": "requirement_payload",
|
||||
"reason": "explore-modus; utenfor B slik rad 2 avgrenser den"
|
||||
},
|
||||
{
|
||||
"symbol": "resolve_model",
|
||||
"reason": "slår opp deployment-navnet for chatklienten; modellveien"
|
||||
},
|
||||
{
|
||||
"symbol": "tool_call_payload",
|
||||
"reason": "bygger nyttelasten «debatt-verktøy» skriver"
|
||||
},
|
||||
{
|
||||
"symbol": "tool_server_index",
|
||||
"reason": "indekserer MCP-serverne for debatten; modellveien"
|
||||
},
|
||||
{
|
||||
"symbol": "assert_declared_ids_agree",
|
||||
"reason": "konsistenssjekk inne i «pakke»"
|
||||
},
|
||||
{
|
||||
"symbol": "declared_reference_numbers",
|
||||
"reason": "leser de erklærte referansetallene inne i «pakke»"
|
||||
},
|
||||
{
|
||||
"symbol": "load_optional_cost_baseline",
|
||||
"reason": "valgfri overstyring inne i «kostnadsgrunnlag»"
|
||||
},
|
||||
{
|
||||
"symbol": "reconcile_bundle_id",
|
||||
"reason": "binder bundle-id-en inne i «pakke»"
|
||||
},
|
||||
{
|
||||
"symbol": "declaration_of",
|
||||
"reason": "leser prepass-erklæringen inne i «prepass»"
|
||||
},
|
||||
{
|
||||
"symbol": "declaration_payload",
|
||||
"reason": "bygger erklæringens nyttelast inne i «prepass»"
|
||||
},
|
||||
{
|
||||
"symbol": "render_context",
|
||||
"reason": "renderer prepass-konteksten inn i prompten; modellveien"
|
||||
}
|
||||
]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "pakke",
|
||||
|
|
@ -11,10 +130,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "kostnadsgrunnlag",
|
||||
|
|
@ -26,10 +146,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "kontekst",
|
||||
|
|
@ -41,10 +162,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "prepass",
|
||||
|
|
@ -56,10 +178,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "validering",
|
||||
|
|
@ -71,10 +194,11 @@
|
|||
"scope": "evaluate_mandate_candidates"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "verdict-id",
|
||||
|
|
@ -86,10 +210,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "dom",
|
||||
|
|
@ -101,10 +226,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "kjørekonfig",
|
||||
|
|
@ -116,10 +242,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "coverage",
|
||||
|
|
@ -131,10 +258,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "utboks",
|
||||
|
|
@ -146,10 +274,11 @@
|
|||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "rundebinding",
|
||||
|
|
@ -161,10 +290,13 @@
|
|||
"scope": "main"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "module-main",
|
||||
"module": "evals/round_builder.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": [
|
||||
"tests/test_round_builder_loadbearing.py::test_the_command_line_builds_a_round_and_refuses_one_it_cannot"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rapport",
|
||||
|
|
@ -176,10 +308,13 @@
|
|||
"scope": "build_round"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "module-main",
|
||||
"module": "evals/round_builder.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": [
|
||||
"tests/test_round_builder_loadbearing.py::test_the_report_names_every_approach_that_was_considered"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gate",
|
||||
|
|
@ -191,10 +326,77 @@
|
|||
"scope": "main"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "cli",
|
||||
"kind": "module-main",
|
||||
"module": "evals/v1_gate.py",
|
||||
"scope": "main"
|
||||
}
|
||||
},
|
||||
"probe": [
|
||||
"tests/test_v1_gate.py::test_the_command_is_red_today_with_every_row_in_its_output"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prepass-artefakt",
|
||||
"label": "skriv prepassets egen artefakt",
|
||||
"module": "outbox.py",
|
||||
"symbol": "write_prepass",
|
||||
"driver": {
|
||||
"module": "run.py",
|
||||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "parse-feil",
|
||||
"label": "skriv det som ikke lot seg lese",
|
||||
"module": "outbox.py",
|
||||
"symbol": "write_parse_failures",
|
||||
"driver": {
|
||||
"module": "run.py",
|
||||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "forslagsvurderinger",
|
||||
"label": "skriv vurderingene av hvert forslag",
|
||||
"module": "outbox.py",
|
||||
"symbol": "write_proposal_reviews",
|
||||
"driver": {
|
||||
"module": "run.py",
|
||||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
},
|
||||
"probe": []
|
||||
},
|
||||
{
|
||||
"id": "debatt-verktøy",
|
||||
"label": "skriv verktøyene debatten kalte",
|
||||
"module": "outbox.py",
|
||||
"symbol": "write_debate_tools",
|
||||
"driver": {
|
||||
"module": "run.py",
|
||||
"scope": "run_project"
|
||||
},
|
||||
"entry": {
|
||||
"kind": "console-script",
|
||||
"module": "run.py",
|
||||
"scope": "main"
|
||||
},
|
||||
"probe": []
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
|
|
@ -214,19 +416,20 @@
|
|||
}
|
||||
},
|
||||
"no_claude_path": {
|
||||
"roots": [
|
||||
"src",
|
||||
"scripts",
|
||||
"shared",
|
||||
"docs",
|
||||
"tests",
|
||||
"pyproject.toml",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"DEPLOY.md",
|
||||
"CONTRIBUTING.md",
|
||||
"SECURITY.md"
|
||||
],
|
||||
"manifest": {
|
||||
"sentinel": "src/portfolio_optimiser/run.py",
|
||||
"skip_dirs": [
|
||||
".git",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".mypy_cache",
|
||||
"node_modules",
|
||||
".idea",
|
||||
".vscode"
|
||||
]
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"id": "prosess",
|
||||
|
|
@ -269,20 +472,59 @@
|
|||
"pattern_b64": "KEFOVEhST1BJQ19CQVNFX1VSTHxBTlRIUk9QSUNfQVBJX0tFWXxhcGlcLmFudGhyb3BpY1wuY29tKQ==",
|
||||
"known_positive_b64": "b3MuZW52aXJvblsiQU5USFJPUElDX0JBU0VfVVJMIl0gPSBlbmRwb2ludA==",
|
||||
"known_negative_b64": "b3MuZW52aXJvblsiQVpVUkVfT1BFTkFJX0VORFBPSU5UIl0gPSBlbmRwb2ludA=="
|
||||
},
|
||||
{
|
||||
"id": "argv-literal",
|
||||
"label": "claude som programnavn i en argumentliste",
|
||||
"pattern_b64": "W1xbKCxdXHMqWyInXSg/OlteIidcc10qLyk/Y2xhdWRlWyInXQ==",
|
||||
"known_positive_b64": "Y21kID0gWyJjbGF1ZGUiLCAiLXAiLCAiYW5hbHlzZXIgcHJvc2pla3RldCJd",
|
||||
"known_negative_b64": "bW9kZWxsZXIgPSBbImNsYXVkZS1vcHVzLTUiLCAiZ3B0LTUiXQ=="
|
||||
},
|
||||
{
|
||||
"id": "kommando-streng",
|
||||
"label": "claude med flagg inne i en streng",
|
||||
"pattern_b64": "WyInXSg/OlteIidcc10qLyk/Y2xhdWRlXHMrLXsxLDJ9W0EtWmEtel0=",
|
||||
"known_positive_b64": "c3VicHJvY2Vzcy5ydW4oc2hsZXguc3BsaXQoImNsYXVkZSAtcCBhbmFseXNlciIpKQ==",
|
||||
"known_negative_b64": "c3VicHJvY2Vzcy5ydW4oc2hsZXguc3BsaXQoInB5dGhvbiAtbSBwb3J0Zm9saW9fb3B0aW1pc2VyLnJ1biBQMSIpKQ=="
|
||||
},
|
||||
{
|
||||
"id": "binaersti",
|
||||
"label": "en konstant som navngir claude-binaeren",
|
||||
"pattern_b64": "PVxzKlsiJ10oPzpbXiInXHNdKi8pP2NsYXVkZVsiJ10=",
|
||||
"known_positive_b64": "Q0xBVURFX0JJTiA9ICIvdXNyL2xvY2FsL2Jpbi9jbGF1ZGUi",
|
||||
"known_negative_b64": "TU9ERUxMID0gImNsYXVkZS1vcHVzLTUi"
|
||||
}
|
||||
]
|
||||
},
|
||||
"no_model_calls": {
|
||||
"checks": {
|
||||
"fullfører": [
|
||||
"tests/test_b_toolbox_probes.py::test_the_whole_run_completes_with_the_chat_client_raising"
|
||||
],
|
||||
"rundekatalog": [
|
||||
"tests/test_b_toolbox_probes.py::test_the_toolbox_outbox_binds_to_a_scratch_round_directory"
|
||||
],
|
||||
"gate-form-ok": [
|
||||
"tests/test_b_toolbox_probes.py::test_v1_gate_reads_that_round_as_form_ok_not_proven"
|
||||
]
|
||||
"fullfører": {
|
||||
"source": {
|
||||
"module": "run.py",
|
||||
"symbol": "run_project"
|
||||
},
|
||||
"probe": [
|
||||
"tests/test_b_toolbox_probes.py::test_the_whole_run_completes_with_the_chat_client_raising"
|
||||
]
|
||||
},
|
||||
"rundekatalog": {
|
||||
"source": {
|
||||
"module": "evals/round_builder.py",
|
||||
"symbol": "build_round"
|
||||
},
|
||||
"probe": [
|
||||
"tests/test_b_toolbox_probes.py::test_the_toolbox_outbox_binds_to_a_scratch_round_directory"
|
||||
]
|
||||
},
|
||||
"gate-form-ok": {
|
||||
"source": {
|
||||
"module": "evals/v1_gate.py",
|
||||
"symbol": "FORM_OK"
|
||||
},
|
||||
"probe": [
|
||||
"tests/test_b_toolbox_probes.py::test_v1_gate_reads_that_round_as_form_ok_not_proven"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"foundry": {
|
||||
|
|
@ -310,10 +552,15 @@
|
|||
}
|
||||
},
|
||||
"runbook": {
|
||||
"artefacts": [
|
||||
"kjørebok",
|
||||
"attestering"
|
||||
],
|
||||
"path": "docs/kjoerebok-verktoeykassen.md",
|
||||
"attestation": "v1-rounds/kjoerebok-attestering.txt",
|
||||
"keys": [
|
||||
"kjørebok",
|
||||
"sjekksum",
|
||||
"kjørt",
|
||||
"dato"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -27,9 +27,12 @@ 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
|
||||
|
|
@ -39,6 +42,7 @@ from portfolio_optimiser.evals.v1_gate import (
|
|||
NOT_MEASURED,
|
||||
RED,
|
||||
Row,
|
||||
_parse_given,
|
||||
exit_code,
|
||||
run_probes,
|
||||
)
|
||||
|
|
@ -75,13 +79,18 @@ CHAT_CLIENT_NAMES = frozenset(
|
|||
|
||||
#: Rad 1: hvilken dør som teller, og hvorfor den ble valgt framfor den andre.
|
||||
EXTERNAL_DOOR = (
|
||||
"valgt CLI-underkommando som dør UT av verktøykassen, fordi po allerede har fem main() og to "
|
||||
"konsoll-kommandoer, og operatørens ord er «som alle andre tools Claude Code kan bruke» — en "
|
||||
"kommando er nettopp det. Grunnen til å velge bort MCP her er at MCP ville krevd en server po "
|
||||
"ikke har i kjørestien i dag; gaten teller likevel en MCP-registrert dør som kallbar, så "
|
||||
"valget binder ikke neste ordre"
|
||||
"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 "
|
||||
|
|
@ -123,37 +132,6 @@ finnes er RØD, aldri grønn og aldri hoppet over.
|
|||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# STUBBER (rød-først). Navnene finnes her for at armene under skal felle på en ASSERT om ATFERD
|
||||
# og ikke ved innsamling; verdiene er MED VILJE gale og erstattes i reparasjons-commiten.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
#: Inngangsartene gaten kan VERIFISERE. Tom her med vilje: stubben kjenner ingen.
|
||||
ENTRY_KINDS: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def run_path_calls(src: Path, spec: Mapping[str, Any]) -> dict[str, str]:
|
||||
"""STUB: skal lese kjørestiens faktiske kall av KILDEN. Returnerer ingenting ennå."""
|
||||
return {}
|
||||
|
||||
|
||||
def console_scripts(repo_root: Path) -> dict[str, str]:
|
||||
"""STUB: skal lese [project.scripts] fra pyproject.toml."""
|
||||
return {}
|
||||
|
||||
|
||||
def registered_entry(
|
||||
src: Path, repo_root: Path, package: str, entry: Mapping[str, Any]
|
||||
) -> tuple[bool, str]:
|
||||
"""STUB: godtar ALT som dør — nøyaktig feilen reparasjonen skal fjerne."""
|
||||
return True, ""
|
||||
|
||||
|
||||
def published_files(root: Path, manifest: Mapping[str, Any]) -> tuple[list[Path], str]:
|
||||
"""STUB: skal utlede den publiserte flaten av repo-manifestet."""
|
||||
return [], "stub"
|
||||
|
||||
|
||||
def load_config(path: Path = _DATA) -> dict[str, Any]:
|
||||
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data
|
||||
|
|
@ -285,7 +263,148 @@ def entry_reaches(src: Path, entry: Mapping[str, Any], symbol: str) -> tuple[boo
|
|||
return True, ""
|
||||
|
||||
|
||||
def measure_steps(steps: Sequence[Mapping[str, Any]], src: Path) -> list[Step]:
|
||||
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"]))
|
||||
|
|
@ -299,7 +418,16 @@ def measure_steps(steps: Sequence[Mapping[str, Any]], src: Path) -> list[Step]:
|
|||
why = f"{step['driver']['module']}::{step['driver']['scope']} kaller ikke {step['symbol']}"
|
||||
else:
|
||||
ok, why = entry_reaches(src, step["entry"], str(step["symbol"]))
|
||||
why = "" if ok else why
|
||||
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
|
||||
|
|
@ -312,18 +440,28 @@ def score_toolbox(
|
|||
outcomes: Mapping[str, str] | None = None,
|
||||
repo_root: Path = _REPO_ROOT,
|
||||
) -> Row:
|
||||
measured = measure_steps(steps, src)
|
||||
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]
|
||||
undeclared = sorted(
|
||||
name
|
||||
for name in (run_path_calls(src, run_path) if run_path else {})
|
||||
if name not in {str(s["symbol"]) for s in steps}
|
||||
and name not in {str(h["symbol"]) for h in (run_path or {}).get("held_out", ())}
|
||||
)
|
||||
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 kjørestien uten å være et steg" for name in undeclared
|
||||
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",
|
||||
|
|
@ -331,8 +469,10 @@ def score_toolbox(
|
|||
k,
|
||||
n,
|
||||
GREEN if n and k == n else RED,
|
||||
f"{k} av {n} steg har en dør ut; {len(measured) - n} erklærte steg er ikke i kjørestien",
|
||||
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,),
|
||||
)
|
||||
|
||||
|
|
@ -397,30 +537,55 @@ def score_roles(roles: Mapping[str, Any], outcomes: Mapping[str, str], src: Path
|
|||
# Rad 3 — po har ingen vei til Claude
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
#: Flatene som publiseres, lest som tekst. Alt annet er binært og bærer ingen kjørbar linje.
|
||||
TEXT_SUFFIXES = frozenset(
|
||||
{".py", ".sh", ".toml", ".json", ".md", ".txt", ".cfg", ".ini", ".yaml", ".yml", ".template"}
|
||||
)
|
||||
_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 surface_files(root: Path, roots: Sequence[str]) -> list[Path]:
|
||||
found: list[Path] = []
|
||||
for rel in roots:
|
||||
base = root / rel
|
||||
if base.is_file():
|
||||
found.append(base)
|
||||
continue
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for path in sorted(base.rglob("*")):
|
||||
if not path.is_file() or path.is_symlink():
|
||||
continue
|
||||
if "__pycache__" in path.parts or ".git" in path.parts:
|
||||
continue
|
||||
if path.suffix in TEXT_SUFFIXES:
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
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]]:
|
||||
|
|
@ -451,17 +616,25 @@ class Pattern:
|
|||
hits: tuple[str, ...]
|
||||
|
||||
|
||||
def measure_patterns(config: Mapping[str, Any], root: Path) -> list[Pattern]:
|
||||
@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]]]] = []
|
||||
surface = (
|
||||
surface_files(root, list(config["roots"]))
|
||||
if "roots" in config
|
||||
else published_files(root, config.get("manifest", {}))[0]
|
||||
)
|
||||
for path in surface:
|
||||
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] = []
|
||||
|
|
@ -482,18 +655,14 @@ def measure_patterns(config: Mapping[str, Any], root: Path) -> list[Pattern]:
|
|||
if rx.search(line)
|
||||
)
|
||||
measured.append(Pattern(str(spec["id"]), str(spec["label"]), not why, why, hits))
|
||||
return measured
|
||||
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 = measure_patterns(config, root)
|
||||
measured, surface = measure_patterns(config, root)
|
||||
n = len(measured)
|
||||
k = sum(1 for p in measured if p.valid and not p.hits)
|
||||
files = (
|
||||
len(surface_files(root, list(config["roots"])))
|
||||
if "roots" in config
|
||||
else len(published_files(root, config.get("manifest", {}))[0])
|
||||
)
|
||||
exceptions: list[str] = []
|
||||
for pattern in measured:
|
||||
if not pattern.valid:
|
||||
|
|
@ -501,14 +670,23 @@ def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
|
|||
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,
|
||||
GREEN if n and k == n else RED,
|
||||
f"{k} av {n} vakter måler og gir null treff over {files} publiserte filer",
|
||||
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,),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -520,18 +698,35 @@ def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
|
|||
def score_no_model_calls(
|
||||
config: Mapping[str, Any], outcomes: Mapping[str, str], src: Path | None = None
|
||||
) -> Row:
|
||||
checks = config["checks"]
|
||||
verdicts = {name: _probe_verdict(list(ids), outcomes) for name, ids in checks.items()}
|
||||
k = sum(1 for why in verdicts.values() if not why)
|
||||
n = len(verdicts)
|
||||
"""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",
|
||||
exceptions=tuple(f"{name}: {why}" for name, why in verdicts.items() if why),
|
||||
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,),
|
||||
)
|
||||
|
||||
|
|
@ -582,12 +777,18 @@ def foundry_intact(config: Mapping[str, Any], src: Path) -> tuple[bool, tuple[st
|
|||
|
||||
|
||||
def score_foundry(config: Mapping[str, Any], outcomes: Mapping[str, str], src: Path) -> Row:
|
||||
intact, missing = foundry_intact(config, src)
|
||||
"""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 = int(intact) + sum(1 for why in verdicts.values() if not why)
|
||||
n = 1 + len(verdicts)
|
||||
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(
|
||||
|
|
@ -596,7 +797,8 @@ def score_foundry(config: Mapping[str, Any], outcomes: Mapping[str, str], src: P
|
|||
k,
|
||||
n,
|
||||
GREEN if k == n else RED,
|
||||
"profil, fabrikk og injeksjonssøm står" if intact else "Foundry-veien er rørt",
|
||||
f"{structural - len(missing)} av {structural} strukturkrav står"
|
||||
+ ("" if not missing else " — Foundry-veien er rørt"),
|
||||
exceptions=tuple(exceptions),
|
||||
)
|
||||
|
||||
|
|
@ -613,7 +815,23 @@ class RunbookAttestation:
|
|||
why: str
|
||||
|
||||
|
||||
def read_runbook_attestation(path: Path, keys: Sequence[str]) -> RunbookAttestation:
|
||||
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():
|
||||
|
|
@ -621,7 +839,7 @@ def read_runbook_attestation(path: Path, keys: Sequence[str]) -> RunbookAttestat
|
|||
if not path.is_file():
|
||||
return RunbookAttestation(False, False, f"{path} finnes ikke")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
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] = {}
|
||||
|
|
@ -638,6 +856,37 @@ def read_runbook_attestation(path: Path, keys: Sequence[str]) -> RunbookAttestat
|
|||
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, "")
|
||||
|
||||
|
||||
|
|
@ -645,13 +894,16 @@ def score_runbook(
|
|||
config: Mapping[str, Any],
|
||||
repo_root: Path,
|
||||
attest: Path | None = None,
|
||||
now: Any | 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"]))
|
||||
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")
|
||||
|
|
@ -661,9 +913,9 @@ def score_runbook(
|
|||
"kjørebok",
|
||||
"6 kjøreboka finnes og er kjørt",
|
||||
k,
|
||||
2,
|
||||
GREEN if k == 2 else NOT_MEASURED,
|
||||
"kjøreboka og operatørens bekreftelse står" if k == 2 else "venter på operatøren",
|
||||
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,),
|
||||
)
|
||||
|
|
@ -675,13 +927,12 @@ def score_runbook(
|
|||
|
||||
|
||||
def probe_nodeids(config: Mapping[str, Any]) -> list[str]:
|
||||
ids: list[str] = []
|
||||
for group in (
|
||||
config["roles"]["evidence"],
|
||||
config["no_model_calls"]["checks"],
|
||||
config["foundry"]["evidence"],
|
||||
):
|
||||
ids += [n for nodeids in group.values() for n in nodeids]
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -692,16 +943,17 @@ def evaluate(
|
|||
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),
|
||||
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),
|
||||
score_no_model_calls(config["no_model_calls"], outcomes, src),
|
||||
score_foundry(config["foundry"], outcomes, src),
|
||||
score_runbook(config["runbook"], repo_root, attest),
|
||||
score_runbook(config["runbook"], repo_root, attest, now),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -713,6 +965,11 @@ def render(rows: Sequence[Row]) -> str:
|
|||
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue