3bf62b95bb
gates / gates (push) Successful in 33s
CI convicted ALL 174 checked tags while the pre-push hook was green. Cause, read from the run log rather than guessed at the second attempt: the search used `grep -rnE --include=…`, and the CI runner's image carries python3 and git and deliberately little else — its grep does not support `--include`, so stdout was empty and the gate read empty as "the tag is absent". That is a gate silently treating a tool failure as a finding, which is worse than no gate, and it is exactly the error-swallowing this repo forbids. A green from it would have been just as untrustworthy as the red. Fixed by removing the dependency, not by working around it: the search is now pure Python — one token index per receiving repo, built in a single pass, no subprocess. Faster too (one walk instead of ~350 greps), and unreadable-file / empty-repo cases now exit 2 INCONCLUSIVE rather than reporting absence. THE BEFORE CAPTURE WAS RE-VERIFIED, NOT RE-GENERATED — the stronger claim. All 40 fields recorded in BEFORE.md were re-tested against the new implementation: agree=40, disagree=0, i.e. exactly the four this session fixed are now present and the other 36 still absent. The number 40 stands under both implementations; only the mechanism changed. The whole-token property survives by construction — a token index treats `healed_at` and `privsep_healed_at` as distinct tokens. This is the THIRD instrument defect this gate's own controls caught before it was trusted, after the substring false negative and the dr_recipe over-opacity. The first two were caught by re-finding the known instances; this one by the CI-versus-hook disagreement the workflow's alarm mail explicitly says outranks whatever the push was for.
509 lines
24 KiB
Python
509 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""wire_contract_gate.py — a field one side emits and the other cannot receive is a DEFECT (G-1).
|
|
|
|
Run from the repo root: python3 scripts/wire_contract_gate.py
|
|
Exit 0 clean · 1 convicted (an emitted field has no receiver) · 2 inconclusive (a root or a sibling
|
|
clone could not be resolved — never a silent pass).
|
|
|
|
Self-test: python3 scripts/wire_contract_gate.py --selftest
|
|
Plants an unreachable tag on a real root and asserts this gate convicts it, then asserts a clean
|
|
run is clean. A gate nobody has watched fail has not been shown to work.
|
|
|
|
WHY THIS EXISTS.
|
|
|
|
Campaign 12 (2026-08-08, `documentation/audits/CAMPAIGN-12-class-sweep-2026-08-08.md`) swept seven
|
|
recurring defect classes. This one — "a field one side sends and the other silently drops" — had
|
|
already been found by hand three times, most recently R-247: the hub sends `escrow_stale` in the
|
|
report ACK, the controller's `report.EscrowStatus` has no field for it, `encoding/json` discards it,
|
|
and the box then told a customer something false about their own recovery package.
|
|
|
|
The sweep found more of the same, and Part 4 ranked a gate for this class **first of eight
|
|
candidates** — cheapest, definitive, and `--fast`-eligible. The sharpest instance it found:
|
|
|
|
the agent emits `operator_key_configured` on every heartbeat (agent hub/report.go, commented
|
|
"operator authorized_key installed"); the hub's OOB decoder had no field for it; so
|
|
`oobDegraded` — the check that answers "can the operator get into this box right now" —
|
|
returned ok for a box with NO OPERATOR KEY INSTALLED.
|
|
|
|
The agent knew and said so. The hub threw it away on arrival.
|
|
|
|
THE TEST, AND WHY IT IS DEFINITIVE.
|
|
|
|
For every json tag reachable from a declared wire ROOT, ask whether that literal tag string occurs
|
|
ANYWHERE in the receiving repo's production Go or templates. **A tag that occurs nowhere cannot be
|
|
decoded by any struct, named or anonymous.** That last clause is the whole reason this test is used
|
|
instead of comparing struct to struct: the campaign's first attempt paired types by shape and
|
|
false-positived badly, because the hub decodes one report through SEVERAL ad-hoc anonymous structs.
|
|
A string test cannot be fooled that way.
|
|
|
|
⚠ WHAT THIS GATE DOES NOT SEE — stated here so nobody reads a green as coverage. Campaign 12's own
|
|
C1 guard turned out blind to one of the three shapes it was written for; the lesson taken is that a
|
|
gate must publish its holes.
|
|
|
|
1. GENERIC TAG NAMES ARE SKIPPED. `name`, `type`, `state`, `status` and the like occur in every
|
|
repo for unrelated reasons, so the string test cannot say anything about them. They are listed
|
|
in GENERIC below and are NOT checked — a genuine drop of a generically-named field is MISSED.
|
|
This makes the gate conservative: it under-reports and does not over-report.
|
|
2. IT PROVES REACHABILITY OF A NAME, NOT THAT ANYTHING ACTS ON THE VALUE. A tag mentioned once in
|
|
a struct nobody consults passes. It answers "can this be decoded at all", not "is it used".
|
|
3. ROOTS ARE DECLARED, NOT DISCOVERED. Only the wires in ROOTS are covered. The hub's
|
|
desired-state is served as raw stored JSON (`host.DesiredJSON`) with no typed emitter to walk,
|
|
and the agent's local API has no single root type — NEITHER IS COVERED.
|
|
4. IT READS SOURCE, NOT TRAFFIC. A field populated only at runtime under a key built by string
|
|
concatenation is invisible.
|
|
5. TEST FILES AND testdata/ ARE EXCLUDED ON THE RECEIVING SIDE, deliberately. A tag present only
|
|
in a fixture is not decodable by production — and R-262 is exactly that: the hub's
|
|
host-report golden carries `cpu_temp_c` and `loadavg`, which no hub struct reads.
|
|
"""
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
WORKSPACE = os.path.dirname(ROOT)
|
|
|
|
REPOS = {
|
|
"hub": os.path.join(ROOT, "hub"),
|
|
"controller": os.path.join(WORKSPACE, "felhom-controller", "controller"),
|
|
"agent": os.path.join(WORKSPACE, "felhom-agent"),
|
|
}
|
|
|
|
# (label, emitter repo, package dir relative to that repo, root type, receiver repo)
|
|
# Each row is a CLAIM that this type's json is put on this wire. An unresolvable root is
|
|
# INCONCLUSIVE, never a pass.
|
|
ROOTS = [
|
|
("agent -> hub (POST /host-report)",
|
|
"agent", "internal/hub", "HostReport", "hub"),
|
|
("controller -> hub (POST /report)",
|
|
"controller", "internal/report", "Report", "hub"),
|
|
("hub -> controller (report ACK, `escrow` object)",
|
|
"hub", "internal/store", "EscrowStatus", "controller"),
|
|
]
|
|
|
|
# Tag names whose literal string carries no information in a repo-wide search. NOT CHECKED.
|
|
# Listed rather than silently skipped: each one is a hole.
|
|
GENERIC = {
|
|
"name", "type", "state", "status", "error", "id", "path", "size_bytes", "enabled",
|
|
"message", "value", "version", "code", "label", "mode", "total_bytes", "used_bytes",
|
|
"avail_bytes", "reason", "detail", "where", "vmid", "target", "content", "apps", "app",
|
|
"phase", "warn", "count", "host", "guests", "kind", "url", "port", "user", "role",
|
|
"created_at", "updated_at", "started_at", "timestamp", "time", "percent", "used_fraction",
|
|
}
|
|
|
|
# A tag that is emitted and NOT consumed, with the reason it is acceptable. Every entry is a claim
|
|
# someone must be able to re-check later, so none of them is bare. A quiet exclusion would be a
|
|
# dropped field with paperwork, which is worse than the defect.
|
|
#
|
|
# TWO KINDS OF ENTRY, and the difference is deliberate:
|
|
# * "redundant" — the hub already decodes something that answers the same question. No consumer
|
|
# is wanted; the entry is the end of the matter.
|
|
# * "R-264" — a fact with no consumer that ARGUABLY should have one. The entry does NOT
|
|
# close the question; it records it against an OPEN register row so that
|
|
# allowlisting cannot be mistaken for deciding. R-260 is closed by the gate plus
|
|
# the operator-access fix; the leftover appetite is R-264.
|
|
#
|
|
# Keyed by (wire label, dotted emit path).
|
|
_AH = "agent -> hub (POST /host-report)"
|
|
_CH = "controller -> hub (POST /report)"
|
|
|
|
_REDUNDANT_HOST_METRICS = (
|
|
"redundant: the hub decodes host.cpu_percent / memory_percent / disk_percent from the same "
|
|
"stanza and every host-health threshold is expressed on those. The absolute figure answers no "
|
|
"question the hub asks.")
|
|
_R264 = (
|
|
"no consumer today, and one is arguably owed — recorded against R-264 (OPEN) rather than "
|
|
"decided here. Allowlisting is not deciding.")
|
|
|
|
ALLOWLIST = {
|
|
# ---- redundant: the hub already decodes an equivalent ----
|
|
(_AH, "host.cpu_temp_c"): _REDUNDANT_HOST_METRICS,
|
|
(_AH, "host.loadavg"): _REDUNDANT_HOST_METRICS,
|
|
(_AH, "host.memory_total_bytes"): _REDUNDANT_HOST_METRICS,
|
|
(_AH, "host.memory_used_bytes"): _REDUNDANT_HOST_METRICS,
|
|
(_AH, "host.uptime_seconds"): _REDUNDANT_HOST_METRICS,
|
|
(_CH, "system.load_avg_1"): _REDUNDANT_HOST_METRICS,
|
|
(_CH, "system.load_avg_5"): _REDUNDANT_HOST_METRICS,
|
|
(_CH, "system.load_avg_15"): _REDUNDANT_HOST_METRICS,
|
|
(_CH, "system.memory_total_mb"): _REDUNDANT_HOST_METRICS,
|
|
(_CH, "system.memory_used_mb"): _REDUNDANT_HOST_METRICS,
|
|
(_CH, "system.temperature_celsius"): _REDUNDANT_HOST_METRICS,
|
|
(_CH, "system.uptime_seconds"): _REDUNDANT_HOST_METRICS,
|
|
(_AH, "guests.spec.disk_bytes"): (
|
|
"redundant: guest SIZING is hub-owned intent (the manifest), not box reality. The hub "
|
|
"decodes vmid/name/status/controller_version from the guest list and nothing more."),
|
|
(_AH, "guests.spec.memory_bytes"): (
|
|
"redundant: as guests.spec.disk_bytes — sizing is hub-owned intent, not mirrored reality."),
|
|
(_AH, "storage_targets.smart.model_name"): (
|
|
"redundant for a verdict: the hub decodes smart.health plus every counter it bands on. The "
|
|
"model string is a display label with no threshold attached to it."),
|
|
(_AH, "wireguard.last_handshake_age_s"): (
|
|
"redundant: hub-side wgsync reconciles peers from its own state, and the OOB path's own "
|
|
"wg_handshake_age_s IS now decoded (into HostOOBRow, for the alert text)."),
|
|
|
|
# ---- no consumer, and one is arguably owed: R-264, OPEN ----
|
|
(_AH, "guest_net"): _R264 + " The R-54 guest-network watchdog stanza (whole object).",
|
|
(_AH, "guest_net.checked_at"): _R264,
|
|
(_AH, "guest_net.guests.has_route"): _R264,
|
|
(_AH, "guest_net.guests.dhclient_alive"): _R264,
|
|
(_AH, "guest_net.guests.heal_succeeded"): _R264,
|
|
(_AH, "guest_net.guests.heals_last_hour"): _R264,
|
|
(_AH, "guest_net.guests.last_heal_at"): _R264,
|
|
(_AH, "guest_net.guests.damped"): _R264,
|
|
(_AH, "selfupdate_pending"): _R264 + (
|
|
" NOTE: the agent's own comment beside this field claimed 'the hub reads an absent field as "
|
|
"pending=false, the correct default'. The hub had no field at all, so it read nothing "
|
|
"either way. The comment was corrected in the same change as this entry."),
|
|
(_AH, "selfupdate_pending_version"): _R264,
|
|
(_AH, "mgmt_plane.healed_recently"): _R264 + (
|
|
" The hub DOES alarm on mgmt_plane.privsep_healed_at, which is the timestamp beside this "
|
|
"boolean, so the recurring-clobber signal is not lost — only this flag is."),
|
|
(_AH, "pbs_dr.applied_at"): _R264,
|
|
(_AH, "restore_tests.mount_parity"): _R264 + (
|
|
" R-262: the hub's own comment claims this contract is mirrored field-for-field and that a "
|
|
"key-set test guards drift; it is two fields short and the fixture omits the same two."),
|
|
(_AH, "restore_tests.mount_inventory"): _R264 + " R-262, as mount_parity.",
|
|
(_CH, "config_hash"): _R264,
|
|
(_CH, "reporting_disabled"): _R264,
|
|
(_CH, "stacks"): _R264 + (
|
|
" The whole per-stack report object; the hub's app view is built from app_telemetry."),
|
|
(_CH, "storage.migrated_to"): _R264,
|
|
(_CH, "backup.last_db_dump"): _R264,
|
|
(_CH, "backup.last_integrity_check"): _R264,
|
|
}
|
|
|
|
# A node whose IMMEDIATE CHILDREN are still checked but whose DEEPER descendants are not, because the
|
|
# receiver passes the subtree through without decoding it. Keyed by (wire label, dotted path).
|
|
#
|
|
# ⚠ THE DEPTH IS THE WHOLE POINT AND IT IS NOT COSMETIC. The hub stores each DR-recipe half as
|
|
# json.RawMessage and re-emits nested shapes verbatim — so the LEAVES genuinely are not on this wire
|
|
# in the decoded sense. But the TOP-LEVEL SECTION KEYS of each half are decoded, by
|
|
# `hostHalfShape` / `appHalfShape` in hub/internal/store/dr_recipe.go, and those two structs are
|
|
# ALLOW-LISTS: a section an emitter adds is silently discarded until it is named in BOTH the shape
|
|
# struct and AssembledRecipe. That has already cost one shipped section — `offsite_restic` was
|
|
# emitted by the controller from fork-4, stored intact, and never appeared in a delivered recipe
|
|
# (R-122). Treating `dr_recipe` as wholly opaque would put exactly that defect back outside this
|
|
# gate's reach.
|
|
OPAQUE_BELOW = {
|
|
("agent -> hub (POST /host-report)", "dr_recipe"): (
|
|
"the hub stores the agent's half verbatim (`DRRecipe json.RawMessage`) and re-emits nested "
|
|
"shapes untouched; its TOP-LEVEL sections are decoded by hostHalfShape and ARE checked"),
|
|
("controller -> hub (POST /report)", "dr_recipe"): (
|
|
"the hub stores the controller's half verbatim (`SaveDRRecipeAppHalf`) and re-emits nested "
|
|
"shapes untouched; its TOP-LEVEL sections are decoded by appHalfShape and ARE checked"),
|
|
}
|
|
|
|
STRUCT_RE = re.compile(r"^type\s+(\w+)\s+struct\s*\{", re.M)
|
|
FIELD_RE = re.compile(
|
|
r"^\s*(?:(\w+)\s+)?([\[\]\*\w\.]+)\s+`[^`]*json:\"([^\"]*)\"[^`]*`")
|
|
INLINE_STRUCT_RE = re.compile(r"^\s*(\w+)\s+(\*?)struct\s*\{")
|
|
|
|
|
|
def die(msg, code=2):
|
|
print(msg, file=sys.stderr)
|
|
sys.exit(code)
|
|
|
|
|
|
def go_files(root, include_tests=False):
|
|
out = []
|
|
for dp, dn, fn in os.walk(root):
|
|
dn[:] = [d for d in dn if d not in (".git", "vendor", "node_modules")]
|
|
for f in fn:
|
|
if not f.endswith(".go"):
|
|
continue
|
|
if not include_tests and f.endswith("_test.go"):
|
|
continue
|
|
out.append(os.path.join(dp, f))
|
|
return out
|
|
|
|
|
|
def parse_structs(path):
|
|
"""name -> (body, dir). Anonymous inline structs are inlined into the parent's field list."""
|
|
src = open(path, encoding="utf-8", errors="replace").read()
|
|
out = {}
|
|
for m in STRUCT_RE.finditer(src):
|
|
i, depth = m.end(), 1
|
|
while i < len(src) and depth:
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
i += 1
|
|
out[m.group(1)] = src[m.end():i - 1]
|
|
return out
|
|
|
|
|
|
def build_index(repo_root):
|
|
"""(dir, name) -> body, plus name -> [(dir, body)] for unqualified fallback."""
|
|
by_dir, by_name = {}, {}
|
|
for p in go_files(repo_root):
|
|
d = os.path.relpath(os.path.dirname(p), repo_root)
|
|
for name, body in parse_structs(p).items():
|
|
by_dir[(d, name)] = body
|
|
by_name.setdefault(name, []).append((d, body))
|
|
return by_dir, by_name
|
|
|
|
|
|
def split_fields(body):
|
|
"""Yield (json_tag, go_type, inline_body_or_None) for one struct body, handling nested
|
|
anonymous structs by capturing their body."""
|
|
lines = body.split("\n")
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
m = INLINE_STRUCT_RE.match(line)
|
|
if m:
|
|
# capture the inline struct body, then read the tag off its closing line
|
|
depth, j, buf = 1, i + 1, []
|
|
while j < len(lines) and depth:
|
|
depth += line_delta(lines[j])
|
|
if depth:
|
|
buf.append(lines[j])
|
|
j += 1
|
|
closing = lines[j - 1] if j - 1 < len(lines) else ""
|
|
tm = re.search(r'json:"([^"]*)"', closing)
|
|
tag = tm.group(1).split(",")[0] if tm else None
|
|
yield (tag, "INLINE", "\n".join(buf))
|
|
i = j
|
|
continue
|
|
fm = FIELD_RE.match(line)
|
|
if fm:
|
|
tag = fm.group(3).split(",")[0]
|
|
if tag and tag != "-":
|
|
yield (tag, fm.group(2), None)
|
|
i += 1
|
|
|
|
|
|
def line_delta(line):
|
|
return line.count("{") - line.count("}")
|
|
|
|
|
|
def base_type(t):
|
|
return t.lstrip("*[]").split(".")[-1]
|
|
|
|
|
|
def qualifier(t):
|
|
parts = t.lstrip("*[]").split(".")
|
|
return parts[0] if len(parts) > 1 else None
|
|
|
|
|
|
def walk(by_dir, by_name, start_dir, start_type, seen=None, prefix=""):
|
|
"""Transitively collect (tag, dotted_path) reachable from a root type."""
|
|
if seen is None:
|
|
seen = set()
|
|
key = (start_dir, start_type)
|
|
if key in seen:
|
|
return []
|
|
seen.add(key)
|
|
body = by_dir.get(key)
|
|
if body is None:
|
|
cands = by_name.get(start_type, [])
|
|
if not cands:
|
|
return []
|
|
body = cands[0][1]
|
|
start_dir = cands[0][0]
|
|
out = []
|
|
for tag, gotype, inline in split_fields(body):
|
|
if tag is None:
|
|
continue
|
|
dotted = (prefix + "." + tag) if prefix else tag
|
|
out.append((tag, dotted))
|
|
# NOTE: the walk descends everywhere; OPAQUE_BELOW filtering happens in run(), so that the
|
|
# immediate children of an opaque node are still collected and checked. See OPAQUE_BELOW.
|
|
if inline is not None:
|
|
for t2, g2, i2 in split_fields(inline):
|
|
if t2 is None:
|
|
continue
|
|
out.append((t2, dotted + "." + t2))
|
|
bt = base_type(g2)
|
|
if i2 is None and (start_dir, bt) in by_dir or bt in by_name:
|
|
out += walk(by_dir, by_name, start_dir, bt, seen, dotted + "." + t2)
|
|
continue
|
|
bt = base_type(gotype)
|
|
if bt in ("string", "int", "int64", "bool", "float64", "byte", "any",
|
|
"RawMessage", "Time", "interface{}", "uint64", "int32", "uint32"):
|
|
continue
|
|
q = qualifier(gotype)
|
|
nd = start_dir
|
|
if q:
|
|
for (d, n) in by_dir:
|
|
if n == bt and os.path.basename(d) == q:
|
|
nd = d
|
|
break
|
|
if (nd, bt) in by_dir or bt in by_name:
|
|
out += walk(by_dir, by_name, nd, bt, seen, dotted)
|
|
return out
|
|
|
|
|
|
TOKEN_RE = re.compile(r"[A-Za-z0-9_]+")
|
|
|
|
|
|
def receiver_tokens(repo_root):
|
|
"""Every `[A-Za-z0-9_]+` token in the receiver's PRODUCTION Go and templates, as a set.
|
|
|
|
WHOLE-TOKEN, NOT SUBSTRING. A substring test reports a genuinely dropped field as received:
|
|
`healed_at` occurs inside `privsep_healed_at`. That false negative was caught by this gate's own
|
|
control — R-260 named `healed_at`, so its absence from the first run's output was the tell.
|
|
|
|
PURE PYTHON, NO `grep`. The first version shelled out to `grep -rnE --include=...`, which works
|
|
on a workstation and returns NOTHING on the CI runner, whose image carries python3 and git and
|
|
deliberately little else — its grep does not support `--include`. Empty stdout was then read as
|
|
"the tag is absent", so the gate convicted ALL 174 checked tags and CI went red while the
|
|
pre-push hook was green. **A gate that silently reads a tool failure as a finding is worse than
|
|
no gate**, and swallowing that error is the exact thing this repo's rules forbid. Reading the
|
|
files here removes the dependency, is portable, and is one pass instead of ~350 subprocesses.
|
|
|
|
Test files and `testdata/` are excluded deliberately: a tag present only in a fixture is not
|
|
decodable by production — and that is R-262 exactly, where the cross-repo golden carries two
|
|
fields no hub struct reads.
|
|
"""
|
|
toks = set()
|
|
n = 0
|
|
for dp, dn, fn in os.walk(repo_root):
|
|
dn[:] = [d for d in dn if d not in (".git", "vendor", "node_modules", "testdata")]
|
|
for f in fn:
|
|
if f.endswith("_test.go"):
|
|
continue
|
|
if not (f.endswith(".go") or f.endswith(".html")):
|
|
continue
|
|
p = os.path.join(dp, f)
|
|
try:
|
|
with open(p, encoding="utf-8", errors="replace") as fh:
|
|
toks.update(TOKEN_RE.findall(fh.read()))
|
|
except OSError as e:
|
|
# never swallowed: an unreadable source file makes the answer unknown, not "absent"
|
|
die("wire-contract gate INCONCLUSIVE: cannot read %s: %s" % (p, e))
|
|
n += 1
|
|
if n == 0:
|
|
die("wire-contract gate INCONCLUSIVE: no production .go/.html found under %s — a receiver "
|
|
"with no source cannot be searched, and an empty search is not evidence of absence."
|
|
% repo_root)
|
|
return toks
|
|
|
|
|
|
def run(root_override=None, quiet=False):
|
|
"""Returns (exit_code, convictions)."""
|
|
repos = dict(REPOS)
|
|
if root_override:
|
|
repos.update(root_override)
|
|
for label, path in repos.items():
|
|
if not os.path.isdir(path):
|
|
die("wire-contract gate INCONCLUSIVE: sibling clone %r not found at %s\n"
|
|
" This gate compares two repositories; it cannot pass without both." % (label, path))
|
|
|
|
indexes = {k: build_index(v) for k, v in repos.items()}
|
|
# one pass per receiving repo, not one subprocess per tag
|
|
rtokens = {k: receiver_tokens(v) for k, v in repos.items()}
|
|
convictions = []
|
|
checked = skipped = 0
|
|
|
|
for label, emitter, pkgdir, rootname, receiver in ROOTS:
|
|
by_dir, by_name = indexes[emitter]
|
|
if (pkgdir, rootname) not in by_dir:
|
|
die("wire-contract gate INCONCLUSIVE: declared root %s.%s not found in %s/%s\n"
|
|
" A root that cannot be resolved is not a pass — fix the ROOTS table or the type."
|
|
% (emitter, rootname, emitter, pkgdir))
|
|
tags = walk(by_dir, by_name, pkgdir, rootname)
|
|
seen_tags = {}
|
|
for tag, dotted in tags:
|
|
seen_tags.setdefault(tag, dotted)
|
|
opaque_roots = [p for (lbl, p) in OPAQUE_BELOW if lbl == label]
|
|
missing = []
|
|
for tag, dotted in sorted(seen_tags.items()):
|
|
if tag in GENERIC:
|
|
skipped += 1
|
|
continue
|
|
# opaque BELOW: the node itself and its immediate children are checked, deeper is not
|
|
if any(dotted.startswith(o + ".") and dotted.count(".") > o.count(".") + 1
|
|
for o in opaque_roots):
|
|
skipped += 1
|
|
continue
|
|
if (label, dotted) in ALLOWLIST:
|
|
skipped += 1
|
|
continue
|
|
checked += 1
|
|
if tag not in rtokens[receiver]:
|
|
missing.append((tag, dotted))
|
|
if missing:
|
|
convictions.append((label, receiver, missing))
|
|
|
|
if not quiet:
|
|
print("wire-contract gate — %d tag(s) checked across %d declared wire(s); "
|
|
"%d skipped (generic / opaque / allowlisted)" % (checked, len(ROOTS), skipped))
|
|
if convictions:
|
|
if not quiet:
|
|
for label, receiver, missing in convictions:
|
|
print("\n %s" % label)
|
|
print(" the RECEIVER (%s) contains no occurrence of:" % receiver)
|
|
for tag, dotted in missing:
|
|
print(" %-32s (emitted at %s)" % (tag, dotted))
|
|
total = sum(len(m) for _, _, m in convictions)
|
|
print("\nWIRE-CONTRACT GATE FAILED: %d emitted field(s) cannot be received." % total)
|
|
print("A tag whose literal string occurs nowhere in the receiving repo cannot be decoded")
|
|
print("by any struct, named or anonymous — encoding/json discards it on arrival.")
|
|
print("Fix: model the field on the receiving side and say what consults it — or, if it is")
|
|
print("deliberately not consumed, add it to ALLOWLIST WITH A REASON. Never a quiet skip.")
|
|
print("\nBlind spots (a green is not full coverage — see the module docstring):")
|
|
print(" generic tag names are not checked; reachability of a NAME is not use of a VALUE;")
|
|
print(" only the declared ROOTS are covered (hub desired-state and the agent local API")
|
|
print(" are NOT).")
|
|
return 1, convictions
|
|
|
|
if not quiet:
|
|
print("wire-contract gate OK — every emitted field is at least decodable by its receiver")
|
|
print(" (BLIND SPOTS: generic tag names skipped; name-reachability is not use; only the")
|
|
print(" declared ROOTS are covered — hub desired-state and the agent local API are not.)")
|
|
return 0, []
|
|
|
|
|
|
def selftest():
|
|
"""Plant an unreachable tag on a real root in a THROWAWAY copy and assert conviction.
|
|
|
|
A gate nobody has watched fail has not been shown to work — and last night an off-the-shelf tool
|
|
for a neighbouring class was rejected because it was made to prove itself and could not.
|
|
"""
|
|
tmp = tempfile.mkdtemp(prefix="wirecontract-selftest-")
|
|
try:
|
|
agent = os.path.join(tmp, "agent")
|
|
shutil.copytree(REPOS["agent"], agent, symlinks=True,
|
|
ignore=shutil.ignore_patterns(".git"))
|
|
rp = os.path.join(agent, "internal", "hub", "report.go")
|
|
src = open(rp, encoding="utf-8").read()
|
|
marker = "type HostReport struct {"
|
|
if marker not in src:
|
|
die("SELFTEST INCONCLUSIVE: could not find %r to plant into" % marker)
|
|
planted = src.replace(
|
|
marker,
|
|
marker + "\n\tZZProbeUnreceivable string `json:\"zz_probe_unreceivable\"`", 1)
|
|
if planted == src:
|
|
die("SELFTEST INCONCLUSIVE: the plant did not apply")
|
|
open(rp, "w", encoding="utf-8").write(planted)
|
|
|
|
code, convictions = run({"agent": agent}, quiet=True)
|
|
found = any("zz_probe_unreceivable" == t
|
|
for _, _, miss in convictions for t, _ in miss)
|
|
print(" planted an unreachable tag on agent HostReport -> gate exit %d, "
|
|
"planted tag convicted: %s" % (code, found))
|
|
if code != 1 or not found:
|
|
print("SELFTEST FAILED: the gate did not convict a planted unreachable tag.")
|
|
return 1
|
|
|
|
code_clean, _ = run(quiet=True)
|
|
print(" unplanted tree -> gate exit %d" % code_clean)
|
|
if code_clean == 2:
|
|
print("SELFTEST FAILED: the unplanted run was INCONCLUSIVE.")
|
|
return 1
|
|
print("SELFTEST OK — the gate convicts a planted unreachable tag and the plant is the only "
|
|
"difference.")
|
|
return 0
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--selftest" in sys.argv:
|
|
sys.exit(selftest())
|
|
sys.exit(run()[0])
|