R-576: go-parity gate also judges the CALL — arity vs printf verbs, and no key glued with +
Checks 4 (ARITY) and 5 (NO-CONCAT) in i18n_go_parity.py: at every message-helper call (MsgError/MsgErrorf/Text/Msgf/msg/msgLang/msgHU/note) whose key is a literal hu.json knows, the argument count must equal the Hungarian value's printf verbs; a known key literal may never be an operand of +. 485 calls judged, 0 defects today. Decoys: a dropped argument and a key+suffix concatenation on a real producer convict; a nested-paren argument is accepted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -33,6 +33,12 @@ Checks:
|
|||||||
3. BYTE-EQUAL. A single-literal key: hu.json[key] == from, exactly. A joined key: hu.json[key]
|
3. BYTE-EQUAL. A single-literal key: hu.json[key] == from, exactly. A joined key: hu.json[key]
|
||||||
with its printf verbs removed == the `from` fragments concatenated in order, with their verbs
|
with its printf verbs removed == the `from` fragments concatenated in order, with their verbs
|
||||||
removed too. Plural keys compare their `.one`/`.other`-less base form.
|
removed too. Plural keys compare their `.one`/`.other`-less base form.
|
||||||
|
4. ARITY (R-576). At a message-helper call whose key is a literal hu.json knows, the arguments
|
||||||
|
after the key are as many as the Hungarian value's printf verbs.
|
||||||
|
5. NO-CONCAT (R-576). A literal naming a bundle key is never an operand of `+`.
|
||||||
|
Checks 1-3 ask whether the TEXT is real; 4-5 ask whether the CALL kept all of it. A structural
|
||||||
|
gate over the text cannot see a defect in the call -- that is how 7 producers lost half their
|
||||||
|
sentence on 2026-09-18 with this gate green.
|
||||||
|
|
||||||
The Go literal walker is the inventory's (felhom.eu/scripts/i18n_inventory.py `go_literals`),
|
The Go literal walker is the inventory's (felhom.eu/scripts/i18n_inventory.py `go_literals`),
|
||||||
copied rather than imported: this gate runs from a controller clone that may not sit beside
|
copied rather than imported: this gate runs from a controller clone that may not sit beside
|
||||||
@@ -233,6 +239,150 @@ def base_form(hu: dict, key: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- R-576: the CALL, not only the TEXT ---------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Checks 1-3 ask "is the text a key carries real?". They cannot see a call site that LOST text: on
|
||||||
|
# 2026-09-18 the bulk converter turned `fmt.Errorf("a: "+ "b: %s", x)` into a call that kept only
|
||||||
|
# the key and dropped the continuation and its argument -- 7 producers, and this gate stayed GREEN,
|
||||||
|
# because every surviving fragment WAS a byte-equal base literal. Two more questions convict it:
|
||||||
|
#
|
||||||
|
# 4. ARITY. At a message-helper call whose key argument is a literal hu.json knows, the number of
|
||||||
|
# arguments after the key equals the number of printf verbs in the Hungarian value. A call that
|
||||||
|
# spreads a slice (`args...`) is not counted -- its arity is not visible in the source.
|
||||||
|
# 5. NO-CONCAT. A literal that names a bundle key is never an operand of `+`. A key is a whole
|
||||||
|
# name; a key glued to more text is either a half-converted concatenation or a key nobody can
|
||||||
|
# find.
|
||||||
|
#
|
||||||
|
# helper name -> index of its KEY argument. Matched by NAME (no type checker), so only a call whose
|
||||||
|
# key argument is a literal hu.json actually knows is ever judged.
|
||||||
|
MSG_HELPERS = {
|
||||||
|
"MsgError": 0, # util.MsgError(key, args...)
|
||||||
|
"MsgErrorf": 1, # util.MsgErrorf(kind, key, args...)
|
||||||
|
"Text": 1, # util.Text(lang, key, args...)
|
||||||
|
"Msgf": 1, # (*i18n.Bundle).Msgf(lang, key, args...)
|
||||||
|
"msg": 1, # (*Router|*Server).msg(req, key, args...)
|
||||||
|
"msgLang": 1, # (*Router|*Server).msgLang(lang, key, args...)
|
||||||
|
"msgHU": 0, # stacks.msgHU(key, args...)
|
||||||
|
"note": 0, # (*backup.Manager).note(key, args...)
|
||||||
|
}
|
||||||
|
CALL_RE = re.compile(r"\b(" + "|".join(sorted(MSG_HELPERS, key=len, reverse=True)) + r")\(")
|
||||||
|
|
||||||
|
|
||||||
|
def code_mask(src: str):
|
||||||
|
"""(mask, lits): mask is 1 where src[i] is CODE (outside comments, strings and runes); lits is
|
||||||
|
the (start, end) span of every interpreted string literal, quotes included."""
|
||||||
|
mask = bytearray(len(src))
|
||||||
|
lits = []
|
||||||
|
i, n = 0, len(src)
|
||||||
|
while i < n:
|
||||||
|
c = src[i]
|
||||||
|
if src.startswith("//", i):
|
||||||
|
j = src.find("\n", i)
|
||||||
|
i = n if j < 0 else j
|
||||||
|
elif src.startswith("/*", i):
|
||||||
|
j = src.find("*/", i + 2)
|
||||||
|
i = n if j < 0 else j + 2
|
||||||
|
elif c in "'\"":
|
||||||
|
j = i + 1
|
||||||
|
while j < n and src[j] != c and src[j] != "\n":
|
||||||
|
j += 2 if src[j] == "\\" else 1
|
||||||
|
if c == '"':
|
||||||
|
lits.append((i, j + 1))
|
||||||
|
i = j + 1
|
||||||
|
elif c == "`":
|
||||||
|
j = src.find("`", i + 1)
|
||||||
|
i = n if j < 0 else j + 1
|
||||||
|
else:
|
||||||
|
mask[i] = 1
|
||||||
|
i += 1
|
||||||
|
return mask, lits
|
||||||
|
|
||||||
|
|
||||||
|
def split_args(src: str, mask, open_paren: int):
|
||||||
|
"""The top-level argument source strings of the call whose '(' is at open_paren, or None."""
|
||||||
|
depth, start, args, i, n = 0, open_paren + 1, [], open_paren, len(src)
|
||||||
|
while i < n:
|
||||||
|
if mask[i]:
|
||||||
|
c = src[i]
|
||||||
|
if c in "([{":
|
||||||
|
depth += 1
|
||||||
|
elif c in ")]}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
tail = src[start:i].strip()
|
||||||
|
if tail:
|
||||||
|
args.append(tail)
|
||||||
|
return args
|
||||||
|
elif c == "," and depth == 1:
|
||||||
|
args.append(src[start:i].strip())
|
||||||
|
start = i + 1
|
||||||
|
i += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verb_arity(value: str) -> int:
|
||||||
|
"""How many arguments the printf verbs of value consume (explicit indexes respected)."""
|
||||||
|
s = value.replace("%%", "")
|
||||||
|
need, nxt = 0, 0
|
||||||
|
for m in VERB_RE.finditer(s):
|
||||||
|
idx = re.match(r"%\[(\d+)\]", m.group(0))
|
||||||
|
if idx:
|
||||||
|
nxt = int(idx.group(1))
|
||||||
|
else:
|
||||||
|
nxt += 1
|
||||||
|
need = max(need, nxt)
|
||||||
|
return need
|
||||||
|
|
||||||
|
|
||||||
|
STR_LIT_RE = re.compile(r'^"((?:[^"\\\n]|\\.)*)"$')
|
||||||
|
|
||||||
|
|
||||||
|
def call_site_defects(root: str, hu) -> tuple:
|
||||||
|
"""Checks 4 (ARITY) and 5 (NO-CONCAT) over every in-scope Go file -> (defects, calls judged)."""
|
||||||
|
bad, checked = [], 0
|
||||||
|
if hu is None:
|
||||||
|
return bad, checked
|
||||||
|
for p in go_files(root):
|
||||||
|
rel = os.path.relpath(p, root)
|
||||||
|
src = read(p)
|
||||||
|
mask, lits = code_mask(src)
|
||||||
|
# 5. NO-CONCAT: a known key literal with a `+` on either side.
|
||||||
|
for a, b in lits:
|
||||||
|
text = unescape(src[a + 1:b - 1])
|
||||||
|
if text not in hu or not KEY_SHAPE_RE.match(text):
|
||||||
|
continue
|
||||||
|
left = src[:a].rstrip(" \t")
|
||||||
|
right = src[b:].lstrip(" \t")
|
||||||
|
if left.endswith("+") or right.startswith("+"):
|
||||||
|
line = src.count("\n", 0, a) + 1
|
||||||
|
bad.append("CONCAT %s is glued to more text with + at %s:%d -- a key is a whole "
|
||||||
|
"name; this is a half-converted concatenation" % (text, rel, line))
|
||||||
|
# 4. ARITY.
|
||||||
|
for m in CALL_RE.finditer(src):
|
||||||
|
if not mask[m.start()]:
|
||||||
|
continue
|
||||||
|
args = split_args(src, mask, m.end() - 1)
|
||||||
|
k = MSG_HELPERS[m.group(1)]
|
||||||
|
if args is None or len(args) <= k:
|
||||||
|
continue
|
||||||
|
lit = STR_LIT_RE.match(args[k])
|
||||||
|
if not lit:
|
||||||
|
continue
|
||||||
|
key = unescape(lit.group(1))
|
||||||
|
if key not in hu or not KEY_SHAPE_RE.match(key):
|
||||||
|
continue # a plural key (.one/.other only) or not a key at all
|
||||||
|
rest = args[k + 1:]
|
||||||
|
if rest and rest[-1].endswith("..."):
|
||||||
|
continue # a spread slice: arity not visible
|
||||||
|
checked += 1
|
||||||
|
want = verb_arity(hu[key])
|
||||||
|
if len(rest) != want:
|
||||||
|
line = src.count("\n", 0, m.start()) + 1
|
||||||
|
bad.append("ARITY %s called with %d argument(s) at %s:%d, its Hungarian value "
|
||||||
|
"has %d printf verb(s): %r" % (key, len(rest), rel, line, want, hu[key]))
|
||||||
|
return bad, checked
|
||||||
|
|
||||||
|
|
||||||
def main(argv) -> int:
|
def main(argv) -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--capture", action="store_true",
|
ap.add_argument("--capture", action="store_true",
|
||||||
@@ -334,16 +484,24 @@ def main(argv) -> int:
|
|||||||
" base (verbs removed): %r"
|
" base (verbs removed): %r"
|
||||||
% (key, len(froms), got, want))
|
% (key, len(froms), got, want))
|
||||||
|
|
||||||
|
# 4 + 5. ARITY and NO-CONCAT (R-576) -- the call, not the text.
|
||||||
|
call_bad, judged = call_site_defects(CTRL, hu)
|
||||||
|
bad.extend(call_bad)
|
||||||
|
if judged == 0:
|
||||||
|
bad.append("NO-CALLS no message-helper call with a literal key was found -- the call scanner "
|
||||||
|
"no longer matches the code, so checks 4-5 would be green by seeing nothing")
|
||||||
|
|
||||||
print("go-parity: base capture %s (%d literals), %d slice-2 keys listed, %d pre-existing, "
|
print("go-parity: base capture %s (%d literals), %d slice-2 keys listed, %d pre-existing, "
|
||||||
"%d named in Go"
|
"%d named in Go, %d helper calls judged for arity"
|
||||||
% (base.get("commit", "?")[:12] or "?", len(base_lits), len(keys), len(preexisting),
|
% (base.get("commit", "?")[:12] or "?", len(base_lits), len(keys), len(preexisting),
|
||||||
len(named)))
|
len(named), judged))
|
||||||
if bad:
|
if bad:
|
||||||
print("\ngo-parity gate CONVICTS (%d):" % len(bad))
|
print("\ngo-parity gate CONVICTS (%d):" % len(bad))
|
||||||
for b in bad:
|
for b in bad:
|
||||||
print(" " + b)
|
print(" " + b)
|
||||||
return 1
|
return 1
|
||||||
print("go-parity gate OK: every Go-side key carries base-commit text, byte for byte.")
|
print("go-parity gate OK: every Go-side key carries base-commit text, byte for byte, and every "
|
||||||
|
"judged call passes as many arguments as its message has verbs.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ COVERS = {
|
|||||||
"minagent-header": "the word MinAgent in prose / a code span, not the header line (test_minagent_header_gate.py)",
|
"minagent-header": "the word MinAgent in prose / a code span, not the header line (test_minagent_header_gate.py)",
|
||||||
"i18n": "an undefined marker key, a pleading English value, a shrinking/growing gap (v0.247.0)",
|
"i18n": "an undefined marker key, a pleading English value, a shrinking/growing gap (v0.247.0)",
|
||||||
"go-parity": "a Go-side key REWORDED, a key citing text no base literal has, and a converted "
|
"go-parity": "a Go-side key REWORDED, a key citing text no base literal has, and a converted "
|
||||||
"key left out of the map (v0.252.0, R-557)",
|
"key left out of the map (v0.252.0, R-557); a call that lost an argument, and a key glued "
|
||||||
|
"to more text with + (R-576)",
|
||||||
"gofmt": "R-454: a planted unformatted .go file in internal/ is convicted; the clean tree passes",
|
"gofmt": "R-454: a planted unformatted .go file in internal/ is convicted; the clean tree passes",
|
||||||
"offbox-rename": "R-425: NAS branding in a NEW backups*.html, and in a bundle value an offbox Go file "
|
"offbox-rename": "R-425: NAS branding in a NEW backups*.html, and in a bundle value an offbox Go file "
|
||||||
"names; control: the same token in the network-storage feature's copy is accepted",
|
"names; control: the same token in the network-storage feature's copy is accepted",
|
||||||
@@ -260,6 +261,21 @@ swapped("go-parity/invented", "i18n_go_parity.py", GO_KEYS,
|
|||||||
swapped("go-parity/unlisted", "i18n_go_parity.py", GO_KEYS,
|
swapped("go-parity/unlisted", "i18n_go_parity.py", GO_KEYS,
|
||||||
_one(_A_KEY + ",\n", ""))
|
_one(_A_KEY + ",\n", ""))
|
||||||
|
|
||||||
|
# 4-5. R-576 -- the CALL lost text while every surviving fragment stayed real (2026-09-18, 7
|
||||||
|
# producers, this gate green). Built on a real producer: the update-already-running refusal.
|
||||||
|
UPDATE_GO = os.path.join(CTRL, "internal", "stacks", "update.go")
|
||||||
|
_R576 = 'util.MsgError("update.refusal.already", name), "lost the race'
|
||||||
|
# the argument dropped -- the key's text is still byte-equal, only the call is short.
|
||||||
|
swapped("go-parity/lost-argument", "i18n_go_parity.py", UPDATE_GO,
|
||||||
|
_one(_R576, 'util.MsgError("update.refusal.already"), "lost the race'))
|
||||||
|
# the key glued to a continuation with + -- the converter's exact shape.
|
||||||
|
swapped("go-parity/key-concat", "i18n_go_parity.py", UPDATE_GO,
|
||||||
|
_one(_R576, 'util.MsgError("update.refusal.already" + suffix, name), "lost the race'))
|
||||||
|
# CONTROL: one argument that itself holds parens and commas is still ONE argument.
|
||||||
|
swapped("go-parity/nested-arg-ok", "i18n_go_parity.py", UPDATE_GO,
|
||||||
|
_one(_R576, 'util.MsgError("update.refusal.already", fmt.Sprintf("%s,%s", f(a, b), c)), "lost the race'),
|
||||||
|
expect="accept")
|
||||||
|
|
||||||
# --- golden-notice: R-410's decoy, in the other direction. It is ADVISORY, so rc is never the ---
|
# --- golden-notice: R-410's decoy, in the other direction. It is ADVISORY, so rc is never the ---
|
||||||
# --- question — what it COUNTED is. ---
|
# --- question — what it COUNTED is. ---
|
||||||
ran += 1
|
ran += 1
|
||||||
|
|||||||
Reference in New Issue
Block a user