fix(changes): require strict ledger hunk rejects

This commit is contained in:
777genius 2026-04-29 00:14:53 +03:00
parent 642cea8857
commit 07a9f603de
2 changed files with 199 additions and 2 deletions

View file

@ -293,6 +293,7 @@ export class ReviewApplierService {
decision.fileDecision === 'rejected',
allHunksRejected,
rejectedHunkIndices,
decision.hunkContextHashes,
fileContent.snippets
);
if (ledgerOutcome.handled) {
@ -450,6 +451,7 @@ export class ReviewApplierService {
fileRejected: boolean,
allHunksRejected: boolean,
rejectedHunkIndices: number[],
hunkContextHashes: Record<number, string> | undefined,
snippets: SnippetDiff[]
): Promise<LedgerApplyOutcome> {
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
@ -497,6 +499,20 @@ export class ReviewApplierService {
error: 'Ledger full text is unavailable; partial reject requires manual review.',
};
}
const strictHunks = mapRejectedHunkIndicesByHashStrict(
original,
modified,
rejectedHunkIndices,
hunkContextHashes
);
if (!strictHunks.ok) {
return {
handled: true,
status: strictHunks.code === 'conflict' ? 'conflict' : 'error',
code: strictHunks.code,
error: strictHunks.error,
};
}
const guard = await this.checkLedgerCurrentHash(
filePath,
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
@ -504,7 +520,7 @@ export class ReviewApplierService {
if (!guard.ok) {
return guard.outcome;
}
const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices);
const patchResult = this.tryStrictHunkLevelReject(original, modified, strictHunks.indices);
if (!patchResult) {
return {
handled: true,
@ -1035,6 +1051,46 @@ export class ReviewApplierService {
hadConflicts: false,
};
}
private tryStrictHunkLevelReject(
original: string,
modified: string,
hunkIndices: number[]
): RejectResult | null {
const patch = structuredPatch('file', 'file', original, modified);
if (!patch.hunks || patch.hunks.length === 0) return null;
const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length);
if (validIndices.length !== hunkIndices.length || validIndices.length === 0) return null;
const inversedHunks: StructuredPatchHunk[] = [];
for (const idx of validIndices) {
const hunk = patch.hunks[idx];
if (!hunk) return null;
inversedHunks.push(invertHunk(hunk));
}
const inversePatch = {
oldFileName: 'file',
newFileName: 'file',
oldHeader: undefined,
newHeader: undefined,
hunks: inversedHunks,
};
const result = applyPatch(modified, inversePatch, { fuzzFactor: 0 });
if (result === false) {
logger.debug('Strict ledger hunk-level inverse patch не удался');
return null;
}
return {
success: true,
newContent: result,
hadConflicts: false,
};
}
}
function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
@ -1086,6 +1142,54 @@ function mapRejectedHunkIndicesByHash(
return [...out].sort((a, b) => a - b);
}
function mapRejectedHunkIndicesByHashStrict(
original: string,
modified: string,
rejectedIndices: number[],
hunkContextHashes: Record<number, string> | undefined
): { ok: true; indices: number[] } | { ok: false; code: ApplyErrorCode; error: string } {
if (rejectedIndices.length === 0) {
return { ok: true, indices: [] };
}
if (!hunkContextHashes || Object.keys(hunkContextHashes).length === 0) {
return {
ok: false,
code: 'manual-review-required',
error: 'Ledger partial reject requires stable hunk context hashes.',
};
}
const hashMap = buildHunkHashIndexMap(original, modified);
const out = new Set<number>();
for (const idx of rejectedIndices) {
const hash = hunkContextHashes[idx];
if (!hash) {
return {
ok: false,
code: 'manual-review-required',
error: 'Ledger partial reject is missing a hunk context hash.',
};
}
const candidates = hashMap.get(hash);
if (!candidates || candidates.length === 0) {
return {
ok: false,
code: 'conflict',
error: 'Ledger partial reject hunk context changed; please re-review.',
};
}
if (candidates.length > 1) {
return {
ok: false,
code: 'conflict',
error: 'Ledger partial reject hunk context is ambiguous; please re-review.',
};
}
out.add(candidates[0]!);
}
return { ok: true, indices: [...out].sort((a, b) => a - b) };
}
// ── Module-level helpers ──
/**

View file

@ -2,6 +2,7 @@ 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';
@ -985,7 +986,8 @@ describe('ReviewApplierService', () => {
{
filePath,
fileDecision: 'pending',
hunkDecisions: { 0: 'rejected' },
hunkDecisions: { 0: 'rejected', 1: 'pending' },
hunkContextHashes: buildHunkContextHashes(original, modified),
},
],
},
@ -1034,8 +1036,99 @@ describe('ReviewApplierService', () => {
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;
}