1249 lines
38 KiB
TypeScript
1249 lines
38 KiB
TypeScript
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||
import { createLogger } from '@shared/utils/logger';
|
||
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
|
||
import { createHash } from 'crypto';
|
||
import { applyPatch, structuredPatch } from 'diff';
|
||
import { mkdir, readFile, unlink, writeFile } from 'fs/promises';
|
||
import { diff3Merge } from 'node-diff3';
|
||
import { dirname } from 'path';
|
||
|
||
import { HunkSnippetMatcher } from './HunkSnippetMatcher';
|
||
|
||
import type {
|
||
ApplyReviewRequest,
|
||
ApplyReviewResult,
|
||
ConflictCheckResult,
|
||
FileChangeWithContent,
|
||
LedgerChangeRelation,
|
||
RejectResult,
|
||
SnippetDiff,
|
||
} from '@shared/types';
|
||
import type { StructuredPatchHunk } from 'diff';
|
||
|
||
const logger = createLogger('Service:ReviewApplierService');
|
||
|
||
type ApplyErrorCode = NonNullable<ApplyReviewResult['errors'][number]['code']>;
|
||
type LedgerApplyOutcome =
|
||
| { handled: false }
|
||
| { handled: true; status: 'applied' | 'skipped' }
|
||
| { handled: true; status: 'conflict' | 'error'; error: string; code: ApplyErrorCode };
|
||
|
||
type CurrentTextReadResult =
|
||
| { missing: true; content: '' }
|
||
| { missing: false; content: string }
|
||
| { missing: false; content: ''; error: string };
|
||
|
||
function getCurrentTextReadError(result: CurrentTextReadResult): string | null {
|
||
return 'error' in result ? result.error : null;
|
||
}
|
||
|
||
/**
|
||
* Service for applying reject decisions from code review.
|
||
*
|
||
* Supports:
|
||
* - Conflict detection (file changed since review was computed)
|
||
* - Hunk-level rejection (reverse specific hunks)
|
||
* - File-level rejection (restore entire file to original)
|
||
* - Preview mode (show what would change without writing)
|
||
* - Batch review application
|
||
*/
|
||
export class ReviewApplierService {
|
||
private readonly matcher = new HunkSnippetMatcher();
|
||
|
||
/**
|
||
* Check if the file on disk has been modified since the review was computed.
|
||
* Compares current disk content against the expected modified content.
|
||
*/
|
||
async checkConflict(filePath: string, expectedModified: string): Promise<ConflictCheckResult> {
|
||
let currentContent: string;
|
||
try {
|
||
currentContent = await readFile(filePath, 'utf8');
|
||
} catch {
|
||
return {
|
||
hasConflict: true,
|
||
conflictContent: null,
|
||
currentContent: '',
|
||
originalContent: expectedModified,
|
||
};
|
||
}
|
||
|
||
const hasConflict = currentContent !== expectedModified;
|
||
|
||
return {
|
||
hasConflict,
|
||
conflictContent: hasConflict ? currentContent : null,
|
||
currentContent,
|
||
originalContent: expectedModified,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Reject specific hunks from a file's changes.
|
||
*
|
||
* PRIMARY approach: snippet-level replacement with positional reverse.
|
||
* FALLBACK: hunk-level inverse patch when snippet replacement fails.
|
||
*/
|
||
async rejectHunks(
|
||
_teamName: string,
|
||
filePath: string,
|
||
original: string,
|
||
modified: string,
|
||
hunkIndices: number[],
|
||
snippets: SnippetDiff[]
|
||
): Promise<RejectResult> {
|
||
// Try snippet-level reverse first (most accurate)
|
||
const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets);
|
||
if (snippetResult) {
|
||
try {
|
||
await writeFile(filePath, snippetResult.newContent, 'utf8');
|
||
return snippetResult;
|
||
} catch (err) {
|
||
return {
|
||
success: false,
|
||
newContent: modified,
|
||
hadConflicts: false,
|
||
conflictDescription: `Не удалось записать файл: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Fallback: hunk-level inverse patch
|
||
const patchResult = this.tryHunkLevelReject(original, modified, hunkIndices);
|
||
if (patchResult) {
|
||
try {
|
||
await writeFile(filePath, patchResult.newContent, 'utf8');
|
||
return patchResult;
|
||
} catch (err) {
|
||
return {
|
||
success: false,
|
||
newContent: modified,
|
||
hadConflicts: false,
|
||
conflictDescription: `Не удалось записать файл: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Both approaches failed — try three-way merge as last resort
|
||
const mergeResult = threeWayMerge(original, modified, original);
|
||
if (!mergeResult.hasConflicts) {
|
||
try {
|
||
await writeFile(filePath, mergeResult.content, 'utf8');
|
||
return {
|
||
success: true,
|
||
newContent: mergeResult.content,
|
||
hadConflicts: false,
|
||
};
|
||
} catch (err) {
|
||
return {
|
||
success: false,
|
||
newContent: modified,
|
||
hadConflicts: false,
|
||
conflictDescription: `Не удалось записать файл: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: false,
|
||
newContent: modified,
|
||
hadConflicts: true,
|
||
conflictDescription: 'Не удалось применить reject: все стратегии завершились неудачно',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Reject the entire file — restore to original content.
|
||
*/
|
||
async rejectFile(
|
||
_teamName: string,
|
||
filePath: string,
|
||
original: string,
|
||
modified: string
|
||
): Promise<RejectResult> {
|
||
// Check for conflicts first
|
||
const conflict = await this.checkConflict(filePath, modified);
|
||
if (conflict.hasConflict) {
|
||
// File was modified since review — try three-way merge
|
||
const currentContent = conflict.currentContent;
|
||
const mergeResult = threeWayMerge(modified, currentContent, original);
|
||
|
||
if (mergeResult.hasConflicts) {
|
||
return {
|
||
success: false,
|
||
newContent: currentContent,
|
||
hadConflicts: true,
|
||
conflictDescription:
|
||
'Файл был изменён после вычисления review, и три-сторонний merge обнаружил конфликты',
|
||
};
|
||
}
|
||
|
||
try {
|
||
await writeFile(filePath, mergeResult.content, 'utf8');
|
||
return {
|
||
success: true,
|
||
newContent: mergeResult.content,
|
||
hadConflicts: false,
|
||
};
|
||
} catch (err) {
|
||
return {
|
||
success: false,
|
||
newContent: currentContent,
|
||
hadConflicts: false,
|
||
conflictDescription: `Не удалось записать файл: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
// No conflict — simply write original content
|
||
try {
|
||
await writeFile(filePath, original, 'utf8');
|
||
return {
|
||
success: true,
|
||
newContent: original,
|
||
hadConflicts: false,
|
||
};
|
||
} catch (err) {
|
||
return {
|
||
success: false,
|
||
newContent: modified,
|
||
hadConflicts: false,
|
||
conflictDescription: `Не удалось записать файл: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Preview what a reject operation would produce WITHOUT writing to disk.
|
||
*/
|
||
async previewReject(
|
||
_filePath: string,
|
||
original: string,
|
||
modified: string,
|
||
hunkIndices: number[],
|
||
snippets: SnippetDiff[]
|
||
): Promise<{ preview: string; hasConflicts: boolean }> {
|
||
// Try snippet-level reverse
|
||
const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets);
|
||
if (snippetResult) {
|
||
return { preview: snippetResult.newContent, hasConflicts: false };
|
||
}
|
||
|
||
// Fallback: hunk-level inverse patch
|
||
const patchResult = this.tryHunkLevelReject(original, modified, hunkIndices);
|
||
if (patchResult) {
|
||
return { preview: patchResult.newContent, hasConflicts: patchResult.hadConflicts };
|
||
}
|
||
|
||
// Final fallback — three-way merge
|
||
const mergeResult = threeWayMerge(original, modified, original);
|
||
return { preview: mergeResult.content, hasConflicts: mergeResult.hasConflicts };
|
||
}
|
||
|
||
/**
|
||
* Apply all review decisions in batch.
|
||
*/
|
||
async applyReviewDecisions(
|
||
request: ApplyReviewRequest,
|
||
fileContents = new Map<string, FileChangeWithContent>()
|
||
): Promise<ApplyReviewResult> {
|
||
let applied = 0;
|
||
let skipped = 0;
|
||
let conflicts = 0;
|
||
const errors: ApplyReviewResult['errors'] = [];
|
||
|
||
for (const decision of request.decisions) {
|
||
const fileContent = fileContents.get(decision.filePath);
|
||
if (!fileContent) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
// Skip files where all hunks are accepted (nothing to reject)
|
||
if (decision.fileDecision === 'accepted') {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
const original = fileContent.originalFullContent;
|
||
const modified = fileContent.modifiedFullContent;
|
||
|
||
const rejectedHunkIndices = Object.entries(decision.hunkDecisions)
|
||
.filter(([, d]) => d === 'rejected')
|
||
.map(([idx]) => parseInt(idx, 10));
|
||
|
||
const allHunksRejected =
|
||
Object.keys(decision.hunkDecisions).length > 0 &&
|
||
Object.values(decision.hunkDecisions).every((d) => d === 'rejected');
|
||
const hasNewFileSnippet = fileContent.snippets.some(
|
||
(s) => s.type === 'write-new' || s.ledger?.operation === 'create'
|
||
);
|
||
|
||
// Special case: rejecting an entirely new file should remove it from disk.
|
||
// IMPORTANT: Do NOT delete on partial reject — users may want to keep parts of the new file.
|
||
const shouldDeleteNewFile =
|
||
fileContent.isNewFile &&
|
||
hasNewFileSnippet &&
|
||
original === '' &&
|
||
(decision.fileDecision === 'rejected' || allHunksRejected);
|
||
|
||
const ledgerOutcome = await this.tryApplyLedgerDecision(
|
||
decision.filePath,
|
||
original,
|
||
modified,
|
||
decision.fileDecision === 'rejected',
|
||
allHunksRejected,
|
||
rejectedHunkIndices,
|
||
decision.hunkContextHashes,
|
||
fileContent.snippets
|
||
);
|
||
if (ledgerOutcome.handled) {
|
||
if (ledgerOutcome.status === 'applied') {
|
||
applied++;
|
||
} else if (ledgerOutcome.status === 'skipped') {
|
||
skipped++;
|
||
} else if (ledgerOutcome.status === 'conflict' || ledgerOutcome.status === 'error') {
|
||
if (ledgerOutcome.status === 'conflict') conflicts++;
|
||
errors.push({
|
||
filePath: decision.filePath,
|
||
error: ledgerOutcome.error,
|
||
code: ledgerOutcome.code,
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (shouldDeleteNewFile) {
|
||
// If we have an expected modified baseline, guard against deleting a user-modified file.
|
||
if (modified !== null) {
|
||
const conflict = await this.checkConflict(decision.filePath, modified);
|
||
if (conflict.hasConflict) {
|
||
conflicts++;
|
||
errors.push({
|
||
filePath: decision.filePath,
|
||
error:
|
||
'File was modified since review was computed; refusing to delete new file automatically.',
|
||
code: 'conflict',
|
||
});
|
||
continue;
|
||
}
|
||
} else {
|
||
// No baseline — safest behavior is to only treat "already missing" as success.
|
||
try {
|
||
await readFile(decision.filePath, 'utf8');
|
||
} catch {
|
||
applied++;
|
||
continue;
|
||
}
|
||
errors.push({
|
||
filePath: decision.filePath,
|
||
error: 'Cannot delete new file: expected modified content is unavailable.',
|
||
code: 'unavailable',
|
||
});
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
await unlink(decision.filePath);
|
||
applied++;
|
||
} catch (err) {
|
||
const msg = String(err);
|
||
if (msg.includes('ENOENT')) {
|
||
applied++;
|
||
} else {
|
||
errors.push({
|
||
filePath: decision.filePath,
|
||
error: `Failed to delete new file: ${msg}`,
|
||
code: 'io-error',
|
||
});
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (original === null || modified === null) {
|
||
errors.push({
|
||
filePath: decision.filePath,
|
||
error: 'Содержимое файла недоступно для применения review',
|
||
code: 'unavailable',
|
||
});
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
if (decision.fileDecision === 'rejected') {
|
||
// Reject entire file
|
||
const result = await this.rejectFile(
|
||
request.teamName,
|
||
decision.filePath,
|
||
original,
|
||
modified
|
||
);
|
||
if (result.success) {
|
||
applied++;
|
||
} else {
|
||
if (result.hadConflicts) conflicts++;
|
||
errors.push({
|
||
filePath: decision.filePath,
|
||
error: result.conflictDescription || 'Не удалось применить reject',
|
||
});
|
||
}
|
||
} else {
|
||
// Partial reject — only specific hunks
|
||
if (rejectedHunkIndices.length === 0) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
const mappedRejected =
|
||
decision.hunkContextHashes && Object.keys(decision.hunkContextHashes).length > 0
|
||
? mapRejectedHunkIndicesByHash(
|
||
original,
|
||
modified,
|
||
rejectedHunkIndices,
|
||
decision.hunkContextHashes
|
||
)
|
||
: rejectedHunkIndices;
|
||
|
||
const result = await this.rejectHunks(
|
||
request.teamName,
|
||
decision.filePath,
|
||
original,
|
||
modified,
|
||
mappedRejected,
|
||
fileContent.snippets
|
||
);
|
||
|
||
if (result.success) {
|
||
applied++;
|
||
} else {
|
||
if (result.hadConflicts) conflicts++;
|
||
errors.push({
|
||
filePath: decision.filePath,
|
||
error: result.conflictDescription || 'Не удалось применить reject',
|
||
});
|
||
}
|
||
}
|
||
} catch (err) {
|
||
errors.push({
|
||
filePath: decision.filePath,
|
||
error: `Неожиданная ошибка: ${String(err)}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { applied, skipped, conflicts, errors };
|
||
}
|
||
|
||
/**
|
||
* Save edited file content directly to disk.
|
||
*/
|
||
async saveEditedFile(filePath: string, content: string): Promise<{ success: boolean }> {
|
||
await writeFile(filePath, content, 'utf8');
|
||
return { success: true };
|
||
}
|
||
|
||
// ── Private: Rejection strategies ──
|
||
|
||
private async tryApplyLedgerDecision(
|
||
filePath: string,
|
||
original: string | null,
|
||
modified: string | null,
|
||
fileRejected: boolean,
|
||
allHunksRejected: boolean,
|
||
rejectedHunkIndices: number[],
|
||
hunkContextHashes: Record<number, string> | undefined,
|
||
snippets: SnippetDiff[]
|
||
): Promise<LedgerApplyOutcome> {
|
||
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
|
||
if (ledgerSnippets.length === 0) {
|
||
return { handled: false };
|
||
}
|
||
|
||
const firstLedger = ledgerSnippets[0]?.ledger;
|
||
const lastLedger = ledgerSnippets[ledgerSnippets.length - 1]?.ledger;
|
||
if (!firstLedger || !lastLedger) {
|
||
return { handled: false };
|
||
}
|
||
|
||
const fullReject = fileRejected || allHunksRejected;
|
||
const hasUnavailableState = ledgerSnippets.some(
|
||
(snippet) =>
|
||
snippet.ledger?.beforeState?.unavailableReason ||
|
||
snippet.ledger?.afterState?.unavailableReason
|
||
);
|
||
const relation = this.resolveLedgerRelation(ledgerSnippets);
|
||
|
||
if (hasUnavailableState) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
error: 'Ledger content metadata is unavailable; manual review is required.',
|
||
};
|
||
}
|
||
|
||
if (!fullReject) {
|
||
if (relation?.kind === 'rename' || relation?.kind === 'copy') {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
error: `Ledger ${relation.kind} partial reject requires manual review.`,
|
||
};
|
||
}
|
||
if (original === null || modified === null) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
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
|
||
);
|
||
if (!guard.ok) {
|
||
return guard.outcome;
|
||
}
|
||
const patchResult = this.tryStrictHunkLevelReject(original, modified, strictHunks.indices);
|
||
if (!patchResult) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
error: 'Ledger partial reject could not be applied safely.',
|
||
};
|
||
}
|
||
try {
|
||
await writeFile(filePath, patchResult.newContent, 'utf8');
|
||
return { handled: true, status: 'applied' };
|
||
} catch (err) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: `Не удалось записать файл: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (relation?.kind === 'rename') {
|
||
return this.rejectLedgerRename(ledgerSnippets, relation, original, hasUnavailableState);
|
||
}
|
||
|
||
const operation = this.resolveLedgerOperation(ledgerSnippets);
|
||
if (operation === 'create') {
|
||
const afterHash = lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined;
|
||
const current = await this.readCurrentText(filePath);
|
||
if (current.missing) {
|
||
return { handled: true, status: 'applied' };
|
||
}
|
||
const currentError = getCurrentTextReadError(current);
|
||
if (currentError) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: currentError,
|
||
};
|
||
}
|
||
if (!afterHash) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: hasUnavailableState ? 'manual-review-required' : 'unavailable',
|
||
error: 'Ledger after content hash is unavailable; refusing to delete file automatically.',
|
||
};
|
||
}
|
||
if (this.hashText(current.content) !== afterHash) {
|
||
return {
|
||
handled: true,
|
||
status: 'conflict',
|
||
code: 'conflict',
|
||
error: 'File was modified since review was computed; refusing ledger delete.',
|
||
};
|
||
}
|
||
try {
|
||
await unlink(filePath);
|
||
return { handled: true, status: 'applied' };
|
||
} catch (err) {
|
||
const msg = String(err);
|
||
if (msg.includes('ENOENT')) {
|
||
return { handled: true, status: 'applied' };
|
||
}
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: `Failed to delete new file: ${msg}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (operation === 'delete') {
|
||
if (original === null) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
error: 'Ledger before content is unavailable; deleted file requires manual restore.',
|
||
};
|
||
}
|
||
const current = await this.readCurrentText(filePath);
|
||
if (!current.missing) {
|
||
const currentError = getCurrentTextReadError(current);
|
||
return {
|
||
handled: true,
|
||
status: 'conflict',
|
||
code: 'conflict',
|
||
error:
|
||
currentError || 'File exists on disk; refusing to overwrite while rejecting delete.',
|
||
};
|
||
}
|
||
try {
|
||
await writeFile(filePath, original, 'utf8');
|
||
return { handled: true, status: 'applied' };
|
||
} catch (err) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: `Не удалось записать файл: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (original === null) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
error:
|
||
'Ledger before content is unavailable; rejecting this change requires manual review.',
|
||
};
|
||
}
|
||
const guard = await this.checkLedgerCurrentHash(
|
||
filePath,
|
||
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
|
||
);
|
||
if (!guard.ok) {
|
||
return guard.outcome;
|
||
}
|
||
try {
|
||
await writeFile(filePath, original, 'utf8');
|
||
return { handled: true, status: 'applied' };
|
||
} catch (err) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: `Не удалось записать файл: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
private resolveLedgerOperation(snippets: SnippetDiff[]): 'create' | 'modify' | 'delete' {
|
||
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger);
|
||
const firstLedger = ledgerSnippets[0]?.ledger;
|
||
const lastLedger = ledgerSnippets[ledgerSnippets.length - 1]?.ledger;
|
||
const baselineExists = firstLedger?.beforeState?.exists;
|
||
const finalExists = lastLedger?.afterState?.exists;
|
||
|
||
if (baselineExists === false && finalExists === true) return 'create';
|
||
if (baselineExists === true && finalExists === false) return 'delete';
|
||
if (baselineExists === true && finalExists === true) return 'modify';
|
||
if (baselineExists === false && finalExists === false) return 'create';
|
||
|
||
if (lastLedger?.operation === 'delete') return 'delete';
|
||
if (firstLedger?.operation === 'create') return 'create';
|
||
return 'modify';
|
||
}
|
||
|
||
private resolveLedgerRelation(snippets: SnippetDiff[]): LedgerChangeRelation | undefined {
|
||
return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation;
|
||
}
|
||
|
||
private async rejectLedgerRename(
|
||
snippets: SnippetDiff[],
|
||
relation: LedgerChangeRelation,
|
||
original: string | null,
|
||
hasUnavailableState: boolean
|
||
): Promise<LedgerApplyOutcome> {
|
||
const oldSnippet =
|
||
snippets.find(
|
||
(snippet) =>
|
||
snippet.ledger?.operation === 'delete' &&
|
||
this.pathMatchesRelationPath(snippet.filePath, relation.oldPath)
|
||
) ?? snippets.find((snippet) => snippet.ledger?.operation === 'delete');
|
||
const newSnippet =
|
||
snippets.find(
|
||
(snippet) =>
|
||
snippet.ledger?.operation === 'create' &&
|
||
this.pathMatchesRelationPath(snippet.filePath, relation.newPath)
|
||
) ?? snippets.find((snippet) => snippet.ledger?.operation === 'create');
|
||
const oldFilePath =
|
||
oldSnippet?.filePath ??
|
||
this.resolveRelatedLedgerPath(newSnippet?.filePath, relation.newPath, relation.oldPath);
|
||
const newFilePath = newSnippet?.filePath;
|
||
const oldContent = oldSnippet?.ledger?.originalFullContent ?? original;
|
||
const newHash = newSnippet?.ledger?.afterState?.sha256 ?? newSnippet?.ledger?.afterHash;
|
||
const oldHash = oldSnippet?.ledger?.beforeState?.sha256 ?? oldSnippet?.ledger?.beforeHash;
|
||
|
||
if (!oldFilePath || !newFilePath || oldContent === null) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
error: 'Ledger rename metadata is incomplete; manual review is required.',
|
||
};
|
||
}
|
||
if (hasUnavailableState || !newHash) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
error: 'Ledger rename content metadata is unavailable; manual review is required.',
|
||
};
|
||
}
|
||
|
||
const newCurrent = await this.readCurrentText(newFilePath);
|
||
if (!newCurrent.missing) {
|
||
const newCurrentError = getCurrentTextReadError(newCurrent);
|
||
if (newCurrentError) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: newCurrentError,
|
||
};
|
||
}
|
||
if (this.hashText(newCurrent.content) !== newHash) {
|
||
return {
|
||
handled: true,
|
||
status: 'conflict',
|
||
code: 'conflict',
|
||
error: 'Renamed file was modified since review was computed; refusing ledger reject.',
|
||
};
|
||
}
|
||
}
|
||
|
||
const oldCurrent = await this.readCurrentText(oldFilePath);
|
||
if (!oldCurrent.missing) {
|
||
const oldCurrentError = getCurrentTextReadError(oldCurrent);
|
||
if (oldCurrentError) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: oldCurrentError,
|
||
};
|
||
}
|
||
if (!oldHash || this.hashText(oldCurrent.content) !== oldHash) {
|
||
return {
|
||
handled: true,
|
||
status: 'conflict',
|
||
code: 'conflict',
|
||
error: 'Original rename path already exists with different content; refusing overwrite.',
|
||
};
|
||
}
|
||
}
|
||
|
||
try {
|
||
if (oldCurrent.missing) {
|
||
await mkdir(dirname(oldFilePath), { recursive: true });
|
||
await writeFile(oldFilePath, oldContent, 'utf8');
|
||
}
|
||
if (!newCurrent.missing) {
|
||
await unlink(newFilePath);
|
||
}
|
||
return { handled: true, status: 'applied' };
|
||
} catch (err) {
|
||
return {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: `Failed to reject ledger rename: ${String(err)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
private pathMatchesRelationPath(filePath: string, relationPath: string): boolean {
|
||
const caseInsensitive =
|
||
this.isWindowsReviewPath(filePath) || this.isWindowsReviewPath(relationPath);
|
||
const normalizedFilePath = this.normalizeRelationComparisonPath(filePath, caseInsensitive);
|
||
const normalizedRelationPath = this.normalizeRelationComparisonPath(
|
||
relationPath,
|
||
caseInsensitive
|
||
);
|
||
return (
|
||
normalizedFilePath === normalizedRelationPath ||
|
||
normalizedFilePath.endsWith(`/${normalizedRelationPath}`)
|
||
);
|
||
}
|
||
|
||
private resolveRelatedLedgerPath(
|
||
anchorPath: string | undefined,
|
||
anchorRelationPath: string,
|
||
targetRelationPath: string
|
||
): string | null {
|
||
if (!anchorPath) {
|
||
return null;
|
||
}
|
||
const slashAnchor = anchorPath.replace(/\\/g, '/');
|
||
const slashRelation = anchorRelationPath.replace(/\\/g, '/');
|
||
const caseInsensitive =
|
||
this.isWindowsReviewPath(anchorPath) || this.isWindowsReviewPath(anchorRelationPath);
|
||
const normalizedAnchor = this.normalizeRelationComparisonPath(anchorPath, caseInsensitive);
|
||
const normalizedRelation = this.normalizeRelationComparisonPath(
|
||
anchorRelationPath,
|
||
caseInsensitive
|
||
);
|
||
if (!this.matchesRelationSuffix(normalizedAnchor, normalizedRelation)) {
|
||
return null;
|
||
}
|
||
return `${slashAnchor.slice(0, slashAnchor.length - slashRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`;
|
||
}
|
||
|
||
private normalizeRelationComparisonPath(filePath: string, caseInsensitive: boolean): string {
|
||
const normalized = normalizePathForComparison(filePath);
|
||
return caseInsensitive ? normalized.toLowerCase() : normalized;
|
||
}
|
||
|
||
private isWindowsReviewPath(filePath: string): boolean {
|
||
return isWindowsishPath(filePath) || filePath.includes('\\');
|
||
}
|
||
|
||
private matchesRelationSuffix(normalizedPath: string, normalizedRelationPath: string): boolean {
|
||
return (
|
||
normalizedPath === normalizedRelationPath ||
|
||
normalizedPath.endsWith(`/${normalizedRelationPath}`)
|
||
);
|
||
}
|
||
|
||
private async checkLedgerCurrentHash(
|
||
filePath: string,
|
||
expectedHash: string | undefined
|
||
): Promise<{ ok: true } | { ok: false; outcome: LedgerApplyOutcome }> {
|
||
if (!expectedHash) {
|
||
return {
|
||
ok: false,
|
||
outcome: {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'manual-review-required',
|
||
error: 'Ledger expected content hash is unavailable; refusing automatic apply.',
|
||
},
|
||
};
|
||
}
|
||
const current = await this.readCurrentText(filePath);
|
||
if (current.missing) {
|
||
return {
|
||
ok: false,
|
||
outcome: {
|
||
handled: true,
|
||
status: 'conflict',
|
||
code: 'conflict',
|
||
error: 'File is missing on disk; refusing ledger apply.',
|
||
},
|
||
};
|
||
}
|
||
const currentError = getCurrentTextReadError(current);
|
||
if (currentError) {
|
||
return {
|
||
ok: false,
|
||
outcome: {
|
||
handled: true,
|
||
status: 'error',
|
||
code: 'io-error',
|
||
error: currentError,
|
||
},
|
||
};
|
||
}
|
||
if (this.hashText(current.content) !== expectedHash) {
|
||
return {
|
||
ok: false,
|
||
outcome: {
|
||
handled: true,
|
||
status: 'conflict',
|
||
code: 'conflict',
|
||
error: 'File was modified since review was computed; refusing ledger apply.',
|
||
},
|
||
};
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
private async readCurrentText(filePath: string): Promise<CurrentTextReadResult> {
|
||
try {
|
||
return { missing: false, content: await readFile(filePath, 'utf8') };
|
||
} catch (err) {
|
||
const code =
|
||
err && typeof err === 'object' && 'code' in err
|
||
? String((err as { code?: unknown }).code)
|
||
: '';
|
||
if (code === 'ENOENT') {
|
||
return { missing: true, content: '' };
|
||
}
|
||
return { missing: false, content: '', error: `Не удалось прочитать файл: ${String(err)}` };
|
||
}
|
||
}
|
||
|
||
private hashText(content: string): string {
|
||
return createHash('sha256').update(content).digest('hex');
|
||
}
|
||
|
||
/**
|
||
* Snippet-level rejection: reverse specific snippets by position (most accurate).
|
||
*
|
||
* Uses HunkSnippetMatcher with content overlap analysis to map
|
||
* hunk indices → snippet indices, then reverses matched snippets.
|
||
*/
|
||
private trySnippetLevelReject(
|
||
original: string,
|
||
modified: string,
|
||
hunkIndices: number[],
|
||
snippets: SnippetDiff[]
|
||
): RejectResult | null {
|
||
// Safety: never use full-file Write snippets for snippet-level rejection.
|
||
// They are not localized, and matching a single hunk to a full-file write
|
||
// can incorrectly delete/overwrite large parts of the file.
|
||
const validSnippets = snippets.filter(
|
||
(s) =>
|
||
!s.isError &&
|
||
s.type !== 'write-new' &&
|
||
s.type !== 'write-update' &&
|
||
s.type !== 'notebook-edit' &&
|
||
s.type !== 'shell-snapshot' &&
|
||
s.type !== 'hook-snapshot'
|
||
);
|
||
if (validSnippets.length === 0) return null;
|
||
|
||
// Pass pre-filtered snippets — matcher returns indices relative to this array
|
||
const hunkToSnippets = this.matcher.matchHunksToSnippets(
|
||
original,
|
||
modified,
|
||
hunkIndices,
|
||
validSnippets
|
||
);
|
||
|
||
// Safety: if any requested hunk maps ambiguously, do NOT attempt snippet-level replacement.
|
||
// Fall back to hunk-level inverse patch which is positional and safer.
|
||
for (const hunkIdx of hunkIndices) {
|
||
const set = hunkToSnippets.get(hunkIdx);
|
||
if (set?.size !== 1) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Collect all unique snippet indices to reject
|
||
const snippetIndices = new Set<number>();
|
||
for (const indices of hunkToSnippets.values()) {
|
||
indices.forEach((idx) => snippetIndices.add(idx));
|
||
}
|
||
|
||
const snippetsToReject = Array.from(snippetIndices)
|
||
.map((idx) => validSnippets[idx])
|
||
.filter(Boolean);
|
||
|
||
if (snippetsToReject.length === 0) return null;
|
||
|
||
let content = modified;
|
||
|
||
// Find positions using disambiguation and sort descending for safe replacement
|
||
const positioned = snippetsToReject
|
||
.map((snippet) => {
|
||
const pos = this.matcher.findSnippetPosition(snippet, content);
|
||
return { snippet, pos };
|
||
})
|
||
.filter((item) => item.pos !== -1)
|
||
.sort((a, b) => b.pos - a.pos);
|
||
|
||
if (positioned.length !== snippetsToReject.length) {
|
||
// Some snippets' newStrings not found — can't do snippet-level
|
||
return null;
|
||
}
|
||
|
||
for (const { snippet, pos } of positioned) {
|
||
if (snippet.type === 'write-new') {
|
||
// Can't partially reject a file creation at snippet level
|
||
continue;
|
||
}
|
||
|
||
if (snippet.replaceAll) {
|
||
content = content.split(snippet.newString).join(snippet.oldString);
|
||
} else {
|
||
content =
|
||
content.substring(0, pos) +
|
||
snippet.oldString +
|
||
content.substring(pos + snippet.newString.length);
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
newContent: content,
|
||
hadConflicts: false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Hunk-level rejection: create inverse patch for rejected hunks and apply it.
|
||
*/
|
||
private tryHunkLevelReject(
|
||
original: string,
|
||
modified: string,
|
||
hunkIndices: number[]
|
||
): RejectResult | null {
|
||
// Create structured patch
|
||
const patch = structuredPatch('file', 'file', original, modified);
|
||
|
||
if (!patch.hunks || patch.hunks.length === 0) return null;
|
||
|
||
// Validate hunk indices
|
||
const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length);
|
||
if (validIndices.length === 0) return null;
|
||
|
||
// Build a partial inverse patch: only reverse the rejected hunks
|
||
const inversedHunks: StructuredPatchHunk[] = [];
|
||
for (const idx of validIndices) {
|
||
const hunk = patch.hunks[idx];
|
||
if (!hunk) continue;
|
||
inversedHunks.push(invertHunk(hunk));
|
||
}
|
||
|
||
if (inversedHunks.length === 0) return null;
|
||
|
||
// Create a partial inverse patch with the inverted hunks
|
||
const inversePatch = {
|
||
oldFileName: 'file',
|
||
newFileName: 'file',
|
||
oldHeader: undefined,
|
||
newHeader: undefined,
|
||
hunks: inversedHunks,
|
||
};
|
||
|
||
// Apply the inverse patch to the modified content
|
||
const result = applyPatch(modified, inversePatch, { fuzzFactor: 2 });
|
||
|
||
if (result === false) {
|
||
logger.debug('Hunk-level inverse patch не удался');
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
newContent: result,
|
||
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[]> {
|
||
const patch = structuredPatch('file', 'file', original, modified);
|
||
const hunks = patch.hunks ?? [];
|
||
const map = new Map<string, number[]>();
|
||
for (let i = 0; i < hunks.length; i++) {
|
||
const hunk = hunks[i];
|
||
const oldSideContent = hunk.lines
|
||
.filter((l) => !l.startsWith('+'))
|
||
.map((l) => l.slice(1))
|
||
.join('\n');
|
||
const newSideContent = hunk.lines
|
||
.filter((l) => !l.startsWith('-'))
|
||
.map((l) => l.slice(1))
|
||
.join('\n');
|
||
const hash = computeDiffContextHash(oldSideContent, newSideContent);
|
||
const arr = map.get(hash);
|
||
if (arr) arr.push(i);
|
||
else map.set(hash, [i]);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function mapRejectedHunkIndicesByHash(
|
||
original: string,
|
||
modified: string,
|
||
rejectedIndices: number[],
|
||
hunkContextHashes: Record<number, string>
|
||
): number[] {
|
||
const hashMap = buildHunkHashIndexMap(original, modified);
|
||
const out = new Set<number>();
|
||
|
||
for (const idx of rejectedIndices) {
|
||
const hash = hunkContextHashes[idx];
|
||
if (!hash) {
|
||
out.add(idx);
|
||
continue;
|
||
}
|
||
const candidates = hashMap.get(hash);
|
||
if (candidates?.length === 1) {
|
||
out.add(candidates[0]);
|
||
} else {
|
||
// Ambiguous or missing — fall back to index to preserve prior behavior.
|
||
out.add(idx);
|
||
}
|
||
}
|
||
|
||
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 ──
|
||
|
||
/**
|
||
* Invert a single hunk: swap added/removed lines, swap old/new start/lines.
|
||
*/
|
||
function invertHunk(hunk: StructuredPatchHunk): StructuredPatchHunk {
|
||
const invertedLines = hunk.lines.map((line) => {
|
||
if (line.startsWith('+')) return '-' + line.substring(1);
|
||
if (line.startsWith('-')) return '+' + line.substring(1);
|
||
return line; // context lines remain unchanged
|
||
});
|
||
|
||
return {
|
||
oldStart: hunk.newStart,
|
||
oldLines: hunk.newLines,
|
||
newStart: hunk.oldStart,
|
||
newLines: hunk.oldLines,
|
||
lines: invertedLines,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Three-way merge using node-diff3.
|
||
*
|
||
* @param base base version (common ancestor)
|
||
* @param ours "our" version (current state)
|
||
* @param theirs "their" version (desired state)
|
||
* @returns merged content and conflict indicator
|
||
*/
|
||
function threeWayMerge(
|
||
base: string,
|
||
ours: string,
|
||
theirs: string
|
||
): { content: string; hasConflicts: boolean } {
|
||
const regions = diff3Merge(ours, base, theirs);
|
||
let hasConflicts = false;
|
||
const parts: string[] = [];
|
||
|
||
for (const region of regions) {
|
||
if (region.ok) {
|
||
parts.push(region.ok.join('\n'));
|
||
} else if (region.conflict) {
|
||
hasConflicts = true;
|
||
// Include conflict markers for visibility
|
||
parts.push('<<<<<<< current');
|
||
parts.push(region.conflict.a.join('\n'));
|
||
parts.push('=======');
|
||
parts.push(region.conflict.b.join('\n'));
|
||
parts.push('>>>>>>> original');
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: parts.join('\n'),
|
||
hasConflicts,
|
||
};
|
||
}
|