Skip to content

Commit

Permalink
Explain ERESOLVE errors
Browse files Browse the repository at this point in the history
When peerDependencies conflict, Arborist is now providing details of the
nodes and their reasons for inclusion on the Error object, including
whether or not this resolution error could be overridden using the
--force flag.

Print this data out in a minimal way as a warning if we override an
ERESOLVE forcefully.  When the ERESOLVE causes the install to fail,
print a somewhat longer message, and save a MUCH longer full report to
the cache folder.

PR-URL: #1761
Credit: @isaacs
Close: #1761
Reviewed-by: @darcyclarke, @ruyadorno
  • Loading branch information
isaacs committed Sep 4, 2020
1 parent 371f0f0 commit 2a4e2e9
Show file tree
Hide file tree
Showing 9 changed files with 3,891 additions and 24 deletions.
7 changes: 7 additions & 0 deletions lib/utils/error-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ const { format } = require('util')
const { resolve } = require('path')
const nameValidator = require('validate-npm-package-name')
const npmlog = require('npmlog')
const { report: explainEresolve } = require('./explain-eresolve.js')

module.exports = (er) => {
const short = []
const detail = []
switch (er.code) {
case 'ERESOLVE':
short.push(['ERESOLVE', er.message])
detail.push(['', ''])
detail.push(['', explainEresolve(er)])
break

case 'ENOLOCK': {
const cmd = npm.command || ''
short.push([cmd, 'This command requires an existing lockfile.'])
Expand Down
148 changes: 148 additions & 0 deletions lib/utils/explain-eresolve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// this is called when an ERESOLVE error is caught in the error-handler,
// or when there's a log.warn('eresolve', msg, explanation), to turn it
// into a human-intelligible explanation of what's wrong and how to fix.
//
// TODO: abstract out the explainNode methods into a separate util for
// use by a future `npm explain <path || spec>` command.

const npm = require('../npm.js')
const { writeFileSync } = require('fs')
const { resolve } = require('path')

const chalk = require('chalk')
const nocolor = {
bold: s => s,
dim: s => s
}

// expl is an explanation object that comes from Arborist. It looks like:
// {
// dep: {
// whileInstalling: {
// explanation of the thing being installed when we hit the conflict
// },
// name,
// version,
// dependents: [
// things depending on this node (ie, reason for inclusion)
// { name, version, dependents }, ...
// ]
// }
// current: {
// explanation of the current node that already was in the tree conflicting
// }
// peerConflict: {
// explanation of the peer dependency that couldn't be added, or null
// }
// fixWithForce: Boolean - can we use --force to push through this?
// type: type of the edge that couldn't be met
// isPeer: true if the edge that couldn't be met is a peer dependency
// }
// Depth is how far we want to want to descend into the object making a report.
// The full report (ie, depth=Infinity) is always written to the cache folder
// at ${cache}/eresolve-report.txt along with full json.
const explainEresolve = (expl, color, depth) => {
const { dep, current, peerConflict } = expl

const out = []
/* istanbul ignore else - should always have this for ERESOLVEs */
if (dep.whileInstalling) {
out.push('While resolving: ' + printNode(dep.whileInstalling, color))
}

out.push(explainNode('Found:', current, depth, color))

out.push(explainNode('\nCould not add conflicting dependency:', dep, depth, color))

if (peerConflict) {
const heading = '\nConflicting peer dependency:'
const pc = explainNode(heading, peerConflict, depth, color)
out.push(pc)
}

return out.join('\n')
}

const explainNode = (heading, node, depth, color) =>
`${heading} ${printNode(node, color)}` +
explainDependents(node, depth, color)

const printNode = ({ name, version, location }, color) => {
const { bold, dim } = color ? chalk : nocolor
return `${bold(name)}@${bold(version)}` +
(location ? dim(` at ${location}`) : '')
}

const explainDependents = ({ name, dependents }, depth, color) => {
if (!dependents || !dependents.length || depth <= 0) {
return ''
}

const max = Math.ceil(depth / 2)
const messages = dependents.slice(0, max)
.map(dep => explainDependency(name, dep, depth, color))

// show just the names of the first 5 deps that overflowed the list
if (dependents.length > max) {
const names = dependents.slice(max).map(d => d.from.name)
const showNames = names.slice(0, 5)
if (showNames.length < names.length) {
showNames.push('...')
}
const show = `(${showNames.join(', ')})`
messages.push(`${names.length} more ${show}`)
}

const str = '\nfor: ' + messages.join('\nand: ')
return str.split('\n').join('\n ')
}

const explainDependency = (name, { type, from, spec }, depth, color) => {
const { bold } = color ? chalk : nocolor
return `${type} dependency ` +
`${bold(name)}@"${bold(spec)}"\nfrom: ` +
explainFrom(from, depth, color)
}

const explainFrom = (from, depth, color) => {
if (!from.name && !from.version) {
return 'the root project'
}

return printNode(from, color) +
explainDependents(from, depth - 1, color)
}

// generate a full verbose report and tell the user how to fix it
const report = (expl, depth = 4) => {
const fullReport = resolve(npm.cache, 'eresolve-report.txt')

const orForce = expl.fixWithForce ? ' or --force' : ''
const fix = `Fix the upstream dependency conflict, or retry
this command with --legacy-peer-deps${orForce}
to accept an incorrect (and potentially broken) dependency resolution.`

writeFileSync(fullReport, `# npm resolution error report
${new Date().toISOString()}
${explainEresolve(expl, false, Infinity)}
${fix}
Raw JSON explanation object:
${JSON.stringify(expl, null, 2)}
`, 'utf8')

return explainEresolve(expl, npm.color, depth) +
`\n\n${fix}\n\nSee ${fullReport} for a full report.`
}

// the terser explain method for the warning when using --force
const explain = (expl, depth = 2) => explainEresolve(expl, npm.color, depth)

module.exports = {
explain,
report
}
17 changes: 17 additions & 0 deletions lib/utils/setup-log.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
// module to set the appropriate log settings based on configs
// returns a boolean to say whether we should enable color on
// stdout or not.
//
// Also (and this is a really inexcusable kludge), we patch the
// log.warn() method so that when we see a peerDep override
// explanation from Arborist, we can replace the object with a
// highly abbreviated explanation of what's being overridden.
const log = require('npmlog')
const { explain } = require('./explain-eresolve.js')

module.exports = (config) => {
const color = config.get('color')

const { warn } = log

log.warn = (heading, ...args) => {
if (heading === 'ERESOLVE' && args[1] && typeof args[1] === 'object') {
warn(heading, args[0])
return warn('', explain(args[1]))
}
return warn(heading, ...args)
}

if (config.get('timing') && config.get('loglevel') === 'notice') {
log.level = 'timing'
} else {
Expand Down
21 changes: 21 additions & 0 deletions tap-snapshots/test-lib-utils-error-message.js-TAP.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,27 @@ Object {
}
`

exports[`test/lib/utils/error-message.js TAP explain ERESOLVE errors > must match snapshot 1`] = `
Object {
"detail": Array [
Array [
"",
"",
],
Array [
"",
"explanation",
],
],
"summary": Array [
Array [
"ERESOLVE",
"could not resolve",
],
],
}
`

exports[`test/lib/utils/error-message.js TAP just simple messages > must match snapshot 1`] = `
Object {
"detail": Array [],
Expand Down
Loading

0 comments on commit 2a4e2e9

Please sign in to comment.