## 概要
仕様ドキュメント `/Users/m_naruse/work/git/takt/task_planning/output-contracts-and-quality-gates.md` に基づき、YAML構造の変更を実装する。
---
## タスク一覧
### 【高】トップレベル構造の変更
- 現在の `output_contracts` を `report_formats` にリネーム
- レポートテンプレート定義として機能させる
### 【高】Movement内の output_contracts 構造変更
- 各 movement の `output_contracts` が直接レポート配列を持つ構造に変更
- `output_contracts.report` の `report` キーを廃止
**変更後の構造:**
```yaml
report_formats: # トップレベル(テンプレート定義)
plan: ...
movements:
- name: plan
output_contracts: # 直接配列(reportキー不要)
- name: 00-plan.md
format: plan
```
### 【中】quality_gates の実装
- エージェントへの通達として機能させる(自動検証は将来実装)
- Movement完了時にエージェントが参照できる形式で定義
---
## 制約(ユーザー明示)
- 後方互換性は不要
---
## 確認方法
- 既存のピース定義YAMLが新構造でパースできること
- テストが通ること
202 lines
6.7 KiB
TypeScript
202 lines
6.7 KiB
TypeScript
/**
|
|
* Tests for worktree environment: reportDir should use cwd (clone dir), not projectCwd.
|
|
*
|
|
* Issue #113: In worktree mode, reportDir must be resolved relative to cwd (clone) to
|
|
* prevent agents from discovering and editing the main repository via instruction paths.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { existsSync, rmSync, mkdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
// --- Mock setup (must be before imports that use these modules) ---
|
|
|
|
vi.mock('../agents/runner.js', () => ({
|
|
runAgent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../core/piece/evaluation/index.js', () => ({
|
|
detectMatchedRule: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../core/piece/phase-runner.js', () => ({
|
|
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
|
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
|
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
|
|
}));
|
|
|
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
|
|
}));
|
|
|
|
// --- Imports (after mocks) ---
|
|
|
|
import { PieceEngine } from '../core/piece/index.js';
|
|
import { runReportPhase } from '../core/piece/index.js';
|
|
import {
|
|
makeResponse,
|
|
makeMovement,
|
|
makeRule,
|
|
mockRunAgentSequence,
|
|
mockDetectMatchedRuleSequence,
|
|
applyDefaultMocks,
|
|
} from './engine-test-helpers.js';
|
|
import type { PieceConfig } from '../core/models/index.js';
|
|
|
|
function createWorktreeDirs(): { projectCwd: string; cloneCwd: string } {
|
|
const base = join(tmpdir(), `takt-worktree-test-${randomUUID()}`);
|
|
const projectCwd = join(base, 'project');
|
|
const cloneCwd = join(base, 'clone');
|
|
|
|
// Project side: real .takt/reports directory (for non-worktree tests)
|
|
mkdirSync(join(projectCwd, '.takt', 'reports', 'test-report-dir'), { recursive: true });
|
|
|
|
// Clone side: .takt/reports directory (reports now written directly to clone)
|
|
mkdirSync(join(cloneCwd, '.takt', 'reports', 'test-report-dir'), { recursive: true });
|
|
|
|
return { projectCwd, cloneCwd };
|
|
}
|
|
|
|
function buildSimpleConfig(): PieceConfig {
|
|
return {
|
|
name: 'worktree-test',
|
|
description: 'Test piece for worktree',
|
|
maxIterations: 10,
|
|
initialMovement: 'review',
|
|
movements: [
|
|
makeMovement('review', {
|
|
outputContracts: [{ label: 'review', path: '00-review.md' }],
|
|
rules: [
|
|
makeRule('approved', 'COMPLETE'),
|
|
],
|
|
}),
|
|
],
|
|
};
|
|
}
|
|
|
|
describe('PieceEngine: worktree reportDir resolution', () => {
|
|
let projectCwd: string;
|
|
let cloneCwd: string;
|
|
let baseDir: string;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
applyDefaultMocks();
|
|
const dirs = createWorktreeDirs();
|
|
projectCwd = dirs.projectCwd;
|
|
cloneCwd = dirs.cloneCwd;
|
|
baseDir = join(cloneCwd, '..');
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(baseDir)) {
|
|
rmSync(baseDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should pass cloneCwd-based reportDir to phase runner context in worktree mode', async () => {
|
|
// Given: worktree environment where cwd !== projectCwd
|
|
const config = buildSimpleConfig();
|
|
const engine = new PieceEngine(config, cloneCwd, 'test task', {
|
|
projectCwd,
|
|
});
|
|
|
|
mockRunAgentSequence([
|
|
makeResponse({ persona: 'review', content: 'Review done' }),
|
|
]);
|
|
mockDetectMatchedRuleSequence([
|
|
{ index: 0, method: 'tag' as const },
|
|
]);
|
|
|
|
// When: run the piece
|
|
await engine.run();
|
|
|
|
// Then: runReportPhase was called with context containing cloneCwd-based reportDir
|
|
const reportPhaseMock = vi.mocked(runReportPhase);
|
|
expect(reportPhaseMock).toHaveBeenCalled();
|
|
const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string };
|
|
|
|
// reportDir should be resolved from cloneCwd (cwd), not projectCwd
|
|
// This prevents agents from discovering the main repository path via instruction
|
|
const expectedPath = join(cloneCwd, '.takt/reports/test-report-dir');
|
|
const unexpectedPath = join(projectCwd, '.takt/reports/test-report-dir');
|
|
|
|
expect(phaseCtx.reportDir).toBe(expectedPath);
|
|
expect(phaseCtx.reportDir).not.toBe(unexpectedPath);
|
|
});
|
|
|
|
it('should pass cloneCwd-based reportDir to buildInstruction (used by {report_dir} placeholder)', async () => {
|
|
// Given: worktree environment with a movement that uses {report_dir} in template
|
|
const config: PieceConfig = {
|
|
name: 'worktree-test',
|
|
description: 'Test',
|
|
maxIterations: 10,
|
|
initialMovement: 'review',
|
|
movements: [
|
|
makeMovement('review', {
|
|
instructionTemplate: 'Write report to {report_dir}',
|
|
outputContracts: [{ label: 'review', path: '00-review.md' }],
|
|
rules: [
|
|
makeRule('approved', 'COMPLETE'),
|
|
],
|
|
}),
|
|
],
|
|
};
|
|
const engine = new PieceEngine(config, cloneCwd, 'test task', {
|
|
projectCwd,
|
|
});
|
|
|
|
const { runAgent } = await import('../agents/runner.js');
|
|
mockRunAgentSequence([
|
|
makeResponse({ persona: 'review', content: 'Review done' }),
|
|
]);
|
|
mockDetectMatchedRuleSequence([
|
|
{ index: 0, method: 'tag' as const },
|
|
]);
|
|
|
|
// When: run the piece
|
|
await engine.run();
|
|
|
|
// Then: the instruction should contain cloneCwd-based reportDir
|
|
// This prevents agents from discovering the main repository path
|
|
const runAgentMock = vi.mocked(runAgent);
|
|
expect(runAgentMock).toHaveBeenCalled();
|
|
const instruction = runAgentMock.mock.calls[0][1] as string;
|
|
|
|
const expectedPath = join(cloneCwd, '.takt/reports/test-report-dir');
|
|
expect(instruction).toContain(expectedPath);
|
|
// In worktree mode, projectCwd path should NOT appear in instruction
|
|
expect(instruction).not.toContain(join(projectCwd, '.takt/reports/test-report-dir'));
|
|
});
|
|
|
|
it('should use same path in non-worktree mode (cwd === projectCwd)', async () => {
|
|
// Given: normal environment where cwd === projectCwd
|
|
const normalDir = projectCwd;
|
|
const config = buildSimpleConfig();
|
|
const engine = new PieceEngine(config, normalDir, 'test task', {
|
|
projectCwd: normalDir,
|
|
});
|
|
|
|
mockRunAgentSequence([
|
|
makeResponse({ persona: 'review', content: 'Review done' }),
|
|
]);
|
|
mockDetectMatchedRuleSequence([
|
|
{ index: 0, method: 'tag' as const },
|
|
]);
|
|
|
|
// When: run the piece
|
|
await engine.run();
|
|
|
|
// Then: reportDir should be the same (cwd === projectCwd)
|
|
const reportPhaseMock = vi.mocked(runReportPhase);
|
|
expect(reportPhaseMock).toHaveBeenCalled();
|
|
const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string };
|
|
|
|
const expectedPath = join(normalDir, '.takt/reports/test-report-dir');
|
|
expect(phaseCtx.reportDir).toBe(expectedPath);
|
|
});
|
|
});
|