|
const path = require('path'); |
|
const fs = require('fs'); |
|
const glob = require('globby'); |
|
const memoize = require('lodash/memoize'); |
|
|
|
/** |
|
* @typedef {Object} Metadatum |
|
* @property {string} version |
|
* @property {StringifiableSet} deps |
|
*/ |
|
|
|
/** |
|
* @typedef {Record<string, Metadatum>} Metadata |
|
*/ |
|
|
|
class StringifiableSet extends Set { |
|
toJSON() { |
|
return Array.from(this); |
|
} |
|
} |
|
|
|
/** |
|
* @param {string} fileName |
|
* @returns {boolean} |
|
*/ |
|
function isTopLevelPackage(fileName) { |
|
const firstNodeModules = fileName.indexOf('node_modules/'); |
|
return firstNodeModules !== -1 && fileName.indexOf('node_modules/', firstNodeModules + 1) === -1; |
|
} |
|
|
|
/** |
|
* Determines the package name for the given package.json file |
|
* |
|
* We need to determine the package name from the directory on the filesystem |
|
* instead of using the name field in package.json because we use `npm:` as |
|
* the version in package.json for some packages (e.g. `"react-redux-v7": |
|
* "npm:react-redux"`). |
|
* |
|
* @param {string} fileName |
|
* @returns {string} |
|
*/ |
|
function getPackageName(fileName) { |
|
return fileName.replace(/.*node_modules\/(@[^/]+\/[^/]+|[^/]+)\/package.json/, '$1'); |
|
} |
|
|
|
/** |
|
* Determines the top level package name from the given package.json file |
|
* |
|
* @param {string} fileName |
|
* @returns {string} |
|
*/ |
|
function getTopLevelPackageName(fileName) { |
|
return fileName.replace(/node_modules\/(@[^/]+\/[^/]+|[^/]+).*/, '$1'); |
|
} |
|
|
|
/** |
|
* @param {string} packageName |
|
* @param {string} resolvedFromDir |
|
* @param {Set<string>} allPackageJsons |
|
* @returns {boolean} |
|
*/ |
|
function hasNestedPackage(packageName, resolvedFromDir, allPackageJsons) { |
|
const dirs = resolvedFromDir.split(path.sep); |
|
|
|
// Iterate over directories backwards, stopping at 2 since the first |
|
// directory is always node_modules and we never expect |
|
// node_modules/node_modules to exist. |
|
for (let i = dirs.length; i > 1; i--) { |
|
const dir = dirs.slice(0, i).join(path.sep); |
|
const pathToCheck = path.join(dir, 'node_modules', packageName, 'package.json'); |
|
|
|
if (allPackageJsons.has(pathToCheck)) { |
|
return true; |
|
} |
|
} |
|
|
|
return false; |
|
} |
|
|
|
const getAllTransitiveDeps = memoize( |
|
/** |
|
* @param {string} name |
|
* @param {Metadata} metadata |
|
* @param {Set<string>} [visited] |
|
*/ |
|
(name, metadata, visited = new Set()) => { |
|
// Keep track of visited packages to avoid infinite recursion when there |
|
// are dependency cycles. |
|
visited.add(name); |
|
|
|
const allTransitiveDeps = new StringifiableSet(); |
|
|
|
metadata[name].deps.forEach( |
|
/** |
|
* @param {string} depToFind |
|
*/ |
|
(depToFind) => { |
|
allTransitiveDeps.add(depToFind); |
|
|
|
if (visited.has(depToFind)) { |
|
// We've already visited this package, which means that there is a |
|
// circular dependency. We need to handle this more carefully to |
|
// avoid infinite loops. |
|
metadata[depToFind].deps.forEach((depToAdd) => { |
|
allTransitiveDeps.add(depToAdd); |
|
|
|
if (!visited.has(depToAdd)) { |
|
getAllTransitiveDeps(depToAdd, metadata, visited).forEach((anotherDepToAdd) => { |
|
allTransitiveDeps.add(anotherDepToAdd); |
|
}); |
|
} |
|
}); |
|
} else { |
|
getAllTransitiveDeps(depToFind, metadata, visited).forEach((depToAdd) => { |
|
allTransitiveDeps.add(depToAdd); |
|
}); |
|
} |
|
}, |
|
); |
|
|
|
return allTransitiveDeps; |
|
}, |
|
); |
|
|
|
async function getMetadata() { |
|
/** |
|
* @type {Map<string, Record<string, any>>} |
|
*/ |
|
const packageData = new Map(); |
|
|
|
/** @type {Set<string>} */ |
|
const topLevelPackageNames = new Set(); |
|
|
|
/** @type {Set<string>} */ |
|
const allPackageJsons = new Set(); |
|
|
|
const globPatterns = [ |
|
// Top-level packages |
|
'node_modules/*/package.json', |
|
'node_modules/@*/*/package.json', |
|
|
|
// Nested packages |
|
'node_modules/**/node_modules/*/package.json', |
|
'node_modules/**/node_modules/@*/*/package.json', |
|
]; |
|
|
|
// TODO change `stream` to `globbyStream` when updating to v12 |
|
for await (const fileName of glob.stream(globPatterns)) { |
|
if (typeof fileName !== 'string') { |
|
throw new Error(`Unexpected file name: ${fileName}`); |
|
} |
|
|
|
allPackageJsons.add(fileName); |
|
|
|
const name = getPackageName(fileName); |
|
const data = JSON.parse(fs.readFileSync(fileName, 'utf8')); |
|
packageData.set(fileName, data); |
|
|
|
// If this is a top-level package, add it to a list so we can iterate over |
|
// them next |
|
if (isTopLevelPackage(fileName)) { |
|
topLevelPackageNames.add(name); |
|
} |
|
} |
|
|
|
/** @type {Metadata}} */ |
|
const metadata = {}; |
|
packageData.forEach((data, fileName) => { |
|
const { version, dependencies, peerDependencies } = data; |
|
|
|
const topLevelPackageName = getTopLevelPackageName(fileName); |
|
|
|
if (!metadata[topLevelPackageName]) { |
|
metadata[topLevelPackageName] = { version: '', deps: new StringifiableSet() }; |
|
} |
|
|
|
if (isTopLevelPackage(fileName)) { |
|
metadata[topLevelPackageName].version = version; |
|
} |
|
|
|
[...Object.keys(dependencies || {}), ...Object.keys(peerDependencies || {})].forEach((dep) => { |
|
if ( |
|
topLevelPackageNames.has(dep) && |
|
!hasNestedPackage(dep, path.dirname(fileName), allPackageJsons) |
|
) { |
|
// This dependency is a top-level package and is not a package that is |
|
// nested as a child of the current package.json. This means that it is |
|
// depending on the hoisted version and not the nested one. |
|
metadata[topLevelPackageName].deps.add(dep); |
|
} |
|
}); |
|
}); |
|
|
|
// Flatten all top-level transitive dependencies down |
|
|
|
/** @type {Metadata} */ |
|
const flattenedMetadata = {}; |
|
|
|
Object.entries(metadata).forEach(([name, { version }]) => { |
|
flattenedMetadata[name] = { version, deps: getAllTransitiveDeps(name, metadata) }; |
|
}); |
|
|
|
return flattenedMetadata; |
|
} |
|
|
|
(async () => { |
|
const metadata = await getMetadata(); |
|
console.log(JSON.stringify(metadata, null, 2)); |
|
})(); |