UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

817 lines (763 loc) • 34.2 kB
#!/usr/bin/env node /** * Generate `locales/en/main.json` from the `tr()` / `trFor()` call sites in src/. * * English is the SOURCE catalog: generated from the code, never hand-edited, so the key set * cannot drift from what this process actually prints. Every other language is then validated * against it (scripts/i18n-check.mjs). * * SCOPE — read the header of src/i18n/index.ts first. Almost everything this repo emits is read * by an LLM, not a person: the panel tool definitions, the `.describe()` parameter docs, the * system prompts, `src/tools/vocabulary.ts`. None of that may be translated, and none of it is * in here. A key exists in this catalog if and only if a call site already declared it; this * script never PROPOSES one. Deciding that a string is human-facing is a human decision made in * the source, by wrapping it — not a guess made here. * * That distinction is why the scan resolves aliases instead of matching on shape. The first * draft accepted any `f("dotted.key", "English…")`, and the very first thing it swept up was * `resolvePrompt("panel.persona", PANEL_SYSTEM_APPEND)` — the agent's entire system prompt, * handed to eleven translators. A catalog builder that cannot tell a translation call from a * prompt lookup is worse than no builder. * * node scripts/i18n-build-en.mjs # write locales/en/main.json * node scripts/i18n-build-en.mjs --print # write nothing; emit the catalog on stdout * node scripts/i18n-build-en.mjs --json # dump the parsed call sites instead */ import fs from "node:fs"; import path from "node:path"; const ROOT = path.resolve(import.meta.dirname, ".."); const SRC = path.join(ROOT, "src"); /** The two real entry points. Everything else is reached from them, per file. */ const SEED = [ ["tr", 0], ["trFor", 1], ]; /** A catalog key: a dotted, lower-first identifier path. Checked, not used to find sites. */ const KEY_SHAPE = /^[a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9_]+)+$/; /** Plural categories `Intl.PluralRules` can return; the only object-fallback members read. */ const PLURAL_CATS = ["zero", "one", "two", "few", "many", "other"]; /** Sources whose human-facing strings are ours. Tests declare throwaway keys; the runtime * itself declares none (its doc comment discusses `tr()` but calls it nowhere). */ function sources() { const out = []; const walk = (dir) => { // Code-unit order, for the same reason the key sort below uses it: nothing about this // build may depend on the machine's collation. const entries = fs .readdirSync(dir, { withFileTypes: true }) .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); for (const e of entries) { const p = path.join(dir, e.name); if (e.isDirectory()) { if (e.name === "node_modules" || e.name === "i18n") continue; walk(p); } else if (e.name.endsWith(".ts") && !e.name.endsWith(".d.ts") && !e.name.endsWith(".test.ts")) { out.push(p); } } }; walk(SRC); return out; } /** * Blank comments and template-literal PROSE, preserving every byte offset so positions still * point at the real source line. * * Comments are blanked because a `tr("x", "y")` inside a doc comment is not a call site, and * emitting its key would add an entry no code looks up — which the parity gate then demands * from all eleven translations. * * Template literals are blanked but their `${…}` holes are NOT: a hole is code, and most of * the `say` frames are written as `` `⚠️ ${trFor(locale, "say.x", "…")}` ``. An earlier draft * had this exactly backwards — it blanked the holes to stop `` `say.degraded.${key}` `` from * reading as a key — and silently lost a third of the catalog, including every warning frame * the panel shows. The template-keyed wrappers are read from the raw source instead, which * costs one lookup and does not hide anything. */ function maskNonCode(src) { const out = src.split(""); const modes = [{ kind: "code", depth: 0 }]; let i = 0; while (i < src.length) { const top = modes[modes.length - 1]; const c = src[i]; if (top.kind === "str") { if (c === "\\") i += 2; else { if (c === top.quote) modes.pop(); i++; } continue; } if (top.kind === "tpl") { if (c === "\\") { for (const k of [i, i + 1]) if (src[k] !== "\n") out[k] = " "; i += 2; continue; } if (c === "`") { modes.pop(); i++; continue; } if (c === "$" && src[i + 1] === "{") { modes.push({ kind: "code", depth: 1 }); i += 2; continue; } if (c !== "\n") out[i] = " "; i++; continue; } if (c === "/" && src[i + 1] === "/") { while (i < src.length && src[i] !== "\n") out[i++] = " "; continue; } if (c === "/" && src[i + 1] === "*") { const end = src.indexOf("*/", i + 2); const stop = end === -1 ? src.length : end + 2; for (; i < stop; i++) if (src[i] !== "\n") out[i] = " "; continue; } if (c === '"' || c === "'") { modes.push({ kind: "str", quote: c }); i++; continue; } if (c === "`") { modes.push({ kind: "tpl" }); i++; continue; } // Only a hole's own code frame closes on `}`; the file-level frame never pops. if (top.depth > 0) { if (c === "{") top.depth++; else if (c === "}" && --top.depth === 0) { modes.pop(); i++; continue; } } i++; } return out.join(""); } /** * Blank COMMENTS ONLY, leaving every string and template exactly as written. * * This exists to give the blind-spot counter below a view of the file that `maskNonCode` has * not touched. Counting on the masked copy is what made the counter useless: anything masking * erased vanished from both sides of the comparison, so reinstating the template-hole bug hid * 58 keys and the counter said nothing — the build only failed by luck, through an unrelated * "unreadable fallback" error from two constants that happen to be shared. A check computed * from the parser's own view of the world cannot detect a fault in that view. * * Comments still have to go, because a `tr("x", "y")` written inside one is not a call site. */ function stripComments(src) { const out = src.split(""); let i = 0; let quote = null; while (i < src.length) { const c = src[i]; if (quote) { if (c === "\\") i += 2; else { if (c === quote) quote = null; i++; } continue; } if (c === "/" && src[i + 1] === "/") { while (i < src.length && src[i] !== "\n") out[i++] = " "; continue; } if (c === "/" && src[i + 1] === "*") { const end = src.indexOf("*/", i + 2); const stop = end === -1 ? src.length : end + 2; for (; i < stop; i++) if (src[i] !== "\n") out[i] = " "; continue; } // Backticks are treated as an ordinary delimiter here: a `${…}` hole stays visible, which // is the whole point — that is where the say frames' translator calls live. if (c === '"' || c === "'" || c === "`") quote = c; i++; } return out.join(""); } /** * Split a bracketed group's comma-separated parts. `open` indexes the `(`, `[` or `{`. * Returns the parts as raw source slices plus their absolute start offsets, so a caller can * read an argument back out of the UNMASKED source. */ function splitGroup(src, open) { const parts = []; const starts = []; let depth = 0; let quote = null; let start = open + 1; for (let i = open; i < src.length; i++) { const c = src[i]; if (quote) { if (c === "\\") i++; else if (c === quote) quote = null; continue; } if (c === '"' || c === "'" || c === "`") { quote = c; continue; } if (c === "(" || c === "[" || c === "{") depth++; else if (c === ")" || c === "]" || c === "}") { depth--; if (depth === 0) { parts.push(src.slice(start, i)); starts.push(start); return { parts, starts, end: i }; } } else if (c === "," && depth === 1) { parts.push(src.slice(start, i)); starts.push(start); start = i + 1; } } return null; } function unescape(raw) { let out = ""; for (let i = 0; i < raw.length; i++) { if (raw[i] !== "\\") { out += raw[i]; continue; } const c = raw[++i]; if (c === "n") out += "\n"; else if (c === "t") out += "\t"; else if (c === "r") out += "\r"; else if (c === "u") { if (raw[i + 1] === "{") { const close = raw.indexOf("}", i); out += String.fromCodePoint(parseInt(raw.slice(i + 2, close), 16)); i = close; } else { out += String.fromCharCode(parseInt(raw.slice(i + 1, i + 5), 16)); i += 4; } } else if (c === "\n") { // A backslash-newline continuation contributes nothing. } else out += c; } return out; } /** Read a quoted literal, following `+` concatenation and named English constants. */ function readString(expr, consts) { let rest = expr.trim(); let text = ""; let any = false; for (;;) { const m = rest.match(/^(["'`])((?:\\.|(?!\1)[^\\])*)\1/); if (m) { // A template literal with a HOLE is not a fallback, it is a computed string, and this is // the one place the reader would otherwise guess where it refuses everywhere else. // Captured verbatim, `` `Hello ${NAME}, welcome back.` `` lands in the catalog with the // `${NAME}` still in it. English looks perfect — the template evaluates at runtime — while // every translation renders a literal `${NAME}`, because `trFor` substitutes `{name}` and // has never heard of `${…}`. Refusing sends the author to a real `{placeholder}` instead. if (m[1] === "`" && m[2].includes("${")) return null; // A fallback split across lines as `"first " + "second"` is ONE sentence. Reading only // the first literal would put half of it in the catalog: English still renders whole // (the expression evaluates at runtime), so every translation would silently lose the // rest and nothing in English could reveal it. text += unescape(m[2]); rest = rest.slice(m[0].length).trim(); any = true; } else { const id = rest.match(/^[A-Za-z_$][\w$]*/); // `t("console.credentials.privacy", CREDENTIALS_PRIVACY_EN)` — the English lives in a // module constant because two call sites share it. Resolving it keeps the catalog // complete; refusing would drop a key the page renders. if (id && consts.has(id[0])) { text += consts.get(id[0]); rest = rest.slice(id[0].length).trim(); any = true; } else return null; } if (rest.startsWith("+")) { rest = rest.slice(1).trim(); continue; } return rest === "" && any ? text : null; } } /** `{ one: "…", other: "…" }` — a counted fallback. Returns [cat, text][] or null. */ function readPluralObject(expr, consts) { const trimmed = expr.trim(); if (!trimmed.startsWith("{")) return null; const inner = splitGroup(trimmed, 0); if (!inner) return null; const forms = []; for (const part of inner.parts) { const m = part.match(/^\s*(\w+)\s*:\s*/); if (!m || !PLURAL_CATS.includes(m[1])) continue; const text = readString(part.slice(m[0].length), consts); if (text === null) return null; forms.push([m[1], text]); } return forms.length ? forms : null; } /** Module constants holding English prose (`const UNAUTHORIZED_EN = "…"`). */ function readConsts(masked, raw) { const consts = new Map(); const re = /\bconst\s+([A-Z][A-Z0-9_]*)\s*(?::\s*string\s*)?=\s*([^;]*);/g; let m; while ((m = re.exec(masked)) !== null) { // Slice the initialiser out of the RAW source — the masked copy has the same offsets but // the literal's own bytes are what we want. const initStart = m.index + m[0].length - 1 - m[2].length; const text = readString(raw.slice(initStart, initStart + m[2].length), consts); if (text !== null) consts.set(m[1], text); } return consts; } /** * A callee is a bare identifier, not a property access — but `...tr(…)` IS one. * * The first version rejected anything preceded by a dot, which quietly dropped the only * spread call site in the repo (`...tr("cli.connect_quiet_note", …)` in boot.ts, whose value * is three concatenated lines split back apart). The blind-spot counter below missed it too, * because it was written with the same lookbehind: a check that shares the parser's blind spot * is not a check. The counter now uses a LOOSER rule on purpose, so it over-counts rather than * agreeing. */ const CALLEE_START = String.raw`(?:(?<=\.\.\.)|(?<![\w$.]))`; /** * Every call expression in a file: `{ name, open, parts, starts, index }`. * * `function trSlots(locale, key, …)` is excluded: it is the DECLARATION of a translator, not a * call to one, and reading it as a call reports the parameter `key` as an unreadable key. */ function callSites(masked) { const out = []; const re = new RegExp(`${CALLEE_START}(?:(function|class)\\s*\\*?\\s*)?([A-Za-z_$][\\w$]*)\\s*\\(`, "g"); let m; while ((m = re.exec(masked)) !== null) { // A declaration consumes its own name, so the scan cannot re-match it as a call. if (m[1]) continue; const open = m.index + m[0].length - 1; const group = splitGroup(masked, open); if (!group) continue; out.push({ name: m[2], index: m.index, open, parts: group.parts, starts: group.starts }); } return out; } /** Index of the `(` that opens the group containing `at`, walking back over balanced pairs. */ function openParenBefore(masked, at) { let depth = 0; for (let i = at; i >= 0; i--) { const c = masked[i]; if (c === ")") depth++; else if (c === "(") { if (depth === 0) return i; depth--; } } return -1; } /** * Resolve the local wrappers that end up calling `tr`/`trFor`. * * A wrapper is found by DATA FLOW, not by name: at a call to an already-known translator, the * key slot holds a bare identifier rather than a literal, so that identifier is a parameter of * whatever encloses the call, and its position in that parameter list is the wrapper's own key * index. Iterating to a fixpoint walks the chain the console page actually builds — trFor → * trSlots → trHtml → the arrow `pageT` returns → the `t` each page binds — without this script * knowing any of those names, so renaming one cannot silently empty a namespace. * * Returns `Map<name, { keyIndex, prefix }>`; `prefix` is set for the wrappers that build their * key as `` `say.degraded.${key}` `` from a short argument. */ function discoverTranslators(masked, raw, calls) { const known = new Map(SEED.map(([name, keyIndex]) => [name, { keyIndex, prefix: "" }])); const factories = new Map(); // name -> keyIndex of the translator it RETURNS for (let round = 0; round < 6; round++) { let grew = false; const register = (name, keyIndex, prefix) => { if (!name || known.has(name)) return; known.set(name, { keyIndex, prefix }); grew = true; }; for (const call of calls) { const spec = known.get(call.name); if (!spec || call.parts.length <= spec.keyIndex) continue; const passed = passthroughKey(call, spec.keyIndex, raw); if (!passed) continue; // The key slot holds a parameter name. Find the parameter list that declares it and the // declaration that owns that list. const decl = paramOwner(masked, passed.ident, call.index); if (!decl) continue; if (decl.name) register(decl.name, decl.paramIndex, passed.prefix + spec.prefix); else if (decl.returnedBy && !factories.has(decl.returnedBy)) { factories.set(decl.returnedBy, { keyIndex: decl.paramIndex, prefix: passed.prefix + spec.prefix }); grew = true; } } // `const t = pageT(locale)` — binding a factory's product to a name. for (const call of calls) { const made = factories.get(call.name); if (!made) continue; const before = masked.slice(Math.max(0, call.index - 120), call.index); const m = before.match(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*)?=\s*(?:await\s+)?$/); if (m) register(m[1], made.keyIndex, made.prefix); } if (!grew) break; } return known; } /** * A translator call whose key comes from an enclosing parameter rather than a literal — * `trFor(loc, key, …)` or `` trFor(loc, `say.degraded.${key}`, …) ``. Returns the parameter * name and any constant prefix the wrapper prepends, or null when the key is a literal. * * The template form is read from the RAW source: masking blanks template prose, so the prefix * only survives there. */ function passthroughKey(call, keyIndex, raw) { const rawArg = raw.slice(call.starts[keyIndex], call.starts[keyIndex] + call.parts[keyIndex].length).trim(); if (/^[A-Za-z_$][\w$]*$/.test(rawArg)) return { ident: rawArg, prefix: "" }; const tpl = rawArg.match(/^`([a-z][a-zA-Z0-9_.]*\.)\$\{\s*([A-Za-z_$][\w$]*)\s*\}`$/); return tpl ? { ident: tpl[2], prefix: tpl[1] } : null; } /** * Where a callable's body ends, given the index just past its parameter list. * Returns -1 when what follows is not a body — which is how a call that merely PASSES an * argument list is told apart from a declaration. */ function bodyEnd(masked, closeParen) { const after = masked.slice(closeParen + 1); const lead = after.match(/^\s*(?::[^=>{;]*)?(?:(=>)\s*)?/); if (!lead) return -1; let at = closeParen + 1 + lead[0].length; if (masked[at] === "{") { const g = splitGroup(masked, at); return g ? g.end : -1; } if (!lead[1]) return -1; // a `(…)` with no `=>` and no `{` is a call, not a declaration // Arrow with an expression body: run to the first `,`, `;` or closing bracket at depth 0. let depth = 0; let quote = null; for (let i = at; i < masked.length; i++) { const c = masked[i]; if (quote) { if (c === "\\") i++; else if (c === quote) quote = null; continue; } if (c === '"' || c === "'" || c === "`") quote = c; else if ("([{".includes(c)) depth++; else if (")]}".includes(c)) { if (depth === 0) return i - 1; depth--; } else if ((c === "," || c === ";") && depth === 0) return i - 1; } return masked.length - 1; } /** * The declaration whose parameter list declares `ident` AND whose body contains `before`. * Returns `{ name, paramIndex }`, or `{ returnedBy, paramIndex }` for an anonymous arrow that * a named function returns. * * The containment check is what makes this safe rather than merely plausible. Without it, the * nearest preceding `(backend: string, …)` in a 5000-line file gets claimed as the owner of a * `backend` that is a local const eighty functions later, and an unrelated helper is registered * as a translator. Candidates are therefore walked nearest-first and the first one that really * encloses the call wins. */ function paramOwner(masked, ident, before) { const re = new RegExp(`[(,]\\s*${ident}\\s*[?:,)]`, "g"); const hits = []; let m; while ((m = re.exec(masked)) !== null) { if (m.index >= before) break; hits.push(m.index); } for (const hit of hits.reverse()) { const open = masked[hit] === "(" ? hit : openParenBefore(masked, hit); if (open < 0) continue; const group = splitGroup(masked, open); if (!group || group.end > before) continue; const paramIndex = group.parts.findIndex((p) => p.trim().replace(/[?:=].*$/s, "").trim() === ident); if (paramIndex < 0) continue; const end = bodyEnd(masked, group.end); if (end < before) continue; const head = masked.slice(Math.max(0, open - 160), open); const fn = head.match(/(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*$/); if (fn) return { name: fn[1], paramIndex }; const assigned = head.match(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]*)?=\s*(?:async\s*)?$/); if (assigned) return { name: assigned[1], paramIndex }; // An anonymous arrow. If a named function returns it, that function is a translator FACTORY. if (/\breturn\s*(?:async\s*)?$/.test(head)) { // NEAREST enclosing declaration, so take the LAST match before `open` — a leftmost regex // match names the first function in the file, which is a different function entirely. const fnRe = /(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g; const region = masked.slice(0, open); let last = null; let f; while ((f = fnRe.exec(region)) !== null) last = f[1]; if (last) return { returnedBy: last, paramIndex }; } } return null; } /** * Read a table of object literals — `const NAME: T[] = [{ … }, …]` — pulling the requested * string fields out of each entry. Values may be literals, concatenations or English constants. */ function dataTable(rel, tableName, fields) { const raw = fs.readFileSync(path.join(ROOT, rel), "utf8"); const masked = maskNonCode(raw); const consts = readConsts(masked, raw); // The type annotation is allowed to contain `[]`, so the pre-`=` run may not exclude brackets. const decl = new RegExp(`\\bconst\\s+${tableName}\\b[^=;]*=\\s*\\[`).exec(masked); if (!decl) return null; const arr = splitGroup(masked, decl.index + decl[0].length - 1); if (!arr) return null; const rows = []; for (let i = 0; i < arr.parts.length; i++) { const braceOffset = arr.parts[i].indexOf("{"); if (braceOffset < 0) continue; const obj = splitGroup(masked, arr.starts[i] + braceOffset); if (!obj) continue; const row = {}; for (let j = 0; j < obj.parts.length; j++) { const m = obj.parts[j].match(/^\s*([A-Za-z_$][\w$]*)\s*:\s*/); if (!m || !fields.includes(m[1])) continue; const start = obj.starts[j] + m[0].length; const text = readString(raw.slice(start, obj.starts[j] + obj.parts[j].length), consts); if (text !== null) row[m[1]] = text; } if (fields.every((f) => f in row)) rows.push(row); } return rows; } /** * Call sites whose key's LAST segment is a runtime value, with the table that enumerates it. * * `dtr(backend, reg.degradedMessage…)` in the orchestrator fans the degraded frame out over the * OpenAI-key provider registry, so the keys are `say.degraded.glm`, `…kimi`, `…moonshot`, * `…minimax` — real, live keys that no static read of that one line can produce. Enumerating * them from the registry means adding a provider adds its key here automatically, which is the * property that matters; the alternative is four keys that are missing from English and * therefore untranslatable in every language, with nothing to say so. * * The marker strip mirrors the leading-warning-sign replace the call site performs: the table's * copy carries a leading ⚠️ and the frame re-adds one, so a catalog that kept it renders two. */ const DYNAMIC_SITES = [ { file: "src/orchestrator/index.ts", prefix: "say.degraded.", ident: "backend", fallback: /\breg\.degradedMessage\b/, expand() { const rows = dataTable("src/services/openai-provider-registry.ts", "OPENAI_KEY_PROVIDERS", [ "id", "degradedMessage", ]); if (!rows || !rows.length) return null; return rows.map((r) => [`say.degraded.${r.id}`, r.degradedMessage.replace(/^⚠️\s*/u, "")]); }, }, ]; const candidates = []; const unparsed = []; const wrappersByFile = new Map(); /** Every `"a.b.c"`-shaped literal in the scanned sources — the second, independent detector. */ const keyShapedLiterals = []; for (const file of sources()) { const raw = fs.readFileSync(file, "utf8"); const rel = path.relative(ROOT, file).replace(/\\/g, "/"); const code = stripComments(raw); const lineAt = (idx) => raw.slice(0, idx).split("\n").length; // SECOND DETECTOR, collected for EVERY source file — deliberately in front of the `tr(` // pre-filter below, because a file that never names `tr` is precisely the case the primary // counter cannot see. A wrapper defined in another module (`panelT(locale, key, fallback)` // exported from a helper) leaves its consumer with no `tr(` at all: the file is skipped // wholesale, the counter is satisfied at 0 === 0, and the key is dropped in silence. Measured // before this moved: exit 0, no diagnostic, and `i18n-check` then certifies the catalog 100% // translated while the string renders English in all eleven languages forever. // // This detector knows nothing about call syntax — only that a literal shaped like a key, in a // namespace the catalog owns, had better be in the catalog. That is why it survives a wrapper // nobody anticipated. (Placing it behind the filter was my first attempt, and it reproduced // the bug it was written to catch.) for (const m of code.matchAll(/["']([a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9_]+)+)["']/g)) { keyShapedLiterals.push({ key: m[1], file: rel, line: lineAt(m.index) }); } if (!/(?<![\w$])tr(?:For)?\s*\(/.test(code)) continue; const masked = maskNonCode(raw); const consts = readConsts(masked, raw); const calls = callSites(masked); const known = discoverTranslators(masked, raw, calls); wrappersByFile.set( rel, [...known.keys()].filter((n) => !SEED.some(([s]) => s === n)), ); // BLIND SPOT. Everything below can only police call sites the scanner produced; one it never // produced — an unbalanced group, a shape the regex misses — is absent from both sides of // every comparison, so a check written against the candidates would stay green while the key // silently vanished. Counting the raw occurrences is the only thing that notices, and it has // now found two: masking used to blank template holes (hiding every `${trFor(…)}` say frame), // and the callee rule used to reject `...tr(` as a property access. // // Two things keep this independent of the parser, and both were added only after a // measurement showed the previous version silent on the exact fault it names: // - it counts on `stripComments(raw)`, NOT on `masked`. Masking is the parser's view; a // bug in it erases the evidence from both sides. // - the pattern is deliberately LOOSER than `CALLEE_START` — it would also count a genuine // `obj.tr(` property access. Over-counting produces a failure a human resolves; matching // the parser's own rule produces silence, which is how the `...tr(` bug survived the // template-hole fix. const seen = calls.filter((c) => SEED.some(([name]) => name === c.name)).length; const present = (code.match(/(?<![\w$])tr(?:For)?\s*\(/g) ?? []).length; if (seen !== present) { unparsed.push({ file: rel, line: 0, why: `${present} tr/trFor call(s) in the source, ${seen} readable` }); } for (const call of calls) { const spec = known.get(call.name); if (!spec || call.parts.length <= spec.keyIndex) continue; const line = lineAt(call.index); const keyLit = call.parts[spec.keyIndex].trim().match(/^(["'])((?:\\.|(?!\1)[^\\])*)\1$/); if (!keyLit) { const passed = passthroughKey(call, spec.keyIndex, raw); const fbIdx = spec.keyIndex + 1; const fbSrc = fbIdx < call.parts.length ? call.parts[fbIdx] : ""; // DYNAMIC SITES FIRST, and the order is load-bearing rather than stylistic. // // The passthrough test below asks "is this key an enclosing PARAMETER?", and it used to // run first. `dtr(backend, …)` is matched here because `backend` is a local const today — // but the natural refactor of that 4000-line handler makes it a parameter, at which point // `paramOwner` starts matching, the `continue` fires, and the registry fan-out is skipped. // Measured by forcing that condition: exit 0, no stderr, and `say.degraded.glm/kimi/ // minimax/moonshot` simply gone — four live keys, untranslatable, with nothing to say so. // An enumerated site (file + prefix + identifier + fallback shape) is far more specific // than a structural guess, so it gets to answer first. const dyn = DYNAMIC_SITES.find( (d) => d.file === rel && d.prefix === spec.prefix && passed?.ident === d.ident && d.fallback.test(fbSrc), ); if (dyn) { const rows = dyn.expand(); if (!rows) { unparsed.push({ file: rel, line, why: `${dyn.prefix}* is chosen at runtime and its table could not be read` }); continue; } for (const [key, text] of rows) candidates.push({ key, text, file: rel, line }); continue; } // Otherwise: a non-literal key at a translator call is a WRAPPER body (`trFor(loc, key, // …)`), which discovery above has already turned into its own translator — the real keys // arrive at the wrapper's own call sites. If it is NOT that, the keys behind it are // invisible to this build and would be missing from English, so say so. if (passed && paramOwner(masked, passed.ident, call.index)) continue; unparsed.push({ file: rel, line, why: `key is not a literal: ${call.parts[spec.keyIndex].trim().slice(0, 60)}` }); continue; } const key = spec.prefix + keyLit[2]; if (!KEY_SHAPE.test(key)) { unparsed.push({ file: rel, line, why: `key "${key}" is not a dotted catalog key` }); continue; } if (call.parts.length <= spec.keyIndex + 1) { unparsed.push({ file: rel, line, why: `${key} has no English fallback argument` }); continue; } const fbIdx = spec.keyIndex + 1; const fallbackSrc = raw.slice(call.starts[fbIdx], call.starts[fbIdx] + call.parts[fbIdx].length); const forms = readPluralObject(fallbackSrc, consts); if (forms) { for (const [cat, text] of forms) candidates.push({ key: `${key}_${cat}`, text, file: rel, line }); continue; } const text = readString(fallbackSrc, consts); if (text === null) { // A key we can see with a fallback we cannot read is the dangerous case: the key is live // at runtime and would be ABSENT from English, so every translation of it fails parity. // Refuse rather than guess. unparsed.push({ file: rel, line, why: `${key}: unreadable fallback ${fallbackSrc.trim().slice(0, 60)}` }); continue; } candidates.push({ key, text, file: rel, line }); } } if (unparsed.length) { console.error("call sites this build could not read:"); for (const u of unparsed) console.error(` ${u.file}:${u.line} ${u.why}`); console.error("\nEach is a live key that would be absent from English. Fix the site or this script."); process.exit(1); } // key -> text. The same key rendered from two places is the common case and collapses to one // entry; the same key with DIFFERENT English is a bug that must not reach a translator, who // would translate one of the two and silently change what the other site says. const flat = new Map(); for (const c of candidates) { const prior = flat.get(c.key); if (prior !== undefined && prior !== c.text) { console.error( `key collision: ${c.key}\n a: ${JSON.stringify(prior)}\n b: ${JSON.stringify(c.text)} (${c.file}:${c.line})`, ); process.exit(1); } flat.set(c.key, c.text); } // SECOND DETECTOR — see the collection site above. A literal shaped like a key, in a namespace // this catalog owns, that no call site produced, is a key some path reaches at runtime and this // build cannot see. Scoped to namespaces the catalog already declares so it never has an opinion // about unrelated dotted strings: `panel.persona` is a prompt id, not vocabulary, and stays out. const OWNED = new Set([...flat.keys()].map((k) => k.split(".")[0])); const orphans = keyShapedLiterals.filter((l) => OWNED.has(l.key.split(".")[0]) && !flat.has(l.key)); if (orphans.length) { console.error("key-shaped literals this build did not capture:"); for (const o of orphans) console.error(` ${o.file}:${o.line} ${o.key}`); console.error( "\nEach names a catalog namespace but reached no call site this build understands — most" + "\nlikely a translator wrapper defined in ANOTHER module. Teach the build about it, or" + "\nthe key is missing from English and untranslatable in every language, silently.", ); process.exit(1); } if (process.argv.includes("--json")) { console.log(JSON.stringify({ wrappers: Object.fromEntries(wrappersByFile), candidates }, null, 2)); process.exit(0); } // Nest `a.b.c` so the file reads by area rather than as one long list. // // Ordered by CODE UNIT, not `localeCompare`. The committed file is compared byte for byte // against a fresh build, so a collation that depends on the machine's ICU locale would make // that comparison a property of whose laptop ran it. Every key is ASCII, so this is also the // order a reader expects. const nested = {}; const byCodeUnit = (a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0); for (const [key, text] of [...flat.entries()].sort(byCodeUnit)) { const parts = key.split("."); let cur = nested; for (const p of parts.slice(0, -1)) { if (typeof cur[p] === "string") { console.error(`key "${key}" needs "${p}" to be a group, but a string already sits there`); process.exit(1); } cur = cur[p] ??= {}; } cur[parts.at(-1)] = text; } const serialized = JSON.stringify(nested, null, 2) + "\n"; // `--print` writes nothing, so a test can rebuild the catalog and compare without touching the // tree: // a committed English catalog that no longer matches the code is a silent lie, exactly like a // stale generated doc page. if (process.argv.includes("--print")) { process.stdout.write(serialized); process.exit(0); } const outDir = path.join(ROOT, "locales", "en"); fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(path.join(outDir, "main.json"), serialized); console.log(`wrote locales/en/main.json — ${flat.size} keys from ${candidates.length} call sites`);