dependency-cruiser
Version:
Validate and visualize dependencies. With your rules. JavaScript, TypeScript, CoffeeScript. ES6, CommonJS, AMD.
582 lines (540 loc) • 17.2 kB
JavaScript
/* eslint-disable security/detect-object-injection */
/* eslint-disable unicorn/prevent-abbreviations */
/* eslint-disable max-lines */
/* eslint-disable no-inline-comments */
import tryImport from "#utl/try-import.mjs";
import meta from "#meta.cjs";
/**
* @import typescript, {Node} from "typescript"
*/
/** @type {typescript} */
const typescript = await tryImport(
"typescript",
meta.supportedTranspilers.typescript,
);
const INTERESTING_NODE_KINDS = typescript
? new Set([
typescript.SyntaxKind.CallExpression,
typescript.SyntaxKind.ExportDeclaration,
typescript.SyntaxKind.ImportDeclaration,
typescript.SyntaxKind.ImportEqualsDeclaration,
typescript.SyntaxKind.LastTypeNode,
])
: /* c8 ignore next 1 */
new Set();
function isTypeOnlyImport(pStatement) {
return (
pStatement.importClause &&
(pStatement.importClause.isTypeOnly ||
(pStatement.importClause.namedBindings &&
pStatement.importClause.namedBindings.elements &&
pStatement.importClause.namedBindings.elements.every(
(pElement) => pElement.isTypeOnly,
)))
);
}
function isTypeOnlyExport(pStatement) {
return (
// for some reason the isTypeOnly indicator is on _statement_ level
// and not in exportClause as it is in the importClause ¯\_(ツ)_/¯.
// Also in the case of the omission of an alias the exportClause
// is not there entirely. So regardless whether there is a
// pStatement.exportClause or not, we can directly test for the
// isTypeOnly attribute.
pStatement.isTypeOnly ||
// named reexports are per-element though
(pStatement.exportClause &&
pStatement.exportClause.elements &&
pStatement.exportClause.elements.every((pElement) => pElement.isTypeOnly))
);
}
/*
* Both extractImport* assume the imports/ exports can only occur at
* top level. AFAIK this the only place they're allowed, so we should
* be good. Otherwise we'll need to walk the tree.
*/
/**
* Get all import statements from the top level AST node
*
* @param {Node} pAST - the (top-level in this case) AST node
* @returns {{module: string; moduleSystem: string; exoticallyRequired: boolean; dependencyTypes?: string[];}[]} -
* all import statements in the (top level) AST node
*/
function extractImports(pAST) {
return pAST.statements
.filter(
(pStatement) =>
pStatement.kind === typescript.SyntaxKind.ImportDeclaration &&
pStatement.moduleSpecifier,
)
.map((pStatement) => ({
module: pStatement.moduleSpecifier.text,
moduleSystem: "es6",
exoticallyRequired: false,
...(isTypeOnlyImport(pStatement)
? { dependencyTypes: ["type-only", "import"] }
: { dependencyTypes: ["import"] }),
}));
}
/**
* Get all export statements from the top level AST node
*
* @param {Node} pAST - the (top-level in this case) AST node
* @returns {{module: string; moduleSystem: string; exoticallyRequired: boolean; dependencyTypes?: string[];}[]} -
* all export statements in the (top level) AST node
*/
function extractExports(pAST) {
return pAST.statements
.filter(
(pStatement) =>
pStatement.kind === typescript.SyntaxKind.ExportDeclaration &&
pStatement.moduleSpecifier,
)
.map((pStatement) => ({
module: pStatement.moduleSpecifier.text,
moduleSystem: "es6",
exoticallyRequired: false,
...(isTypeOnlyExport(pStatement)
? { dependencyTypes: ["type-only", "export"] }
: { dependencyTypes: ["export"] }),
}));
}
/**
* Get all import equals statements from the top level AST node
*
* E.g. import thing = require('some-thing')
* Ignores import equals of variables (e.g. import protocol = ts.server.protocol
* which happens in typescript/lib/protocol.d.ts)
*
* @param {Node} pAST - the (top-level in this case) AST node
* @returns {{module: string, moduleSystem: string;exoticallyRequired: boolean;}[]} - all import equals statements in the
* (top level) AST node
*/
function extractImportEquals(pAST) {
return pAST.statements
.filter(
(pStatement) =>
pStatement.kind === typescript.SyntaxKind.ImportEqualsDeclaration &&
pStatement.moduleReference &&
pStatement.moduleReference.expression &&
pStatement.moduleReference.expression.text,
)
.map((pStatement) => ({
module: pStatement.moduleReference.expression.text,
moduleSystem: "cjs",
exoticallyRequired: false,
dependencyTypes: ["import-equals"],
}));
}
/**
* Extracts /// directives e.g.
* /// <reference path="beep-tee-boop" />
*
* @param {Node} pAST - typescript syntax tree
* @returns {{module: string, moduleSystem: string}[]} - 'tripple slash' dependencies
*/
function extractTripleSlashDirectives(pAST) {
return pAST.referencedFiles
.map((pReference) => ({
module: pReference.fileName,
moduleSystem: "tsd",
exoticallyRequired: false,
dependencyTypes: [
"triple-slash-directive",
"triple-slash-file-reference",
],
}))
.concat(
pAST.typeReferenceDirectives.map((pReference) => ({
module: pReference.fileName,
moduleSystem: "tsd",
exoticallyRequired: false,
dependencyTypes: [
"triple-slash-directive",
"triple-slash-type-reference",
],
})),
)
.concat(
pAST.amdDependencies.map((pReference) => ({
module: pReference.path,
moduleSystem: "tsd",
exoticallyRequired: false,
dependencyTypes: [
"triple-slash-directive",
"triple-slash-amd-dependency",
],
})),
);
}
function firstArgumentIsAString(pASTNode) {
const lFirstArgument = pASTNode.arguments[0];
return (
lFirstArgument &&
// "thing" or 'thing'
(lFirstArgument.kind === typescript.SyntaxKind.StringLiteral ||
// `thing`
lFirstArgument.kind === typescript.SyntaxKind.FirstTemplateToken)
);
}
function isRequireCallExpression(pASTNode) {
if (
pASTNode.kind === typescript.SyntaxKind.CallExpression &&
pASTNode.expression
) {
/*
* from typescript 5.0.0 the `originalKeywordKind` attribute is deprecated
* and from 5.2.0 it will be gone. However, in typescript < 5.0.0 (still used
* heavily IRL) it's the only way to get it - hence this test for the
* existence of the * identifierToKeywordKind function
*/
const lSyntaxKind = typescript.identifierToKeywordKind
? typescript.SyntaxKind[
typescript.identifierToKeywordKind(pASTNode.expression)
]
: /* c8 ignore next 1 */
typescript.SyntaxKind[pASTNode.expression.originalKeywordKind];
return lSyntaxKind === "RequireKeyword" && firstArgumentIsAString(pASTNode);
}
return false;
}
function isSingleExoticRequire(pASTNode, pString) {
return (
pASTNode.kind === typescript.SyntaxKind.CallExpression &&
pASTNode.expression &&
pASTNode.expression.text === pString &&
firstArgumentIsAString(pASTNode)
);
}
/* eslint complexity:0 */
function isCompositeExoticRequire(pASTNode, pObjectName, pPropertyName) {
return (
pASTNode.kind === typescript.SyntaxKind.CallExpression &&
pASTNode.expression &&
pASTNode.expression.kind ===
typescript.SyntaxKind.PropertyAccessExpression &&
pASTNode.expression.expression &&
pASTNode.expression.expression.kind === typescript.SyntaxKind.Identifier &&
pASTNode.expression.expression.escapedText === pObjectName &&
pASTNode.expression.name &&
pASTNode.expression.name.kind === typescript.SyntaxKind.Identifier &&
pASTNode.expression.name.escapedText === pPropertyName &&
firstArgumentIsAString(pASTNode)
);
}
function isTripleCursedCompositeExoticRequire(
pASTNode,
pObjectName1,
pObjectName2,
pPropertyName,
) {
return (
pASTNode.kind === typescript.SyntaxKind.CallExpression &&
pASTNode.expression &&
pASTNode.expression.kind ===
typescript.SyntaxKind.PropertyAccessExpression &&
pASTNode.expression.expression &&
pASTNode.expression.expression.kind ===
typescript.SyntaxKind.PropertyAccessExpression &&
// globalThis
pASTNode.expression.expression.expression &&
pASTNode.expression.expression.expression.kind ===
typescript.SyntaxKind.Identifier &&
pASTNode.expression.expression.expression.escapedText === pObjectName1 &&
// process
pASTNode.expression.expression.name.kind ===
typescript.SyntaxKind.Identifier &&
pASTNode.expression.expression.name.escapedText === pObjectName2 &&
// getBuiltinModule
pASTNode.expression.name &&
pASTNode.expression.name.kind === typescript.SyntaxKind.Identifier &&
pASTNode.expression.name.escapedText === pPropertyName &&
firstArgumentIsAString(pASTNode)
);
}
function isExoticRequire(pASTNode, pString) {
const lRequireStringElements = pString.split(".");
return lRequireStringElements.length > 1
? isCompositeExoticRequire(pASTNode, ...lRequireStringElements)
: isSingleExoticRequire(pASTNode, pString);
}
function isDynamicImportExpression(pASTNode) {
return (
pASTNode.kind === typescript.SyntaxKind.CallExpression &&
pASTNode.expression &&
pASTNode.expression.kind === typescript.SyntaxKind.ImportKeyword &&
firstArgumentIsAString(pASTNode)
);
}
function isTypeImport(pASTNode) {
return (
pASTNode.kind === typescript.SyntaxKind.LastTypeNode &&
pASTNode.argument &&
pASTNode.argument.kind === typescript.SyntaxKind.LiteralType &&
((pASTNode.argument.literal &&
pASTNode.argument.literal.kind === typescript.SyntaxKind.StringLiteral) ||
pASTNode.argument.literal.kind ===
typescript.SyntaxKind.FirstTemplateToken)
);
}
function extractJSDocImportTags(pJSDocTags) {
return pJSDocTags
.filter(
(pTag) =>
pTag.tagName.escapedText === "import" &&
pTag.moduleSpecifier &&
pTag.moduleSpecifier.kind === typescript.SyntaxKind.StringLiteral &&
pTag.moduleSpecifier.text,
)
.map((pTag) => ({
module: pTag.moduleSpecifier.text,
moduleSystem: "es6",
exoticallyRequired: false,
dependencyTypes: ["type-only", "import", "jsdoc", "jsdoc-import-tag"],
}));
}
function isJSDocImport(pTypeNode) {
// import('./hello.mjs') within jsdoc
return (
pTypeNode?.kind === typescript.SyntaxKind.LastTypeNode &&
pTypeNode.argument?.kind === typescript.SyntaxKind.LiteralType &&
pTypeNode.argument?.literal?.kind === typescript.SyntaxKind.StringLiteral &&
pTypeNode.argument.literal.text
);
}
const IGNORABLE_JSDOC_KEYS = new Set([
"parent",
"pos",
"end",
"flags",
"emitNode",
"modifierFlagsCache",
"transformFlags",
"id",
"flowNode",
"symbol",
"original",
]);
export function walkJSDoc(pObject, pCollection = new Set()) {
if (isJSDocImport(pObject)) {
pCollection.add(pObject.argument.literal.text);
} else if (Array.isArray(pObject)) {
for (const lValue of pObject) {
walkJSDoc(lValue, pCollection);
}
} else if (typeof pObject === "object") {
for (const lKey in pObject) {
if (!IGNORABLE_JSDOC_KEYS.has(lKey) && pObject[lKey]) {
walkJSDoc(pObject[lKey], pCollection);
}
}
}
}
export function getJSDocImports(pTagNode) {
const lCollection = new Set();
walkJSDoc(pTagNode, lCollection);
return Array.from(lCollection);
}
function extractJSDocBracketImports(pJSDocTags) {
// https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html
return pJSDocTags
.filter(
(pTag) =>
pTag.tagName.escapedText !== "import" &&
pTag.typeExpression?.kind === typescript.SyntaxKind.FirstJSDocNode,
)
.flatMap((pTag) => getJSDocImports(pTag))
.map((pImportName) => ({
module: pImportName,
moduleSystem: "es6",
exoticallyRequired: false,
dependencyTypes: ["type-only", "import", "jsdoc", "jsdoc-bracket-import"],
}));
}
function extractJSDocImports(pJSDocNodes) {
// https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#the-jsdoc-import-tag
const lJSDocNodesWithTags = pJSDocNodes.filter(
(pJSDocLine) => pJSDocLine.tags,
);
const lJSDocImportTags = lJSDocNodesWithTags.flatMap((pJSDocLine) =>
extractJSDocImportTags(pJSDocLine.tags),
);
const lJSDocBracketImports = lJSDocNodesWithTags.flatMap((pJSDocLine) =>
extractJSDocBracketImports(pJSDocLine.tags),
);
return lJSDocImportTags.concat(lJSDocBracketImports);
}
// eslint-disable-next-line max-lines-per-function
function visitNode(
pResult,
pASTNode,
pExoticRequireStrings,
pDetectProcessBuiltinModuleCalls,
) {
// checks are in order of ~expected frequency
// early returns as these types of dependencyTypes cannot occur at the same
// time in a single AST node
// require('a-string'), require(`a-template-literal`)
if (isRequireCallExpression(pASTNode)) {
pResult.push({
module: pASTNode.arguments[0].text,
moduleSystem: "cjs",
exoticallyRequired: false,
dependencyTypes: ["require"],
});
return;
}
// import('a-string'), import(`a-template-literal`)
if (isDynamicImportExpression(pASTNode)) {
pResult.push({
module: pASTNode.arguments[0].text,
moduleSystem: "es6",
dynamic: true,
exoticallyRequired: false,
dependencyTypes: ["dynamic-import"],
});
return;
}
// const atype: import('./types').T
// const atype: import(`./types`).T
if (isTypeImport(pASTNode)) {
pResult.push({
module: pASTNode.argument.literal.text,
moduleSystem: "es6",
exoticallyRequired: false,
dependencyTypes: ["type-import"],
});
return;
}
// const want = require; {lalala} = want('yudelyo'), window.require('elektron')
// strictly speaking checking whether kind is CallExpression is not necessary as
// the functions inside the loop will do that as well. However, it saves quite a
// of computation when the list of exotic require strings is not empty
if (pASTNode.kind === typescript.SyntaxKind.CallExpression) {
for (const lExoticRequireString of pExoticRequireStrings) {
if (isExoticRequire(pASTNode, lExoticRequireString)) {
pResult.push({
module: pASTNode.arguments[0].text,
moduleSystem: "cjs",
exoticallyRequired: true,
exoticRequire: lExoticRequireString,
dependencyTypes: ["exotic-require"],
});
return;
}
}
}
// const path = process.getBuiltinModule('node:path'); const fs = globalThis.process.getBuiltinModule(`node:fs`);
if (
pDetectProcessBuiltinModuleCalls &&
(isCompositeExoticRequire(pASTNode, "process", "getBuiltinModule") ||
isTripleCursedCompositeExoticRequire(
pASTNode,
"globalThis",
"process",
"getBuiltinModule",
))
) {
pResult.push({
module: pASTNode.arguments[0].text,
moduleSystem: "cjs",
exoticallyRequired: false,
dependencyTypes: ["process-get-builtin-module"],
});
}
}
/**
* Walks the AST and collects all dependencies
*
* @param {Node} pASTNode - the AST node to start from
* @param {string[]} pExoticRequireStrings - exotic require strings to look for
* @param {boolean} pDetectJSDocImports - whether to detect jsdoc imports
* @returns {(pASTNode: Node) => void} - the walker function
*/
function walk(
pResult,
pExoticRequireStrings,
pDetectJSDocImports,
pDetectProcessBuiltinModuleCalls,
) {
return (pASTNode) => {
// /** @import thing from './module' */ etc
// /** @type {import('module').thing}*/ etc
if (pDetectJSDocImports && pASTNode.jsDoc) {
const lJSDocImports = extractJSDocImports(pASTNode.jsDoc);
// pResult = pResult.concat(lJSDocImports) looks like the more obvious
// way to do this, but it re-assigns the pResult parameter
for (const lImport of lJSDocImports) {
pResult.push(lImport);
}
}
if (INTERESTING_NODE_KINDS.has(pASTNode.kind)) {
visitNode(
pResult,
pASTNode,
pExoticRequireStrings,
pDetectProcessBuiltinModuleCalls,
);
}
typescript.forEachChild(
pASTNode,
walk(
pResult,
pExoticRequireStrings,
pDetectJSDocImports,
pDetectProcessBuiltinModuleCalls,
),
);
};
}
/**
* returns an array of dependencies that come potentially nested within
* a source file, like commonJS or dynamic imports
*
* @param {Node} pAST - typescript syntax tree
* @param {string[]} pExoticRequireStrings - exotic require strings to look for
* @param {boolean} pDetectJSDocImports - whether to detect jsdoc imports
* @returns {{module: string, moduleSystem: string}[]} - all commonJS dependencies
*/
function extractNestedDependencies(
pAST,
pExoticRequireStrings,
pDetectJSDocImports,
pDetectProcessBuiltinModuleCalls,
) {
let lResult = [];
walk(
lResult,
pExoticRequireStrings,
pDetectJSDocImports,
pDetectProcessBuiltinModuleCalls,
)(pAST);
return lResult;
}
/**
* returns all dependencies in the AST
*
* @type {(pTypeScriptAST: (Node), pExoticRequireStrings: string[], pDetectJSDocImports: boolean) => {module: string, moduleSystem: string, dynamic: boolean}[]}
*/
export default function extractTypeScriptDependencies(
pTypeScriptAST,
pExoticRequireStrings,
pDetectJSDocImports,
pDetectProcessBuiltinModuleCalls,
) {
return typescript
? extractImports(pTypeScriptAST)
.concat(extractExports(pTypeScriptAST))
.concat(extractImportEquals(pTypeScriptAST))
.concat(extractTripleSlashDirectives(pTypeScriptAST))
.concat(
extractNestedDependencies(
pTypeScriptAST,
pExoticRequireStrings,
pDetectJSDocImports,
pDetectProcessBuiltinModuleCalls,
),
)
.map((pModule) => ({ dynamic: false, ...pModule }))
: /* c8 ignore next */ [];
}