From 8b8354f4604a905d123e10baeca4fb57d807824c Mon Sep 17 00:00:00 2001 From: nonz250 Date: Fri, 31 Jul 2026 22:17:54 +0900 Subject: [PATCH 1/2] fix(worker): resolve prompt templates by the configured language User prompt templates were stored in a flat ~/.sabori-flow/prompts/ directory regardless of config.yml's language setting, so the language selected at `init` time stuck permanently. Split the user directory into per-language subdirectories and migrate existing flat installations (without discarding user customizations) on worker startup and on `init`. Co-Authored-By: Claude --- CLAUDE.md | 7 ++--- src/commands/init.ts | 8 ++++-- src/utils/paths.ts | 9 ++++++- src/worker/main.ts | 6 +++++ src/worker/prompt-migration.ts | 48 ++++++++++++++++++++++++++++++++++ src/worker/prompt.ts | 10 +++---- 6 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 src/worker/prompt-migration.ts diff --git a/CLAUDE.md b/CLAUDE.md index 97cdba0..2fa4554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ src/ fetcher.ts # gh api で Issue 取得・優先度ソート pipeline.ts # 1 Issue の処理パイプライン + resumeSpecReview(DI パターン) prompt.ts # プロンプトテンプレート読み込み・展開 + prompt-migration.ts # フラットレイアウト→言語別ディレクトリへの移行 executor.ts # Claude CLI 実行 worktree.ts # git worktree ライフサイクル管理 label.ts # ラベル遷移操作(applyLabelTransition) @@ -143,7 +144,7 @@ spec の review 状態では、ワーカーが毎サイクル評価を行う。 |---|---| | 設定ファイル | `~/.sabori-flow/config.yml` | | 認証トークン | `~/.sabori-flow/auth-token` | -| プロンプトテンプレート | `~/.sabori-flow/prompts/` | +| プロンプトテンプレート | `~/.sabori-flow/prompts/{lang}/` | | ログ | `~/.sabori-flow/logs/` | | git worktree | `~/.sabori-flow/worktrees///issue-<番号>-<タイムスタンプ>/` | | plist バックアップ | `~/.sabori-flow/com.github.sabori-flow.plist` | @@ -152,11 +153,11 @@ spec の review 状態では、ワーカーが毎サイクル評価を行う。 ### プロンプトテンプレート -パッケージ同梱テンプレートは `prompts/{lang}/` に言語別で配置される。`init` コマンド実行時に選択言語のテンプレートが `~/.sabori-flow/prompts/` にコピーされ、ユーザーが自由にカスタマイズ可能。 +パッケージ同梱テンプレートは `prompts/{lang}/` に言語別で配置される。`init` コマンド実行時に選択言語のテンプレートが `~/.sabori-flow/prompts/{lang}/` にコピーされ、ユーザーが自由にカスタマイズ可能。ワーカー起動時およびinit実行時に、旧フラットレイアウト(`~/.sabori-flow/prompts/` 直下)のテンプレートを言語別ディレクトリへ自動移行する。 テンプレート読み込みの優先順位(2 層フォールバック): -1. `~/.sabori-flow/prompts/`(ユーザー共通、init 時にコピー) +1. `~/.sabori-flow/prompts/{lang}/`(ユーザーカスタム、init 時にコピー) 2. パッケージ同梱 `prompts/{lang}/`(フォールバック) diff --git a/src/commands/init.ts b/src/commands/init.ts index 0b54a5c..c5588d1 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -2,7 +2,7 @@ import { input, select, confirm } from "@inquirer/prompts"; import { stringify } from "yaml"; import fs from "fs"; import { join, resolve } from "node:path"; -import { getBaseDir, getConfigPath, getUserPromptsDir, getDefaultPromptsDir } from "../utils/paths.js"; +import { getBaseDir, getConfigPath, getUserPromptsLanguageDir, getDefaultPromptsDir } from "../utils/paths.js"; import { getDefaultPriorityLabels, getDefaultExecution, @@ -16,6 +16,7 @@ import { setLanguage, t } from "../i18n/index.js"; import type { Language } from "../i18n/types.js"; import { Autonomy } from "../worker/models.js"; import { TEMPLATE_FILES } from "../worker/prompt.js"; +import { migrateFlatPromptTemplates } from "../worker/prompt-migration.js"; function buildConfigData( repos: RepositoryInput[], @@ -43,8 +44,11 @@ function buildConfigData( } async function copyPromptTemplates(language: Language): Promise { + // Migrate before copy so overwrite prompts target user-customized files, not stale flat ones. + migrateFlatPromptTemplates(language); + const srcDir = join(getDefaultPromptsDir(), language); - const destDir = getUserPromptsDir(); + const destDir = getUserPromptsLanguageDir(language); fs.mkdirSync(destDir, { recursive: true, mode: 0o700 }); for (const filename of Object.values(TEMPLATE_FILES)) { diff --git a/src/utils/paths.ts b/src/utils/paths.ts index fe7ac7f..3fb824c 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -2,6 +2,8 @@ import { fileURLToPath } from "node:url"; import path, { dirname, resolve } from "node:path"; import { homedir } from "node:os"; +import type { Language } from "../i18n/types.js"; + // ESM で __dirname を代替 // src/utils/ → ../../ = package root // ビルド後: dist/utils/ → ../../ = package root @@ -70,11 +72,16 @@ export function getDefaultPromptsDir(): string { return DEFAULT_PROMPTS_DIR; } -/** ~/.sabori-flow/prompts — ユーザーカスタムプロンプトディレクトリ */ +/** ~/.sabori-flow/prompts — 言語別ディレクトリの親 */ export function getUserPromptsDir(): string { return path.join(getBaseDir(), "prompts"); } +/** ~/.sabori-flow/prompts/{language} — 言語別ユーザーカスタムプロンプトディレクトリ */ +export function getUserPromptsLanguageDir(language: Language): string { + return path.join(getUserPromptsDir(), language); +} + /** ~/.sabori-flow/logs */ export function getLogsDir(): string { return path.join(getBaseDir(), "logs"); diff --git a/src/worker/main.ts b/src/worker/main.ts index f8bc881..62ab1ae 100644 --- a/src/worker/main.ts +++ b/src/worker/main.ts @@ -5,6 +5,7 @@ import { loadConfig } from "./config.js"; import { fetchIssues } from "./fetcher.js"; import { processIssue, resumeSpecReview } from "./pipeline.js"; import { resolveAutonomyLogMessage } from "./executor.js"; +import { migrateFlatPromptTemplates } from "./prompt-migration.js"; import { configureLogger, createLogger, rotateOldLogs } from "./logger.js"; import { getConfigPath, getLogsDir } from "../utils/paths.js"; import { readAuthToken } from "../utils/auth-token.js"; @@ -13,6 +14,7 @@ import { ProcessTimeoutError, ProcessExecutionError, } from "./process.js"; +import type { Language } from "../i18n/types.js"; const logger = createLogger("main"); @@ -46,6 +48,7 @@ export interface WorkerDeps { labels: readonly string[], ) => Promise; readAuthToken: () => string | null; + migrateFlatPromptTemplates: (language: Language) => void; } export const defaultWorkerDeps: WorkerDeps = { @@ -55,6 +58,7 @@ export const defaultWorkerDeps: WorkerDeps = { resumeSpecReview, ensureLabelsExist, readAuthToken, + migrateFlatPromptTemplates, }; // ---------- Step definitions ---------- @@ -312,6 +316,8 @@ export async function workerMain( appConfig.repositories.length, ); + deps.migrateFlatPromptTemplates(appConfig.language); + const autonomyLog = resolveAutonomyLogMessage(appConfig.execution.autonomy); if (autonomyLog !== null) { logger[autonomyLog.level](autonomyLog.message); diff --git a/src/worker/prompt-migration.ts b/src/worker/prompt-migration.ts new file mode 100644 index 0000000..b79e008 --- /dev/null +++ b/src/worker/prompt-migration.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdirSync, renameSync } from "node:fs"; +import { join } from "node:path"; + +import type { Language } from "../i18n/types.js"; +import { getUserPromptsDir, getUserPromptsLanguageDir } from "../utils/paths.js"; +import { TEMPLATE_FILES } from "./prompt.js"; +import { createLogger } from "./logger.js"; + +const logger = createLogger("prompt-migration"); + +/** + * Migrate flat prompt templates into a language-specific subdirectory. + * + * The actual language of flat-side templates is unknown; we use the + * config.yml `language` value as the target directory label. + */ +export function migrateFlatPromptTemplates(language: Language): void { + const flatDir = getUserPromptsDir(); + const langDir = getUserPromptsLanguageDir(language); + + for (const filename of Object.values(TEMPLATE_FILES)) { + const src = join(flatDir, filename); + if (!existsSync(src)) { + continue; + } + + const dest = join(langDir, filename); + if (existsSync(dest)) { + // Preserve user customizations; let the user merge manually. + logger.warn( + "Skipping migration: both %s and %s exist", + src, + dest, + ); + continue; + } + + // Per-file try/catch so one failure does not block others; + // unmigrated files will be retried on the next startup. + try { + mkdirSync(langDir, { recursive: true, mode: 0o700 }); + renameSync(src, dest); + logger.info("Migrated %s -> %s", src, dest); + } catch (error: unknown) { + logger.warn("Failed to migrate %s: %s", filename, error); + } + } +} diff --git a/src/worker/prompt.ts b/src/worker/prompt.ts index 8f9305f..bd1439c 100644 --- a/src/worker/prompt.ts +++ b/src/worker/prompt.ts @@ -5,7 +5,7 @@ import { basename, join, resolve } from "node:path"; import type { Language } from "../i18n/types.js"; import type { Issue, RepositoryConfig } from "./models.js"; import { Phase, repoFullName } from "./models.js"; -import { getUserPromptsDir, getDefaultPromptsDir } from "../utils/paths.js"; +import { getUserPromptsLanguageDir, getDefaultPromptsDir } from "../utils/paths.js"; import { createLogger } from "./logger.js"; /** テンプレート関連のエラー */ @@ -42,7 +42,7 @@ const USER_INPUT_KEYS: ReadonlySet = new Set([ * * テンプレートファイルを読み込み、プレースホルダを展開して返す。 * 2 段階のフォールバックでテンプレートを解決する: - * 1. ユーザーディレクトリ (`~/.sabori-flow/prompts/`) + * 1. ユーザーディレクトリ (`~/.sabori-flow/prompts//`) * 2. パッケージ同梱のデフォルトテンプレート (`prompts//`) * * @throws {PromptTemplateError} テンプレートの読み込みまたは展開に失敗した場合 @@ -53,7 +53,7 @@ export function buildPrompt( language: Language, specContext: string | null = null, ): string { - const userDir = getUserPromptsDir(); + const userDir = getUserPromptsLanguageDir(language); const defaultDir = join(getDefaultPromptsDir(), language); const template = loadTemplate(issue.phase, userDir, defaultDir); const variables = buildVariables(issue, repoConfig, specContext); @@ -71,7 +71,7 @@ export function buildPrompt( /** * 2 段階フォールバックでテンプレートファイルを読み込む。 * - * 1. ユーザーディレクトリ (`~/.sabori-flow/prompts/`) + * 1. ユーザーディレクトリ (`~/.sabori-flow/prompts//`) * 2. パッケージ同梱のデフォルトディレクトリ (`prompts//`) * * @throws {PromptTemplateError} フェーズが未定義、テンプレートが存在しない、 @@ -87,7 +87,7 @@ function loadTemplate( throw new PromptTemplateError(`Unknown phase: ${phase}`); } - // 1. ユーザーディレクトリ (~/.sabori-flow/prompts/) + // 1. ユーザーディレクトリ (~/.sabori-flow/prompts//) const userPath = resolve(userDir, filename); if (existsSync(userPath)) { logger.info("Loaded template from user directory: %s", userDir); From ed6232ba147c22a4e54293be118c42d7548eba4e Mon Sep 17 00:00:00 2001 From: nonz250 Date: Fri, 31 Jul 2026 22:18:00 +0900 Subject: [PATCH 2/2] test(worker): cover language-aware prompt template migration Co-Authored-By: Claude --- tests/commands/init.test.ts | 42 ++++++- tests/utils/paths.test.ts | 16 +++ tests/worker/helpers/mock-deps.ts | 1 + tests/worker/main.test.ts | 25 +++++ tests/worker/prompt-migration.test.ts | 156 ++++++++++++++++++++++++++ tests/worker/prompt.test.ts | 78 +++++++++++-- 6 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 tests/worker/prompt-migration.test.ts diff --git a/tests/commands/init.test.ts b/tests/commands/init.test.ts index 9053996..9301fe7 100644 --- a/tests/commands/init.test.ts +++ b/tests/commands/init.test.ts @@ -35,6 +35,9 @@ vi.mock("../../src/worker/prompt.js", () => ({ }, })); +vi.mock("../../src/worker/prompt-migration.js", () => ({ + migrateFlatPromptTemplates: vi.fn(), +})); vi.mock("../../src/utils/paths.js", async (importOriginal) => { const original = await importOriginal(); @@ -43,6 +46,7 @@ vi.mock("../../src/utils/paths.js", async (importOriginal) => { getBaseDir: vi.fn().mockReturnValue("/mock/config/dir"), getConfigPath: vi.fn().mockReturnValue("/mock/config/dir/config.yml"), getUserPromptsDir: vi.fn().mockReturnValue("/mock/config/dir/prompts"), + getUserPromptsLanguageDir: vi.fn().mockReturnValue("/mock/config/dir/prompts/ja"), getDefaultPromptsDir: vi.fn().mockReturnValue("/mock/package/prompts"), }; }); @@ -52,7 +56,8 @@ import { confirm, select, input } from "@inquirer/prompts"; import { promptRepository } from "../../src/commands/helpers/repository-prompt.js"; import type { RepositoryInput } from "../../src/commands/helpers/repository-prompt.js"; import { setTokenCommand } from "../../src/commands/set-token.js"; -import { getBaseDir, getConfigPath, getUserPromptsDir, getDefaultPromptsDir } from "../../src/utils/paths.js"; +import { getBaseDir, getConfigPath, getUserPromptsDir, getUserPromptsLanguageDir, getDefaultPromptsDir } from "../../src/utils/paths.js"; +import { migrateFlatPromptTemplates } from "../../src/worker/prompt-migration.js"; import { Autonomy } from "../../src/worker/models.js"; import type { Language } from "../../src/i18n/types.js"; @@ -65,7 +70,9 @@ const mockedSetTokenCommand = vi.mocked(setTokenCommand); const mockedGetBaseDir = vi.mocked(getBaseDir); const mockedGetConfigPath = vi.mocked(getConfigPath); const mockedGetUserPromptsDir = vi.mocked(getUserPromptsDir); +const mockedGetUserPromptsLanguageDir = vi.mocked(getUserPromptsLanguageDir); const mockedGetDefaultPromptsDir = vi.mocked(getDefaultPromptsDir); +const mockedMigrateFlatPromptTemplates = vi.mocked(migrateFlatPromptTemplates); // ---------- Helpers ---------- @@ -147,7 +154,9 @@ beforeEach(() => { mockedGetBaseDir.mockReturnValue("/mock/config/dir"); mockedGetConfigPath.mockReturnValue("/mock/config/dir/config.yml"); mockedGetUserPromptsDir.mockReturnValue("/mock/config/dir/prompts"); + mockedGetUserPromptsLanguageDir.mockReturnValue("/mock/config/dir/prompts/ja"); mockedGetDefaultPromptsDir.mockReturnValue("/mock/package/prompts"); + mockedMigrateFlatPromptTemplates.mockReset(); // 言語 / autonomy / interval_minutes / timeout_minutes のデフォルト応答 setupInitPrompts(); @@ -468,9 +477,9 @@ describe("initCommand - テンプレートコピー", () => { await runInitCommand(); - // prompts ディレクトリが作成される + // prompts 言語ディレクトリが作成される expect(mockedFs.mkdirSync).toHaveBeenCalledWith( - "/mock/config/dir/prompts", + "/mock/config/dir/prompts/ja", { recursive: true, mode: 0o700 }, ); // テンプレートファイルがコピーされる(spec.md, plan.md, impl.md) @@ -606,8 +615,33 @@ describe("initCommand - テンプレートコピー", () => { await runInitCommand(); expect(mockedFs.mkdirSync).toHaveBeenCalledWith( - "/mock/config/dir/prompts", + "/mock/config/dir/prompts/ja", { recursive: true, mode: 0o700 }, ); }); + + it("migrateFlatPromptTemplates is called with the selected language", async () => { + mockExistsSyncForConfig(false); + mockedPromptRepository.mockResolvedValueOnce(makeRepoInput()); + mockedConfirm.mockResolvedValueOnce(false); + + await runInitCommand(); + + expect(mockedMigrateFlatPromptTemplates).toHaveBeenCalledOnce(); + expect(mockedMigrateFlatPromptTemplates).toHaveBeenCalledWith("ja"); + }); + + it("migrateFlatPromptTemplates is called before copyFileSync", async () => { + mockExistsSyncForConfig(false); + mockedPromptRepository.mockResolvedValueOnce(makeRepoInput()); + mockedConfirm.mockResolvedValueOnce(false); + + await runInitCommand(); + + const migrationOrder = mockedMigrateFlatPromptTemplates.mock.invocationCallOrder[0]; + const copyOrders = mockedFs.copyFileSync.mock.invocationCallOrder; + for (const copyOrder of copyOrders) { + expect(migrationOrder).toBeLessThan(copyOrder); + } + }); }); diff --git a/tests/utils/paths.test.ts b/tests/utils/paths.test.ts index f45b7b2..8dcc6f5 100644 --- a/tests/utils/paths.test.ts +++ b/tests/utils/paths.test.ts @@ -40,6 +40,22 @@ describe("getUserPromptsDir", () => { }); }); +describe("getUserPromptsLanguageDir", () => { + it("~/.sabori-flow/prompts/ja を返す (language=ja)", async () => { + const { getUserPromptsLanguageDir } = await import("../../src/utils/paths.js"); + expect(getUserPromptsLanguageDir("ja")).toBe( + path.join("/mock/home", ".sabori-flow", "prompts", "ja"), + ); + }); + + it("~/.sabori-flow/prompts/en を返す (language=en)", async () => { + const { getUserPromptsLanguageDir } = await import("../../src/utils/paths.js"); + expect(getUserPromptsLanguageDir("en")).toBe( + path.join("/mock/home", ".sabori-flow", "prompts", "en"), + ); + }); +}); + describe("getLogsDir", () => { it("~/.sabori-flow/logs を返す", async () => { const { getLogsDir } = await import("../../src/utils/paths.js"); diff --git a/tests/worker/helpers/mock-deps.ts b/tests/worker/helpers/mock-deps.ts index 97b672a..6416d52 100644 --- a/tests/worker/helpers/mock-deps.ts +++ b/tests/worker/helpers/mock-deps.ts @@ -50,6 +50,7 @@ export function createMockWorkerDeps( resumeSpecReview: vi.fn().mockResolvedValue({ outcome: "deferred", claudeExecuted: false }), ensureLabelsExist: vi.fn().mockResolvedValue(undefined), readAuthToken: vi.fn().mockReturnValue(null), + migrateFlatPromptTemplates: vi.fn(), ...overrides, }; } diff --git a/tests/worker/main.test.ts b/tests/worker/main.test.ts index e04a681..a4bcbea 100644 --- a/tests/worker/main.test.ts +++ b/tests/worker/main.test.ts @@ -582,6 +582,31 @@ describe("workerMain", () => { }); }); + // ----------------------------------------------------------------------- + // migrateFlatPromptTemplates + // ----------------------------------------------------------------------- + + describe("migrateFlatPromptTemplates", () => { + it("workerMain calls migrateFlatPromptTemplates with appConfig.language", async () => { + vi.mocked(deps.loadConfig).mockReturnValue(makeAppConfig({ language: "ja" })); + vi.mocked(deps.fetchIssues).mockResolvedValue([]); + + await workerMain("/path/to/config.yml", deps); + + expect(deps.migrateFlatPromptTemplates).toHaveBeenCalledOnce(); + expect(deps.migrateFlatPromptTemplates).toHaveBeenCalledWith("ja"); + }); + + it("workerMain calls migrateFlatPromptTemplates with 'en' when language is en", async () => { + vi.mocked(deps.loadConfig).mockReturnValue(makeAppConfig({ language: "en" })); + vi.mocked(deps.fetchIssues).mockResolvedValue([]); + + await workerMain("/path/to/config.yml", deps); + + expect(deps.migrateFlatPromptTemplates).toHaveBeenCalledWith("en"); + }); + }); + // ----------------------------------------------------------------------- // autonomy startup ログ // ----------------------------------------------------------------------- diff --git a/tests/worker/prompt-migration.test.ts b/tests/worker/prompt-migration.test.ts new file mode 100644 index 0000000..1c81c11 --- /dev/null +++ b/tests/worker/prompt-migration.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockLoggerInstance } = vi.hoisted(() => ({ + mockLoggerInstance: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("node:fs"); + +vi.mock("../../src/worker/logger.js", () => ({ + createLogger: vi.fn(() => mockLoggerInstance), +})); + +vi.mock("../../src/utils/paths.js", () => ({ + getUserPromptsDir: vi.fn(() => "/mock/prompts"), + getUserPromptsLanguageDir: vi.fn(() => "/mock/prompts/ja"), +})); + +import { existsSync, mkdirSync, renameSync } from "node:fs"; +import { getUserPromptsDir, getUserPromptsLanguageDir } from "../../src/utils/paths.js"; +import { migrateFlatPromptTemplates } from "../../src/worker/prompt-migration.js"; + +const mockedExistsSync = vi.mocked(existsSync); +const mockedMkdirSync = vi.mocked(mkdirSync); +const mockedRenameSync = vi.mocked(renameSync); +const mockedGetUserPromptsDir = vi.mocked(getUserPromptsDir); +const mockedGetUserPromptsLanguageDir = vi.mocked(getUserPromptsLanguageDir); + +beforeEach(() => { + vi.restoreAllMocks(); + mockedGetUserPromptsDir.mockReturnValue("/mock/prompts"); + mockedGetUserPromptsLanguageDir.mockReturnValue("/mock/prompts/ja"); +}); + +describe("migrateFlatPromptTemplates", () => { + it("renames both plan.md and impl.md into the language directory", () => { + mockedExistsSync.mockImplementation((p) => { + const path = String(p); + if (path === "/mock/prompts/plan.md") return true; + if (path === "/mock/prompts/impl.md") return true; + // dest files do not exist + return false; + }); + + migrateFlatPromptTemplates("ja"); + + expect(mockedRenameSync).toHaveBeenCalledTimes(2); + expect(mockedRenameSync).toHaveBeenCalledWith( + "/mock/prompts/plan.md", + "/mock/prompts/ja/plan.md", + ); + expect(mockedRenameSync).toHaveBeenCalledWith( + "/mock/prompts/impl.md", + "/mock/prompts/ja/impl.md", + ); + }); + + it("calls mkdirSync with recursive and mode 0o700 before renaming", () => { + mockedExistsSync.mockImplementation((p) => { + const path = String(p); + if (path === "/mock/prompts/plan.md") return true; + return false; + }); + + migrateFlatPromptTemplates("ja"); + + expect(mockedMkdirSync).toHaveBeenCalledWith("/mock/prompts/ja", { + recursive: true, + mode: 0o700, + }); + const mkdirOrder = mockedMkdirSync.mock.invocationCallOrder[0]; + const renameOrder = mockedRenameSync.mock.invocationCallOrder[0]; + expect(mkdirOrder).toBeLessThan(renameOrder); + }); + + it("skips rename when dest file already exists and logs a warning", () => { + mockedExistsSync.mockImplementation((p) => { + const path = String(p); + if (path === "/mock/prompts/plan.md") return true; + if (path === "/mock/prompts/ja/plan.md") return true; + return false; + }); + + migrateFlatPromptTemplates("ja"); + + expect(mockedRenameSync).not.toHaveBeenCalled(); + expect(mockedMkdirSync).not.toHaveBeenCalled(); + expect(mockLoggerInstance.warn).toHaveBeenCalledWith( + "Skipping migration: both %s and %s exist", + "/mock/prompts/plan.md", + "/mock/prompts/ja/plan.md", + ); + }); + + it("continues to impl.md when plan.md rename throws and does not propagate the error", () => { + mockedExistsSync.mockImplementation((p) => { + const path = String(p); + if (path === "/mock/prompts/plan.md") return true; + if (path === "/mock/prompts/impl.md") return true; + return false; + }); + mockedRenameSync.mockImplementation((src) => { + if (String(src) === "/mock/prompts/plan.md") { + throw new Error("EPERM"); + } + }); + + expect(() => migrateFlatPromptTemplates("ja")).not.toThrow(); + + expect(mockedRenameSync).toHaveBeenCalledTimes(2); + expect(mockLoggerInstance.warn).toHaveBeenCalledWith( + "Failed to migrate %s: %s", + "plan.md", + expect.any(Error), + ); + expect(mockLoggerInstance.info).toHaveBeenCalledWith( + "Migrated %s -> %s", + "/mock/prompts/impl.md", + "/mock/prompts/ja/impl.md", + ); + }); + + it("does not call mkdirSync or renameSync when no flat files exist", () => { + mockedExistsSync.mockReturnValue(false); + + migrateFlatPromptTemplates("ja"); + + expect(mockedMkdirSync).not.toHaveBeenCalled(); + expect(mockedRenameSync).not.toHaveBeenCalled(); + }); + + it("uses the 'en' language directory when language is 'en'", () => { + mockedGetUserPromptsLanguageDir.mockReturnValue("/mock/prompts/en"); + mockedExistsSync.mockImplementation((p) => { + const path = String(p); + if (path === "/mock/prompts/plan.md") return true; + return false; + }); + + migrateFlatPromptTemplates("en"); + + expect(mockedGetUserPromptsLanguageDir).toHaveBeenCalledWith("en"); + expect(mockedMkdirSync).toHaveBeenCalledWith("/mock/prompts/en", { + recursive: true, + mode: 0o700, + }); + expect(mockedRenameSync).toHaveBeenCalledWith( + "/mock/prompts/plan.md", + "/mock/prompts/en/plan.md", + ); + }); +}); diff --git a/tests/worker/prompt.test.ts b/tests/worker/prompt.test.ts index a49339f..2b2acbd 100644 --- a/tests/worker/prompt.test.ts +++ b/tests/worker/prompt.test.ts @@ -17,6 +17,7 @@ vi.mock("../../src/worker/logger.js", () => ({ // paths mock vi.mock("../../src/utils/paths.js", () => ({ getUserPromptsDir: vi.fn(() => "/mock/user/prompts"), + getUserPromptsLanguageDir: vi.fn(() => "/mock/user/prompts/ja"), getDefaultPromptsDir: vi.fn(() => "/mock/default/prompts"), })); @@ -27,12 +28,13 @@ import { } from "../../src/worker/prompt.js"; import { Phase, Priority } from "../../src/worker/models.js"; import type { Issue, RepositoryConfig } from "../../src/worker/models.js"; -import { getUserPromptsDir, getDefaultPromptsDir } from "../../src/utils/paths.js"; +import { getUserPromptsDir, getUserPromptsLanguageDir, getDefaultPromptsDir } from "../../src/utils/paths.js"; const mockedReadFileSync = vi.mocked(readFileSync); const mockedExistsSync = vi.mocked(existsSync); const mockedStatSync = vi.mocked(statSync); const mockedGetUserPromptsDir = vi.mocked(getUserPromptsDir); +const mockedGetUserPromptsLanguageDir = vi.mocked(getUserPromptsLanguageDir); const mockedGetDefaultPromptsDir = vi.mocked(getDefaultPromptsDir); // --------------------------------------------------------------------------- @@ -132,7 +134,7 @@ const BOUNDARY_CLOSE_PATTERN = new RegExp( ``, ); -const USER_DIR = "/mock/user/prompts"; +const USER_DIR = "/mock/user/prompts/ja"; const DEFAULT_DIR = "/mock/default/prompts"; // --------------------------------------------------------------------------- @@ -155,7 +157,7 @@ function setupDefaultDirMocks(): void { describe("buildPrompt - normal cases", () => { beforeEach(() => { vi.restoreAllMocks(); - mockedGetUserPromptsDir.mockReturnValue(USER_DIR); + mockedGetUserPromptsLanguageDir.mockReturnValue(USER_DIR); mockedGetDefaultPromptsDir.mockReturnValue(DEFAULT_DIR); }); @@ -269,7 +271,7 @@ describe("buildPrompt - normal cases", () => { describe("buildPrompt - error cases", () => { beforeEach(() => { vi.restoreAllMocks(); - mockedGetUserPromptsDir.mockReturnValue(USER_DIR); + mockedGetUserPromptsLanguageDir.mockReturnValue(USER_DIR); mockedGetDefaultPromptsDir.mockReturnValue(DEFAULT_DIR); }); @@ -321,7 +323,7 @@ describe("buildPrompt - error cases", () => { describe("buildPrompt - random boundary", () => { beforeEach(() => { vi.restoreAllMocks(); - mockedGetUserPromptsDir.mockReturnValue(USER_DIR); + mockedGetUserPromptsLanguageDir.mockReturnValue(USER_DIR); mockedGetDefaultPromptsDir.mockReturnValue(DEFAULT_DIR); }); @@ -437,8 +439,8 @@ describe("buildPrompt - integration", () => { mockedReadFileSync.mockImplementation(actualFs.readFileSync as typeof readFileSync); mockedExistsSync.mockImplementation(actualFs.existsSync as typeof existsSync); mockedStatSync.mockImplementation(actualFs.statSync as typeof statSync); - // getUserPromptsDir returns a non-existent path so it falls through to package default - mockedGetUserPromptsDir.mockReturnValue("/nonexistent/user/prompts"); + // getUserPromptsLanguageDir returns a non-existent path so it falls through to package default + mockedGetUserPromptsLanguageDir.mockReturnValue("/nonexistent/user/prompts"); // getDefaultPromptsDir returns the real package prompts dir const actualPaths = await vi.importActual("../../src/utils/paths.js"); mockedGetDefaultPromptsDir.mockReturnValue(actualPaths.getDefaultPromptsDir()); @@ -645,7 +647,7 @@ describe("buildPrompt - user prompt directory", () => { beforeEach(() => { vi.restoreAllMocks(); - mockedGetUserPromptsDir.mockReturnValue(USER_DIR); + mockedGetUserPromptsLanguageDir.mockReturnValue(USER_DIR); mockedGetDefaultPromptsDir.mockReturnValue(DEFAULT_DIR); }); @@ -688,7 +690,7 @@ describe("buildPrompt - 2-tier priority", () => { beforeEach(() => { vi.restoreAllMocks(); - mockedGetUserPromptsDir.mockReturnValue(USER_DIR); + mockedGetUserPromptsLanguageDir.mockReturnValue(USER_DIR); mockedGetDefaultPromptsDir.mockReturnValue(DEFAULT_DIR); mockedStatSync.mockReturnValue({ size: 1024, isFile: () => true } as unknown as ReturnType); }); @@ -731,7 +733,7 @@ describe("buildPrompt - 2-tier priority", () => { describe("buildPrompt - spec phase", () => { beforeEach(() => { vi.restoreAllMocks(); - mockedGetUserPromptsDir.mockReturnValue(USER_DIR); + mockedGetUserPromptsLanguageDir.mockReturnValue(USER_DIR); mockedGetDefaultPromptsDir.mockReturnValue(DEFAULT_DIR); }); @@ -860,3 +862,59 @@ describe("buildPrompt - spec phase", () => { expect(feedbackIdx).toBeLessThan(closeIdx); }); }); + +// --------------------------------------------------------------------------- +// getUserPromptsLanguageDir dispatch tests +// --------------------------------------------------------------------------- + +describe("buildPrompt - language directory dispatch", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockedGetUserPromptsLanguageDir.mockReturnValue(USER_DIR); + mockedGetDefaultPromptsDir.mockReturnValue(DEFAULT_DIR); + }); + + it("getUserPromptsLanguageDir is called with the given language", () => { + setupDefaultDirMocks(); + mockedReadFileSync.mockReturnValue(MINIMAL_PLAN_TEMPLATE); + + buildPrompt(makeIssue(), makeRepoConfig(), "ja"); + + expect(mockedGetUserPromptsLanguageDir).toHaveBeenCalledWith("ja"); + }); + + it("getUserPromptsLanguageDir is called with 'en' when language=en", () => { + mockedGetUserPromptsLanguageDir.mockReturnValue("/mock/user/prompts/en"); + setupDefaultDirMocks(); + mockedReadFileSync.mockReturnValue(MINIMAL_PLAN_TEMPLATE); + + buildPrompt(makeIssue(), makeRepoConfig(), "en"); + + expect(mockedGetUserPromptsLanguageDir).toHaveBeenCalledWith("en"); + }); + + it("User language directory template takes priority over package default", () => { + const USER_LANG_TEMPLATE = "Lang: {issue_title}"; + mockedExistsSync.mockReturnValue(true); + mockedStatSync.mockReturnValue({ size: 1024, isFile: () => true } as unknown as ReturnType); + mockedReadFileSync.mockReturnValue(USER_LANG_TEMPLATE); + + const result = buildPrompt(makeIssue(), makeRepoConfig(), "ja"); + + expect(result).toContain("Lang: Test Issue Title"); + }); + + it("Falls back to package default when language directory has no template", () => { + mockedExistsSync.mockImplementation((p) => { + const path = String(p); + if (path.startsWith(USER_DIR)) return false; + return true; + }); + mockedStatSync.mockReturnValue({ size: 1024, isFile: () => true } as unknown as ReturnType); + mockedReadFileSync.mockReturnValue(MINIMAL_PLAN_TEMPLATE); + + const result = buildPrompt(makeIssue(), makeRepoConfig(), "ja"); + + expect(result).toContain("Repo: testowner/testrepo"); + }); +});