fix(javascript): centralize module syntax and separate erased type dependencies

This commit is contained in:
morluto committed 2026-10-13 14:39:06 +08:00
1 parent e1cd292aa6
commit 1b953021ea
18 files changed
+513 -134

No files matched your search

+8
View File
@@ -23,6 +23,14 @@ artifact digest and structural graph ID. Semantic tracing requires the semantic
relation graph and reports when it is unavailable rather than treating missing
data as an empty graph.
Explicit TypeScript `import type` and `export type` declarations remain source
facts and do not create runtime import or export relationships. Type export names
are reported separately from value export names. Inline `type` specifiers can
leave an empty import or re-export with side effects under
[`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/verbatimModuleSyntax.html);
REA retains those dependency candidates and reports the unavailable compiler
options rather than assuming the dependency is erased.
The semantic graph wire format stores shared non-location provenance once in
its required `evidence_contexts` table. Each node, relation, fingerprint, and
unknown retains its exact `evidence.location` and names the owning context with
@@ -12,12 +12,14 @@ import {
detectReferenceSourceLanguage,
} from "../domain/referenceSourceClassification.js";
import type { HistoricalSourceGraphInput } from "../domain/referenceSourceGraph.js";
import { parseReferenceSourceImports } from "../domain/referenceSourceImportParsing.js";
import {
parseReferenceSourceImports,
PARSEABLE_REFERENCE_SOURCE_LANGUAGES,
} from "../domain/referenceSourceImportParsing.js";
import type {
ReferenceSourceEntry,
ReferenceSourceRead,
} from "../reference/ReferenceSourceReaderTypes.js";
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
export interface ParsedReferenceSourceEntries {
@@ -87,6 +87,3 @@ export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [
".coverage",
"*.log",
] as const;
export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet<string> =
new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
@@ -20,6 +20,7 @@ import {
lookupExactPackageImport,
} from "../../domain/javascript/javascriptPackageImportAliases.js";
import { typescriptEmittedSourceCandidates } from "../TypeScriptEmittedSourceCandidates.js";
import { isRelativeJavaScriptModuleSpecifier } from "../../domain/javascript/javascriptModuleSpecifier.js";
type ArtifactPathResolutionContext =
| "package-entrypoint"
@@ -166,7 +167,7 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
);
const isModuleUrlPath =
input.context === "module-specifier" &&
(input.declaredPath.startsWith(".") ||
(isRelativeJavaScriptModuleSpecifier(input.declaredPath) ||
input.declaredPath.startsWith("/") ||
fileUrlPath(input.declaredPath) !== undefined);
const emittedSourceCandidates =
@@ -229,7 +230,10 @@ const contextualCandidate = (
return unresolvedCandidate("external", [
"URL and Node builtin schemes are outside static artifact module resolution.",
]);
if (!declared.startsWith(".") && !declared.startsWith("/"))
if (
!isRelativeJavaScriptModuleSpecifier(declared) &&
!declared.startsWith("/")
)
return bareModuleCandidate(input);
if (input.moduleKind !== "require") {
const decoded = decodeModuleUrlPath(stripQueryAndFragment(declared));
@@ -275,7 +279,10 @@ const contextualCandidate = (
"URL",
);
}
return relativeCandidate(input.sourcePath, declared);
const candidate = relativeCandidate(input.sourcePath, declared);
return context === "module-specifier" && input.moduleKind === "import"
? exactUrlPathCandidate(declared, candidate, "module URL")
: candidate;
};
const decodeModuleUrlPath = (
@@ -310,9 +317,12 @@ const bareModuleCandidate = (
return unresolvedCandidate("external", [
"The bare specifier is a Node builtin or invalid package name.",
]);
if (input.moduleKind === "import" && request.packageName.includes("%"))
if (
input.moduleKind === "import" &&
(request.packageName.startsWith(".") || request.packageName.includes("%"))
)
return unresolvedCandidate("rejected", [
"Percent signs are not admitted in ES module package names.",
"ES module package names cannot start with a dot or contain percent signs.",
]);
const self = packageSelfCandidate(input, request);
if (self !== null) return self;
@@ -735,7 +745,7 @@ const htmlCandidate = (
const exactUrlPathCandidate = (
declared: string,
candidate: string,
kind: "HTML" | "URL",
kind: "HTML" | "URL" | "module URL",
): string | CandidateResolution => {
if (!urlPathIsDirectory(declared)) return candidate;
const confined = confineCandidate(candidate);
@@ -81,8 +81,13 @@ export const addJavaScriptSourceModules = (
if (asset === undefined || semantic === null || program === undefined)
continue;
const exports = semantic.ir.moduleLinks
.flatMap(({ exportedName }) =>
exportedName === null ? [] : [exportedName],
.flatMap(({ exportedName, kind }) =>
exportedName === null || kind === "type-import" ? [] : [exportedName],
)
.sort(compareUnicodeCodePoints);
const typeExports = semantic.ir.moduleLinks
.flatMap(({ exportedName, kind }) =>
exportedName === null || kind !== "type-import" ? [] : [exportedName],
)
.sort(compareUnicodeCodePoints);
const retainedExports = [...new Set(exports)];
@@ -105,8 +110,12 @@ export const addJavaScriptSourceModules = (
import_relationships: semantic.ir.moduleLinks.filter(
({ kind }) => kind === "import" || kind === "require",
).length,
type_relationships: semantic.ir.moduleLinks.filter(
({ kind }) => kind === "type-import",
).length,
export_relationships: exports.length,
export_names: retainedExports,
type_export_names: [...new Set(typeExports)],
omitted_export_names: 0,
},
evidence: astObservationEvidence({
@@ -152,6 +161,7 @@ export function* addJavaScriptModuleRelationshipsSteps(
for (const link of semantic.ir.moduleLinks) {
// One module can export thousands of bindings, each with return shapes.
yield;
if (link.kind === "type-import") continue;
const input = { context, file, semantic, source, link };
if (isExportLink(link)) addExportRelationship(input, omissions);
else if (link.specifier !== null)
@@ -0,0 +1,60 @@
import * as t from "@babel/types";
/** Whether TypeScript explicitly erases this entire import declaration. */
export const isTypeOnlyImportDeclaration = (
node: t.ImportDeclaration,
): boolean => node.importKind === "type" || node.importKind === "typeof";
/** Whether TypeScript explicitly erases this entire export declaration. */
export const isTypeOnlyExportDeclaration = (
node: t.ExportNamedDeclaration | t.ExportAllDeclaration,
): boolean =>
node.exportKind === "type" ||
(t.isExportNamedDeclaration(node) &&
node.declaration !== null &&
(t.isTSInterfaceDeclaration(node.declaration) ||
t.isTSTypeAliasDeclaration(node.declaration) ||
t.isTSDeclareFunction(node.declaration)));
/** Whether this import binding is explicitly type-only. */
export const isTypeOnlyImportSpecifier = (node: t.ImportSpecifier): boolean =>
node.importKind === "type" || node.importKind === "typeof";
/** Whether this exported binding is explicitly type-only. */
export const isTypeOnlyExportSpecifier = (node: t.ExportSpecifier): boolean =>
node.exportKind === "type";
/** A regular import whose bindings are all erased but may retain side effects. */
export const hasOnlyInlineTypeImportSpecifiers = (
node: t.ImportDeclaration,
): boolean =>
!isTypeOnlyImportDeclaration(node) &&
node.specifiers.length > 0 &&
node.specifiers.every(
(specifier) =>
t.isImportSpecifier(specifier) && isTypeOnlyImportSpecifier(specifier),
);
/** A regular re-export whose bindings are all erased but may retain side effects. */
export const hasOnlyInlineTypeExportSpecifiers = (
node: t.ExportNamedDeclaration,
): boolean =>
!isTypeOnlyExportDeclaration(node) &&
node.source != null &&
node.specifiers.length > 0 &&
node.specifiers.every(
(specifier) =>
t.isExportSpecifier(specifier) && isTypeOnlyExportSpecifier(specifier),
);
/** Whether compiler options leave an inline type-only declaration's side effect unknown. */
export const hasUncertainTypeOnlyModuleSideEffect = (
program: t.Program,
): boolean =>
program.body.some(
(node) =>
(t.isImportDeclaration(node) &&
hasOnlyInlineTypeImportSpecifiers(node)) ||
(t.isExportNamedDeclaration(node) &&
hasOnlyInlineTypeExportSpecifiers(node)),
);
@@ -0,0 +1,6 @@
/** Relative module syntax differs from an arbitrary dot-prefixed filename. */
export const isRelativeJavaScriptModuleSpecifier = (value: string): boolean =>
value === "." ||
value === ".." ||
value.startsWith("./") ||
value.startsWith("../");
@@ -22,6 +22,11 @@ describe("TypeScript import-equals links", () => {
localName: "dependency",
exportedName: null,
}),
expect.objectContaining({
kind: "type-import",
specifier: "./types",
localName: "types",
}),
]);
});
@@ -51,6 +56,57 @@ describe("TypeScript import-equals links", () => {
});
});
describe("TypeScript type-only module links", () => {
it("retains declarations without runtime bindings or callable claims", () => {
const ir = analyzeJavaScriptSemantics(`
import type { Whole } from "./whole";
import { type Inline } from "./inline";
import { type Erased, value } from "./mixed";
export type { Reexported } from "./reexported";
export { type InlineExport } from "./inline-export";
export type LocalType = string;
export interface LocalInterface { value: string }
function hiddenFunction() { return 1; }
export type { hiddenFunction };
`);
const externalLinks = ir.moduleLinks
.filter(({ specifier }) => specifier !== null)
.map(
({ kind, specifier, importedName, exportedName }) =>
[kind, specifier, importedName, exportedName] as const,
);
expect(externalLinks).toEqual([
["type-import", "./whole", "Whole", null],
["type-import", "./inline", "Inline", null],
["type-import", "./mixed", "Erased", null],
["import", "./mixed", "value", null],
["type-import", "./reexported", "Reexported", "Reexported"],
["type-import", "./inline-export", "InlineExport", "InlineExport"],
]);
const typeLinks = ir.moduleLinks
.filter(({ kind }) => kind === "type-import")
.map(
({ specifier, localName, exportedName, callableId }) =>
[specifier, localName, exportedName, callableId] as const,
);
expect(typeLinks).toEqual([
["./whole", "Whole", null, null],
["./inline", "Inline", null, null],
["./mixed", "Erased", null, null],
["./reexported", null, "Reexported", null],
["./inline-export", null, "InlineExport", null],
[null, "LocalType", "LocalType", null],
[null, "LocalInterface", "LocalInterface", null],
[null, "hiddenFunction", "hiddenFunction", null],
]);
expect(ir.limitations).toContain(
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
);
});
});
describe("CommonJS dependency links", () => {
it("retains an import-equals binding and its module origin", () => {
const ir = analyzeJavaScriptSemantics(`
@@ -76,6 +132,12 @@ describe("CommonJS dependency links", () => {
importedName: null,
localName: "typed",
}),
expect.objectContaining({
kind: "type-import",
specifier: "type-only",
importedName: null,
localName: "TypeOnly",
}),
]);
expect(ir.references.filter(({ name }) => name === "typed")).toHaveLength(
1,
@@ -43,6 +43,7 @@ import {
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
import { indexJavaScriptAmbientWritesSteps } from "./javascriptSemanticAmbientWrites.js";
import { hasUncertainTypeOnlyModuleSideEffect } from "./javascriptModuleDeclaration.js";
import {
collectSemanticReturns,
resolveSemanticModuleCallables,
@@ -200,6 +201,11 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
"The parser recovered from syntax errors; affected bindings are partial.",
]
: []),
...(hasUncertainTypeOnlyModuleSideEffect(file.program)
? [
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
]
: []),
"Values and aliases were recovered from inert syntax only; no JavaScript was executed.",
"Return sites include only direct callable returns; nested callable returns remain separate.",
"Local call, argument, return, and closure relations are static candidates and do not prove runtime invocation.",
@@ -28,6 +28,12 @@ import {
propertyName,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import {
isTypeOnlyExportDeclaration,
isTypeOnlyExportSpecifier,
isTypeOnlyImportDeclaration,
isTypeOnlyImportSpecifier,
} from "./javascriptModuleDeclaration.js";
interface CollectCallableInput {
readonly node: t.Node;
@@ -75,7 +81,7 @@ export const collectSemanticModuleLink = (
addModuleLink(
state,
{
kind: "re-export",
kind: isTypeOnlyExportDeclaration(node) ? "type-import" : "re-export",
specifier: node.source.value,
importedName: "*",
localName: null,
@@ -108,12 +114,19 @@ const collectImportEquals = (
node: t.TSImportEqualsDeclaration,
state: JavaScriptSemanticAnalysisState,
): void => {
const specifier = semanticImportEqualsSpecifier(node);
const moduleReference = node.moduleReference;
const specifier =
semanticImportEqualsSpecifier(node) ??
(node.importKind === "type" &&
t.isTSExternalModuleReference(moduleReference) &&
t.isStringLiteral(moduleReference.expression)
? moduleReference.expression.value
: null);
if (specifier === null) return;
addModuleLink(
state,
{
kind: "require",
kind: node.importKind === "type" ? "type-import" : "require",
specifier,
importedName: null,
localName: node.id.name,
@@ -287,7 +300,7 @@ const collectImports = (
addModuleLink(
state,
{
kind: "import",
kind: isTypeOnlyImportDeclaration(node) ? "type-import" : "import",
specifier: node.source.value,
importedName: null,
localName: null,
@@ -300,7 +313,12 @@ const collectImports = (
addModuleLink(
state,
{
kind: "import",
kind:
isTypeOnlyImportDeclaration(node) ||
(t.isImportSpecifier(specifier) &&
isTypeOnlyImportSpecifier(specifier))
? "type-import"
: "import",
specifier: node.source.value,
importedName: t.isImportDefaultSpecifier(specifier)
? "default"
@@ -319,12 +337,13 @@ const collectNamedExports = (
node: t.ExportNamedDeclaration,
state: JavaScriptSemanticAnalysisState,
): void => {
const declarationTypeOnly = isTypeOnlyExportDeclaration(node);
if (node.declaration !== null && node.declaration !== undefined)
for (const localName of declarationNames(node.declaration))
addModuleLink(
state,
{
kind: "export",
kind: declarationTypeOnly ? "type-import" : "export",
specifier: null,
importedName: null,
localName,
@@ -342,7 +361,14 @@ const collectNamedExports = (
addModuleLink(
state,
{
kind: node.source == null ? "export" : "re-export",
kind:
declarationTypeOnly ||
(t.isExportSpecifier(specifier) &&
isTypeOnlyExportSpecifier(specifier))
? "type-import"
: node.source == null
? "export"
: "re-export",
specifier: node.source?.value ?? null,
importedName: t.isExportNamespaceSpecifier(specifier)
? "*"
@@ -745,6 +771,13 @@ const declarationNames = (declaration: t.Declaration): string[] => {
t.isIdentifier(declaration.id)
)
return [declaration.id.name];
if (
(t.isTSInterfaceDeclaration(declaration) ||
t.isTSTypeAliasDeclaration(declaration) ||
t.isTSDeclareFunction(declaration)) &&
t.isIdentifier(declaration.id)
)
return [declaration.id.name];
if (
t.isTSImportEqualsDeclaration(declaration) &&
semanticImportEqualsSpecifier(declaration) !== null
@@ -101,6 +101,7 @@ export const resolveSemanticModuleCallables = (
bindings.map(({ bindingId, provenance }) => [bindingId, provenance]),
);
return state.moduleLinks.map((link) => {
if (link.kind === "type-import") return { ...link, callableId: null };
// Named exports follow their lexical binding, including every assignment.
// A declaration's ID or a callable's display name cannot prove that link.
if (link.localName === null)
@@ -53,6 +53,11 @@ import {
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
import { semanticImportEqualsSpecifier } from "./javascriptSemanticProjection.js";
import {
hasUncertainTypeOnlyModuleSideEffect,
isTypeOnlyExportDeclaration,
isTypeOnlyImportDeclaration,
} from "./javascriptModuleDeclaration.js";
/** Parse one JavaScript artifact and recover static structure only. */
export const analyzeJavaScriptStaticSource = (
@@ -154,6 +159,11 @@ const finalizeStaticAnalysis = (
: [
"One or more static keys, expressions, or Electron boundary values were dynamic and remain unknown.",
]),
...(hasUncertainTypeOnlyModuleSideEffect(file.program)
? [
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
]
: []),
...(accumulator.references.some(
({ value }) =>
value.specifier !== null && ELECTRON_MODULE.test(value.specifier),
@@ -246,14 +256,20 @@ const inspectNode = (
addReference(findings, { node, kind: "require", specifier });
} else if (
(t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) &&
node.source !== undefined
node.source !== undefined &&
!(t.isImportDeclaration(node) && isTypeOnlyImportDeclaration(node)) &&
!(t.isExportAllDeclaration(node) && isTypeOnlyExportDeclaration(node))
)
addReference(findings, {
node,
kind: "static-import",
specifier: node.source.value,
});
else if (t.isExportNamedDeclaration(node) && node.source != null)
else if (
t.isExportNamedDeclaration(node) &&
node.source != null &&
!isTypeOnlyExportDeclaration(node)
)
addReference(findings, {
node,
kind: "static-import",
+9 -5
View File
@@ -11,6 +11,7 @@ import {
parserPluginsForPath,
} from "./javascript/javascriptSourceParser.js";
import { unshadowedReferenceSourceRequireCalls } from "./referenceSourceRequireBindings.js";
import { isRelativeJavaScriptModuleSpecifier } from "./javascript/javascriptModuleSpecifier.js";
import {
isCallExpression,
isExportAllDeclaration,
@@ -60,7 +61,8 @@ export interface ReferenceSourceImportParseResult {
readonly limitations: readonly string[];
}
const codeSourceLanguages = new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
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;
@@ -70,9 +72,11 @@ const safeModuleName = (node: Node | null | undefined): string | undefined => {
const resolveSpecifier = (
specifier: string,
): ReferenceSourceImportResolution => {
if (specifier.startsWith(".") || specifier.startsWith("/")) return "internal";
if (specifier.startsWith("node:") || specifier.startsWith("data:"))
return "external";
if (
isRelativeJavaScriptModuleSpecifier(specifier) ||
specifier.startsWith("/")
)
return "internal";
return "external";
};
@@ -314,7 +318,7 @@ export const parseReferenceSourceImports = (
bytes: Uint8Array,
language: string | null,
): ReferenceSourceImportParseResult => {
if (language !== null && !codeSourceLanguages.has(language))
if (language !== null && !PARSEABLE_REFERENCE_SOURCE_LANGUAGES.has(language))
return { relationships: [], parse_failures: [], limitations: [] };
let source: string;
+9 -2
View File
@@ -25,11 +25,18 @@ describe("native module syntax observations", () => {
item.expression,
);
});
it("excludes erased TypeScript imports/exports and bundler inference", () => {
it("excludes whole type declarations and retains inline type dependency syntax", () => {
const result = collectWebModuleImports(
'import type {T} from "types"; import {type X} from "also-types"; export type {T} from "export-types"; export {type Y} from "only-types"; import {type T, value} from "mixed"; __webpack_require__.e(4); __vitePreload(() => 1, ["chunk.js"]);',
);
expect(result.imports.map((item) => item.specifier)).toEqual(["mixed"]);
expect(result.imports.map((item) => item.specifier)).toEqual([
"also-types",
"only-types",
"mixed",
]);
expect(result.diagnostics).toContain(
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
);
});
it("uses UTF-16 source locations after astral characters", () => {
const source = 'const emoji = "😀";\nimport "./x.js";';
+16 -21
View File
@@ -4,8 +4,13 @@ import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.
import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js";
import type { WebModuleImport } from "./webModuleTrace.js";
import type { ExportedWebScript } from "./webScriptExport.js";
import {
hasUncertainTypeOnlyModuleSideEffect,
isTypeOnlyExportDeclaration,
isTypeOnlyImportDeclaration,
} from "./javascript/javascriptModuleDeclaration.js";
/** Collect native import syntax, excluding erased type-only references. */
/** Collect module declarations, omitting whole-statement type-only forms. */
export const collectWebModuleImports = (
source: string,
): {
@@ -55,35 +60,18 @@ export const collectWebModuleImports = (
traverseJavaScriptAst(ast, {
enter: (node) => {
if (node.type === "ImportDeclaration") {
if (node.importKind === "type" || node.importKind === "typeof") return;
if (
node.specifiers.length > 0 &&
node.specifiers.every(
(s) =>
s.type === "ImportSpecifier" &&
(s.importKind === "type" || s.importKind === "typeof"),
)
)
return;
if (isTypeOnlyImportDeclaration(node)) return;
add("static-import", node.source);
} else if (
node.type === "ExportAllDeclaration" ||
node.type === "ExportNamedDeclaration"
) {
if (
node.exportKind === "type" ||
isTypeOnlyExportDeclaration(node) ||
node.source === null ||
node.source === undefined
)
return;
if (
node.type === "ExportNamedDeclaration" &&
node.specifiers.length > 0 &&
node.specifiers.every(
(s) => s.type === "ExportSpecifier" && s.exportKind === "type",
)
)
return;
add("re-export", node.source);
} else if (node.type === "ImportExpression")
add("dynamic-import", node.source);
@@ -99,7 +87,14 @@ export const collectWebModuleImports = (
return {
imports,
state: "parsed",
diagnostics: ast.errors?.map((error) => error.message) ?? [],
diagnostics: [
...(ast.errors?.map((error) => error.message) ?? []),
...(hasUncertainTypeOnlyModuleSideEffect(ast.program)
? [
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
]
: []),
],
};
};
@@ -29,6 +29,14 @@ const createModuleLookupFixture = async () => {
'import "ancestor-empty";',
'import "ancestor-blocked";',
'import "ancestor-sub/parser";',
'import "./.hidden.js";',
'import "./..pkg.js";',
'import "./exact.js/.";',
'import "./exact.js/child/..";',
'import ".hidden.js";',
'import "..pkg.js";',
'import ".";',
'import "..";',
"void fallback;",
].join("\n"),
"esm/extensionless.js": "module.exports = 1;",
@@ -36,6 +44,10 @@ const createModuleLookupFixture = async () => {
"esm/explicit.js.js": "module.exports = 3;",
"esm/exact.js": "module.exports = 4;",
"esm/actual.cjs": "module.exports = 5;",
"esm/.hidden.js": "module.exports = 'local hidden';",
"esm/..pkg.js": "module.exports = 'local dot package';",
"node_modules/.hidden.js/index.js": "module.exports = 'hidden package';",
"node_modules/..pkg.js/index.js": "module.exports = 'dot package';",
"esm/typed.mts": 'import value from "./typed-dep.js"; void value;',
"esm/typed-dep.ts": "export default 42;",
"cjs/entry.cjs": [
@@ -54,8 +66,15 @@ const createModuleLookupFixture = async () => {
'require("ancestor-empty");',
'require("ancestor-blocked");',
'require("ancestor-sub/parser");',
'require(".hidden.js");',
'require("./.hidden.js");',
'require(".");',
'require("..");',
].join("\n"),
"cjs/directory/index.cjs": "module.exports = 6;",
"cjs/index.js": "module.exports = 'current directory';",
"index.js": "module.exports = 'parent directory';",
"cjs/.hidden.js": "module.exports = 'local hidden';",
"cjs/extensionless.cjs": "module.exports = 1;",
"cjs/feature.cjs": "module.exports = 2;",
"cjs/module-only.mjs": "export default 42;",
@@ -79,7 +98,7 @@ const createModuleLookupFixture = async () => {
"node_modules/ancestor-blocked/index.js": "module.exports = 9;",
"node_modules/ancestor-sub/parser.js": "module.exports = 10;",
"esm/runtime-control.mjs": [
'const specifiers = ["./extensionless", "./directory", "./explicit.js", "./exact.js", "./actual.cjs?cache=1", "empty-main", "nested-main", "file-only", "ancestor-empty", "ancestor-blocked", "ancestor-sub/parser"];',
'const specifiers = ["./extensionless", "./directory", "./explicit.js", "./exact.js", "./actual.cjs?cache=1", "empty-main", "nested-main", "file-only", "ancestor-empty", "ancestor-blocked", "ancestor-sub/parser", "./.hidden.js", "./..pkg.js", "./exact.js/.", "./exact.js/child/..", ".hidden.js", "..pkg.js", ".", ".."];',
"const results = [];",
"for (const specifier of specifiers) {",
" try { const value = (await import(specifier)).default; results.push({ specifier, ok: true, value }); }",
@@ -88,7 +107,7 @@ const createModuleLookupFixture = async () => {
"console.log(JSON.stringify(results));",
].join("\n"),
"cjs/runtime-control.cjs": [
'const specifiers = ["./extensionless", "./directory", "./feature", "./feature.cjs", "./module-only", "./valid-directory", "./default-js", "./default-json", "empty-main", "nested-main", "file-only", "ancestor-empty", "ancestor-blocked", "ancestor-sub/parser"];',
'const specifiers = ["./extensionless", "./directory", "./feature", "./feature.cjs", "./module-only", "./valid-directory", "./default-js", "./default-json", "empty-main", "nested-main", "file-only", "ancestor-empty", "ancestor-blocked", "ancestor-sub/parser", ".hidden.js", "./.hidden.js", ".", ".."];',
"const results = [];",
"for (const specifier of specifiers) {",
" try { const value = require(specifier); results.push({ specifier, ok: true, value }); }",
@@ -109,6 +128,110 @@ const createModuleLookupFixture = async () => {
return root;
};
type RuntimeControlResult = {
readonly specifier: string;
readonly ok: boolean;
readonly code?: string;
readonly value?: unknown;
};
const expectNodeRuntimeControls = (
esmOutput: string,
cjsOutput: string,
): void => {
const esmResults = JSON.parse(esmOutput) as RuntimeControlResult[];
const cjsResults = JSON.parse(cjsOutput) as RuntimeControlResult[];
expect(esmResults).toEqual([
{
specifier: "./extensionless",
ok: false,
code: "ERR_MODULE_NOT_FOUND",
},
{
specifier: "./directory",
ok: false,
code: "ERR_UNSUPPORTED_DIR_IMPORT",
},
{
specifier: "./explicit.js",
ok: false,
code: "ERR_MODULE_NOT_FOUND",
},
{ specifier: "./exact.js", ok: true, value: 4 },
{ specifier: "./actual.cjs?cache=1", ok: true, value: 5 },
{ specifier: "empty-main", ok: true, value: 42 },
{ specifier: "nested-main", ok: false, code: "ERR_MODULE_NOT_FOUND" },
{ specifier: "file-only", ok: false, code: "ERR_MODULE_NOT_FOUND" },
{ specifier: "ancestor-empty", ok: false, code: "ERR_MODULE_NOT_FOUND" },
{
specifier: "ancestor-blocked",
ok: false,
code: "ERR_MODULE_NOT_FOUND",
},
{
specifier: "ancestor-sub/parser",
ok: false,
code: "ERR_MODULE_NOT_FOUND",
},
{ specifier: "./.hidden.js", ok: true, value: "local hidden" },
{ specifier: "./..pkg.js", ok: true, value: "local dot package" },
{
specifier: "./exact.js/.",
ok: false,
code: "ERR_UNSUPPORTED_DIR_IMPORT",
},
{
specifier: "./exact.js/child/..",
ok: false,
code: "ERR_UNSUPPORTED_DIR_IMPORT",
},
{
specifier: ".hidden.js",
ok: false,
code: "ERR_INVALID_MODULE_SPECIFIER",
},
{
specifier: "..pkg.js",
ok: false,
code: "ERR_INVALID_MODULE_SPECIFIER",
},
{ specifier: ".", ok: false, code: "ERR_UNSUPPORTED_DIR_IMPORT" },
{ specifier: "..", ok: false, code: "ERR_UNSUPPORTED_DIR_IMPORT" },
]);
expect(cjsResults).toEqual([
{
specifier: "./extensionless",
ok: false,
code: "MODULE_NOT_FOUND",
},
{
specifier: "./directory",
ok: false,
code: "MODULE_NOT_FOUND",
},
{
specifier: "./feature",
ok: false,
code: "MODULE_NOT_FOUND",
},
{ specifier: "./feature.cjs", ok: true, value: 2 },
{ specifier: "./module-only", ok: false, code: "MODULE_NOT_FOUND" },
{ specifier: "./valid-directory", ok: true, value: 42 },
{ specifier: "./default-js", ok: true, value: 3 },
{ specifier: "./default-json", ok: true, value: { value: 4 } },
{ specifier: "empty-main", ok: true, value: 42 },
{ specifier: "nested-main", ok: false, code: "MODULE_NOT_FOUND" },
{ specifier: "file-only", ok: true, value: 7 },
{ specifier: "ancestor-empty", ok: true, value: 8 },
{ specifier: "ancestor-blocked", ok: false, code: "MODULE_NOT_FOUND" },
{ specifier: "ancestor-sub/parser", ok: true, value: 10 },
{ specifier: ".hidden.js", ok: true, value: "hidden package" },
{ specifier: "./.hidden.js", ok: true, value: "local hidden" },
{ specifier: ".", ok: true, value: "current directory" },
{ specifier: "..", ok: true, value: "parent directory" },
]);
};
cliTest.for(["CLI", "stdio MCP"] as const)(
"matches Node module lookup for ESM and CommonJS through %s",
async (surface, { cli }) => {
@@ -123,79 +246,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
timeout: 5_000,
}),
]);
const esmResults = JSON.parse(esmControl.stdout) as Array<{
readonly specifier: string;
readonly ok: boolean;
readonly code?: string;
readonly value?: unknown;
}>;
const cjsResults = JSON.parse(cjsControl.stdout) as Array<{
readonly specifier: string;
readonly ok: boolean;
readonly code?: string;
readonly value?: unknown;
}>;
expect(esmResults).toEqual([
{
specifier: "./extensionless",
ok: false,
code: "ERR_MODULE_NOT_FOUND",
},
{
specifier: "./directory",
ok: false,
code: "ERR_UNSUPPORTED_DIR_IMPORT",
},
{
specifier: "./explicit.js",
ok: false,
code: "ERR_MODULE_NOT_FOUND",
},
{ specifier: "./exact.js", ok: true, value: 4 },
{ specifier: "./actual.cjs?cache=1", ok: true, value: 5 },
{ specifier: "empty-main", ok: true, value: 42 },
{ specifier: "nested-main", ok: false, code: "ERR_MODULE_NOT_FOUND" },
{ specifier: "file-only", ok: false, code: "ERR_MODULE_NOT_FOUND" },
{ specifier: "ancestor-empty", ok: false, code: "ERR_MODULE_NOT_FOUND" },
{
specifier: "ancestor-blocked",
ok: false,
code: "ERR_MODULE_NOT_FOUND",
},
{
specifier: "ancestor-sub/parser",
ok: false,
code: "ERR_MODULE_NOT_FOUND",
},
]);
expect(cjsResults).toEqual([
{
specifier: "./extensionless",
ok: false,
code: "MODULE_NOT_FOUND",
},
{
specifier: "./directory",
ok: false,
code: "MODULE_NOT_FOUND",
},
{
specifier: "./feature",
ok: false,
code: "MODULE_NOT_FOUND",
},
{ specifier: "./feature.cjs", ok: true, value: 2 },
{ specifier: "./module-only", ok: false, code: "MODULE_NOT_FOUND" },
{ specifier: "./valid-directory", ok: true, value: 42 },
{ specifier: "./default-js", ok: true, value: 3 },
{ specifier: "./default-json", ok: true, value: { value: 4 } },
{ specifier: "empty-main", ok: true, value: 42 },
{ specifier: "nested-main", ok: false, code: "MODULE_NOT_FOUND" },
{ specifier: "file-only", ok: true, value: 7 },
{ specifier: "ancestor-empty", ok: true, value: 8 },
{ specifier: "ancestor-blocked", ok: false, code: "MODULE_NOT_FOUND" },
{ specifier: "ancestor-sub/parser", ok: true, value: 10 },
]);
expectNodeRuntimeControls(esmControl.stdout, cjsControl.stdout);
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
const expectations = [
@@ -211,6 +262,14 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
["import", "ancestor-empty", null, "not-found"],
["import", "ancestor-blocked", null, "not-found"],
["import", "ancestor-sub/parser", null, "not-found"],
["import", "./.hidden.js", "esm/.hidden.js", "resolved"],
["import", "./..pkg.js", "esm/..pkg.js", "resolved"],
["import", ".hidden.js", null, "rejected"],
["import", "..pkg.js", null, "rejected"],
["import", ".", null, "not-found"],
["import", "..", null, "not-found"],
["import", "./exact.js/.", null, "not-found"],
["import", "./exact.js/child/..", null, "not-found"],
["require", "./extensionless", null, "not-found"],
["require", "./directory", null, "not-found"],
["require", "./feature", null, "not-found"],
@@ -242,19 +301,30 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
"node_modules/ancestor-sub/parser.js",
"resolved",
],
["require", ".hidden.js", "node_modules/.hidden.js/index.js", "resolved"],
["require", "./.hidden.js", "cjs/.hidden.js", "resolved"],
["require", ".", "cjs/index.js", "resolved"],
["require", "..", "index.js", "resolved"],
] as const;
for (const [kind, specifier, resolvedPath, status] of expectations)
expect(graph.edges).toContainEqual(
for (const [kind, specifier, resolvedPath, status] of expectations) {
const matchingProperties = graph.edges
.filter((edge) => edge.relation === "imports")
.map((edge) => edge.properties)
.filter(
(properties) =>
"module_link_kind" in properties &&
properties.module_link_kind === kind &&
properties.specifier === specifier,
);
expect(matchingProperties).toContainEqual(
expect.objectContaining({
relation: "imports",
properties: expect.objectContaining({
module_link_kind: kind,
specifier,
resolved_path: resolvedPath,
resolution_status: status,
}),
module_link_kind: kind,
specifier,
resolved_path: resolvedPath,
resolution_status: status,
}),
);
}
},
);
@@ -15,6 +15,60 @@ import {
} from "../../../support/javascriptApplicationFixture.js";
describe("TypeScript source module loader semantics", () => {
it("keeps whole type-only declarations out of runtime module relationships", async () => {
const root = await createTestTempDirectory("rea-type-module-links-");
await writeFixtureFiles(root, {
"main.ts": `
import type { Whole } from "./whole";
export type { Remote } from "./remote";
import { type Inline } from "./inline";
export type LocalType = string;
export interface LocalInterface { value: string }
`,
"whole.ts": "export interface Whole { value: string }",
"remote.ts": "export interface Remote { value: string }",
"inline.ts": "export interface Inline { value: string }",
});
const { graph } = await reconstructJavaScriptFixture(root);
const source = findSourceModule(graph, "main.ts");
const sourceProperties = source?.observations.find(
({ properties }) => properties.semantic_role === "source-module",
)?.properties;
expect(sourceProperties).toMatchObject({
import_relationships: 0,
type_relationships: 5,
export_names: [],
type_export_names: ["LocalInterface", "LocalType", "Remote"],
});
expect(
graph.edges.filter(
({ relation, properties }) =>
relation === "imports" &&
["./whole", "./remote"].includes(String(properties.specifier)),
),
).toEqual([]);
expect(
graph.edges.find(
({ source_node_id, relation, properties }) =>
source_node_id === source?.node_id &&
relation === "imports" &&
properties.kind === "static-import" &&
properties.specifier === "./inline",
),
).toBeDefined();
expect(
source?.observations
.flatMap(({ evidence }) => evidence.limitations)
.join(" "),
).toContain(
"inline type-only imports or re-exports may preserve a module side-effect dependency",
);
expect(findExportNode(graph, "main.ts", "LocalType")).toBeUndefined();
expect(findExportNode(graph, "main.ts", "LocalInterface")).toBeUndefined();
});
it("uses emitted TypeScript loader semantics without changing dynamic imports", async () => {
const root = await createTestTempDirectory("rea-cts-module-loader-");
await writeFixtureFiles(root, {
@@ -378,6 +378,44 @@ describe("reference source path selection", () => {
});
describe("reference source rooted module specifiers", () => {
it("keeps dot-prefixed bare specifiers external while resolving explicit relative paths", async () => {
const root = await createTestTempDirectory("rea-reference-dot-prefix-");
await Promise.all([
writeFile(
join(root, "main.js"),
'import ".hidden.js"; import "..pkg.js"; import "./.hidden.js"; require(".hidden.js");\n',
),
writeFile(join(root, ".hidden.js"), "export {};\n"),
writeFile(join(root, "..pkg.js"), "export {};\n"),
]);
const result = await importTree(root);
if (!result.ok) throw result.error;
expect(result.value.relationships).toEqual(
expect.arrayContaining([
expect.objectContaining({
to: ".hidden.js",
kind: "imports",
resolution: "external",
}),
expect.objectContaining({
to: "..pkg.js",
kind: "imports",
resolution: "external",
}),
expect.objectContaining({
to: ".hidden.js",
kind: "imports",
resolution: "internal",
}),
expect.objectContaining({
to: ".hidden.js",
kind: "requires",
resolution: "external",
}),
]),
);
});
it("does not rebase an absolute import onto a coincidentally matching source member", async () => {
const root = await createTestTempDirectory("rea-reference-rooted-");
await mkdir(join(root, "src", "outside"), { recursive: true });