1134 lines
38 KiB
TypeScript
1134 lines
38 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { createHash } from 'crypto';
|
|
import { structuredPatch } from 'diff';
|
|
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
|
|
|
import type { SnippetDiff } from '@shared/types';
|
|
|
|
vi.mock('fs/promises', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('fs/promises')>();
|
|
const readFile = vi.fn();
|
|
const writeFile = vi.fn();
|
|
const unlink = vi.fn();
|
|
const mkdir = vi.fn();
|
|
return {
|
|
...actual,
|
|
mkdir,
|
|
readFile,
|
|
writeFile,
|
|
unlink,
|
|
// ESM interop: some code paths expect a default export
|
|
default: { ...actual, mkdir, readFile, writeFile, unlink },
|
|
};
|
|
});
|
|
|
|
describe('ReviewApplierService', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
it('previewReject avoids write-update snippet-level replacement', async () => {
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const original = 'hello\nworld\n';
|
|
const modified = 'HELLO\nworld\n';
|
|
|
|
// Sanity: ensure there is at least one hunk for this change
|
|
const patch = structuredPatch('file', 'file', original, modified);
|
|
expect(patch.hunks.length).toBeGreaterThan(0);
|
|
|
|
const snippets: SnippetDiff[] = [
|
|
{
|
|
toolUseId: 't1',
|
|
filePath: '/tmp/file.txt',
|
|
toolName: 'Write',
|
|
type: 'write-update',
|
|
oldString: '',
|
|
newString: modified, // full file write
|
|
replaceAll: false,
|
|
timestamp: new Date().toISOString(),
|
|
isError: false,
|
|
},
|
|
];
|
|
|
|
const svc = new ReviewApplierService();
|
|
|
|
// Preview should restore original content (and must not collapse to empty due to write-update).
|
|
const preview = await svc.previewReject('/tmp/file.txt', original, modified, [0], snippets);
|
|
expect(preview.hasConflicts).toBe(false);
|
|
expect(preview.preview).toBe(original);
|
|
});
|
|
|
|
it('deletes a newly created file when fully rejected', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
readFile.mockResolvedValue('content\n');
|
|
unlink.mockResolvedValue(undefined);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
|
|
const filePath = '/tmp/new-file.txt';
|
|
const snippets: SnippetDiff[] = [
|
|
{
|
|
toolUseId: 't1',
|
|
filePath,
|
|
toolName: 'Write',
|
|
type: 'write-new',
|
|
oldString: '',
|
|
newString: 'content\n',
|
|
replaceAll: false,
|
|
timestamp: new Date().toISOString(),
|
|
isError: false,
|
|
},
|
|
];
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{
|
|
filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'new-file.txt',
|
|
snippets,
|
|
linesAdded: 1,
|
|
linesRemoved: 0,
|
|
isNewFile: true,
|
|
originalFullContent: '',
|
|
modifiedFullContent: 'content\n',
|
|
contentSource: 'snippet-reconstruction',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res.applied).toBe(1);
|
|
expect(unlink).toHaveBeenCalledWith(filePath);
|
|
expect(writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ledger create reject deletes only when current hash matches', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const content = 'created\n';
|
|
readFile.mockResolvedValue(content);
|
|
unlink.mockResolvedValue(undefined);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
const filePath = '/tmp/ledger-created.txt';
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{
|
|
filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'ledger-created.txt',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: '',
|
|
newString: content,
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-1',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: null,
|
|
modifiedFullContent: content,
|
|
beforeHash: null,
|
|
afterHash: sha(content),
|
|
operation: 'create',
|
|
beforeState: { exists: false },
|
|
afterState: { exists: true, sha256: sha(content), sizeBytes: content.length },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 0,
|
|
isNewFile: true,
|
|
originalFullContent: '',
|
|
modifiedFullContent: content,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
|
|
expect(unlink).toHaveBeenCalledWith(filePath);
|
|
});
|
|
|
|
it('ledger create reject blocks metadata-only create even when final hash is known', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
const filePath = '/tmp/metadata-only-created.txt';
|
|
const content = 'created\n';
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'metadata-only-created.txt',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath,
|
|
toolName: 'Edit',
|
|
type: 'edit',
|
|
oldString: '',
|
|
newString: '',
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-1',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'medium',
|
|
originalFullContent: null,
|
|
modifiedFullContent: null,
|
|
beforeHash: null,
|
|
afterHash: sha(content),
|
|
operation: 'create',
|
|
beforeState: {
|
|
exists: false,
|
|
unavailableReason: 'gitless-before-content-unavailable',
|
|
},
|
|
afterState: { exists: true, sha256: sha(content), sizeBytes: content.length },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 0,
|
|
linesRemoved: 0,
|
|
isNewFile: true,
|
|
originalFullContent: null,
|
|
modifiedFullContent: null,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res.applied).toBe(0);
|
|
expect(res.errors[0]?.code).toBe('manual-review-required');
|
|
expect(readFile).not.toHaveBeenCalled();
|
|
expect(unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ledger create reject blocks when current hash changed', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
readFile.mockResolvedValue('user changed\n');
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
const filePath = '/tmp/ledger-conflict.txt';
|
|
const ledgerContent = 'created\n';
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'ledger-conflict.txt',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: '',
|
|
newString: ledgerContent,
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-1',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: null,
|
|
modifiedFullContent: ledgerContent,
|
|
beforeHash: null,
|
|
afterHash: sha(ledgerContent),
|
|
operation: 'create',
|
|
beforeState: { exists: false },
|
|
afterState: { exists: true, sha256: sha(ledgerContent) },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 0,
|
|
isNewFile: true,
|
|
originalFullContent: '',
|
|
modifiedFullContent: ledgerContent,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res.applied).toBe(0);
|
|
expect(res.conflicts).toBe(1);
|
|
expect(res.errors[0]?.code).toBe('conflict');
|
|
expect(unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ledger delete reject restores only when file is missing', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
|
|
writeFile.mockResolvedValue(undefined);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
const filePath = '/tmp/deleted.txt';
|
|
const original = 'restore me\n';
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'deleted.txt',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: original,
|
|
newString: '',
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-1',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: original,
|
|
modifiedFullContent: null,
|
|
beforeHash: sha(original),
|
|
afterHash: null,
|
|
operation: 'delete',
|
|
beforeState: { exists: true, sha256: sha(original) },
|
|
afterState: { exists: false },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 0,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: original,
|
|
modifiedFullContent: '',
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res.applied).toBe(1);
|
|
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
|
|
});
|
|
|
|
it('ledger binary or large unavailable content requires manual review', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
readFile.mockResolvedValue('binary placeholder');
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
const filePath = '/tmp/blob.bin';
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'blob.bin',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: '',
|
|
newString: '',
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-1',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: null,
|
|
modifiedFullContent: null,
|
|
beforeHash: null,
|
|
afterHash: null,
|
|
operation: 'modify',
|
|
beforeState: { exists: true, unavailableReason: 'binary file' },
|
|
afterState: { exists: true, unavailableReason: 'binary file' },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 0,
|
|
linesRemoved: 0,
|
|
isNewFile: false,
|
|
originalFullContent: null,
|
|
modifiedFullContent: null,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res.applied).toBe(0);
|
|
expect(res.errors[0]?.code).toBe('manual-review-required');
|
|
expect(writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ledger rename reject restores old path and deletes new path with hash guards', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
const mkdir = fsPromises.mkdir as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const oldPath = '/repo/src/old.ts';
|
|
const newPath = '/repo/src/new.ts';
|
|
const oldContent = 'old\n';
|
|
const newContent = 'new\n';
|
|
readFile.mockImplementation(async (filePath: string) => {
|
|
if (filePath === newPath) return newContent;
|
|
if (filePath === oldPath) throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
throw new Error(`unexpected read ${filePath}`);
|
|
});
|
|
mkdir.mockResolvedValue(undefined);
|
|
writeFile.mockResolvedValue(undefined);
|
|
unlink.mockResolvedValue(undefined);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
const relation = { kind: 'rename' as const, oldPath: 'src/old.ts', newPath: 'src/new.ts' };
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{ filePath: newPath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } },
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
newPath,
|
|
{
|
|
filePath: newPath,
|
|
relativePath: 'src/new.ts',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath: oldPath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: oldContent,
|
|
newString: '',
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-old',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: oldContent,
|
|
modifiedFullContent: null,
|
|
beforeHash: sha(oldContent),
|
|
afterHash: null,
|
|
operation: 'delete',
|
|
beforeState: { exists: true, sha256: sha(oldContent) },
|
|
afterState: { exists: false },
|
|
relation,
|
|
},
|
|
},
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath: newPath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: '',
|
|
newString: newContent,
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:01.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-new',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: null,
|
|
modifiedFullContent: newContent,
|
|
beforeHash: null,
|
|
afterHash: sha(newContent),
|
|
operation: 'create',
|
|
beforeState: { exists: false },
|
|
afterState: { exists: true, sha256: sha(newContent) },
|
|
relation,
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: oldContent,
|
|
modifiedFullContent: newContent,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
|
|
expect(mkdir).toHaveBeenCalledWith('/repo/src', { recursive: true });
|
|
expect(writeFile).toHaveBeenCalledWith(oldPath, oldContent, 'utf8');
|
|
expect(unlink).toHaveBeenCalledWith(newPath);
|
|
});
|
|
|
|
it('ledger rename reject blocks when new path hash changed', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const oldPath = '/repo/src/old.ts';
|
|
const newPath = '/repo/src/new.ts';
|
|
const oldContent = 'old\n';
|
|
const newContent = 'new\n';
|
|
readFile.mockResolvedValue('user changed\n');
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
const relation = { kind: 'rename' as const, oldPath: 'src/old.ts', newPath: 'src/new.ts' };
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{ filePath: newPath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } },
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
newPath,
|
|
{
|
|
filePath: newPath,
|
|
relativePath: 'src/new.ts',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath: oldPath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: oldContent,
|
|
newString: '',
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-old',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: oldContent,
|
|
modifiedFullContent: null,
|
|
beforeHash: sha(oldContent),
|
|
afterHash: null,
|
|
operation: 'delete',
|
|
beforeState: { exists: true, sha256: sha(oldContent) },
|
|
afterState: { exists: false },
|
|
relation,
|
|
},
|
|
},
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath: newPath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: '',
|
|
newString: newContent,
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:01.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-new',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: null,
|
|
modifiedFullContent: newContent,
|
|
beforeHash: null,
|
|
afterHash: sha(newContent),
|
|
operation: 'create',
|
|
beforeState: { exists: false },
|
|
afterState: { exists: true, sha256: sha(newContent) },
|
|
relation,
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: oldContent,
|
|
modifiedFullContent: newContent,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res.applied).toBe(0);
|
|
expect(res.conflicts).toBe(1);
|
|
expect(res.errors[0]?.code).toBe('conflict');
|
|
expect(writeFile).not.toHaveBeenCalled();
|
|
expect(unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ledger rename reject resolves Windows relation paths case-insensitively', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
const mkdir = fsPromises.mkdir as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const newPath = 'C:\\Repo\\SRC\\New.ts';
|
|
const expectedOldPath = 'C:/Repo/src/OLD.ts';
|
|
const oldContent = 'old\n';
|
|
const newContent = 'new\n';
|
|
readFile.mockImplementation(async (filePath: string) => {
|
|
if (filePath === newPath) return newContent;
|
|
if (filePath === expectedOldPath) throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
|
throw new Error(`unexpected read ${filePath}`);
|
|
});
|
|
mkdir.mockResolvedValue(undefined);
|
|
writeFile.mockResolvedValue(undefined);
|
|
unlink.mockResolvedValue(undefined);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
const relation = { kind: 'rename' as const, oldPath: 'src/OLD.ts', newPath: 'src/NEW.ts' };
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{ filePath: newPath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } },
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
newPath,
|
|
{
|
|
filePath: newPath,
|
|
relativePath: 'SRC\\New.ts',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath: newPath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: '',
|
|
newString: newContent,
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:01.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-new',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: null,
|
|
modifiedFullContent: newContent,
|
|
beforeHash: null,
|
|
afterHash: sha(newContent),
|
|
operation: 'create',
|
|
beforeState: { exists: false },
|
|
afterState: { exists: true, sha256: sha(newContent) },
|
|
relation,
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: oldContent,
|
|
modifiedFullContent: newContent,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
|
|
expect(mkdir).toHaveBeenCalledWith('C:/Repo/src', { recursive: true });
|
|
expect(writeFile).toHaveBeenCalledWith(expectedOldPath, oldContent, 'utf8');
|
|
expect(unlink).toHaveBeenCalledWith(newPath);
|
|
});
|
|
|
|
it('ledger rename reject does not infer related paths from unsafe suffix matches', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const newPath = 'C:\\Repo\\src\\renew.ts';
|
|
const newContent = 'new\n';
|
|
const relation = { kind: 'rename' as const, oldPath: 'old.ts', newPath: 'new.ts' };
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{ filePath: newPath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } },
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
newPath,
|
|
{
|
|
filePath: newPath,
|
|
relativePath: 'src\\renew.ts',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath: newPath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: '',
|
|
newString: newContent,
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:01.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-new',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: null,
|
|
modifiedFullContent: newContent,
|
|
beforeHash: null,
|
|
afterHash: sha(newContent),
|
|
operation: 'create',
|
|
beforeState: { exists: false },
|
|
afterState: { exists: true, sha256: sha(newContent) },
|
|
relation,
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: 'old\n',
|
|
modifiedFullContent: newContent,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res.errors[0]?.code).toBe('manual-review-required');
|
|
expect(readFile).not.toHaveBeenCalled();
|
|
expect(writeFile).not.toHaveBeenCalled();
|
|
expect(unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('treats delete-then-create on an existing ledger file as modify, not new-file delete', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const filePath = '/tmp/replaced.ts';
|
|
const original = 'export const value = 1;\n';
|
|
const modified = 'export const value = 2;\n';
|
|
readFile.mockResolvedValue(modified);
|
|
writeFile.mockResolvedValue(undefined);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{
|
|
filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected', 1: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'replaced.ts',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-delete',
|
|
filePath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: original,
|
|
newString: '',
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-delete',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: original,
|
|
modifiedFullContent: null,
|
|
beforeHash: sha(original),
|
|
afterHash: null,
|
|
operation: 'delete',
|
|
beforeState: { exists: true, sha256: sha(original) },
|
|
afterState: { exists: false },
|
|
},
|
|
},
|
|
{
|
|
toolUseId: 'ledger-create',
|
|
filePath,
|
|
toolName: 'Bash',
|
|
type: 'shell-snapshot',
|
|
oldString: '',
|
|
newString: modified,
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:01.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-create',
|
|
source: 'ledger-snapshot',
|
|
confidence: 'high',
|
|
originalFullContent: null,
|
|
modifiedFullContent: modified,
|
|
beforeHash: null,
|
|
afterHash: sha(modified),
|
|
operation: 'create',
|
|
beforeState: { exists: false },
|
|
afterState: { exists: true, sha256: sha(modified) },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: original,
|
|
modifiedFullContent: modified,
|
|
contentSource: 'ledger-snapshot',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
|
|
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
|
|
expect(unlink).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ledger full modify reject accepts legacy afterHash when afterState hash is absent', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const filePath = '/tmp/legacy-ledger.ts';
|
|
const original = 'export const value = 1;\n';
|
|
const modified = 'export const value = 2;\n';
|
|
readFile.mockResolvedValue(modified);
|
|
writeFile.mockResolvedValue(undefined);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{
|
|
filePath,
|
|
fileDecision: 'rejected',
|
|
hunkDecisions: { 0: 'rejected' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'legacy-ledger.ts',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath,
|
|
toolName: 'Edit',
|
|
type: 'edit',
|
|
oldString: original,
|
|
newString: modified,
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-1',
|
|
source: 'ledger-exact',
|
|
confidence: 'exact',
|
|
originalFullContent: original,
|
|
modifiedFullContent: modified,
|
|
beforeHash: sha(original),
|
|
afterHash: sha(modified),
|
|
operation: 'modify',
|
|
beforeState: { exists: true, sha256: sha(original) },
|
|
afterState: { exists: true },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: original,
|
|
modifiedFullContent: modified,
|
|
contentSource: 'ledger-exact',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
|
|
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
|
|
});
|
|
|
|
it('ledger exact partial reject stays in the strict ledger lane and applies inverse hunk patch', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const filePath = '/tmp/exact-ledger.ts';
|
|
const original = 'const value = 1;\nconst keep = true;\n';
|
|
const modified = 'const value = 2;\nconst keep = true;\n';
|
|
readFile.mockResolvedValue(modified);
|
|
writeFile.mockResolvedValue(undefined);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{
|
|
filePath,
|
|
fileDecision: 'pending',
|
|
hunkDecisions: { 0: 'rejected', 1: 'pending' },
|
|
hunkContextHashes: buildHunkContextHashes(original, modified),
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'exact-ledger.ts',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath,
|
|
toolName: 'Edit',
|
|
type: 'edit',
|
|
oldString: 'const value = 1;\n',
|
|
newString: 'const value = 2;\n',
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-1',
|
|
source: 'ledger-exact',
|
|
confidence: 'exact',
|
|
originalFullContent: original,
|
|
modifiedFullContent: modified,
|
|
beforeHash: sha(original),
|
|
afterHash: sha(modified),
|
|
operation: 'modify',
|
|
beforeState: { exists: true, sha256: sha(original) },
|
|
afterState: { exists: true, sha256: sha(modified) },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: original,
|
|
modifiedFullContent: modified,
|
|
contentSource: 'ledger-exact',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
|
|
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
|
|
});
|
|
|
|
it('ledger partial reject refuses stale hunk context instead of falling back to index', async () => {
|
|
const fsPromises = await import('fs/promises');
|
|
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
|
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
const filePath = '/tmp/stale-ledger.ts';
|
|
const original = 'const value = 1;\nconst keep = true;\n';
|
|
const modified = 'const value = 2;\nconst keep = true;\n';
|
|
readFile.mockResolvedValue(modified);
|
|
|
|
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
|
const svc = new ReviewApplierService();
|
|
|
|
const res = await svc.applyReviewDecisions(
|
|
{
|
|
teamName: 'team',
|
|
decisions: [
|
|
{
|
|
filePath,
|
|
fileDecision: 'pending',
|
|
hunkDecisions: { 0: 'rejected', 1: 'pending' },
|
|
hunkContextHashes: { 0: 'stale-context-hash' },
|
|
},
|
|
],
|
|
},
|
|
new Map([
|
|
[
|
|
filePath,
|
|
{
|
|
filePath,
|
|
relativePath: 'stale-ledger.ts',
|
|
snippets: [
|
|
{
|
|
toolUseId: 'ledger-1',
|
|
filePath,
|
|
toolName: 'Edit',
|
|
type: 'edit',
|
|
oldString: 'const value = 1;\n',
|
|
newString: 'const value = 2;\n',
|
|
replaceAll: false,
|
|
timestamp: '2026-03-01T10:00:00.000Z',
|
|
isError: false,
|
|
ledger: {
|
|
eventId: 'event-1',
|
|
source: 'ledger-exact',
|
|
confidence: 'exact',
|
|
originalFullContent: original,
|
|
modifiedFullContent: modified,
|
|
beforeHash: sha(original),
|
|
afterHash: sha(modified),
|
|
operation: 'modify',
|
|
beforeState: { exists: true, sha256: sha(original) },
|
|
afterState: { exists: true, sha256: sha(modified) },
|
|
},
|
|
},
|
|
],
|
|
linesAdded: 1,
|
|
linesRemoved: 1,
|
|
isNewFile: false,
|
|
originalFullContent: original,
|
|
modifiedFullContent: modified,
|
|
contentSource: 'ledger-exact',
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(res.applied).toBe(0);
|
|
expect(res.conflicts).toBe(1);
|
|
expect(res.errors[0]?.code).toBe('conflict');
|
|
expect(writeFile).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function sha(content: string): string {
|
|
return createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
function buildHunkContextHashes(original: string, modified: string): Record<number, string> {
|
|
const patch = structuredPatch('file', 'file', original, modified);
|
|
const out: Record<number, string> = {};
|
|
for (let i = 0; i < patch.hunks.length; i++) {
|
|
const hunk = patch.hunks[i]!;
|
|
const oldSideContent = hunk.lines
|
|
.filter((line) => !line.startsWith('+'))
|
|
.map((line) => line.slice(1))
|
|
.join('\n');
|
|
const newSideContent = hunk.lines
|
|
.filter((line) => !line.startsWith('-'))
|
|
.map((line) => line.slice(1))
|
|
.join('\n');
|
|
out[i] = computeDiffContextHash(oldSideContent, newSideContent);
|
|
}
|
|
return out;
|
|
}
|