Skip to content

Instantly share code, notes, and snippets.

@4sskick
Last active May 11, 2026 06:16
Show Gist options
  • Select an option

  • Save 4sskick/9d038d9c34cf5cfe5d5a4573deeef47c to your computer and use it in GitHub Desktop.

Select an option

Save 4sskick/9d038d9c34cf5cfe5d5a4573deeef47c to your computer and use it in GitHub Desktop.
Inherited a React Native app held together by edited node_modules and a prayer? I built script to finds the patches you never knew you had.

We ship node_modules by hand. So I built this.

I work on a React Native app that's old enough to remember when class components were the default. Some packages in node_modules/ have been edited by hand — not patched, not forked, literally opened in a text editor and saved. The "fixes" have been there for years. Nobody kept track of which files. And whenever we onboard a new dev, we hand them a 250 MB .rar of the entire node_modules/ folder, because running yarn install would wipe everything out.

Our setup runs like an old lady. Not in a mean way — it works, it gets to the store and back, it just shouldn't be doing what it's doing in 2026. We have a postinstall hook that mostly works on prayer. We have a Confluence page from 2021 that lists "which packages are safe to reinstall." Half the packages on that list no longer exist.

Somewhere in the last few years, 12+ packages accumulated hand-edits. Some are one-liners. Some are entire sections of code commented out or rewritten. The senior dev who wrote half of them left two years ago. The edits stayed.

The Tuesday it broke

Someone needed to add lodash. They ran yarn install. Five hours later production was on fire and three of us were doing line-by-line git status archaeology trying to figure out which packages got destroyed. Turns out: most of them. We didn't have an inventory. Nobody did.

We spent the afternoon manually restoring files from an old laptop's backup. That's when I realized we needed to know what we had broken before we could fix it.

Why we can't just...

I know about patch-package. We want to use it. The problem is you can't run patch-package on something if you don't know what changed in the first place. We had years of edits, no baseline, no inventory. We needed to see what was patched before we could patch it properly.

Forking each library sounded good until I counted them. Twelve libraries to fork means twelve libraries to maintain. We barely have time for one.

Upgrading React Native? That solves nothing. Half the patches are workarounds for RN bugs we don't have time to retest against a new version. The other half are Android API level changes that would need redoing anyway.

What I built

So I wrote a script. It's about 600 lines of Node, zero dependencies, runs on anything from Node 12 up. It walks node_modules/, hashes every file with SHA-256, and saves a baseline. Later, when you want to know what was edited, it re-hashes, compares, and shows you exactly which lines changed in which files.

Here's what real output looks like:

PATCHED: react-native-share@7.1.1
  ~ android/src/main/java/cl/json/social/TargetChosenReceiver.java
      @@ Line 4 @@
      +import android.app.DownloadManager;
      -            context.registerReceiver(sLastRegisteredReceiver, new IntentFilter(sTargetChosenReceiveAction));
      +            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
      +                context.registerReceiver(sLastRegisteredReceiver, new IntentFilter(sTargetChosenReceiveAction), Context.RECEIVER_EXPORTED);
      +            } else {
      +                context.registerReceiver(sLastRegisteredReceiver, new IntentFilter(sTargetChosenReceiveAction));
      +            }

That's a real patch. Android 13 API compatibility. Someone added that by hand. The script found it, showed the exact lines, and spit out npx patch-package@6.5.1 react-native-share so we can lock it in properly.

The version thing

One thing that mattered more than I expected: the script distinguishes between "this package was upgraded" and "this package was hand-edited." A bumped version changes the hash. Without that distinction, every routine upgrade would flag as a manual patch. So now:

  • PATCHED: axios@0.22.0 — same version as baseline, but content changed. Actually needs patching.
  • VERSION UPGRADED: axios 0.18.0 → 0.22.0 — version doesn't match baseline. Skip the patch suggestion.

That one feature cut down false positives by a lot.

How to use it

# First time: save baseline with snapshots
node detect-patches.js --baseline --files

# Detect what changed
node detect-patches.js

# Save results to a file
node detect-patches.js --save results.txt

It outputs a list of patched packages and the exact commands to run patch-package on each one.

What this is (and isn't)

This isn't a victory lap. The right answer is "don't edit node_modules." If you're starting a project, please don't do what we did. But if you've inherited a codebase that already lives like this, you need a bridge to get off that path.

Step one is knowing what you broke. This script does step one.

Step two is converting it to real patches with patch-package. That's on you.

Step three is, maybe, someday, an upgrade or a fork or just accepting this is how the codebase works. We're between step one and two.

Real numbers

  • 101 packages scanned
  • 33 packages with manual edits detected
  • 986 lines of detailed output
  • Zero npm dependencies
  • Runs on Node 12+

The script is shorter than most React component files I've seen this year.

If your team also does this

You have my sympathy. And this script. The files are all in this gist.

If your team doesn't do this — please go appreciate that today.

/**
* 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();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment