796 lines
27 KiB
TypeScript
796 lines
27 KiB
TypeScript
import * as fs from 'fs/promises';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
|
|
import { FileContentResolver } from '@main/services/team/FileContentResolver';
|
|
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
|
|
import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader';
|
|
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
|
import { buildPathChangeLabels } from '@renderer/components/team/review/pathChangeLabels';
|
|
|
|
import { materializeTaskChangeLedgerFixture } from './taskChangeLedgerFixtureUtils';
|
|
|
|
const TEAM_NAME = 'team-a';
|
|
const SUMMARY_OPTIONS = {
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
stateBucket: 'completed' as const,
|
|
summaryOnly: true,
|
|
};
|
|
|
|
async function writeTaskFile(baseDir: string, taskId: string, projectPath: string): Promise<void> {
|
|
const taskPath = path.join(baseDir, 'tasks', TEAM_NAME, `${taskId}.json`);
|
|
await fs.mkdir(path.dirname(taskPath), { recursive: true });
|
|
await fs.writeFile(
|
|
taskPath,
|
|
JSON.stringify(
|
|
{
|
|
id: taskId,
|
|
owner: 'alice',
|
|
status: 'completed',
|
|
createdAt: '2026-03-01T09:55:00.000Z',
|
|
updatedAt: '2026-03-01T10:10:00.000Z',
|
|
projectPath,
|
|
workIntervals: [
|
|
{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' },
|
|
],
|
|
historyEvents: [],
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
function createLedgerBackedChangeExtractorService(params: {
|
|
projectDir: string;
|
|
taskChangePresenceRepository?: {
|
|
upsertEntry: ReturnType<typeof vi.fn>;
|
|
deleteEntry?: ReturnType<typeof vi.fn>;
|
|
};
|
|
teamLogSourceTracker?: {
|
|
ensureTracking: ReturnType<
|
|
typeof vi.fn<
|
|
() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>
|
|
>
|
|
>;
|
|
};
|
|
}) {
|
|
const findLogFileRefsForTask = vi.fn(async () => {
|
|
throw new Error('fallback log reconstruction should not run for ledger fixtures');
|
|
});
|
|
const computeTaskChanges = vi.fn(async () => {
|
|
throw new Error('worker path should not run for ledger fixtures');
|
|
});
|
|
const service = new ChangeExtractorService(
|
|
{
|
|
getLogSourceWatchContext: vi.fn(async () => ({
|
|
projectDir: params.projectDir,
|
|
projectPath: params.projectDir,
|
|
})),
|
|
findLogFileRefsForTask,
|
|
findMemberLogPaths: vi.fn(async () => []),
|
|
} as any,
|
|
{
|
|
parseBoundaries: vi.fn(async () => {
|
|
throw new Error('inline parser should not run for ledger fixtures');
|
|
}),
|
|
} as any,
|
|
{ getConfig: vi.fn(async () => ({ projectPath: params.projectDir })) } as any,
|
|
undefined,
|
|
{
|
|
isAvailable: vi.fn(() => true),
|
|
computeTaskChanges,
|
|
} as any
|
|
);
|
|
|
|
if (params.taskChangePresenceRepository && params.teamLogSourceTracker) {
|
|
service.setTaskChangePresenceServices(
|
|
params.taskChangePresenceRepository as any,
|
|
params.teamLogSourceTracker as any
|
|
);
|
|
}
|
|
|
|
return { service, findLogFileRefsForTask, computeTaskChanges };
|
|
}
|
|
|
|
describe('task change ledger golden fixtures', () => {
|
|
const cleanups: Array<() => Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
setClaudeBasePathOverride(null);
|
|
vi.restoreAllMocks();
|
|
while (cleanups.length > 0) {
|
|
await cleanups.pop()?.();
|
|
}
|
|
});
|
|
|
|
it('reads rename and copy fixtures as grouped ledger changes', async () => {
|
|
const renameFixture = await materializeTaskChangeLedgerFixture('rename');
|
|
const copyFixture = await materializeTaskChangeLedgerFixture('copy');
|
|
cleanups.push(renameFixture.cleanup, copyFixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
|
|
const rename = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: renameFixture.manifest.taskId,
|
|
projectDir: renameFixture.projectDir,
|
|
projectPath: renameFixture.projectDir,
|
|
includeDetails: false,
|
|
});
|
|
const copy = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: copyFixture.manifest.taskId,
|
|
projectDir: copyFixture.projectDir,
|
|
projectPath: copyFixture.projectDir,
|
|
includeDetails: false,
|
|
});
|
|
|
|
expect(rename?.files).toHaveLength(1);
|
|
expect(rename?.files[0]?.changeKey).toBe('rename:src/old.ts->src/new.ts');
|
|
expect(rename?.files[0]?.filePath).toBe(path.join(renameFixture.projectDir, 'src', 'new.ts'));
|
|
expect(rename?.files[0]?.ledgerSummary?.relation).toEqual({
|
|
kind: 'rename',
|
|
oldPath: 'src/old.ts',
|
|
newPath: 'src/new.ts',
|
|
});
|
|
|
|
expect(copy?.files).toHaveLength(1);
|
|
expect(copy?.files[0]?.changeKey).toBe('copy:src/base.ts->src/copy.ts');
|
|
expect(copy?.files[0]?.isNewFile).toBe(true);
|
|
expect(copy?.files[0]?.filePath).toBe(path.join(copyFixture.projectDir, 'src', 'copy.ts'));
|
|
expect(copy?.files[0]?.ledgerSummary?.relation).toEqual({
|
|
kind: 'copy',
|
|
oldPath: 'src/base.ts',
|
|
newPath: 'src/copy.ts',
|
|
});
|
|
});
|
|
|
|
it('projects service-read ledger rename and copy fixtures into UI relation labels', async () => {
|
|
const renameFixture = await materializeTaskChangeLedgerFixture('rename');
|
|
const copyFixture = await materializeTaskChangeLedgerFixture('copy');
|
|
cleanups.push(renameFixture.cleanup, copyFixture.cleanup);
|
|
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-labels-ledger-'));
|
|
cleanups.push(async () => {
|
|
await fs.rm(claudeBaseDir, { recursive: true, force: true });
|
|
});
|
|
setClaudeBasePathOverride(claudeBaseDir);
|
|
await writeTaskFile(claudeBaseDir, renameFixture.manifest.taskId, renameFixture.projectDir);
|
|
await writeTaskFile(claudeBaseDir, copyFixture.manifest.taskId, copyFixture.projectDir);
|
|
|
|
const renameService = createLedgerBackedChangeExtractorService({
|
|
projectDir: renameFixture.projectDir,
|
|
}).service;
|
|
const copyService = createLedgerBackedChangeExtractorService({
|
|
projectDir: copyFixture.projectDir,
|
|
}).service;
|
|
|
|
const rename = await renameService.getTaskChanges(
|
|
TEAM_NAME,
|
|
renameFixture.manifest.taskId,
|
|
SUMMARY_OPTIONS
|
|
);
|
|
const copy = await copyService.getTaskChanges(
|
|
TEAM_NAME,
|
|
copyFixture.manifest.taskId,
|
|
SUMMARY_OPTIONS
|
|
);
|
|
|
|
const renameFile = rename.files[0];
|
|
const copyFile = copy.files[0];
|
|
expect(renameFile?.filePath).toBe(path.join(renameFixture.projectDir, 'src', 'new.ts'));
|
|
expect(copyFile?.filePath).toBe(path.join(copyFixture.projectDir, 'src', 'copy.ts'));
|
|
expect(buildPathChangeLabels(rename.files, {})[renameFile!.filePath]).toEqual({
|
|
kind: 'renamed',
|
|
direction: 'from',
|
|
otherPath: 'src/old.ts',
|
|
});
|
|
expect(buildPathChangeLabels(copy.files, {})[copyFile!.filePath]).toEqual({
|
|
kind: 'copied',
|
|
direction: 'from',
|
|
otherPath: 'src/base.ts',
|
|
});
|
|
});
|
|
|
|
it('returns warning-only notice fixtures without synthesizing fake file changes', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('notices-only');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
|
|
const result = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result?.files).toEqual([]);
|
|
expect(result?.warnings).toContain(
|
|
'Task change ledger skipped attribution because multiple task scopes were active.'
|
|
);
|
|
});
|
|
|
|
it('falls back when bundle freshness is intentionally mismatched', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('generation-mismatch');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
|
|
const result = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: false,
|
|
});
|
|
|
|
expect(result?.files).toHaveLength(1);
|
|
expect(result?.warnings).toContain('Task change summary fell back to journal reconstruction.');
|
|
});
|
|
|
|
it('uses journal tail hash, not only size and mtime, when freshness is missing', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('v2-summary');
|
|
cleanups.push(fixture.cleanup);
|
|
const taskId = fixture.manifest.taskId;
|
|
const eventPath = path.join(
|
|
fixture.projectDir,
|
|
'.board-task-changes',
|
|
'events',
|
|
`${encodeURIComponent(taskId)}.jsonl`
|
|
);
|
|
const freshnessSignalPath = path.join(
|
|
fixture.projectDir,
|
|
'.board-task-change-freshness',
|
|
`${encodeURIComponent(taskId)}.json`
|
|
);
|
|
const originalStat = await fs.stat(eventPath);
|
|
const raw = await fs.readFile(eventPath, 'utf8');
|
|
const mutated = raw.replace(
|
|
/"eventId":"([0-9a-f])([0-9a-f]+)"/,
|
|
(_match, first: string, rest: string) => `"eventId":"${first === 'a' ? 'b' : 'a'}${rest}"`
|
|
);
|
|
expect(mutated).not.toBe(raw);
|
|
expect(mutated.length).toBe(raw.length);
|
|
await fs.writeFile(eventPath, mutated, 'utf8');
|
|
await fs.utimes(eventPath, originalStat.atime, originalStat.mtime);
|
|
await fs.unlink(freshnessSignalPath);
|
|
|
|
const reader = new TaskChangeLedgerReader();
|
|
const result = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: false,
|
|
});
|
|
|
|
expect(result?.files).toHaveLength(1);
|
|
expect(result?.warnings).toContain('Task change summary fell back to journal reconstruction.');
|
|
});
|
|
|
|
it('surfaces recovered-journal warnings from real recovered artifacts', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('recovered-journal');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
|
|
const result = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: false,
|
|
});
|
|
|
|
expect(result?.files).toHaveLength(1);
|
|
expect(result?.warnings).toContain(
|
|
'Task change ledger recovered from malformed journal lines.'
|
|
);
|
|
});
|
|
|
|
it('keeps missing-blob fixture unavailable instead of synthesizing empty text', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('missing-blob');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
const file = changeSet?.files[0];
|
|
expect(file).toBeDefined();
|
|
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'alice',
|
|
file!.filePath,
|
|
file!.snippets
|
|
);
|
|
|
|
expect(resolved.originalFullContent).toBeNull();
|
|
expect(resolved.modifiedFullContent).toBe('export const missing = 2;\n');
|
|
expect(resolved.contentSource).toBe('ledger-snapshot');
|
|
});
|
|
|
|
it('reads OpenCode snapshot upgrade fixtures as one full-text ledger row', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('opencode-snapshot-upgrade');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
|
|
expect(changeSet?.files).toHaveLength(1);
|
|
const file = changeSet!.files[0]!;
|
|
expect(file.relativePath).toBe('src/snapshot-only.js');
|
|
expect(file.ledgerSummary).toMatchObject({
|
|
reviewability: 'full-text',
|
|
contentAvailability: 'full-text',
|
|
});
|
|
expect(file.snippets).toHaveLength(1);
|
|
const snippet = file.snippets[0]!;
|
|
expect(snippet.toolName).toBe('Edit');
|
|
expect(snippet.type).toBe('edit');
|
|
expect(snippet.ledger).toMatchObject({
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
textAvailability: 'full-text',
|
|
operation: 'modify',
|
|
});
|
|
expect(snippet.ledger?.originalFullContent).toBe('export const snapshot = 1;\n');
|
|
expect(snippet.ledger?.modifiedFullContent).toBe('export const snapshot = 2;\n');
|
|
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'bob',
|
|
file.filePath,
|
|
file.snippets
|
|
);
|
|
expect(resolved.originalFullContent).toBe('export const snapshot = 1;\n');
|
|
expect(resolved.modifiedFullContent).toBe('export const snapshot = 2;\n');
|
|
expect(resolved.contentSource).toBe('ledger-snapshot');
|
|
});
|
|
|
|
it('rejects grouped copy fixtures by deleting only the copied path', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('copy');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
const file = changeSet?.files[0];
|
|
expect(file).toBeDefined();
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'alice',
|
|
file!.filePath,
|
|
file!.snippets
|
|
);
|
|
|
|
const service = new ReviewApplierService();
|
|
const result = await service.applyReviewDecisions(
|
|
{
|
|
teamName: TEAM_NAME,
|
|
decisions: [
|
|
{
|
|
filePath: file!.filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
file!.filePath,
|
|
{
|
|
...file!,
|
|
...resolved,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(result).toMatchObject({ applied: 1, conflicts: 0 });
|
|
await expect(fs.stat(path.join(fixture.projectDir, 'src', 'copy.ts'))).rejects.toMatchObject({
|
|
code: 'ENOENT',
|
|
});
|
|
await expect(
|
|
fs.readFile(path.join(fixture.projectDir, 'src', 'base.ts'), 'utf8')
|
|
).resolves.toBe('export const copied = true;\n');
|
|
});
|
|
|
|
it('rejects create fixtures by deleting the created path only when the ledger hash matches', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('v2-summary');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
const file = changeSet?.files[0];
|
|
expect(file).toBeDefined();
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'alice',
|
|
file!.filePath,
|
|
file!.snippets
|
|
);
|
|
await fs.mkdir(path.dirname(file!.filePath), { recursive: true });
|
|
await fs.writeFile(file!.filePath, resolved.modifiedFullContent ?? '', 'utf8');
|
|
|
|
const service = new ReviewApplierService();
|
|
const result = await service.applyReviewDecisions(
|
|
{
|
|
teamName: TEAM_NAME,
|
|
decisions: [
|
|
{
|
|
filePath: file!.filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
file!.filePath,
|
|
{
|
|
...file!,
|
|
...resolved,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(result).toMatchObject({ applied: 1, conflicts: 0 });
|
|
await expect(fs.stat(file!.filePath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('blocks create fixture reject when the created path changed after ledger capture', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('v2-summary');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
const file = changeSet?.files[0];
|
|
expect(file).toBeDefined();
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'alice',
|
|
file!.filePath,
|
|
file!.snippets
|
|
);
|
|
await fs.mkdir(path.dirname(file!.filePath), { recursive: true });
|
|
await fs.writeFile(file!.filePath, 'external edit\n', 'utf8');
|
|
|
|
const service = new ReviewApplierService();
|
|
const result = await service.applyReviewDecisions(
|
|
{
|
|
teamName: TEAM_NAME,
|
|
decisions: [
|
|
{
|
|
filePath: file!.filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
file!.filePath,
|
|
{
|
|
...file!,
|
|
...resolved,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(result.applied).toBe(0);
|
|
expect(result.conflicts).toBe(1);
|
|
expect(result.errors[0]?.code).toBe('conflict');
|
|
await expect(fs.readFile(file!.filePath, 'utf8')).resolves.toBe('external edit\n');
|
|
});
|
|
|
|
it('rejects grouped rename fixtures by restoring the old path and removing the new path', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('rename');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
const file = changeSet?.files[0];
|
|
expect(file).toBeDefined();
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'alice',
|
|
file!.filePath,
|
|
file!.snippets
|
|
);
|
|
|
|
const service = new ReviewApplierService();
|
|
const result = await service.applyReviewDecisions(
|
|
{
|
|
teamName: TEAM_NAME,
|
|
decisions: [
|
|
{
|
|
filePath: file!.filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
file!.filePath,
|
|
{
|
|
...file!,
|
|
...resolved,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(result).toMatchObject({ applied: 1, conflicts: 0 });
|
|
await expect(fs.stat(path.join(fixture.projectDir, 'src', 'new.ts'))).rejects.toMatchObject({
|
|
code: 'ENOENT',
|
|
});
|
|
await expect(
|
|
fs.readFile(path.join(fixture.projectDir, 'src', 'old.ts'), 'utf8')
|
|
).resolves.toBe('export const renamed = true;\n');
|
|
});
|
|
|
|
it('blocks grouped rename reject when the new path changed after ledger capture', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('rename');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
const file = changeSet?.files[0];
|
|
expect(file).toBeDefined();
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'alice',
|
|
file!.filePath,
|
|
file!.snippets
|
|
);
|
|
await fs.writeFile(path.join(fixture.projectDir, 'src', 'new.ts'), 'external edit\n', 'utf8');
|
|
|
|
const service = new ReviewApplierService();
|
|
const result = await service.applyReviewDecisions(
|
|
{
|
|
teamName: TEAM_NAME,
|
|
decisions: [
|
|
{
|
|
filePath: file!.filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
file!.filePath,
|
|
{
|
|
...file!,
|
|
...resolved,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(result.applied).toBe(0);
|
|
expect(result.conflicts).toBe(1);
|
|
expect(result.errors[0]?.code).toBe('conflict');
|
|
await expect(
|
|
fs.readFile(path.join(fixture.projectDir, 'src', 'new.ts'), 'utf8')
|
|
).resolves.toBe('external edit\n');
|
|
await expect(fs.stat(path.join(fixture.projectDir, 'src', 'old.ts'))).rejects.toMatchObject({
|
|
code: 'ENOENT',
|
|
});
|
|
});
|
|
|
|
it('requires manual review when a fixture is missing original ledger text', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('missing-blob');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
const file = changeSet?.files[0];
|
|
expect(file).toBeDefined();
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'alice',
|
|
file!.filePath,
|
|
file!.snippets
|
|
);
|
|
|
|
const service = new ReviewApplierService();
|
|
const result = await service.applyReviewDecisions(
|
|
{
|
|
teamName: TEAM_NAME,
|
|
decisions: [
|
|
{
|
|
filePath: file!.filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
file!.filePath,
|
|
{
|
|
...file!,
|
|
...resolved,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(result.applied).toBe(0);
|
|
expect(result.errors[0]?.code).toBe('manual-review-required');
|
|
});
|
|
|
|
it('requires manual review for binary metadata-only fixtures and keeps the binary file intact', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('binary');
|
|
cleanups.push(fixture.cleanup);
|
|
const reader = new TaskChangeLedgerReader();
|
|
const changeSet = await reader.readTaskChanges({
|
|
teamName: TEAM_NAME,
|
|
taskId: fixture.manifest.taskId,
|
|
projectDir: fixture.projectDir,
|
|
projectPath: fixture.projectDir,
|
|
includeDetails: true,
|
|
});
|
|
const file = changeSet?.files[0];
|
|
expect(file).toBeDefined();
|
|
const before = await fs.readFile(file!.filePath);
|
|
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
|
const resolved = await resolver.getFileContent(
|
|
TEAM_NAME,
|
|
'alice',
|
|
file!.filePath,
|
|
file!.snippets
|
|
);
|
|
expect(file!.snippets[0]?.ledger?.modifiedFullContent).toBeNull();
|
|
expect(resolved.originalFullContent).toBeNull();
|
|
expect(resolved.modifiedFullContent).toBeNull();
|
|
expect(resolved.contentSource).toBe('unavailable');
|
|
|
|
const service = new ReviewApplierService();
|
|
const result = await service.applyReviewDecisions(
|
|
{
|
|
teamName: TEAM_NAME,
|
|
decisions: [
|
|
{
|
|
filePath: file!.filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
file!.filePath,
|
|
{
|
|
...file!,
|
|
...resolved,
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(result.applied).toBe(0);
|
|
expect(result.errors[0]?.code).toBe('manual-review-required');
|
|
await expect(fs.readFile(file!.filePath)).resolves.toEqual(before);
|
|
});
|
|
|
|
it('uses ledger fixtures as the primary source in ChangeExtractorService', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('generation-mismatch');
|
|
cleanups.push(fixture.cleanup);
|
|
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-ledger-'));
|
|
cleanups.push(async () => {
|
|
await fs.rm(claudeBaseDir, { recursive: true, force: true });
|
|
});
|
|
setClaudeBasePathOverride(claudeBaseDir);
|
|
await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir);
|
|
|
|
const { service, findLogFileRefsForTask, computeTaskChanges } =
|
|
createLedgerBackedChangeExtractorService({
|
|
projectDir: fixture.projectDir,
|
|
});
|
|
|
|
const result = await service.getTaskChanges(
|
|
TEAM_NAME,
|
|
fixture.manifest.taskId,
|
|
SUMMARY_OPTIONS
|
|
);
|
|
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.warnings).toContain('Task change summary fell back to journal reconstruction.');
|
|
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
|
|
expect(computeTaskChanges).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears cached presence from diagnostic-only warning ledger fixtures', async () => {
|
|
const fixture = await materializeTaskChangeLedgerFixture('notices-only');
|
|
cleanups.push(fixture.cleanup);
|
|
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-'));
|
|
cleanups.push(async () => {
|
|
await fs.rm(claudeBaseDir, { recursive: true, force: true });
|
|
});
|
|
setClaudeBasePathOverride(claudeBaseDir);
|
|
await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir);
|
|
|
|
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
|
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
|
const ensureTracking = vi.fn(() =>
|
|
Promise.resolve({
|
|
projectFingerprint: 'fixture-project-fingerprint',
|
|
logSourceGeneration: 'fixture-log-generation',
|
|
})
|
|
);
|
|
const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({
|
|
projectDir: fixture.projectDir,
|
|
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
|
teamLogSourceTracker: { ensureTracking },
|
|
});
|
|
|
|
const result = await service.getTaskChanges(
|
|
TEAM_NAME,
|
|
fixture.manifest.taskId,
|
|
SUMMARY_OPTIONS
|
|
);
|
|
|
|
expect(result.files).toEqual([]);
|
|
expect(result.warnings).toContain(
|
|
'Task change ledger skipped attribution because multiple task scopes were active.'
|
|
);
|
|
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
|
|
expect(upsertEntry).not.toHaveBeenCalled();
|
|
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, fixture.manifest.taskId);
|
|
});
|
|
});
|