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.
This commit is contained in:
morluto committed 2026-10-14 08:46:50 +08:00
1 parent 04672f2b91
commit b58adafa4f
63 files changed
+4066 -326

No files matched your search

@@ -83,6 +83,7 @@ export const unambiguousElectronIpcPairings = (
/** Summarize static Electron findings without treating gaps as absence. */
export const summarizeElectronBoundaries = (
analysis: JavaScriptModuleArtifactAnalysis,
directoryPaths?: ReadonlySet<string>,
): ElectronBoundarySummary => {
const files = new Map(analysis.files.map(({ file }) => [file.path, file]));
const javascript = analysis.files.flatMap(({ javascript: value }) =>
@@ -163,6 +164,7 @@ export const summarizeElectronBoundaries = (
specifier: finding.module_path,
context: finding.module_resolution_context,
files,
directoryPaths,
runtimePathKind: "node-entrypoint",
}),
).length,
@@ -173,6 +175,7 @@ export const summarizeElectronBoundaries = (
specifier: finding.specifier,
context: "module-specifier",
files,
directoryPaths,
kind: "native-addon",
moduleKind: finding.module_kind,
}),
@@ -206,6 +209,7 @@ interface ResolveToFileInput {
readonly specifier: string;
readonly context: "module-specifier" | "filesystem-expression";
readonly files: ReadonlyMap<string, JavaScriptArtifactFile>;
readonly directoryPaths?: ReadonlySet<string> | undefined;
readonly kind?: JavaScriptArtifactFile["kind"];
readonly moduleKind?: "import" | "require";
readonly runtimePathKind?: ResolveArtifactPathInput["runtimePathKind"];
@@ -217,6 +221,9 @@ const resolvesToFile = (input: ResolveToFileInput): boolean => {
sourcePath: input.file.path,
context: input.context,
files: input.files,
...(input.directoryPaths === undefined
? {}
: { directoryPaths: input.directoryPaths }),
...(input.runtimePathKind === undefined
? {}
: { runtimePathKind: input.runtimePathKind }),
@@ -50,6 +50,7 @@ const addNativeBinding = (input: NativeBindingInput): void => {
file.path,
context.filesByPath,
value.module_kind,
context.directoryPaths,
);
const addon = resolved === null ? undefined : context.fileNodes.get(resolved);
const binding = context.accumulator.addNode({
@@ -140,6 +140,8 @@ const addWindowPreload = (
sourcePath: input.file.path,
context: resolutionContext,
files: input.context.filesByPath,
directoryPaths: input.context.directoryPaths,
unanalyzedFiles: input.context.unanalyzedFiles,
runtimePathKind: "exact-file",
});
const role = createElectronRoleNode(input.context, {
@@ -232,6 +234,23 @@ const addUtilityProcess = (
input: FindingInput<ElectronUtilityProcessFinding>,
): void => {
const { context, file, value, coverage } = input;
const resolution =
value.module_path === null
? undefined
: resolveArtifactPathByContext({
declaredPath: value.module_path,
sourcePath: file.path,
context: value.module_resolution_context,
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
runtimePathKind: "node-entrypoint",
});
const resolutionProperties = {
resolved_path: resolution?.resolved_path ?? null,
resolution_status: resolution?.resolution_status ?? null,
...artifactPathResolutionProperties(resolution),
};
const utility = context.accumulator.addNode({
kind: "electron-utility",
identity: artifactLocalIdentity(
@@ -256,18 +275,24 @@ const addUtilityProcess = (
coverage,
}),
},
...(resolution === undefined
? []
: [
{
label: "utility process entrypoint resolution",
properties: resolutionProperties,
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "resolve-utility-process-entrypoint",
coverage,
limitations: resolution.limitations,
}),
},
]),
],
});
const resolution =
value.module_path === null
? undefined
: resolveArtifactPathByContext({
declaredPath: value.module_path,
sourcePath: file.path,
context: value.module_resolution_context,
files: context.filesByPath,
runtimePathKind: "node-entrypoint",
});
addElectronInferenceEdge(context, {
source: input.source,
target: utility,
@@ -276,18 +301,11 @@ const addUtilityProcess = (
coverage,
relation: "loads",
operation: "map-utility-process-launch",
limitations: resolution?.limitations ?? [],
properties: {
declared_path: value.module_path,
expression: value.module_expression,
...(resolution === undefined
? {}
: {
resolution_context: resolution.resolution_context,
resolved_path: resolution.resolved_path,
resolution_status: resolution.resolution_status,
limitations: resolution.limitations,
...artifactPathResolutionProperties(resolution),
}),
...resolutionProperties,
},
});
if (value.module_path === null) return;
@@ -311,6 +329,7 @@ const addUtilityProcess = (
resolution_context: resolution.resolution_context,
resolved_path: resolved,
resolution_status: resolution.resolution_status,
...artifactPathResolutionProperties(resolution),
},
});
};
@@ -17,11 +17,7 @@ import {
flattenSourceMapLeaves,
sourceMapJsonText,
} from "../../domain/sourceMapEnvelope.js";
import type {
JavaScriptSourceRange,
JavaScriptSourcePoint,
JavaScriptStaticAnalysis,
} from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import { failedJavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type {
JavaScriptArtifactFile,
@@ -32,6 +28,7 @@ import type {
JavaScriptArtifactAnalysis,
JavaScriptModuleArtifactAnalysis,
JavaScriptModuleSemanticIr,
JavaScriptHtmlInlineScriptsObservation,
JavaScriptHtmlScriptObservation,
JavaScriptJsonModuleObservation,
JavaScriptPackageObservation,
@@ -58,6 +55,7 @@ interface MutableArtifactAnalysis<
readonly packages: JavaScriptPackageObservation[];
readonly jsonModules: JavaScriptJsonModuleObservation[];
readonly htmlScripts: JavaScriptHtmlScriptObservation[];
readonly htmlInlineScripts: JavaScriptHtmlInlineScriptsObservation[];
readonly sourceMaps: JavaScriptSourceMapObservation[];
visitedNodes: number;
findings: number;
@@ -329,14 +327,22 @@ const finalizeArtifactAnalysis = <
packages: state.packages,
json_modules: state.jsonModules,
html_scripts: state.htmlScripts,
html_inline_scripts: state.htmlInlineScripts,
source_maps: state.sourceMaps,
visited_ast_nodes: state.visitedNodes,
findings: state.findings + state.htmlScripts.length,
findings:
state.findings +
state.htmlScripts.length +
state.htmlInlineScripts.reduce(
(count, { script_count }) => count + script_count,
0,
),
modules: state.modules,
parse_failures: state.parseFailures,
limitations: [
"JavaScript and HTML were parsed as inert text; bundle bootstrap code was never executed.",
"Static paths and relationships may remain unresolved when expressions are dynamic or obfuscated.",
...state.htmlInlineScripts.map(htmlInlineScriptLimitation),
...state.files.flatMap(
({ analysis_failure, application_projection_failure }) =>
[analysis_failure, application_projection_failure].flatMap(
@@ -356,8 +362,13 @@ function* analyzeArtifactFileSteps<
const { state } = context;
if (classifyOutsideAnalysisScope(file, state)) return;
addStructuredObservations(file, state);
if (file.kind === "html" && file.text.included)
state.htmlScripts.push(...parseHtmlScripts(file.path, file.text.value));
if (file.kind === "html" && file.text.included) {
const html = parseHtmlScripts(file.path, file.text.value);
// Append without spreading: argument count is bounded by the engine.
for (const script of html.scripts) state.htmlScripts.push(script);
if (html.inlineScripts !== null)
state.htmlInlineScripts.push(html.inlineScripts);
}
if (file.kind === "source-map") addSourceMap(file, context);
if (file.kind !== "javascript" || !file.text.included) {
state.files.push({ file, javascript: null, semantic: null });
@@ -443,6 +454,7 @@ const emptyArtifactAnalysis = <
packages: [],
jsonModules: [],
htmlScripts: [],
htmlInlineScripts: [],
sourceMaps: [],
visitedNodes: 0,
findings: 0,
@@ -515,14 +527,31 @@ const unavailablePackage = (
const parseHtmlScripts = (
path: string,
text: string,
): JavaScriptHtmlScriptObservation[] => {
const { scripts, baseHref } = htmlArtifactReferences(text);
return scripts.map(({ scriptPath, startOffset, endOffset }) => ({
html_path: path,
script_path: scriptPath,
base_href: baseHref,
location: rangeForOffsets(text, startOffset, endOffset),
}));
): {
readonly scripts: JavaScriptHtmlScriptObservation[];
readonly inlineScripts: JavaScriptHtmlInlineScriptsObservation | null;
} => {
const { scripts, inlineScripts, inlineScriptCount, baseHref } =
htmlArtifactReferences(text);
return {
scripts: scripts.map(({ scriptPath, location }) => ({
html_path: path,
script_path: scriptPath,
base_href: baseHref,
location,
})),
inlineScripts:
inlineScriptCount === 0
? null
: {
path,
script_count: inlineScriptCount,
scripts: inlineScripts.map(({ scriptType, location }) => ({
script_type: scriptType,
location,
})),
},
};
};
const parseSourceMap = (
@@ -599,6 +628,23 @@ const collectSourceMapOriginals = (
};
};
/** Name the HTML file and lines whose inline JavaScript was not analyzed. */
export const htmlInlineScriptLimitation = ({
path,
script_count: count,
scripts,
}: JavaScriptHtmlInlineScriptsObservation): string => {
const lines = scripts
.slice(0, HTML_INLINE_SCRIPT_LIMITATION_LINES)
.map(({ location }) => String(location.start.line));
const unnamed = count - lines.length;
const one = count === 1;
return `${path} has ${String(count)} inline JavaScript ${one ? "body" : "bodies"} starting at line${one ? "" : "s"} ${lines.join(", ")}${unnamed > 0 ? ` and ${String(unnamed)} more` : ""} that ${one ? "was" : "were"} not analyzed; endpoints, storage, imports and other facts in ${one ? "it" : "them"} are absent from the evidence.`;
};
// Keep the agent-facing limitation short; the unknown scope keeps the ranges.
const HTML_INLINE_SCRIPT_LIMITATION_LINES = 10;
const unavailableSourceMap = (
file: JavaScriptArtifactFile,
): JavaScriptSourceMapObservation => {
@@ -638,23 +684,6 @@ const findingCount = (analysis: JavaScriptStaticAnalysis): number =>
analysis.electron.utility_processes.length +
analysis.electron.native_addon_bindings.length;
const rangeForOffsets = (
text: string,
start: number,
end: number,
): JavaScriptSourceRange => ({
start: pointForOffset(text, start),
end: pointForOffset(text, end),
});
const pointForOffset = (
text: string,
offset: number,
): JavaScriptSourcePoint => {
const lines = text.slice(0, offset).split(/\r\n|\r|\n/u);
return { line: lines.length, column: lines.at(-1)?.length ?? 0 };
};
const optionalString = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
@@ -45,6 +45,16 @@ export interface JavaScriptHtmlScriptObservation {
readonly location: JavaScriptSourceRange;
}
/** Bounded source locations for inline JavaScript bodies skipped by analysis. */
export interface JavaScriptHtmlInlineScriptsObservation {
readonly path: string;
readonly script_count: number;
readonly scripts: readonly {
readonly script_type: "classic" | "module";
readonly location: JavaScriptSourceRange;
}[];
}
/** One original source declared by a local source map. */
export interface JavaScriptSourceMapOriginal {
/** Index within the leaf map's sources and sourcesContent arrays. */
@@ -128,6 +138,7 @@ export interface JavaScriptArtifactAnalysis<
readonly packages: readonly JavaScriptPackageObservation[];
readonly json_modules: readonly JavaScriptJsonModuleObservation[];
readonly html_scripts: readonly JavaScriptHtmlScriptObservation[];
readonly html_inline_scripts: readonly JavaScriptHtmlInlineScriptsObservation[];
readonly source_maps: readonly JavaScriptSourceMapObservation[];
readonly visited_ast_nodes: number;
readonly findings: number;
@@ -9,6 +9,8 @@ import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnaly
import type { JavaScriptArtifactFileSet } from "../../domain/javascript/javascriptArtifactFiles.js";
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import {
HTML_INLINE_SCRIPT_LOCATION_COVERAGE_LIMIT,
omittedHtmlInlineScriptLocations,
selfReferenceOmissions,
type JavaScriptArtifactGraphContext,
} from "./JavaScriptArtifactGraphContext.js";
@@ -25,6 +27,7 @@ import {
import {
completeApplicationCoverage,
partialApplicationCoverage,
truncatedApplicationCoverage,
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptSemanticResourceLimit } from "../../domain/javascript/javascriptSemanticValueTypes.js";
import {
@@ -95,8 +98,14 @@ function* buildJavaScriptArtifactGraphInputSteps(
snapshot,
fileSet,
analysis,
directoryPaths: new Set(
snapshot.occurrences
.filter(({ entry_kind }) => entry_kind === "directory")
.map(({ logical_path }) => logical_path),
),
root,
filesByPath: new Map(fileSet.files.map((file) => [file.path, file])),
unanalyzedFiles: fileSet.unanalyzed_files,
fileNodes: new Map(),
assetNodes: new Map(),
sourceModuleNodes: new Map(),
@@ -176,6 +185,7 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
) ||
context.analysis.parse_failures > 0 ||
context.fileSet.invalid_utf8_files > 0 ||
context.analysis.html_inline_scripts.length > 0 ||
sourceMapPolicyGap ||
malformedStructuredData ||
partialJavaScript;
@@ -187,18 +197,30 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
: javaScriptAnalysisCoverageLimits(failure.limits),
),
);
if (resourceLimits.length > 0 || executionLimits.length > 0)
return partialApplicationCoverage(
[
...new Map(
[
...semanticResourceLimitCoverage(resourceLimits),
...executionLimits,
].map((limit) => [JSON.stringify(limit), limit]),
).values(),
],
null,
);
// Capped inline script locations truncate the result. The total omission
// stays unknown because the unanalyzed bodies hide an unknown fact count.
const inlineScriptsTruncated =
omittedHtmlInlineScriptLocations(context.analysis.html_inline_scripts) > 0;
if (
resourceLimits.length > 0 ||
executionLimits.length > 0 ||
inlineScriptsTruncated
) {
const limits = [
...new Map(
[
...semanticResourceLimitCoverage(resourceLimits),
...executionLimits,
...(inlineScriptsTruncated
? [HTML_INLINE_SCRIPT_LOCATION_COVERAGE_LIMIT]
: []),
].map((limit) => [JSON.stringify(limit), limit]),
).values(),
];
return inlineScriptsTruncated
? truncatedApplicationCoverage(limits, null)
: partialApplicationCoverage(limits, null);
}
if (unknownGap) return partialApplicationCoverage([], null);
if (context.snapshot.integrity_contradictions.length > 0) {
const nestedArchiveWasOpaque =
@@ -4,11 +4,17 @@ import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import {
completeApplicationCoverage,
partialApplicationCoverage,
truncatedApplicationCoverage,
type ApplicationGraphEvidence,
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type {
JavaScriptHtmlInlineScriptsObservation,
JavaScriptModuleArtifactAnalysis,
} from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptUnanalyzedArtifactLookup } from "../../domain/javascript/javascriptArtifactFiles.js";
import { HTML_INLINE_SCRIPT_LOCATION_LIMIT } from "../../domain/javascript/htmlArtifactReferences.js";
import type {
JavaScriptArtifactFile,
JavaScriptArtifactFileSet,
@@ -33,8 +39,11 @@ export interface JavaScriptArtifactGraphContext {
readonly snapshot: ArtifactInventorySnapshot;
readonly fileSet: JavaScriptArtifactFileSet;
readonly analysis: JavaScriptModuleArtifactAnalysis;
readonly directoryPaths: ReadonlySet<string>;
readonly root: ApplicationNode;
readonly filesByPath: ReadonlyMap<string, JavaScriptArtifactFile>;
/** Borrowed inventory lookup for present files outside the source projection. */
readonly unanalyzedFiles: JavaScriptUnanalyzedArtifactLookup | undefined;
readonly fileNodes: Map<string, ApplicationNode>;
readonly assetNodes: Map<string, ApplicationNode>;
readonly sourceModuleNodes: Map<string, ApplicationNode>;
@@ -104,8 +113,29 @@ export interface UnavailableParseInput {
readonly limitation: string;
readonly failure?: JsonValue;
readonly limits?: JavaScriptArtifactGraphCoverage["limits"];
/** Items a named limit dropped from this gap's own record, when known. */
readonly omittedCount?: number;
/** Structured details of the gap, such as the exact omitted locations. */
readonly details?: Readonly<Record<string, JsonValue>>;
}
/** The per-file bound on retained inline HTML script locations. */
export const HTML_INLINE_SCRIPT_LOCATION_COVERAGE_LIMIT = {
name: "html_inline_script_locations_per_file",
value: HTML_INLINE_SCRIPT_LOCATION_LIMIT,
unit: "items",
} as const;
/** Count inline HTML script locations dropped by the per-file bound. */
export const omittedHtmlInlineScriptLocations = (
files: readonly JavaScriptHtmlInlineScriptsObservation[],
): number =>
files.reduce(
(omitted, { script_count, scripts }) =>
omitted + script_count - scripts.length,
0,
);
/** Add a direct artifact-containment edge. */
export const addArtifactContainsEdge = (
context: JavaScriptArtifactGraphContext,
@@ -187,6 +217,8 @@ export const addUnavailableStaticParseScope = (
{
label: `${input.file.path} parse scope`,
properties: {
// Details come first so they cannot replace the gap's own fields.
...input.details,
path: input.file.path,
operation: input.operation,
limitation: input.limitation,
@@ -198,7 +230,13 @@ export const addUnavailableStaticParseScope = (
sha256: input.file.sha256,
path: input.file.path,
operation: input.operation,
coverage: partialApplicationCoverage(input.limits ?? [], null),
coverage:
input.omittedCount !== undefined && input.omittedCount > 0
? truncatedApplicationCoverage(
input.limits ?? [],
input.omittedCount,
)
: partialApplicationCoverage(input.limits ?? [], null),
limitation: input.limitation,
}),
},
@@ -269,12 +307,12 @@ export const createElectronRoleNode = (
{
label: input.resolution.resolved_path ?? input.resolution.declared_path,
properties: {
...artifactPathResolutionProperties(input.resolution),
declared_path: input.resolution.declared_path,
resolution_context: input.resolution.resolution_context,
resolved_path: input.resolution.resolved_path,
resolution_status: input.resolution.resolution_status,
limitations: input.resolution.limitations,
...artifactPathResolutionProperties(input.resolution),
mechanism: input.mechanism,
},
evidence: staticInferenceEvidence({
@@ -354,12 +392,14 @@ export const resolveArtifactPath = (
sourcePath: string,
files: ReadonlyMap<string, JavaScriptArtifactFile>,
moduleKind?: "import" | "require" | "compiler-dependent",
directoryPaths?: ReadonlySet<string>,
): string | null =>
resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath,
context: "module-specifier",
files,
...(directoryPaths === undefined ? {} : { directoryPaths }),
...(moduleKind === undefined ? {} : { moduleKind }),
}).resolved_path;
@@ -58,6 +58,8 @@ export const addJavaScriptHtmlRoles = (
sourcePath: html.path,
context: "html-reference",
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
htmlBaseHref: script.base_href,
});
const resolvedPath = resolution.resolved_path;
@@ -44,7 +44,7 @@ interface FindingInput<Value> {
interface ResolvedReference {
readonly path: string | null;
readonly file: JavaScriptArtifactFile | null;
readonly node: ApplicationNode;
readonly node: ApplicationNode | null;
readonly limitations: readonly string[];
readonly resolutionStatus?: string;
readonly resolution?: ArtifactPathResolution;
@@ -110,6 +110,8 @@ const addReference = (
declared_specifier: input.value.specifier,
expression: input.value.expression,
resolved_path: resolved?.path ?? null,
resolution_status: resolved?.resolutionStatus ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
@@ -117,6 +119,7 @@ const addReference = (
range: input.value.location,
operation: "discover-worker",
coverage: input.coverage,
limitations: resolved?.limitations ?? [],
}),
},
],
@@ -128,9 +131,14 @@ const addReference = (
range: input.value.location,
coverage: input.coverage,
relation: "loads",
properties: { kind: input.value.kind },
properties: {
kind: input.value.kind,
resolution_status: resolved?.resolutionStatus ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
limitations: resolved?.limitations ?? [],
});
if (resolved !== null && resolved.path !== null)
if (resolved !== null && resolved.path !== null && resolved.node !== null)
addStaticInferenceEdge(context, {
source: worker,
target: resolved.node,
@@ -146,7 +154,7 @@ const addReference = (
// A reference that resolves to its own source, such as a bundled module
// requiring its own key, cannot become an edge: the application graph
// forbids self-referential edges and one would fail the whole analysis.
if (resolved?.node.node_id === source.node_id) {
if (resolved?.node?.node_id === source.node_id) {
omissions.selfReferences += 1;
return;
}
@@ -283,6 +291,8 @@ const addDiscoveredRole = (
sourcePath: input.file.path,
context: input.value.resolution_context,
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
...(input.value.role === "preload" ||
(input.value.role === "renderer" &&
input.value.resolution_context === "filesystem-expression")
@@ -333,18 +343,27 @@ const addSourceMapEdge = (
const declaredUrlSha256 = createHash("sha256")
.update(input.value.declared_url)
.digest("hex");
const resolvedPath = resolveArtifactPathByContext({
const resolution = resolveArtifactPathByContext({
declaredPath: input.value.declared_url,
sourcePath: input.file.path,
context: "url-reference",
files: context.filesByPath,
}).resolved_path;
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
});
const resolvedPath = resolution.resolved_path;
const resolvedFile =
resolvedPath === null ? undefined : context.filesByPath.get(resolvedPath);
const resolvedNode =
resolvedPath === null ? undefined : context.fileNodes.get(resolvedPath);
const isAvailable =
resolvedFile?.kind === "source-map" && resolvedNode !== undefined;
const limitations =
isAvailable || resolution.limitations.length > 0
? resolution.limitations
: [
"The declared reference does not identify an available source-map projection.",
];
const target = context.accumulator.addNode({
kind: "source-map",
identity:
@@ -362,6 +381,8 @@ const addSourceMapEdge = (
declared_url: input.value.declared_url,
declared_url_sha256: declaredUrlSha256,
resolved_path: resolvedPath,
resolution_status: resolution.resolution_status,
...artifactPathResolutionProperties(resolution),
available: isAvailable,
},
evidence: staticInferenceEvidence({
@@ -371,11 +392,7 @@ const addSourceMapEdge = (
operation: "discover-source-map",
coverage: input.coverage,
confidence: "medium",
limitations: isAvailable
? []
: [
"The declared source map was not present in the inventoried artifact.",
],
limitations,
}),
},
],
@@ -387,9 +404,12 @@ const addSourceMapEdge = (
range: input.value.location,
coverage: input.coverage,
relation: "maps_to",
limitations,
properties: {
declared_url_sha256: declaredUrlSha256,
resolved_path: resolvedPath,
resolution_status: resolution.resolution_status,
...artifactPathResolutionProperties(resolution),
},
});
};
@@ -432,12 +452,16 @@ const resolveReference = (
sourcePath,
context: "url-reference",
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
})
: resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath,
context: "module-specifier",
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
...("require" === reference.kind ||
"static-import" === reference.kind ||
"dynamic-import" === reference.kind
@@ -451,20 +475,21 @@ const resolveReference = (
});
const path = resolution.resolved_path;
if (path === null) {
if (isWorker) return null;
const source = context.filesByPath.get(sourcePath);
return source === undefined
? null
: {
path: null,
file: null,
node: unresolvedReferenceNode(
context,
source,
reference,
coverage,
resolution,
),
node: isWorker
? null
: unresolvedReferenceNode(
context,
source,
reference,
coverage,
resolution,
),
limitations: resolution.limitations,
resolutionStatus: resolution.resolution_status,
resolution,
@@ -1,5 +1,6 @@
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import { htmlInlineScriptLimitation } from "./JavaScriptArtifactAnalysis.js";
import { completeApplicationCoverage } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
@@ -17,6 +18,8 @@ import {
artifactLocalIdentity,
javascriptAnalysisCoverage,
createElectronRoleNode,
HTML_INLINE_SCRIPT_LOCATION_COVERAGE_LIMIT,
omittedHtmlInlineScriptLocations,
linkElectronRoleToAsset,
type JavaScriptArtifactGraphContext,
} from "./JavaScriptArtifactGraphContext.js";
@@ -25,7 +28,10 @@ import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import {
artifactPathResolutionProperties,
resolveArtifactPathByContext,
} from "./JavaScriptArtifactPathResolution.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
interface PackageRoleInput {
@@ -124,6 +130,9 @@ export const addJavaScriptArtifactFiles = (
const packagesByPath = indexFirstByPath(context.analysis.packages);
const jsonModulesByPath = indexFirstByPath(context.analysis.json_modules);
const sourceMapsByPath = indexFirstByPath(context.analysis.source_maps);
const inlineScriptsByPath = indexFirstByPath(
context.analysis.html_inline_scripts,
);
for (const analyzed of context.analysis.files) {
const { file } = analyzed;
const jsonValue = jsonModulesByPath.get(file.path);
@@ -265,6 +274,31 @@ export const addJavaScriptArtifactFiles = (
operation: "parse-local-source-map",
limitation: sourceMap.limitation,
});
const inlineScripts = inlineScriptsByPath.get(file.path);
if (inlineScripts !== undefined)
addUnavailableStaticParseScope(context, {
file,
asset: target,
operation: "parse-html-inline-scripts",
limitation: htmlInlineScriptLimitation(inlineScripts),
limits:
inlineScripts.script_count > inlineScripts.scripts.length
? [HTML_INLINE_SCRIPT_LOCATION_COVERAGE_LIMIT]
: [],
omittedCount: omittedHtmlInlineScriptLocations([inlineScripts]),
details: {
inline_script_count: inlineScripts.script_count,
inline_scripts: inlineScripts.scripts.map(
({ script_type, location }) => ({
script_type,
location: {
start: { ...location.start },
end: { ...location.end },
},
}),
),
},
});
}
};
@@ -472,6 +506,8 @@ const addPackageRole = (
sourcePath: input.packageFile.path,
context: "package-entrypoint",
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
});
const role = createElectronRoleNode(context, {
kind: input.kind,
@@ -491,6 +527,7 @@ const addPackageRole = (
resolution_context: resolution.resolution_context,
resolved_path: resolution.resolved_path,
resolution_status: resolution.resolution_status,
...artifactPathResolutionProperties(resolution),
limitations: resolution.limitations,
},
evidence: staticInferenceEvidence({
@@ -121,6 +121,69 @@ describe("loader-owned path lookup", () => {
}),
).toMatchObject({ resolved_path: "app/feature.cjs" });
});
it("keeps present opaque inventory files distinct from missing module targets", () => {
const opaque = new Map([
[
"app/core.jsc",
{
path: "app/core.jsc",
container_sha256: file("app/entry.cjs", "root").container_sha256,
sha256: "a".repeat(64),
bytes: 7,
inventory_artifact_id: "opaque-core",
},
],
]);
const files = fileMap([file("app/entry.cjs", "root")]);
const unanalyzedFiles = {
get: (path: string) => opaque.get(path),
hasDirectory: () => false,
};
expect(
resolve({
declaredPath: "./core.jsc",
sourcePath: "app/entry.cjs",
context: "module-specifier",
moduleKind: "require",
files,
unanalyzedFiles,
}),
).toMatchObject({
resolution_status: "unavailable",
resolved_path: null,
candidate_path: "app/core.jsc",
limitations: [
expect.stringContaining(`sha256 ${"a".repeat(64)}, 7 bytes`),
],
});
expect(
resolve({
declaredPath: "./missing.jsc",
sourcePath: "app/entry.cjs",
context: "module-specifier",
moduleKind: "require",
files,
unanalyzedFiles,
}),
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
expect(
resolve({
declaredPath: "./core.jsc",
sourcePath: "app/entry.cjs",
context: "module-specifier",
moduleKind: "require",
files,
unanalyzedFiles: {
...unanalyzedFiles,
get: () => ({
...opaque.get("app/core.jsc")!,
container_sha256: "b".repeat(64),
}),
},
}),
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
});
});
describe("bare package root file lookup", () => {
@@ -226,6 +289,32 @@ describe("legacy package main lookup", () => {
},
);
it("retains inventoried directories during legacy ESM main finalization", () => {
const files = fileMap([
file("app/src/entry.mjs", "root"),
file(
"app/node_modules/fixture/package.json",
"root",
'{"main":"./lib/"}',
),
file("app/node_modules/fixture/lib.js", "root"),
]);
expect(
resolve({
declaredPath: "fixture",
sourcePath: "app/src/entry.mjs",
context: "module-specifier",
moduleKind: "import",
files,
directoryPaths: new Set(["app/node_modules/fixture/lib/.js"]),
}),
).toMatchObject({
resolution_status: "rejected",
resolved_path: null,
candidate_path: "app/node_modules/fixture/lib/.js",
});
});
it.each(["import", "require"] as const)(
"does not recursively resolve a nested package from a %s package main",
(moduleKind) => {
@@ -250,7 +339,9 @@ describe("legacy package main lookup", () => {
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
},
);
});
describe("legacy package main metadata validation and fallback", () => {
it("still rejects non-string package main metadata", () => {
expect(
resolve({
@@ -265,6 +356,54 @@ describe("legacy package main lookup", () => {
}),
).toMatchObject({ resolution_status: "unavailable", resolved_path: null });
});
it.each(["import", "require"] as const)(
"uses Node's index fallback for a non-string %s package main",
(moduleKind) => {
const files = fileMap([
file("app/src/entry.js", "root"),
file("app/node_modules/fixture/package.json", "root", '{"main":42}'),
file("app/node_modules/fixture/index.js", "root"),
]);
expect(
resolve({
declaredPath: "fixture",
sourcePath: "app/src/entry.js",
context: "module-specifier",
moduleKind,
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/node_modules/fixture/index.js",
});
},
);
it.each(["import", "require"] as const)(
"does not treat a root-pointing %s package main as a dotfile module",
(moduleKind) => {
const files = fileMap([
file("src/entry.js", "root"),
file("node_modules/fixture/package.json", "root", '{"main":"../.."}'),
file("index.js", "root"),
file(".js", "root"),
file(".json", "root"),
]);
expect(
resolve({
declaredPath: "fixture",
sourcePath: "src/entry.js",
context: "module-specifier",
moduleKind,
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "index.js",
});
},
);
});
describe("CommonJS bare package ancestor lookup", () => {
@@ -565,6 +704,54 @@ describe("contextual JavaScript artifact path resolution", () => {
});
});
describe("ESM module URL path decoding", () => {
it.each([
["./a%2eb.mjs", "app/esm/a.b.mjs"],
["./sub/%2e%2e/x.mjs", "app/esm/x.mjs"],
])(
"decodes confined URL path %s before artifact lookup",
(declaredPath, target) => {
expect(
resolve({
declaredPath,
sourcePath: "app/esm/entry.mjs",
context: "module-specifier",
moduleKind: "import",
files: fileMap([
file("app/esm/entry.mjs", "root"),
file("app/esm/a.b.mjs", "root"),
file("app/esm/x.mjs", "root"),
]),
}),
).toMatchObject({
resolved_path: target,
resolution_status: "resolved",
});
},
);
it("rejects encoded separators and paths escaping the artifact root", () => {
const files = fileMap([
file("app/esm/entry.mjs", "root"),
file("app/esm/x.mjs", "root"),
]);
for (const declaredPath of ["./sub%2fx.mjs", "../../../escape.mjs"]) {
expect(
resolve({
declaredPath,
sourcePath: "app/esm/entry.mjs",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({
resolved_path: null,
resolution_status: "rejected",
});
}
});
});
describe("JavaScript file URL resolution", () => {
it("resolves file URL paths without interpreting query bytes as path syntax", () => {
const files = fileMap([file("assets/app.js", "root")]);
@@ -2371,6 +2558,30 @@ describe("exact HTML resource resolution", () => {
});
});
it.each(["require", "import"] as const)(
"rejects a literal trailing backslash package main before %s probing",
(moduleKind) => {
const files = fileMap([
file("entry.js", "root"),
file(
"node_modules/p/package.json",
"root",
JSON.stringify({ main: "lib\\" }),
),
file("node_modules/p/lib", "root"),
]);
expect(
resolve({
declaredPath: "p",
sourcePath: "entry.js",
context: "module-specifier",
moduleKind,
files,
}),
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
},
);
describe("exact Electron runtime entrypoint resolution", () => {
it("requires a protocol for Electron URL resources", () => {
const files = fileMap([
@@ -2530,10 +2741,10 @@ it("resolves Node runtime entrypoints without substituting emitted TypeScript so
file("runtime.js", "root"),
file("directory/index.js", "root"),
]);
for (const [declaredPath, status, path] of [
["./worker.js", "not-found", null],
["./runtime", "resolved", "runtime.js"],
["./directory", "resolved", "directory/index.js"],
for (const [declaredPath, status, path, candidatePath] of [
["./worker.js", "not-found", null, "worker.js"],
["./runtime", "resolved", "runtime.js", undefined],
["./directory", "resolved", "directory/index.js", undefined],
] as const)
expect(
resolve({
@@ -2543,5 +2754,9 @@ it("resolves Node runtime entrypoints without substituting emitted TypeScript so
files,
runtimePathKind: "node-entrypoint",
}),
).toMatchObject({ resolution_status: status, resolved_path: path });
).toMatchObject({
resolution_status: status,
resolved_path: path,
...(candidatePath === undefined ? {} : { candidate_path: candidatePath }),
});
});
@@ -5,7 +5,10 @@ import {
normalizeJoinedLogicalPath,
logicalPathEscapesRoot,
} from "../../domain/artifactIdentity.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import type {
JavaScriptArtifactFile,
JavaScriptUnanalyzedArtifactLookup,
} from "../../domain/javascript/javascriptArtifactFiles.js";
import {
admitsCanonicalPathSyntax,
hasScheme,
@@ -26,6 +29,7 @@ import {
import { isRelativeJavaScriptModuleSpecifier } from "../../domain/javascript/javascriptModuleSpecifier.js";
import type { JavaScriptModuleResolutionMode } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
type ArtifactPathResolutionContext =
| "package-entrypoint"
@@ -75,6 +79,10 @@ export interface ResolveArtifactPathInput {
readonly sourcePath: string;
readonly context: ArtifactPathResolutionContext;
readonly files: ReadonlyMap<string, JavaScriptArtifactFile>;
/** Directory identities retained by the complete artifact inventory. */
readonly directoryPaths?: ReadonlySet<string> | undefined;
/** Inventoried files outside the JavaScript projection: present, never read. */
readonly unanalyzedFiles?: JavaScriptUnanalyzedArtifactLookup | undefined;
readonly htmlBaseHref?: string | null;
/** Import/require semantics; runtime resource paths omit this provenance. */
readonly moduleKind?: JavaScriptModuleResolutionMode;
@@ -102,16 +110,66 @@ const EXTENSIONS = [
// A private URL namespace for logical artifact paths, including root packages.
// Encode inventory segments so filename punctuation remains literal in the base.
const PACKAGE_ARTIFACT_ROOT_URL = new URL("file:///rea-artifact/");
const packageMetadataUrl = (path: string): URL =>
const encodePackageMetadataUrl = (path: string): URL =>
new URL(
path.split("/").map(encodeURIComponent).join("/"),
PACKAGE_ARTIFACT_ROOT_URL,
);
// URL identity cannot represent lone UTF-16 surrogates. Use request-local
// opaque segments only for structural admission, never inventory lookup. A
// target cannot spell these identities after URL's tab/newline preprocessing.
const packageMetadataUrl = (
path: string,
relativeTarget: string,
): Result<
URL,
{
readonly structuralUrl: URL;
readonly structuralSegments: ReadonlySet<string>;
readonly limitation: string;
}
> => {
try {
return ok(encodePackageMetadataUrl(path));
} catch (cause: unknown) {
if (!(cause instanceof URIError)) throw cause;
const reserved = new Set(
`${path}\n${relativeTarget.replace(/[\t\n\r]/gu, "")}`.match(
/rea-opaque-\d+/gu,
) ?? [],
);
const structuralSegments = new Set<string>();
let nextIdentity = 0;
const structuralPath = path
.split("/")
.map((segment) => {
try {
return encodeURIComponent(segment);
} catch (segmentCause: unknown) {
if (!(segmentCause instanceof URIError)) throw segmentCause;
let identity: string;
do identity = `rea-opaque-${nextIdentity++}`;
while (reserved.has(identity));
reserved.add(identity);
structuralSegments.add(identity);
return identity;
}
})
.join("/");
return err({
structuralUrl: new URL(structuralPath, PACKAGE_ARTIFACT_ROOT_URL),
structuralSegments,
limitation: `Package metadata path ${JSON.stringify(path)} contains an unpaired UTF-16 surrogate and cannot be represented as a URL.`,
});
}
};
interface PackageFileTarget {
readonly value: string;
/** Normalized URL retained until loader-specific validation and file lookup. */
readonly url: URL;
/** Structural validation remains possible when the inventory base has no URL identity. */
readonly lookupLimitation?: string;
}
type CandidateResolution = {
@@ -316,7 +374,13 @@ const resolveNodeRuntimeEntrypoint = (
if (typeof confined !== "string") return confined;
// Node entrypoints use runtime file/directory probes. They do not originate
// in emitted TypeScript import syntax and cannot select a source substitute.
return resolveCandidate(runtimeInput, confined, new Set());
const resolution = resolveCandidate(runtimeInput, confined, new Set());
// Runtime entrypoint analysis has no extension or directory fallback, but
// retain the normalized loader path when that exact target is absent.
return resolution.status === "not-found" &&
resolution.candidatePath === undefined
? { ...resolution, candidatePath: confined }
: resolution;
};
const resolveRuntimeEntrypoint = (
@@ -349,19 +413,13 @@ const resolveRuntimeSourceCandidates = (
exactPath: string,
sourceCandidates: readonly TypeScriptEmittedSourceCandidate[],
): CandidateResolution | null => {
const source = input.files.get(input.sourcePath);
const exactCandidate: TypeScriptEmittedSourceCandidate = {
path: exactPath,
kind: "implementation",
};
for (const candidate of [exactCandidate, ...sourceCandidates]) {
const target = input.files.get(candidate.path);
if (
target === undefined ||
(source !== undefined &&
target.container_sha256 !== source.container_sha256)
)
continue;
const selected = findFileCandidate(input, candidate.path);
if (selected === null) continue;
return candidate.kind === "declaration"
? {
resolvedPath: null,
@@ -371,7 +429,7 @@ const resolveRuntimeSourceCandidates = (
"Only a TypeScript declaration fallback was found; it does not establish the requested runtime module.",
],
}
: { resolvedPath: candidate.path, status: "resolved", limitations: [] };
: selected;
}
return null;
};
@@ -439,7 +497,12 @@ const contextualCandidate = (
"File URL authorities or invalid file URL forms cannot be mapped to artifact-local paths.",
]);
}
const rejected = rejectDeclaration(declared, declaresLiteralPercent(input));
const hasUrlPathSemantics =
context === "module-specifier" && input.moduleKind === "import";
const rejected = rejectDeclaration(
declared,
declaresLiteralPercent(input) || hasUrlPathSemantics,
);
if (rejected !== null) return rejected;
if (context === "module-specifier") {
if (hasScheme(declared))
@@ -452,6 +515,10 @@ const contextualCandidate = (
)
return bareModuleCandidate(input);
if (input.moduleKind !== "require") {
if (/%(?:2f|5c)/iu.test(stripQueryAndFragment(declared)))
return unresolvedCandidate("rejected", [
"ES module URL paths cannot contain percent-encoded slash or backslash bytes.",
]);
const decoded = decodeModuleUrlPath(stripQueryAndFragment(declared));
if (typeof decoded !== "string") return decoded;
declared = decoded;
@@ -575,7 +642,13 @@ const bareModuleCandidate = (
metadata.exports === undefined)));
const packageResolution =
metadata.kind !== "absent" ||
hasContainerDirectory(input.files, candidate, source?.container_sha256)
hasContainerDirectory(
input.files,
candidate,
source?.container_sha256,
input.directoryPaths,
input.unanalyzedFiles,
)
? resolvePackageDependency(input, candidate, request, metadata)
: request.packageSubpath === "." && input.moduleKind !== "import"
? resolveFileCandidates(
@@ -674,7 +747,7 @@ const resolvePackageImportAlias = (
const selected = selectExportTarget(
lookup.target,
packageExportConditions(input.moduleKind),
packageMetadataUrl(metadata.path),
metadata.path,
"imports",
);
if (selected.kind === "invalid" || selected.kind === "invalid-config")
@@ -750,17 +823,9 @@ const resolvePackageDependency = (
fileCandidates(directory, input.moduleKind),
)) ?? resolveDirectory(input, directory, metadata, new Set())
);
// Resolve legacy ESM subpaths as URLs: suffixes identify the loaded module
// but do not belong to its filesystem path. Keep the declared specifier on
// the relationship; only derive the inventory lookup path here.
const fileSubpath =
input.moduleKind === "import"
? decodeModuleUrlPath(stripQueryAndFragment(subpath.slice(2)))
: subpath.slice(2);
if (typeof fileSubpath !== "string") return fileSubpath;
const candidate = confineCandidate(`${directory}/${fileSubpath}`);
if (input.moduleKind === "import" && typeof candidate === "string")
return resolveLegacyEsmSubpath(input, candidate);
if (input.moduleKind === "import")
return resolveLegacyEsmSubpath(input, directory, subpath);
const candidate = confineCandidate(`${directory}/${subpath.slice(2)}`);
return typeof candidate === "string"
? resolveCandidate(input, candidate, new Set())
: candidate;
@@ -776,12 +841,58 @@ const resolvePackageDependency = (
/** Legacy ESM package subpaths are exact URLs, never extension or directory lookups. */
const resolveLegacyEsmSubpath = (
input: ResolveArtifactPathInput,
candidate: string,
directory: string,
subpath: string,
): CandidateResolution => {
const declarationRejection = rejectDeclaration(
stripQueryAndFragment(htmlUrlText(subpath)),
);
if (declarationRejection !== null) return declarationRejection;
const metadataUrl = packageMetadataUrl(
posix.join(directory, "package.json"),
subpath,
);
const url = new URL(
subpath,
metadataUrl.ok ? metadataUrl.value : metadataUrl.error.structuralUrl,
);
if (!url.pathname.startsWith(PACKAGE_ARTIFACT_ROOT_URL.pathname))
return unresolvedCandidate("rejected", [
"The resolved candidate escapes the canonical artifact root.",
]);
const encodedPath = url.pathname.slice(
PACKAGE_ARTIFACT_ROOT_URL.pathname.length,
);
const rejected = rejectDeclaration(encodedPath);
if (rejected !== null) return rejected;
const decoded = decodeModuleUrlPath(encodedPath);
if (typeof decoded !== "string") return decoded;
const candidate = confineCandidate(decoded);
if (typeof candidate !== "string") return candidate;
if (urlPathIsDirectory(url.pathname))
return unresolvedCandidate("rejected", [
`ESM package subpath ${JSON.stringify(input.declaredPath)} has URL directory form; directory imports are unsupported and no exact-file, package main or index lookup was attempted.`,
]);
const unrepresentableBase = metadataUrl.ok ? undefined : metadataUrl.error;
if (
unrepresentableBase !== undefined &&
url.pathname
.split("/")
.some((segment) => unrepresentableBase.structuralSegments.has(segment))
)
return unresolvedCandidate("unavailable", [unrepresentableBase.limitation]);
const file = resolveFileCandidates(input, [candidate]);
if (file !== null) return file;
const source = input.files.get(input.sourcePath);
if (hasContainerDirectory(input.files, candidate, source?.container_sha256))
if (
hasContainerDirectory(
input.files,
candidate,
source?.container_sha256,
input.directoryPaths,
input.unanalyzedFiles,
)
)
return unresolvedCandidate("rejected", [
`ESM package subpath ${candidate} names a directory; directory imports are unsupported and package main or index lookup was not attempted.`,
]);
@@ -849,6 +960,8 @@ const resolveExportTarget = (
if (typeof decoded !== "string") return withTargetSource(decoded);
const candidate = confineCandidate(decoded);
if (typeof candidate !== "string") return withTargetSource(candidate);
if (target.lookupLimitation !== undefined)
return unresolvedCandidate("unavailable", [target.lookupLimitation]);
return (
resolveFileCandidates(input, [candidate]) ??
withTargetSource(exactPackageTargetNotFound(candidate, mapKind))
@@ -891,7 +1004,10 @@ const hasContainerDirectory = (
files: ReadonlyMap<string, JavaScriptArtifactFile>,
candidate: string,
containerSha256: string | undefined,
directoryPaths: ReadonlySet<string> | undefined,
unanalyzedFiles?: JavaScriptUnanalyzedArtifactLookup,
): boolean => {
if (directoryPaths?.has(candidate) === true) return true;
if (
[...indexCandidates(candidate), posix.join(candidate, "package.json")].some(
(path) => {
@@ -915,7 +1031,7 @@ const hasContainerDirectory = (
file.container_sha256 === containerSha256)
)
return true;
return false;
return unanalyzedFiles?.hasDirectory(candidate, containerSha256) ?? false;
};
const htmlCandidate = (
@@ -1055,6 +1171,7 @@ const readPackageMetadata = (
const name = Reflect.get(value, "name");
const exports = Reflect.get(value, "exports");
const imports = Reflect.get(value, "imports");
const main = Reflect.get(value, "main");
return {
kind: "available",
path,
@@ -1071,7 +1188,10 @@ const readPackageMetadata = (
typeof imports === "object" && imports !== null && !Array.isArray(imports)
? imports
: undefined,
main: Reflect.get(value, "main"),
main:
input.moduleKind !== undefined && typeof main !== "string"
? undefined
: main,
module: Reflect.get(value, "module"),
};
};
@@ -1149,12 +1269,20 @@ const resolveDirectory = (
sourcePath: metadata.path,
context: "package-entrypoint",
files: input.files,
...(input.directoryPaths === undefined
? {}
: { directoryPaths: input.directoryPaths }),
...(input.unanalyzedFiles === undefined
? {}
: { unanalyzedFiles: input.unanalyzedFiles }),
...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }),
};
const candidate = contextualCandidate(entryInput);
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(candidate);
if (typeof confined !== "string") return confined;
if (input.moduleKind !== undefined)
return resolveNodePackageMain(entryInput, directory, confined);
const selected =
resolveFileCandidates(entryInput, [
...fileCandidates(confined, entryInput.moduleKind),
@@ -1162,9 +1290,6 @@ const resolveDirectory = (
]) ??
resolveFileCandidates(input, indexCandidates(directory, input.moduleKind));
if (selected !== null) return selected;
if (input.moduleKind === "require")
return { ...notFoundCandidate(), stopsAncestorLookup: true };
if (input.moduleKind !== undefined) return notFoundCandidate();
return resolveCandidate(
entryInput,
confined,
@@ -1172,32 +1297,115 @@ const resolveDirectory = (
);
};
const resolveNodePackageMain = (
input: ResolveArtifactPathInput,
directory: string,
mainPath: string,
): CandidateResolution => {
const normalizedMain = mainPath.replace(/\/$/u, "");
for (const suffix of NODE_PACKAGE_MAIN_SUFFIXES) {
const isDirectoryIndex = suffix.startsWith("/");
if (normalizedMain === "" && !isDirectoryIndex) continue;
const selected = resolveFileCandidates(input, [
isDirectoryIndex
? posix.join(normalizedMain, suffix.slice(1))
: posix.normalize(`${normalizedMain}${suffix}`),
]);
if (selected === null) continue;
if (input.moduleKind === "require") return selected;
// ESM probes the normalized main, then appends the selected suffix to
// the original declaration before finalizing the resulting file path.
const finalized = confineCandidate(
relativeCandidate(input.sourcePath, `${input.declaredPath}${suffix}`),
);
if (typeof finalized !== "string") return finalized;
const finalizedFile = resolveFileCandidates(input, [
finalized.replace(/\/$/u, ""),
]);
if (finalizedFile !== null) return finalizedFile;
const source = input.files.get(input.sourcePath);
if (
hasContainerDirectory(
input.files,
finalized,
source?.container_sha256,
input.directoryPaths,
input.unanalyzedFiles,
)
)
return {
...unresolvedCandidate("rejected", [
`The selected legacy ESM package main finalizes to directory ${finalized}; directory imports are unsupported and no later main or index candidate was tried.`,
]),
candidatePath: finalized,
};
return unresolvedCandidate("not-found", [
`The selected legacy ESM package main finalizes to ${finalized}, which is not an inventoried file; no later main or index candidate was tried.`,
]);
}
const fallback = resolveFileCandidates(
input,
indexCandidates(directory, input.moduleKind),
);
if (fallback !== null) return fallback;
return input.moduleKind === "require"
? { ...notFoundCandidate(), stopsAncestorLookup: true }
: notFoundCandidate();
};
const resolveFileCandidates = (
input: ResolveArtifactPathInput,
candidates: readonly string[],
): CandidateResolution | null => {
const source = input.files.get(input.sourcePath);
for (const path of candidates) {
const target = input.files.get(path);
if (
target !== undefined &&
(source === undefined ||
target.container_sha256 === source.container_sha256)
)
return isRuntimeDeclarationPath(input, path)
? {
resolvedPath: null,
status: "unavailable",
candidatePath: path,
limitations: [
`The TypeScript declaration ${path} is compile-time evidence and cannot identify a runtime module implementation.`,
],
}
: { resolvedPath: path, status: "resolved", limitations: [] };
const selected = findFileCandidate(input, path);
if (selected === null) continue;
return selected.status === "resolved" &&
isRuntimeDeclarationPath(input, path)
? {
resolvedPath: null,
status: "unavailable",
candidatePath: path,
limitations: [
`The TypeScript declaration ${path} is compile-time evidence and cannot identify a runtime module implementation.`,
],
}
: selected;
}
return null;
};
/** Own candidate presence and container identity independently of loader policy. */
const findFileCandidate = (
input: ResolveArtifactPathInput,
path: string,
): CandidateResolution | null => {
const source = input.files.get(input.sourcePath);
const target = input.files.get(path);
if (
target !== undefined &&
(source === undefined ||
target.container_sha256 === source.container_sha256)
)
return { resolvedPath: path, status: "resolved", limitations: [] };
const unanalyzed = input.unanalyzedFiles?.get(path);
if (
unanalyzed !== undefined &&
(source === undefined ||
unanalyzed.container_sha256 === source.container_sha256)
)
return {
resolvedPath: null,
status: "unavailable",
candidatePath: path,
limitations: [
...(unanalyzed.limitations ?? []),
`${path} exists in the inventoried artifact container (sha256 ${unanalyzed.sha256 ?? "unknown"}, ${unanalyzed.bytes === null ? "unknown size" : `${String(unanalyzed.bytes)} bytes`}) but is not a JavaScript, JSON, HTML, source map, or native add-on file; its contents were not analyzed.`,
],
};
return null;
};
const isRuntimeDeclarationPath = (
input: ResolveArtifactPathInput,
path: string,
@@ -1205,6 +1413,11 @@ const isRuntimeDeclarationPath = (
input.moduleKind !== undefined && /\.d\.(?:ts|mts|cts)$/iu.test(path);
const NODE_MODULE_EXTENSIONS = [".js", ".json", ".node"] as const;
const NODE_PACKAGE_MAIN_SUFFIXES = [
"",
...NODE_MODULE_EXTENSIONS,
...NODE_MODULE_EXTENSIONS.map((extension) => `/index${extension}`),
];
const fileCandidates = (
candidate: string,
@@ -1264,7 +1477,7 @@ const selectPackageExport = (
const selected = selectExportTarget(
target,
packageExportConditions(moduleKind),
packageMetadataUrl(packagePath),
packagePath,
);
if (selected.kind === "invalid" || selected.kind === "invalid-config")
return { status: "rejected", limitation: selected.limitation };
@@ -1284,6 +1497,11 @@ const selectPackageExport = (
status: "value",
value: selected.value,
url: selected.url,
...(selected.lookupLimitation === undefined
? {}
: {
lookupLimitation: selected.lookupLimitation,
}),
};
};
@@ -1323,7 +1541,7 @@ const forbiddenExportSegment = (value: string): string | undefined =>
const selectStringPackageTarget = (
value: string,
packageUrl: URL,
packagePath: string,
mapKind: "exports" | "imports",
): ExportTargetOutcome => {
if (mapKind === "imports") {
@@ -1344,6 +1562,10 @@ const selectStringPackageTarget = (
`contains forbidden path segment ${JSON.stringify(forbidden)} before URL normalization`,
mapKind,
);
const metadataUrl = packageMetadataUrl(packagePath, value);
const packageUrl = metadataUrl.ok
? metadataUrl.value
: metadataUrl.error.structuralUrl;
// Node constructs the URL before deciding whether this array entry is an
// invalid package target. Tabs/newlines and dot segments normalize here;
// percent decoding and existence checks belong after target selection.
@@ -1358,13 +1580,18 @@ const selectStringPackageTarget = (
kind: "value",
value,
url,
...(metadataUrl.ok
? {}
: {
lookupLimitation: metadataUrl.error.limitation,
}),
};
};
const selectArrayPackageTarget = (
value: readonly unknown[],
conditions: ReadonlySet<string>,
packageUrl: URL,
packagePath: string,
mapKind: "exports" | "imports",
): ExportTargetOutcome => {
if (value.length === 0) return { kind: "blocked", cause: "empty-array" };
@@ -1373,7 +1600,7 @@ const selectArrayPackageTarget = (
| undefined;
const declared = new Set<string>();
for (const entry of value) {
const nested = selectExportTarget(entry, conditions, packageUrl, mapKind);
const nested = selectExportTarget(entry, conditions, packagePath, mapKind);
// Node's array fallback catches invalid targets, not invalid configuration.
if (nested.kind === "invalid-config") return nested;
if (nested.kind === "unmatched") {
@@ -1399,14 +1626,14 @@ const selectArrayPackageTarget = (
const selectExportTarget = (
value: unknown,
conditions: ReadonlySet<string>,
packageUrl: URL,
packagePath: string,
mapKind: "exports" | "imports" = "exports",
): ExportTargetOutcome => {
if (typeof value === "string")
return selectStringPackageTarget(value, packageUrl, mapKind);
return selectStringPackageTarget(value, packagePath, mapKind);
if (value === null) return { kind: "blocked", cause: "null" };
if (Array.isArray(value))
return selectArrayPackageTarget(value, conditions, packageUrl, mapKind);
return selectArrayPackageTarget(value, conditions, packagePath, mapKind);
if (typeof value !== "object")
return invalidExportTarget(
value,
@@ -1425,7 +1652,7 @@ const selectExportTarget = (
for (const [condition, target] of Object.entries(value)) {
declared.add(condition);
if (!conditions.has(condition)) continue;
const nested = selectExportTarget(target, conditions, packageUrl, mapKind);
const nested = selectExportTarget(target, conditions, packagePath, mapKind);
if (nested.kind !== "unmatched") return nested;
for (const name of nested.declared) declared.add(name);
}
@@ -252,7 +252,14 @@ export const reconstructJavaScriptArtifact = async (
: { source_selection: files.source_selection }),
graph,
semantic_graph: semanticGraph,
electron_summary: summarizeElectronBoundaries(analysis),
electron_summary: summarizeElectronBoundaries(
analysis,
new Set(
snapshot.occurrences
.filter(({ entry_kind }) => entry_kind === "directory")
.map(({ logical_path }) => logical_path),
),
),
statistics: {
relevant_files: files.files.length,
nested_asar_containers: files.containers.length,
@@ -289,6 +289,8 @@ const resolveModuleTarget = (
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"
@@ -2,9 +2,12 @@ import { expect, it } from "vitest";
import { BrowserObservationError } from "../../domain/browserObservationError.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { err } from "../../domain/result.js";
import { err, ok } from "../../domain/result.js";
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
import { observeJavaScriptRuntime } from "./JavaScriptRuntimeObservationService.js";
import {
listJavaScriptRuntimeTargets,
observeJavaScriptRuntime,
} from "./JavaScriptRuntimeObservationService.js";
it("preserves failed Inspector cleanup when cancellation occurs during observation", async () => {
const controller = new AbortController();
@@ -44,3 +47,42 @@ it("preserves failed Inspector cleanup when cancellation occurs during observati
expect(result.error.cleanupResources).toEqual(["browser_transport"]);
expect(result.error.userCategory).toBe("cancelled");
});
it("does not publish targets when cancellation arrives during provider cleanup", async () => {
const controller = new AbortController();
const provider: JavaScriptRuntimeObservationPort = {
identity: () => ({
id: "test-inspector",
name: "Test Inspector",
version: "1",
}),
listTargets: async () => {
controller.abort();
return ok({
runtime: {
product: "node.js/v24",
protocol_version: "1.3",
v8_version: "13",
},
targets: [],
excluded: { unsupported_location: 0, unconnectable: 0 },
limitations: [],
});
},
observe: async () =>
err(new AnalysisCancelledError("observe_javascript_runtime")),
};
const result = await listJavaScriptRuntimeTargets(
provider,
{ inspector_endpoint: "http://127.0.0.1:9229" },
{ signal: controller.signal },
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "list_javascript_runtime_targets",
},
});
});
@@ -22,6 +22,8 @@ export const listJavaScriptRuntimeTargets = async (
const ready = requireProvider(provider, "list_javascript_runtime_targets");
if (!ready.ok) return ready;
const result = await ready.value.listTargets(input, options);
if (result.ok && options.signal?.aborted === true)
return err(new AnalysisCancelledError("list_javascript_runtime_targets"));
return result.ok
? ok(
createJavaScriptRuntimeObservationEvidence(