From 0d931c91db02ab510d79b0edd8e8dbed2a6ee578 Mon Sep 17 00:00:00 2001 From: ankaifeng <2895443235@qq.com> Date: Wed, 26 Aug 2026 16:28:01 +0800 Subject: [PATCH] feat(cli): add Japanese and Korean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four languages now: en, zh, ja, ko. Picked from the locale, overridable with --lang or ORCACODE_LANG. Adding a language used to be more places than it looked. It is now three: - append the code to LANGUAGES - add its locale prefixes to LOCALE_PREFIX - add the table The language picker was a hardcoded two-element array, so a new table would have shipped with no way to select it. It is generated from LANGUAGES now, and every table must carry a `lang.` label for every language so each one can name the others — a test enforces that. Traditional Chinese locales (zh-TW, zh-HK) resolve to English on purpose. The vocabulary diverges enough from Simplified that serving zh reads worse than not translating, and a test pins that so nobody "fixes" it later. The parity tests only compared Chinese against English, so this change could have shipped half-translated with a green suite. They now run over every non-English table: missing keys, extra keys, function/string mismatches, and differing argument counts. The literals check — flags, gh commands, workflow input names, which must never be translated because the reader still has to type them — also runs per language now. 411 tests. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 10 +- RELEASE.md | 14 +- bin/i18n.mjs | 487 +++++++++++++++++++++++++++++++- bin/orcacode-review.mjs | 16 +- package.json | 2 +- scripts/i18n.test.mjs | 106 ++++--- 8 files changed, 575 insertions(+), 64 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8305f19..e0eaa8b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Skills for OrcaRouter products.", - "version": "1.3.2" + "version": "1.4.0" }, "plugins": [ { diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9b22719..4124744 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "orca-code-review", "description": "Set up, reconfigure, troubleshoot, and remove OrcaCode Review — AI pull-request review powered by OrcaRouter — in any GitHub repository.", - "version": "1.3.2", + "version": "1.4.0", "author": { "name": "Continuum-AI-Corp", "url": "https://github.com/Continuum-AI-Corp" diff --git a/README.md b/README.md index 73ccae0..33ed186 100644 --- a/README.md +++ b/README.md @@ -90,14 +90,18 @@ An existing identical skill is left unchanged; an existing **different** one is ### Language -The CLI speaks **English and Simplified Chinese**, picked from your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`). Override it per run, or pin it for good: +The CLI speaks **English, Simplified Chinese, Japanese and Korean**, picked from your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`). Override it per run, or pin it for good: ```bash -npx @orcarouter/code-review --lang zh # 中文界面 npx @orcarouter/code-review --lang en # English -export ORCACODE_LANG=zh # 固定下来 +npx @orcarouter/code-review --lang zh # 简体中文 +npx @orcarouter/code-review --lang ja # 日本語 +npx @orcarouter/code-review --lang ko # 한국어 +export ORCACODE_LANG=ja # pin it ``` +Traditional Chinese locales (`zh-TW`, `zh-HK`) fall back to English on purpose — the vocabulary diverges enough that serving Simplified reads worse than not translating at all. + Guided flows open with a language screen when `--lang` is not given. Add `--no-banner` to skip the wordmark. Menus are arrow-key driven — `↑↓` to move, `Enter` to pick. Multi-select adds `space` to toggle, `a`/`n` for all/none, and `/` to filter (`ctrl-u` clears it), which is how you find one agent among 36 without scrolling. Terminals without raw mode fall back to typing a number. diff --git a/RELEASE.md b/RELEASE.md index dc6792d..44f0e0f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -177,10 +177,16 @@ the README platform list, and the orcadub README. #### Adding a user-facing string -`bin/i18n.mjs` holds both languages. `scripts/i18n.test.mjs` fails the build if -the two tables diverge — a missing Chinese key, a key only Chinese has, or a -parameterized string whose two versions take different argument counts (which -would silently drop a branch name or a count from the Chinese output). +`bin/i18n.mjs` holds every language. `scripts/i18n.test.mjs` checks each +translation against English and fails the build on a missing key, a +translation-only key, or a parameterized string whose versions take different +argument counts (which would silently drop a branch name or a count). + +Adding a language is three edits: append the code to `LANGUAGES`, add its +locale prefixes to `LOCALE_PREFIX`, add the table. The language picker is +generated from `LANGUAGES`, and a `lang.` label is required in *every* +table so each language can name the others — a test enforces that. Nothing else +needs touching. Translate prose only. Flags, platform IDs, workflow inputs, severity codes, paths, and shell commands stay verbatim in both languages: a reader of the diff --git a/bin/i18n.mjs b/bin/i18n.mjs index bd7096c..6bd9de8 100644 --- a/bin/i18n.mjs +++ b/bin/i18n.mjs @@ -11,7 +11,16 @@ // `--platform codex` and grep for `block-on`, and translating those would // make the output impossible to act on. -export const LANGUAGES = Object.freeze(["en", "zh"]); +export const LANGUAGES = Object.freeze(["en", "zh", "ja", "ko"]); + +// The locale prefixes that map onto each table. Traditional Chinese is +// deliberately absent — zh-TW/zh-HK differ enough in vocabulary that serving +// them Simplified reads worse than serving them English. +const LOCALE_PREFIX = Object.freeze({ + zh: ["zh", "zh-cn", "zh-sg", "zh-hans", "zh-hans-cn"], + ja: ["ja", "ja-jp"], + ko: ["ko", "ko-kr"], +}); export function parseLanguage(raw) { const value = String(raw ?? "").trim().toLowerCase(); @@ -26,11 +35,14 @@ export function detectLanguage(env = process.env) { } const raw = env.LC_ALL || env.LC_MESSAGES || env.LANG || ""; const locale = raw.split(".")[0].replaceAll("_", "-").toLowerCase(); - return ["zh", "zh-cn", "zh-sg", "zh-hans", "zh-hans-cn"].includes(locale) ? "zh" : "en"; + for (const [lang, prefixes] of Object.entries(LOCALE_PREFIX)) { + if (prefixes.includes(locale)) return lang; + } + return "en"; } const EN = { - lang: { question: "Language / 语言", en: "English", zh: "简体中文" }, + lang: { question: "Language / 语言 / 言語 / 언어", en: "English", zh: "简体中文", ja: "日本語", ko: "한국어" }, common: { recommended: "(recommended)", @@ -54,7 +66,7 @@ const EN = { unknownOptionHint: "Run with --help for usage.", unknownCommand: (c) => `Unknown command: ${c}`, unknownLanguage: (l) => `Unknown language "${l}".`, - unknownLanguageHint: "Use --lang zh or --lang en.", + unknownLanguageHint: "Use --lang en, zh, ja or ko.", missingPlatformValue: "--platform needs a value.", aborted: "Aborted. Nothing was written.", abortedRemoval: "Aborted. Nothing was removed.", @@ -249,7 +261,7 @@ const EN = { optYes: "Accept the recommended defaults; never prompt", optForce: "Overwrite without asking", optJson: "Machine-readable output (skill install / skill list)", - optLang: "Interface language: zh | en (default: from your locale)", + optLang: "Interface language: en | zh | ja | ko (default: from your locale)", optNoBanner: "Skip the wordmark", optScope: "For `skill`: project | global", optPlatform: "For `skill`: repeatable; omit to pick interactively", @@ -259,7 +271,7 @@ const EN = { }; const ZH = { - lang: { question: "Language / 语言", en: "English", zh: "简体中文" }, + lang: { question: "Language / 语言 / 言語 / 언어", en: "English", zh: "简体中文", ja: "日本語", ko: "한국어" }, common: { recommended: "(推荐)", @@ -283,7 +295,7 @@ const ZH = { unknownOptionHint: "用 --help 查看用法。", unknownCommand: (c) => `未知命令:${c}`, unknownLanguage: (l) => `未知语言「${l}」。`, - unknownLanguageHint: "请用 --lang zh 或 --lang en。", + unknownLanguageHint: "请用 --lang en / zh / ja / ko。", missingPlatformValue: "--platform 需要一个值。", aborted: "已取消,未写入任何内容。", abortedRemoval: "已取消,未删除任何内容。", @@ -472,7 +484,7 @@ const ZH = { optYes: "使用推荐默认值,不再询问", optForce: "直接覆盖,不询问", optJson: "机器可读输出(skill install / skill list)", - optLang: "界面语言:zh | en(默认跟随系统 locale)", + optLang: "界面语言:en | zh | ja | ko(默认跟随系统 locale)", optNoBanner: "不显示字符画标题", optScope: "用于 `skill`:project | global", optPlatform: "用于 `skill`:可重复;不指定则交互选择", @@ -481,7 +493,464 @@ const ZH = { }, }; -const STRINGS = { en: EN, zh: ZH }; + +const JA = { + lang: { question: "Language / 语言 / 言語 / 언어", en: "English", zh: "简体中文", ja: "日本語", ko: "한국어" }, + + common: { + recommended: "(推奨)", + detected: "検出済み", + chooseRange: (n, d) => ` 1-${n} を入力 [${d}] `, + enterNumber: (n) => `1 から ${n} までの数字を入力してください。`, + multiHint: "例: 1,3 または 1-4 · a = 全選択 · Enter = 選択中のまま確定", + selectPrompt: " 選択 ", + nothingSelected: "何も選択されていません。1 つ以上選ぶか、Ctrl-C で中止してください。", + hintSelect: "↑↓ 移動 · Enter 選択", + hintMulti: "space 切替 · a 全選択 · n 全解除 · / 絞り込み · Enter 確定", + hintFiltering: "入力して絞り込み · ctrl-u クリア · Enter 適用", + hintFilterLabel: "絞り込み:", + hintNoMatch: "該当なし", + countSelected: (n) => `${n} 件選択中`, + nonTTY: (what) => `「${what}」を確認できません — 標準入力が端末ではありません。`, + nonTTYHint: "--yes を付けて推奨値を使うか、フラグで明示してください(--help)。", + notGitRepo: "git リポジトリの中ではありません。", + notGitRepoHint: "リポジトリに cd してから実行してください。", + unknownOption: (a) => `不明なオプション: ${a}`, + unknownOptionHint: "使い方は --help を参照してください。", + unknownCommand: (c) => `不明なコマンド: ${c}`, + unknownLanguage: (l) => `不明な言語「${l}」。`, + unknownLanguageHint: "--lang en / zh / ja / ko のいずれかを指定してください。", + missingPlatformValue: "--platform には値が必要です。", + aborted: "中止しました。何も書き込んでいません。", + abortedRemoval: "中止しました。何も削除していません。", + }, + + config: { + settingsQ: "レビュー設定はどこで管理しますか?", + settingsDashboard: "OrcaRouter ダッシュボード", + settingsDashboardDetail: + "モデル・レビューモード・評価基準・重大度ルールをコンソールから変更。workflow の編集は不要です。", + settingsFile: "この workflow ファイル", + settingsFileDetail: + "ダッシュボードの取得をスキップします。YAML が唯一の正解になり、サーバ側から上書きされません。", + + blockQ: "どの指摘でマージを止めますか?", + blockBoth: "P0 と P1", + blockBothDetail: "既定の取り決め。重大・高リスクの指摘でチェックが失敗します。", + blockP0: "P0 のみ", + blockP0Detail: "重大な問題だけを止めます。P1 は行コメントとして残ります。", + blockNone: "止めない — コメントのみ", + blockNoneDetail: "チェックは常に通ります。試用期間に向いています。", + + oversizedQ: "PR の差分が大きすぎてレビューできない場合は?", + oversizedFail: "チェックを失敗させる", + oversizedFailDetail: "差分を上限まで膨らませても、必須マージゲートを素通りできなくなります。", + oversizedPass: "通知だけ出して通す", + oversizedPassDetail: "スキップ通知を投稿し、チェックは緑のままです。", + + publicWarning: (url) => + "これは公開リポジトリです。workflow は pull_request_target で動き、あなたの\n" + + " OrcaRouter シークレットを使います。fork 承認ゲートを迂回するため、第三者が\n" + + ` PR を開くだけであなたの残高を消費できます。${url} で予算と通知を設定してください。`, + authorsQ: "誰の PR を自動レビューしますか?", + authorsKnown: "既知のコントリビューターのみ", + authorsKnownDetail: "それ以外も /orcacode-review で個別にレビューできます。", + authorsAll: "全員", + authorsAllDetail: "誰の PR でも有料レビューが走ります。", + }, + + init: { + title: "OrcaCode Review — インストール", + repo: (name) => ` リポジトリ ${name}`, + repoUnknown: "(不明 — GitHub リモートが見つかりません)", + workflow: (p) => ` workflow ${p}`, + exists: "そのパスには既に workflow があります。", + reconfigureInstead: "代わりに設定を変更しますか?", + unchangedHint: "何も変更していません。上書きするには --force を付けて再実行してください。", + preview: "書き込む workflow:", + writeConfirm: (p) => `${p} を書き込みますか?`, + wrote: (p) => `${p} を書き込みました`, + remaining: "残りの手順", + step1: (url) => ` 1. アプリを有効化: ${url} → Apps → OrcaCode Review`, + step1Note: " 有効化するまでレビューは動きません。", + step2: " 2. ブランチにコミットして PR を作成 —", + step2Note: + " pull_request_target はベースブランチから workflow を読むため、\n" + + " このファイルが既定ブランチに入って初めてレビューが始まります。", + step3: " 3. ゲートを有効に: Settings → Branches / Rulesets → Require status checks", + step3Note: (check) => ` ${check} チェックを必須に。赤いだけのチェックは何も止めません。`, + diagnoseHint: " いつでも診断: npx @orcarouter/code-review doctor", + }, + + reconfigure: { + title: "OrcaCode Review — 設定変更", + missing: (p) => `${p} が見つかりません。`, + missingHint: "先に `npx @orcarouter/code-review init` を実行してください。", + dashboardOwns: "このインストールでは、ほとんどの設定を OrcaRouter ダッシュボードが持っています。", + dashboardOwnsDetail: (url) => + " レビューモード・モデル・徹底モード・静音モード・評価基準はこのファイルには\n" + + ` ありません。${url} → Apps → OrcaCode Review で変更してください。\n` + + " ここに書いた input は、文書化された既定値と異なる場合のみ有効になります。", + noChanges: "変更なし — workflow は既にその内容です。", + changes: "変更内容", + applyConfirm: (p) => `${p} に適用しますか?`, + updated: (p) => `${p} を更新しました`, + commitHint: "コミットして push してください。次回どれかの PR に push した時点で反映されます。", + }, + + doctor: { + title: "OrcaCode Review — 診断", + repo: (name) => ` リポジトリ ${name}`, + repoUnknown: "(不明)", + workflowExists: (p) => `${p} があります`, + workflowMissing: (p) => `${p} がありません`, + workflowMissingFix: "実行: npx @orcarouter/code-review init", + onBase: (b) => `origin/${b} に存在します`, + notOnBase: (b) => `まだ origin/${b} に入っていません`, + notOnBaseFix: + "pull_request_target は「ベースブランチ」から workflow を読みます。マージしてから実行を待ってください。", + noGh: "GitHub CLI が使えないか未認証です — シークレット・実行履歴・ゲートの確認をスキップします。", + noGhHint: " https://cli.github.com から導入し `gh auth login` を実行すると完全な結果が出ます。", + secretSet: (s) => `リポジトリシークレット ${s} は設定済みです`, + secretMissing: (s) => `リポジトリシークレット ${s} が見つかりません`, + secretMissingFix: (s, url) => `実行: gh secret set ${s} (キーは ${url})`, + runsUnreadable: "実行履歴を取得できません(一度も動いていない可能性があります)。", + noRuns: "この workflow の実行履歴がありません", + noRunsFix: + "よくある原因: ダッシュボードでアプリが無効、auto_review が off、trigger=ready_for_review で\n" + + " PR が下書きのまま、作者が auto-review-authors の対象外。", + recentRuns: (n) => `直近 ${n} 件の実行:`, + inspectFailure: "失敗の詳細: gh run view --log-failed", + gateRequired: (b) => `${b} で「review」チェックが必須になっています`, + gateMissing: (b) => `${b} で「review」チェックが必須になっていません`, + gateMissingFix: + "指摘は投稿されますが、何も止まりません。Settings → Branches / Rulesets → Require status checks。", + noProtection: (b) => `${b} のブランチ保護を読めません — マージは制限されていません。`, + clean: "問題は見つかりませんでした。", + problems: (n) => `${n} 件の問題 — 上の修正方法を参照してください。`, + problemsHint: + " 症状 → 原因 → 対処の一覧: skills/setup-orca-code-review/references/troubleshooting.md", + }, + + uninstall: { + title: "OrcaCode Review — アンインストール", + nothing: (p) => `削除するものがありません — ${p} は存在しません。`, + gateFirst: + "先に必須の「review」チェックを外してください。\n" + + " workflow が消えた必須チェックは二度と報告されず、すべての PR が永久に\n" + + " ブロックされ、解除する手段がなくなります。", + gateWhere: (repo, branch) => ` ${repo} の Settings → Branches / Rulesets(${branch})`, + deleteConfirm: (p) => `${p} を削除しますか?`, + removed: (p) => `${p} を削除しました`, + keepSecret: (s) => ` • ${s} シークレットは残しても無害で、再導入するなら残す価値があります。`, + disableApp: (url) => ` • 課金を止めるには ${url} → Apps → OrcaCode Review でアプリを無効化してください。`, + keepComments: " • 既存のレビューコメントはそのまま残ります — PR の履歴の一部です。", + }, + + skill: { + missingBundle: "このパッケージに同梱の skill が見つかりません。", + missingBundleHint: "再インストール: npx @orcarouter/code-review@latest skill", + scopeQ: "skill をどこにインストールしますか?", + scopeProject: "このプロジェクト", + scopeProjectDetail: "リポジトリと一緒にコミットされ、clone した全員が使えます。", + scopeGlobal: "このユーザー", + scopeGlobalDetail: "このマシンのすべてのプロジェクトで使えます。", + unknownScope: (s) => `不明なスコープ「${s}」。`, + unknownScopeHint: "--scope project または --scope global を指定してください。", + unknownPlatform: (id) => `不明なプラットフォーム「${id}」。`, + unknownPlatformHint: "一覧: npx @orcarouter/code-review skill list", + noneDetected: "エージェントを検出できず、指定もありません。", + noneDetectedHint: + "明示してください。例: --platform claude --platform codex(`skill list` で 36 個すべて表示)。", + platformQ: "どのエージェントに skill を入れますか?", + platformCount: (n) => `(${n} 個検出)`, + statusUpdated: "(更新しました)", + statusUnchanged: "(既に最新)", + statusConflict: "(スキップ — 内容の異なる版が既にあります)", + forceHint: "内容が異なる版を上書きするには --force を付けて再実行してください。", + askAgent: "エージェントにこう伝えてください:「このリポジトリに OrcaCode Review を設定して」。", + pluginHint: " Claude Code はプラグインの方が自動で更新されます:", + handoffTitle: "あとはエージェントに頼むだけ", + handoffPrimary: "このリポジトリに OrcaCode Review を設定して", + handoffMore: "他にもできること:", + handoffDoctor: "「OrcaCode Review が動かないのはなぜ?」", + handoffDoctorWhat: "診断", + handoffTune: "「OrcaCode Review を P0 だけ止めるように」", + handoffTuneWhat: "マージ方針の変更", + handoffRemove: "「このリポジトリから OrcaCode Review を外して」", + handoffRemoveWhat: "アンインストール", + handoffCli: "端末で使いたい場合は、これらもサブコマンドとして使えます — --help を参照。", + listTitle: (n) => `対応プラットフォーム ${n} 個`, + listColumns: " (プロジェクト側 / グローバル側)", + listLegend: " ● = このマシンで検出。導入: npx @orcarouter/code-review skill install --platform ", + }, + + secret: { + title: (s) => `リポジトリシークレット: ${s}`, + createHint: (url) => ` ${url} でキーを作成またはコピーしてください`, + alreadySet: (s) => `${s} は既に設定済みです。`, + setNow: "GitHub CLI で今すぐ設定しますか?", + wasSet: (s) => `${s} を設定しました。`, + ghFailed: "gh でシークレットを設定できませんでした。ブラウザから追加してください:", + addManually: (s) => ` ${s} という名前でシークレットを追加:`, + manualPath: "リポジトリ → Settings → Secrets and variables → Actions → New repository secret", + }, + + usage: { + tagline: "OrcaCode Review インストーラー(OrcaRouter による AI PR レビュー)", + usage: "使い方", + bareCommand: "コマンドなしで実行すると skill を入れます。あとはエージェントに頼んでください。", + commands: "コマンド", + options: "オプション", + examples: "例", + docs: "ドキュメント", + cmdInit: ".github/workflows/orca-code-review.yml を自分で書き込む", + cmdReconfigure: "既存 workflow の input を変更する", + cmdDoctor: "動かないインストールを診断する", + cmdUninstall: "workflow を削除する", + cmdSkillInstall: (n) => `エージェント skill を導入(${n} プラットフォーム)— 既定`, + cmdSkillList: "対応プラットフォームと検出状況を一覧表示", + optYes: "推奨値を使い、一切確認しない", + optForce: "確認せず上書きする", + optJson: "機械可読な出力(skill install / skill list)", + optLang: "表示言語: en | zh | ja | ko(既定: ロケールに従う)", + optNoBanner: "ロゴを表示しない", + optScope: "`skill` 用: project | global", + optPlatform: "`skill` 用: 繰り返し可。省略すると対話選択", + optHelp: "このヘルプを表示", + optVersion: "バージョンを表示", + }, +}; + +const KO = { + lang: { question: "Language / 语言 / 言語 / 언어", en: "English", zh: "简体中文", ja: "日本語", ko: "한국어" }, + + common: { + recommended: "(권장)", + detected: "감지됨", + chooseRange: (n, d) => ` 1-${n} 입력 [${d}] `, + enterNumber: (n) => `1에서 ${n} 사이의 숫자를 입력하세요.`, + multiHint: "예: 1,3 또는 1-4 · a = 전체 · Enter = 선택 상태로 확정", + selectPrompt: " 선택 ", + nothingSelected: "선택된 항목이 없습니다. 하나 이상 고르거나 Ctrl-C로 중단하세요.", + hintSelect: "↑↓ 이동 · Enter 선택", + hintMulti: "space 토글 · a 전체 · n 해제 · / 검색 · Enter 확정", + hintFiltering: "입력해 검색 · ctrl-u 지우기 · Enter 적용", + hintFilterLabel: "검색:", + hintNoMatch: "일치 없음", + countSelected: (n) => `${n}개 선택됨`, + nonTTY: (what) => `「${what}」을(를) 물어볼 수 없습니다 — 표준 입력이 터미널이 아닙니다.`, + nonTTYHint: "--yes로 권장값을 쓰거나 플래그로 직접 지정하세요(--help).", + notGitRepo: "git 저장소 안이 아닙니다.", + notGitRepoHint: "저장소로 cd한 뒤 다시 실행하세요.", + unknownOption: (a) => `알 수 없는 옵션: ${a}`, + unknownOptionHint: "사용법은 --help를 보세요.", + unknownCommand: (c) => `알 수 없는 명령: ${c}`, + unknownLanguage: (l) => `알 수 없는 언어 "${l}".`, + unknownLanguageHint: "--lang en / zh / ja / ko 중 하나를 쓰세요.", + missingPlatformValue: "--platform에는 값이 필요합니다.", + aborted: "중단했습니다. 아무것도 쓰지 않았습니다.", + abortedRemoval: "중단했습니다. 아무것도 삭제하지 않았습니다.", + }, + + config: { + settingsQ: "리뷰 설정을 어디에 둘까요?", + settingsDashboard: "OrcaRouter 콘솔", + settingsDashboardDetail: + "모델, 리뷰 모드, 평가 기준, 심각도 규칙을 콘솔에서 변경합니다. workflow는 건드리지 않습니다.", + settingsFile: "이 workflow 파일", + settingsFileDetail: + "콘솔 조회를 건너뜁니다. YAML이 기준이 되고 서버 쪽 값이 덮어쓸 수 없습니다.", + + blockQ: "어떤 지적이 머지를 막아야 하나요?", + blockBoth: "P0와 P1", + blockBothDetail: "기본 규칙. 심각·높음 등급에서 체크가 실패합니다.", + blockP0: "P0만", + blockP0Detail: "심각한 문제만 막습니다. P1은 라인 코멘트로 남습니다.", + blockNone: "막지 않음 — 코멘트만", + blockNoneDetail: "체크는 항상 통과합니다. 시범 기간에 적합합니다.", + + oversizedQ: "PR diff가 너무 커서 리뷰할 수 없으면?", + oversizedFail: "체크를 실패 처리", + oversizedFailDetail: "diff를 상한 너머로 부풀려도 필수 머지 게이트를 통과할 수 없습니다.", + oversizedPass: "안내만 남기고 통과", + oversizedPassDetail: "건너뛰었다는 안내를 남기고 체크는 초록으로 유지됩니다.", + + publicWarning: (url) => + "공개 저장소입니다. workflow는 pull_request_target에서 당신의 OrcaRouter\n" + + " 시크릿으로 실행되며 fork 승인 게이트를 우회합니다 — 즉 외부인이 PR을\n" + + ` 여는 것만으로 당신의 잔액을 씁니다. ${url}에서 예산과 알림을 설정하세요.`, + authorsQ: "누구의 PR을 자동 리뷰할까요?", + authorsKnown: "알려진 기여자만", + authorsKnownDetail: "나머지도 /orcacode-review로 필요할 때 리뷰할 수 있습니다.", + authorsAll: "모두", + authorsAllDetail: "누구의 PR이든 유료 리뷰가 실행됩니다.", + }, + + init: { + title: "OrcaCode Review — 설치", + repo: (name) => ` 저장소 ${name}`, + repoUnknown: "(알 수 없음 — GitHub 리모트를 찾지 못함)", + workflow: (p) => ` workflow ${p}`, + exists: "해당 경로에 이미 workflow가 있습니다.", + reconfigureInstead: "대신 설정을 변경할까요?", + unchangedHint: "아무것도 바꾸지 않았습니다. 덮어쓰려면 --force로 다시 실행하세요.", + preview: "작성할 workflow:", + writeConfirm: (p) => `${p}을(를) 작성할까요?`, + wrote: (p) => `${p} 작성 완료`, + remaining: "남은 단계", + step1: (url) => ` 1. 앱 켜기: ${url} → Apps → OrcaCode Review`, + step1Note: " 켜기 전까지 리뷰는 실행되지 않습니다.", + step2: " 2. 브랜치에 커밋하고 PR 열기 —", + step2Note: + " pull_request_target은 베이스 브랜치에서 workflow를 읽습니다. 이 파일이\n" + + " 기본 브랜치에 들어가야 리뷰가 시작됩니다.", + step3: " 3. 게이트를 실제로: Settings → Branches / Rulesets → Require status checks", + step3Note: (check) => ` ${check} 체크를 필수로 지정하세요. 빨간 체크만으로는 아무것도 막지 못합니다.`, + diagnoseHint: " 언제든 진단: npx @orcarouter/code-review doctor", + }, + + reconfigure: { + title: "OrcaCode Review — 설정 변경", + missing: (p) => `${p}이(가) 없습니다.`, + missingHint: "먼저 `npx @orcarouter/code-review init`을 실행하세요.", + dashboardOwns: "이 설치는 대부분의 설정을 OrcaRouter 콘솔이 관리합니다.", + dashboardOwnsDetail: (url) => + " 리뷰 모드, 모델, 철저 모드, 조용 모드, 평가 기준은 이 파일에 없습니다 —\n" + + ` ${url} → Apps → OrcaCode Review에서 바꾸세요.\n` + + " 여기 적은 input은 문서화된 기본값과 다를 때만 적용됩니다.", + noChanges: "변경 없음 — workflow가 이미 그 내용입니다.", + changes: "변경 사항", + applyConfirm: (p) => `${p}에 적용할까요?`, + updated: (p) => `${p} 업데이트 완료`, + commitHint: "커밋 후 push하세요. 열려 있는 PR에 다음 push가 있을 때 적용됩니다.", + }, + + doctor: { + title: "OrcaCode Review — 진단", + repo: (name) => ` 저장소 ${name}`, + repoUnknown: "(알 수 없음)", + workflowExists: (p) => `${p} 있음`, + workflowMissing: (p) => `${p} 없음`, + workflowMissingFix: "실행: npx @orcarouter/code-review init", + onBase: (b) => `origin/${b}에 있습니다`, + notOnBase: (b) => `아직 origin/${b}에 없습니다`, + notOnBaseFix: + "pull_request_target은 '베이스' 브랜치에서 workflow를 읽습니다. 머지한 뒤에 실행을 기대하세요.", + noGh: "GitHub CLI를 쓸 수 없거나 로그인되지 않았습니다 — 시크릿·실행 기록·게이트 확인을 건너뜁니다.", + noGhHint: " https://cli.github.com 에서 설치하고 `gh auth login`을 실행하면 전체 결과가 나옵니다.", + secretSet: (s) => `저장소 시크릿 ${s} 설정됨`, + secretMissing: (s) => `저장소 시크릿 ${s}을(를) 찾을 수 없음`, + secretMissingFix: (s, url) => `실행: gh secret set ${s} (키는 ${url})`, + runsUnreadable: "실행 기록을 읽을 수 없습니다(한 번도 실행되지 않았을 수 있습니다).", + noRuns: "이 workflow의 실행 기록이 없습니다", + noRunsFix: + "흔한 원인: 콘솔에서 앱이 꺼짐, auto_review가 off, trigger=ready_for_review인데 PR이\n" + + " 초안 상태, 또는 작성자가 auto-review-authors 대상이 아님.", + recentRuns: (n) => `최근 실행 ${n}건:`, + inspectFailure: "실패 내용 확인: gh run view --log-failed", + gateRequired: (b) => `${b}에서 "review" 체크가 필수입니다`, + gateMissing: (b) => `${b}에서 "review" 체크가 필수가 아닙니다`, + gateMissingFix: + "지적은 올라오지만 아무것도 막지 못합니다. Settings → Branches / Rulesets → Require status checks.", + noProtection: (b) => `${b}의 브랜치 보호를 읽을 수 없습니다 — 머지가 통제되지 않습니다.`, + clean: "문제를 찾지 못했습니다.", + problems: (n) => `문제 ${n}건 — 위의 해결 방법을 보세요.`, + problemsHint: + " 증상 → 원인 → 해결 표: skills/setup-orca-code-review/references/troubleshooting.md", + }, + + uninstall: { + title: "OrcaCode Review — 제거", + nothing: (p) => `제거할 것이 없습니다 — ${p}이(가) 없습니다.`, + gateFirst: + "필수 \"review\" 체크를 먼저 해제하세요.\n" + + " workflow가 사라진 필수 체크는 다시는 보고되지 않고, 모든 PR이 영원히\n" + + " 막히며 풀 방법이 없습니다.", + gateWhere: (repo, branch) => ` ${repo}의 Settings → Branches / Rulesets (${branch})`, + deleteConfirm: (p) => `${p}을(를) 지금 삭제할까요?`, + removed: (p) => `${p} 삭제 완료`, + keepSecret: (s) => ` • ${s} 시크릿은 남겨도 무해하며, 다시 설치할 생각이면 남길 만합니다.`, + disableApp: (url) => ` • 과금을 멈추려면 ${url} → Apps → OrcaCode Review에서 앱을 끄세요.`, + keepComments: " • 기존 리뷰 코멘트는 그대로 남습니다 — PR 기록의 일부입니다.", + }, + + skill: { + missingBundle: "이 패키지에 포함된 skill을 찾을 수 없습니다.", + missingBundleHint: "다시 설치: npx @orcarouter/code-review@latest skill", + scopeQ: "skill을 어디에 설치할까요?", + scopeProject: "이 프로젝트", + scopeProjectDetail: "저장소와 함께 커밋되어, clone한 모두가 쓸 수 있습니다.", + scopeGlobal: "내 사용자 계정", + scopeGlobalDetail: "이 컴퓨터의 모든 프로젝트에서 쓸 수 있습니다.", + unknownScope: (s) => `알 수 없는 범위 "${s}".`, + unknownScopeHint: "--scope project 또는 --scope global을 쓰세요.", + unknownPlatform: (id) => `알 수 없는 플랫폼 "${id}".`, + unknownPlatformHint: "목록: npx @orcarouter/code-review skill list", + noneDetected: "에이전트를 감지하지 못했고, 지정된 것도 없습니다.", + noneDetectedHint: + "직접 지정하세요. 예: --platform claude --platform codex (`skill list`로 36개 전체 확인).", + platformQ: "어떤 에이전트에 skill을 설치할까요?", + platformCount: (n) => ` (${n}개 감지)`, + statusUpdated: "(업데이트됨)", + statusUnchanged: "(이미 최신)", + statusConflict: "(건너뜀 — 내용이 다른 버전이 이미 있음)", + forceHint: "내용이 다른 복사본을 덮어쓰려면 --force로 다시 실행하세요.", + askAgent: '에이전트에게 말하세요: "이 저장소에 OrcaCode Review를 설정해줘".', + pluginHint: " Claude Code는 플러그인 쪽이 자동으로 최신을 유지합니다:", + handoffTitle: "이제 에이전트에게 말하기만 하면 됩니다", + handoffPrimary: "이 저장소에 OrcaCode Review를 설정해줘", + handoffMore: "이런 것도 됩니다:", + handoffDoctor: '"OrcaCode Review가 왜 안 돌지?"', + handoffDoctorWhat: "진단", + handoffTune: '"OrcaCode Review가 P0만 막게 해줘"', + handoffTuneWhat: "머지 정책 변경", + handoffRemove: '"이 저장소에서 OrcaCode Review 제거해줘"', + handoffRemoveWhat: "제거", + handoffCli: "터미널이 편하다면 이것들도 서브커맨드로 있습니다 — --help 참고.", + listTitle: (n) => `지원 플랫폼 ${n}개`, + listColumns: " (프로젝트 경로 / 전역 경로)", + listLegend: " ● = 이 컴퓨터에서 감지됨. 설치: npx @orcarouter/code-review skill install --platform ", + }, + + secret: { + title: (s) => `저장소 시크릿: ${s}`, + createHint: (url) => ` ${url} 에서 키를 만들거나 복사하세요`, + alreadySet: (s) => `${s}은(는) 이미 설정되어 있습니다.`, + setNow: "지금 GitHub CLI로 설정할까요?", + wasSet: (s) => `${s} 설정 완료.`, + ghFailed: "gh로 시크릿을 설정하지 못했습니다. 브라우저에서 추가하세요:", + addManually: (s) => ` ${s} 이름으로 시크릿을 추가:`, + manualPath: "저장소 → Settings → Secrets and variables → Actions → New repository secret", + }, + + usage: { + tagline: "OrcaCode Review 설치 도구 (OrcaRouter 기반 AI PR 리뷰)", + usage: "사용법", + bareCommand: "명령 없이 실행하면 skill을 설치합니다. 나머지는 에이전트에게 맡기세요.", + commands: "명령", + options: "옵션", + examples: "예시", + docs: "문서", + cmdInit: ".github/workflows/orca-code-review.yml을 직접 작성", + cmdReconfigure: "기존 workflow의 input 변경", + cmdDoctor: "설치했는데 동작하지 않을 때 진단", + cmdUninstall: "workflow 제거", + cmdSkillInstall: (n) => `에이전트 skill 설치 (${n}개 플랫폼) — 기본`, + cmdSkillList: "지원 플랫폼과 감지 여부 목록", + optYes: "권장값을 쓰고 묻지 않음", + optForce: "묻지 않고 덮어쓰기", + optJson: "기계 판독용 출력 (skill install / skill list)", + optLang: "인터페이스 언어: en | zh | ja | ko (기본: 로케일 따름)", + optNoBanner: "로고 표시 안 함", + optScope: "`skill`용: project | global", + optPlatform: "`skill`용: 반복 가능. 생략하면 대화형 선택", + optHelp: "이 도움말 표시", + optVersion: "버전 표시", + }, +}; + +const STRINGS = { en: EN, zh: ZH, ja: JA, ko: KO }; const lookup = (table, key) => key.split(".").reduce((node, part) => node?.[part], table); diff --git a/bin/orcacode-review.mjs b/bin/orcacode-review.mjs index f2761f7..dfd13ae 100755 --- a/bin/orcacode-review.mjs +++ b/bin/orcacode-review.mjs @@ -35,7 +35,7 @@ import { spawnSync } from "node:child_process"; import { SKILL_PLATFORMS, POPULAR_PLATFORM_IDS, findPlatform, detectPlatforms, resolveTargets } from "./platforms.mjs"; import { installTree, STATUS } from "./skill-tree.mjs"; -import { makeT, detectLanguage, parseLanguage } from "./i18n.mjs"; +import { LANGUAGES, makeT, detectLanguage, parseLanguage } from "./i18n.mjs"; import { renderBanner } from "./banner.mjs"; import * as tui from "./prompt.mjs"; @@ -224,14 +224,12 @@ async function multiSelectTyped(question, options, { preselected = [] } = {}) { // language has been chosen. async function askLanguage(argv) { if (argv.lang || ASSUME_YES || !process.stdin.isTTY) return; - const picked = await select( - t("lang.question"), - [ - { label: t("lang.en"), value: "en" }, - { label: t("lang.zh"), value: "zh" }, - ], - { defaultIndex: LANG === "zh" ? 1 : 0 }, - ); + // Built from LANGUAGES so adding a table is the only step — a hardcoded list + // here would silently ship a language nobody can select. + const options = LANGUAGES.map((code) => ({ label: t(`lang.${code}`), value: code })); + const picked = await select(t("lang.question"), options, { + defaultIndex: Math.max(0, LANGUAGES.indexOf(LANG)), + }); setLanguage(picked); } diff --git a/package.json b/package.json index 271a5fd..4b5f379 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@orcarouter/code-review", - "version": "1.3.2", + "version": "1.4.0", "description": "One-command installer for OrcaCode Review — AI pull-request review powered by OrcaRouter.", "bin": { "orcacode-review": "bin/orcacode-review.mjs" diff --git a/scripts/i18n.test.mjs b/scripts/i18n.test.mjs index ec7c905..dd28bfd 100644 --- a/scripts/i18n.test.mjs +++ b/scripts/i18n.test.mjs @@ -26,37 +26,44 @@ const lookup = (table, key) => key.split(".").reduce((n, p) => n?.[p], table); // ------------------------------------------------------------------- table --- -test("Chinese covers every English key", () => { - // A missing key falls back to English, which reads as a half-translated tool. - const missing = flatten(TABLES.en).filter((k) => lookup(TABLES.zh, k) === undefined); - assert.deepEqual(missing, [], `untranslated keys: ${missing.join(", ")}`); -}); - -test("Chinese adds no keys English lacks", () => { - // A zh-only key is dead weight: nothing reads it, and it hides the fact that - // the English side was never written. - const extra = flatten(TABLES.zh).filter((k) => lookup(TABLES.en, k) === undefined); - assert.deepEqual(extra, [], `zh-only keys: ${extra.join(", ")}`); -}); - -test("a key is a function in both languages or neither", () => { - // A parameterized English string paired with a plain Chinese one silently - // drops the argument — the branch name or count just vanishes. - const mismatched = flatten(TABLES.en).filter( - (k) => typeof lookup(TABLES.en, k) !== typeof lookup(TABLES.zh, k), - ); - assert.deepEqual(mismatched, [], `arity mismatch: ${mismatched.join(", ")}`); -}); - -test("parameterized strings take the same argument count in both languages", () => { - for (const key of flatten(TABLES.en)) { - const en = lookup(TABLES.en, key); - if (typeof en !== "function") continue; - assert.equal(lookup(TABLES.zh, key).length, en.length, `${key}: differing arity`); - } -}); +// Every non-English table is checked against English. The earlier version only +// checked Chinese, so adding Japanese and Korean would have been able to ship +// half-translated with a green suite. +const TRANSLATIONS = LANGUAGES.filter((l) => l !== "en"); + +for (const lang of TRANSLATIONS) { + test(`${lang} covers every English key`, () => { + // A missing key falls back to English, which reads as a half-translated tool. + const missing = flatten(TABLES.en).filter((k) => lookup(TABLES[lang], k) === undefined); + assert.deepEqual(missing, [], `untranslated ${lang} keys: ${missing.join(", ")}`); + }); + + test(`${lang} adds no keys English lacks`, () => { + // A language-only key is dead weight, and it hides that the English side + // was never written. + const extra = flatten(TABLES[lang]).filter((k) => lookup(TABLES.en, k) === undefined); + assert.deepEqual(extra, [], `${lang}-only keys: ${extra.join(", ")}`); + }); + + test(`${lang} keys are functions exactly where English keys are`, () => { + // A parameterized English string paired with a plain translation silently + // drops the argument — the branch name or count just vanishes. + const mismatched = flatten(TABLES.en).filter( + (k) => typeof lookup(TABLES.en, k) !== typeof lookup(TABLES[lang], k), + ); + assert.deepEqual(mismatched, [], `${lang} arity mismatch: ${mismatched.join(", ")}`); + }); + + test(`${lang} takes the same argument count as English`, () => { + for (const key of flatten(TABLES.en)) { + const en = lookup(TABLES.en, key); + if (typeof en !== "function") continue; + assert.equal(lookup(TABLES[lang], key).length, en.length, `${lang}.${key}: differing arity`); + } + }); +} -test("every string is non-empty in both languages", () => { +test("every string is non-empty in every language", () => { for (const lang of LANGUAGES) { for (const key of flatten(TABLES[lang])) { const value = lookup(TABLES[lang], key); @@ -68,12 +75,22 @@ test("every string is non-empty in both languages", () => { } }); +test("every language can name every language", () => { + // The language screen is generated from LANGUAGES, so a missing label would + // render an empty row the user cannot identify. + for (const lang of LANGUAGES) { + for (const other of LANGUAGES) { + assert.ok(lookup(TABLES[lang], `lang.${other}`), `${lang} cannot name ${other}`); + } + } +}); + test("commands and flags survive translation", () => { - // A reader of the Chinese output still has to type these. Translating or + // A reader of any translation still has to type these. Translating or // dropping one produces an instruction that cannot be followed. // // The invariant is derived from English rather than hardcoded: a literal the - // English table never mentions proves nothing about the Chinese one. + // English table never mentions proves nothing about the others. const render = (lang) => JSON.stringify( flatten(TABLES[lang]).map((k) => { @@ -82,7 +99,6 @@ test("commands and flags survive translation", () => { }), ); const en = render("en"); - const zh = render("zh"); const candidates = [ "--force", "--platform", "--scope", "--yes", "--help", "--lang", @@ -92,7 +108,13 @@ test("commands and flags survive translation", () => { ]; const present = candidates.filter((literal) => en.includes(literal)); assert.ok(present.length >= 10, "the English table stopped mentioning the literals this test guards"); - for (const literal of present) assert.ok(zh.includes(literal), `zh table lost the literal: ${literal}`); + + for (const lang of TRANSLATIONS) { + const text = render(lang); + for (const literal of present) { + assert.ok(text.includes(literal), `${lang} lost the literal: ${literal}`); + } + } }); // ----------------------------------------------------------------- selection --- @@ -116,13 +138,25 @@ test("parseLanguage accepts zh/en in any casing and rejects the rest", () => { assert.throws(() => parseLanguage("fr"), /unknown language/); }); -test("locale detection maps the Chinese locales and defaults to English", () => { +test("locale detection maps each supported language and defaults to English", () => { assert.equal(detectLanguage({ LANG: "zh_CN.UTF-8" }), "zh"); - assert.equal(detectLanguage({ LC_ALL: "zh_TW.UTF-8" }), "en"); // Traditional is not translated + assert.equal(detectLanguage({ LANG: "ja_JP.UTF-8" }), "ja"); + assert.equal(detectLanguage({ LANG: "ko_KR.UTF-8" }), "ko"); assert.equal(detectLanguage({ LANG: "en_US.UTF-8" }), "en"); assert.equal(detectLanguage({}), "en"); }); +test("Traditional Chinese falls back to English rather than Simplified", () => { + // zh-TW/zh-HK diverge enough in vocabulary that serving Simplified reads + // worse than serving English. Deliberate, so pin it. + assert.equal(detectLanguage({ LC_ALL: "zh_TW.UTF-8" }), "en"); + assert.equal(detectLanguage({ LC_ALL: "zh_HK.UTF-8" }), "en"); +}); + +test("--lang accepts every language the picker offers", () => { + for (const lang of LANGUAGES) assert.equal(parseLanguage(lang), lang); +}); + test("LC_ALL outranks LANG, and ORCACODE_LANG outranks both", () => { assert.equal(detectLanguage({ LC_ALL: "en_US.UTF-8", LANG: "zh_CN.UTF-8" }), "en"); assert.equal(detectLanguage({ ORCACODE_LANG: "zh", LANG: "en_US.UTF-8" }), "zh");