From b910d63444e29f0af9d4a9eae23b03dd8fd404a6 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Tue, 1 Sep 2026 03:27:23 -0700 Subject: [PATCH] chore(cli): enforce locale copy boundaries Add scripts/check-tui-copy.mjs to CI: reject CJK literals, locale-branch copy selection, and bare literals at user-visible sinks across the 13 covered TUI files. Legacy English literals are ratcheted by an exact per-occurrence inventory that also fails when an allowance goes stale. The /new failure notice now names the actual cause (the only failing path is a local command refusing to stop) instead of a generic retry hint, and pending-queue renderers lose their 'en' locale defaults so callers must thread the real locale. --- .github/workflows/ci.yml | 6 + package.json | 1 + .../cli/src/__tests__/pi-transcript.test.ts | 4 +- .../src/__tests__/pi-tui-mcp-status.test.ts | 265 ++++-------- .../cli/src/__tests__/pi-tui-runner.test.ts | 6 +- .../src/__tests__/tui-copy-catalog.test.ts | 21 - packages/cli/src/pi-transcript.ts | 4 +- packages/cli/src/pi-tui-layout.ts | 2 +- packages/cli/src/pi-tui-mcp-status.ts | 2 +- packages/cli/src/tui-copy-catalog.ts | 5 +- scripts/check-tui-copy.mjs | 376 ++++++++++++++++++ scripts/check-tui-copy.test.mjs | 115 ++++++ 12 files changed, 597 insertions(+), 210 deletions(-) create mode 100644 scripts/check-tui-copy.mjs create mode 100644 scripts/check-tui-copy.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64e13402525..aafa0570ad5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,6 +175,12 @@ jobs: if: needs.plan.outputs.code == 'true' || needs.plan.outputs.astryx_surface == 'true' || needs.plan.outputs.asf_source == 'true' || needs.plan.outputs.cli_package == 'true' || needs.plan.outputs.release_contract == 'true' run: npm ci + - name: Check localized TUI copy boundaries + if: needs.plan.outputs.code == 'true' + run: | + npm run check:tui-copy + node --test scripts/check-tui-copy.test.mjs + # The header audit above remains install-free. The complete source gate # also exercises generation and therefore runs after its pinned formatter # dependency is installed, matching the source-candidate workflow. diff --git a/package.json b/package.json index d2c7cc144fa..42bce735552 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "release:cli:qualify-state-root": "node scripts/qualify-released-cli-state-root.mjs", "release:cli:eval": "node scripts/release-cli-eval-package.mjs", "check:app-shell-hooks": "node scripts/check-app-shell-hooks.mjs", + "check:tui-copy": "node scripts/check-tui-copy.mjs", "check:asf-headers": "node scripts/asf-license-headers.mjs check", "write:asf-headers": "node scripts/asf-license-headers.mjs write", "release:asf:source": "node scripts/asf-source-release.mjs create", diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 83e67374a22..5d107965c1c 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -138,7 +138,7 @@ describe('Maka Pi TUI transcript', () => { state.steering = ['s'.repeat(250)]; state.followup = ['f'.repeat(250)]; - const lines = renderMakaPiPendingQueue(state, 400); + const lines = renderMakaPiPendingQueue(state, 400, process.platform, 'en'); for (const line of lines) { assert.doesNotMatch(line, /[\r\n]/, `pending-queue row must be a single row: ${line}`); } @@ -150,7 +150,7 @@ describe('Maka Pi TUI transcript', () => { const state = createMakaPiTranscriptState(); state.steering = ['Keep going']; const renderFor = (platform: NodeJS.Platform) => - renderMakaPiPendingQueue(state, 80, platform).map(stripAnsi); + renderMakaPiPendingQueue(state, 80, platform, 'en').map(stripAnsi); assert.equal(renderFor('darwin').at(-1), '⌥+↑ take queued messages back to re-edit'); assert.equal(renderFor('linux').at(-1), 'Alt+↑ take queued messages back to re-edit'); diff --git a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts index d058440674f..9a0e12fbaf0 100644 --- a/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts +++ b/packages/cli/src/__tests__/pi-tui-mcp-status.test.ts @@ -251,119 +251,75 @@ describe('MCP management overlay', () => { assert.match(text, /› ○ s4/u); }); - const editorCopy = { - en: { - add: 'Add MCP server', - transport: 'Transport', - protocol: 'Protocol preference', - confirmAdd: 'Add this MCP server?', - confirmRemove: 'Remove filesystem?', - confirmHint: 'y Confirm · Esc cancel', - serverId: 'Server ID', - command: 'Command', - args: 'Arguments', - argsHint: 'JSON string array, optional', - cwd: 'Working directory', - optionalHint: 'optional', - env: 'Environment', - mapHint: 'JSON string map, optional', - submitHint: 'Enter submit · Esc back', - testing: 'Testing MCP server…', - reconnecting: 'Reconnecting MCP server…', - applying: 'Applying MCP configuration…', - published: 'Configuration saved and tools refreshed.', - managerFailed: 'The MCP connection action failed.', - }, - zh: { - add: '添加 MCP 服务器', - transport: '传输方式', - protocol: '协议偏好', - confirmAdd: '添加该 MCP 服务器?', - confirmRemove: '删除 filesystem?', - confirmHint: 'y 确认 · Esc 取消', - serverId: '服务器 ID', - command: '命令', - args: '参数', - argsHint: 'JSON string array, 可留空', - cwd: '工作目录', - optionalHint: '可留空', - env: '环境变量', - mapHint: 'JSON string map, 可留空', - submitHint: 'Enter 提交 · Esc 返回', - testing: '正在测试 MCP 服务器…', - reconnecting: '正在重连 MCP 服务器…', - applying: '正在应用 MCP 配置…', - published: '配置已保存,工具已刷新。', - managerFailed: 'MCP 连接操作失败。', - }, - } as const; - - for (const locale of ['en', 'zh'] as const) { - const expected = editorCopy[locale]; - - test(`walks the guided add flow with ${locale} headings, labels, and hints`, () => { - const overlay = new McpManagementOverlay({ - locale, - tui: fakeTui(), - surface: surface(listSnapshot()), - viewportRows: () => 14, - onClose: () => undefined, - onChange: () => undefined, - }); - const rendered = () => overlay.render(100).map(stripAnsi).join('\n'); - - overlay.handleInput('a'); - assert.ok(rendered().includes(expected.add)); - overlay.handleInput('g'); - let text = rendered(); - assert.ok(text.includes(expected.serverId)); - assert.ok(text.includes(expected.submitHint)); - for (const char of 'demo') overlay.handleInput(char); - overlay.handleInput('\r'); - assert.ok(rendered().includes(expected.transport)); - overlay.handleInput('1'); - text = rendered(); - assert.ok(text.includes(expected.command)); - for (const char of 'echo') overlay.handleInput(char); - overlay.handleInput('\r'); - text = rendered(); - assert.ok(text.includes(expected.args)); - assert.ok(text.includes(expected.argsHint)); - overlay.handleInput('\r'); - assert.ok(rendered().includes(expected.protocol)); - overlay.handleInput('2'); - text = rendered(); - assert.ok(text.includes(expected.cwd)); - assert.ok(text.includes(expected.optionalHint)); - overlay.handleInput('\r'); - text = rendered(); - assert.ok(text.includes(expected.env)); - assert.ok(text.includes(expected.mapHint)); - overlay.handleInput('\r'); - text = rendered(); - assert.ok(text.includes(expected.confirmAdd)); - assert.ok(text.includes('demo · stdio · auto')); - assert.ok(text.includes('echo')); - assert.ok(text.includes(expected.confirmHint)); + test('routes the guided add flow through catalog headings, labels, and hints', () => { + const locale = 'en'; + const expected = TUI_COPY_RESOURCES['mcp-status'][locale].editor; + const overlay = new McpManagementOverlay({ + locale, + tui: fakeTui(), + surface: surface(listSnapshot()), + viewportRows: () => 14, + onClose: () => undefined, + onChange: () => undefined, }); + const rendered = () => overlay.render(100).map(stripAnsi).join('\n'); + + overlay.handleInput('a'); + assert.ok(rendered().includes(expected.addTitle)); + overlay.handleInput('g'); + let text = rendered(); + assert.ok(text.includes(expected.inputLabels.server_id)); + assert.ok(text.includes(expected.hints.submit)); + for (const char of 'demo') overlay.handleInput(char); + overlay.handleInput('\r'); + assert.ok(rendered().includes(expected.transportTitle)); + overlay.handleInput('1'); + text = rendered(); + assert.ok(text.includes(expected.inputLabels.command)); + for (const char of 'echo') overlay.handleInput(char); + overlay.handleInput('\r'); + text = rendered(); + assert.ok(text.includes(expected.inputLabels.args)); + assert.ok(text.includes(expected.hints.args)); + overlay.handleInput('\r'); + assert.ok(rendered().includes(expected.protocolTitle)); + overlay.handleInput('2'); + text = rendered(); + assert.ok(text.includes(expected.inputLabels.cwd)); + assert.ok(text.includes(expected.hints.optional)); + overlay.handleInput('\r'); + text = rendered(); + assert.ok(text.includes(expected.inputLabels.env)); + assert.ok(text.includes(expected.hints.map)); + overlay.handleInput('\r'); + text = rendered(); + assert.ok(text.includes(expected.confirmAddTitle)); + assert.ok(text.includes('demo · stdio · auto')); + assert.ok(text.includes('echo')); + assert.ok(text.includes(expected.confirmHint)); + }); - test(`renders the ${locale} remove confirmation with the server id`, () => { - const overlay = new McpManagementOverlay({ - locale, - surface: surface(listSnapshot()), - viewportRows: () => 8, - onClose: () => undefined, - onChange: () => undefined, - }); - overlay.render(100); - - overlay.handleInput('d'); - const text = overlay.render(100).map(stripAnsi).join('\n'); - assert.ok(text.includes(expected.confirmRemove)); - assert.ok(text.includes(expected.confirmHint)); + test('routes remove confirmation copy and interpolates the server id', () => { + const locale = 'en'; + const expected = TUI_COPY_RESOURCES['mcp-status'][locale].editor; + const overlay = new McpManagementOverlay({ + locale, + surface: surface(listSnapshot()), + viewportRows: () => 8, + onClose: () => undefined, + onChange: () => undefined, }); + overlay.render(100); + overlay.handleInput('d'); + const text = overlay.render(100).map(stripAnsi).join('\n'); + assert.ok(text.includes(expected.confirmRemoveTitle.replace('{serverId}', 'filesystem'))); + assert.ok(text.includes(expected.confirmHint)); + }); + + for (const locale of ['en', 'zh'] as const) { test(`labels the busy phase per action kind in ${locale}`, () => { + const expected = TUI_COPY_RESOURCES['mcp-status'][locale].editor; const mcp = surface(listSnapshot()); mcp.execute = () => new Promise(() => undefined); const overlay = new McpManagementOverlay({ @@ -376,9 +332,9 @@ describe('MCP management overlay', () => { overlay.render(100); const labels: [string, string][] = [ - ['t', expected.testing], - ['r', expected.reconnecting], - [' ', expected.applying], + ['t', expected.action.test], + ['r', expected.action.reconnect], + [' ', expected.action.apply], ]; for (const [key, label] of labels) { overlay.handleInput(key); @@ -386,31 +342,10 @@ describe('MCP management overlay', () => { overlay.handleInput('\u001b'); } }); - - test(`localizes ${locale} action result notices`, async () => { - const mcp = surface(listSnapshot()); - mcp.execute = async () => ({ status: 'applied', effect: 'published' }); - const overlay = new McpManagementOverlay({ - locale, - surface: mcp, - viewportRows: () => 8, - onClose: () => undefined, - onChange: () => undefined, - }); - overlay.render(100); - - overlay.handleInput(' '); - await new Promise((resolve) => setImmediate(resolve)); - assert.ok(overlay.render(100).map(stripAnsi).join('\n').includes(expected.published)); - - mcp.execute = async () => ({ status: 'failed', reason: 'manager-failed' }); - overlay.handleInput('t'); - await new Promise((resolve) => setImmediate(resolve)); - assert.ok(overlay.render(100).map(stripAnsi).join('\n').includes(expected.managerFailed)); - }); } - const RESULT_CASES: readonly [TuiMcpActionResult, string][] = [ + const resultCopy = TUI_COPY_RESOURCES['mcp-status'].en.editor.results; + const RESULT_CASES: readonly [TuiMcpActionResult, keyof typeof resultCopy][] = [ [{ status: 'conflict', reason: 'exists' }, 'exists'], [{ status: 'conflict', reason: 'stale_config' }, 'stale_config'], [{ status: 'conflict', reason: 'stale_edit' }, 'stale_edit'], @@ -434,52 +369,24 @@ describe('MCP management overlay', () => { [{ status: 'tested', test: testResult(true), effect: 'pending_host' }, 'test_pending_host'], ]; - for (const locale of ['en', 'zh'] as const) { - test(`routes every action result to its ${locale} catalog notice`, async () => { - const results = TUI_COPY_RESOURCES['mcp-status'][locale].editor.results; - for (const [result, code] of RESULT_CASES) { - const mcp = surface(listSnapshot()); - mcp.execute = async () => result; - const overlay = new McpManagementOverlay({ - locale, - surface: mcp, - viewportRows: () => 8, - onClose: () => undefined, - onChange: () => undefined, - }); - overlay.render(100); - - overlay.handleInput(' '); - await new Promise((resolve) => setImmediate(resolve)); - const text = overlay.render(100).map(stripAnsi).join('\n'); - assert.ok(text.includes(results[code as keyof typeof results]), `${code} (${locale})`); - } - }); - } - - test('renders an unknown runtime result code as the raw code', async () => { - const mcp = surface(listSnapshot()); - mcp.execute = async () => - ({ status: 'failed', reason: 'future-code' }) as unknown as TuiMcpActionResult; - const overlay = new McpManagementOverlay({ - locale: 'en', - surface: mcp, - viewportRows: () => 8, - onClose: () => undefined, - onChange: () => undefined, - }); - overlay.render(100); - - overlay.handleInput(' '); - await new Promise((resolve) => setImmediate(resolve)); - assert.ok(overlay.render(100).map(stripAnsi).join('\n').includes('future-code')); - }); + test('routes every action result to its catalog notice', async () => { + for (const [result, code] of RESULT_CASES) { + const mcp = surface(listSnapshot()); + mcp.execute = async () => result; + const overlay = new McpManagementOverlay({ + locale: 'en', + surface: mcp, + viewportRows: () => 8, + onClose: () => undefined, + onChange: () => undefined, + }); + overlay.render(100); - test('keeps punctuation-sensitive catalog strings byte-for-byte', () => { - const editor = (locale: 'en' | 'zh') => TUI_COPY_RESOURCES['mcp-status'][locale].editor; - assert.equal(editor('zh').results.stale_import, '导入项已变化,请重新预览。'); - assert.equal(editor('en').confirmImportTitle, 'Import MCP servers?'); - assert.equal(editor('zh').confirmImportTitle, '导入 MCP 服务器?'); + overlay.handleInput(' '); + await new Promise((resolve) => setImmediate(resolve)); + const text = overlay.render(100).map(stripAnsi).join('\n'); + assert.ok(text.includes(resultCopy[code]), code); + } }); }); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index e19d253044c..a954c903188 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -396,13 +396,15 @@ describe('Maka Pi TUI runner', () => { // The driver's raw error text is not product copy; the TUI reports the // failure with its own localized notice (#2672). await waitFor(() => - plainTerminalOutput(terminal.output()).includes('Could not start a new session'), + plainTerminalOutput(terminal.output()).includes('a local command could not be stopped'), ); // Identity untouched… assert.equal(driver.getSessionId(), 'session-1'); // …and the transcript was not wiped by the aborted /new. - assert.match(plainTerminalOutput(terminal.screenOutput()), /Could not start a new session/); + const screen = plainTerminalOutput(terminal.screenOutput()); + assert.match(screen, /a local command could not be stopped/u); + assert.match(screen, /Press Ctrl\+C to stop it/u); assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /host_draining/); assert.ok(transcriptBefore.length > 0); diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index 0a1ef6b4eae..e752b9f7856 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -89,27 +89,6 @@ describe('TUI copy resources', () => { assert.equal(formatUiMessage(template, { count: 2 }, 'en'), '2 tools'); }); - test('localizes rewind and skill notices in both locales', () => { - assert.equal(TUI_COPY_RESOURCES.rewind.en.noTargets, 'No turns to rewind to.'); - assert.equal(TUI_COPY_RESOURCES.rewind.zh.noTargets, '没有可回退的轮次。'); - assert.equal( - formatUiMessage( - TUI_COPY_RESOURCES.skills.en.failedItem, - { request: 'nope', reason: TUI_COPY_RESOURCES.skills.en.failureReasons.not_found }, - 'en', - ), - '/skill:nope (not found)', - ); - assert.equal( - formatUiMessage( - TUI_COPY_RESOURCES.skills.zh.failedItem, - { request: 'nope', reason: TUI_COPY_RESOURCES.skills.zh.failureReasons.not_found }, - 'zh', - ), - '/skill:nope(未找到)', - ); - }); - test('localizes stable onboarding failure codes at the TUI boundary', () => { assert.equal( onboardingFailureMessage({ kind: 'rejected', reason: 'connection_not_found' }, 'en'), diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 1ef16e170df..a95fd2f9adb 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1889,8 +1889,8 @@ const TUI_PENDING_QUEUE_COPY = resolveUiMessageCatalog( export function renderMakaPiPendingQueue( state: MakaPiTranscriptState, width: number, - platform: NodeJS.Platform = process.platform, - locale: UiLocale = 'en', + platform: NodeJS.Platform, + locale: UiLocale, ): string[] { if (state.steering.length === 0 && state.followup.length === 0) { return []; diff --git a/packages/cli/src/pi-tui-layout.ts b/packages/cli/src/pi-tui-layout.ts index bca2fe430cc..f76c6f4dbf7 100644 --- a/packages/cli/src/pi-tui-layout.ts +++ b/packages/cli/src/pi-tui-layout.ts @@ -114,7 +114,7 @@ export class MakaActivityStripComponent implements Component { export class MakaPendingQueueComponent implements Component { constructor( private readonly state: MakaPiTranscriptState, - private readonly locale: UiLocale = 'en', + private readonly locale: UiLocale, ) {} invalidate(): void {} diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts index 300d757e994..d9dbff2fed9 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -763,7 +763,7 @@ function actionNotice( } function resultCopy(locale: UiLocale, code: TuiMcpResultCode): string { - return MCP_STATUS_COPY[locale].editor.results[code] ?? code; + return MCP_STATUS_COPY[locale].editor.results[code]; } function confirmAddDocument(draft: GuidedDraft, locale: UiLocale): string[] { diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 28bd48ba352..1e8ada26474 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -589,11 +589,12 @@ export const TUI_COPY_RESOURCES = { 'session-actions': { en: { foreignScanFailed: 'Could not read external conversations: {detail}', - newSessionFailed: 'Could not start a new session. Try again shortly.', + newSessionFailed: + 'Could not start a new session: a local command could not be stopped. Press Ctrl+C to stop it, then try again.', }, zh: { foreignScanFailed: '读取外部对话失败:{detail}', - newSessionFailed: '无法开始新会话,请稍后重试。', + newSessionFailed: '无法开始新会话:无法停止本地命令。请按 Ctrl+C 停止命令后重试。', }, }, 'pending-queue': { diff --git a/scripts/check-tui-copy.mjs b/scripts/check-tui-copy.mjs new file mode 100644 index 00000000000..f7baeab378b --- /dev/null +++ b/scripts/check-tui-copy.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from '@babel/parser'; + +const root = fileURLToPath(new URL('..', import.meta.url)); + +export const COVERED_FILES = [ + 'packages/cli/src/pi-tui-transcript-viewer.ts', + 'packages/cli/src/pi-tui-turn.ts', + 'packages/cli/src/pi-tui-runner.ts', + 'packages/cli/src/pi-tui-mcp-status.ts', + 'packages/cli/src/pi-transcript.ts', + 'packages/cli/src/pi-tui-pickers.ts', + 'packages/cli/src/tui-primary-guidance.ts', + 'packages/cli/src/tui-session-status.ts', + 'packages/cli/src/runtime-host-onboarding.ts', + 'packages/cli/src/runtime-host-tui-command.ts', + 'packages/cli/src/tui-attention.ts', + 'packages/cli/src/tui-shortcut-copy.ts', + 'packages/cli/src/pi-tui-layout.ts', +]; + +export const EXCLUDED_TUI_FILES = [ + 'packages/cli/src/pi-tui-contracts.ts', + 'packages/cli/src/runtime-host-tui-context.ts', + 'packages/cli/src/tui-ansi.ts', + 'packages/cli/src/tui-autocomplete-layout.ts', + 'packages/cli/src/tui-copy-catalog.ts', + 'packages/cli/src/tui-diff.ts', + 'packages/cli/src/tui-mcp-control.ts', + 'packages/cli/src/tui-mcp-remote-publication.ts', +]; + +const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/u; +const VISIBLE_PROPERTIES = new Set(['title', 'hint', 'placeholder', 'notice', 'label']); +const VISIBLE_CALLS = new Set([ + 'notice', + 'addNotice', + 'setNotice', + 'setStatus', + 'setMessage', + 'question', + 'write', + 'Text', +]); + +export const ALLOWED_VISIBLE_LITERALS = { + 'packages/cli/src/pi-tui-runner.ts': [ + 'Usage: !', + 'Cannot run a user command while a turn is running.', + 'Cannot change or start Swarm Mode while a turn is running.', + 'Cannot change or start Graph Mode while a turn is running.', + 'Cannot run /${…} while a turn is running — interrupt it (Esc) or wait for it to finish.', + 'Model changed: ${…} → ${…}', + 'Model changed: ${…} → ${…}', + 'Model changed: ${…} (${…}) → ${…} (${…})', + 'Thinking: ${…}', + 'Thinking: default', + 'Session moved to "${…}".${…}', + 'Resumed session "${…}"', + 'Resumed session "${…}"', + 'Detached from the running Turn — it keeps running. /session back to reattach.', + 'Side conversation closed; cleanup will be retried on the next launch.', + 'Close the current side conversation before opening another.', + 'Side conversations are unavailable on this runtime.', + 'Side conversation opened.', + 'Side conversation closed; cleanup will be retried on the next launch.', + '↑↓ move · type to answer · Enter select · Esc unanswered · Ctrl+C stop', + 'Other: type your answer…', + 'Recap is not available in this environment.', + 'Recap already running.', + 'Nothing to recap yet.', + 'Recap failed: ${…}', + 'Recap: ${…}', + 'Compacting context…', + 'Resuming from the latest safe boundary…', + 'Resume Session', + 'Tab scope · ↑↓ move · Enter select · Esc close', + 'Permissions: ${…}', + 'Keep Auto', + 'Turn on full access', + 'Using Swarm Mode for this turn only.', + 'Swarm Mode is on for this session.', + 'Swarm Mode is off for this session.', + 'Swarm Mode enabled for this session.', + 'Swarm Mode disabled.', + 'Run #${…}${…}', + 'This session has no Agent Graph runs.', + '↑↓ move · Enter inspect · Esc close', + 'Using Graph Mode for this turn only.', + 'Graph Mode is on for this session.', + 'Graph Mode is off.', + 'Graph Mode enabled for this session.', + 'Graph Mode disabled.', + ' · Current', + 'Moving sessions is not available in this environment.', + 'Session is already at "${…}".', + 'Session moved to "${…}".${…}', + 'Moving sessions is not available in this environment.', + 'Goal status is unavailable on this runtime.', + 'No goal set.', + 'No goal set.', + 'Goal control is unavailable on this runtime.', + 'Cannot pause: the goal is ${…}.', + 'Cannot resume: the goal is ${…}.', + 'Cannot clear: the goal is ${…}.', + 'Goal paused. /goal resume continues it, /goal clear stops it.', + 'Goal resumed.', + 'Goal cleared.', + 'Goal cleared.', + 'The goal no longer exists.', + 'Usage: /context', + 'Usage: /thinking ${…}', + 'default', + 'Usage: /compact', + 'Usage: /goal [pause|resume|clear]', + 'Cannot control the goal while a turn or another action is running — interrupt it (Esc) or wait for it to finish.', + 'Usage: /mcp', + 'Usage: /setup', + 'Usage: /model ', + 'Usage: /transcript', + 'Usage: /permissions auto|bypass', + 'Usage: /rename ', + 'Session renamed to "${…}"', + 'Usage: /resume', + 'Usage: /session ', + 'Press Ctrl+C again to exit.', + 'Could not resume session ${…}: ${…}.${…} Starting fresh.', + ], + 'packages/cli/src/pi-transcript.ts': [ + 'User command', + '${…} ${…} above the view stayed expanded in scrollback — press ${…} again within ${…}s to collapse them too (this redraws the screen and clears pre-session scrollback). New ${…} starts collapsed.', + 'Access ${…}', + 'Plan submitted: ${…}', + 'Stopped: ${…}', + 'Stopped: max tokens', + 'Background task ${…}: ${…}${…}${…}', + 'Context compacted.', + 'Nothing to compact.', + 'Context compaction failed: ${…}.', + 'expanded', + 'unchanged', + ], + 'packages/cli/src/pi-tui-pickers.ts': ['/skill:${…}', '/skill:${…}', 'Auto', 'Full access'], + 'packages/cli/src/runtime-host-tui-command.ts': [ + 'Maka', + 'Maka — ${…}', + 'Maka', + 'Restart this local Host if it is idle, wait for it to exit, or cancel? [r/w/C] ', + 'Wait only if the existing Host is expected to exit, or cancel? [w/C] ', + 'The existing Runtime Host still owns active or durable work and was not interrupted.\n', + ], +}; + +function staticText(node) { + if (node?.type === 'StringLiteral') return node.value; + if (node?.type === 'JSXText') return node.value.trim() || undefined; + if (node?.type !== 'TemplateLiteral') return undefined; + return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join('${…}'); +} + +function propertyName(node) { + if (!node || node.computed) return undefined; + if (node.key.type === 'Identifier') return node.key.name; + return staticText(node.key); +} + +function calleeName(node) { + if (node?.type === 'Identifier') return node.name; + if (node?.type === 'MemberExpression' || node?.type === 'OptionalMemberExpression') { + if (!node.computed && node.property.type === 'Identifier') return node.property.name; + return staticText(node.property); + } + return undefined; +} + +function containsLocaleReference(node) { + let found = false; + walk(node, (candidate) => { + if (candidate.type === 'Identifier' && /locale/i.test(candidate.name)) { + found = true; + } + }); + return found; +} + +function containsLiteralCopy(node) { + let found = false; + walk(node, (candidate) => { + const value = staticText(candidate); + if (value !== undefined && containsCopyCharacters(value)) found = true; + }); + return found; +} + +function containsCopyCharacters(value) { + return /[\p{L}\p{N}]/u.test(value); +} + +function noticeTextProperty(node) { + if (node.type !== 'ObjectProperty' || propertyName(node) !== 'text') return false; + const object = node.__parent; + if (object?.type !== 'ObjectExpression') return false; + return object.properties.some( + (property) => + property.type === 'ObjectProperty' && + propertyName(property) === 'kind' && + staticText(property.value) === 'notice', + ); +} + +function visibleLiteral(node) { + if (node.type === 'JSXText') return true; + let child = node; + for (let parent = node.__parent; parent; child = parent, parent = parent.__parent) { + if (parent.type === 'JSXAttribute') { + return parent.name.type === 'JSXIdentifier' && VISIBLE_PROPERTIES.has(parent.name.name); + } + if (parent.type === 'JSXElement' || parent.type === 'JSXFragment') return true; + if (parent.type === 'ConditionalExpression' && parent.test === child) return false; + if (parent.type === 'ObjectProperty' && parent.value === child) { + const name = propertyName(parent); + return VISIBLE_PROPERTIES.has(name) || noticeTextProperty(parent); + } + if ( + (parent.type === 'CallExpression' || + parent.type === 'OptionalCallExpression' || + parent.type === 'NewExpression') && + VISIBLE_CALLS.has(calleeName(parent.callee)) && + parent.arguments.includes(child) + ) { + return true; + } + } + return false; +} + +function walk(node, visit, parent) { + if (!node || typeof node !== 'object') return; + if (typeof node.type === 'string') { + Object.defineProperty(node, '__parent', { value: parent, configurable: true }); + visit(node); + parent = node; + } + for (const [key, value] of Object.entries(node)) { + if (key === '__parent' || key === 'loc' || key === 'start' || key === 'end') continue; + if (Array.isArray(value)) { + for (const child of value) walk(child, visit, parent); + } else if (value && typeof value === 'object') { + walk(value, visit, parent); + } + } +} + +function failure(file, node, rule, text) { + return { file, line: node.loc?.start.line ?? 1, rule, text }; +} + +export function checkSource(source, file = '') { + const ast = parse(source, { + sourceType: 'module', + plugins: ['typescript', 'jsx', 'importAttributes'], + errorRecovery: false, + }); + const failures = []; + walk(ast, (node) => { + const text = staticText(node); + if (text !== undefined && CJK.test(text)) { + failures.push(failure(file, node, 'cjk-literal', text)); + } + if ( + (node.type === 'ConditionalExpression' || node.type === 'IfStatement') && + containsLocaleReference(node.test) && + (containsLiteralCopy(node.consequent) || containsLiteralCopy(node.alternate)) + ) { + failures.push(failure(file, node, 'locale-branch', source.slice(node.start, node.end))); + } + if ( + node.type === 'SwitchStatement' && + containsLocaleReference(node.discriminant) && + node.cases.some((item) => item.consequent.some(containsLiteralCopy)) + ) { + failures.push(failure(file, node, 'locale-branch', source.slice(node.start, node.end))); + } + if (text !== undefined && containsCopyCharacters(text) && visibleLiteral(node)) { + failures.push(failure(file, node, 'visible-literal', text)); + } + }); + return failures; +} + +export function unexpectedFailures(failures, allowed = ALLOWED_VISIBLE_LITERALS) { + const remaining = []; + const inventory = new Map(); + for (const [file, entries] of Object.entries(allowed)) { + for (const entry of entries) { + const key = `${file}\u0000visible-literal\u0000${entry}`; + inventory.set(key, (inventory.get(key) ?? 0) + 1); + } + } + for (const failure of failures) { + const key = `${failure.file}\u0000${failure.rule}\u0000${failure.text}`; + const count = inventory.get(key) ?? 0; + if (count === 0) remaining.push(failure); + else inventory.set(key, count - 1); + } + for (const [key, count] of inventory) { + if (count === 0) continue; + const [file, rule, text] = key.split('\u0000'); + remaining.push({ file, line: 1, rule: 'stale-allowance', text: `${rule}: ${text}` }); + } + return remaining; +} + +export function checkFiles(files = COVERED_FILES) { + const failures = files.flatMap((file) => + checkSource(readFileSync(join(root, file), 'utf8'), file), + ); + return unexpectedFailures(failures); +} + +export function unclassifiedTuiFiles() { + const classified = new Set([...COVERED_FILES, ...EXCLUDED_TUI_FILES]); + return readdirSync(join(root, 'packages/cli/src'), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => relative(root, join(entry.parentPath, entry.name)).replaceAll(sep, '/')) + .filter( + (file) => + !file.includes('/__tests__/') && + /(?:^|[-/])tui(?:-|\.)/u.test(file) && + !classified.has(file), + ); +} + +function main() { + const failures = [ + ...unclassifiedTuiFiles().map((file) => ({ + file, + line: 1, + rule: 'unclassified-tui-file', + text: 'Add this file to COVERED_FILES or EXCLUDED_TUI_FILES.', + })), + ...checkFiles(), + ]; + if (failures.length === 0) { + console.log(`TUI copy boundaries: ok (${COVERED_FILES.length} files)`); + return; + } + for (const item of failures) { + console.error(`${item.file}:${item.line}: ${item.rule}: ${JSON.stringify(item.text)}`); + } + process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/scripts/check-tui-copy.test.mjs b/scripts/check-tui-copy.test.mjs new file mode 100644 index 00000000000..404433fd568 --- /dev/null +++ b/scripts/check-tui-copy.test.mjs @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { checkSource, unexpectedFailures, unclassifiedTuiFiles } from './check-tui-copy.mjs'; + +function rules(source) { + return checkSource(source).map(({ rule }) => rule); +} + +test('rejects CJK in string and template literals', () => { + assert.deepEqual(rules("const a = '重试'; const b = `失败:${reason}`;"), [ + 'cjk-literal', + 'cjk-literal', + ]); +}); + +test('ignores CJK in comments and runtime data', () => { + assert.deepEqual(rules('// 中文说明\nconst copy = userInput;'), []); +}); + +test('rejects locale comparisons that select copy', () => { + assert.deepEqual(rules("const copy = locale === 'zh' ? '重试' : 'Retry';"), [ + 'locale-branch', + 'cjk-literal', + ]); + assert.deepEqual(rules("if (options.locale === 'en') notice('Retry');"), [ + 'locale-branch', + 'visible-literal', + ]); + assert.deepEqual(rules("switch (input.locale) { case 'en': notice('Retry'); }"), [ + 'locale-branch', + 'visible-literal', + ]); + assert.deepEqual( + rules("switch (input.locale) { case 'en': count = 1; case 'zh': count = 2; }"), + [], + ); + assert.deepEqual(rules("const count = locale === 'zh' ? 1 : 2;"), []); +}); + +test('rejects literals at user-visible presentation sinks', () => { + const failures = checkSource(` + state.entries.push({ kind: 'notice', level: 'error', text: 'Try again' }); + const input = { title: 'Choose a model', hint: 'Enter select' }; + notice('Saved'); + new Text('Loading'); + notice(ansi.red('Wrapped failure')); + const wrapped = { title: ansi.bold('Wrapped title') }; + const jsx =

