Files
rea/src/domain/referenceSourceImportParsing.ts
T

334 lines
9.8 KiB
TypeScript

import type {
CallExpression,
File,
OptionalCallExpression,
Node,
} from "@babel/types";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { readExactJavaScriptLiteral } from "./javascript/javascriptAstValues.js";
import {
parseRecoveredJavaScript,
parserPluginsForPath,
} from "./javascript/javascriptSourceParser.js";
import {
referenceSourceRequireCalls,
type ReferenceSourceRequireCall,
} from "./referenceSourceRequireBindings.js";
import { isRelativeJavaScriptModuleSpecifier } from "./javascript/javascriptModuleSpecifier.js";
import {
isCallExpression,
isExportAllDeclaration,
isExportNamedDeclaration,
isImportDeclaration,
isImportExpression,
isOptionalCallExpression,
isTSImportType,
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;
/** Distinguishes call-form CommonJS require from TS import-equals `requires`. */
readonly resolutionStrategy?: "runtime-require";
/** Internal lookup origin for a proven createRequire URL base. */
readonly resolutionBasePath?: string;
}
interface ReferenceSourceParseFailure {
readonly path: string;
readonly parser: string;
readonly reason: string;
}
export interface ReferenceSourceImportParseResult {
readonly relationships: readonly ReferenceSourceImportRelationship[];
readonly parse_failures: readonly ReferenceSourceParseFailure[];
readonly limitations: readonly string[];
}
export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet<string> =
new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
const safeModuleName = (node: Node | null | undefined): string | undefined => {
if (isStringLiteral(node)) return node.value;
return undefined;
};
const exactStringModuleName = (
node: Node | null | undefined,
): string | undefined => {
if (node === null || node === undefined) return undefined;
const literal = readExactJavaScriptLiteral(node);
return literal.found && typeof literal.value === "string"
? literal.value
: undefined;
};
const resolveSpecifier = (
specifier: string,
): ReferenceSourceImportResolution => {
if (
isRelativeJavaScriptModuleSpecifier(specifier) ||
specifier.startsWith("/")
)
return "internal";
return "external";
};
const appendRelationship = (
relationships: ReferenceSourceImportRelationship[],
limitations: string[],
input: {
readonly fromPath: string;
readonly to: string | undefined;
readonly kind: ReferenceSourceImportKind;
readonly parseState: ReferenceSourceImportParseState;
readonly resolution?: ReferenceSourceImportResolution;
readonly resolutionStrategy?: "runtime-require";
readonly resolutionBasePath?: string;
},
): void => {
if (input.to === undefined) return;
if (input.to.length === 0) {
limitations.push(
`Empty module specifier in ${input.fromPath} was not recorded as a relationship.`,
);
return;
}
relationships.push({
from_path: input.fromPath,
to: input.to,
kind: input.kind,
resolution: input.resolution ?? resolveSpecifier(input.to),
parse_state: input.parseState,
...(input.resolutionStrategy === undefined
? {}
: { resolutionStrategy: input.resolutionStrategy }),
...(input.resolutionBasePath === undefined
? {}
: { resolutionBasePath: input.resolutionBasePath }),
});
};
const appendImportEquals = (
statement: Node,
from_path: string,
relationships: ReferenceSourceImportRelationship[],
limitations: string[],
): void => {
if (!isTSImportEqualsDeclaration(statement)) return;
const reference = statement.moduleReference;
if (!isTSExternalModuleReference(reference)) return;
appendRelationship(relationships, limitations, {
fromPath: from_path,
to: safeModuleName(reference.expression),
kind: "requires",
parseState: "parsed",
});
};
/** One AST owner retains historical declarations, types and runtime requests. */
const extractImports = (
ast: File,
from_path: string,
relationships: ReferenceSourceImportRelationship[],
limitations: string[],
requireCalls: ReadonlyMap<
CallExpression | OptionalCallExpression,
ReferenceSourceRequireCall
>,
): void => {
traverseJavaScriptAst(ast.program, {
enter: (node) => {
if (
isImportDeclaration(node) ||
isExportAllDeclaration(node) ||
(isExportNamedDeclaration(node) && node.source != null)
) {
appendRelationship(relationships, limitations, {
fromPath: from_path,
to: safeModuleName(node.source),
kind: "imports",
parseState: "parsed",
});
} else if (isTSImportEqualsDeclaration(node)) {
appendImportEquals(node, from_path, relationships, limitations);
} else if (isTSModuleDeclaration(node)) {
if (node.declare === true && isStringLiteral(node.id))
appendRelationship(relationships, limitations, {
fromPath: from_path,
to: node.id.value,
kind: "declares-module",
parseState: "parsed",
resolution: "unknown",
});
} else if (isImportExpression(node) || isTSImportType(node)) {
const specifier = exactStringModuleName(
isTSImportType(node) ? node.argument : node.source,
);
appendRelationship(relationships, limitations, {
fromPath: from_path,
to: specifier ?? "<dynamic-import>",
kind: "imports",
parseState: specifier === undefined ? "partial" : "parsed",
...(specifier === undefined ? { resolution: "unknown" } : {}),
});
} else if (isCallExpression(node) || isOptionalCallExpression(node)) {
const requireCall = requireCalls.get(node);
if (requireCall === undefined) return;
const specifier = exactStringModuleName(requireCall.arguments?.[0]);
if (specifier === undefined) return;
const uncertainResolution = requireCall.resolutionBase === "unknown";
appendRelationship(relationships, limitations, {
fromPath: from_path,
to: specifier,
kind: "requires",
parseState: uncertainResolution ? "partial" : "parsed",
...(uncertainResolution ? { resolution: "unknown" } : {}),
...(uncertainResolution
? {}
: { resolutionStrategy: "runtime-require" as const }),
...(typeof requireCall.resolutionBase === "object"
? { resolutionBasePath: requireCall.resolutionBase.path }
: {}),
});
}
},
});
};
const parseWithBabel = (
path: string,
source: string,
language: string | null,
): {
ast: File | undefined;
reasons: readonly string[];
moduleOnlyErrors: readonly string[];
} => {
// Single parser-mode owner: path-driven plugins (dts/JSX/mts) shared with
// the semantic pipeline. `language` only gates non-JS/TS callers upstream;
// TypeScript syntax must parse identically here and in semantic analysis.
void language;
const plugins = parserPluginsForPath(path);
try {
const { file: ast, moduleOnlyErrors } = parseRecoveredJavaScript(
source,
path,
{
allowImportExportEverywhere: false,
allowReturnOutsideFunction: true,
allowUndeclaredExports: true,
plugins,
},
);
return {
ast,
reasons: ast.errors.map((error) => error.message),
moduleOnlyErrors,
};
} catch (cause: unknown) {
return {
ast: undefined,
reasons: [cause instanceof Error ? cause.message : "Parse failed"],
moduleOnlyErrors: [],
};
}
};
/** Parse ESM/CommonJS imports and dynamic imports from source bytes. */
export const parseReferenceSourceImports = (
path: string,
bytes: Uint8Array,
language: string | null,
packageType?: "module" | "commonjs" | "unknown",
): ReferenceSourceImportParseResult => {
if (language !== null && !PARSEABLE_REFERENCE_SOURCE_LANGUAGES.has(language))
return { relationships: [], parse_failures: [], limitations: [] };
let source: string;
try {
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return {
relationships: [],
parse_failures: [
{
path,
parser: "utf-8",
reason:
"Source bytes are not valid UTF-8; import targets were not parsed.",
},
],
limitations: [],
};
}
const { ast, reasons, moduleOnlyErrors } = parseWithBabel(
path,
source,
language,
);
const limitations =
moduleOnlyErrors.length === 0
? []
: [
`Strict-mode errors apply only if this file is an ES module: ${moduleOnlyErrors.join("; ")}`,
];
const parseFailures = reasons.map((reason) => ({
path,
parser: "babel",
reason,
}));
if (ast === undefined) {
return {
relationships: [],
parse_failures: parseFailures,
limitations,
};
}
const relationships: ReferenceSourceImportRelationship[] = [];
extractImports(
ast,
path,
relationships,
limitations,
referenceSourceRequireCalls(ast, path, packageType),
);
return {
relationships: relationships.map((relationship) => ({
...relationship,
parse_state:
parseFailures.length > 0 && relationship.parse_state === "parsed"
? "partial"
: relationship.parse_state,
})),
parse_failures: parseFailures,
limitations,
};
};