checks / unit (pull_request) Failing after 3s
Round 2 pinned the operand order of `SignWatchdog.evaluate()` at main.py:1620 -
the SITE detector. Part 9c asserts that detector is never armed at a roster of
one, so on every install this add-on has, the PER-UNIT banner is the only
sign-disagreement sentence that can ever be printed, and its call site is a
different line. main.py:1560 was left unpinned.
1. THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER.
`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)`:
app/main.py 234 542 bytes before and after, delta 0, fingerprint
61aa413f... -> e57e6026..., bytecode purged, tree mounted :ro, line re-read
from disk. On the round-2 tree the FULL 21-FILE SUITE PASSES.
Nothing behavioural could ever have caught it. `watchdog.disagrees()` is
SYMMETRIC in its first two arguments - `commanded_w * against_w < 0` with an
abs() gate on both - so the mutant takes identical actions on every input.
Not "no fixture happens to separate them": no fixture CAN.
What moves is the sentence. Measured by diffing the suite's own printed
banners between the round-3 tree and the same tree carrying only this edit:
9 emitted banner lines invert, in 3 distinct sentences.
shipped commanded -2000 W against a meter reading +2000 W
swapped commanded +2000 W against a meter reading -2000 W
shipped commanded +1000 W against its own measured power -1000 W
swapped commanded -1000 W against its own measured power +1000 W
shipped commanded -2000 W against a meter reading +4000 W (the N=1 arm)
swapped commanded +4000 W against a meter reading -2000 W
Persisted to `sign_reason` and re-read after the restart the latch exists to
survive; also the action log, `_LOG.error` and `status_payload()`'s headline.
CLOSED by the ORDERED PAIR on both per-unit banners, both operands read off
the OBSERVED CYCLE - `inv.last_write` and `inv.batt` off the objects the
plant moved, never off the arguments the detector was handed, which would
compare the swap against itself:
- part 3, the N>=2 trip: "commanded {stood:+.0f} W against its own
measured power {meas:+.0f} W", plus the persisted namespaced copy;
- part 7, the N=1 controller-driven trip: "commanded {stood:+.0f} W
against a meter reading {grid:+.0f} W", plus the namespaced key AND the
flat rollback mirror;
- a PRECONDITION on each that the two operands are different numbers of
opposite sign - without it a transposition produces the same string and
the check is decoration.
Measured after the fix: RED on four named checks in test_watchdog.py, GREEN
in the other twenty files. M11 (the site swap) re-measured on this tree:
still red. Round 1's and round 2's kills are intact.
2. A FALSE STATEMENT ROUND 2 INTRODUCED, AND IT WAS WORSE THAN REPORTED.
Section 4 said "three of these left the whole suite green - M1, M2 and M3 -
and the remaining seven were caught by SAFETY-30's own N>=2 checks". Round
1's vaguer wording was harmless; naming three made it specific and wrong.
Re-measured mutation by mutation on clean release/1.0 @ bbc903b, each on a
fresh copy, bytecode purged, byte delta and fingerprint printed, FULL 21-FILE
SUITE each time:
M1 +24 B fp 362374cf... 21/21 PASS - SURVIVOR
M2 +9 B fp 86573e74... 21/21 PASS - SURVIVOR
M3 +4 B fp 59fa8815... 21/21 PASS - SURVIVOR
M4 +9 B fp 43d852be... 21/21 PASS - SURVIVOR
M5 +16 B fp 58d26de8... RED test_watchdog.py, 3 checks
M6 +1 B fp 407ddb5f... RED test_watchdog.py, 3 checks
M7 -11 B fp 2a635a4e... 21/21 PASS - SURVIVOR
M8 +6 B fp b21eed89... 21/21 PASS - SURVIVOR
M9 +6 B fp 0973d3ca... 21/21 PASS - SURVIVOR
M10 +7 B fp 0e7b7a4f... 21/21 PASS - SURVIVOR
EIGHT survived, not three - and not the four a reviewer proposed either.
The corrected sentence is in DECISIONS section 4.
AND THE TWO THAT WERE CAUGHT MAKE THE CARD'S CASE. M5 (`sum(abs ...)`) and
M6 (`-sum`) are the only two entries that reverse the SIGN. Every other one
changes only the MAGNITUDE, and `_sign_site` - the suite's only N>=2 site
fixture before this card - runs shares of about -2500 W each against a 320 W
gate, so sum / mean / max / min / sorted[-1] / [0] / self.target / half /
double land on -2500, -2500, -2500, -2500, -2500, -2500, -2500, -1250 and
-5000 W: nine numbers, ALL ON THE SAME SIDE OF THE SAME THRESHOLD. The
pre-existing coverage could see a sign and nothing else.
3. THEN THE THING THAT WOULD HAVE CAUGHT BOTH: the SYMMETRIC-PREDICATE FAMILY,
enumerated by machine rather than by hand for a third time.
`tools/symmetric_message_scan.py` renames each parameter pair, erases every
formatted string, canonicalises the commutative operators and iterates to a
FIXPOINT - the symmetry is usually one call deep, and a single pass reports
an EMPTY family on a tree whose answer is already known. A second census
covers sentences the CALLER builds. Both print their own blind spots,
including the one way the scan OVER-reports.
It found one open survivor this card does not close and now discloses:
rail_unreachable()'s dev_min/dev_max at main.py:3208 - 0 bytes, 21/21 green,
a status-page NOT COVERED row - and twelve unpinned caller-built sentences,
including SAFETY-31's `circ_reason`, which is the same shape and is also
persisted and re-read after a restart. DECISIONS section 7 has the list, the
pinned status of every member, and what the instrument cannot see.
No app/ code changes. MUTATION.md gains habit 15.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
566 lines
25 KiB
Python
566 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""SAFETY-38 round 3 - the SYMMETRIC-PREDICATE FAMILY, enumerated by machine.
|
|
|
|
WHAT IT LOOKS FOR, in one sentence: a call whose argument ORDER is load-bearing
|
|
for a MESSAGE and for nothing else - so swapping the arguments cannot change a
|
|
single decision, is invisible to every behavioural check BY CONSTRUCTION, and
|
|
still inverts what an operator reads on the line they are woken by.
|
|
|
|
⚠️ WHY IT IS AN INSTRUMENT AND NOT A LIST. MUTATION.md habit 12: "a hand-written
|
|
scope is the same defect as a hand-written fixture". SAFETY-38 met this family
|
|
twice - `SignWatchdog.evaluate()` called at `main.py:1620` (round 2) and at
|
|
`main.py:1560` (round 3), ONE CALL SITE OVER - and both times the enumeration
|
|
was written by hand and both times it was short. So the scope is computed.
|
|
|
|
------------------------------------------------------------------------------
|
|
THE TEST IT APPLIES
|
|
|
|
For every function in `app/` and every pair (a, b) of its parameters:
|
|
|
|
rename a -> b and b -> a throughout the body, ERASE every string literal
|
|
and f-string, CANONICALISE the commutative operators, and compare the two
|
|
ASTs. If they are EQUAL, nothing the function DECIDES can depend on which
|
|
of the two values arrived first.
|
|
|
|
Then, if some f-string in that function mentions BOTH names, the order is
|
|
load-bearing for the MESSAGE ALONE, and the pair is reported.
|
|
|
|
⚠️ THE SYMMETRY IS USUALLY ONE CALL DEEP, WHICH IS WHY THIS IS A FIXPOINT AND
|
|
NOT A SINGLE PASS. `SignWatchdog.evaluate()` does not compare its two arguments
|
|
itself; it hands them to `watchdog.disagrees()`, whose body is
|
|
`commanded_w * against_w < 0 and abs(commanded_w) > min_w and
|
|
abs(against_w) > min_w`. An intraprocedural scan sees `disagrees(a, b, ...)`
|
|
become `disagrees(b, a, ...)`, calls that a difference, and reports NOTHING -
|
|
which is exactly what the first version of this file did, on a tree where the
|
|
answer was already known. So symmetric pairs discovered in one round are used
|
|
to canonicalise CALL ARGUMENTS in the next, and the scan iterates until the set
|
|
stops growing.
|
|
|
|
CANONICALISATION, and each entry is here because something in `app/` needs it:
|
|
* `a * b`, `a + b` - operand order sorted (`disagrees`)
|
|
* `x and y`, `x or y` - term order sorted (`disagrees`' conjunction)
|
|
* `a == b`, `a != b` - operand order sorted
|
|
* `min(a, b)`, `max(a, b)` - argument order sorted (`clamp_to_rating`)
|
|
* a tuple/list/set LITERAL being iterated - element order sorted
|
|
(`rail_ceiling_w`'s `for bound in (dev_min, dev_max)`)
|
|
* arguments at a KNOWN SYMMETRIC POSITION PAIR of a called function - sorted
|
|
(the fixpoint)
|
|
|
|
Equal ASTs after the rename is a SUFFICIENT condition for behavioural
|
|
invariance, not a necessary one. This scan therefore UNDER-REPORTS and says so
|
|
- see "WHAT THIS CANNOT SEE". That is the conservative direction: everything it
|
|
names is genuinely in the family, and the gap is stated with its shape.
|
|
|
|
Run: `python3 tools/symmetric_message_scan.py [--quiet]` from goodwe_controller/.
|
|
Exit status is 0 always; it is a census, not a gate.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import itertools
|
|
import pathlib
|
|
import sys
|
|
|
|
APP = pathlib.Path(__file__).resolve().parent.parent / "app"
|
|
|
|
# {function name: {(i, j) positional index pairs that are interchangeable}}
|
|
_SYM: dict[str, set[tuple[int, int]]] = {}
|
|
# ⚠️ AND THE SAME THING FOR KEYWORD-ONLY PARAMETERS, WHICH THE FIRST FIXPOINT
|
|
# DROPPED. `freeze.rail_ceiling_w(max_w, *, dev_min=None, dev_max=None)` folds
|
|
# its two device bounds with `for bound in (dev_min, dev_max): ceiling = min(
|
|
# ceiling, abs(bound))` - perfectly symmetric - and `rail_unreachable()` prints
|
|
# `"a range of {dev_min}...{dev_max} W"`. A scan that only followed POSITIONAL
|
|
# arguments called that pair asymmetric and hid a genuine member of the family
|
|
# behind an implementation detail of its own traversal.
|
|
_SYM_KW: dict[str, set[tuple[str, str]]] = {}
|
|
|
|
|
|
class _Canon(ast.NodeTransformer):
|
|
"""Rename one pair, erase every string, and sort what commutes."""
|
|
|
|
COMMUTATIVE_BINOP = (ast.Mult, ast.Add, ast.BitAnd, ast.BitOr, ast.BitXor)
|
|
COMMUTATIVE_CMP = (ast.Eq, ast.NotEq)
|
|
COMMUTATIVE_CALL = ("min", "max")
|
|
|
|
def __init__(self, a: str = "", b: str = ""):
|
|
self.a, self.b = a, b
|
|
|
|
# -- the rename ---------------------------------------------------------
|
|
def visit_Name(self, node):
|
|
self.generic_visit(node)
|
|
if self.a and node.id == self.a:
|
|
node.id = self.b
|
|
elif self.b and node.id == self.b:
|
|
node.id = self.a
|
|
return node
|
|
|
|
def visit_arg(self, node):
|
|
if self.a and node.arg == self.a:
|
|
node.arg = self.b
|
|
elif self.b and node.arg == self.b:
|
|
node.arg = self.a
|
|
return node
|
|
|
|
# -- the message is exactly what is ALLOWED to differ --------------------
|
|
def visit_JoinedStr(self, node):
|
|
return ast.Constant(value="<msg>")
|
|
|
|
def visit_Constant(self, node):
|
|
if isinstance(node.value, str):
|
|
return ast.Constant(value="<msg>")
|
|
return node
|
|
|
|
# -- canonicalisation ---------------------------------------------------
|
|
def visit_BinOp(self, node):
|
|
self.generic_visit(node)
|
|
if isinstance(node.op, self.COMMUTATIVE_BINOP):
|
|
l, r = ast.dump(node.left), ast.dump(node.right)
|
|
if l > r:
|
|
node.left, node.right = node.right, node.left
|
|
return node
|
|
|
|
def visit_BoolOp(self, node):
|
|
self.generic_visit(node)
|
|
node.values = sorted(node.values, key=ast.dump)
|
|
return node
|
|
|
|
def visit_Compare(self, node):
|
|
self.generic_visit(node)
|
|
if (len(node.ops) == 1
|
|
and isinstance(node.ops[0], self.COMMUTATIVE_CMP)):
|
|
l, r = ast.dump(node.left), ast.dump(node.comparators[0])
|
|
if l > r:
|
|
node.left, node.comparators[0] = node.comparators[0], node.left
|
|
return node
|
|
|
|
def visit_For(self, node):
|
|
self.generic_visit(node)
|
|
if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)):
|
|
node.iter.elts = sorted(node.iter.elts, key=ast.dump)
|
|
return node
|
|
|
|
def visit_Call(self, node):
|
|
self.generic_visit(node)
|
|
name = (node.func.id if isinstance(node.func, ast.Name)
|
|
else node.func.attr if isinstance(node.func, ast.Attribute)
|
|
else "")
|
|
if name in self.COMMUTATIVE_CALL and len(node.args) == 2 \
|
|
and not node.keywords:
|
|
if ast.dump(node.args[0]) > ast.dump(node.args[1]):
|
|
node.args = [node.args[1], node.args[0]]
|
|
return node
|
|
# the fixpoint: sort the arguments at a known symmetric position pair
|
|
for (i, j) in sorted(_SYM.get(name, ())):
|
|
if j < len(node.args):
|
|
if ast.dump(node.args[i]) > ast.dump(node.args[j]):
|
|
node.args[i], node.args[j] = node.args[j], node.args[i]
|
|
# ...and at a known symmetric KEYWORD pair
|
|
for (ka, kb) in sorted(_SYM_KW.get(name, ())):
|
|
ia = next((k for k, w in enumerate(node.keywords)
|
|
if w.arg == ka), None)
|
|
ib = next((k for k, w in enumerate(node.keywords)
|
|
if w.arg == kb), None)
|
|
if ia is not None and ib is not None:
|
|
va, vb = node.keywords[ia].value, node.keywords[ib].value
|
|
if ast.dump(va) > ast.dump(vb):
|
|
node.keywords[ia].value, node.keywords[ib].value = vb, va
|
|
return node
|
|
|
|
|
|
def _canon(src: str, a: str = "", b: str = "") -> str:
|
|
return ast.dump(_Canon(a, b).visit(ast.parse(src)))
|
|
|
|
|
|
# ⚠️ THE SINK IS HALF THE CLAIM, AND THE FIRST VERSION OF THIS SCAN OMITTED IT.
|
|
# Erasing every f-string makes a function whose ONLY job is to FORMAT one look
|
|
# perfectly symmetric - `store.unit_key(unit, key)` and
|
|
# `mqtt.unit_state_key(slug, key)` both came back as "the swap changes nothing"
|
|
# when in fact the string IS the return value: swapping there renames a STORE
|
|
# KEY and an MQTT TOPIC, which is a behaviour change every ordinary check can
|
|
# see, and MAINT-05 is the card about exactly that. The family this file is
|
|
# looking for is narrower - the string goes to an OPERATOR and to nobody else -
|
|
# so each sentence is classified by where it GOES.
|
|
_LOG_SINKS = {"debug", "info", "warning", "warn", "error", "exception",
|
|
"critical", "log", "log_event", "notify", "persistent_notify"}
|
|
_MSG_ATTRS = {"reason", "message", "msg", "banner", "detail", "note",
|
|
"explanation", "why", "text"}
|
|
|
|
|
|
def _sink_of(fn, target, follow=True):
|
|
"""RETURN | MESSAGE | OTHER - where this f-string node ends up.
|
|
|
|
`follow` is the ONE step of data flow below, and it is off on the recursive
|
|
call so that two locals that interpolate each other cannot loop forever.
|
|
"""
|
|
for stmt in ast.walk(fn):
|
|
for child in ast.iter_child_nodes(stmt):
|
|
pass
|
|
# a parent map is cheaper than repeated walks
|
|
parent = {}
|
|
for node in ast.walk(fn):
|
|
for child in ast.iter_child_nodes(node):
|
|
parent[id(child)] = node
|
|
cur = target
|
|
while id(cur) in parent:
|
|
up = parent[id(cur)]
|
|
if isinstance(up, ast.Return):
|
|
return "RETURN"
|
|
if isinstance(up, ast.Raise):
|
|
return "MESSAGE"
|
|
if isinstance(up, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
|
tgts = (up.targets if isinstance(up, ast.Assign) else [up.target])
|
|
for t in tgts:
|
|
if isinstance(t, ast.Attribute) and t.attr in _MSG_ATTRS:
|
|
return "MESSAGE"
|
|
if isinstance(t, ast.Name) and t.id in _MSG_ATTRS:
|
|
return "MESSAGE"
|
|
# ⚠️ ONE STEP OF DATA FLOW, AND IT IS NOT OPTIONAL. `main.py`'s
|
|
# status page builds the device-range clause into a LOCAL - `_cap =
|
|
# "; ".join(f"... accepts only {inv.dev_min}...{inv.dev_max} W" ...)`
|
|
# - and only then concatenates it into the row's `text`. Stopping at
|
|
# the assignment scores that OTHER and drops a member this card
|
|
# MEASURED as a live survivor (0 bytes, 21/21 green). So: if the
|
|
# name assigned here is interpolated into some other sentence in the
|
|
# same function that IS a message, this one is a message too.
|
|
if follow:
|
|
for t in tgts:
|
|
if isinstance(t, ast.Name) and _feeds_a_message(fn, t.id,
|
|
up):
|
|
return "MESSAGE"
|
|
return "OTHER"
|
|
if isinstance(up, ast.Call):
|
|
name = (up.func.attr if isinstance(up.func, ast.Attribute)
|
|
else up.func.id if isinstance(up.func, ast.Name) else "")
|
|
if name in _LOG_SINKS:
|
|
return "MESSAGE"
|
|
if name == "append":
|
|
return "MESSAGE"
|
|
cur = up
|
|
return "OTHER"
|
|
|
|
|
|
def _feeds_a_message(fn, name, origin):
|
|
"""Is `name` interpolated into some OTHER sentence that IS a message?"""
|
|
for node in ast.walk(fn):
|
|
if not isinstance(node, ast.JoinedStr) or node is origin:
|
|
continue
|
|
used = any(isinstance(x, ast.Name) and x.id == name
|
|
for v in node.values if isinstance(v, ast.FormattedValue)
|
|
for x in ast.walk(v.value))
|
|
if used and _sink_of(fn, node, follow=False) == "MESSAGE":
|
|
return True
|
|
return False
|
|
|
|
|
|
def _fstring_pairs(fn, params):
|
|
"""{(a, b): [(lineno, sink), ...]} for every f-string mentioning both."""
|
|
out: dict[tuple[str, str], list[tuple[int, str]]] = {}
|
|
for node in ast.walk(fn):
|
|
if not isinstance(node, ast.JoinedStr):
|
|
continue
|
|
used = set()
|
|
for v in node.values:
|
|
if isinstance(v, ast.FormattedValue):
|
|
for x in ast.walk(v.value):
|
|
if isinstance(x, ast.Name) and x.id in params:
|
|
used.add(x.id)
|
|
if len(used) < 2:
|
|
continue
|
|
sink = _sink_of(fn, node)
|
|
for a, b in itertools.combinations(sorted(used), 2):
|
|
out.setdefault((a, b), []).append((node.lineno, sink))
|
|
return out
|
|
|
|
|
|
def _functions():
|
|
for path in sorted(APP.glob("*.py")):
|
|
tree = ast.parse(path.read_text())
|
|
for fn in ast.walk(tree):
|
|
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
yield path, fn
|
|
|
|
|
|
def _positional(fn):
|
|
return [a.arg for a in fn.args.posonlyargs + fn.args.args
|
|
if a.arg != "self"]
|
|
|
|
|
|
def _all_params(fn):
|
|
return _positional(fn) + [a.arg for a in fn.args.kwonlyargs]
|
|
|
|
|
|
def _body_src(fn) -> str:
|
|
"""The function's BODY alone.
|
|
|
|
⚠️ NOT `ast.unparse(fn)`. The signature lists the parameters IN ORDER, so
|
|
renaming a pair inside it reorders `arguments.args` and every function in
|
|
the repository compares unequal to itself - which is what the first version
|
|
of this scan did, silently, and it reported an empty family on a tree whose
|
|
answer was already known. The claim being tested is about what the body
|
|
DECIDES, and the body is what is compared.
|
|
"""
|
|
return ast.unparse(ast.Module(body=list(fn.body), type_ignores=[]))
|
|
|
|
|
|
def _symmetric(fn, a, b) -> bool:
|
|
src = _body_src(fn)
|
|
return _canon(src) == _canon(src, a, b)
|
|
|
|
|
|
def build_fixpoint(max_rounds=6):
|
|
"""Fill `_SYM` until it stops growing. Returns the number of rounds."""
|
|
for round_no in range(1, max_rounds + 1):
|
|
grew = False
|
|
for _path, fn in _functions():
|
|
pos = _positional(fn)
|
|
kws = [a.arg for a in fn.args.kwonlyargs]
|
|
if len(pos) + len(kws) < 2:
|
|
continue
|
|
found = set()
|
|
for i, j in itertools.combinations(range(len(pos)), 2):
|
|
if _symmetric(fn, pos[i], pos[j]):
|
|
found.add((i, j))
|
|
found_kw = set()
|
|
for ka, kb in itertools.combinations(sorted(kws), 2):
|
|
if _symmetric(fn, ka, kb):
|
|
found_kw.add((ka, kb))
|
|
if found - _SYM.get(fn.name, set()):
|
|
_SYM.setdefault(fn.name, set()).update(found)
|
|
grew = True
|
|
if found_kw - _SYM_KW.get(fn.name, set()):
|
|
_SYM_KW.setdefault(fn.name, set()).update(found_kw)
|
|
grew = True
|
|
if not grew:
|
|
return round_no
|
|
return max_rounds
|
|
|
|
|
|
def scan():
|
|
rows = []
|
|
for path, fn in _functions():
|
|
params = _all_params(fn)
|
|
if len(params) < 2:
|
|
continue
|
|
kwonly = {a.arg for a in fn.args.kwonlyargs}
|
|
for (a, b), hits in sorted(_fstring_pairs(fn, params).items()):
|
|
rows.append(dict(
|
|
module=path.name, func=fn.name, line=fn.lineno, a=a, b=b,
|
|
msg_lines=sorted(set(hits)),
|
|
sinks={s for _l, s in hits},
|
|
symmetric=_symmetric(fn, a, b),
|
|
positional=not (a in kwonly or b in kwonly)))
|
|
return rows
|
|
|
|
|
|
# =============================================================================
|
|
# CENSUS B - THE SENTENCE THE CALLER BUILDS
|
|
# =============================================================================
|
|
# ⚠️ CENSUS A ABOVE ONLY SEES A CALLEE FORMATTING ITS OWN PARAMETERS, AND THAT
|
|
# WAS GAP 2 OF ITS OWN "WHAT THIS CANNOT SEE" LIST. The other half of the family
|
|
# never passes through a function boundary at all: an operator sentence built
|
|
# where it is printed, interpolating two values that are INTERCHANGEABLE BY
|
|
# SHAPE. `main.py`'s status page says
|
|
#
|
|
# f"{inv.setpoint_entity or inv.name} accepts only {inv.dev_min}...{inv.dev_max} W"
|
|
#
|
|
# and transposing those two costs ZERO BYTES, changes nothing any check can
|
|
# observe, and tells the operator a range backwards. No predicate is involved,
|
|
# so census A's symmetry test has nothing to test.
|
|
#
|
|
# THE TEST: inside one MESSAGE-sink f-string, take every pair of interpolated
|
|
# expressions, blank every attribute NAME and every string CONSTANT, and compare.
|
|
# If the blanked forms are EQUAL while the originals DIFFER, the two expressions
|
|
# are the same shape with one leaf changed - `inv.dev_min` / `inv.dev_max`,
|
|
# `site_dev_bound("dev_min")` / `site_dev_bound("dev_max")` - which is exactly
|
|
# the pair a transposition swaps. Same direction as census A: it under-reports
|
|
# (two values of the same KIND spelled differently are missed) rather than
|
|
# inventing members.
|
|
|
|
|
|
class _Blank(ast.NodeTransformer):
|
|
def visit_Attribute(self, node):
|
|
self.generic_visit(node)
|
|
node.attr = "<leaf>"
|
|
return node
|
|
|
|
def visit_Constant(self, node):
|
|
if isinstance(node.value, str):
|
|
return ast.Constant(value="<leaf>")
|
|
return node
|
|
|
|
|
|
def _blanked(expr):
|
|
return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr))))
|
|
|
|
|
|
def scan_caller():
|
|
rows = []
|
|
for path in sorted(APP.glob("*.py")):
|
|
tree = ast.parse(path.read_text())
|
|
for fn in ast.walk(tree):
|
|
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
continue
|
|
for node in ast.walk(fn):
|
|
if not isinstance(node, ast.JoinedStr):
|
|
continue
|
|
# ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS
|
|
# CENSUS SAID "anything but a RETURN". That let in
|
|
# `p1.py`'s `self.url = f"http://{self.host}:{self.port}…"` -
|
|
# an f-string that is DATA, used to make a request, where a
|
|
# transposition is an ordinary behavioural change every check
|
|
# can see. Same distinction as census A's RETURN bucket: a
|
|
# formatted string is only in this family when its only
|
|
# consumer is a person.
|
|
if _sink_of(fn, node) != "MESSAGE":
|
|
continue
|
|
exprs = [v.value for v in node.values
|
|
if isinstance(v, ast.FormattedValue)]
|
|
for a, b in itertools.combinations(exprs, 2):
|
|
ua, ub = ast.unparse(a), ast.unparse(b)
|
|
if ua == ub:
|
|
continue
|
|
if _blanked(a) == _blanked(b):
|
|
rows.append((path.name, node.lineno, fn.name, ua, ub))
|
|
# de-duplicate: the same sentence reached through several enclosing defs
|
|
return sorted(set(rows), key=lambda r: (r[0], r[1], r[3]))
|
|
|
|
|
|
# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the
|
|
# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand
|
|
# order pinned and the defect still shipped, because the pin was on the call two
|
|
# lines below the one a one-inverter roster actually reaches. So a family member
|
|
# is reported WITH EVERY PLACE IT IS CALLED.
|
|
def call_sites(name):
|
|
out = []
|
|
for path in sorted(APP.glob("*.py")):
|
|
tree = ast.parse(path.read_text())
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call):
|
|
continue
|
|
called = (node.func.attr if isinstance(node.func, ast.Attribute)
|
|
else node.func.id if isinstance(node.func, ast.Name)
|
|
else "")
|
|
if called == name:
|
|
# ⚠️ THE RECEIVER IS PRINTED, because matching on the bare
|
|
# attribute name conflates methods: `evaluate` is the name of
|
|
# SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and
|
|
# a list of four lines with no receiver tells a reader nothing
|
|
# about which detector they are looking at. `inv.sign_wd` and
|
|
# `self.freeze_wd` are the disambiguation, and they are the
|
|
# caller's own words.
|
|
out.append(f"{path.name}:{node.lineno} "
|
|
f"`{ast.unparse(node.func)}`")
|
|
return out
|
|
|
|
|
|
def main():
|
|
quiet = "--quiet" in sys.argv
|
|
rounds = build_fixpoint()
|
|
rows = scan()
|
|
fam = [r for r in rows if r["symmetric"] and "MESSAGE" in r["sinks"]
|
|
and "RETURN" not in r["sinks"]]
|
|
valued = [r for r in rows if r["symmetric"] and "RETURN" in r["sinks"]]
|
|
print("SYMMETRIC-PREDICATE FAMILY - argument order load-bearing for a "
|
|
"MESSAGE only")
|
|
print("=" * 78)
|
|
print(f"fixpoint reached after {rounds} round(s); "
|
|
f"{sum(len(v) for v in _SYM.values())} interchangeable positional "
|
|
f"and {sum(len(v) for v in _SYM_KW.values())} keyword pair(s) "
|
|
f"across {len(set(_SYM) | set(_SYM_KW))} function(s) in app/")
|
|
print(f"functions formatting >=2 of their own parameters into one "
|
|
f"sentence: {len(rows)} pair(s)")
|
|
print(f"of those, pairs whose SWAP LEAVES EVERY DECISION IDENTICAL: "
|
|
f"{len(fam) + len(valued)}")
|
|
print(f" ...of which the sentence is RETURNED (so the swap is a VALUE "
|
|
f"change an ordinary check can see): {len(valued)}")
|
|
print(f" ...and the sentence goes ONLY TO AN OPERATOR - THE FAMILY: "
|
|
f"{len(fam)}")
|
|
print()
|
|
print("THE FAMILY - a swap here is invisible to every behavioural check "
|
|
"BY CONSTRUCTION:")
|
|
for r in sorted(fam, key=lambda r: (r["module"], r["line"])):
|
|
kind = ("POSITIONAL - a transposition at a call site costs ZERO BYTES"
|
|
if r["positional"] else
|
|
"KEYWORD-ONLY at the definition - a transposition has to be "
|
|
"written out by name, which is not free and not silent")
|
|
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
|
f"({r['a']}, {r['b']})")
|
|
print(f" sentence at line(s): "
|
|
f"{', '.join(f'{l} -> {s}' for l, s in r['msg_lines'])}")
|
|
print(f" {kind}")
|
|
sites = call_sites(r["func"])
|
|
print(f" CALL SITES ({len(sites)}) - each one is a separate pin, "
|
|
f"and the receiver says which detector it is:")
|
|
for site in sites or ["(none inside app/)"]:
|
|
print(f" {site}")
|
|
print()
|
|
print("SYMMETRIC, BUT THE SENTENCE IS THE RETURN VALUE - READ THIS BUCKET "
|
|
"BY HAND.")
|
|
print("A returned string is sometimes a KEY (a swap renames a store key or "
|
|
"an MQTT topic,")
|
|
print("which is behaviour every ordinary check can see - MAINT-05 is the "
|
|
"card about it)")
|
|
print("and sometimes a SENTENCE (a swap is message-only after all, and it "
|
|
"IS this family):")
|
|
for r in sorted(valued, key=lambda r: (r["module"], r["line"])):
|
|
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
|
f"({r['a']}, {r['b']})")
|
|
print()
|
|
print("=" * 78)
|
|
print("CENSUS B - SENTENCES THE CALLER BUILDS, interpolating two values of "
|
|
"the SAME SHAPE")
|
|
print("A transposition inside one of these is ZERO BYTES and no predicate "
|
|
"is involved at")
|
|
print("all, so nothing behavioural can ever see it. Same rule: assert the "
|
|
"ORDERED PAIR.")
|
|
cb = scan_caller()
|
|
print(f"candidates: {len(cb)}")
|
|
for mod, line, fn, ua, ub in cb:
|
|
print(f" {mod}:{line} in {fn}()")
|
|
print(f" {ua} <-> {ub}")
|
|
print()
|
|
print("WHAT THIS CANNOT SEE - the shape of each gap, named rather than "
|
|
"rounded off:")
|
|
print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of "
|
|
"commutative")
|
|
print(" forms is finite and hand-written; a function symmetric only "
|
|
"through a form")
|
|
print(" not on it is REJECTED and never appears. This under-reports.")
|
|
print(" 2. In CENSUS B, two interpolated values of the same KIND that "
|
|
"are not the same")
|
|
print(" SHAPE - `a.lo` beside `hi`, or one of them behind a local "
|
|
"variable. Only")
|
|
print(" one-leaf-apart expressions are paired. `%s`-style logging "
|
|
"(`_LOG.error('%s")
|
|
print(" ... %s', a, b)`) and non-f-string payloads are not scanned at "
|
|
"all.")
|
|
print(" 3. Anything outside `app/` - `sim/`, `tools/`, the firmware, the "
|
|
"witness.")
|
|
print(" 4. WHETHER each call site is pinned. The sites are listed above; "
|
|
"whether a")
|
|
print(" check asserts the ORDERED PAIR at each one is a fact about "
|
|
"`test_*.py` and is")
|
|
print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section "
|
|
"7. Call sites")
|
|
print(" outside `app/` are not listed either, and the site list matches "
|
|
"on the bare")
|
|
print(" METHOD NAME - three different watchdogs are all called "
|
|
"`evaluate`, so the")
|
|
print(" receiver is printed beside each one and a reader has to pick.")
|
|
print(" 5. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is "
|
|
"ERASED before")
|
|
print(" the comparison, so a swap that would make the FORMATTING "
|
|
"ITSELF fail - a")
|
|
print(" `DAY_NAMES[start]` where `start` is 1320 - is scored as "
|
|
"'changes no decision'.")
|
|
print(" `_raise_gap`'s (start, weekday) and (end, weekday) pairs are "
|
|
"that: mixed")
|
|
print(" types, and a real transposition there raises IndexError rather "
|
|
"than lying")
|
|
print(" quietly. The hazard is a SAME-TYPED pair; read the list with "
|
|
"that in hand.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|