|
/** |
|
* Offline patch detector — uses SHA-256 file hashing, no network calls. |
|
* |
|
* Modes: |
|
* node detect-patches.js --baseline Save hashes as ground truth |
|
* node detect-patches.js --baseline --files Save hashes + file snapshots for line diffs |
|
* node detect-patches.js Compare current state against baseline |
|
* node detect-patches.js --save <filename> Save detection results to file |
|
* |
|
* After detecting changed packages: |
|
* npx patch-package@6.5.1 <package-name> |
|
*/ |
|
|
|
const crypto = require("crypto"); |
|
const fs = require("fs"); |
|
const path = require("path"); |
|
|
|
// ─── Config ──────────────────────────────────────────────────────────────── |
|
|
|
const BASELINE_FILE = "./patches/.hashes.json"; |
|
const SNAPSHOTS_DIR = "./patches/.snapshots"; |
|
|
|
const GIT_PREFIXES = [ |
|
"git+", |
|
"git://", |
|
"https://github", |
|
"http://github", |
|
"file:", |
|
"npm:", |
|
]; |
|
|
|
const SKIP_DIRS = new Set([ |
|
"__mocks__", |
|
"__tests__", |
|
".circleci", |
|
".git", |
|
".github", |
|
".gradle", |
|
".idea", |
|
".kotlin", |
|
"build", |
|
"gradle", |
|
"node_modules", |
|
]); |
|
|
|
const SKIP_FILES = new Set([ |
|
".DS_Store", |
|
".eslintrc.js", |
|
".eslintrc.json", |
|
".eslintrc", |
|
".gitignore", |
|
".npmignore", |
|
".prettierrc", |
|
"AUTHORS", |
|
"CHANGELOG", |
|
"Gemfile.lock", |
|
"gradlew", |
|
"HISTORY", |
|
"LICENCE", |
|
"LICENSE.txt", |
|
"license", |
|
"LICENSE", |
|
"NOTICE", |
|
"package-lock.json", |
|
"podfile.lock", |
|
"readme", |
|
"README", |
|
"Thumbs.db", |
|
"yarn.lock", |
|
]); |
|
|
|
const SKIP_EXTS = new Set([ |
|
".bat", |
|
".cmd", |
|
".eot", |
|
".gif", |
|
".ico", |
|
".jpeg", |
|
".jpg", |
|
".map", // source maps |
|
".md", |
|
".mp3", |
|
".mp4", |
|
".ogg", |
|
".otf", |
|
".patch", // patch files themselves |
|
".pbxproj", |
|
".png", |
|
".ttf", |
|
".wav", |
|
".webp", |
|
".woff", |
|
".woff2", |
|
]); |
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────── |
|
|
|
function shouldSkip(name, isDir) { |
|
if (isDir) return SKIP_DIRS.has(name); |
|
if (SKIP_FILES.has(name)) return true; |
|
const ext = path.extname(name).toLowerCase(); |
|
return SKIP_EXTS.has(ext); |
|
} |
|
|
|
function walkFiles(dir, base, result) { |
|
base = base || dir; |
|
result = result || []; |
|
let entries; |
|
try { |
|
entries = fs.readdirSync(dir, { withFileTypes: true }); |
|
} catch { |
|
return result; |
|
} |
|
for (const entry of entries) { |
|
if (shouldSkip(entry.name, entry.isDirectory())) continue; |
|
const full = path.join(dir, entry.name); |
|
if (entry.isDirectory()) { |
|
walkFiles(full, base, result); |
|
} else if (entry.isFile()) { |
|
result.push(path.relative(base, full).replace(/\\/g, "/")); |
|
} |
|
} |
|
return result; |
|
} |
|
|
|
function hashPackage(pkgPath) { |
|
const files = walkFiles(pkgPath).sort(); |
|
const hasher = crypto.createHash("sha256"); |
|
for (const relPath of files) { |
|
let content; |
|
try { |
|
content = fs.readFileSync(path.join(pkgPath, relPath)); |
|
} catch { |
|
continue; |
|
} |
|
hasher.update(relPath); |
|
hasher.update(":"); |
|
hasher.update(content); |
|
hasher.update("\n"); |
|
} |
|
return { hash: hasher.digest("hex"), files }; |
|
} |
|
|
|
function getNpmPackages() { |
|
const pkg = require("./package.json"); |
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; |
|
return Object.entries(allDeps).filter( |
|
([, ver]) => !GIT_PREFIXES.some((p) => ver.startsWith(p)), |
|
); |
|
} |
|
|
|
function getInstalledVersion(pkgPath) { |
|
try { |
|
return JSON.parse( |
|
fs.readFileSync(path.join(pkgPath, "package.json"), "utf8"), |
|
).version; |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
const LINE = "─".repeat(60); |
|
|
|
// ─── Output management ──────────────────────────────────────────────────── |
|
|
|
let outputBuffer = []; |
|
let outputFile = null; |
|
|
|
// Parse --save flag |
|
const args = process.argv.slice(2); |
|
const saveIdx = args.indexOf("--save"); |
|
if (saveIdx !== -1 && saveIdx + 1 < args.length) { |
|
outputFile = args[saveIdx + 1]; |
|
} |
|
|
|
// Override console.log to capture output |
|
const originalLog = console.log; |
|
console.log = function (...args) { |
|
const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" "); |
|
originalLog.apply(console, [text]); |
|
outputBuffer.push(text); |
|
}; |
|
|
|
function writeOutputToFile() { |
|
if (!outputFile) return; |
|
fs.writeFileSync(outputFile, outputBuffer.join("\n"), "utf8"); |
|
originalLog(`\n✓ Results saved to: ${outputFile}`); |
|
} |
|
|
|
// ─── Snapshot helpers (for line-level diff) ──────────────────────────────── |
|
|
|
function saveSnapshot(pkgName, relPath, content) { |
|
// Skip binary files (null bytes indicate non-text content) |
|
if (content.indexOf(0) !== -1) return; |
|
|
|
const snapshotPath = path.join(SNAPSHOTS_DIR, pkgName, relPath); |
|
const snapshotDir = path.dirname(snapshotPath); |
|
fs.mkdirSync(snapshotDir, { recursive: true }); |
|
fs.writeFileSync(snapshotPath, content); |
|
} |
|
|
|
function loadSnapshot(pkgName, relPath) { |
|
try { |
|
return fs.readFileSync(path.join(SNAPSHOTS_DIR, pkgName, relPath), "utf8"); |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
function buildLCS(a, b) { |
|
// Guard against pathological cases (two very different large files) |
|
if (a.length * b.length > 400000) return null; |
|
|
|
const m = a.length; |
|
const n = b.length; |
|
const dp = Array(m + 1) |
|
.fill(0) |
|
.map(() => Array(n + 1).fill(0)); |
|
|
|
for (let i = 1; i <= m; i++) { |
|
for (let j = 1; j <= n; j++) { |
|
if (a[i - 1] === b[j - 1]) { |
|
dp[i][j] = dp[i - 1][j - 1] + 1; |
|
} else { |
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); |
|
} |
|
} |
|
} |
|
return dp; |
|
} |
|
|
|
function backtrack(dp, oldLines, newLines) { |
|
const ops = []; |
|
let i = oldLines.length; |
|
let j = newLines.length; |
|
|
|
while (i > 0 || j > 0) { |
|
if (i === 0) { |
|
ops.unshift({ op: "+", text: newLines[j - 1], idx: j }); |
|
j--; |
|
} else if (j === 0) { |
|
ops.unshift({ op: "-", text: oldLines[i - 1], idx: i }); |
|
i--; |
|
} else if (oldLines[i - 1] === newLines[j - 1]) { |
|
ops.unshift({ op: "=", text: oldLines[i - 1], idx: i }); |
|
i--; |
|
j--; |
|
} else if (dp[i - 1][j] > dp[i][j - 1]) { |
|
ops.unshift({ op: "-", text: oldLines[i - 1], idx: i }); |
|
i--; |
|
} else { |
|
ops.unshift({ op: "+", text: newLines[j - 1], idx: j }); |
|
j--; |
|
} |
|
} |
|
return ops; |
|
} |
|
|
|
function diffLines(oldText, newText, contextLines = 2) { |
|
// Normalize line endings |
|
oldText = oldText.replace(/\r\n/g, "\n"); |
|
newText = newText.replace(/\r\n/g, "\n"); |
|
|
|
const oldLines = oldText.split("\n"); |
|
const newLines = newText.split("\n"); |
|
|
|
// Find common prefix |
|
let prefixLen = 0; |
|
while ( |
|
prefixLen < oldLines.length && |
|
prefixLen < newLines.length && |
|
oldLines[prefixLen] === newLines[prefixLen] |
|
) { |
|
prefixLen++; |
|
} |
|
|
|
// Find common suffix |
|
let suffixLen = 0; |
|
while ( |
|
suffixLen < oldLines.length - prefixLen && |
|
suffixLen < newLines.length - prefixLen && |
|
oldLines[oldLines.length - 1 - suffixLen] === |
|
newLines[newLines.length - 1 - suffixLen] |
|
) { |
|
suffixLen++; |
|
} |
|
|
|
// Extract middle sections |
|
const oldMid = oldLines.slice(prefixLen, oldLines.length - suffixLen); |
|
const newMid = newLines.slice(prefixLen, newLines.length - suffixLen); |
|
|
|
// If no changes in middle, return empty string |
|
if (oldMid.length === 0 && newMid.length === 0) return ""; |
|
|
|
// Build LCS |
|
const dp = buildLCS(oldMid, newMid); |
|
|
|
// If too large, return truncated message |
|
if (dp === null) { |
|
return "@@ file too large for inline diff @@"; |
|
} |
|
|
|
// Backtrack to get operations |
|
const ops = backtrack(dp, oldMid, newMid); |
|
|
|
// Build hunks with context |
|
const hunks = []; |
|
let currentHunk = null; |
|
let oldIdx = 1; |
|
let newIdx = 1; |
|
|
|
for (const op of ops) { |
|
if (op.op === "=") { |
|
// Unchanged line |
|
if (currentHunk && currentHunk.lines.length > 0) { |
|
// Check if this line starts a new section (too far from last change) |
|
const lastOp = currentHunk.lines[currentHunk.lines.length - 1]; |
|
if (lastOp.op !== "=" || oldIdx - lastOp.oldIdx > contextLines * 2) { |
|
hunks.push(currentHunk); |
|
currentHunk = null; |
|
} |
|
} |
|
|
|
if (!currentHunk) { |
|
currentHunk = { |
|
oldStart: Math.max(1, oldIdx - contextLines), |
|
newStart: Math.max(1, newIdx - contextLines), |
|
lines: [], |
|
}; |
|
} |
|
|
|
currentHunk.lines.push({ op: " ", text: op.text, oldIdx, newIdx }); |
|
oldIdx++; |
|
newIdx++; |
|
} else { |
|
// Changed line |
|
if (!currentHunk) { |
|
currentHunk = { |
|
oldStart: Math.max(1, oldIdx - contextLines), |
|
newStart: Math.max(1, newIdx - contextLines), |
|
lines: [], |
|
}; |
|
} |
|
|
|
if (op.op === "-") { |
|
currentHunk.lines.push({ op: "-", text: op.text, oldIdx }); |
|
oldIdx++; |
|
} else { |
|
currentHunk.lines.push({ op: "+", text: op.text, newIdx }); |
|
newIdx++; |
|
} |
|
} |
|
} |
|
|
|
if (currentHunk) { |
|
// Add trailing context |
|
while (currentHunk.lines.length < contextLines * 2 + 2) { |
|
if (oldIdx <= oldLines.length || newIdx <= newLines.length) { |
|
const line = |
|
oldIdx <= oldLines.length ? oldLines[oldIdx] : newLines[newIdx]; |
|
currentHunk.lines.push({ op: " ", text: line, oldIdx, newIdx }); |
|
if (oldIdx <= oldLines.length) oldIdx++; |
|
if (newIdx <= newLines.length) newIdx++; |
|
} else { |
|
break; |
|
} |
|
} |
|
hunks.push(currentHunk); |
|
} |
|
|
|
// Simplest format: just show removed (-) and added (+) lines |
|
const changedOps = ops.filter((o) => o.op !== "="); |
|
if (changedOps.length === 0) return ""; |
|
|
|
const result = []; |
|
const firstOp = ops.indexOf(changedOps[0]); |
|
const lineNum = prefixLen + firstOp + 1; |
|
|
|
result.push(`@@ Line ${lineNum} @@`); |
|
for (const op of changedOps) { |
|
result.push(op.op + op.text); |
|
} |
|
|
|
return result.join("\n"); |
|
} |
|
|
|
// ─── Baseline mode ───────────────────────────────────────────────────────── |
|
|
|
function runBaseline() { |
|
const packages = getNpmPackages(); |
|
console.log(`\nGenerating baseline for ${packages.length} npm packages...\n`); |
|
console.log("(git-fork packages skipped — intentional forks)\n"); |
|
console.log(LINE); |
|
|
|
const baseline = {}; |
|
let count = 0; |
|
|
|
for (const [name] of packages) { |
|
const pkgPath = path.join("./node_modules", name); |
|
if (!fs.existsSync(pkgPath)) continue; |
|
|
|
const version = getInstalledVersion(pkgPath); |
|
if (!version) continue; |
|
|
|
process.stdout.write(` Hashing ${name}@${version}...`); |
|
const { hash } = hashPackage(pkgPath); |
|
baseline[name] = { version, hash }; |
|
process.stdout.write(" done\n"); |
|
count++; |
|
} |
|
|
|
if (!fs.existsSync("./patches")) fs.mkdirSync("./patches"); |
|
fs.writeFileSync(BASELINE_FILE, JSON.stringify(baseline, null, 2)); |
|
|
|
console.log(`\n${LINE}`); |
|
console.log(`\nBaseline saved: ${BASELINE_FILE}`); |
|
console.log(`Captured ${count} packages.\n`); |
|
console.log("To enable line-level diffs, run with --files flag:"); |
|
console.log(" node detect-patches.js --baseline --files\n"); |
|
console.log("Then use:"); |
|
console.log(" node detect-patches.js at any time to detect changes.\n"); |
|
} |
|
|
|
// ─── Detect mode ─────────────────────────────────────────────────────────── |
|
|
|
function getChangedFiles(pkgPath, expectedFiles) { |
|
const current = walkFiles(pkgPath).sort(); |
|
const expectedSet = new Set(expectedFiles); |
|
const currentSet = new Set(current); |
|
|
|
const added = current.filter((f) => !expectedSet.has(f)); |
|
const removed = expectedFiles.filter((f) => !currentSet.has(f)); |
|
|
|
const modified = []; |
|
for (const relPath of current) { |
|
if (!expectedSet.has(relPath)) continue; |
|
// file exists in both — check content hash |
|
let content; |
|
try { |
|
content = fs.readFileSync(path.join(pkgPath, relPath)); |
|
} catch { |
|
continue; |
|
} |
|
const fileHash = crypto.createHash("sha256").update(content).digest("hex"); |
|
// we don't store per-file hashes in baseline — reuse hashPackage but targeted |
|
// instead: just include it in "modified" list and let user review via patch-package |
|
// To avoid re-reading, this is the full diff pass |
|
modified.push(relPath); // placeholder — filtered below |
|
} |
|
// re-hash each file vs a fresh package baseline isn't possible without registry |
|
// so: report added + removed as definite; for modified, compare file-level hashes |
|
return { added, removed }; |
|
} |
|
|
|
function runDetect() { |
|
if (!fs.existsSync(BASELINE_FILE)) { |
|
console.error( |
|
"\nNo baseline found. Run first:\n node detect-patches.js --baseline\n", |
|
); |
|
process.exit(1); |
|
} |
|
|
|
const baseline = JSON.parse(fs.readFileSync(BASELINE_FILE, "utf8")); |
|
const total = Object.keys(baseline).length; |
|
|
|
console.log(`\nComparing ${total} packages against baseline...\n`); |
|
console.log(LINE); |
|
|
|
const patched = []; |
|
const upgraded = []; |
|
const missing = []; |
|
|
|
for (const [name, saved] of Object.entries(baseline)) { |
|
const pkgPath = path.join("./node_modules", name); |
|
|
|
if (!fs.existsSync(pkgPath)) { |
|
missing.push(name); |
|
continue; |
|
} |
|
|
|
const currentVersion = getInstalledVersion(pkgPath); |
|
const { hash: currentHash, files: currentFiles } = hashPackage(pkgPath); |
|
|
|
if (currentHash !== saved.hash) { |
|
const versionChanged = currentVersion !== saved.version; |
|
const savedFiles = saved.files || []; |
|
const currentSet = new Set(currentFiles); |
|
const savedSet = new Set(savedFiles); |
|
|
|
const added = currentFiles.filter((f) => !savedSet.has(f)); |
|
const removed = savedFiles.filter((f) => !currentSet.has(f)); |
|
const common = currentFiles.filter((f) => savedSet.has(f)); |
|
|
|
// find modified files by individual hashing |
|
const modified = []; |
|
for (const relPath of common) { |
|
let content; |
|
try { |
|
content = fs.readFileSync(path.join(pkgPath, relPath)); |
|
} catch { |
|
continue; |
|
} |
|
const fHash = crypto.createHash("sha256").update(content).digest("hex"); |
|
const savedFHash = saved.fileHashes && saved.fileHashes[relPath]; |
|
if (savedFHash && fHash !== savedFHash) modified.push(relPath); |
|
} |
|
|
|
const changeInfo = { |
|
name, |
|
savedVersion: saved.version, |
|
currentVersion, |
|
versionChanged, |
|
added, |
|
removed, |
|
modified: modified.length |
|
? modified |
|
: common.length |
|
? [ |
|
"(edit detected — re-run --baseline with --files to see exact files)", |
|
] |
|
: [], |
|
pkgPath, |
|
}; |
|
|
|
if (versionChanged) { |
|
upgraded.push(changeInfo); |
|
} else { |
|
patched.push(changeInfo); |
|
} |
|
} |
|
} |
|
|
|
console.log(""); |
|
|
|
if (missing.length) { |
|
console.log(`Missing from node_modules (${missing.length}):`); |
|
missing.forEach((n) => console.log(` ! ${n}`)); |
|
console.log(""); |
|
} |
|
|
|
if (patched.length === 0 && upgraded.length === 0) { |
|
console.log("No changes detected. node_modules matches baseline.\n"); |
|
} else { |
|
if (patched.length > 0) { |
|
console.log(`Found ${patched.length} manually patched package(s):\n`); |
|
for (const { |
|
name, |
|
currentVersion, |
|
added, |
|
removed, |
|
modified, |
|
pkgPath, |
|
} of patched) { |
|
console.log(`PATCHED: ${name}@${currentVersion}`); |
|
added.forEach((f) => console.log(` + ${f}`)); |
|
removed.forEach((f) => console.log(` - ${f}`)); |
|
|
|
// Show modified files with line-level diffs |
|
for (const relPath of modified) { |
|
console.log(` ~ ${relPath}`); |
|
const snapshot = loadSnapshot(name, relPath); |
|
if (snapshot !== null) { |
|
try { |
|
const currentContent = fs.readFileSync( |
|
path.join(pkgPath, relPath), |
|
"utf8", |
|
); |
|
const diff = diffLines(snapshot, currentContent, 2); |
|
if (diff && diff.length > 0) { |
|
diff.split("\n").forEach((l) => console.log(` ${l}`)); |
|
} |
|
} catch { |
|
console.log(` (unable to read current file)`); |
|
} |
|
} else { |
|
console.log( |
|
` (no snapshot — re-run --baseline --files to enable line diffs)`, |
|
); |
|
} |
|
} |
|
console.log(""); |
|
} |
|
|
|
console.log(LINE); |
|
console.log( |
|
"\nTo create patch files for manually patched packages, run:\n", |
|
); |
|
patched.forEach(({ name }) => |
|
console.log(` npx patch-package@6.5.1 ${name}`), |
|
); |
|
console.log(""); |
|
} |
|
|
|
if (upgraded.length > 0) { |
|
console.log("\n" + "─".repeat(60)); |
|
console.log( |
|
`\nVersion upgraded (NOT manually patched — skip patch-package):`, |
|
); |
|
console.log(""); |
|
for (const { name, savedVersion, currentVersion } of upgraded) { |
|
console.log(` ${name} ${savedVersion} → ${currentVersion}`); |
|
} |
|
console.log( |
|
"\nTo update the baseline, run:\n node detect-patches.js --baseline --files\n", |
|
); |
|
} |
|
} |
|
} |
|
|
|
// ─── Baseline with per-file hashes (--files flag) ────────────────────────── |
|
|
|
function runBaselineWithFiles() { |
|
const packages = getNpmPackages(); |
|
console.log( |
|
`\nGenerating detailed baseline (per-file hashes + snapshots) for ${packages.length} packages...\n`, |
|
); |
|
console.log(LINE); |
|
|
|
const baseline = {}; |
|
let count = 0; |
|
let snapshotCount = 0; |
|
|
|
for (const [name] of packages) { |
|
const pkgPath = path.join("./node_modules", name); |
|
if (!fs.existsSync(pkgPath)) continue; |
|
const version = getInstalledVersion(pkgPath); |
|
if (!version) continue; |
|
|
|
process.stdout.write(` ${name}@${version}...`); |
|
const files = walkFiles(pkgPath).sort(); |
|
const fileHashes = {}; |
|
const hasher = crypto.createHash("sha256"); |
|
|
|
for (const relPath of files) { |
|
let content; |
|
try { |
|
content = fs.readFileSync(path.join(pkgPath, relPath)); |
|
} catch { |
|
continue; |
|
} |
|
const fHash = crypto.createHash("sha256").update(content).digest("hex"); |
|
fileHashes[relPath] = fHash; |
|
hasher.update(relPath); |
|
hasher.update(":"); |
|
hasher.update(content); |
|
hasher.update("\n"); |
|
|
|
// Save snapshot for later diff |
|
saveSnapshot(name, relPath, content); |
|
snapshotCount++; |
|
} |
|
|
|
baseline[name] = { version, hash: hasher.digest("hex"), files, fileHashes }; |
|
process.stdout.write(" done\n"); |
|
count++; |
|
} |
|
|
|
if (!fs.existsSync("./patches")) fs.mkdirSync("./patches"); |
|
fs.writeFileSync(BASELINE_FILE, JSON.stringify(baseline, null, 2)); |
|
|
|
console.log(`\n${LINE}`); |
|
console.log(`\nDetailed baseline saved: ${BASELINE_FILE}`); |
|
console.log(`Captured ${count} packages with per-file hashes.`); |
|
console.log( |
|
`Saved ${snapshotCount} file snapshots to ${SNAPSHOTS_DIR}/ for line-level diffs.\n`, |
|
); |
|
} |
|
|
|
// ─── Entry ───────────────────────────────────────────────────────────────── |
|
|
|
if (args.includes("--baseline") && args.includes("--files")) { |
|
runBaselineWithFiles(); |
|
} else if (args.includes("--baseline")) { |
|
runBaseline(); |
|
} else { |
|
runDetect(); |
|
} |
|
|
|
writeOutputToFile(); |