UNPKG

dependency-cruiser

Version:

Validate and visualize dependencies. With your rules. JavaScript, TypeScript, CoffeeScript. ES6, CommonJS, AMD.

331 lines (311 loc) 11.3 kB
/* eslint-disable max-lines */ import { isAbsolute, resolve as path_resolve } from "node:path"; import { join as posix_join } from "node:path/posix"; import picomatch from "picomatch"; import getExtension from "#utl/get-extension.mjs"; import { getCachedRegExp } from "#utl/regex-util.mjs"; /** * @import { ITranspileOptions } from "../../../types/dependency-cruiser.mjs" */ let gFollowableExtensionsCache = new Set(); let gFollowableExtensionsCacheInitialized = false; export function isScoped(pModuleName) { return pModuleName.startsWith("@"); } export function isRelativeModuleName(pModuleName) { return ( pModuleName.startsWith("./") || pModuleName.startsWith("../") || pModuleName === "." || pModuleName === ".." // note ".blah" and "..blah" are _not_ relative ); } export function isExternalModule( pResolvedModuleName, pModuleFolderNames = ["node_modules"], pBaseDirectory = ".", ) { return ( Boolean(pResolvedModuleName) && pModuleFolderNames.some( // pModules can contain relative paths, but also absolute ones. // WebPack treats these differently: // - absolute paths only match that exact path // - relative paths get a node lookup treatment so "turtle" matches // "turtle", "../turtle", "../../turtle" // hence we'll have to test for them in different fashion as well. // reference: https://webpack.js.org/configuration/resolve/#resolve-modules (pModuleFolderName) => { if (isAbsolute(pModuleFolderName)) { return path_resolve(pBaseDirectory, pResolvedModuleName).startsWith( pModuleFolderName, ); } return pResolvedModuleName.includes(pModuleFolderName); }, ) ); } function determineFollowableExtensions(pResolveOptions) { let lReturnValue = new Set(pResolveOptions.extensions); // we could include things like pictures, movies, html, xml etc in // lKnownUnfollowables as well. Ijavascript-like sources you don't // import non-javascript stuff without mentioning the extension // (`import 'styles.scss` is more clear than`import 'styles'` as you'd expect // that to resolve to something javascript-like. Defensively added the // stylesheetlanguages here explicitly nonetheless - they can contain import // statements and the fallback javascript parser will happily parse them, which // will result in false positives. const lKnownUnfollowables = [ ".json", ".node", ".css", ".sass", ".scss", ".stylus", ".less", ]; for (const lUnfollowable of lKnownUnfollowables) { lReturnValue.delete(lUnfollowable); } return lReturnValue; } function getFollowableExtensions(pResolveOptions) { if (!gFollowableExtensionsCacheInitialized || pResolveOptions.bustTheCache) { gFollowableExtensionsCache = determineFollowableExtensions(pResolveOptions); gFollowableExtensionsCacheInitialized = true; } return gFollowableExtensionsCache; } export function isFollowable(pResolvedFilename, pResolveOptions) { return getFollowableExtensions(pResolveOptions).has( getExtension(pResolvedFilename), ); } /** * @param {string} pModuleName * @param {object} pManifest * @returns {boolean} */ function isSubpathImport(pModuleName, pManifest) { return ( pModuleName.startsWith("#") && Object.keys(pManifest?.imports ?? {}).some((pImportLHS) => { // Although they might look as such, the LHS of an import statement // (a 'subpath pattern') is not a glob. The * functions as string // replacement only. // Quoting https://nodejs.org/api/packages.html#subpath-imports : // > "* maps expose nested subpaths as it is a string replacement syntax only" const lMatchREasString = pImportLHS.replaceAll("*", ".+"); return getCachedRegExp(`^${lMatchREasString}$`).test(pModuleName); }) ); } /** * @param {string} pModuleName * @param {object} pAliasObject * @returns {boolean} */ function isWebPackAliased(pModuleName, pAliasObject) { return Object.keys(pAliasObject || {}).some((pAliasLHS) => pModuleName.startsWith(pAliasLHS), ); } function getWorkspacesArray(pManifestWorkspacesField) { if (Array.isArray(pManifestWorkspacesField)) { return pManifestWorkspacesField; } if (pManifestWorkspacesField?.packages) { return pManifestWorkspacesField.packages; } return []; } /** * @param {string} pModuleName * @param {string} pResolvedModuleName * @param {object} pManifest * @returns {boolean} */ // eslint-disable-next-line max-lines-per-function function isWorkspaceAliased(pModuleName, pResolvedModuleName, pManifest) { // reference: https://docs.npmjs.com/cli/v10/using-npm/workspaces // for yarn the workspaces field might be either an array or // an object. To prevent the code from borking we check whether it's an array // see https://github.com/sverweij/dependency-cruiser/issues/919 const lWorkspaces = getWorkspacesArray(pManifest?.workspaces); if (lWorkspaces.length > 0) { // workspaces are an array of globs that match the (sub) workspace // folder itself only. // // workspaces: [ // "packages/*", -> matches packages/a, packages/b, packages/c, ... // "libs/x", -> matches libs/x // "libs/y", -> matches libs/y // "apps" -> matches apps // ] // // By definition this will _never_ match resolved module names. // E.g. in packages/a => packages/a/dist/main/index.js or // in libs/x => libs/x/index.js // // This is why we chuck a `/**` at the end of each workspace glob, which // transforms it into a 'starts with' glob. And yeah, you can have a / // at the end of a glob. And because double slashes are taken literally // we have a ternary operator to prevent those. // // oh and: ```picomatch.isMatch('asdf', 'asdf/**') === true``` so // in case it's only 'asdf' that's in the resolved module name for some reason // we're good as well. // // workspaces is supposed to be array of strings, where each string is // a glob pattern. However, in the field there's occasions where it's // not a string. We'll just ignore those for now, hence the presence of // the `typeof pWorkspace === "string"` check. const lModuleFriendlyWorkspaceGlobs = lWorkspaces.map((pWorkspace) => typeof pWorkspace === "string" && pWorkspace.endsWith("/") ? `${pWorkspace}**` : `${pWorkspace}/**`, ); if (picomatch.isMatch(pResolvedModuleName, lModuleFriendlyWorkspaceGlobs)) { return true; } // it's possible to run node with --preserve-symlinks. The symlinked workspace folders // then are not resolved to their realpath. // So we need to check both the thing in node_modules _and_ the resolved // thing. Annoyingly, the symlink in node_modules is the `name` attribute // of the workspace, not the path of the workspace itself. So if it's // in node_modules we need to check against the unresolved modulename. // // Other than the detection for when symlinks are resolved to their realpath // (the if above), this is a 'best effort' detection only for now; there's // guaranteed to be scenarios where this will fail. How often is the // --preserve-symlinks flag used in practice, though? const lModuleFriendlyWorkspaceGlobsWithNodeModules = lModuleFriendlyWorkspaceGlobs.map( (pWorkspace) => `(node_modules/)?${pWorkspace}`, ); return picomatch.isMatch( pModuleName, lModuleFriendlyWorkspaceGlobsWithNodeModules, ); } return false; } /** * * @param {string} pModuleName * @param {Record<string, string[]>} pPaths * @returns {boolean} */ function matchesTSConfigPaths(pModuleName, pPaths) { // "paths patterns can contain a single * wildcard, which matches any string. // The * token can then be used in the file path values to substitute the // matched string." // https://www.typescriptlang.org/docs/handbook/modules/reference.html#wildcard-substitutions // // So, just like with subpath imports, the LHS of a path pattern is not a glob // and the '*' functions as a string replacement only. // // TODO: 'any string' - does this include the empty string as well? Checks seem // to indicate it doesn't, so we use `.+` instead of `.*` return Object.keys(pPaths).some((pPathLHS) => getCachedRegExp(`^${pPathLHS.replaceAll("*", ".+")}$`).test(pModuleName), ); } function stripExtension(pModulePath) { const lExtension = getExtension(pModulePath); return lExtension ? pModulePath.slice(0, -lExtension.length) : pModulePath; } function stripIndex(pModulePath) { return pModulePath.endsWith("/index") ? pModulePath.slice(0, -"/index".length) : pModulePath; } /** * * https://www.typescriptlang.org/docs/handbook/modules/reference.html#baseurl * * Assumes the pModuleName is not relative. * * @param {string} pModuleName * @param {string} pResolvedModuleName * @param {string} pTSConfigBaseURL */ function matchesTSConfigBaseURL( pModuleName, pResolvedModuleName, pTSConfigBaseURL, ) { // the pModuleName === pResolvedModuleName check is there to prevent // false positives for core modules ('fs' resolved === 'fs') and modules that // we couldn't resolve at all (e.g. 'this/does/not/exist' => 'this/does/not/exist') // // we could also check whether the moduleName is relative, but that's // not efficient as that was already done before this function was called. if (!pTSConfigBaseURL || pModuleName === pResolvedModuleName) { return false; } // "If baseUrl is set, TypeScript will resolve non-relative module names // relative to the baseUrl." // https://www.typescriptlang.org/docs/handbook/modules.html#base-url const strippedModuleNameJoinedToBaseURL = stripIndex( stripExtension(posix_join(pTSConfigBaseURL, pModuleName)), ); const strippedResolvedModuleName = stripIndex( stripExtension(pResolvedModuleName), ); return strippedModuleNameJoinedToBaseURL.endsWith(strippedResolvedModuleName); } /** * @param {string} pModuleName * @param {string} pResolvedModuleName * @param {importIResolveOptions} pResolveOptions * @param {any} pManifest * @param {ITranspileOptions} pTranspileOptions * @returns {string[]} */ // eslint-disable-next-line max-params, complexity export function getAliasTypes( pModuleName, pResolvedModuleName, pResolveOptions, pManifest, pTranspileOptions, ) { if (isRelativeModuleName(pModuleName)) { return []; } // the order of these ifs is deliberate. First stuff bolted on by bundlers & transpilers. if (isWebPackAliased(pModuleName, pResolveOptions.alias)) { return ["aliased", "aliased-webpack"]; } if ( matchesTSConfigBaseURL( pModuleName, pResolvedModuleName, pTranspileOptions?.tsConfig?.options?.baseUrl, ) ) { return ["aliased", "aliased-tsconfig", "aliased-tsconfig-base-url"]; } if ( matchesTSConfigPaths( pModuleName, pTranspileOptions?.tsConfig?.options?.paths ?? {}, ) ) { return ["aliased", "aliased-tsconfig", "aliased-tsconfig-paths"]; } // The order of subpath imports and workspaces isn't _that_ important, as they // can't be confused // - subpath imports _must_ start with a # // - workspaces (or, more precise: package names) are forbidden to even _contain_ a # if (isSubpathImport(pModuleName, pManifest)) { return ["aliased", "aliased-subpath-import"]; } if (isWorkspaceAliased(pModuleName, pResolvedModuleName, pManifest)) { return ["aliased", "aliased-workspace"]; } return []; }