fix(b-gate): a probe is bound to its step, a reason cannot be empty, and the row's limit is what was measured [skip-docs]

Row 1 goes 3 of 17 -> 0 of 17. Nothing was removed from the product and no row
changed colour; the three that counted stopped counting because the contract they
satisfied was satisfiable without capability.

BEARING 1 -- the probe is bound to the step, and the binding is MEASURED in the
probe's own source. Chosen: read the probe (ast) rather than demand it live in a
contract-named file, because a file name is a convention a stub meets as easily as a
real probe. Three traits, each measured: it drives the DOOR (the dotted module or the
registered command name appears as a string it uses -- anywhere but a docstring,
because the honest form assembles argv in a variable first), it names the STEP (the
symbol, id or subcommand as a whole WORD in what it passes INTO a call or calls), and
it asserts at all. A probe claimed by two steps proves at most one and the gate cannot
tell which -- so neither.

Two traps found while measuring, both closed:
  - substring: "gate" is not named by portfolio_optimiser.evals.v1_gate
  - local alias: the first cut accepted step `gate` because the probe file imports the
    module AS `gate`. Names are therefore read only where they are sent or called.

What that costs, measured against the contract that stands:
  rundebinding  drives the door, names no step         (was green)
  rapport       never goes through the door at all     (was green)
  gate          drives the door, names no step         (was green)

BEARING 2 -- the limit statement said exactly TWO ways remained; the checkpoint
planted 21 call forms and measured SIX. Four are closed with a guard each (the
official Python SDK in both spellings, the node and uv runners, a dynamic import);
three remain and are now named: a runtime-composed name, a name from an environment
variable, a base64-decoded name. Left open deliberately -- the encodings are not
enumerable and our own contract stores base64 by design. Row 3: 9 of 9 -> 12 of 12,
still GREEN, 0 hits over 512 files. One of the three caught a command written in this
round's own test docstring; it was rewritten, not exempted.

BEARING 3 -- held_out accepted an EMPTY reason and shrank the denominator, while the
summary said "held out with a reason" either way. A blank reason is no reason: the
symbol stays in the denominator as a call without a door, the summary counts reasons,
and the four steps declared OUTSIDE the run path are now named one by one as having no
derived source instead of being counted in silence.

Five small rests, closed: a pruned manifest (451 of 512 was still GREEN) is now NOT
MEASURED, one sentinel per area the old handlist missed; a non-UTF-8 file is read as
byte text instead of counted and skipped; a symlink out of the tree is named and
fails the row; a runbook whose whole content is "x" no longer passes, the contract
names its sections; and the row states that its ratio is not a coverage measure.

Two stated, not closed, each with its reason in the row's own attestation: a po call
moved one floor down into a helper leaves the denominator (following helpers would
pull private ones in and make the denominator the curated list this row exists to
avoid), and row 3's k/n can still be padded by a guard with no measured escape behind
it. The ledger's two precision errors are corrected: "no row became greener" is true
of colour, not of numbers, and ENTRY_KINDS has three arts, not four.

Suite: 2172 passed, 5 skipped, 5 xfailed in 645 s. ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 21:48:44 +02:00
commit 9eec31b7da
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 441 additions and 73 deletions

View file

