diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index e81214d2..734c5174 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -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 | undefined, snippets: SnippetDiff[] ): Promise { 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 { @@ -1086,6 +1142,54 @@ function mapRejectedHunkIndicesByHash( return [...out].sort((a, b) => a - b); } +function mapRejectedHunkIndicesByHashStrict( + original: string, + modified: string, + rejectedIndices: number[], + hunkContextHashes: Record | 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(); + 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 ── /** diff --git a/test/main/services/team/ReviewApplierService.test.ts b/test/main/services/team/ReviewApplierService.test.ts index 5b784788..528595ac 100644 --- a/test/main/services/team/ReviewApplierService.test.ts +++ b/test/main/services/team/ReviewApplierService.test.ts @@ -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; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + 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 { + const patch = structuredPatch('file', 'file', original, modified); + const out: Record = {}; + 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; +}