takt/src/__tests__/engine-worktree-report.test.ts
nrslib 2460dbdf61 refactor(output-contracts): unify OutputContractEntry to item format with use_judge and move runtime dir under .takt
- Remove OutputContractLabelPath (label:path format), unify to OutputContractItem only
- Add required format field and use_judge flag to output contracts
- Add getJudgmentReportFiles() to filter reports eligible for Phase 3 status judgment
- Add supervisor-validation output contract, remove review-summary
- Enhance output contracts with finding_id tracking (new/persists/resolved sections)
- Move runtime environment directory from .runtime to .takt/.runtime
- Update all builtin pieces, e2e fixtures, and tests
2026-02-15 11:17:55 +09:00

295 lines
10 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, readdirSync, readFileSync } 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({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
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/phase-runner.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/runs directory (for non-worktree tests)
mkdirSync(join(projectCwd, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true });
// Clone side: .takt/runs directory (reports now written directly to clone)
mkdirSync(join(cloneCwd, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true });
return { projectCwd, cloneCwd };
}
function buildSimpleConfig(): PieceConfig {
return {
name: 'worktree-test',
description: 'Test piece for worktree',
maxMovements: 10,
initialMovement: 'review',
movements: [
makeMovement('review', {
outputContracts: [{ name: '00-review.md', format: '00-review', useJudge: true }],
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/runs/test-report-dir/reports');
const unexpectedPath = join(projectCwd, '.takt/runs/test-report-dir/reports');
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',
maxMovements: 10,
initialMovement: 'review',
movements: [
makeMovement('review', {
instructionTemplate: 'Write report to {report_dir}',
outputContracts: [{ name: '00-review.md', format: '00-review', useJudge: true }],
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/runs/test-report-dir/reports');
expect(instruction).toContain(expectedPath);
// In worktree mode, projectCwd path should NOT appear in instruction
expect(instruction).not.toContain(join(projectCwd, '.takt/runs/test-report-dir/reports'));
});
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/runs/test-report-dir/reports');
expect(phaseCtx.reportDir).toBe(expectedPath);
});
it('should use explicit reportDirName when provided', async () => {
const normalDir = projectCwd;
const config = buildSimpleConfig();
const engine = new PieceEngine(config, normalDir, 'test task', {
projectCwd: normalDir,
reportDirName: '20260201-015714-foptng',
});
mockRunAgentSequence([
makeResponse({ persona: 'review', content: 'Review done' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'tag' as const },
]);
await engine.run();
const reportPhaseMock = vi.mocked(runReportPhase);
expect(reportPhaseMock).toHaveBeenCalled();
const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string };
expect(phaseCtx.reportDir).toBe(join(normalDir, '.takt/runs/20260201-015714-foptng/reports'));
});
it('should reject invalid explicit reportDirName', () => {
const normalDir = projectCwd;
const config = buildSimpleConfig();
expect(() => new PieceEngine(config, normalDir, 'test task', {
projectCwd: normalDir,
reportDirName: '..',
})).toThrow('Invalid reportDirName: ..');
expect(() => new PieceEngine(config, normalDir, 'test task', {
projectCwd: normalDir,
reportDirName: '.',
})).toThrow('Invalid reportDirName: .');
expect(() => new PieceEngine(config, normalDir, 'test task', {
projectCwd: normalDir,
reportDirName: '',
})).toThrow('Invalid reportDirName: ');
});
it('should persist context snapshots and update latest previous response', async () => {
const normalDir = projectCwd;
const config: PieceConfig = {
name: 'snapshot-test',
description: 'Test',
maxMovements: 10,
initialMovement: 'implement',
movements: [
makeMovement('implement', {
policyContents: ['Policy content'],
knowledgeContents: ['Knowledge content'],
rules: [makeRule('go-review', 'review')],
}),
makeMovement('review', {
rules: [makeRule('approved', 'COMPLETE')],
}),
],
};
const engine = new PieceEngine(config, normalDir, 'test task', {
projectCwd: normalDir,
reportDirName: 'test-report-dir',
});
mockRunAgentSequence([
makeResponse({ persona: 'implement', content: 'implement output' }),
makeResponse({ persona: 'review', content: 'review output' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'tag' as const },
{ index: 0, method: 'tag' as const },
]);
await engine.run();
const base = join(normalDir, '.takt', 'runs', 'test-report-dir', 'context');
const knowledgeDir = join(base, 'knowledge');
const policyDir = join(base, 'policy');
const previousResponsesDir = join(base, 'previous_responses');
const knowledgeFiles = readdirSync(knowledgeDir);
const policyFiles = readdirSync(policyDir);
const previousResponseFiles = readdirSync(previousResponsesDir);
expect(knowledgeFiles.some((name) => name.endsWith('.md'))).toBe(true);
expect(policyFiles.some((name) => name.endsWith('.md'))).toBe(true);
expect(previousResponseFiles).toContain('latest.md');
expect(previousResponseFiles.filter((name) => name.endsWith('.md')).length).toBe(3);
expect(readFileSync(join(previousResponsesDir, 'latest.md'), 'utf-8')).toBe('review output');
});
});