@ -417,7 +417,13 @@
},
"no_claude_path": {
"manifest": {
"sentinel": "src/portfolio_optimiser/run.py",
"sentinels": [
"src/portfolio_optimiser/run.py",
"main.py",
"examples/ingest-golden-file/expected-bundle/index.md",
"spikes/__init__.py",
"contexts/dekke-og-kontrakt-lindaas-2027/bundle.txt"
],
"skip_dirs": [
".git",
"__pycache__",
@ -493,6 +499,27 @@
"pattern_b64": "PVxzKlsiJ10oPzpbXiInXHNdKi8pP2NsYXVkZVsiJ10=",
"known_positive_b64": "Q0xBVURFX0JJTiA9ICIvdXNyL2xvY2FsL2Jpbi9jbGF1ZGUi",
"known_negative_b64": "TU9ERUxMID0gImNsYXVkZS1vcHVzLTUi"
},
{
"id": "sdk-anthropic",
"label": "import av den offisielle Anthropic-SDK-en",
"pattern_b64": "XlxzKihmcm9tfGltcG9ydClccythbnRocm9waWNcYg==",
"known_positive_b64": "aW1wb3J0IGFudGhyb3BpYw==",
"known_negative_b64": "aW1wb3J0IGFudGhyb3BpY19ub3Rlcw=="
},
{
"id": "node-runner",
"label": "claude-code startet av en node- eller uv-kjoerer",
"pattern_b64": "KG5weHx1dnh8YnVueHxwbnBtXHMrZGx4KVxiWyInXHMsXSsoLXsxLDJ9XHdbXHctXSpbIidccyxdKykqWyInXT8oQGFudGhyb3BpYy1haS8pP2NsYXVkZS1jb2RlXGI=",
"known_positive_b64": "bnB4IEBhbnRocm9waWMtYWkvY2xhdWRlLWNvZGUgLXAgImFuYWx5c2VyIg==",
"known_negative_b64": "bnB4IHByZXR0aWVyIC0td3JpdGUgLg=="
},
{
"id": "dynamisk-import",
"label": "dynamisk import av en Claude-klient",
"pattern_b64": "X19pbXBvcnRfX1woXHMqWyInXShjbGF1ZGVfYWdlbnRfc2RrfGNsYXVkZV9jb2RlX3Nka3xhbnRocm9waWMpWyInXQ==",
"known_positive_b64": "X19pbXBvcnRfXygiY2xhdWRlX2FnZW50X3NkayIp",
"known_negative_b64": "X19pbXBvcnRfXygiYWdlbnRfZnJhbWV3b3JrIik="
}
]
},
@ -557,6 +584,13 @@
"attestering"
],
"path": "docs/kjoerebok-verktoeykassen.md",
"sections": [
"Forutsetninger",
"Kommandoene",
"En hel analyse, steg for steg",
"Naar noe feiler",
"Hva gaten ikke beviser"
],
"attestation": "v1-rounds/kjoerebok-attestering.txt",
"keys": [
"kjørebok",

View file

@ -83,8 +83,25 @@ EXTERNAL_DOOR = (
"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"
"argparse). En art gaten ikke kan verifisere er ikke en dør her. Og et navn er uansett ikke "
"nok: PROBENS EGEN KILDE leses, og tre trekk måles i den — at proben rører DØREN (dens "
"dotted modulnavn eller dens registrerte kommandonavn står i et kall der), at den navngir "
"STEGET (symbolet, id-en eller underkommandoen som et eget ord), og at den asserter i det "
"hele tatt. En probe som er DELT mellom to steg beviser høyst ett av dem, og gaten vet ikke "
"hvilket — altså ingen. Valgt å lese probens kilde framfor å kreve at den ligger i en "
"kontrakt-navngitt fil, fordi et filnavn er en konvensjon en stub oppfyller like lett som en "
"ekte probe; målt 19.09 tok fjorten stubber og ÉN urelatert bestått test raden til 17 av 17"
)
#: Rad 1: grensen for hvordan nevneren leses, sagt av raden selv.
RUN_PATH_LIMIT = (
"rad 1s nevner leses LEKSIKALSK i run_project selv, ikke gjennom hjelperne den kaller: et "
"po-kall flyttet én etasje ned, til en hjelper i samme modul, faller ut av nevneren (målt "
"19.09: 41 → 40). Gaten NAVNGIR at symbolet ikke lenger kalles i kjørestien, men feller det "
"ikke. Valgt å uttale grensen framfor å følge hjelpere transitivt, fordi et kallgraf-søk "
"ville trukket private hjelpere inn i nevneren og gjort den til en ny kuratert liste — den "
"ene tingen raden finnes for å unngå. Stegene som er erklært UTENFOR kjørestien har ingen "
"utledet nevner i det hele tatt; de står navngitt under raden, ett for ett"
)
#: Inngangsartene rad 1 kan VERIFISERE mot kilden. En art utenfor lista teller aldri som en dør:
@ -356,6 +373,200 @@ def registered_entry(
return True, ""
_TOKEN = re.compile(r"[\w-]+")
def _meaningful(node: ast.AST) -> list[ast.AST]:
"""Hver node i kroppen UNNTATT strenger som står alene — docstringen er ikke en atferd."""
stack = [node]
out: list[ast.AST] = []
while stack:
current = stack.pop()
if (
isinstance(current, ast.Expr)
and isinstance(current.value, ast.Constant)
and isinstance(current.value.value, str)
):
continue
out.append(current)
stack += list(ast.iter_child_nodes(current))
return out
def door_strings(nodes: Sequence[ast.AST]) -> set[str]:
"""Strengene proben bruker, docstringen unntatt.
Videre enn ``call_payload`` med vilje, og grunnen er målt: den ekte probe-formen bygger
kommandoen i en variabel først (``base = [sys.executable, "-m", "<modul>"]``) og sender den
inn etterpå. Et dotted modulnavn er spesifikt at det ER døren uansett hvor det settes
sammen det er nettopp den formen rad 3 selv har en egen vakt for."""
found: set[str] = set()
for node in nodes:
for child in _meaningful(node):
if isinstance(child, ast.Constant) and isinstance(child.value, str):
found.add(child.value)
return found
def call_payload(nodes: Sequence[ast.AST]) -> tuple[set[str], set[str]]:
"""(ord, hele strenger) proben sender INN i et kall, pluss navnene den kaller.
To avgrensninger, begge målt fram:
* **Bare det som går inn i et kall.** En docstring som nevner symbolet, eller en kommentar
som navngir modulen, er ikke en atferd. Leses hele funksjonskroppen, er «nevner» nok og
da er et navn en dør igjen, bare ett hakk lenger inne.
* **Delt i ORD, aldri delstreng.** «gate» er ikke nevnt av
``portfolio_optimiser.evals.v1_gate``. Målt 19.09: en tidligere utgave av denne regelen
godtok steget «gate» fordi probefila importerte modulen som ``gate`` den bandt til et
lokalt alias, ikke til steget."""
words: set[str] = set()
constants: set[str] = set()
for node in nodes:
for call in ast.walk(node):
if not isinstance(call, ast.Call):
continue
func = call.func
if isinstance(func, ast.Name):
words.add(func.id)
elif isinstance(func, ast.Attribute):
words.add(func.attr)
for argument in [*call.args, *[k.value for k in call.keywords]]:
for child in ast.walk(argument):
if isinstance(child, ast.Constant) and isinstance(child.value, str):
constants.add(child.value)
words |= set(_TOKEN.findall(child.value))
elif isinstance(child, ast.Name):
words.add(child.id)
elif isinstance(child, ast.Attribute):
words.add(child.attr)
return words, constants
def step_needles(step: Mapping[str, Any]) -> set[str]:
"""Ordene som skiller DETTE steget fra de andre bak samme dør."""
needles = {str(step["symbol"]), str(step["id"])}
command = str(dict(step.get("entry", {})).get("command", ""))
if command:
needles.add(command)
return needles
def drives_door(
tree: ast.AST,
nodes: Sequence[ast.AST],
entry: Mapping[str, Any],
dotted: str,
repo_root: Path,
) -> bool:
"""Proben rører døren — MÅLT i probens egen kilde, på én av to måter.
Som subprosess: dørens dotted modulnavn (``python -m <modul>``) eller dens registrerte
kommandonavn står som en streng et kall bruker. Eller i prosess: inngangen er importert fra
dørens egen modul og kalt. Alt annet er en test som består et annet sted."""
scope = str(entry.get("scope", "main"))
names = {dotted}
if str(entry.get("kind", "")) == "console-script":
target = f"{dotted}:{scope}"
names |= {name for name, value in console_scripts(repo_root).items() if value == target}
if names & door_strings(nodes):
return True
parent, _, last = dotted.rpartition(".")
aliases: set[str] = set()
imported = False
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
module = node.module or ""
if module == parent:
aliases |= {a.asname or a.name for a in node.names if a.name == last}
elif module == dotted:
imported = imported or any(a.name == scope for a in node.names)
elif isinstance(node, ast.Import):
aliases |= {
a.asname or a.name.rpartition(".")[2] for a in node.names if a.name == dotted
}
for node in nodes:
for call in ast.walk(node):
if not isinstance(call, ast.Call):
continue
func = call.func
if (
isinstance(func, ast.Attribute)
and func.attr == scope
and isinstance(func.value, ast.Name)
and func.value.id in aliases
):
return True
if isinstance(func, ast.Name) and func.id == scope and imported:
return True
return False
def probe_binds(
repo_root: Path,
step: Mapping[str, Any],
nodeid: str,
package: str = "portfolio_optimiser",
) -> tuple[bool, str]:
"""(proben beviser DETTE steget, grunnen når den ikke gjør det).
Målt 19.09.2026 (N2/N2b): gaten slo opp om nodeid-en var ``passed`` og godtok den. En
registrert stub-dør med en probe pekt en vilkårlig grønn test tok rad 1 fra 3 til 4 av 17
og gjort for alle fjorten røde steg: 17 av 17 GRØNN, uten én linje kapabilitet. Bindingen
er derfor en MÅLING i probens kilde, ikke en erklæring i kontrakten."""
file_part, separator, rest = nodeid.partition("::")
if not separator or not rest:
return False, f"proben {nodeid} navngir ingen test"
path = repo_root / file_part
if not path.is_file():
return False, f"probefila {file_part} finnes ikke"
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (OSError, SyntaxError, UnicodeDecodeError):
return False, f"probefila {file_part} lar seg ikke lese som kilde"
name = rest.rpartition("::")[2]
functions = _functions(tree)
if name not in functions:
return False, f"proben {name} finnes ikke i {file_part}"
nodes = [functions[n] for n in sorted(_reachable(functions, name))]
if not any(isinstance(x, ast.Assert) for node in nodes for x in ast.walk(node)):
return False, f"proben {name} asserter ingenting — da måler den ikke"
dotted = _dotted(package, str(step["entry"]["module"]))
if not drives_door(tree, nodes, step["entry"], dotted, repo_root):
return False, f"proben {name} rører ikke døren ({dotted})"
needles = step_needles(step)
words, _constants = call_payload(nodes)
if not needles & words:
return False, f"proben {name} navngir ikke steget ({', '.join(sorted(needles))})"
return True, ""
def shared_probes(steps: Sequence[Mapping[str, Any]]) -> dict[str, list[str]]:
"""Nodeid-er mer enn ett steg gjør krav på. En probe beviser ett steg, eller ingen."""
claimed: dict[str, set[str]] = {}
for step in steps:
for nodeid in step.get("probe", ()):
claimed.setdefault(str(nodeid), set()).add(str(step["id"]))
return {nodeid: sorted(ids) for nodeid, ids in claimed.items() if len(ids) > 1}
def _binding_why(
step: Mapping[str, Any],
probes: Sequence[str],
repo_root: Path,
package: str,
shared: Mapping[str, Sequence[str]],
) -> str:
for nodeid in probes:
if nodeid in shared:
others = ", ".join(i for i in shared[nodeid] if i != str(step["id"]))
return f"atferdsprobe: delt med {others} — en probe beviser ett steg, eller ingen"
ok, why = probe_binds(repo_root, step, nodeid, package)
if not ok:
return f"atferdsprobe: {why}"
return ""
def run_path_calls(src: Path, spec: Mapping[str, Any]) -> dict[str, str]:
"""``symbol -> modulen det kom fra``: po-funksjonene kjørestien FAKTISK kaller.
@ -406,6 +617,7 @@ def measure_steps(
package: str = "portfolio_optimiser",
) -> list[Step]:
measured: list[Step] = []
shared = shared_probes(steps)
for step in steps:
tree = _tree(src, str(step["module"]))
resolved = tree is not None and _defined(tree, str(step["symbol"]))
@ -421,13 +633,14 @@ def measure_steps(
if ok:
ok, why = registered_entry(src, repo_root, package, step["entry"])
if ok:
probes = list(step.get("probe", ()))
probes = [str(n) for n in 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 "")
)
if not probes:
why = "ingen atferdsprobe registrert — et navn er ikke en atferd"
elif verdict:
why = f"atferdsprobe: {verdict}"
else:
why = _binding_why(step, probes, repo_root, package, shared)
external = resolved and called and not why
measured.append(Step(str(step["id"]), str(step["label"]), resolved, called, external, why))
return measured
@ -444,25 +657,41 @@ def score_toolbox(
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 = {
claimed = {
str(h["symbol"]): str(h.get("reason", "")) for h in (run_path or {}).get("held_out", ())
}
#: Målt 19.09 (N4/N5): et steg flyttet til «held_out» tok nevneren 17 → 16 MED ELLER UTEN
#: grunn, og sammendraget sa «med grunn» uansett. En tom grunn er ingen grunn: symbolet blir
#: stående i nevneren som et kall uten dør, nøyaktig som et udeklarert kall.
held = {symbol: reason for symbol, reason in claimed.items() if reason.strip()}
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"
(
f"holdt utenfor UTEN grunn: {name} kalles i {calls[name]} fra kjørestien og teller "
"i nevneren"
if name in claimed
else 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())
for symbol, reason in sorted(claimed.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
)
driver = (str((run_path or {}).get("module", "")), str((run_path or {}).get("scope", "")))
off_path = tuple(
f"utenfor kjørestien: {s['id']} (driver {s['driver']['module']}::{s['driver']['scope']}) "
"— uten utledet kilde, kuratert i kontrakten"
for s in steps
if run_path and (str(s["driver"]["module"]), str(s["driver"]["scope"])) != driver
)
return Row(
"verktøykasse",
"1 steg i kjørestien kallbare utenfra",
@ -470,10 +699,10 @@ def score_toolbox(
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",
f"{len(off_path)} erklærte steg er utenfor kjørestien",
exceptions=exceptions,
diagnostics=diagnostics,
attests=(EXTERNAL_DOOR,),
diagnostics=diagnostics + off_path,
attests=(EXTERNAL_DOOR, RUN_PATH_LIMIT),
)
@ -539,19 +768,73 @@ def score_roles(roles: Mapping[str, Any], outcomes: Mapping[str, str], src: Path
_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.
#: Rad 3: hva vakten IKKE rekker, sagt av raden selv — og ETTERPRØVD. Erklæringen oppga to
#: gjenstående veier; PM plantet 21 kallformer 19.09 og MÅLTE at seks slapp. De fire billigste
#: (den offisielle SDK-en i to skriveformer, node- og uv-kjørerne, det dynamiske import-kallet)
#: er lukket med hver sin vakt; de tre som står igjen står her, navngitt.
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"
"konstant, en shell-streng, den offisielle Python-SDK-en, en node- eller uv-kjører som "
"starter kommandoen, og et dynamisk import-kall. TRE veier står igjen og kan ikke felles "
"linje for linje: et kommandonavn satt sammen av deler ved kjøretid, et navn lest ut av en "
"miljøvariabel, og et navn som er base64-kodet eller på annen måte avkodet først. 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. k av n er ikke et dekningsmål: det teller vakter som MÅLER "
"(hver med kjent-positiv og kjent-negativ), aldri hvor stor del av angrepsflaten de dekker "
"— en vakt lagt til uten en målt omgåelse bak seg hever både k og n og beviser ingenting"
)
def _published_paths(root: Path, manifest: Mapping[str, Any]) -> tuple[list[Path], str]:
"""(hver publisert sti, symlenker inkludert, hvor lista kom fra)."""
skip = set(manifest.get("skip_dirs", (".git", "__pycache__")))
if (root / ".git").exists():
try:
listing: str | None = 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_symlink() or p.is_file()], "git-manifestet"
walked = [
path
for path in sorted(root.rglob("*"))
if (path.is_symlink() or path.is_file()) and not (skip & set(path.relative_to(root).parts))
]
return walked, "filtreet (uttrekk uten git-metadata)"
def escaping_symlinks(root: Path, manifest: Mapping[str, Any]) -> tuple[str, ...]:
"""Symlenker i den publiserte flaten som peker UT av treet.
Målt 19.09 (N10b): symlenker ble filtrert bort. Peker lenka INN i treet er målet skannet
selv, og lenka er en dublett; peker den UT, bar den en kommando ingen leste. Den blir ikke
skannet (innholdet publiseres ikke), men den KAN ikke passere i stillhet."""
paths, _source = _published_paths(root, manifest)
anchor = root.resolve()
escaping: list[str] = []
for path in paths:
if not path.is_symlink():
continue
try:
target = path.resolve()
except OSError: # pragma: no cover - en brutt lenke er også en lenke ut
escaping.append(str(path.relative_to(root)))
continue
try:
target.relative_to(anchor)
except ValueError:
escaping.append(str(path.relative_to(root)))
return tuple(sorted(escaping))
def published_files(root: Path, manifest: Mapping[str, Any]) -> tuple[list[Path], str]:
"""(filene som PUBLISERES, hvor lista kom fra).
@ -563,29 +846,8 @@ def published_files(root: Path, manifest: Mapping[str, Any]) -> tuple[list[Path]
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)"
paths, source = _published_paths(root, manifest)
return [p for p in paths if p.is_file() and not p.is_symlink()], source
def scannable_lines(path: Path, text: str) -> list[tuple[int, str]]:
@ -622,18 +884,42 @@ class Surface:
unreadable: int
source: str
sentinel: bool
missing: tuple[str, ...] = ()
escaping: tuple[str, ...] = ()
def sentinels_of(manifest: Mapping[str, Any]) -> list[str]:
"""Filene som MÅ ligge i flaten for at et fravær skal bety noe.
Målt 19.09 (N6): ÉN sentinel stopper en TOM flate, ikke en BESKÅRET. Manifestet filtrert fra
512 til 451 filer hele ``examples/``, ``spikes/``, ``contexts/`` og ``main.py`` ga
fortsatt GRØNN, og det var nøyaktig de områdene den gamle håndlista også utelot. Kontrakten
planter derfor én sentinel per område; forsvinner ett av dem, er raden IKKE MÅLT."""
listed = [str(s) for s in manifest.get("sentinels", ())]
single = str(manifest.get("sentinel", ""))
if single and single not in listed:
listed.append(single)
return listed
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):
except UnicodeDecodeError:
#: Målt 19.09 (N9): «\xff\xfe» + kommandoen ga GRØNN, og fila ble bare TELT som
#: ulesbar. Et fravær som er målt bort er ikke et fravær. latin-1 kan ikke feile og
#: bevarer hver ASCII-byte, så en kommandolinje i en ellers binær fil leses nå.
unreadable += 1
try:
text = path.read_bytes().decode("latin-1")
except OSError: # pragma: no cover - fila forsvant mellom to lesninger
continue
except OSError: # pragma: no cover - en fil som ikke kan åpnes publiseres ikke
unreadable += 1
continue
scanned.append((path, scannable_lines(path, text)))
@ -655,8 +941,17 @@ def measure_patterns(config: Mapping[str, Any], root: Path) -> tuple[list[Patter
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)
names = {str(p.relative_to(root)) for p in files}
wanted = sentinels_of(manifest)
missing = tuple(s for s in wanted if s not in names)
return measured, Surface(
len(files),
unreadable,
source,
bool(wanted) and not missing,
missing or (("(ingen sentinel erklært)",) if not wanted else ()),
escaping_symlinks(root, manifest),
)
def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
@ -670,13 +965,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"
f"flaten mangler sin sentinel ({', '.join(surface.missing)}) — {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
for link in surface.escaping:
exceptions.append(
f"symlenke ut av treet: {link} — målet publiseres ikke og skannes ikke, "
"og lenka kan bære en kommando raden aldri leser"
)
status = (
NOT_MEASURED
if not surface.sentinel
else GREEN
if n and k == n and not surface.escaping
else RED
)
return Row(
"ingen-claude-vei",
"3 vakter mot en vei fra po til Claude",
@ -684,7 +989,7 @@ def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
n,
status,
f"{k} av {n} vakter måler og gir null treff over {surface.files} publiserte filer "
f"({surface.source}; {surface.unreadable} ulesbare)",
f"({surface.source}; {surface.unreadable} ikke-UTF-8, lest som byte-tekst)",
exceptions=tuple(exceptions),
attests=(NO_CLAUDE_LIMIT,),
)
@ -897,7 +1202,16 @@ def score_runbook(
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())
try:
text = runbook.read_text(encoding="utf-8") if runbook.is_file() else ""
except (OSError, UnicodeDecodeError): # pragma: no cover - en ulesbar kjørebok er ingen bok
text = ""
#: Målt 19.09 (N3): en kjørebok hvis HELE innhold er «x», med en korrekt sjekksummet
#: attestering, ga «2 av 2 GRØNN». Tom fil var alt som var avvist. Kontrakten navngir derfor
#: seksjonene en kjørebok må bære — det gjør den ikke sann, men det gjør «x» umulig.
sections = [str(s) for s in config.get("sections", ())]
missing_sections = [s for s in sections if s not in text]
has_runbook = bool(text.strip()) and not missing_sections
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
@ -905,8 +1219,13 @@ def score_runbook(
k = int(has_runbook) + int(attestation.ok)
n = len(config["artefacts"])
exceptions: list[str] = []
if not has_runbook:
if not text.strip():
exceptions.append(f"kjørebok: {config['path']} finnes ikke")
elif missing_sections:
exceptions.append(
f"kjørebok: {config['path']} mangler seksjonene kontrakten navngir "
f"({', '.join(missing_sections)})"
)
if not attestation.ok:
exceptions.append(f"attestering: {attestation.why}")
return Row(