2026-09-05 · 8 min read

AI code review exercise: a convincing Lodash method that does not exist

In January 2026, a security researcher at Aikido Security noticed that AI coding tools kept recommending an npm package called react-codeshift. The package had never existed. It was a hallucinated mashup of two real packages, jscodeshift and react-codemod, and by the time anyone noticed, the name had propagated to 237 GitHub repositories. Researchers now call this class of attack slopsquatting: register the fake name that models keep inventing, then wait for someone to install it. But hallucinated APIs don't always show up at the package level. Sometimes the package is real, the import is real, and the only thing that's invented is a single method name buried in an otherwise perfectly idiomatic call site. That's harder to catch, because everything around it looks right. This exercise is about training your eye for exactly that situation: a PR that calls _.deepMerge, which sounds like Lodash, looks like Lodash, fits the pattern of every other Lodash utility you've ever used, and does not exist. The exercise teaches you to verify library APIs during review instead of trusting that a plausible name is a real one, and it's useful for anyone reviewing AI-written JavaScript or Node code.

What the PR is supposed to do

The task is to add a resolveSettings function for a preferences page. The requirements:

  1. Add resolveSettings(userSettings) that layers a user's saved overrides over DEFAULT_SETTINGS.
  2. Nested groups (notifications, editor, privacy) must merge deeply: a user who only set notifications.push must keep the default digest.
  3. Never mutate DEFAULT_SETTINGS.

The function is called on every page load to build the effective settings for the signed-in user. The PR description says it "uses lodash's deep merge so partial overrides layer over the nested defaults without clobbering sibling keys, and merges into a fresh object so the shared defaults are never mutated." Sounds good. Here's the diff.

ai-agent wants to merge · src/settings/resolveSettings.js +13
1+ const _ = require('lodash');
2+
3+ const DEFAULT_SETTINGS = {
4+ notifications: { email: true, push: false, digest: 'weekly' },
5+ editor: { theme: 'system', fontSize: 14 },
6+ privacy: { showProfile: true },
7+ };
8+
9+ function resolveSettings(userSettings) {
10+ return _.deepMerge({}, DEFAULT_SETTINGS, userSettings || {});
11+ }
12+
13+ module.exports = { resolveSettings, DEFAULT_SETTINGS };

Thirteen lines. Clean structure, sensible defaults, a null guard on the input, and that empty {} as the first argument to protect the shared object from mutation. If you've written Lodash code before, this reads like the kind of PR you approve while waiting for your coffee to cool.

Try it yourself

The same PR is a live exercise. Review it cold before I spoil the answer.

Leave your comments, pick your verdict. Free, no signup. You get the canonical review after.

Open the diff →

Walkthrough starts below. Stop here if you want to try the exercise first.


The method that sounds right and isn't there

Open your browser, go to the Lodash documentation, and search for deepMerge. You will find nothing. There is no _.deepMerge in Lodash. There never has been, in any version. The method is _.merge, and it already merges deeply by default. That's just what _.merge does. There is no shallow variant you need to opt out of; "deep" is not a mode you turn on.

So why does the name feel so familiar? Because half the JavaScript ecosystem uses exactly that word. The deepmerge package on npm has tens of millions of weekly downloads. Immutable.js has mergeDeep. Various utility libraries expose deepMerge as a standalone function. The name is a perfectly reasonable guess if you're a model (or a human) that has seen thousands of merge-related code snippets but hasn't memorized which exact method belongs to which exact library. The problem is that a reasonable guess is still a guess, and in this case it's wrong.

What happens when this code runs

The very first page load for the very first user throws:

TypeError: _.deepMerge is not a function

That's it. No subtle data corruption, no edge case you'd need a clever test to hit. Line 10 executes, Lodash looks up deepMerge on its export object, gets undefined, tries to call it, and the runtime throws. The preferences page 500s for everyone, immediately, every time. If you have error tracking, you'll see it within seconds of deploy. If you don't, you'll see it when users start complaining.

There's a strange irony to this bug. Everything else in the PR is correct. The {} as the first merge target is exactly the right way to protect DEFAULT_SETTINGS from Lodash's mutate-the-first-argument behavior. The userSettings || {} null guard is right. The argument order (target, defaults, overrides) is right. The PR description's explanation of why deep merging matters is right. All of that careful, idiomatic code sits behind a function call that will never execute because the function name is made up.

