/** * Why? Few reasons: * - tsup treats dependencies as external code and does not bundle them * - without dependencies turborepo will always serve stale code when some of dependencies changed * * Moving internal dependencies to devDependencies makes tsup treat them as non-external and turborepo still keep tracks of relations */ /// @ts-check const path = require('path'); const fs = require('fs'); const minimatch = require('minimatch'); const readPkgUp = require('eslint-module-utils/readPkgUp').default; const moduleVisitor = require('eslint-module-utils/moduleVisitor').default; function isHivePackage(packageName, scopes) { return ( typeof packageName === 'string' && scopes.some(scope => packageName.startsWith(`${scope}/`)) ); } const depFieldCache = new Map(); function hasKeys(obj = {}) { return Object.keys(obj).length > 0; } function extractDepFields(pkg) { return { name: pkg.name, dependencies: pkg.dependencies || {}, devDependencies: pkg.devDependencies || {}, optionalDependencies: pkg.optionalDependencies || {}, peerDependencies: pkg.peerDependencies || {}, }; } function getDependencies(context, packageDir) { let paths = []; try { const packageContent = { name: '', dependencies: {}, devDependencies: {}, optionalDependencies: {}, peerDependencies: {}, }; if (packageDir && packageDir.length > 0) { if (!Array.isArray(packageDir)) { paths = [path.resolve(packageDir)]; } else { paths = packageDir.map(dir => path.resolve(dir)); } } if (paths.length > 0) { // use rule config to find package.json paths.forEach(dir => { const packageJsonPath = path.join(dir, 'package.json'); if (!depFieldCache.has(packageJsonPath)) { const depFields = extractDepFields(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))); depFieldCache.set(packageJsonPath, depFields); } const _packageContent = depFieldCache.get(packageJsonPath); Object.keys(packageContent).forEach(depsKey => Object.assign(packageContent[depsKey], _packageContent[depsKey]), ); }); } else { // use closest package.json Object.assign( packageContent, extractDepFields( readPkgUp({ cwd: context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(), normalize: false, }).pkg, ), ); } if ( ![ packageContent.dependencies, packageContent.devDependencies, packageContent.optionalDependencies, packageContent.peerDependencies, ].some(hasKeys) ) { return null; } return packageContent; } catch (e) { if (paths.length > 0 && e.code === 'ENOENT') { context.report({ message: 'The package.json file could not be found.', loc: { line: 0, column: 0 }, }); } if (e.name === 'JSONError' || e instanceof SyntaxError) { context.report({ message: 'The package.json file could not be parsed: ' + e.message, loc: { line: 0, column: 0 }, }); } return null; } } function missingErrorMessage(packageName) { return `'${packageName}' should be listed in the project's devDependencies. `; } function directDepErrorMessage(packageName) { return `'${packageName}' should be listed in the project's devDependencies, not dependencies.`; } function optDepErrorMessage(packageName) { return `'${packageName}' should be listed in the project's devDependencies, not optionalDependencies`; } function peerDepErrorMessage(packageName) { return `'${packageName}' should be listed in the project's devDependencies, not peerDependencies`; } function getModuleOriginalName(name) { const [first, second] = name.split('/'); return first.startsWith('@') ? `${first}/${second}` : first; } function checkDependencyDeclaration(deps, packageName, declarationStatus) { const newDeclarationStatus = declarationStatus || { isInDeps: false, isInDevDeps: false, isInOptDeps: false, isInPeerDeps: false, }; // in case of sub package.json inside a module // check the dependencies on all hierarchy const packageHierarchy = []; const packageNameParts = packageName ? packageName.split('/') : []; packageNameParts.forEach((namePart, index) => { if (!namePart.startsWith('@')) { const ancestor = packageNameParts.slice(0, index + 1).join('/'); packageHierarchy.push(ancestor); } }); return packageHierarchy.reduce((result, ancestorName) => { return { isInDeps: result.isInDeps || deps.dependencies[ancestorName] !== undefined, isInDevDeps: result.isInDevDeps || deps.devDependencies[ancestorName] !== undefined, isInOptDeps: result.isInOptDeps || deps.optionalDependencies[ancestorName] !== undefined, isInPeerDeps: result.isInPeerDeps || deps.peerDependencies[ancestorName] !== undefined, }; }, newDeclarationStatus); } function reportIfMissing(context, deps, node, name, scopes) { if (node.importKind === 'type' || node.importKind === 'typeof') { return; } if (!isHivePackage(name, scopes)) { return; } const importPackageName = getModuleOriginalName(name); let declarationStatus = checkDependencyDeclaration(deps, importPackageName); if (declarationStatus.isInDevDeps) { return; } if (declarationStatus.isInDeps) { context.report(node, directDepErrorMessage(importPackageName)); return; } if (declarationStatus.isInOptDeps) { context.report(node, optDepErrorMessage(importPackageName)); return; } if (declarationStatus.isInPeerDeps) { context.report(node, peerDepErrorMessage(importPackageName)); return; } context.report(node, missingErrorMessage(importPackageName)); } module.exports = { meta: { type: 'problem', }, create(context) { const options = context.options[0] || {}; const deps = getDependencies(context, options.packageDir) || extractDepFields({}); if (Array.isArray(options.ignored)) { const filepath = context.getPhysicalFilename ? context.getPhysicalFilename() : context.getFilename(); if ( options.ignored.some( ignored => minimatch(filepath, ignored) || minimatch(filepath, path.join(process.cwd(), ignored)), ) ) { return {}; } } if (!Array.isArray(options.scopes)) { throw new Error('[hive/enforce-deps-in-dev] The scopes option must be an array.'); } return moduleVisitor( (source, node) => { reportIfMissing(context, deps, node, source.value, options.scopes); }, { commonjs: true }, ); }, };