Files
rea/src/application/javascript/JavaScriptModuleRelationships.ts
T
morluto b58adafa4f fix(javascript): align module lookup and source evidence
Preserve CommonJS literal paths and confined ESM URL semantics, including package main fallback, directory intent, Unicode identity, and present opaque modules. Parse extraction inputs at their AST/source owner, retain skipped HTML and source-map coverage, and distinguish endpoint candidates from proven receiver facts. Version structural fingerprints with computed literal contents and propagate them through worker contracts.

Reviewed fixes include #1810, #1840, #2642, #2700, #2702, #2714, #2803, #2960, #2968, #2986, #3035, and #3036.

Validation: 158 resolver tests passed; public CLI/MCP module lookup controls, package-main/Unicode workflows, and six builtin workflows passed in the current compiled lane. Worker schema/fingerprint regressions passed. Large public pdf.js completion and real Electron analysis remain unverified.
2026-10-14 08:46:50 +08:00

488 lines
16 KiB
TypeScript

import { posix } from "node:path";
import {
compareUnicodeCodePoints,
compositeKey,
} from "../../domain/unicodeCodePointOrder.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import {
completeApplicationCoverage,
partialApplicationCoverage,
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import {
semanticCoverageResourceLimits,
semanticResourceLimitCoverage,
} from "../../domain/javascript/javascriptSemanticCoverage.js";
import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js";
import type {
JavaScriptModuleOrigin,
JavaScriptSemanticModuleLink,
} from "../../domain/javascript/javascriptSemanticIr.js";
import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addAstContainsEdge,
artifactLocalIdentity,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
artifactPathResolutionProperties,
moduleResolutionModeProperties,
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
import { projectJavaScriptExportReturnShapes } from "../../domain/javascript/javascriptExportReturnShapeProjection.js";
import type { JavaScriptModuleSemanticIr } from "./JavaScriptArtifactAnalysisTypes.js";
interface SemanticAnalysis {
readonly ir: JavaScriptModuleSemanticIr;
}
interface RelationshipInput {
readonly context: JavaScriptArtifactGraphContext;
readonly file: JavaScriptArtifactFile;
readonly semantic: SemanticAnalysis;
readonly source: ApplicationNode;
readonly link: JavaScriptSemanticModuleLink;
}
type ResolvedModuleTarget = {
readonly node: ApplicationNode;
readonly limitations: readonly string[];
readonly resolution?: ArtifactPathResolution;
} & (
| {
readonly path: string;
readonly file: JavaScriptArtifactFile;
readonly status: "resolved";
}
| {
readonly path: null;
readonly file: null;
readonly status: Exclude<
ArtifactPathResolution["resolution_status"],
"resolved"
>;
}
);
/** Create one path-scoped source-module identity for every analyzed source file. */
export const addJavaScriptSourceModules = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, semantic } = analyzed;
const asset = context.assetNodes.get(file.path);
const program = semantic?.ir.scopes.find(({ kind }) => kind === "program");
if (asset === undefined || semantic === null || program === undefined)
continue;
const exports = semantic.ir.moduleLinks
.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)];
const source = context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
file.sha256,
"source-module",
`${file.container_sha256}:${file.path}`,
),
observations: [
{
label: file.path,
properties: {
semantic_role: "source-module",
path: file.path,
container_sha256: file.container_sha256,
logical_module_key: file.path,
module_format: moduleFormat(file.path, semantic.ir),
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({
sha256: file.sha256,
path: file.path,
range: program.location,
operation: "recover-source-module",
coverage: semanticCoverage(semantic),
limitations: semantic.ir.limitations,
}),
},
],
});
context.sourceModuleNodes.set(file.path, source);
addAstContainsEdge(context, {
source: asset,
target: source,
file,
range: program.location,
coverage: semanticCoverage(semantic),
properties: { logical_module_key: file.path },
});
}
};
/** Relationship compositions the application graph cannot represent, for disclosure. */
export interface JavaScriptModuleRelationshipOmissions {
/** Import specifiers that resolved back to the importing module itself. */
readonly selfImports: number;
}
type RelationshipOmissionCounter = { selfImports: number };
/** Compose bounded CommonJS and ESM binding relationships across artifact files. */
export function* addJavaScriptModuleRelationshipsSteps(
context: JavaScriptArtifactGraphContext,
): Generator<void, JavaScriptModuleRelationshipOmissions> {
const omissions = { selfImports: 0 };
for (const analyzed of context.analysis.files) {
const { file, semantic } = analyzed;
const source = context.sourceModuleNodes.get(file.path);
if (semantic === null || source === undefined) continue;
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)
addImportRelationship(input, source, omissions, {
specifier: link.specifier,
importedPath: link.importedName === null ? [] : [link.importedName],
});
}
}
return omissions;
}
const addExportRelationship = (
input: RelationshipInput,
omissions: RelationshipOmissionCounter,
): void => {
const { context, file, semantic, source, link } = input;
if (link.exportedName === null) return;
const baseCoverage = semanticCoverage(semantic);
const returnShapes = projectJavaScriptExportReturnShapes({
ir: semantic.ir,
link,
modulePath: file.path,
baseCoverage,
});
const exported = context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
file.sha256,
"module-export",
compositeKey([file.container_sha256, file.path, link.exportedName]),
),
observations: [
{
label: `${file.path}:${link.exportedName}`,
properties: {
semantic_role: "export-binding",
module_path: file.path,
relationship_kind: link.kind,
exported_name: link.exportedName,
local_name: link.localName,
imported_name: link.importedName,
declared_specifier: link.specifier,
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: link.location,
operation: "recover-module-export",
coverage: baseCoverage,
limitations: semantic.ir.limitations,
}),
},
...(returnShapes === null
? []
: [
{
label: `${file.path}:${link.exportedName}:return-shapes`,
properties: returnShapes.properties,
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: returnShapes.range,
operation: "recover-export-return-shapes",
coverage: returnShapes.coverage,
limitations: returnShapes.limitations,
}),
},
]),
],
});
context.accumulator.addEdge({
source_node_id: source.node_id,
target_node_id: exported.node_id,
relation: "exposes",
properties: relationshipProperties(link, null),
evidence: relationshipEvidence(input, "expose-module-export", []),
});
const origin = moduleOriginForExport(semantic.ir, link);
if (origin !== null)
addImportRelationship(input, exported, omissions, origin);
};
const addImportRelationship = (
input: RelationshipInput,
source: ApplicationNode,
omissions: RelationshipOmissionCounter,
origin: JavaScriptModuleOrigin,
): void => {
const target = resolveModuleTarget(input, origin.specifier);
// A specifier that resolves back to the importing module itself (a literal
// self-import, self-require, or artifact-confined path alias)
// cannot become an edge: the application graph forbids self-referential
// edges, and emitting one fails result validation for the whole analysis.
if (target.node.node_id === source.node_id) {
omissions.selfImports += 1;
return;
}
input.context.accumulator.addEdge({
source_node_id: source.node_id,
target_node_id: target.node.node_id,
relation: "imports",
properties: relationshipProperties(input.link, target, origin),
evidence: relationshipEvidence(
input,
"resolve-semantic-module-relationship",
target.limitations,
target.status === "resolved"
? "high"
: target.status === "external"
? "medium"
: "low",
),
});
};
const resolveModuleTarget = (
input: RelationshipInput,
specifier: string,
): ResolvedModuleTarget => {
const resolution = resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath: input.file.path,
context: "module-specifier",
files: input.context.filesByPath,
directoryPaths: input.context.directoryPaths,
unanalyzedFiles: input.context.unanalyzedFiles,
moduleKind: moduleResolutionModeForJavaScriptReference(
input.file.path,
input.link.kind === "require" || input.link.kind === "commonjs-export"
? "require"
: "static-import",
input.link.resolutionMode,
),
});
if (resolution.resolution_status !== "resolved")
return {
node: unresolvedModuleNode(input, specifier, resolution),
path: null,
file: null,
status: resolution.resolution_status,
limitations: resolution.limitations,
resolution,
};
const path = resolution.resolved_path;
const file = input.context.filesByPath.get(path);
const node =
input.context.sourceModuleNodes.get(path) ??
input.context.fileNodes.get(path);
if (file === undefined || node === undefined)
throw new TypeError(
`Resolved artifact path ${path} is absent from its graph context.`,
);
return {
node,
path,
file,
status: "resolved",
limitations: resolution.limitations,
resolution,
};
};
const unresolvedModuleNode = (
input: RelationshipInput,
specifier: string,
resolution: Extract<
ArtifactPathResolution,
{ resolution_status: Exclude<ResolvedModuleTarget["status"], "resolved"> }
>,
): ApplicationNode =>
input.context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
input.file.sha256,
"unresolved-semantic-module",
`${resolution.resolution_status}:${specifier}`,
),
observations: [
{
label: specifier,
properties: {
semantic_role: "module-reference",
declared_specifier: specifier,
resolution_status: resolution.resolution_status,
...artifactPathResolutionProperties(resolution),
},
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.link.location,
operation: "retain-unresolved-semantic-module",
coverage: semanticCoverage(input.semantic),
confidence: "low",
limitations: input.semantic.ir.limitations,
}),
},
],
});
const moduleOriginForExport = (
ir: JavaScriptModuleSemanticIr,
link: JavaScriptSemanticModuleLink,
): JavaScriptModuleOrigin | null => {
if (link.specifier !== null)
return {
...(link.resolutionMode === undefined
? {}
: { resolutionMode: link.resolutionMode }),
specifier: link.specifier,
importedPath: link.importedName === null ? [] : [link.importedName],
};
if (link.localName === null) return null;
const program = ir.scopes.find(({ kind }) => kind === "program");
const binding = ir.bindings.find(
({ name, scopeId }) =>
name === link.localName && scopeId === program?.scopeId,
);
return binding?.provenance.status === "module" &&
binding.provenance.origins.length === 1
? (binding.provenance.origins[0] ?? null)
: null;
};
const relationshipProperties = (
link: JavaScriptSemanticModuleLink,
target: ResolvedModuleTarget | null,
origin?: JavaScriptModuleOrigin,
) => ({
module_link_kind: link.kind,
...moduleResolutionModeProperties(link.resolutionMode),
specifier: origin?.specifier ?? link.specifier,
imported_name: origin?.importedPath.at(-1) ?? link.importedName,
imported_path: origin?.importedPath ?? [],
local_name: link.localName,
exported_name: link.exportedName,
resolved_path: target?.path ?? null,
resolution_status: target?.status ?? null,
...artifactPathResolutionProperties(target?.resolution),
target_file_kind: target?.file?.kind ?? null,
target_json_status:
target?.file?.kind === "json" || target?.file?.kind === "package-json"
? jsonStatus(target.file)
: null,
});
const relationshipEvidence = (
input: RelationshipInput,
operation: string,
limitations: readonly string[],
confidence: "high" | "medium" | "low" = "high",
) =>
staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.link.location,
operation,
coverage: semanticCoverage(input.semantic),
confidence,
limitations: [
...input.semantic.ir.limitations,
...semanticCoverageResourceLimits(input.semantic.ir.coverage).map(
semanticResourceLimitReason,
),
...limitations,
],
});
const semanticCoverage = (
semantic: SemanticAnalysis,
): JavaScriptArtifactGraphCoverage => {
const resourceLimits = semanticCoverageResourceLimits(semantic.ir.coverage);
if (semantic.ir.coverage.status === "complete" && resourceLimits.length === 0)
return completeApplicationCoverage();
return partialApplicationCoverage(
semanticResourceLimitCoverage(resourceLimits),
semantic.ir.coverage.omittedCount,
);
};
const moduleFormat = (
path: string,
ir: JavaScriptModuleSemanticIr,
): "commonjs" | "esm" | "mixed" | "unknown" => {
const extension = posix.extname(path).toLowerCase();
if (extension === ".mjs" || extension === ".mts") return "esm";
if (extension === ".cjs" || extension === ".cts") return "commonjs";
if (extension === ".ts" || extension === ".tsx") return "unknown";
const esm = ir.moduleLinks.some(({ kind }) =>
["import", "export", "re-export"].includes(kind),
);
const commonJs = ir.moduleLinks.some(({ kind }) =>
["require", "commonjs-export"].includes(kind),
);
return esm && commonJs
? "mixed"
: esm
? "esm"
: commonJs
? "commonjs"
: "unknown";
};
const jsonStatus = (
file: JavaScriptArtifactFile,
): "included" | "invalid" | "unavailable" => {
if (!file.text.included) return "unavailable";
try {
JSON.parse(file.text.value);
return "included";
} catch (cause: unknown) {
// Invalid JSON is classified without further detail.
void cause;
return "invalid";
}
};
const isExportLink = (link: JavaScriptSemanticModuleLink): boolean =>
link.kind === "export" ||
link.kind === "re-export" ||
link.kind === "commonjs-export";