027a573d89
Retires the defect class behind #129. The engine's state store returns "" for a key nothing writes — no error, no warning, no log. That is how the agentic path's crisis-escalation input scored 0 on every real conversation for two days afterff421d3moved conversation history behind conv_hist_key(session_id) and left one consumer reading the old "conv_history" bucket by hand. scripts/verify-state-keys.sh is the gate; scripts/state-key-audit.py is the El reader behind it. Two checks: DEAD-READ a state_get whose key resolves to something no state_set in the tree produces. HAND-ROLLED a literal that belongs to a namespace a helper owns, accessed without the helper. This is #129's actual shape, and DEAD-READ alone does NOT catch it: the dead handle_chat() still writes "conv_history" through conv_hist_key(""). Stating that plainly because a gate that only appears to work is worse than none. WHY IT DOES NOT CRY WOLF. Keys are usually computed, so a literal-matching script would flood and be switched off in a day. The resolver handles concatenation (matched on the static prefix), helper functions (resolved to their possible returns, with guard conditions folded so conv_hist_key("") does not falsely claim to produce the session_hist_ namespace), keys built into a local, and keys arriving as a parameter (resolved through the call sites). 278 of 278 sites on this tree resolve: UNRESOLVED 0, FINDINGS 0. Unresolvable keys would be listed and would NOT fail the build. TWO-LEG PROOF, one variable — agentic_safety_screen's single line: pre-fix scripts/verify-state-keys.sh --root <scratch> chat.el:2536 state_get("conv_history") conv_hist_key() owns this key namespace (EXACT 'conv_history') FAIL: 1 state-key finding(s) exit 1 as-is scripts/verify-state-keys.sh FINDINGS (0) ... PASS exit 0 INDEPENDENT CONFIRMATION: run read-only against origin/feat/soul-openai-tools-v2, which carries the same defect on its own, the gate reported chat.el:2937 — the exact line 43d0449's message had named by hand, with no prior knowledge. Against origin/fix/129-on-openai-tools: PASS. PRODUCER-MOVED CONTROLS: renaming the sole writer of an EXACT key (soul_model) orphans 3 readers across 3 files; renaming the sole writer of a PREFIX namespace (agent_workspace_root_*) orphans 3 readers, including when the producer moves to a NARROWER namespace — a case an earlier, more permissive prefix rule let through. That rule is now directional, with the reason written next to it. FOUND ON ITS FIRST RUN, unprompted: soul.el's state_set("soul_identity", ...) was deleted 2026-05-13 inb163fa6(a commit about awareness/ISE writes) and five readers in chat.el were left behind — build_system_prompt, the vision handler, the agentic system prompt and two council handlers have prefixed "" for ~3 months. studio.el:57 emits "principal":"" and never had a producer. Both are recorded in state-key-baseline.txt with dates and causes so the gate can be turned on today; they are DEBT, not false positives, and every run prints them. Baseline signatures carry no line number (an unrelated edit must not un-mute an accepted finding) but do carry a count, so a GROWTH in a baselined finding still fails the build. Engine behaviour unchanged: this commit adds scripts only, no .el is touched. CI is deliberately NOT wired here — .gitea/workflows/ci.yaml has changes in flight from someone else, and turning the gate on would immediately red feat/soul-openai-tools-v2 (correctly). That flip should be deliberate. Rung reached: RUNS — the gate executes (0.15s), discriminates on four independent test pairs, and its verdicts are quoted above. Not wired to CI, and no engine binary was built from this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
938 lines
36 KiB
Python
Executable File
938 lines
36 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""state-key-audit.py — the analyzer behind scripts/verify-state-keys.sh.
|
|
|
|
Read that script's header for WHY this exists (issue #129). This file is the
|
|
HOW: a small El reader that resolves the key expression at every state_get /
|
|
state_set site, including keys that are computed.
|
|
|
|
WHAT IT PARSES
|
|
El as this engine writes it: `fn f(a: T, b: T) -> T { ... }`, `let x: T = e`,
|
|
`return e`, `if c { a } else { b }` as an expression, `+` concatenation,
|
|
`"..."` with backslash escapes, `//` line comments. No block comments, no
|
|
const/match/struct exist in this dialect (verified over the whole tree).
|
|
|
|
KEY PATTERNS — the only two things a key expression can resolve to
|
|
EXACT "soul_model" the whole key is known
|
|
PREFIX "session_hist_" a known head, then runtime text
|
|
(plus UNRESOLVED, which is a report line and never a failure)
|
|
|
|
RESOLUTION — resolve_expr() returns a SET of patterns; unions are how branches,
|
|
multiple returns, and multiple bindings of one name are represented.
|
|
literal "k" -> {EXACT k}
|
|
concat A + B -> fold left; all-static -> EXACT,
|
|
static head + dynamic tail -> PREFIX
|
|
if-expression if c {A} else {B} -> resolve(A) | resolve(B), except that
|
|
str_eq(X,"") with X statically ""
|
|
folds to the taken branch only
|
|
call f(args) -> union over f's return expressions,
|
|
with f's params bound to THIS call
|
|
site's actual argument expressions
|
|
local var let k = e; state_get(k)-> union over every `let k =` in the
|
|
enclosing function
|
|
parameter fn g(k) { state_get(k) }-> union over the argument at that
|
|
position across every call site of g
|
|
anything else json_get(...), env(...)-> UNRESOLVED
|
|
Recursion is depth- and cycle-guarded; a guard trip yields UNRESOLVED, never a
|
|
failure.
|
|
|
|
COVERAGE — a read is satisfied when some write can produce the same key:
|
|
read EXACT k <- write EXACT k, or write PREFIX p where k starts with p
|
|
read PREFIX p <- write EXACT k where k starts with p, or write PREFIX q
|
|
where p and q are prefixes of each other
|
|
Deliberately permissive at the boundaries: a gate that cries wolf gets deleted.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
MAX_DEPTH = 12
|
|
|
|
# ── patterns ────────────────────────────────────────────────────────────────
|
|
EXACT = "exact"
|
|
PREFIX = "prefix"
|
|
|
|
|
|
def pat_exact(s):
|
|
return (EXACT, s)
|
|
|
|
|
|
def pat_prefix(s):
|
|
# A prefix with no static text at all carries no information; that is the
|
|
# UNRESOLVED case, not a pattern.
|
|
return (PREFIX, s) if s else None
|
|
|
|
|
|
def covers(write, read):
|
|
"""Can a write of pattern `write` produce a key that `read` reads?
|
|
|
|
The prefix rule is DIRECTIONAL, and that direction is the whole point. A
|
|
write namespace that is the same or BROADER than the read namespace covers
|
|
it (write "rl:" covers read "rl:x"). A write namespace that is NARROWER does
|
|
NOT (write "session_histv2_" does not cover read "session_hist_") — being
|
|
permissive there re-opens the exact hole this gate exists to close: rename
|
|
the producer, leave the readers, stay green. Verified with a control run
|
|
that renames sessions.el's writer and leaves its four readers behind."""
|
|
wk, wv = write
|
|
rk, rv = read
|
|
if rk == EXACT:
|
|
return rv == wv if wk == EXACT else rv.startswith(wv)
|
|
# read is a PREFIX: some key starting with rv is read
|
|
if wk == EXACT:
|
|
return wv.startswith(rv) # that one written key is in range
|
|
return rv.startswith(wv) # write namespace same-or-broader
|
|
|
|
|
|
# ── lexer ───────────────────────────────────────────────────────────────────
|
|
TOK_STR, TOK_IDENT, TOK_PUNCT, TOK_NUM = "str", "ident", "punct", "num"
|
|
IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
NUM_RE = re.compile(r"[0-9]+(\.[0-9]+)?")
|
|
|
|
|
|
class Tok:
|
|
__slots__ = ("kind", "val", "line")
|
|
|
|
def __init__(self, kind, val, line):
|
|
self.kind, self.val, self.line = kind, val, line
|
|
|
|
def __repr__(self):
|
|
return "%s(%r)@%d" % (self.kind, self.val, self.line)
|
|
|
|
|
|
def lex(src):
|
|
toks, i, n, line = [], 0, len(src), 1
|
|
while i < n:
|
|
c = src[i]
|
|
if c == "\n":
|
|
line += 1
|
|
i += 1
|
|
continue
|
|
if c in " \t\r":
|
|
i += 1
|
|
continue
|
|
if c == "/" and i + 1 < n and src[i + 1] == "/":
|
|
while i < n and src[i] != "\n":
|
|
i += 1
|
|
continue
|
|
if c == '"':
|
|
j, buf = i + 1, []
|
|
while j < n:
|
|
if src[j] == "\\" and j + 1 < n:
|
|
esc = src[j + 1]
|
|
buf.append({"n": "\n", "t": "\t", "r": "\r"}.get(esc, esc))
|
|
j += 2
|
|
continue
|
|
if src[j] == '"':
|
|
break
|
|
if src[j] == "\n":
|
|
line += 1
|
|
buf.append(src[j])
|
|
j += 1
|
|
toks.append(Tok(TOK_STR, "".join(buf), line))
|
|
i = j + 1
|
|
continue
|
|
m = IDENT_RE.match(src, i)
|
|
if m:
|
|
toks.append(Tok(TOK_IDENT, m.group(0), line))
|
|
i = m.end()
|
|
continue
|
|
m = NUM_RE.match(src, i)
|
|
if m:
|
|
toks.append(Tok(TOK_NUM, m.group(0), line))
|
|
i = m.end()
|
|
continue
|
|
toks.append(Tok(TOK_PUNCT, c, line))
|
|
i += 1
|
|
return toks
|
|
|
|
|
|
def match_close(toks, i, open_ch, close_ch):
|
|
"""toks[i] is open_ch; return index of its matching close_ch."""
|
|
depth = 0
|
|
while i < len(toks):
|
|
if toks[i].kind == TOK_PUNCT:
|
|
if toks[i].val == open_ch:
|
|
depth += 1
|
|
elif toks[i].val == close_ch:
|
|
depth -= 1
|
|
if depth == 0:
|
|
return i
|
|
i += 1
|
|
return len(toks) - 1
|
|
|
|
|
|
# ── program model ───────────────────────────────────────────────────────────
|
|
class Func:
|
|
def __init__(self, name, path, line, params, toks, start, end):
|
|
self.name, self.path, self.line = name, path, line
|
|
self.params = params # [param name]
|
|
self.toks = toks # the whole file's token list
|
|
self.start, self.end = start, end # body token range, exclusive of braces
|
|
self.lets = None # name -> [expr token ranges], lazily built
|
|
|
|
|
|
class Site:
|
|
def __init__(self, kind, path, line, func, arg_range, text):
|
|
self.kind = kind # "get" | "set"
|
|
self.path, self.line = path, line
|
|
self.func = func
|
|
self.arg_range = arg_range
|
|
self.text = text # source text of the key expression
|
|
self.pats = set()
|
|
self.unresolved = False
|
|
self.literal = None # set when the key expression is a bare literal
|
|
|
|
|
|
class Program:
|
|
def __init__(self):
|
|
self.files = {} # path -> toks
|
|
self.funcs = {} # name -> [Func] (El allows no overloads, but be safe)
|
|
self.toplevel = [] # [Func] one per file, params=[]
|
|
self.sites = [] # [Site]
|
|
self.calls = {} # callee name -> [(Func caller, [arg ranges])]
|
|
|
|
# -- loading ------------------------------------------------------------
|
|
def load(self, path, rel):
|
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
src = fh.read()
|
|
toks = lex(src)
|
|
self.files[rel] = toks
|
|
self._scan_funcs(rel, toks)
|
|
|
|
def _scan_funcs(self, rel, toks):
|
|
covered = []
|
|
i = 0
|
|
while i < len(toks):
|
|
t = toks[i]
|
|
if t.kind == TOK_IDENT and t.val == "fn" and i + 2 < len(toks) \
|
|
and toks[i + 1].kind == TOK_IDENT and toks[i + 2].val == "(":
|
|
name = toks[i + 1].val
|
|
pclose = match_close(toks, i + 2, "(", ")")
|
|
params = self._params(toks, i + 3, pclose)
|
|
bopen = pclose + 1
|
|
while bopen < len(toks) and toks[bopen].val != "{":
|
|
bopen += 1
|
|
bclose = match_close(toks, bopen, "{", "}")
|
|
f = Func(name, rel, t.line, params, toks, bopen + 1, bclose)
|
|
self.funcs.setdefault(name, []).append(f)
|
|
covered.append((i, bclose))
|
|
i = bclose + 1
|
|
continue
|
|
i += 1
|
|
# everything outside a fn is the file's top-level "function"
|
|
tl = Func("<toplevel:%s>" % rel, rel, 1, [], toks, 0, len(toks))
|
|
tl.covered = covered
|
|
self.toplevel.append(tl)
|
|
|
|
@staticmethod
|
|
def _params(toks, i, end):
|
|
"""`a: T, b: T` -> ['a','b'] (top-level commas only)."""
|
|
names, depth, expect = [], 0, True
|
|
while i < end:
|
|
t = toks[i]
|
|
if t.kind == TOK_PUNCT and t.val in "([{":
|
|
depth += 1
|
|
elif t.kind == TOK_PUNCT and t.val in ")]}":
|
|
depth -= 1
|
|
elif depth == 0 and t.kind == TOK_PUNCT and t.val == ",":
|
|
expect = True
|
|
elif depth == 0 and expect and t.kind == TOK_IDENT:
|
|
names.append(t.val)
|
|
expect = False
|
|
i += 1
|
|
return names
|
|
|
|
def func_at(self, rel, tok_index):
|
|
for f in self.funcs_in(rel):
|
|
if f.start <= tok_index < f.end:
|
|
return f
|
|
for f in self.toplevel:
|
|
if f.path == rel:
|
|
return f
|
|
return None
|
|
|
|
def funcs_in(self, rel):
|
|
for fl in self.funcs.values():
|
|
for f in fl:
|
|
if f.path == rel:
|
|
yield f
|
|
|
|
# -- indexing -----------------------------------------------------------
|
|
def index(self):
|
|
for rel, toks in self.files.items():
|
|
i = 0
|
|
while i < len(toks):
|
|
t = toks[i]
|
|
if t.kind == TOK_IDENT and i + 1 < len(toks) and toks[i + 1].val == "(" \
|
|
and t.val not in KEYWORDS \
|
|
and not (i > 0 and toks[i - 1].kind == TOK_IDENT
|
|
and toks[i - 1].val == "fn"):
|
|
# ^ the `fn f(a: T)` declaration is not a call site; counting
|
|
# it as one makes every parameter resolve to its own name
|
|
# and reports the whole function UNRESOLVED.
|
|
close = match_close(toks, i + 1, "(", ")")
|
|
args = split_args(toks, i + 2, close)
|
|
self.calls.setdefault(t.val, []).append(
|
|
(self.func_at(rel, i), args, rel, t.line))
|
|
if t.val in ("state_get", "state_set") and args:
|
|
self.sites.append(Site(
|
|
"get" if t.val == "state_get" else "set",
|
|
rel, t.line, self.func_at(rel, i), args[0],
|
|
render(toks, *args[0])))
|
|
i += 1
|
|
|
|
# -- resolution ---------------------------------------------------------
|
|
def lets_of(self, f):
|
|
if f.lets is not None:
|
|
return f.lets
|
|
f.lets = {}
|
|
toks = f.toks
|
|
skip = getattr(f, "covered", [])
|
|
i = f.start
|
|
while i < f.end:
|
|
if any(a <= i <= b for a, b in skip):
|
|
i = max(b for a, b in skip if a <= i <= b) + 1
|
|
continue
|
|
t = toks[i]
|
|
if t.kind == TOK_IDENT and t.val == "let" and i + 1 < f.end \
|
|
and toks[i + 1].kind == TOK_IDENT:
|
|
name = toks[i + 1].val
|
|
j = i + 2
|
|
if j < f.end and toks[j].val == ":": # skip the type
|
|
while j < f.end and toks[j].val != "=":
|
|
j += 1
|
|
if j < f.end and toks[j].val == "=":
|
|
s = j + 1
|
|
e = stmt_end(toks, s, f.end)
|
|
f.lets.setdefault(name, []).append((s, e))
|
|
i = e
|
|
continue
|
|
i += 1
|
|
return f.lets
|
|
|
|
def returns_of(self, ctx, depth=0, seen=None):
|
|
"""The value expressions of a function, in the context it was CALLED in.
|
|
|
|
Context-sensitive on purpose. `conv_hist_key` is written as a guard:
|
|
|
|
if str_eq(session_id, "") { return "conv_history" }
|
|
return "session_hist_" + session_id
|
|
|
|
Collecting both returns flat would make state_set(conv_hist_key("")) — the
|
|
dead handle_chat() write — claim to produce the session_hist_ namespace
|
|
too. That is a producer this engine does not actually have, and claiming
|
|
it would let the gate stay green if sessions.el's real writer vanished:
|
|
a masking hole in the exact namespace #129 lives in. So a guard whose
|
|
condition folds is honoured, and the branch not taken is dropped."""
|
|
out = []
|
|
self._values(ctx.toks, ctx.start, ctx.end, ctx, depth,
|
|
seen if seen is not None else set(), out)
|
|
return out
|
|
|
|
def _values(self, toks, s, e, ctx, depth, seen, out):
|
|
"""Append the value expressions of a statement sequence.
|
|
Returns True when the sequence definitely returns (rest unreachable)."""
|
|
if depth > MAX_DEPTH:
|
|
return False
|
|
i = s
|
|
while i < e:
|
|
t = toks[i]
|
|
if t.kind == TOK_IDENT and t.val == "return":
|
|
j = stmt_end(toks, i + 1, e)
|
|
if j > i + 1:
|
|
out.append((i + 1, j))
|
|
return True
|
|
if t.kind == TOK_IDENT and t.val == "let":
|
|
i = stmt_end(toks, i + 2, e)
|
|
continue
|
|
if t.kind == TOK_IDENT and t.val == "if":
|
|
i = self._if_stmt(toks, i, e, ctx, depth, seen, out)
|
|
if i is True:
|
|
return True
|
|
continue
|
|
if t.kind == TOK_PUNCT and t.val in "([{":
|
|
i = match_close(toks, i, t.val,
|
|
{"(": ")", "[": "]", "{": "}"}[t.val]) + 1
|
|
continue
|
|
en = stmt_end(toks, i, e)
|
|
if en <= i:
|
|
i += 1
|
|
continue
|
|
if en >= e: # trailing expression = the value
|
|
out.append((i, en))
|
|
i = en
|
|
return False
|
|
|
|
def _if_stmt(self, toks, i, e, ctx, depth, seen, out):
|
|
"""Walk one if / else-if / else chain. Returns the next index, or True
|
|
if the chain definitely returns on every reachable branch."""
|
|
bopen = i + 1
|
|
while bopen < e and toks[bopen].val != "{":
|
|
bopen += 1
|
|
if bopen >= e:
|
|
return e
|
|
bclose = match_close(toks, bopen, "{", "}")
|
|
fold = self._fold_cond(toks, i + 1, bopen, ctx, depth, seen)
|
|
|
|
j = bclose + 1
|
|
else_s = else_e = None
|
|
if j < e and toks[j].kind == TOK_IDENT and toks[j].val == "else":
|
|
if j + 1 < e and toks[j + 1].val == "{":
|
|
ec = match_close(toks, j + 1, "{", "}")
|
|
else_s, else_e = j + 2, ec
|
|
j = ec + 1
|
|
else: # `else if ...` — the rest of the chain
|
|
else_s = j + 1
|
|
else_e = stmt_end(toks, j + 1, e)
|
|
j = else_e
|
|
|
|
then_ret = else_ret = False
|
|
if fold is not False:
|
|
then_ret = self._values(toks, bopen + 1, bclose, ctx, depth + 1, seen, out)
|
|
if fold is not True and else_s is not None:
|
|
else_ret = self._values(toks, else_s, else_e, ctx, depth + 1, seen, out)
|
|
|
|
if fold is True and then_ret:
|
|
return True
|
|
if fold is False and else_s is not None and else_ret:
|
|
return True
|
|
if fold is None and else_s is not None and then_ret and else_ret:
|
|
return True
|
|
return j
|
|
|
|
def resolve(self, rng, func, depth=0, seen=None):
|
|
"""-> (set of patterns, unresolved_flag)"""
|
|
if seen is None:
|
|
seen = set()
|
|
if depth > MAX_DEPTH:
|
|
return set(), True
|
|
return self._expr(func.toks, rng[0], rng[1], func, depth, seen)
|
|
|
|
# -- expression walker --------------------------------------------------
|
|
def _expr(self, toks, s, e, func, depth, seen):
|
|
parts, cur, d = [], s, 0
|
|
i = s
|
|
while i < e: # split on top-level '+'
|
|
v = toks[i].val
|
|
if toks[i].kind == TOK_PUNCT and v in "([{":
|
|
d += 1
|
|
elif toks[i].kind == TOK_PUNCT and v in ")]}":
|
|
d -= 1
|
|
elif d == 0 and toks[i].kind == TOK_PUNCT and v == "+" and i > s:
|
|
parts.append((cur, i))
|
|
cur = i + 1
|
|
i += 1
|
|
parts.append((cur, e))
|
|
if len(parts) == 1:
|
|
return self._primary(toks, s, e, func, depth, seen)
|
|
|
|
# concatenation: keep folding while every operand so far is EXACT
|
|
head, unres = "", False
|
|
static = True
|
|
for (ps, pe) in parts:
|
|
pats, u = self._primary(toks, ps, pe, func, depth, seen)
|
|
exacts = {p[1] for p in pats if p[0] == EXACT}
|
|
if static and len(exacts) == 1 and not u and len(pats) == 1:
|
|
head += exacts.pop()
|
|
continue
|
|
if static and pats and all(p[0] == EXACT for p in pats) and len(pats) > 1:
|
|
# a branchy static operand: keep the shared head only
|
|
static = False
|
|
head += os.path.commonprefix(sorted({p[1] for p in pats}))
|
|
break
|
|
static = False
|
|
# first non-static operand: everything after it is runtime text
|
|
if (ps, pe) == parts[0]:
|
|
for p in pats:
|
|
if p[0] == PREFIX:
|
|
head = p[1]
|
|
break
|
|
if not head:
|
|
unres = True
|
|
break
|
|
if static:
|
|
return {pat_exact(head)}, False
|
|
p = pat_prefix(head)
|
|
return ({p} if p else set()), (unres or not p)
|
|
|
|
def _primary(self, toks, s, e, func, depth, seen):
|
|
while s < e and toks[s].kind == TOK_PUNCT and toks[s].val == "(" \
|
|
and match_close(toks, s, "(", ")") == e - 1:
|
|
s, e = s + 1, e - 1
|
|
if s >= e:
|
|
return set(), True
|
|
t = toks[s]
|
|
|
|
if t.kind == TOK_STR and e == s + 1:
|
|
return {pat_exact(t.val)}, False
|
|
|
|
if t.kind == TOK_IDENT and t.val == "if":
|
|
return self._if_expr(toks, s, e, func, depth, seen)
|
|
|
|
if t.kind == TOK_IDENT and s + 1 < e and toks[s + 1].val == "(":
|
|
close = match_close(toks, s + 1, "(", ")")
|
|
if close == e - 1:
|
|
return self._call(toks, t.val, split_args(toks, s + 2, close),
|
|
func, depth, seen)
|
|
|
|
if t.kind == TOK_IDENT and e == s + 1:
|
|
return self._var(t.val, func, depth, seen)
|
|
|
|
return set(), True
|
|
|
|
def _if_expr(self, toks, s, e, func, depth, seen):
|
|
bopen = s + 1
|
|
while bopen < e and toks[bopen].val != "{":
|
|
bopen += 1
|
|
cond = (s + 1, bopen)
|
|
bclose = match_close(toks, bopen, "{", "}")
|
|
then_rng = block_tail(toks, bopen + 1, bclose) or (bopen + 1, bclose)
|
|
|
|
else_rng = None
|
|
j = bclose + 1
|
|
if j < e and toks[j].kind == TOK_IDENT and toks[j].val == "else":
|
|
if j + 1 < e and toks[j + 1].val == "{":
|
|
ec = match_close(toks, j + 1, "{", "}")
|
|
else_rng = block_tail(toks, j + 2, ec) or (j + 2, ec)
|
|
else:
|
|
else_rng = (j + 1, e) # `else if ...`
|
|
|
|
taken = self._fold_cond(toks, cond[0], cond[1], func, depth, seen)
|
|
rngs = []
|
|
if taken is not False:
|
|
rngs.append(then_rng)
|
|
if taken is not True and else_rng:
|
|
rngs.append(else_rng)
|
|
|
|
pats, unres = set(), False
|
|
for r in rngs:
|
|
p, u = self._expr(toks, r[0], r[1], func, depth + 1, seen)
|
|
pats |= p
|
|
unres = unres or u
|
|
return pats, unres
|
|
|
|
def _fold_cond(self, toks, s, e, func, depth, seen):
|
|
"""Constant-fold `str_eq(X, "")` / `!str_eq(X, "")` so a helper called with
|
|
a literal (conv_hist_key("")) yields only the branch it really takes.
|
|
Returns True / False / None(unknown)."""
|
|
neg = False
|
|
if s < e and toks[s].kind == TOK_PUNCT and toks[s].val == "!":
|
|
neg, s = True, s + 1
|
|
if not (s < e and toks[s].kind == TOK_IDENT and toks[s].val == "str_eq"
|
|
and s + 1 < e and toks[s + 1].val == "("):
|
|
return None
|
|
close = match_close(toks, s + 1, "(", ")")
|
|
if close != e - 1:
|
|
return None
|
|
args = split_args(toks, s + 2, close)
|
|
if len(args) != 2:
|
|
return None
|
|
va, ua = self._expr(toks, args[0][0], args[0][1], func, depth + 1, seen)
|
|
vb, ub = self._expr(toks, args[1][0], args[1][1], func, depth + 1, seen)
|
|
if ua or ub or len(va) != 1 or len(vb) != 1:
|
|
return None
|
|
(ka, sa), (kb, sb) = va.pop(), vb.pop()
|
|
if ka != EXACT or kb != EXACT:
|
|
return None
|
|
r = (sa == sb)
|
|
return (not r) if neg else r
|
|
|
|
def _call(self, toks, name, args, func, depth, seen):
|
|
cands = self.funcs.get(name)
|
|
if not cands:
|
|
return set(), True # builtin: json_get, env, ...
|
|
pats, unres = set(), False
|
|
for callee in cands:
|
|
key = ("fn", callee.path, callee.name, tuple(args))
|
|
if key in seen:
|
|
unres = True
|
|
continue
|
|
seen = seen | {key}
|
|
# bind the callee's params to THIS call site's argument expressions
|
|
binding = {}
|
|
for idx, pname in enumerate(callee.params):
|
|
if idx < len(args):
|
|
binding[pname] = (args[idx], func)
|
|
callee_ctx = _Bound(callee, binding)
|
|
for r in self.returns_of(callee_ctx, depth + 1, seen):
|
|
p, u = self._expr(callee.toks, r[0], r[1], callee_ctx,
|
|
depth + 1, seen)
|
|
pats |= p
|
|
unres = unres or u
|
|
return pats, unres
|
|
|
|
def _var(self, name, func, depth, seen):
|
|
real = func.func if isinstance(func, _Bound) else func
|
|
|
|
# 1. a parameter bound by the call site we came through
|
|
if isinstance(func, _Bound) and name in func.binding:
|
|
rng, caller_ctx = func.binding[name]
|
|
return self._expr(caller_ctx.toks, rng[0], rng[1], caller_ctx,
|
|
depth + 1, seen)
|
|
|
|
# 2. a local `let` in the enclosing function
|
|
lets = self.lets_of(real)
|
|
if name in lets:
|
|
key = ("let", real.path, real.name, name)
|
|
if key in seen:
|
|
return set(), True
|
|
seen = seen | {key}
|
|
pats, unres = set(), False
|
|
for rng in lets[name]:
|
|
p, u = self._expr(real.toks, rng[0], rng[1], real, depth + 1, seen)
|
|
pats |= p
|
|
unres = unres or u
|
|
return pats, unres
|
|
|
|
# 3. an unbound parameter -> look at every call site of the enclosing fn
|
|
if name in real.params:
|
|
key = ("param", real.path, real.name, name)
|
|
if key in seen:
|
|
return set(), True
|
|
seen = seen | {key}
|
|
idx = real.params.index(name)
|
|
pats, unres = set(), False
|
|
sites = self.calls.get(real.name, [])
|
|
if not sites:
|
|
return set(), True
|
|
for caller, args, _rel, _line in sites:
|
|
if caller is None or idx >= len(args):
|
|
unres = True
|
|
continue
|
|
p, u = self._expr(caller.toks, args[idx][0], args[idx][1],
|
|
caller, depth + 1, seen)
|
|
pats |= p
|
|
unres = unres or u
|
|
return pats, unres
|
|
|
|
# 4. a file-level / cross-file top-level `let`
|
|
for tl in self.toplevel:
|
|
lets = self.lets_of(tl)
|
|
if name in lets:
|
|
key = ("let", tl.path, tl.name, name)
|
|
if key in seen:
|
|
return set(), True
|
|
seen2 = seen | {key}
|
|
pats, unres = set(), False
|
|
for rng in lets[name]:
|
|
p, u = self._expr(tl.toks, rng[0], rng[1], tl, depth + 1, seen2)
|
|
pats |= p
|
|
unres = unres or u
|
|
return pats, unres
|
|
|
|
return set(), True
|
|
|
|
|
|
class _Bound:
|
|
"""A callee view that also knows what its params were called with."""
|
|
|
|
def __init__(self, func, binding):
|
|
self.func, self.binding = func, binding
|
|
self.toks, self.start, self.end = func.toks, func.start, func.end
|
|
self.params, self.path, self.name = func.params, func.path, func.name
|
|
|
|
def __getattr__(self, k):
|
|
return getattr(self.func, k)
|
|
|
|
|
|
# ── token helpers ───────────────────────────────────────────────────────────
|
|
def split_args(toks, s, e):
|
|
out, cur, d = [], s, 0
|
|
i = s
|
|
while i < e:
|
|
v = toks[i].val
|
|
if toks[i].kind == TOK_PUNCT and v in "([{":
|
|
d += 1
|
|
elif toks[i].kind == TOK_PUNCT and v in ")]}":
|
|
d -= 1
|
|
elif d == 0 and toks[i].kind == TOK_PUNCT and v == ",":
|
|
out.append((cur, i))
|
|
cur = i + 1
|
|
i += 1
|
|
if cur < e:
|
|
out.append((cur, e))
|
|
return out
|
|
|
|
|
|
STMT_START = {"let", "return", "if", "while", "for"}
|
|
KEYWORDS = {"if", "while", "for", "return", "fn", "let", "else", "match"}
|
|
|
|
|
|
def stmt_end(toks, s, limit):
|
|
"""End of the expression starting at s: the next top-level statement
|
|
boundary. El has no semicolons, so a newline that starts a new statement
|
|
ends this one."""
|
|
d, i = 0, s
|
|
while i < limit:
|
|
t = toks[i]
|
|
if t.kind == TOK_PUNCT and t.val in "([":
|
|
d += 1
|
|
elif t.kind == TOK_PUNCT and t.val in ")]":
|
|
d -= 1
|
|
if d < 0:
|
|
return i
|
|
elif t.kind == TOK_PUNCT and t.val == "{":
|
|
# a brace at depth 0 belongs to this expression only when it is an
|
|
# if/else block that is part of it
|
|
d += 1
|
|
elif t.kind == TOK_PUNCT and t.val == "}":
|
|
d -= 1
|
|
if d < 0:
|
|
return i
|
|
elif d == 0 and t.kind == TOK_PUNCT and t.val == ",":
|
|
return i
|
|
elif d == 0 and i > s and t.kind == TOK_IDENT and t.val in STMT_START:
|
|
if t.val == "if" and toks[i - 1].kind == TOK_IDENT and toks[i - 1].val == "else":
|
|
i += 1
|
|
continue
|
|
return i
|
|
elif d == 0 and i > s and t.kind == TOK_IDENT and t.val == "fn":
|
|
return i
|
|
i += 1
|
|
return limit
|
|
|
|
|
|
def block_tail(toks, s, e):
|
|
"""The trailing expression of a block, if the block ends in one."""
|
|
i, last = s, None
|
|
while i < e:
|
|
t = toks[i]
|
|
if t.kind == TOK_IDENT and t.val in ("let", "return"):
|
|
i = stmt_end(toks, i + 1, e)
|
|
last = None
|
|
continue
|
|
if t.kind == TOK_PUNCT and t.val in "([{":
|
|
i = match_close(toks, i, t.val, {"(": ")", "[": "]", "{": "}"}[t.val]) + 1
|
|
continue
|
|
st = i
|
|
en = stmt_end(toks, i, e)
|
|
if en <= st:
|
|
i = st + 1
|
|
continue
|
|
last = (st, en)
|
|
i = en
|
|
return last
|
|
|
|
|
|
def render(toks, s, e):
|
|
out = []
|
|
for t in toks[s:e]:
|
|
out.append('"%s"' % t.val if t.kind == TOK_STR else t.val)
|
|
return " ".join(out)
|
|
|
|
|
|
# ── the gate ────────────────────────────────────────────────────────────────
|
|
def collect(root, include_tests):
|
|
files = []
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames
|
|
if d not in ("dist", "vendor", ".git", "node_modules")]
|
|
rel_dir = os.path.relpath(dirpath, root)
|
|
if not include_tests and rel_dir.split(os.sep)[0] == "tests":
|
|
continue
|
|
for fn in sorted(filenames):
|
|
if fn.endswith(".el"):
|
|
rel = os.path.normpath(os.path.join(rel_dir, fn))
|
|
files.append((os.path.join(dirpath, fn), rel))
|
|
return sorted(files, key=lambda x: x[1])
|
|
|
|
|
|
def is_bare_literal(prog, site):
|
|
toks = prog.files[site.path]
|
|
s, e = site.arg_range
|
|
return e == s + 1 and toks[s].kind == TOK_STR
|
|
|
|
|
|
def read_decl(path):
|
|
"""A declaration file: one entry per line, `# ...` comments stripped."""
|
|
out = []
|
|
if not path or not os.path.exists(path):
|
|
return out
|
|
with open(path) as fh:
|
|
for ln in fh:
|
|
ln = ln.split("#", 1)[0].strip()
|
|
if ln:
|
|
out.append(ln)
|
|
return out
|
|
|
|
|
|
def opt(argv, name, default=None):
|
|
for i, a in enumerate(argv):
|
|
if a == name and i + 1 < len(argv):
|
|
return argv[i + 1]
|
|
return default
|
|
|
|
|
|
def main(argv):
|
|
root = os.path.abspath(argv[1]) if len(argv) > 1 and not argv[1].startswith("-") else "."
|
|
include_tests = "--include-tests" in argv
|
|
verbose = "--verbose" in argv
|
|
baseline_path = opt(argv, "--baseline")
|
|
external_path = opt(argv, "--external")
|
|
|
|
prog = Program()
|
|
for path, rel in collect(root, include_tests):
|
|
prog.load(path, rel)
|
|
prog.index()
|
|
for site in prog.sites:
|
|
pats, unres = prog.resolve(site.arg_range, site.func)
|
|
site.pats, site.unresolved = {p for p in pats if p}, unres
|
|
if is_bare_literal(prog, site):
|
|
site.literal = prog.files[site.path][site.arg_range[0]].val
|
|
|
|
writes = [s for s in prog.sites if s.kind == "set"]
|
|
reads = [s for s in prog.sites if s.kind == "get"]
|
|
write_pats = set()
|
|
for w in writes:
|
|
write_pats |= w.pats
|
|
|
|
# Declared host-set keys: written by something outside the El tree (an
|
|
# operator, the installer, a host process). Each entry must carry a reason.
|
|
external = []
|
|
for ln in read_decl(external_path):
|
|
parts = ln.split(None, 1)
|
|
if len(parts) != 2 or parts[0] not in (EXACT, PREFIX):
|
|
print("bad --external line (want `exact|prefix <key>`): %r" % ln,
|
|
file=sys.stderr)
|
|
return 2
|
|
external.append((parts[0], parts[1]))
|
|
write_pats |= set(external)
|
|
|
|
# F1 — a read of a key no write in the tree produces.
|
|
f1 = []
|
|
for r in reads:
|
|
for p in sorted(r.pats):
|
|
if not any(covers(w, p) for w in write_pats):
|
|
f1.append((r, p))
|
|
|
|
# F2 — a key namespace owned by a helper, accessed by a hand-rolled literal.
|
|
# This is the #129 shape: the producer moved behind conv_hist_key() and
|
|
# one consumer kept spelling the old key out by hand.
|
|
owners = {} # helper fn name -> its value set
|
|
for s in prog.sites:
|
|
toks = prog.files[s.path]
|
|
a, b = s.arg_range
|
|
if toks[a].kind == TOK_IDENT and a + 1 < b and toks[a + 1].val == "(" \
|
|
and match_close(toks, a + 1, "(", ")") == b - 1 \
|
|
and toks[a].val in prog.funcs:
|
|
name = toks[a].val
|
|
if name not in owners:
|
|
vals = set()
|
|
for callee in prog.funcs[name]:
|
|
# No call context here on purpose: the OWNED namespace is
|
|
# every key the helper can ever produce, over all call sites.
|
|
for rng in prog.returns_of(callee):
|
|
p, _ = prog._expr(callee.toks, rng[0], rng[1], callee, 0, set())
|
|
vals |= {x for x in p if x}
|
|
owners[name] = vals
|
|
f2 = []
|
|
for s in prog.sites:
|
|
if s.literal is None:
|
|
continue
|
|
for owner, vals in sorted(owners.items()):
|
|
for v in sorted(vals):
|
|
if covers(v, pat_exact(s.literal)):
|
|
f2.append((s, owner, v))
|
|
break
|
|
else:
|
|
continue
|
|
break
|
|
|
|
unresolved = [s for s in prog.sites if s.unresolved or not s.pats]
|
|
|
|
# Baseline signatures carry NO line number on purpose: an unrelated edit that
|
|
# shifts a line must not un-mute an accepted finding (that is crying wolf),
|
|
# but a GROWTH in count must not hide either. So a baseline entry is
|
|
# `<file> <CODE> <detail> [xN]` and only the first N matches are muted.
|
|
baseline, bad_baseline = {}, []
|
|
for ln in read_decl(baseline_path):
|
|
n, key = 1, ln
|
|
parts = ln.rsplit(" x", 1)
|
|
if len(parts) == 2 and parts[1].isdigit():
|
|
key, n = parts[0].strip(), int(parts[1])
|
|
baseline[key] = n
|
|
|
|
def sig(path, code, detail):
|
|
return "%s %s %s" % (path, code, detail)
|
|
|
|
findings = []
|
|
for r, p in f1:
|
|
findings.append((sig(r.path, "DEAD-READ", "%s:%s" % p), r.line,
|
|
" %s:%d state_get(%s)\n resolves to %s %r — no state_set in the tree produces it"
|
|
% (r.path, r.line, r.text, p[0].upper(), p[1])))
|
|
for s, owner, v in f2:
|
|
findings.append((sig(s.path, "HAND-ROLLED", "%s<-%s()" % (s.literal, owner)), s.line,
|
|
" %s:%d state_%s(\"%s\")\n %s() owns this key namespace (%s %r) — go through the helper, "
|
|
"or a rename orphans this site silently" % (s.path, s.line, s.kind, s.literal, owner, v[0].upper(), v[1])))
|
|
findings.sort(key=lambda f: (f[0], f[1]))
|
|
|
|
live, muted, budget = [], [], dict(baseline)
|
|
for f in findings:
|
|
if budget.get(f[0], 0) > 0:
|
|
budget[f[0]] -= 1
|
|
muted.append(f)
|
|
else:
|
|
live.append(f)
|
|
stale = sorted(k for k, v in budget.items() if v > 0)
|
|
|
|
print("── state-key audit ─────────────────────────────────────────────")
|
|
print("scanned %d .el files%s" % (len(prog.files),
|
|
"" if include_tests else " (tests/ excluded)"))
|
|
print("sites %d state_set, %d state_get" % (len(writes), len(reads)))
|
|
print("keys %d distinct write patterns" % len(write_pats))
|
|
print("")
|
|
|
|
if verbose:
|
|
print("WRITE PATTERNS")
|
|
for k, v in sorted(write_pats):
|
|
print(" %-6s %s" % (k, v))
|
|
print("")
|
|
|
|
if external:
|
|
print("DECLARED HOST-SET (%d) — %s" % (len(external), external_path))
|
|
for k, v in sorted(external):
|
|
print(" %-6s %s" % (k, v))
|
|
print("")
|
|
|
|
print("UNRESOLVED (%d) — reported, never fails the build" % len(unresolved))
|
|
if not unresolved:
|
|
print(" (none)")
|
|
for s in sorted(unresolved, key=lambda x: (x.path, x.line)):
|
|
print(" %s:%d state_%s(%s)%s"
|
|
% (s.path, s.line, s.kind, s.text,
|
|
" [partial: %s]" % ", ".join("%s %r" % p for p in sorted(s.pats))
|
|
if s.pats else ""))
|
|
print("")
|
|
|
|
if muted:
|
|
print("BASELINED (%d) — pre-existing debt accepted in %s. NOT clean; fix these."
|
|
% (len(muted), baseline_path))
|
|
for sg, line, _ in muted:
|
|
print(" %s (line %d)" % (sg, line))
|
|
print("")
|
|
if stale:
|
|
print("STALE BASELINE (%d) — entries that no longer match anything; delete them:"
|
|
% len(stale))
|
|
for sg in stale:
|
|
print(" %s" % sg)
|
|
print("")
|
|
|
|
print("FINDINGS (%d)" % len(live))
|
|
if not live:
|
|
print(" (none)")
|
|
for _, _, body in live:
|
|
print(body)
|
|
print("")
|
|
|
|
if live:
|
|
print("FAIL: %d state-key finding(s). See scripts/verify-state-keys.sh "
|
|
"for why this gate exists (issue #129)." % len(live))
|
|
return 1
|
|
print("PASS: every resolvable state_get key has a producer, and no key "
|
|
"namespace is spelled two ways.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|