363 lines
9.8 KiB
TypeScript
363 lines
9.8 KiB
TypeScript
import { parse, type ParserPlugin } from "@babel/parser";
|
|
import type {
|
|
CallExpression,
|
|
File,
|
|
ImportExpression,
|
|
Node,
|
|
StringLiteral,
|
|
} from "@babel/types";
|
|
import {
|
|
isCallExpression,
|
|
isExportAllDeclaration,
|
|
isExportNamedDeclaration,
|
|
isIdentifier,
|
|
isImport,
|
|
isImportDeclaration,
|
|
isImportExpression,
|
|
isMemberExpression,
|
|
isNode,
|
|
isStringLiteral,
|
|
isTSExternalModuleReference,
|
|
isTSImportEqualsDeclaration,
|
|
isTSModuleDeclaration,
|
|
} from "@babel/types";
|
|
|
|
type ReferenceSourceImportKind =
|
|
| "imports"
|
|
| "requires"
|
|
| "references"
|
|
| "declares-module";
|
|
|
|
type ReferenceSourceImportResolution =
|
|
| "internal"
|
|
| "external"
|
|
| "unresolved"
|
|
| "unknown";
|
|
|
|
type ReferenceSourceImportParseState = "parsed" | "partial" | "unknown";
|
|
|
|
interface ReferenceSourceImportRelationship {
|
|
readonly from_path: string;
|
|
readonly to: string;
|
|
readonly kind: ReferenceSourceImportKind;
|
|
readonly resolution: ReferenceSourceImportResolution;
|
|
readonly parse_state: ReferenceSourceImportParseState;
|
|
}
|
|
|
|
interface ReferenceSourceParseFailure {
|
|
readonly path: string;
|
|
readonly parser: string;
|
|
readonly reason: string;
|
|
}
|
|
|
|
export interface ReferenceSourceImportParseResult {
|
|
readonly relationships: readonly ReferenceSourceImportRelationship[];
|
|
readonly parse_failures: readonly ReferenceSourceParseFailure[];
|
|
}
|
|
|
|
const codeSourceLanguages = new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
|
|
|
|
const safeModuleName = (node: Node | null | undefined): string | undefined => {
|
|
if (isStringLiteral(node)) return node.value;
|
|
return undefined;
|
|
};
|
|
|
|
const resolveSpecifier = (
|
|
specifier: string,
|
|
): ReferenceSourceImportResolution => {
|
|
if (specifier.startsWith(".") || specifier.startsWith("/")) return "internal";
|
|
if (specifier.startsWith("node:") || specifier.startsWith("data:"))
|
|
return "external";
|
|
return "external";
|
|
};
|
|
|
|
const isModuleExpression = (
|
|
expression: Node | null | undefined,
|
|
): expression is StringLiteral | CallExpression =>
|
|
isStringLiteral(expression) || isCallExpression(expression);
|
|
|
|
const moduleSpecifierFromExpression = (
|
|
expression: Node | null | undefined,
|
|
):
|
|
| { specifier: string; parseState: ReferenceSourceImportParseState }
|
|
| undefined => {
|
|
if (isStringLiteral(expression))
|
|
return { specifier: expression.value, parseState: "parsed" };
|
|
if (
|
|
isCallExpression(expression) &&
|
|
isImport(expression.callee) &&
|
|
expression.arguments.length > 0
|
|
) {
|
|
const first = expression.arguments[0];
|
|
if (isStringLiteral(first)) {
|
|
return { specifier: first.value, parseState: "parsed" };
|
|
}
|
|
return { specifier: "<dynamic-import>", parseState: "partial" };
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const appendRelationship = (
|
|
relationships: ReferenceSourceImportRelationship[],
|
|
input: {
|
|
readonly fromPath: string;
|
|
readonly to: string | undefined;
|
|
readonly kind: ReferenceSourceImportKind;
|
|
readonly parseState: ReferenceSourceImportParseState;
|
|
readonly resolution?: ReferenceSourceImportResolution;
|
|
},
|
|
): void => {
|
|
if (input.to === undefined) return;
|
|
relationships.push({
|
|
from_path: input.fromPath,
|
|
to: input.to,
|
|
kind: input.kind,
|
|
resolution: input.resolution ?? resolveSpecifier(input.to),
|
|
parse_state: input.parseState,
|
|
});
|
|
};
|
|
|
|
const extractImportDeclarations = (
|
|
body: readonly Node[],
|
|
from_path: string,
|
|
relationships: ReferenceSourceImportRelationship[],
|
|
): void => {
|
|
for (const statement of body) {
|
|
if (isImportDeclaration(statement)) {
|
|
appendRelationship(relationships, {
|
|
fromPath: from_path,
|
|
to: safeModuleName(statement.source),
|
|
kind: "imports",
|
|
parseState: "parsed",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (isExportNamedDeclaration(statement)) {
|
|
if (statement.source !== null && statement.source !== undefined) {
|
|
appendRelationship(relationships, {
|
|
fromPath: from_path,
|
|
to: safeModuleName(statement.source),
|
|
kind: "imports",
|
|
parseState: "parsed",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isExportAllDeclaration(statement)) {
|
|
appendRelationship(relationships, {
|
|
fromPath: from_path,
|
|
to: safeModuleName(statement.source),
|
|
kind: "imports",
|
|
parseState: "parsed",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (isTSImportEqualsDeclaration(statement)) {
|
|
const reference = statement.moduleReference;
|
|
if (isTSExternalModuleReference(reference)) {
|
|
appendRelationship(relationships, {
|
|
fromPath: from_path,
|
|
to: safeModuleName(reference.expression),
|
|
kind: "requires",
|
|
parseState: "parsed",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isTSModuleDeclaration(statement)) {
|
|
if (statement.declare === true && isStringLiteral(statement.id)) {
|
|
appendRelationship(relationships, {
|
|
fromPath: from_path,
|
|
to: statement.id.value,
|
|
kind: "declares-module",
|
|
parseState: "parsed",
|
|
resolution: "unknown",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const isRequireCallee = (callee: Node | null | undefined): boolean => {
|
|
if (isIdentifier(callee) && callee.name === "require") return true;
|
|
if (
|
|
isMemberExpression(callee) &&
|
|
isIdentifier(callee.object) &&
|
|
callee.object.name === "require" &&
|
|
(isIdentifier(callee.property) || isStringLiteral(callee.property))
|
|
) {
|
|
const propertyName = isIdentifier(callee.property)
|
|
? callee.property.name
|
|
: isStringLiteral(callee.property)
|
|
? callee.property.value
|
|
: undefined;
|
|
return propertyName === "resolve" || propertyName === "main";
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const isImportCallee = (callee: Node | null | undefined): boolean =>
|
|
isImport(callee);
|
|
|
|
const collectModuleExpressions = (
|
|
node: unknown,
|
|
targets: Array<CallExpression | ImportExpression>,
|
|
): void => {
|
|
if (node === null || node === undefined) return;
|
|
if (typeof node !== "object") return;
|
|
if (Array.isArray(node)) {
|
|
for (const item of node) collectModuleExpressions(item, targets);
|
|
return;
|
|
}
|
|
if (!isNode(node)) return;
|
|
if (isCallExpression(node) || isImportExpression(node)) {
|
|
targets.push(node);
|
|
}
|
|
for (const value of Object.values(node)) {
|
|
if (
|
|
value !== null &&
|
|
value !== undefined &&
|
|
typeof value === "object" &&
|
|
!isSourceLocation(value)
|
|
) {
|
|
collectModuleExpressions(value, targets);
|
|
}
|
|
}
|
|
};
|
|
|
|
const isSourceLocation = (value: unknown): boolean =>
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"start" in value &&
|
|
"end" in value &&
|
|
!("type" in value);
|
|
|
|
const extractRequireAndDynamicImports = (
|
|
body: readonly Node[],
|
|
from_path: string,
|
|
relationships: ReferenceSourceImportRelationship[],
|
|
): void => {
|
|
const expressions: Array<CallExpression | ImportExpression> = [];
|
|
for (const statement of body)
|
|
collectModuleExpressions(statement, expressions);
|
|
|
|
for (const call of expressions) {
|
|
if (isImportExpression(call)) {
|
|
const specifier = safeModuleName(call.source);
|
|
appendRelationship(relationships, {
|
|
fromPath: from_path,
|
|
to: specifier ?? "<dynamic-import>",
|
|
kind: "imports",
|
|
parseState: specifier === undefined ? "partial" : "parsed",
|
|
...(specifier === undefined ? { resolution: "unknown" } : {}),
|
|
});
|
|
continue;
|
|
}
|
|
const first = call.arguments[0];
|
|
if (isRequireCallee(call.callee) && isModuleExpression(first)) {
|
|
const result = moduleSpecifierFromExpression(first);
|
|
if (result !== undefined) {
|
|
appendRelationship(relationships, {
|
|
fromPath: from_path,
|
|
to: result.specifier,
|
|
kind: "requires",
|
|
parseState: result.parseState,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isImportCallee(call.callee) && isModuleExpression(first)) {
|
|
const result = moduleSpecifierFromExpression(first);
|
|
if (result !== undefined) {
|
|
appendRelationship(relationships, {
|
|
fromPath: from_path,
|
|
to: result.specifier,
|
|
kind: "imports",
|
|
parseState: result.parseState,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const parseWithBabel = (
|
|
path: string,
|
|
source: string,
|
|
language: string | null,
|
|
): {
|
|
ast: File | undefined;
|
|
parseState: ReferenceSourceImportParseState;
|
|
reason?: string;
|
|
} => {
|
|
const plugins: ParserPlugin[] = ["jsx"];
|
|
if (language === "TypeScript" || language === "TSX") {
|
|
plugins.push([
|
|
"typescript",
|
|
{
|
|
dts:
|
|
path.endsWith(".d.ts") ||
|
|
path.endsWith(".d.mts") ||
|
|
path.endsWith(".d.cts"),
|
|
},
|
|
]);
|
|
}
|
|
|
|
try {
|
|
const ast = parse(source, {
|
|
sourceType: "unambiguous",
|
|
allowImportExportEverywhere: false,
|
|
allowReturnOutsideFunction: true,
|
|
allowUndeclaredExports: true,
|
|
errorRecovery: true,
|
|
plugins,
|
|
});
|
|
return { ast, parseState: "parsed" };
|
|
} catch (cause: unknown) {
|
|
return {
|
|
ast: undefined,
|
|
parseState: "unknown",
|
|
reason: cause instanceof Error ? cause.message : "Parse failed",
|
|
};
|
|
}
|
|
};
|
|
|
|
/** Parse ESM/CommonJS imports and dynamic imports from source bytes. */
|
|
export const parseReferenceSourceImports = (
|
|
path: string,
|
|
bytes: Uint8Array,
|
|
language: string | null,
|
|
): ReferenceSourceImportParseResult => {
|
|
if (language !== null && !codeSourceLanguages.has(language))
|
|
return { relationships: [], parse_failures: [] };
|
|
|
|
const source = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
const { ast, parseState, reason } = parseWithBabel(path, source, language);
|
|
|
|
if (ast === undefined || parseState === "unknown") {
|
|
return {
|
|
relationships: [],
|
|
parse_failures: [
|
|
{
|
|
path,
|
|
parser: "babel",
|
|
reason: reason ?? "Unknown parse failure",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const relationships: ReferenceSourceImportRelationship[] = [];
|
|
extractImportDeclarations(ast.program.body, path, relationships);
|
|
extractRequireAndDynamicImports(ast.program.body, path, relationships);
|
|
|
|
return {
|
|
relationships,
|
|
parse_failures: [],
|
|
};
|
|
};
|