takt/src/__tests__/projectConfig.test.ts
nrslib a8223d231d refactor: config 3層モデル整理 + supervisor ペルソナのファセット分離是正
Config:
- PersistedGlobalConfig → GlobalConfig にリネーム、互換エイリアス削除
- persisted-global-config.ts → config-types.ts にリネーム
- ProjectConfig → GlobalConfig extends Omit<ProjectConfig, ...> の継承構造に整理
- verbose/logLevel/log_level を削除(logging セクションに統一)
- migration 機構(migratedProjectLocalKeys 等)を削除

Supervisor ペルソナ:
- 後方互換コードの検出・その場しのぎの検出・ボーイスカウトルールを除去(review.md ポリシー / architecture.md ナレッジと重複)
- ピース全体の見直しを supervise.md インストラクションに移動

takt-default-team-leader:
- loop_monitor のインライン instruction_template を既存ファイル参照に変更
- implement の「判断できない」ルールを ai_review → plan に修正
2026-03-06 01:29:46 +09:00

507 lines
17 KiB
TypeScript

/**
* Project config tests.
*
* Tests project config loading and saving with piece_overrides,
* including empty array round-trip behavior.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadProjectConfig, saveProjectConfig } from '../infra/config/project/projectConfig.js';
import type { ProjectLocalConfig } from '../infra/config/types.js';
describe('projectConfig', () => {
let testDir: string;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'takt-test-project-config-'));
mkdirSync(join(testDir, '.takt'), { recursive: true });
});
afterEach(() => {
if (testDir) {
rmSync(testDir, { recursive: true, force: true });
}
});
describe('piece_overrides empty array round-trip', () => {
it('should preserve empty quality_gates array in save/load cycle', () => {
// Write config with empty quality_gates array
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = `
piece_overrides:
quality_gates: []
`;
writeFileSync(configPath, configContent, 'utf-8');
// Load config
const loaded = loadProjectConfig(testDir);
expect(loaded.pieceOverrides?.qualityGates).toEqual([]);
// Save config
saveProjectConfig(testDir, loaded);
// Reload and verify empty array is preserved
const reloaded = loadProjectConfig(testDir);
expect(reloaded.pieceOverrides?.qualityGates).toEqual([]);
});
it('should preserve empty quality_gates in movements', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = `
piece_overrides:
movements:
implement:
quality_gates: []
`;
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]);
saveProjectConfig(testDir, loaded);
const reloaded = loadProjectConfig(testDir);
expect(reloaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]);
});
it('should distinguish undefined from empty array', () => {
// Test with undefined (not specified)
const configPath1 = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath1, 'piece_overrides: {}\n', 'utf-8');
const loaded1 = loadProjectConfig(testDir);
expect(loaded1.pieceOverrides?.qualityGates).toBeUndefined();
// Test with empty array (explicitly disabled)
const configPath2 = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath2, 'piece_overrides:\n quality_gates: []\n', 'utf-8');
const loaded2 = loadProjectConfig(testDir);
expect(loaded2.pieceOverrides?.qualityGates).toEqual([]);
});
it('should preserve non-empty quality_gates array', () => {
const config: ProjectLocalConfig = {
pieceOverrides: {
qualityGates: ['Test 1', 'Test 2'],
},
};
saveProjectConfig(testDir, config);
const reloaded = loadProjectConfig(testDir);
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
});
it('should preserve personas quality_gates in save/load cycle', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = `
piece_overrides:
personas:
coder:
quality_gates:
- "Project persona gate"
`;
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir);
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Project persona gate']);
saveProjectConfig(testDir, loaded);
const reloaded = loadProjectConfig(testDir);
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Project persona gate']);
});
it('should preserve empty quality_gates array in personas', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = `
piece_overrides:
personas:
coder:
quality_gates: []
`;
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir);
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
saveProjectConfig(testDir, loaded);
const reloaded = loadProjectConfig(testDir);
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
});
});
describe('migrated project-local fields', () => {
it('should load project-local fields from project config yaml', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = [
'pipeline:',
' default_branch_prefix: "proj/"',
' commit_message_template: "feat: {title} (#{issue})"',
'persona_providers:',
' coder:',
' provider: opencode',
' model: opencode/big-pickle',
'branch_name_strategy: ai',
'minimal_output: true',
'concurrency: 3',
'task_poll_interval_ms: 1200',
'interactive_preview_movements: 2',
].join('\n');
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.pipeline).toEqual({
defaultBranchPrefix: 'proj/',
commitMessageTemplate: 'feat: {title} (#{issue})',
});
expect(loaded.personaProviders).toEqual({
coder: { provider: 'opencode', model: 'opencode/big-pickle' },
});
expect(loaded.branchNameStrategy).toBe('ai');
expect(loaded.minimalOutput).toBe(true);
expect(loaded.concurrency).toBe(3);
expect(loaded.taskPollIntervalMs).toBe(1200);
expect(loaded.interactivePreviewMovements).toBe(2);
});
it('should save project-local fields as snake_case keys', () => {
const config = {
pipeline: {
defaultBranchPrefix: 'task/',
prBodyTemplate: 'Body {report}',
},
personaProviders: {
reviewer: { provider: 'codex', model: 'gpt-5' },
},
branchNameStrategy: 'romaji',
minimalOutput: true,
concurrency: 4,
taskPollIntervalMs: 1500,
interactivePreviewMovements: 1,
} as ProjectLocalConfig;
saveProjectConfig(testDir, config);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).toContain('pipeline:');
expect(raw).toContain('default_branch_prefix: task/');
expect(raw).toContain('pr_body_template: Body {report}');
expect(raw).toContain('persona_providers:');
expect(raw).toContain('provider: codex');
expect(raw).toContain('branch_name_strategy: romaji');
expect(raw).toContain('minimal_output: true');
expect(raw).toContain('concurrency: 4');
expect(raw).toContain('task_poll_interval_ms: 1500');
expect(raw).toContain('interactive_preview_movements: 1');
});
it('should not persist empty pipeline object on save', () => {
// Given: empty pipeline object
const config = {
pipeline: {},
} as ProjectLocalConfig;
// When: project config is saved
saveProjectConfig(testDir, config);
// Then: pipeline key is not serialized
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('pipeline:');
});
it('should not persist empty personaProviders object on save', () => {
// Given: empty personaProviders object
const config = {
personaProviders: {},
} as ProjectLocalConfig;
// When: project config is saved
saveProjectConfig(testDir, config);
// Then: persona_providers key is not serialized
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('persona_providers:');
expect(raw).not.toContain('personaProviders:');
});
it('should not persist unset values on save', () => {
const loaded = loadProjectConfig(testDir);
saveProjectConfig(testDir, loaded);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('minimal_output:');
expect(raw).not.toContain('concurrency:');
expect(raw).not.toContain('task_poll_interval_ms:');
expect(raw).not.toContain('interactive_preview_movements:');
});
it('should fail fast when project config contains global-only cli path keys', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'claude_cli_path: /tmp/bin/claude',
'codex_cli_path: /tmp/bin/codex',
'cursor_cli_path: /tmp/bin/cursor-agent',
'copilot_cli_path: /tmp/bin/copilot',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/unrecognized/i);
});
it('should fail fast when project config contains other global-only keys', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'language: ja',
'anthropic_api_key: sk-test',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/unrecognized/i);
});
});
describe('fail fast validation', () => {
it('should throw on invalid yaml syntax', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'pipeline: [unclosed', 'utf-8');
expect(() => loadProjectConfig(testDir)).toThrow(/failed to parse/);
});
it('should throw when yaml root is not an object', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, '- item1\n- item2\n', 'utf-8');
expect(() => loadProjectConfig(testDir)).toThrow(/must be a YAML object/);
});
it('should throw when pipeline has unknown field', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'pipeline:',
' default_branch_prefix: "task/"',
' unknown_field: "x"',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid pipeline/);
});
it('should throw when pipeline value has invalid type', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'pipeline:',
' commit_message_template: 123',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid pipeline\.commit_message_template/);
});
it('should throw when persona_providers entry has unknown field', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: codex',
' unsupported: true',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
});
it('should throw when persona_providers entry has invalid provider', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: invalid-provider',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
});
it('should throw when persona_providers entry has both provider and type', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: codex',
' type: opencode',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Configuration error: invalid persona_providers\.coder/);
});
it('should throw when persona_providers entry has codex provider with Claude model alias', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: codex',
' model: opus',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/Claude model alias/);
});
it('should throw when persona_providers entry has opencode provider without model', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' reviewer:',
' provider: opencode',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).toThrow(/provider 'opencode' requires model/);
});
it('should allow persona_providers entry with opencode provider and provider/model value', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(
configPath,
[
'persona_providers:',
' coder:',
' provider: opencode',
' model: opencode/big-pickle',
].join('\n'),
'utf-8',
);
expect(() => loadProjectConfig(testDir)).not.toThrow();
});
});
describe('runtime.prepare round-trip', () => {
it('should load single preset entry', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare:\n - node\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toEqual({ prepare: ['node'] });
});
it('should load multiple preset entries', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare:\n - node\n - gradle\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toEqual({ prepare: ['node', 'gradle'] });
});
it('should load custom script paths', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare:\n - ./setup.sh\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toEqual({ prepare: ['./setup.sh'] });
});
it('should round-trip save and load', () => {
const config: ProjectLocalConfig = {
runtime: { prepare: ['node'] },
};
saveProjectConfig(testDir, config);
const reloaded = loadProjectConfig(testDir);
expect(reloaded.runtime).toEqual({ prepare: ['node'] });
});
it('should deduplicate entries on load', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare:\n - node\n - node\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toEqual({ prepare: ['node'] });
});
it('should return undefined when runtime is not specified', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, '{}\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toBeUndefined();
});
it('should return undefined when prepare is empty array', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare: []\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toBeUndefined();
});
it('should not serialize runtime when config has no runtime', () => {
const config: ProjectLocalConfig = {};
saveProjectConfig(testDir, config);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('runtime');
});
it('should round-trip mixed presets and custom scripts', () => {
const config: ProjectLocalConfig = {
runtime: { prepare: ['node', 'gradle', './custom-setup.sh'] },
};
saveProjectConfig(testDir, config);
const reloaded = loadProjectConfig(testDir);
expect(reloaded.runtime).toEqual({ prepare: ['node', 'gradle', './custom-setup.sh'] });
});
});
});