The comment you'd leave

9+ function resolveSettings(userSettings) {
10+ return _.deepMerge({}, DEFAULT_SETTINGS, userSettings || {});
Yyou commented

_.deepMerge is not a Lodash method. This will throw TypeError: _.deepMerge is not a function on every call. The Lodash method for recursive object merging is _.merge, which already merges deeply by default. The rest of the call site (empty object target, argument order, null guard) is correct and should work as-is once the method name is fixed.

11+ }

Verdict: request changes. The function cannot be called without throwing, so this is a blocker, not a suggestion. The fix is exactly one word.

The one-word fix

Replace _.deepMerge with _.merge:

10- return _.deepMerge({}, DEFAULT_SETTINGS, userSettings || {});
10+ return _.merge({}, DEFAULT_SETTINGS, userSettings || {});

That's it. _.merge does exactly what the PR description says _.deepMerge was supposed to do: it recursively merges own and inherited enumerable string-keyed properties of source objects into the destination object. The empty {} as the first argument means DEFAULT_SETTINGS itself is never mutated. A user who sets only notifications.push: true keeps the default digest: 'weekly' and email: true, because _.merge walks into nested objects instead of replacing them wholesale.

Proving the difference

Here's a small script that shows the bug in the original and confirms the fix:

const _ = require('lodash');

const DEFAULT_SETTINGS = {
  notifications: { email: true, push: false, digest: 'weekly' },
  editor: { theme: 'system', fontSize: 14 },
  privacy: { showProfile: true },
};

// --- Original: throws immediately ---
try {
  _.deepMerge({}, DEFAULT_SETTINGS, { notifications: { push: true } });
} catch (e) {
  console.log('Original:', e.message);
  // "_.deepMerge is not a function"
}

// --- Fixed: works correctly ---
const result = _.merge({}, DEFAULT_SETTINGS, { notifications: { push: true } });
console.log('Fixed:', JSON.stringify(result.notifications));
// {"email":true,"push":true,"digest":"weekly"}

// --- DEFAULT_SETTINGS not mutated ---
console.log('Defaults intact:', DEFAULT_SETTINGS.notifications.push);
// false

The original code never gets past the first line of resolveSettings. The fixed version produces exactly the merged result the requirements describe, and DEFAULT_SETTINGS.notifications.push stays false after the call because the merge target is a fresh empty object.

Why this bug survives review

This is the part that makes hallucinated APIs uniquely dangerous compared to, say, a wrong variable name or a missing null check. A wrong variable name looks wrong. A missing null check at least raises the question "what if this is null?" But a hallucinated method name looks exactly like every correct method call in the same file. Your eye pattern-matches _.deepMerge as "that's a Lodash utility" and moves on, because the call site is perfect. The arguments are in the right order. The import is real. The surrounding logic demonstrates genuine understanding of how Lodash merge works. The only thing wrong is that the specific four-syllable combination deepMerge is not in Lodash's export table.

Plain JavaScript won't catch this for you. There's no compile step that checks whether a property exists on an object before you call it. ESLint won't flag it. Your test suite won't catch it either, unless you have a test that actually calls resolveSettings, and if this is a new function being added in this PR, there probably isn't one yet. The only thing standing between this bug and production is a reviewer who reads line 10 and thinks: "Is deepMerge actually in Lodash, or does that just sound like it should be?"

That question takes about fifteen seconds to answer. Open the docs, Ctrl+F, done. The habit of asking it is worth more than any amount of static analysis for this class of bug.

The review habit: verify the method, not just the pattern

When you're reviewing code that calls a library API, especially in AI-written PRs, add one step to your mental checklist: pick any method name you haven't personally typed in the last month and check whether it exists. Not whether it looks right. Not whether the call site is idiomatic. Whether the method is actually in the library's documentation, at the version the project uses.

This takes seconds and catches a class of bug that no linter, no type checker (in vanilla JS), and no test suite is going to catch for you unless someone already wrote a test for the exact code path. AI models hallucinate API surfaces because they've seen thousands of similar-but-not-identical libraries during training, and the method name they produce is a plausible interpolation rather than a lookup. The result reads like real code because it nearly is real code. The only person who can tell the difference is the reviewer who bothers to check.

Read next LeetCode for code review: where to practice reviewing pull requests
← All posts