/* 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('') .option('-o, --out ', '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);