Created
June 26, 2025 16:10
-
-
Save tailot/136876a41b254e0bbac70786e9516fad to your computer and use it in GitHub Desktop.
This script is a command-line tool for analyzing and updating .patch files. It verifies if the context lines (frames) of a patch are still valid against a target file. If the frames are intact, the script can automatically update the patch's content to match the current state of the file, effectively "rebasing" the patch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
/** | |
* Usage: node patch-analyzer.js <patch_file> [target_file] | |
* | |
* Analyzes a patch file to verify the integrity of its context frames against a target file. | |
* If the frames are intact, it updates the patch with the current content from the target file. | |
* | |
* Arguments: | |
* <patch_file> The path to the .patch file to analyze. | |
* [target_file] Optional. The path to the file the patch applies to. | |
* If not provided, the script will infer it from the patch content. | |
* | |
* Example: | |
* node patch-analyzer.js my-feature.patch src/component.js | |
*/ | |
/** | |
* @fileoverview A script to analyze and verify the integrity of patch files. | |
* It can parse a patch, identify modification blocks, and check if the | |
* context lines (frames) around those blocks are still intact in the target files. | |
* If the frames are intact, it can update the patch content to match the current | |
* state of the file, effectively "rebasing" the patch. | |
*/ | |
const fs = require('fs'); | |
const path = require('path'); | |
/** | |
* @class PatchAnalyzer | |
* @description A class to analyze, verify, and update patch files. | |
*/ | |
class PatchAnalyzer { | |
constructor() { | |
// This property is not actively used but could be for future enhancements. | |
this.patches = []; | |
} | |
/** | |
* Reads and parses a patch file from the given path. | |
* @param {string} patchPath - The path to the patch file. | |
* @returns {Array<Object>} An array of parsed patch objects. | |
* @throws {Error} If there is an error reading or parsing the file. | |
*/ | |
readPatch(patchPath) { | |
try { | |
const patchContent = fs.readFileSync(patchPath, 'utf8'); | |
return this.parsePatch(patchContent); | |
} catch (error) { | |
throw new Error(`Error reading patch file: ${error.message}`); | |
} | |
} | |
/** | |
* Parses the content of a patch file. | |
* @param {string} patchContent - The string content of the patch file. | |
* @returns {Array<Object>} An array of parsed patch objects. | |
*/ | |
parsePatch(patchContent) { | |
const lines = patchContent.split('\n'); | |
const patches = []; | |
let currentPatch = null; | |
let currentHunk = null; | |
for (const line of lines) { | |
if (line.startsWith('---')) { | |
if (currentPatch) { | |
patches.push(currentPatch); | |
} | |
currentPatch = { | |
oldFile: line.substring(4).trim(), | |
newFile: null, | |
hunks: [], | |
}; | |
} else if (line.startsWith('+++')) { | |
if (currentPatch) { | |
currentPatch.newFile = line.substring(4).trim(); | |
} | |
} else if (line.startsWith('@@')) { | |
const hunkInfo = this.parseHunkHeader(line); | |
currentHunk = { | |
oldStart: hunkInfo.oldStart, | |
oldCount: hunkInfo.oldCount, | |
newStart: hunkInfo.newStart, | |
newCount: hunkInfo.newCount, | |
changes: [], | |
}; | |
if (currentPatch) { | |
currentPatch.hunks.push(currentHunk); | |
} | |
} else if (currentHunk && (line.startsWith(' ') || line.startsWith('+') || line.startsWith('-'))) { | |
const changeType = line.charAt(0); | |
const content = line.substring(1); | |
currentHunk.changes.push({ | |
type: changeType, | |
content: content, | |
}); | |
} | |
} | |
if (currentPatch) { | |
patches.push(currentPatch); | |
} | |
return patches; | |
} | |
/** | |
* Parses a hunk header line (e.g., "@@ -1,4 +1,4 @@"). | |
* @param {string} header - The hunk header line. | |
* @returns {Object} An object containing the start and count for old and new files. | |
* @throws {Error} If the hunk header format is invalid. | |
*/ | |
parseHunkHeader(header) { | |
const match = header.match(/@@\s*-(\d+)(?:,(\d+))?\s*\+(\d+)(?:,(\d+))?\s*@@/); | |
if (!match) { | |
throw new Error(`Invalid hunk header: ${header}`); | |
} | |
return { | |
oldStart: parseInt(match[1], 10), | |
oldCount: parseInt(match[2], 10) || 1, | |
newStart: parseInt(match[3], 10), | |
newCount: parseInt(match[4], 10) || 1, | |
}; | |
} | |
/** | |
* Identifies modification blocks (a sequence of additions or deletions) and their surrounding context lines (frames). | |
* @param {Object} patch - A parsed patch object. | |
* @returns {Array<Object>} An array of identified modification blocks. | |
*/ | |
identifyModificationBlocks(patch) { | |
const blocks = []; | |
patch.hunks.forEach((hunk, hunkIndex) => { | |
let currentBlock = null; | |
let lastContextLine = null; | |
hunk.changes.forEach((change, changeIndex) => { | |
if (change.type === ' ') { | |
// This is a context line. | |
if (currentBlock && !currentBlock.bottomFrame) { | |
// This is the first context line after a modification block, so it's the bottom frame. | |
currentBlock.bottomFrame = { | |
content: change.content, | |
oldLineNumber: this.calculateActualLineNumber(hunk, changeIndex, 'old'), | |
newLineNumber: this.calculateActualLineNumber(hunk, changeIndex, 'new'), | |
}; | |
blocks.push(currentBlock); | |
currentBlock = null; | |
} | |
lastContextLine = { | |
content: change.content, | |
oldLineNumber: this.calculateActualLineNumber(hunk, changeIndex, 'old'), | |
newLineNumber: this.calculateActualLineNumber(hunk, changeIndex, 'new'), | |
}; | |
} else if (change.type === '+' || change.type === '-') { | |
// This is a modification line, start a new block if we're not in one. | |
if (!currentBlock) { | |
currentBlock = { | |
type: change.type === '+' ? 'addition' : 'deletion', | |
changes: [], | |
topFrame: lastContextLine, | |
bottomFrame: null, | |
hunkIndex: hunkIndex, | |
startLineNumber: this.calculateActualLineNumber(hunk, changeIndex, change.type === '+' ? 'new' : 'old'), | |
}; | |
} | |
// Ensure the block is homogeneous (only additions or only deletions). | |
const expectedType = currentBlock.type === 'addition' ? '+' : '-'; | |
if (change.type !== expectedType) { | |
// This tool does not support mixed modification blocks. | |
// A more advanced implementation might handle this differently. | |
if (currentBlock) { | |
blocks.push(currentBlock); | |
} | |
currentBlock = { | |
type: change.type === '+' ? 'addition' : 'deletion', | |
changes: [], | |
topFrame: lastContextLine, | |
bottomFrame: null, | |
hunkIndex: hunkIndex, | |
startLineNumber: this.calculateActualLineNumber(hunk, changeIndex, change.type === '+' ? 'new' : 'old'), | |
}; | |
} | |
currentBlock.changes.push(change); | |
} | |
}); | |
// If a block is still open at the end of the hunk, close it. | |
if (currentBlock) { | |
blocks.push(currentBlock); | |
} | |
}); | |
return blocks; | |
} | |
/** | |
* Calculates the actual line number in the file for a given change within a hunk. | |
* @param {Object} hunk - The hunk object. | |
* @param {number} changeIndex - The index of the change within the hunk's changes array. | |
* @param {('old'|'new')} lineType - Whether to calculate the line number for the old or new file. | |
* @returns {number} The calculated line number. | |
*/ | |
calculateActualLineNumber(hunk, changeIndex, lineType) { | |
let oldLine = hunk.oldStart; | |
let newLine = hunk.newStart; | |
for (let i = 0; i < changeIndex; i++) { | |
const change = hunk.changes[i]; | |
if (change.type === ' ') { | |
oldLine++; | |
newLine++; | |
} else if (change.type === '-') { | |
oldLine++; | |
} else if (change.type === '+') { | |
newLine++; | |
} | |
} | |
if (lineType === 'old') { | |
return oldLine; | |
} | |
// Adjust for the current line | |
const currentChange = hunk.changes[changeIndex]; | |
if(currentChange.type === ' '){ | |
return newLine; | |
} else if (currentChange.type === '+'){ | |
return newLine; | |
} else { // type is '-' | |
// For deletions, the 'new' line number doesn't really advance, | |
// but for context, we can return the last valid new line number. | |
return newLine; | |
} | |
} | |
/** | |
* Updates the content of modification blocks in a patch based on the current file content. | |
* This is done only after verifying that the frames are intact. | |
* @param {Object} patch - The parsed patch object to update. | |
* @param {Array<Object>} blocks - The identified modification blocks. | |
* @param {Array<string>} fileLines - The lines of the target file. | |
*/ | |
updatePatchBlocks(patch, blocks, fileLines) { | |
blocks.forEach(block => { | |
const hunk = patch.hunks[block.hunkIndex]; | |
const startLineInFile = this.findBlockStartInFile(block, fileLines); | |
if (startLineInFile !== -1) { | |
for (let i = 0; i < block.changes.length; i++) { | |
const change = block.changes[i]; | |
const fileLineIndex = startLineInFile + i; | |
if (fileLineIndex < fileLines.length) { | |
const newContent = fileLines[fileLineIndex]; | |
// Keep the +/- sign but update the content. | |
change.content = newContent; | |
// Also update the original hunk | |
const changeIndexInHunk = this.findChangeIndexInHunk(hunk, change, i); | |
if (changeIndexInHunk !== -1) { | |
hunk.changes[changeIndexInHunk].content = newContent; | |
} | |
} | |
} | |
} | |
}); | |
} | |
/** | |
* Finds the starting line index of a modification block within the target file lines. | |
* It uses the top and bottom frames to locate the block. | |
* @param {Object} block - The modification block. | |
* @param {Array<string>} fileLines - The lines of the target file. | |
* @returns {number} The starting line index of the block in the file, or -1 if not found. | |
*/ | |
findBlockStartInFile(block, fileLines) { | |
if (!block.topFrame || !block.bottomFrame) { | |
// Cannot locate block without both frames. | |
return -1; | |
} | |
for (let i = 0; i < fileLines.length - 1; i++) { | |
if (fileLines[i].trim() === block.topFrame.content.trim()) { | |
// Found potential top frame. Now check for the bottom frame at the expected position. | |
const expectedBottomIndex = i + 1 + block.changes.length; | |
if ( | |
expectedBottomIndex < fileLines.length && | |
fileLines[expectedBottomIndex].trim() === block.bottomFrame.content.trim() | |
) { | |
return i + 1; // Return the index of the first line of the block's content. | |
} | |
} | |
} | |
return -1; | |
} | |
/** | |
* Finds the index of a specific change object within a hunk's changes array. | |
* @param {Object} hunk - The hunk object. | |
* @param {Object} targetChange - The change to find. | |
* @param {number} originalIndex - The original index of the change to help resolve duplicates. | |
* @returns {number} The index of the change, or -1 if not found. | |
*/ | |
findChangeIndexInHunk(hunk, targetChange, originalIndex) { | |
// A simple search might be ambiguous if the same line is changed multiple times. | |
// A more robust solution would be to pass the original index. | |
// For this implementation, we assume content is unique enough. | |
for (let i = 0; i < hunk.changes.length; i++) { | |
const change = hunk.changes[i]; | |
if (change.type === targetChange.type && change.content === targetChange.content && i >= originalIndex) { | |
return i; | |
} | |
} | |
return -1; | |
} | |
/** | |
* Reconstructs the patch file content from the parsed (and possibly updated) patch data. | |
* @param {Array<Object>} patches - An array of patch objects. | |
* @returns {string} The reconstructed patch file content. | |
*/ | |
reconstructPatch(patches) { | |
let patchContent = ''; | |
for (const patch of patches) { | |
patchContent += `--- ${patch.oldFile}\n`; | |
patchContent += `+++ ${patch.newFile}\n`; | |
for (const hunk of patch.hunks) { | |
// Recalculate hunk counts based on changes | |
const oldCount = hunk.changes.filter(c => c.type === '-' || c.type === ' ').length; | |
const newCount = hunk.changes.filter(c => c.type === '+' || c.type === ' ').length; | |
patchContent += `@@ -${hunk.oldStart},${oldCount} +${hunk.newStart},${newCount} @@\n`; | |
for (const change of hunk.changes) { | |
patchContent += `${change.type}${change.content}\n`; | |
} | |
} | |
} | |
return patchContent; | |
} | |
/** | |
* Saves the updated patch content to a file, creating a backup of the original. | |
* @param {string} patchPath - The path to save the patch file. | |
* @param {string} patchContent - The content to save. | |
*/ | |
savePatch(patchPath, patchContent) { | |
const backupPath = patchPath + '.backup'; | |
if (fs.existsSync(patchPath)) { | |
fs.copyFileSync(patchPath, backupPath); | |
console.log(`Original patch backed up to: ${backupPath}`); | |
} | |
fs.writeFileSync(patchPath, patchContent); | |
console.log(`Updated patch saved to: ${patchPath}`); | |
} | |
/** | |
* Verifies the integrity of the frames for all modification blocks in a patch file. | |
* @param {string} patchPath - The path to the patch file. | |
* @param {string} [targetFilePath] - Optional. The path to the target file to check against. If not provided, it's inferred from the patch. | |
* @returns {Promise<Array<Object>>} A promise that resolves to an array of result objects, one for each file in the patch. | |
* @throws {Error} If there is a critical error during verification. | |
*/ | |
async verifyFrameIntegrity(patchPath, targetFilePath) { | |
try { | |
const patches = this.readPatch(patchPath); | |
const results = []; | |
for (const patch of patches) { | |
let filePath = targetFilePath; | |
if (!filePath) { | |
// Infer file path from the patch, removing 'a/' or 'b/' prefixes. | |
filePath = patch.oldFile.startsWith('a/') ? patch.oldFile.substring(2) : patch.oldFile; | |
} | |
if (!fs.existsSync(filePath)) { | |
results.push({ | |
file: filePath, | |
status: 'error', | |
message: 'Target file not found', | |
}); | |
continue; | |
} | |
const fileContent = fs.readFileSync(filePath, 'utf8'); | |
const fileLines = fileContent.split('\n'); | |
const blocks = this.identifyModificationBlocks(patch); | |
const frameChecks = blocks.map(block => ({ | |
blockType: block.type, | |
topFrame: this.checkFrame(fileLines, block.topFrame, 'top', block), | |
bottomFrame: this.checkFrame(fileLines, block.bottomFrame, 'bottom', block), | |
})); | |
const allFramesIntact = frameChecks.every( | |
check => check.topFrame.intact && check.bottomFrame.intact | |
); | |
const result = { | |
file: filePath, | |
status: allFramesIntact ? 'intact' : 'corrupted', | |
blocks: frameChecks, | |
message: allFramesIntact ? 'All frames are intact.' : 'Some frames are corrupted or have shifted.', | |
}; | |
if (allFramesIntact && blocks.length > 0) { | |
console.log(`Frames are intact for ${filePath}. Updating patch content...`); | |
this.updatePatchBlocks(patch, blocks, fileLines); | |
result.updated = true; | |
} | |
results.push(result); | |
} | |
const hasUpdates = results.some(result => result.updated); | |
if (hasUpdates) { | |
const updatedPatchContent = this.reconstructPatch(patches); | |
this.savePatch(patchPath, updatedPatchContent); | |
console.log('✅ Patch updated successfully!'); | |
} | |
return results; | |
} catch (error) { | |
throw new Error(`Verification failed: ${error.message}`); | |
} | |
} | |
/** | |
* Checks a single frame (top or bottom) against the content of the target file. | |
* @param {Array<string>} fileLines - The lines of the target file. | |
* @param {Object} frame - The frame object to check. | |
* @param {('top'|'bottom')} position - The position of the frame. | |
* @param {Object} block - The modification block this frame belongs to. | |
* @returns {Object} A result object indicating if the frame is intact. | |
*/ | |
checkFrame(fileLines, frame, position, block) { | |
if (!frame) { | |
return { | |
intact: true, // No frame to check, so it's considered intact. | |
message: `Frame ${position} not present in patch hunk.`, | |
expected: null, | |
actual: null, | |
}; | |
} | |
// Use the line number from the frame object, which is calculated during block identification. | |
const frameLineIndex = (position === 'top' ? frame.oldLineNumber : frame.oldLineNumber) -1; | |
if (frameLineIndex < 0 || frameLineIndex >= fileLines.length) { | |
return { | |
intact: false, | |
message: `Frame ${position} is outside the file boundaries (line ${frameLineIndex + 1}).`, | |
expected: frame.content, | |
actual: null, | |
lineNumber: frameLineIndex + 1, | |
}; | |
} | |
const actualContent = fileLines[frameLineIndex]; | |
const intact = actualContent.trim() === frame.content.trim(); | |
return { | |
intact: intact, | |
message: intact ? `Frame ${position} is intact.` : `Frame ${position} has been modified.`, | |
expected: frame.content, | |
actual: actualContent, | |
lineNumber: frameLineIndex + 1, | |
}; | |
} | |
/** | |
* Generates a detailed human-readable report from the verification results. | |
* @param {Array<Object>} results - The array of verification result objects. | |
* @returns {string} A formatted report string. | |
*/ | |
generateReport(results) { | |
let report = '=== FRAME INTEGRITY VERIFICATION REPORT ===\n\n'; | |
for (const result of results) { | |
report += `File: ${result.file}\n`; | |
report += `Status: ${result.status.toUpperCase()}`; | |
if (result.updated) { | |
report += ' (UPDATED)'; | |
} | |
report += '\n'; | |
report += `Message: ${result.message}\n`; | |
if (result.blocks && result.status !== 'intact') { | |
report += `\nModification Blocks Found: ${result.blocks.length}\n`; | |
result.blocks.forEach((block, index) => { | |
if(block.topFrame.intact && block.bottomFrame.intact) return; | |
report += `\n Block ${index + 1} (${block.blockType}):\n`; | |
report += ` Top Frame: ${block.topFrame.intact ? 'INTACT' : 'CORRUPTED'}`; | |
if (block.topFrame.lineNumber) { | |
report += ` (line ${block.topFrame.lineNumber})`; | |
} | |
report += '\n'; | |
if (!block.topFrame.intact) { | |
report += ` Expected: "${block.topFrame.expected}"\n`; | |
report += ` Found: "${block.topFrame.actual}"\n`; | |
} | |
report += ` Bottom Frame: ${block.bottomFrame.intact ? 'INTACT' : 'CORRUPTED'}`; | |
if (block.bottomFrame.lineNumber) { | |
report += ` (line ${block.bottomFrame.lineNumber})`; | |
} | |
report += '\n'; | |
if (!block.bottomFrame.intact) { | |
report += ` Expected: "${block.bottomFrame.expected}"\n`; | |
report += ` Found: "${block.bottomFrame.actual}"\n`; | |
} | |
}); | |
} | |
report += '\n' + '='.repeat(50) + '\n\n'; | |
} | |
return report; | |
} | |
} | |
/** | |
* The main function to run the patch analyzer from the command line. | |
*/ | |
async function main() { | |
const analyzer = new PatchAnalyzer(); | |
try { | |
const args = process.argv.slice(2); | |
if (args.length === 0 || args.includes('--help')) { | |
console.log(`\nUsage: node t.js <patch_file> [target_file]\n\nAnalyzes a patch file to verify the integrity of its context frames against a target file.\nIf the frames are intact, it updates the patch with the current content from the target file.\n\nArguments:\n <patch_file> The path to the .patch file to analyze.\n [target_file] Optional. The path to the file the patch applies to. \n If not provided, the script will infer it from the patch content.\n\nExample:\n node t.js my-feature.patch src/component.js\n`); | |
process.exit(0); | |
} | |
const patchPath = args[0]; | |
const targetFile = args[1]; // Optional | |
console.log(`Analyzing patch: ${patchPath}`); | |
if (targetFile) { | |
console.log(`Against target file: ${targetFile}`); | |
} | |
const results = await analyzer.verifyFrameIntegrity(patchPath, targetFile); | |
const report = analyzer.generateReport(results); | |
console.log(report); | |
const reportPath = 'frame_integrity_report.txt'; | |
fs.writeFileSync(reportPath, report); | |
console.log(`Report saved to: ${reportPath}`); | |
const intactCount = results.filter(r => r.status === 'intact').length; | |
const updatedCount = results.filter(r => r.updated).length; | |
const errorCount = results.filter(r => r.status === 'error').length; | |
console.log(`\n📊 Statistics:`); | |
console.log(` Files with intact frames: ${intactCount}/${results.length}`); | |
console.log(` Patches updated: ${updatedCount}`); | |
console.log(` Errors (e.g., file not found): ${errorCount}`); | |
if (updatedCount > 0) { | |
console.log(`\n✨ Patches have been synchronized with the current file content!`); | |
} | |
} catch (error) { | |
console.error('Error:', error.message); | |
process.exit(1); | |
} | |
} | |
// Export the class for use as a module | |
module.exports = PatchAnalyzer; | |
// If run directly from the command line, execute the main function | |
if (require.main === module) { | |
main(); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment