paragon/dependent-usage-analyzer/index.js

189 lines
7.3 KiB
JavaScript

/* eslint-disable no-param-reassign */
/* eslint-disable no-console */
const parser = require('@babel/parser');
const fs = require('fs');
const walk = require('babel-walk');
const glob = require('glob');
const { Command } = require('commander');
const path = require('path');
function getProjectFiles(dir) {
// Common project directories to ignore
const ignore = [
`${dir}/**/node_modules/**`,
`${dir}/dist/**`,
`${dir}/public/**`,
`${dir}/coverage/**`,
`${dir}/**/*.config.*`,
];
// Gather all js and jsx source files
return glob.sync(`${dir}/**/*.{js,jsx}`, { ignore });
}
/**
* Attempts to extract the Paragon version for a given package directory.
* When no package-lock.json file is found in the given directory path or when
* no Paragon version can be retrieved, recursively traverse up the directory tree
* until we reach the top-level projects directory. This approach is necessary in
* order to account for potential projects that are technically monorepos containing
* multiple packages, where dependencies are hoisted to a parent directory.
*
* @param {string} dir Path to directory
* @param {object} options Optional options
* @param {string} options.projectsDir Path to top-level projects directory
* @returns Object containing direct or peer Paragon dependency version
*/
function getDependencyVersion(dir, options = {}) {
// package-lock.json contains the actual Paragon version
// rather than a range in package.json.
const packageFilename = 'package-lock.json';
const { projectsDir } = options;
if (dir === projectsDir) {
// At the top-level directory containing all projects; Paragon version not found.
return {};
}
const parentDir = dir.split('/').slice(0, -1).join('/');
if (!fs.existsSync(`${dir}/${packageFilename}`)) {
// No package-lock.json file exists, so try traversing up the tree until
// reaching the top-level ``projectsDir``.
return getDependencyVersion(parentDir, options);
}
const { dependencies, peerDependencies } = JSON.parse(fs.readFileSync(`${dir}/${packageFilename}`, { encoding: 'utf-8' }));
const directDependencyVersion = dependencies && dependencies['@edx/paragon'] ? dependencies['@edx/paragon'].version : false;
const peerDependencyVersion = peerDependencies && peerDependencies['@edx/paragon'] ? peerDependencies['@edx/paragon'].version : false;
if (directDependencyVersion || peerDependencyVersion) {
return {
directDependencyVersion,
peerDependencyVersion,
}
}
// No Paragon dependency exists, so try traversing up the tree until
// reaching the top-level ``projectsDir``.
return getDependencyVersion(parentDir, options)
}
function getPackageInfo(dir, options = {}) {
const { directDependencyVersion, peerDependencyVersion } = getDependencyVersion(dir, options);
try {
const { name, repository } = JSON.parse(fs.readFileSync(`${dir}/package.json`, { encoding: 'utf-8' }));
return {
version: directDependencyVersion || peerDependencyVersion,
name,
repository,
folderName: dir.split('/').pop(),
};
} catch (e) {
console.error('Unable to read package.json in ', dir);
return {};
}
}
function getComponentUsagesInFiles(files, rootDir) {
// Save the file and line location of components for all files
return files.reduce((usagesAccumulator, filePath) => {
const sourceCode = fs.readFileSync(filePath, { encoding: 'utf-8' });
let ast;
try {
ast = parser.parse(sourceCode, { sourceType: 'module', plugins: ['jsx', 'classProperties'] });
} catch (e) {
console.error(`There was an error parsing a file into an abstract syntax tree. Skipping file: ${filePath}`);
return usagesAccumulator;
}
// Track the local names of imported paragon components
const paragonImportsInFile = {};
const addParagonImport = (specifierNode) => {
const { local, imported } = specifierNode;
paragonImportsInFile[local.name] = imported ? imported.name : local.name;
};
const addComponentUsage = (fullComponentName, startLocation) => {
if (!usagesAccumulator[fullComponentName]) {
usagesAccumulator[fullComponentName] = [];
}
usagesAccumulator[fullComponentName].push({
filePath: filePath.substring(rootDir.length + 1),
...startLocation,
});
};
// Walk the abstract syntax tree of the file looking for paragon imports and component usages
walk.simple({
ImportDeclaration(node) {
// Ignore icons and direct imports for now
if (node.source.value === '@edx/paragon') {
node.specifiers.forEach(addParagonImport);
}
},
JSXOpeningElement(node) {
const componentName = node.name.object ? node.name.object.name : node.name.name;
const isParagonComponent = componentName in paragonImportsInFile;
if (isParagonComponent) {
const paragonName = paragonImportsInFile[componentName];
const subComponentName = node.name.object ? node.name.property.name : null;
const fullComponentName = subComponentName ? `${paragonName}.${subComponentName}` : paragonName;
addComponentUsage(fullComponentName, node.loc.start);
}
},
})(ast);
return usagesAccumulator;
}, {});
}
function analyzeProject(dir, options = {}) {
const packageInfo = getPackageInfo(dir, options);
const files = getProjectFiles(dir);
const usages = getComponentUsagesInFiles(files, dir);
// Add Paragon version to each component usage
Object.keys(usages).forEach(componentName => {
usages[componentName] = usages[componentName].map(usage => ({
...usage,
version: packageInfo.version,
}));
});
return { ...packageInfo, usages };
}
function findProjectsToAnalyze(dir) {
// Find all directories containing a package.json file.
const packageJSONFiles = glob.sync(`${dir}/**/package.json`, { ignore: [`${dir}/**/node_modules/**`] });
// If paragon isn't included in the package.json file then skip analyzing it
const packageJSONFilesWithParagon = packageJSONFiles.filter(packageJSONFile => {
const { dependencies, peerDependencies } = JSON.parse(fs.readFileSync(packageJSONFile, { encoding: 'utf-8' }));
const hasDirectDependency = dependencies && dependencies['@edx/paragon'] !== undefined;
const hasPeerDependency = peerDependencies && peerDependencies['@edx/paragon'] !== undefined
return hasDirectDependency || hasPeerDependency;
});
console.log(packageJSONFilesWithParagon)
return packageJSONFilesWithParagon.map(packageJSONFile => path.dirname(packageJSONFile));
}
const program = new Command();
program
.version('1.0.0')
.arguments('<projectsDir>')
.option('-o, --out <outFilePath>', 'output filepath')
.action((projectsDir, options) => {
const outputFilePath = options.out || 'out.json';
const projectDirectories = findProjectsToAnalyze(projectsDir);
console.log(`Found ${projectDirectories.length} projects to analyze`);
const analyzedProjects = projectDirectories.map((dir) => analyzeProject(dir, { projectsDir }));
const analysis = {
lastModified: Date.now(),
projectUsages: analyzedProjects,
}
fs.writeFileSync(outputFilePath, JSON.stringify(analysis, null, 2));
console.log(`Analyzed ${projectDirectories.length} projects:`);
console.log(analysis);
});
program.parse(process.argv);