Visible JSX

; + const attribute = ; + `); + assert.deepEqual( + failures.map(({ text }) => text), + [ + 'Try again', + 'Choose a model', + 'Enter select', + 'Saved', + 'Loading', + 'Wrapped failure', + 'Wrapped title', + 'Visible JSX', + 'Visible title', + ], + ); +}); + +test('does not classify protocol fields or dynamic labels as copy literals', () => { + assert.deepEqual( + rules("const result = { kind: 'error', text: 'machine-value', label: value };"), + [], + ); +}); + +test('the legacy inventory is exact in both directions', () => { + const failure = { + file: 'boundary.ts', + line: 4, + rule: 'visible-literal', + text: 'Legacy copy', + }; + const allowed = { 'boundary.ts': ['Legacy copy'] }; + assert.deepEqual(unexpectedFailures([failure], allowed), []); + assert.deepEqual(unexpectedFailures([failure, failure], allowed), [failure]); + assert.deepEqual(unexpectedFailures([], allowed), [ + { + file: 'boundary.ts', + line: 1, + rule: 'stale-allowance', + text: 'visible-literal: Legacy copy', + }, + ]); +}); + +test('classifies every TUI source file as covered or infrastructure', () => { + assert.deepEqual(unclassifiedTuiFiles(), []); +});