When devel deletes a dependency from package.json as “unused”, it is only unused in core. Every customization/<partner> branch is a separate tree that devel’s tooling never looked at — so the removal merges conflict-free and breaks the partner at runtime. Found on FKITDEV-9197 (CIB, across vuer_oss / vuer_css / portal_css); applies to every partner devel update in every vuer repo.

Clean · clean · dead

The signature of this class: git merge-tree reports the affected files clean, yarn lint passes clean, and the process dies at require-time on boot. Every signal you would normally trust stays silent, because devel edited package.json while the partner’s require() sits in customization/ — different files, no conflict.

depcheck on devel is structurally blind to partner code

depcheck (and any “unused deps” audit) runs on the branch it is invoked on. customization/ code for a partner lives only on that partner’s branch, so it is not in the tree when the removal decision is made. The removal is correct for devel and wrong for the partner, and nothing in CI can tell you that until the partner merge — which produces no conflict, because the two sides touched different files.

The canonical case

devel commit 7a42894a chore(FKQA-304): remove unused libs (#675) in portal_css removed multer and uuid from package.json. CIB’s customization/ requires both:

PackagePartner usagePost-merge state
multercustomization/api/document-upload.jsrequire('multer'), multer.memoryStorage()Unresolvable. Genuine runtime break — the document-upload endpoint throws Cannot find module.
uuidcustomization/api/submit-login.js, customization/api/submit-registration.jsrequire('uuid'), uuid.v4()Resolves transitively (uuid@14.0.1 hoisted by another dependency). Works by accident; eslint n/no-extraneous-require is the only thing that notices.

Fix = restore the versions the partner branch declared before the merge:

"multer": "1.4.5-lts.2",
"uuid": "^10.0.0"

It cuts both ways — three outcomes, only one of which is “restore the line”

Treating this as “devel dropped a dep, put it back” is wrong roughly a third of the time. The full CIB round produced all three shapes at once.

SituationCorrect actionWhy the naive fix is wrong
devel removed it deliberately — security retirement, EOL packagePort the partner code off it.Restoring the declaration re-introduces exactly what devel spent a ticket removing.
devel removed it as merely unused in core, partner still imports itRestore the version the partner declared pre-merge.
Partner-only dependency devel never hadKEEP it.Nothing removed it — you did, by taking devel’s package.json wholesale at conflict resolution.

That third row has no detector at all. It is not a merge artefact; it is a conflict-resolution mistake, and it looks identical to a clean merge afterwards.

Verified instances — CIB 1.9.11.102 (FKITDEV-9197)

RepoPackagesRowOutcome
vuer_ossrequest, request-promise-nativedeliberatePorted to fetch. devel’s removal was a tracked security action — CIB’s own security/*.md logged “Remove of request and request-promise-native scheduled” monthly since 2025-03. The mTLS call sites needed undici (vuer-oss-global-fetch-ignores-agent-mtls).
vuer_ossclamscan, soap, xml-formatter, short-uuid, uuid, zodpartner-onlyKEPT
vuer_cssnode-fetch, passport, passport-oauth2partner-onlyKEPT — the CIB corporate-portal SSO path
portal_cssmulter, uuidunused-in-coreRestored — the canonical case above

Read the removal commit, don't just read the diff

The request row and the multer row are indistinguishable in git diff -- package.json. The only thing that separates “port it” from “restore it” is why devel removed it — which lives in the commit message, the ticket, and sometimes in the partner’s own security/*.md log.

Two failure shapes, and why the quiet one is worse

The transitive case is the real hazard

A hard-missing package (multer) fails the first time the code path runs — annoying, but self-announcing. A transitively-satisfied package (uuid) keeps working, at a version nobody chose (14.0.1 where the partner declared ^10.0.0), until the unrelated dependency that hoisted it is bumped or dropped. Then login and registration break with zero changes on the partner branch and no obvious culprit.

“It still resolves” is not a pass. Undeclared-but-hoisted is unowned. Restore the declaration.

Note the major-version drift hiding in that row: the partner declared ^10.0.0, the hoisted copy was 14.0.1. Even while “working”, the partner was running a different major than it declared.

The check — add it to the semantic sweep

Run this in Phase 1.4 of devel-update-and-release-flow, right after the deletions/renames check:

# 1. what dependency lines did the merge REMOVE?
git diff <merge>^1..<merge> -- package.json | grep '^-' | grep -v '^---'
 
# 2. for each removed package name, is it used under customization/ ?
git grep -n "require(['\"]<pkg>" -- customization/
git grep -n "from ['\"]<pkg>"    -- customization/
 
# 3. did it survive only transitively? (present in node_modules, absent from package.json)
node -e "console.log(require('<pkg>/package.json').version)"   # resolves => hoisted
 
# 4. the OTHER direction — what did the partner declare that devel never had?
#    (these must survive your conflict resolution)
git show customization/<partner>:package.json > /tmp/p.json
git show origin/devel:package.json            > /tmp/d.json
node -e "const p=require('/tmp/p.json').dependencies,d=require('/tmp/d.json').dependencies;
         console.log(Object.keys(p).filter(k=>!(k in d)))"

Step 3 is the one people skip. A package that resolves at runtime but is not in dependencies is a finding, not a pass.

Step 4 is the one nobody thinks to run, because nothing broke — yet. It is the only guard against silently deleting a partner-only dependency while resolving the package.json conflict.

eslint already has a detector for the quiet case

n/no-extraneous-require (from eslint-plugin-n) flags exactly “you require something you did not declare”. In repos where yarn lint runs --max-warnings 0, this fires on the transitive case for free — which is how the uuid half of the CIB break was caught. Do not silence it.

Why this class keeps appearing

It is the same shape as the other conflict-free-merge breaks already catalogued:

Breakdevel sidePartner sideDetector
Dependency removal (this note)package.json dep deleted as unusedcustomization/ requires itn/no-extraneous-require, or runtime
Partner-only dep dropped at conflict resolution (this note)— (devel never had it)customization/ requires itnothing — step 4 of the check
nusz-devel-update-2026-06-16-lint-merge-fix.js.ts renameextensionless require()n/no-missing-require
mjml-v5-esm-breaks-commonjs-email-templatesloader goes ESM-onlyCommonJS require() in templatesruntime (ReferenceError)
FKITDEV-9194core file deleted / auth() refactored awayoverride binds by path / calls the old name--diff-filter=D, then runtime

Common denominator: git reconciles text, and the two sides never touch the same line. The merge is clean and the product is broken. See breakage-risks.