diff --git a/controller/scripts/i18n_go_parity.py b/controller/scripts/i18n_go_parity.py index e216eb6..8ac6972 100644 --- a/controller/scripts/i18n_go_parity.py +++ b/controller/scripts/i18n_go_parity.py @@ -33,6 +33,12 @@ Checks: 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 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`), 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 +# ---- 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: ap = argparse.ArgumentParser() ap.add_argument("--capture", action="store_true", @@ -334,16 +484,24 @@ def main(argv) -> int: " base (verbs removed): %r" % (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, " - "%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), - len(named))) + len(named), judged)) if bad: print("\ngo-parity gate CONVICTS (%d):" % len(bad)) for b in bad: print(" " + b) 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 diff --git a/controller/scripts/test_gate_decoys.py b/controller/scripts/test_gate_decoys.py index 90d210b..e3e3dfc 100644 --- a/controller/scripts/test_gate_decoys.py +++ b/controller/scripts/test_gate_decoys.py @@ -46,7 +46,8 @@ COVERS = { "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)", "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", "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", @@ -260,6 +261,21 @@ swapped("go-parity/invented", "i18n_go_parity.py", GO_KEYS, swapped("go-parity/unlisted", "i18n_go_parity.py", GO_KEYS, _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 --- # --- question — what it COUNTED is. --- ran += 1