From cfbcd010af50ffe33c960a2b8feefdcce94242d6 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 22:38:30 +0800 Subject: [PATCH] emrg: GUI i18n orphan/missing-key guard test + remove 12 dead keys (fix #771 review class) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #755 orphan-key cleanup and the #771 review ❌ (7 orphaned i18n keys + 12 unused CSS) exposed a missing guard: dictionary keys defined but never referenced accumulate silently. New i18n.test.js guard scans index.html data-i18n* attrs + all renderer JS t-call sites (incl. ternary forms _t(cond ? "A" : "B"), template globs tool.${base}.doing, and registry property values hint: "cmd.x.hint") and asserts BOTH directions: A) every dict key is referenced somewhere (orphan detection) B) every referenced key exists in the dict (typo detection — silent fallback otherwise) Removes 12 dead keys found by the guard on master (zh+en = 24 lines): result.title, settings.groupTasks, settings.githubTokenEmpty, tasks.title, tasks.desc, panel.openFailed, panel.copied, app.helpNoSessions, app.historyLoading, app.globalAll, app.noTasks, sidebar.noOpenSessions. GUI 242→243 (i18n 15→16), Agent.md synced. --- Agent.md | 4 +-- emrg/gui/renderer/js/i18n.js | 30 ------------------- emrg/gui/test/i18n.test.js | 58 ++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/Agent.md b/Agent.md index 687d0d84..0e345d73 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (242: 44 daemon_client + 19 conn-manager + 22 app-commands + 118 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (243: 44 daemon_client + 19 conn-manager + 22 app-commands + 118 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -119,7 +119,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (801) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (242: 44 daemon_client + 19 conn-manager + 22 app-commands + 118 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (243: 44 daemon_client + 19 conn-manager + 22 app-commands + 118 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 45b53fb4..0348ded1 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -37,7 +37,6 @@ const I18N = (() => { "projects.empty": "暂无项目(点击「添加项目」注册工作目录)", "projects.added": "项目已添加:{path}", "projects.addFailed": "添加项目失败:{msg}", - "projects.sessions": "会话", "projects.autoEvolve": "演化", "projects.viewSessions": "查看会话", "projects.noSessions": "该项目暂无会话", @@ -56,15 +55,12 @@ const I18N = (() => { "composer.modelLoading": "加载中…", "composer.switchModel": "切换模型", "composer.modeTitle": "工作模式:Ask 只对话 / Auto 自动执行", - "composer.modeAsk": "Ask", - "composer.modeAuto": "Auto", "composer.placeholder": "发消息给 EMRG… (Enter 发送 / Shift+Enter 换行)", "composer.send": "发送", "composer.stop": "停止回复", "composer.hint": "EMRG 可能会犯错,请核对重要信息", // 结果面板 - "result.title": "产物", "result.collapse": "折叠/展开 (⌘\\)", "result.empty": "还没有产物,对话中生成的文件会出现在这里", "result.tabFiles": "文件", @@ -95,7 +91,6 @@ const I18N = (() => { "settings.aboutTitle": "关于", "settings.aboutDesc": "EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。", "settings.recentTitle": "最近改进", - "settings.groupTasks": "定时任务", "settings.tasksHint": "管理定时任务与自定义任务类型(内置类型只读)", "settings.taskAdd": "+ 添加任务", "settings.taskTemplates": "自定义类型", @@ -168,7 +163,6 @@ const I18N = (() => { "settings.installFailed": "安装启动失败:{msg}", "app.updateReady": "新版本 v{latest} 已下载 —— 设置 → 关于 → 点击安装", "app.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装):https://github.com/argszero/emrg/releases", - "settings.githubTokenEmpty": "请先粘贴 GitHub Personal Access Token", "settings.githubConnecting": "连接中…", "settings.githubConnected": "已连接 GitHub:@{user}(gh auth setup-git 已执行)", "settings.githubConnectFailed": "GitHub 连接失败:{msg}", @@ -266,8 +260,6 @@ const I18N = (() => { "rants.statusCompleted": "已完成", "rants.detail": "完整内容", "rants.noProgress": "(无进度说明)", - "tasks.title": "后台任务", - "tasks.desc": "点击任务立即触发一次运行。", // / 指令 hint(commands.js 补全菜单) "cmd.clear.hint": "清空当前对话", @@ -341,8 +333,6 @@ const I18N = (() => { "util.groupYesterday": "昨天", "util.groupEarlier": "更早", // 结果面板(result-panel.js) - "panel.openFailed": "无法打开文件", - "panel.copied": "✓ 已复制", // Markdown 代码块(markdown.js) "md.copyCode": "复制代码", // 对话框动态文案(dialogs.js) @@ -382,13 +372,11 @@ const I18N = (() => { "app.compacted": "已压缩当前对话历史。", "app.imagePaste": "请直接粘贴图片到输入框(Ctrl+V / ⌘V)。", "app.cmdFailed": "指令 {cmd} 执行失败:{msg}", - "app.helpNoSessions": "还没有对话,输入内容即可开始。", "app.unnamed": "(未命名)", "app.current": "当前", "app.noHistory": "没有可回退的历史消息。", "app.historyFailed": "加载历史失败:{msg}", "app.historyLoadMore": "↑ 加载更早消息", - "app.historyLoading": "加载中…", "app.historyNoMore": "没有更多历史", "app.rewound": "已回退到消息点 #{index},移除了 {n} 条记录。", "app.rewindFailed": "回退失败:{msg}", @@ -399,12 +387,10 @@ const I18N = (() => { "app.memFailed": "加载记忆失败:{msg}", "app.noSkills": "还没有加载技能。", "app.skillsFailed": "加载技能失败:{msg}", - "app.globalAll": "(全局 — 所有项目)", "app.rantEmpty": "写点内容再提交吧。", "app.rantReceived": "✓ 收到!EMRG 会据此进化。", "app.rantCount": "(已累计 {n} 条反馈)", "app.rantFailed": "提交失败了:{msg}", - "app.noTasks": "没有可触发的任务。", "app.taskDisabled": "已停用", "app.taskInterval": "间隔 {n}s", "app.tasksFailed": "加载任务失败:{msg}", @@ -422,7 +408,6 @@ const I18N = (() => { "app.closeSession": "❌ 关闭会话(保留数据)", "app.closeFailed": "关闭会话失败了:{msg}", "sidebar.openSessions": "打开的会话", - "sidebar.noOpenSessions": "暂无打开的会话", "sidebar.openSessionOf": "{project} / {title}", "app.noModels": "还没有配置模型", "app.goSettings": "去设置添加", @@ -460,7 +445,6 @@ const I18N = (() => { "projects.empty": "No projects yet (click “Add project” to register a working directory)", "projects.added": "Project added: {path}", "projects.addFailed": "Failed to add project: {msg}", - "projects.sessions": "Sessions", "projects.autoEvolve": "Evolve", "projects.viewSessions": "Sessions", "projects.noSessions": "No sessions for this project yet", @@ -479,15 +463,12 @@ const I18N = (() => { "composer.modelLoading": "Loading…", "composer.switchModel": "Switch model", "composer.modeTitle": "Work mode: Ask (chat only) / Auto (run tools)", - "composer.modeAsk": "Ask", - "composer.modeAuto": "Auto", "composer.placeholder": "Message EMRG… (Enter to send / Shift+Enter for new line)", "composer.send": "Send", "composer.stop": "Stop reply", "composer.hint": "EMRG may make mistakes — please verify important information", // Result panel - "result.title": "Artifacts", "result.collapse": "Collapse/expand (⌘\\)", "result.empty": "No artifacts yet — files generated in the conversation will appear here", "result.tabFiles": "Files", @@ -518,7 +499,6 @@ const I18N = (() => { "settings.aboutTitle": "About", "settings.aboutDesc": "EMRG is a self-evolving AI agent — it reports every improvement, and you can watch it grow right here.", "settings.recentTitle": "Recent improvements", - "settings.groupTasks": "Scheduled tasks", "settings.tasksHint": "Manage scheduled tasks and custom task types (builtin types are read-only)", "settings.taskAdd": "+ Add task", "settings.taskTemplates": "Custom types", @@ -591,7 +571,6 @@ const I18N = (() => { "settings.installFailed": "Failed to launch installer: {msg}", "app.updateReady": "New version v{latest} downloaded — install it from Settings → About", "app.updateAvailable": "New version v{latest} available — https://github.com/argszero/emrg/releases (no auto-install)", - "settings.githubTokenEmpty": "Please paste a GitHub Personal Access Token first", "settings.githubConnecting": "Connecting…", "settings.githubConnected": "Connected to GitHub: @{user} (gh auth setup-git done)", "settings.githubConnectFailed": "GitHub connect failed: {msg}", @@ -689,8 +668,6 @@ const I18N = (() => { "rants.statusCompleted": "Completed", "rants.detail": "Full content", "rants.noProgress": "(no progress note)", - "tasks.title": "Background tasks", - "tasks.desc": "Click a task to trigger it immediately.", // / command hints "cmd.clear.hint": "Clear current conversation", @@ -764,8 +741,6 @@ const I18N = (() => { "util.groupYesterday": "Yesterday", "util.groupEarlier": "Earlier", // Result panel (result-panel.js) - "panel.openFailed": "Could not open file", - "panel.copied": "✓ Copied", // Markdown code block (markdown.js) "md.copyCode": "Copy code", // Dialog dynamic copy (dialogs.js) @@ -805,13 +780,11 @@ const I18N = (() => { "app.compacted": "Conversation history compacted.", "app.imagePaste": "Paste the image into the input (Ctrl+V / ⌘V).", "app.cmdFailed": "Command {cmd} failed: {msg}", - "app.helpNoSessions": "No conversations yet — start typing.", "app.unnamed": "(untitled)", "app.current": "current", "app.noHistory": "No history points to rewind to.", "app.historyFailed": "Failed to load history: {msg}", "app.historyLoadMore": "↑ Load earlier messages", - "app.historyLoading": "Loading…", "app.historyNoMore": "No more history", "app.rewound": "Rewound to message point #{index}, removed {n} records.", "app.rewindFailed": "Rewind failed: {msg}", @@ -822,12 +795,10 @@ const I18N = (() => { "app.memFailed": "Failed to load memories: {msg}", "app.noSkills": "No skills loaded yet.", "app.skillsFailed": "Failed to load skills: {msg}", - "app.globalAll": "(Global — all projects)", "app.rantEmpty": "Write something first.", "app.rantReceived": "✓ Got it! EMRG will evolve accordingly.", "app.rantCount": "({n} feedback items so far)", "app.rantFailed": "Submission failed: {msg}", - "app.noTasks": "No tasks to trigger.", "app.taskDisabled": "Disabled", "app.taskInterval": "every {n}s", "app.tasksFailed": "Failed to load tasks: {msg}", @@ -845,7 +816,6 @@ const I18N = (() => { "app.closeSession": "❌ Close session (keep data)", "app.closeFailed": "Failed to close session: {msg}", "sidebar.openSessions": "Open sessions", - "sidebar.noOpenSessions": "No open sessions", "sidebar.openSessionOf": "{project} / {title}", "app.noModels": "No models configured", "app.goSettings": "Add in Settings", diff --git a/emrg/gui/test/i18n.test.js b/emrg/gui/test/i18n.test.js index 46a50934..ff3bd984 100644 --- a/emrg/gui/test/i18n.test.js +++ b/emrg/gui/test/i18n.test.js @@ -226,3 +226,61 @@ test("Stage3b:最近改进列表文案双语(#502 evolution_summary 侧)", const { ctx: zh } = makeSandbox({ navigator: { language: "zh-CN" } }); assert.strictEqual(evalIn(zh, 'I18N.t("app.recentImprovements")'), "最近改进"); }); + +// ── Orphan-key guard(cycle 20260813-223026:#771 ❌ 教训固化)──────── +// #755 清理了 8 组孤儿键、#771 评审 ❌ 检出 7 个孤儿 i18n keys + 12 个未用 CSS 类—— +// 两个方向都缺自动守卫:①词典里定义了但全库无引用的孤儿键(删除功能后残留) +// ②标记/代码里引用了但词典未定义的键(拼写错误会静默回退显示原始 key)。 +// 本测试扫描 index.html data-i18n* 属性 + renderer JS 全部取词点,双向校验。 +test("i18n 守卫:词典键无孤儿(定义了必须被引用)且引用键无缺失(引用必须已定义)", () => { + const html = fs.readFileSync(path.join(__dirname, "..", "renderer", "index.html"), "utf8"); + const { ctx } = makeSandbox({ navigator: { language: "zh-CN" } }); + const dictKeys = new Set(evalIn(ctx, "Object.keys(I18N.DICTS.zh)")); + assert.ok(dictKeys.size > 60, `词典应有键(实际 ${dictKeys.size})`); + + // 1) 收集引用:index.html data-i18n / data-i18n-placeholder / data-i18n-title + const used = new Set(); + const attrRe = /data-i18n(?:-placeholder|-title)?="([^"]+)"/g; + let m; + while ((m = attrRe.exec(html)) !== null) used.add(m[1]); + + // 2) 收集引用:renderer JS 全部取词调用(跳过 i18n.js 词典本身)。 + // 取 t-call 整体跨度(含三元前缀 _t(cond ? "A" : "B")),再取其中所有字面键; + // 模板串 t(`tool.${base}.doing`) → 静态段拼 glob(tool.*.doing)。 + const globs = []; + const callRe = /(?:_t|I18N\.t|EMRG_I18N\.t|\bt)\([^)]*\)/g; + for (const f of fs.readdirSync(path.join(__dirname, "..", "renderer", "js"))) { + if (!f.endsWith(".js") || f === "i18n.js") continue; + const src = fs.readFileSync(path.join(__dirname, "..", "renderer", "js", f), "utf8"); + let cm; + while ((cm = callRe.exec(src)) !== null) { + const body = cm[0]; + if (body.includes("`")) { + const tplRe = /`([^`]*)`/; + const tpl = (tplRe.exec(body) || [])[1] || ""; + if (tpl.includes("${")) globs.push(tpl.replace(/\$\{[^}]+\}/g, "*")); + else if (tpl) used.add(tpl); + continue; + } + const keyRe = /"([a-z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)+)"/g; + let km; + while ((km = keyRe.exec(body)) !== null) used.add(km[1]); + } + // 对象属性值引用(命令注册表 hint: "cmd.clear.hint" 形态) + const propRe = /\b(?:hint|title|key|label|placeholder|text|msg):\s*"([a-z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)+)"/g; + let pm; + while ((pm = propRe.exec(src)) !== null) used.add(pm[1]); + } + // glob → 正则:转义特殊字符,* → .* + const globRes = globs.map((g) => new RegExp("^" + g.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$")); + + // 3) 方向 A:词典键必须被引用(孤儿检测) + const orphans = [...dictKeys].filter( + (k) => !used.has(k) && !globRes.some((re) => re.test(k)) + ); + assert.strictEqual(orphans.length, 0, `孤儿 i18n 键(词典定义但无引用,应删除):\n ${orphans.join("\n ")}`); + + // 4) 方向 B:引用键必须已在词典定义(拼写错误检测) + const missing = [...used].filter((k) => !dictKeys.has(k)); + assert.strictEqual(missing.length, 0, `引用了但词典未定义的 i18n 键(拼写错误会静默回退):\n ${missing.join("\n ")}`); +});