fix(javascript): preserve runtime path bytes and Electron ownership (#4941)

* fix(javascript): preserve literal percent runtime paths

* fix(javascript): ignore resolve prefixes before the final anchor

* fix(electron): retain application roots for loadFile declarations

* fix(electron): resolve loadFile from enclosing application package

* fix(electron): retain outer application root across package scopes

* fix(electron): derive application ownership from inventoried packages

* fix(electron): retain resolved package entrypoint ownership

* fix(electron): preserve competing application ownership

* fix(electron): retain invalid and omitted runtime ownership

* fix(electron): preserve omitted module ownership and runtime paths

* fix(electron): preserve proven roots before confining page paths

* fix(electron): retain possible owners behind missing imports

* fix(electron): distinguish absent imports and owned page candidates

* fix(electron): keep unavailable JSON out of owner gaps

* fix(electron): retain exact loader formats and default entry status

* fix(electron): retain unavailable entrypoint ownership candidates

* test(ghidra): preserve Windows Node probe prerequisites

* test(ghidra): assert cancellation during profile shutdown

* fix(electron): separate examined imports from frontend ownership

* fix(electron): retain unavailable directory entrypoint owners

* test(jeb): retain Linux ownership prerequisite in isolation

* fix(javascript): retain unchecked loader ownership and literal main paths

* fix(javascript): preserve runtime paths and call-site module aliases

* fix(javascript): bound module alias traversal and preserve projections

* fix(javascript): retain host module and nested artifact path boundaries

* fix(javascript): preserve ESM and archive module loader provenance

* test(javascript): distinguish unmapped containers from missing exports

* fix: retain default module aliases and Electron builtin imports

* fix: preserve host file URL and inward ASAR ownership

* fix: preserve CommonJS file URL classification in CI fixtures

* fix: retain bound module loaders and Windows entrypoint uncertainty

* fix: retain ASAR uncertainty and invalidate changed Electron app roots

* fix(javascript): retain ownership uncertainty across loader boundaries

* fix(javascript): preserve loader sources and bound alias queries

* fix(javascript): retain destructured loader sources

* fix(javascript): preserve loader uncertainty at resolution boundaries

* fix(javascript): preserve callable and package launch ownership

* fix(javascript): preserve captured values across member reads

* test(process): assert tagged filesystem cancellation failures

* fix(javascript): preserve inventory bases and invocation read timing

* fix(javascript): preserve ownership across indirect and unread loads

* test(javascript): order fixture directory creation on Windows

* test(javascript): share ordered filesystem fixture writer

* fix(javascript): unify loader provenance and package validation

* fix(javascript): preserve captured Node require loader values

* fix(javascript): preserve loader invocation authority

* fix(javascript): verify generic loader invocation authority

* fix(javascript): preserve conditional loader method alternatives

* fix(javascript): require proven factories for source association

* fix(javascript): honor replaced Electron app path setters

* fix(javascript): prove path and assignment source ownership

* fix(javascript): retain native delegate eligibility through shared writes

* fix(process): settle stale baseline errors after confirmed absence

* fix(javascript): preserve assignment and path boundary evidence

* fix(javascript): prove source-directory resolve anchors

* fix: preserve application path provenance and portable schemas

* fix(javascript): retain CommonJS exported setter effects

* fix: isolate Linux identity observations and retain native module exports

* fix(javascript): preserve published app and helper provenance

* fix(javascript): normalize typed stores and native path fixtures
This commit is contained in:
ShuaixinHuang
2026-10-20 17:25:19 +08:00
committed by GitHub
parent d532b18123
commit e9fc8fb30c
149 changed files with 16881 additions and 988 deletions
@@ -0,0 +1,64 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
const packageObservation = (text: string) =>
analyzeJavaScriptArtifactFiles({
files: [
{
path: "app/package.json",
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: Buffer.byteLength(text),
inventory_artifact_id: "package-main-fixture",
kind: "package-json",
unpacked: false,
text: { included: true, value: text },
},
],
unavailable_relevant_files: [],
containers: [],
text_bytes_read: Buffer.byteLength(text),
invalid_utf8_files: 0,
}).packages[0];
it.each([null, false, 123, {}, []].map((main) => ({ main })))(
"retains non-string main $main evidence separately from Node index fallback",
({ main }) => {
expect(packageObservation(JSON.stringify({ main }))).toMatchObject({
status: "included",
main: null,
main_status: "invalid",
main_entrypoint: "",
});
},
);
it.each([
["{}", "default", ""],
['{"main":""}', "default", ""],
['{"main":"main.cjs"}', "declared", "main.cjs"],
['{"main":"sub\\\\main.cjs"}', "declared", "sub\\main.cjs"],
['{"main":"C:main.cjs"}', "declared", "C:main.cjs"],
] as const)(
"preserves package launch declarations from %s",
(text, status, entry) => {
expect(packageObservation(text)).toMatchObject({
status: "included",
main_status: status,
main_entrypoint: entry,
});
},
);
it.each(["{", "null", "[]", "[{}]", "123", '"root"', "false"])(
"keeps malformed package metadata %s out of known launch fallback",
(text) => {
expect(packageObservation(text)).toMatchObject({
status: "invalid",
main: null,
main_status: "unavailable",
main_entrypoint: null,
});
},
);
@@ -512,6 +512,13 @@ const parsePackage = (
name: optionalString(value.name),
version: optionalString(value.version),
main: optionalString(value.main),
main_status:
value.main === undefined || value.main === ""
? "default"
: typeof value.main === "string"
? "declared"
: "invalid",
main_entrypoint: typeof value.main === "string" ? value.main : "",
renderer:
optionalString(value.renderer) ??
optionalString(value.browser) ??
@@ -538,6 +545,8 @@ const unavailablePackage = (
name: null,
version: null,
main: null,
main_status: "unavailable",
main_entrypoint: null,
renderer: null,
limitation,
});
@@ -20,6 +20,9 @@ interface JavaScriptPackageValues {
readonly name: string | null;
readonly version: string | null;
readonly main: string | null;
readonly main_status: "default" | "declared" | "invalid";
/** Node launch declaration; an empty string requests the package index fallback. */
readonly main_entrypoint: string;
readonly renderer: string | null;
}
@@ -35,6 +38,8 @@ export type JavaScriptPackageObservation = JavaScriptStructuredObservation &
readonly name: null;
readonly version: null;
readonly main: null;
readonly main_status: "unavailable";
readonly main_entrypoint: null;
readonly renderer: null;
readonly limitation: string;
}
@@ -12,6 +12,8 @@ import {
HTML_INLINE_SCRIPT_LOCATION_COVERAGE_LIMIT,
omittedHtmlInlineScriptLocations,
selfReferenceOmissions,
dependencyManifest,
applicationContainerIdentity,
type JavaScriptArtifactGraphContext,
} from "./JavaScriptArtifactGraphContext.js";
import {
@@ -93,6 +95,57 @@ function* buildJavaScriptArtifactGraphInputSteps(
): Generator<void, unknown> {
const accumulator = new JavaScriptArtifactGraphAccumulator();
const root = createJavaScriptArtifactRootNode(accumulator, snapshot);
const parsedPackages = new Set(
analysis.packages
.filter((metadata) => metadata.status === "included")
.map(({ path }) => path),
);
const applicationOwnershipUnknownContainers = new Set<string>();
const applicationModuleGapSources = new Map<string, Set<string>>();
const applicationModuleReferencesRetained = new Set<string>();
const recordModuleGap = (file: {
path: string;
container_sha256: string;
}) => {
const container = applicationContainerIdentity(fileSet, file);
const sources =
applicationModuleGapSources.get(container) ?? new Set<string>();
sources.add(file.path);
applicationModuleGapSources.set(container, sources);
};
for (const files of [fileSet.files, fileSet.unavailable_relevant_files]) {
for (const file of files) {
if (
file.kind === "package-json" &&
!dependencyManifest(file.path) &&
!parsedPackages.has(file.path)
)
applicationOwnershipUnknownContainers.add(
applicationContainerIdentity(fileSet, file),
);
if (
file.kind === "javascript" &&
(!("text" in file) || !file.text.included)
)
recordModuleGap(file);
}
}
// Omitted module relationships can hide another launch owner. Do not expand
// retained facts beyond the projection guard merely to recover ownership.
for (const analyzed of analysis.files) {
if (
analyzed.application_projection_failure !== undefined ||
analyzed.analysis_failure !== undefined ||
analyzed.javascript?.parse_status === "failed" ||
(analyzed.javascript?.parse_error_count ?? 0) > 0
)
recordModuleGap(analyzed.file);
else if (analyzed.javascript !== null)
applicationModuleReferencesRetained.add(analyzed.file.path);
}
const unavailableFilesByPath = new Map(
fileSet.unavailable_relevant_files.map((file) => [file.path, file]),
);
const context: JavaScriptArtifactGraphContext = {
accumulator,
snapshot,
@@ -105,13 +158,23 @@ function* buildJavaScriptArtifactGraphInputSteps(
),
root,
filesByPath: new Map(fileSet.files.map((file) => [file.path, file])),
unanalyzedFiles: fileSet.unanalyzed_files,
unanalyzedFiles: {
get: (path) =>
unavailableFilesByPath.get(path) ?? fileSet.unanalyzed_files?.get(path),
hasDirectory: (path, container) =>
fileSet.unanalyzed_files?.hasDirectory(path, container) ?? false,
},
fileNodes: new Map(),
assetNodes: new Map(),
sourceModuleNodes: new Map(),
chunkNodes: new Map(),
moduleNodes: new Map(),
containerNodes: new Map([[snapshot.manifest.root_sha256, root]]),
applicationRootsBySource: new Map(),
applicationOwnershipUnknownContainers,
applicationModuleGapSources,
applicationModuleReferencesRetained,
applicationPossibleRootsByContainer: new Map(),
};
addJavaScriptArtifactContainers(context);
yield;
@@ -1,6 +1,9 @@
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import {
compositeKey,
compareUnicodeCodePoints,
} from "../../domain/unicodeCodePointOrder.js";
import {
completeApplicationCoverage,
partialApplicationCoverage,
@@ -51,8 +54,112 @@ export interface JavaScriptArtifactGraphContext {
readonly chunkNodes: Map<string, ApplicationNode>;
readonly moduleNodes: Map<string, ApplicationNode>;
readonly containerNodes: Map<string, ApplicationNode>;
/** Resolved entrypoints/imports retain unique or competing application owners. */
readonly applicationRootsBySource: Map<string, Set<string>>;
/** Incomplete package facts can hide competing launch declarations. */
readonly applicationOwnershipUnknownContainers: ReadonlySet<string>;
/** Gap origins retain possible owners separately from proven source owners. */
readonly applicationModuleGapSources: Map<string, Set<string>>;
readonly applicationPossibleRootsByContainer: Map<string, Set<string>>;
/** Sources with retained references; unchecked loads carry their own gaps. */
readonly applicationModuleReferencesRetained: ReadonlySet<string>;
}
/** Application manifests exclude dependency packages but retain nested app roots. */
export const dependencyManifest = (path: string): boolean =>
path.split("/").slice(0, -1).includes("node_modules");
/** Two distinct roots prove ambiguity; further roots cannot restore uniqueness. */
export const addApplicationRootCandidate = (
roots: Set<string>,
root: string,
): void => {
if (roots.size < 2) roots.add(root);
};
const applicationContainerRoot = (
fileSet: JavaScriptArtifactFileSet,
source: Pick<JavaScriptArtifactFile, "path" | "container_sha256">,
): string =>
fileSet.containers.find(
({ sha256, path }) =>
sha256 === source.container_sha256 && source.path.startsWith(`${path}/`),
)?.path ?? "";
/** Equal archive bytes at different logical paths are distinct ownership scopes. */
export const applicationContainerIdentity = (
fileSet: JavaScriptArtifactFileSet,
source: Pick<JavaScriptArtifactFile, "path" | "container_sha256">,
): string =>
compositeKey([
source.container_sha256,
applicationContainerRoot(fileSet, source),
]);
/** Select the inventory's display root; this does not establish runtime ownership. */
export const applicationManifestPath = (
context: JavaScriptArtifactGraphContext,
): string | undefined =>
context.analysis.packages
.map(({ path }) => path)
.filter((path) => {
const file = context.filesByPath.get(path);
return file !== undefined && !dependencyManifest(path);
})
.sort(
(left, right) =>
left.split("/").length - right.split("/").length ||
compareUnicodeCodePoints(left, right),
)[0];
/** Inspect inventoried ancestor packages; absent parsing is not absent ownership.
* Entry-bearing packages are candidates; multiple candidates stay unknown.
*/
export const applicationRootPath = (
context: JavaScriptArtifactGraphContext,
source: JavaScriptArtifactFile,
): string | null => {
// Unread launch metadata can name a main in another inventoried container.
// Archive occurrence identity does not prove isolation from the Node loader.
if (context.applicationOwnershipUnknownContainers.size > 0) return null;
const entryRoots = context.applicationRootsBySource.get(source.path);
const container = applicationContainerIdentity(context.fileSet, source);
for (const root of context.applicationPossibleRootsByContainer.get(
container,
) ?? [])
if (!entryRoots?.has(root)) return null;
if (
entryRoots === undefined &&
context.applicationModuleGapSources.has(container)
)
return null;
const roots = new Set<string>(entryRoots);
const directories = source.path.split("/").slice(0, -1);
for (let depth = directories.length; depth >= 0; depth -= 1) {
const root = directories.slice(0, depth).join("/");
const prefix = root === "" ? "" : `${root}/`;
const path = `${prefix}package.json`;
if (dependencyManifest(path)) continue;
const file =
context.filesByPath.get(path) ?? context.unanalyzedFiles?.get(path);
if (file?.container_sha256 !== source.container_sha256) continue;
const metadata = context.analysis.packages.find(
(value) => value.path === path,
);
if (metadata?.status !== "included") return null;
const defaultEntry = context.filesByPath.get(`${prefix}index.js`);
if (
entryRoots === undefined &&
(metadata.main_status === "declared" ||
defaultEntry?.container_sha256 === source.container_sha256)
)
addApplicationRootCandidate(roots, root);
}
if (roots.size > 1) return null;
for (const root of roots) return root;
return applicationContainerRoot(context.fileSet, source);
};
/** Evidence coverage shape shared by reconstruction graph helpers. */
export type JavaScriptArtifactGraphCoverage =
ApplicationGraphEvidence["coverage"];
@@ -302,13 +409,25 @@ export const artifactFileNodeKind = (
export const createElectronRoleNode = (
context: JavaScriptArtifactGraphContext,
input: RoleNodeInput,
): ApplicationNode =>
context.accumulator.addNode({
): ApplicationNode => {
const target =
input.resolution.resolved_path ??
(input.resolution.runtime_path_base_proven === true
? input.resolution.candidate_path
: undefined) ??
input.resolution.declared_path;
// Raw application-relative declarations keep their source occurrence even
// when identical source bytes appear under separate application prefixes.
const identityPath =
input.resolution.runtime_path_base === "application-root"
? compositeKey([input.anchor.path, target])
: target;
return context.accumulator.addNode({
kind: input.kind,
identity: artifactLocalIdentity(
input.anchor.sha256,
"electron-role",
`${input.kind}:${input.resolution.resolution_context}:${input.resolution.resolved_path ?? input.resolution.declared_path}`,
`${input.kind}:${input.resolution.resolution_context}:${identityPath}`,
),
observations: [
{
@@ -333,6 +452,7 @@ export const createElectronRoleNode = (
},
],
});
};
/** Resolve an Electron role to an inventoried asset when available. */
export const linkElectronRoleToAsset = (
@@ -6,6 +6,9 @@ import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascrip
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addStaticInferenceEdge,
applicationRootPath,
applicationContainerIdentity,
addApplicationRootCandidate,
artifactLocalIdentity,
chunkLookupKey,
createElectronRoleNode,
@@ -26,7 +29,10 @@ import {
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import {
moduleResolutionModeForJavaScriptReference,
isJsonModuleTarget,
} from "../../domain/javascript/javascriptModuleResolutionMode.js";
type StaticReference = JavaScriptStaticAnalysis["references"][number];
type StaticEndpoint = JavaScriptStaticAnalysis["endpoints"][number];
@@ -42,11 +48,11 @@ interface FindingInput<Value> {
}
interface ResolvedReference {
readonly targetImportsUnexamined?: true;
readonly path: string | null;
readonly file: JavaScriptArtifactFile | null;
readonly node: ApplicationNode | null;
readonly limitations: readonly string[];
readonly resolutionStatus?: string;
readonly resolution?: ArtifactPathResolution;
}
@@ -54,7 +60,10 @@ interface ResolvedReference {
export function* addJavaScriptStaticFindingsSteps(
context: JavaScriptArtifactGraphContext,
): Generator<void, readonly string[]> {
const omissions = { selfReferences: 0 };
const omissions = {
selfReferences: 0,
imports: new Map<string, Set<string>>(),
};
for (const analyzed of context.analysis.files) {
yield;
const { file, javascript } = analyzed;
@@ -67,11 +76,42 @@ export function* addJavaScriptStaticFindingsSteps(
addEndpoint(context, { file, asset, value, coverage });
for (const value of javascript.storage)
addStorage(context, { file, asset, value, coverage });
for (const value of javascript.role_paths)
addDiscoveredRole(context, { file, asset, value, coverage });
for (const value of javascript.source_map_urls)
addSourceMapEdge(context, { file, asset, value, coverage });
}
yield* propagateApplicationOwners(context, omissions.imports);
const possible = new Set<string>();
for (const roots of context.applicationPossibleRootsByContainer.values())
for (const root of roots) addApplicationRootCandidate(possible, root);
for (const gaps of context.applicationModuleGapSources.values()) {
for (const gap of gaps) {
yield;
for (const root of context.applicationRootsBySource.get(gap) ?? [])
addApplicationRootCandidate(possible, root);
}
}
// An unknown loader may cross an inventoried archive boundary in either
// direction. Retain possible owners in every container it could reach.
for (const file of context.fileSet.files) {
yield;
context.applicationPossibleRootsByContainer.set(
applicationContainerIdentity(context.fileSet, file),
possible,
);
}
const changedRoots = changedApplicationRoots(context);
for (const { file, javascript } of context.analysis.files) {
yield;
const asset = context.assetNodes.get(file.path);
if (javascript === null || asset === undefined) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.role_paths)
addDiscoveredRole(
context,
{ file, asset, value, coverage },
changedRoots,
);
}
return selfReferenceOmissions(
omissions.selfReferences,
["static reference", "static references"],
@@ -79,10 +119,78 @@ export function* addJavaScriptStaticFindingsSteps(
);
}
/** A setter changes process-wide runtime state, not package ownership. */
const changedApplicationRoots = (
context: JavaScriptArtifactGraphContext,
): ReadonlySet<string> | null => {
const roots = new Set<string>();
// An unchecked load can execute a setter even when the imported source has
// no retained AST. Package ownership alone does not prove its runtime path
// unchanged. Only launch owners that can reach the gap are affected; an
// unrelated unselected source is not evidence of a running setter.
for (const sources of context.applicationModuleGapSources.values()) {
for (const source of sources) {
const owners = context.applicationRootsBySource.get(source);
if (owners === undefined) continue;
if (owners.size >= 2) return null;
for (const root of owners) roots.add(root);
}
}
for (const { file, javascript } of context.analysis.files) {
if (!javascript?.electron.app_path_changes?.length) continue;
const owners = context.applicationRootsBySource.get(file.path);
// A setter whose launching application is unknown cannot establish an
// unchanged root for any package-based fallback.
if (owners === undefined) return null;
// Owner sets stop at two distinct roots once uniqueness is disproven.
// They cannot enumerate every application affected by a shared setter.
if (owners.size >= 2) return null;
for (const root of owners) roots.add(root);
const possibleOwners = context.applicationPossibleRootsByContainer.get(
applicationContainerIdentity(context.fileSet, file),
);
// An unchecked loader can execute this setter in another application's
// process even when only one entrypoint has a proven import of this source.
// Possible-owner sets have the same two-root saturation as proven owners.
if (possibleOwners !== undefined) {
if (possibleOwners.size >= 2) return null;
for (const root of possibleOwners) roots.add(root);
}
}
return roots;
};
/** Carry every entrypoint owner through already-resolved imports before roles.
* Sets and change-driven traversal retain competition and terminate on cycles.
*/
function* propagateApplicationOwners(
context: JavaScriptArtifactGraphContext,
imports: ReadonlyMap<string, ReadonlySet<string>>,
): Generator<void> {
const pending = [...context.applicationRootsBySource.keys()];
while (pending.length > 0) {
yield;
const source = pending.pop();
if (source === undefined) break;
const roots = context.applicationRootsBySource.get(source);
if (roots === undefined) continue;
for (const target of imports.get(source) ?? []) {
yield;
const candidates =
context.applicationRootsBySource.get(target) ?? new Set<string>();
const previousSize = candidates.size;
for (const root of roots) addApplicationRootCandidate(candidates, root);
if (candidates.size === previousSize) continue;
context.applicationRootsBySource.set(target, candidates);
pending.push(target);
}
}
}
const addReference = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticReference>,
omissions: { selfReferences: number },
omissions: { selfReferences: number; imports: Map<string, Set<string>> },
): void => {
const source =
sourceNodeFor(
@@ -95,6 +203,32 @@ const addReference = (
input.value.specifier === null
? null
: resolveReference(context, input.file.path, input.value, input.coverage);
if (
(input.value.kind === "static-import" ||
input.value.kind === "dynamic-import" ||
input.value.kind === "require") &&
(input.value.specifier === null ||
resolved === null ||
resolved.targetImportsUnexamined === true)
) {
const container = applicationContainerIdentity(context.fileSet, input.file);
const gaps =
context.applicationModuleGapSources.get(container) ?? new Set<string>();
gaps.add(input.file.path);
context.applicationModuleGapSources.set(container, gaps);
}
if (
resolved !== null &&
resolved.path !== null &&
resolved.file !== null &&
(input.value.kind === "static-import" ||
input.value.kind === "dynamic-import" ||
input.value.kind === "require")
) {
const targets = omissions.imports.get(input.file.path) ?? new Set<string>();
targets.add(resolved.path);
omissions.imports.set(input.file.path, targets);
}
if (input.value.kind === "worker" || input.value.kind === "service-worker") {
const worker = context.accumulator.addNode({
kind: input.value.kind === "worker" ? "worker" : "service-worker",
@@ -110,7 +244,7 @@ const addReference = (
declared_specifier: input.value.specifier,
expression: input.value.expression,
resolved_path: resolved?.path ?? null,
resolution_status: resolved?.resolutionStatus ?? null,
resolution_status: resolved?.resolution?.resolution_status ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
evidence: staticInferenceEvidence({
@@ -133,7 +267,7 @@ const addReference = (
relation: "loads",
properties: {
kind: input.value.kind,
resolution_status: resolved?.resolutionStatus ?? null,
resolution_status: resolved?.resolution?.resolution_status ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
limitations: resolved?.limitations ?? [],
@@ -182,7 +316,7 @@ const addReference = (
)
: null,
resolved_path: resolved?.path ?? null,
resolution_status: resolved?.resolutionStatus ?? null,
resolution_status: resolved?.resolution?.resolution_status ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
...(resolved === null ? {} : { limitations: resolved.limitations }),
@@ -344,7 +478,9 @@ const addStorage = (
const addDiscoveredRole = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticRolePath>,
changedRoots: ReadonlySet<string> | null,
): void => {
const root = applicationRootPath(context, input.file);
const resolution = resolveArtifactPathByContext({
declaredPath: input.value.path,
sourcePath: input.file.path,
@@ -352,14 +488,25 @@ const addDiscoveredRole = (
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
...(input.value.role === "preload" ||
(input.value.role === "renderer" &&
input.value.resolution_context === "filesystem-expression")
...(input.value.role === "preload"
? { runtimePathKind: "exact-file" as const }
: input.value.role === "renderer" &&
input.value.resolution_context === "module-specifier"
? { runtimePathKind: "url-resource" as const }
: {}),
input.value.resolution_context === "filesystem-expression"
? {
// Raw loadFile literals belong to the application, not its caller.
runtimePathKind: "application-file" as const,
applicationRootPath:
changedRoots === null || (root !== null && changedRoots.has(root))
? null
: root,
applicationRootIsProven: context.applicationRootsBySource.has(
input.file.path,
),
}
: input.value.role === "renderer" &&
input.value.resolution_context === "module-specifier"
? { runtimePathKind: "url-resource" as const }
: {}),
});
const role = createElectronRoleNode(context, {
kind:
@@ -533,6 +680,23 @@ const resolveReference = (
: {}),
});
const path = resolution.resolved_path;
const targetPath = path ?? resolution.candidate_path;
const targetKind =
targetPath === undefined
? undefined
: (
context.filesByPath.get(targetPath) ??
context.unanalyzedFiles?.get(targetPath)
)?.kind;
// Resolving bytes does not prove that the loader's module imports were read.
// Only an exact JSON loader target can be inert without JavaScript analysis.
const targetIsJson =
targetPath !== undefined && isJsonModuleTarget(targetPath, targetKind);
const targetImportsUnexamined =
!targetIsJson &&
(resolution.resolution_status === "unavailable" ||
(path !== null &&
!context.applicationModuleReferencesRetained.has(path)));
if (path === null) {
const source = context.filesByPath.get(sourcePath);
return source === undefined
@@ -550,7 +714,9 @@ const resolveReference = (
resolution,
),
limitations: resolution.limitations,
resolutionStatus: resolution.resolution_status,
...(targetImportsUnexamined
? { targetImportsUnexamined: true as const }
: {}),
resolution,
};
}
@@ -563,7 +729,9 @@ const resolveReference = (
file,
node,
limitations: resolution.limitations,
resolutionStatus: resolution.resolution_status,
...(targetImportsUnexamined
? { targetImportsUnexamined: true as const }
: {}),
resolution,
};
};
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
@@ -14,6 +15,8 @@ import {
addJavaScriptArtifactFiles,
createJavaScriptArtifactRootNode,
} from "./JavaScriptArtifactGraphStructure.js";
import { addJavaScriptStaticFindingsSteps } from "./JavaScriptArtifactGraphFindings.js";
import { applicationContainerIdentity } from "./JavaScriptArtifactGraphContext.js";
const SHA256 = "a".repeat(64);
const REASON =
@@ -98,6 +101,11 @@ const contextFor = (
chunkNodes: new Map(),
moduleNodes: new Map(),
containerNodes: new Map(),
applicationRootsBySource: new Map(),
applicationOwnershipUnknownContainers: new Set(),
applicationModuleGapSources: new Map(),
applicationPossibleRootsByContainer: new Map(),
applicationModuleReferencesRetained: new Set(),
};
};
@@ -146,3 +154,225 @@ describe("skipped JavaScript parse reporting", () => {
expect(fileObservation(context).parse_status).toBe(javascript.parse_status);
});
});
it.each([false, true])(
"retains possible setter owners independently of proven imports (possible owner: %s)",
(possibleOwner) => {
const window: JavaScriptArtifactFile = {
...skippedFile(),
path: "b/window.cjs",
sha256: "b".repeat(64),
};
const setter: JavaScriptArtifactFile = {
...skippedFile(),
path: "shared/change.cjs",
sha256: "c".repeat(64),
};
const page: JavaScriptArtifactFile = {
...skippedFile(),
path: "b/index.html",
sha256: "d".repeat(64),
kind: "html",
};
const analysis = analysisFor([
{
file: setter,
javascript: analyzeJavaScriptStaticSource(
'const { app } = require("electron"); app.setAppPath("/changed");',
),
semantic: null,
},
{
file: window,
javascript: analyzeJavaScriptStaticSource(
'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");',
),
semantic: null,
},
{ file: page, javascript: null, semantic: null },
]);
const base = contextFor(window, analysis);
const files = [setter, window, page];
const context: JavaScriptArtifactGraphContext = {
...base,
fileSet: { ...base.fileSet, files },
filesByPath: new Map(files.map((file) => [file.path, file])),
};
context.applicationRootsBySource.set(setter.path, new Set(["a"]));
context.applicationRootsBySource.set(window.path, new Set(["b"]));
if (possibleOwner)
context.applicationPossibleRootsByContainer.set(
applicationContainerIdentity(context.fileSet, setter),
new Set(["b"]),
);
addJavaScriptArtifactFiles(context);
const steps = addJavaScriptStaticFindingsSteps(context);
let step = steps.next();
while (!step.done) step = steps.next();
const role = context.accumulator
.nodes()
.find(({ kind }) => kind === "electron-renderer");
expect(role?.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
resolved_path: possibleOwner ? null : "b/index.html",
resolution_status: possibleOwner ? "unavailable" : "resolved",
}),
}),
]),
);
},
);
it.each([
"unselected",
"transitive-unselected",
"invalid-utf8",
"worker-failure",
"parse-failed",
"opaque",
"unknown-loader",
"read-inert",
"unreferenced",
"json",
"missing",
] as const)(
"does not prove an unchanged application path across a reachable %s load gap",
(gap) => {
const makeFile = (
path: string,
kind: JavaScriptArtifactFile["kind"] = "javascript",
): JavaScriptArtifactFile => ({
...skippedFile(),
path,
sha256: createHash("sha256").update(path).digest("hex"),
kind,
});
const main = makeFile("a/main.cjs");
const other = makeFile("b/main.cjs");
const target = makeFile(
gap === "json" ? "a/change.json" : "a/change.cjs",
gap === "json" ? "json" : "javascript",
);
const page = makeFile("a/index.html", "html");
const otherPage = makeFile("b/index.html", "html");
const window =
'const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");';
const statement =
gap === "unknown-loader"
? "require(process.env.CHANGE);"
: gap === "transitive-unselected"
? 'require("./middle.cjs");'
: gap === "unreferenced"
? ""
: `require("./${gap === "json" ? "change.json" : "change.cjs"}");`;
const entry: AnalyzedJavaScriptArtifactFile =
gap === "worker-failure"
? skippedEntry(target)
: {
file:
gap === "invalid-utf8"
? {
...target,
text: { included: false, reason: "invalid-utf8" },
}
: target,
javascript:
gap === "read-inert"
? analyzeJavaScriptStaticSource("module.exports=42;")
: gap === "parse-failed"
? {
...analyzeJavaScriptStaticSource(""),
parse_status: "failed",
parse_error_count: 1,
}
: null,
semantic: null,
};
const entries: AnalyzedJavaScriptArtifactFile[] = [
{
file: main,
javascript: analyzeJavaScriptStaticSource(statement + window),
semantic: null,
},
{
file: other,
javascript: analyzeJavaScriptStaticSource(window),
semantic: null,
},
...(gap === "transitive-unselected"
? [
{
file: makeFile("a/middle.cjs"),
javascript: analyzeJavaScriptStaticSource(
'require("./change.cjs");',
),
semantic: null,
},
]
: []),
...(gap === "missing" || gap === "opaque" ? [] : [entry]),
{ file: page, javascript: null, semantic: null },
{ file: otherPage, javascript: null, semantic: null },
];
const analysis = analysisFor(entries);
const base = contextFor(main, analysis);
const files = entries.map(({ file }) => file);
const context: JavaScriptArtifactGraphContext = {
...base,
fileSet: { ...base.fileSet, files },
filesByPath: new Map(files.map((file) => [file.path, file])),
unanalyzedFiles:
gap === "opaque"
? {
get: (path) => (path === target.path ? target : undefined),
hasDirectory: () => false,
}
: undefined,
applicationModuleReferencesRetained: new Set([
main.path,
other.path,
...(gap === "transitive-unselected" ? ["a/middle.cjs"] : []),
...(gap === "read-inert" ? [target.path] : []),
]),
};
context.applicationRootsBySource.set(main.path, new Set(["a"]));
context.applicationRootsBySource.set(other.path, new Set(["b"]));
addJavaScriptArtifactFiles(context);
const steps = addJavaScriptStaticFindingsSteps(context);
while (!steps.next().done) {}
const roles = context.accumulator
.nodes()
.filter(({ kind }) => kind === "electron-renderer");
expect(roles).toHaveLength(2);
const changed = !["read-inert", "unreferenced", "json", "missing"].includes(
gap,
);
for (const [path, expected] of [
[main.path, changed ? null : "a/index.html"],
// Existing loader ownership also allows an unchecked source to import
// the other app's module; no process isolation has been established.
[other.path, changed ? null : "b/index.html"],
] as const) {
const role = roles.find(({ observations }) =>
observations.some(
({ evidence }) =>
evidence.location.available &&
evidence.location.value.kind === "source-range" &&
evidence.location.value.source === path,
),
);
expect(role?.observations).toEqual(
expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
resolved_path: expected,
resolution_status: expected === null ? "unavailable" : "resolved",
}),
}),
]),
);
}
},
);
@@ -1,4 +1,5 @@
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import { isJsonModuleTarget } from "../../domain/javascript/javascriptModuleResolutionMode.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import { htmlInlineScriptLimitation } from "./JavaScriptArtifactAnalysis.js";
import {
@@ -18,6 +19,10 @@ import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGra
import { isSkippedJavaScriptAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import {
addArtifactContainsEdge,
applicationManifestPath,
applicationContainerIdentity,
addApplicationRootCandidate,
dependencyManifest,
addUnavailableStaticParseScope,
artifactFileNodeKind,
artifactLocalIdentity,
@@ -36,8 +41,8 @@ import {
import {
artifactPathResolutionProperties,
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
interface PackageRoleInput {
readonly packageNode: ApplicationNode;
@@ -480,6 +485,7 @@ export const addJavaScriptPackageNodes = (
name: packageValue.name,
version: packageValue.version,
main: packageValue.main,
main_status: packageValue.main_status,
renderer: packageValue.renderer,
parse_status: packageValue.status,
},
@@ -516,6 +522,24 @@ export const addJavaScriptPackageNodes = (
operation: "inventory-package",
});
if (dependencyManifest(file.path)) continue;
if (
packageValue.status === "included" &&
packageValue.main_entrypoint === ""
) {
registerPackageResolutionOwner(
context,
file,
resolveArtifactPathByContext({
declaredPath: packageValue.main_entrypoint,
sourcePath: file.path,
context: "package-entrypoint",
runtimePathKind: "node-entrypoint",
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
}),
);
}
addPackageRole(context, {
packageNode: node,
packageFile: file,
@@ -532,24 +556,6 @@ export const addJavaScriptPackageNodes = (
return roots;
};
/** Dependency-only artifacts retain their inventory root. */
const applicationManifestPath = (
context: JavaScriptArtifactGraphContext,
): string | undefined =>
context.analysis.packages
.map(({ path }) => path)
.filter(
(path) => context.filesByPath.has(path) && !dependencyManifest(path),
)
.sort(
(left, right) =>
left.split("/").length - right.split("/").length ||
compareUnicodeCodePoints(left, right),
)[0];
const dependencyManifest = (path: string): boolean =>
path.split("/").slice(0, -1).includes("node_modules");
const createFileTarget = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
@@ -645,10 +651,17 @@ const addPackageRole = (
declaredPath: input.declaredPath,
sourcePath: input.packageFile.path,
context: "package-entrypoint",
// Launch main uses Node's filesystem lookup, independently of renderer
// metadata. Literal percent bytes are filename bytes at this boundary.
...(input.kind === "electron-main"
? { runtimePathKind: "node-entrypoint" as const }
: {}),
files: context.filesByPath,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
});
if (input.kind === "electron-main")
registerPackageResolutionOwner(context, input.packageFile, resolution);
const role = createElectronRoleNode(context, {
kind: input.kind,
anchor: input.packageFile,
@@ -685,6 +698,61 @@ const addPackageRole = (
});
};
const registerPackageResolutionOwner = (
context: JavaScriptArtifactGraphContext,
packageFile: JavaScriptArtifactFile,
resolution: ArtifactPathResolution,
): void => {
if (resolution.resolution_status === "resolved")
registerPackageEntrypointOwner(
context,
packageFile,
resolution.resolved_path,
);
else if (
resolution.resolution_status === "unavailable" &&
!(
resolution.candidate_path !== undefined &&
isJsonModuleTarget(
resolution.candidate_path,
context.unanalyzedFiles?.get(resolution.candidate_path)?.kind,
)
)
)
registerPossiblePackageOwner(context, packageFile);
};
const registerPackageEntrypointOwner = (
context: JavaScriptArtifactGraphContext,
packageFile: JavaScriptArtifactFile,
sourcePath: string,
): void => {
const roots =
context.applicationRootsBySource.get(sourcePath) ?? new Set<string>();
addApplicationRootCandidate(roots, packageRoot(packageFile));
context.applicationRootsBySource.set(sourcePath, roots);
if (
!context.applicationModuleReferencesRetained.has(sourcePath) &&
!isJsonModuleTarget(sourcePath, context.filesByPath.get(sourcePath)?.kind)
)
registerPossiblePackageOwner(context, packageFile);
};
const packageRoot = (file: JavaScriptArtifactFile): string =>
file.path.slice(0, -"package.json".length).replace(/\/$/u, "");
const registerPossiblePackageOwner = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
): void => {
const container = applicationContainerIdentity(context.fileSet, file);
const roots =
context.applicationPossibleRootsByContainer.get(container) ??
new Set<string>();
addApplicationRootCandidate(roots, packageRoot(file));
context.applicationPossibleRootsByContainer.set(container, roots);
};
const containerProperties = (container: JavaScriptArtifactContainer) => ({
path: container.path,
format: "asar",
@@ -0,0 +1,67 @@
import { expect, it } from "vitest";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
const files = new Map(
["app/main.mjs", "shared/window.cjs", "rea-artifact/shared/window.cjs"].map(
(path) => [
path,
{
path,
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: 0,
inventory_artifact_id: path,
kind: "javascript",
unpacked: false,
text: { included: true, value: "" },
} satisfies JavaScriptArtifactFile,
],
),
);
it.each([
"../../rea-artifact/shared/window.cjs",
"../%2e%2e/rea-artifact/shared/window.cjs",
"%2e%2e/%2e%2e/rea-artifact/shared/window.cjs",
"../../rea-artifact/shared/../shared/window.cjs",
])(
"retains above-root traversal before synthetic URL normalization: %s",
(declaredPath) => {
const actual = new URL(declaredPath, "file:///selected-root/app/main.mjs");
expect(actual.pathname.startsWith("/selected-root/")).toBe(false);
expect(
resolveArtifactPathByContext({
declaredPath,
sourcePath: "app/main.mjs",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
},
);
it.each([
["../shared/window.cjs", "shared/window.cjs"],
["./discard%2Fsegment/../../shared/window.cjs", "shared/window.cjs"],
["./discard%/../../shared/window.cjs", "shared/window.cjs"],
["./%252e%252e/../../shared/window.cjs", "shared/window.cjs"],
["../rea-artifact/shared/window.cjs", "rea-artifact/shared/window.cjs"],
])(
"retains an inside-root URL without banning synthetic-looking names: %s",
(declaredPath, target) => {
const actual = new URL(declaredPath, "file:///selected-root/app/main.mjs");
expect(actual.pathname).toBe(`/selected-root/${target}`);
expect(
resolveArtifactPathByContext({
declaredPath,
sourcePath: "app/main.mjs",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({ resolution_status: "resolved", resolved_path: target });
},
);
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { isBuiltin } from "node:module";
import { posix } from "node:path";
import { posix, win32 } from "node:path";
import { ELECTRON_MODULE } from "../../domain/javascript/javascriptElectronMemberWrites.js";
import {
normalizeJoinedLogicalPath,
@@ -27,6 +28,7 @@ import {
type TypeScriptEmittedSourceCandidate,
} from "../TypeScriptEmittedSourceCandidates.js";
import { isRelativeJavaScriptModuleSpecifier } from "../../domain/javascript/javascriptModuleSpecifier.js";
import { selectPackageSubpathPattern } from "../../domain/javascript/javascriptPackageSubpathPatterns.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";
@@ -46,6 +48,8 @@ type UnresolvedArtifactPathStatus =
interface ArtifactPathResolutionBase {
readonly declared_path: string;
readonly runtime_path_base?: "application-root";
readonly runtime_path_base_proven?: true;
readonly resolution_context: ArtifactPathResolutionContext;
readonly limitations: readonly string[];
readonly conditional_resolutions?: readonly ConditionalModuleResolution[];
@@ -87,7 +91,14 @@ export interface ResolveArtifactPathInput {
/** Import/require semantics; runtime resource paths omit this provenance. */
readonly moduleKind?: JavaScriptModuleResolutionMode;
/** Runtime paths retain their loader owner independently of source imports. */
readonly runtimePathKind?: "exact-file" | "node-entrypoint" | "url-resource";
readonly runtimePathKind?:
| "exact-file"
| "application-file"
| "node-entrypoint"
| "url-resource";
/** Logical root of an inventoried nested application container. */
readonly applicationRootPath?: string | null;
readonly applicationRootIsProven?: boolean;
}
/** Preserve an explicitly retained loader mode on graph relationships. */
@@ -193,6 +204,14 @@ export const resolveArtifactPathByContext = (
const resolution = resolvePath(input);
const facts = {
declared_path: input.declaredPath,
...(input.runtimePathKind === "application-file"
? { runtime_path_base: "application-root" as const }
: {}),
...(input.runtimePathKind === "application-file" &&
input.applicationRootIsProven === true &&
input.applicationRootPath !== null
? { runtime_path_base_proven: true as const }
: {}),
resolution_context: input.context,
limitations: resolution.limitations,
...(resolution.candidatePath === undefined
@@ -215,6 +234,12 @@ export const resolveArtifactPathByContext = (
export const artifactPathResolutionProperties = (
resolution: ArtifactPathResolution | undefined,
): Readonly<Record<string, JsonValue>> => ({
...(resolution?.runtime_path_base_proven === undefined
? {}
: { runtime_path_base_proven: resolution.runtime_path_base_proven }),
...(resolution?.runtime_path_base === undefined
? {}
: { runtime_path_base: resolution.runtime_path_base }),
...(resolution?.candidate_path === undefined
? {}
: { candidate_path: resolution.candidate_path }),
@@ -248,7 +273,50 @@ const conditionalResolution = (
: { candidate_path: result.candidatePath }),
});
/** Host path admission is shared by top-level and metadata-derived inputs. */
const resolveRuntimeHostPath = (
input: ResolveArtifactPathInput,
): CandidateResolution | null => {
if (
(input.runtimePathKind !== undefined || isNodeFilesystemLookup(input)) &&
input.runtimePathKind !== "url-resource" &&
(posix.isAbsolute(input.declaredPath) ||
win32.isAbsolute(input.declaredPath) ||
(input.context === "module-specifier" &&
input.moduleKind !== "require" &&
/^file:/iu.test(input.declaredPath)))
) {
const esm =
input.context === "module-specifier" && input.moduleKind === "import";
if (esm) {
const normalized = URL.parse(
htmlUrlText(input.declaredPath),
PACKAGE_ARTIFACT_ROOT_URL,
);
if (normalized === null)
return unresolvedCandidate("rejected", [
"The module URL cannot be parsed.",
]);
const admitted = decodeModuleUrlPath(normalized.pathname);
if (typeof admitted !== "string") return admitted;
}
// Host syntax is not a canonical logical path. In particular, Windows
// separators and drive letters must not be reclassified as invalid/URLs.
return input.declaredPath.includes("\0") ||
(!esm && fileUrlPath(input.declaredPath) === null)
? unresolvedCandidate("rejected", [
"NUL or unsafe file URL path syntax is not admitted in a runtime path.",
])
: unresolvedCandidate("unavailable", [
"An absolute runtime host path has no established mapping to the selected logical artifact root.",
]);
}
return null;
};
const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
const hostPath = resolveRuntimeHostPath(input);
if (hostPath !== null) return hostPath;
if (input.runtimePathKind === "node-entrypoint")
return resolveNodeRuntimeEntrypoint(input);
if (input.moduleKind === "compiler-dependent") {
@@ -286,7 +354,11 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
}
if (input.runtimePathKind === "url-resource")
return resolveExactUrlResource(input);
if (input.runtimePathKind === "exact-file" && isRuntimePathDeclaration(input))
if (
(input.runtimePathKind === "exact-file" ||
input.runtimePathKind === "application-file") &&
isRuntimePathDeclaration(input)
)
return resolveRuntimeEntrypoint(input);
// Package import keys are literal lookup identities, not artifact paths.
// The selected target is admitted separately by resolveExportTarget.
@@ -365,18 +437,37 @@ const isRuntimePathDeclaration = (input: ResolveArtifactPathInput): boolean =>
const resolveNodeRuntimeEntrypoint = (
input: ResolveArtifactPathInput,
): CandidateResolution => {
const { runtimePathKind: _runtimePathKind, ...sourceInput } = input;
const runtimeInput = { ...sourceInput, moduleKind: "require" as const };
if (!isRuntimePathDeclaration(runtimeInput)) return resolvePath(runtimeInput);
const windowsPath = unknownWindowsRuntimePath(input.declaredPath);
if (windowsPath !== null) return windowsPath;
const runtimeInput = { ...input, moduleKind: "require" as const };
if (input.context === "package-entrypoint" && input.declaredPath === "")
return resolveNodePackageMain(
runtimeInput,
posix.dirname(input.sourcePath),
undefined,
);
if (
runtimeInput.context !== "package-entrypoint" &&
!isRuntimePathDeclaration(runtimeInput)
) {
const { runtimePathKind: _runtimePathKind, ...moduleInput } = runtimeInput;
return resolvePath(moduleInput);
}
const candidate = contextualCandidate(runtimeInput);
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(candidate);
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.
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.
const resolution =
input.context === "package-entrypoint"
? resolveNodePackageMain(
runtimeInput,
posix.dirname(input.sourcePath),
confined,
)
: resolveCandidate(runtimeInput, confined, new Set());
// Retain the normalized declaration when Node's owned probes are exhausted.
return resolution.status === "not-found" &&
resolution.candidatePath === undefined
? { ...resolution, candidatePath: confined }
@@ -386,14 +477,30 @@ const resolveNodeRuntimeEntrypoint = (
const resolveRuntimeEntrypoint = (
input: ResolveArtifactPathInput,
): CandidateResolution => {
const rejected = rejectDeclaration(input.declaredPath);
if (input.runtimePathKind === "application-file") {
const windowsPath = unknownWindowsRuntimePath(input.declaredPath);
if (windowsPath !== null) return windowsPath;
}
const rejected = rejectDeclaration(
input.declaredPath,
declaresLiteralPercent(input),
);
if (rejected !== null) return rejected;
if (hasScheme(input.declaredPath) || looksExternal(input.declaredPath))
return unresolvedCandidate("external", [
"Electron runtime entrypoints must identify local paths, not external URLs.",
]);
if (
input.runtimePathKind === "application-file" &&
input.applicationRootPath === null
)
return unresolvedCandidate("unavailable", [
"Inventoried package metadata does not establish one application root for this source; the loadFile base is unknown.",
]);
const candidate = confineCandidate(
relativeCandidate(input.sourcePath, input.declaredPath),
input.runtimePathKind === "application-file"
? posix.join(input.applicationRootPath ?? "", input.declaredPath)
: relativeCandidate(input.sourcePath, input.declaredPath),
);
if (typeof candidate !== "string") return candidate;
return (
@@ -408,6 +515,17 @@ const resolveRuntimeEntrypoint = (
);
};
/** Host filesystem spellings need target-platform context before admission. */
const unknownWindowsRuntimePath = (
declared: string,
): CandidateResolution | null =>
!declared.includes("\0") &&
(declared.includes("\\") || /^[a-z]:/iu.test(declared))
? unresolvedCandidate("unavailable", [
"Windows-relative runtime paths require target-platform and drive context that the selected logical artifact inventory does not establish.",
])
: null;
const resolveRuntimeSourceCandidates = (
input: ResolveArtifactPathInput,
exactPath: string,
@@ -453,14 +571,19 @@ const rejectDeclaration = (
};
/**
* Node's CommonJS loader never percent-decodes a request or a legacy package
* main, so `%2e` and `%2f` there are literal filename bytes, not encoded dots
* or separators. Root confinement still applies to the joined candidate.
* Filesystem expressions, exact runtime paths, and CommonJS requests retain
* literal filename bytes. URL-to-path conversion belongs to the producer;
* these consumers must not decode or reject the resulting percent spelling.
* Root confinement still applies to the joined candidate.
*/
const declaresLiteralPercent = (input: ResolveArtifactPathInput): boolean =>
input.moduleKind === "require" &&
(input.context === "module-specifier" ||
input.context === "package-entrypoint");
input.context === "filesystem-expression" ||
(input.runtimePathKind === "exact-file" &&
isRuntimePathDeclaration(input) &&
!looksExternal(input.declaredPath)) ||
(input.moduleKind === "require" &&
(input.context === "module-specifier" ||
input.context === "package-entrypoint"));
const contextualCandidate = (
input: ResolveArtifactPathInput,
@@ -497,6 +620,15 @@ const contextualCandidate = (
"File URL authorities or invalid file URL forms cannot be mapped to artifact-local paths.",
]);
}
if (
context === "module-specifier" &&
input.moduleKind === "require" &&
declared.includes("\\") &&
(!hasScheme(declared) || /^[a-z]:/iu.test(declared))
) {
const windowsPath = unknownWindowsRuntimePath(declared);
if (windowsPath !== null) return windowsPath;
}
const hasUrlPathSemantics =
context === "module-specifier" && input.moduleKind === "import";
if (hasUrlPathSemantics) declared = htmlUrlText(declared);
@@ -516,15 +648,59 @@ 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.",
]);
if (hasUrlPathSemantics) {
// URL normalization can leave and then re-enter the synthetic namespace.
// Preserve the shared URL owner's above-root markers before that loss.
if (
applyUrlReference(
urlDirectoryState(posix.dirname(input.sourcePath)),
stripQueryAndFragment(declared),
true,
).escapes > 0
)
return unresolvedCandidate("rejected", [
"The module URL escapes the selected logical artifact root.",
]);
const sourceUrl = packageMetadataUrl(input.sourcePath, declared);
const base = sourceUrl.ok
? sourceUrl.value
: sourceUrl.error.structuralUrl;
const normalized = URL.parse(declared, base);
if (normalized === null)
return unresolvedCandidate("rejected", [
"The module URL cannot be parsed.",
]);
if (!normalized.pathname.startsWith(PACKAGE_ARTIFACT_ROOT_URL.pathname))
return unresolvedCandidate("rejected", [
"The module URL escapes the selected logical artifact root.",
]);
// Node validates the complete normalized file URL, including encoded
// separator bytes inherited from an inventoried source directory.
const decoded = decodeModuleUrlPath(
normalized.pathname.slice(PACKAGE_ARTIFACT_ROOT_URL.pathname.length),
);
if (typeof decoded !== "string") return decoded;
if (
!sourceUrl.ok &&
decoded
.split("/")
.some((segment) => sourceUrl.error.structuralSegments.has(segment))
)
return unresolvedCandidate("unavailable", [
sourceUrl.error.limitation,
]);
const candidate = urlRelativeCandidate("", `/${decoded}`);
return typeof candidate === "string"
? exactUrlPathCandidate(decoded, candidate, "module URL")
: candidate;
}
const decoded = decodeModuleUrlPath(stripQueryAndFragment(declared));
if (typeof decoded !== "string") return decoded;
declared = decoded;
}
} else if (looksExternal(declared))
} else if (context !== "package-entrypoint" && looksExternal(declared))
// Package main is a filesystem declaration. Scheme-like filename bytes
// remain literal; URI classification belongs to module and URL owners.
return unresolvedCandidate("external", [
"URL schemes and protocol-relative URLs are outside this local artifact path context.",
]);
@@ -574,6 +750,10 @@ const contextualCandidate = (
const decodeModuleUrlPath = (
declared: string,
): string | CandidateResolution => {
if (/%(?:2f|5c)/iu.test(declared))
return unresolvedCandidate("rejected", [
"ES module URL paths cannot contain percent-encoded slash or backslash bytes.",
]);
let decoded: string;
try {
decoded = decodeURIComponent(declared);
@@ -618,17 +798,19 @@ const urlDirectoryState = (directory: string): UrlPathState => ({
const applyUrlReference = (
base: UrlPathState,
reference: string,
encodedDotSegments = false,
): UrlPathState => {
const absolute = reference.startsWith("/");
const segments = absolute ? [] : [...base.segments];
let escapes = absolute ? 0 : base.escapes;
const parts = (absolute ? reference.slice(1) : reference).split("/");
for (const [index, part] of parts.entries()) {
if (part !== "." && part !== "..") {
const dot = encodedDotSegments ? part.replace(/%2e/giu, ".") : part;
if (dot !== "." && dot !== "..") {
segments.push(part);
continue;
}
if (part === "..")
if (dot === "..")
if (segments.length > 0) segments.pop();
else escapes += 1;
if (index === parts.length - 1) segments.push("");
@@ -704,7 +886,6 @@ const bareModuleCandidate = (
if (typeof confined !== "string") return confined;
dependencyDirectory = posix.dirname(confined);
}
const source = input.files.get(input.sourcePath);
let nearestNotFound: CandidateResolution | null = null;
for (const directory of packageLookupDirectories(input)) {
const candidate = posix.join(directory, dependencyDirectory);
@@ -716,14 +897,7 @@ const bareModuleCandidate = (
(!request.dependencyExportsEligible ||
metadata.exports === undefined)));
const packageResolution =
metadata.kind !== "absent" ||
hasContainerDirectory(
input.files,
candidate,
source?.container_sha256,
input.directoryPaths,
input.unanalyzedFiles,
)
metadata.kind !== "absent" || hasContainerDirectory(input, candidate)
? resolvePackageDependency(input, candidate, request, metadata)
: request.packageSubpath === "." && input.moduleKind !== "import"
? resolveFileCandidates(
@@ -795,10 +969,6 @@ const resolvePackageImportAlias = (
return unresolvedCandidate("rejected", [
`The package import specifier ${JSON.stringify(specifier)} is invalid; "#" and specifiers that end with "/" are not valid import aliases.`,
]);
if (classified.kind === "pattern")
return unresolvedCandidate("external", [
`Wildcard and directory package import aliases remain unresolved; ${JSON.stringify(specifier)} is not an exact imports key.`,
]);
const metadata = enclosingPackageMetadata(input);
if (metadata.kind === "none")
return unresolvedCandidate("external", [
@@ -816,7 +986,7 @@ const resolvePackageImportAlias = (
`Package metadata ${metadata.path} does not define package import alias ${specifier}; it declares ${lookup.declared.join(", ") || "no imports keys"}.`,
]);
if (lookup.kind === "pattern-unresolved")
return unresolvedCandidate("external", [
return unresolvedCandidate("unavailable", [
`Wildcard package import aliases remain unresolved; package metadata ${metadata.path} has no exact imports key ${specifier}.`,
]);
const selected = selectExportTarget(
@@ -839,11 +1009,18 @@ const resolvePackageImportAlias = (
]);
if (selected.kind === "non-relative") {
const target = classifyPackageImportTargetString(selected.value);
return unresolvedCandidate("external", [
target.kind === "recursive"
? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`
: `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`,
]);
// Unsupported lookup may still reach a selected module and another launch
// owner. Only a known builtin establishes that no local imports follow.
return unresolvedCandidate(
isBuiltin(selected.value) || ELECTRON_MODULE.test(selected.value)
? "external"
: "unavailable",
[
target.kind === "recursive"
? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`
: `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`,
],
);
}
return resolveExportTarget(input, metadata.path, selected, "imports");
};
@@ -958,16 +1135,7 @@ const resolveLegacyEsmSubpath = (
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,
input.directoryPaths,
input.unanalyzedFiles,
)
)
if (hasContainerDirectory(input, candidate))
return unresolvedCandidate("rejected", [
`ESM package subpath ${candidate} names a directory; directory imports are unsupported and package main or index lookup was not attempted.`,
]);
@@ -998,6 +1166,10 @@ const resolvePackageExports = (
return unresolvedCandidate("external", [
`Directory package metadata ${packagePath} blocks subpath ${subpath} with ${exported.cause === "null" ? "an explicit null exports target" : "an empty exports target array"}.`,
]);
if (exported.status === "pattern-unresolved")
return unresolvedCandidate("unavailable", [
`Package metadata ${packagePath} selects wildcard exports key ${JSON.stringify(exported.pattern)} for subpath ${subpath}; wildcard target expansion remains unresolved.`,
]);
return resolveExportTarget(input, packagePath, exported);
};
@@ -1080,37 +1252,35 @@ const parseBarePackageRequest = (
};
const hasContainerDirectory = (
files: ReadonlyMap<string, JavaScriptArtifactFile>,
input: ResolveArtifactPathInput,
candidate: string,
containerSha256: string | undefined,
directoryPaths: ReadonlySet<string> | undefined,
unanalyzedFiles?: JavaScriptUnanalyzedArtifactLookup,
): boolean => {
if (directoryPaths?.has(candidate) === true) return true;
if (input.directoryPaths?.has(candidate) === true) return true;
if (
[...indexCandidates(candidate), posix.join(candidate, "package.json")].some(
(path) => {
const file = files.get(path);
const file = input.files.get(path);
return (
file !== undefined &&
(containerSha256 === undefined ||
file.container_sha256 === containerSha256)
admitsRuntimeContainer(input, path, file.container_sha256)
);
},
)
)
return true;
// Inventory has files rather than directory entries. Any same-container
// descendant establishes a package directory even without package.json.
// Inventory has files rather than directory entries. An admitted descendant
// establishes a package directory even without package.json.
const prefix = `${candidate}/`;
for (const [path, file] of files)
for (const [path, file] of input.files)
if (
path.startsWith(prefix) &&
(containerSha256 === undefined ||
file.container_sha256 === containerSha256)
admitsRuntimeContainer(input, path, file.container_sha256)
)
return true;
return unanalyzedFiles?.hasDirectory(candidate, containerSha256) ?? false;
const container = crossesRuntimeArchiveBoundary(input, `${candidate}/`)
? undefined
: input.files.get(input.sourcePath)?.container_sha256;
return input.unanalyzedFiles?.hasDirectory(candidate, container) ?? false;
};
const htmlCandidate = (
@@ -1228,12 +1398,12 @@ const readPackageMetadata = (
): PackageMetadata => {
const path = posix.join(directory, "package.json");
const file = input.files.get(path);
const source = input.files.get(input.sourcePath);
if (file === undefined) {
const unavailable = input.unanalyzedFiles?.get(path);
if (
source !== undefined &&
unavailable?.container_sha256 === source.container_sha256
unavailable !== undefined &&
(admitsRuntimeContainer(input, path, unavailable.container_sha256) ||
input.moduleKind !== undefined)
)
return {
kind: "unavailable",
@@ -1243,8 +1413,13 @@ const readPackageMetadata = (
};
return { kind: "absent" };
}
if (source !== undefined && file.container_sha256 !== source.container_sha256)
return { kind: "absent" };
if (!admitsRuntimeContainer(input, path, file.container_sha256))
return input.moduleKind === undefined
? { kind: "absent" }
: {
kind: "unavailable",
limitation: `Inventoried package metadata ${path} crosses an artifact container boundary without an established runtime loader mapping.`,
};
if (!file.text.included)
return {
kind: "unavailable",
@@ -1260,7 +1435,7 @@ const readPackageMetadata = (
limitation: `Directory package metadata ${path} is not valid package JSON.`,
};
}
if (typeof value !== "object" || value === null)
if (typeof value !== "object" || value === null || Array.isArray(value))
return {
kind: "unavailable",
limitation: `Directory package metadata ${path} is not valid package JSON.`,
@@ -1351,12 +1526,12 @@ const resolveDirectory = (
? metadata.module
: undefined;
if (entry === undefined)
return (
resolveFileCandidates(
input,
indexCandidates(directory, input.moduleKind),
) ?? notFoundCandidate()
);
return input.moduleKind !== undefined
? resolveNodePackageMain(input, directory, undefined)
: (resolveFileCandidates(
input,
indexCandidates(directory, input.moduleKind),
) ?? notFoundCandidate());
if (typeof entry !== "string" || entry.length === 0)
return unresolvedCandidate("unavailable", [
`Directory package metadata ${metadata.path} is not valid package JSON.`,
@@ -1374,6 +1549,12 @@ const resolveDirectory = (
: { unanalyzedFiles: input.unanalyzedFiles }),
...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }),
};
const hostPath = resolveRuntimeHostPath(entryInput);
if (hostPath !== null) return hostPath;
if (entryInput.moduleKind !== undefined) {
const windowsPath = unknownWindowsRuntimePath(entry);
if (windowsPath !== null) return windowsPath;
}
const candidate = contextualCandidate(entryInput);
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(candidate);
@@ -1397,55 +1578,47 @@ const resolveDirectory = (
const resolveNodePackageMain = (
input: ResolveArtifactPathInput,
directory: string,
mainPath: string,
mainPath: string | undefined,
): 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 normalizedMain = mainPath?.replace(/\/$/u, "");
if (normalizedMain !== undefined)
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;
if (hasContainerDirectory(input, finalized))
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"
return input.moduleKind === "require" && mainPath !== undefined
? { ...notFoundCandidate(), stopsAncestorLookup: true }
: notFoundCandidate();
};
@@ -1472,24 +1645,83 @@ const resolveFileCandidates = (
return null;
};
/** Classify the extension as inventory does; retain exact archive occurrence bytes. */
const runtimeArchiveRoot = (path: string): string | undefined => {
const components = path.split("/");
const archive = components.findLastIndex(
(component, index) =>
index < components.length - 1 &&
component.toLowerCase().endsWith(".asar"),
);
return archive < 0
? undefined
: `${components.slice(0, archive + 1).join("/")}/`;
};
/** One archive-path admission rule serves files, directories and their metadata. */
const isNodeFilesystemLookup = (input: ResolveArtifactPathInput): boolean =>
input.runtimePathKind === "node-entrypoint" ||
(input.moduleKind !== undefined &&
(input.context === "module-specifier" ||
input.context === "package-entrypoint"));
/** Runtime launch and module lookup share archive occurrence admission. */
const crossesRuntimeArchiveBoundary = (
input: ResolveArtifactPathInput,
path: string,
): boolean => {
// Electron's archive filesystem normalizes a relative module path before
// opening it, including entry into an inventoried archive occurrence and
// escape from one, after the existing artifact-root confinement check.
const sourceArchive = runtimeArchiveRoot(input.sourcePath);
const targetArchive = runtimeArchiveRoot(path);
return (
isNodeFilesystemLookup(input) &&
((sourceArchive !== undefined && !path.startsWith(sourceArchive)) ||
(targetArchive !== undefined &&
!input.sourcePath.startsWith(targetArchive)))
);
};
const admitsRuntimeContainer = (
input: ResolveArtifactPathInput,
path: string,
containerSha256: string,
): boolean => {
const source = input.files.get(input.sourcePath);
return (
crossesRuntimeArchiveBoundary(input, path) ||
(input.runtimePathKind === "application-file" &&
input.applicationRootIsProven === true) ||
source === undefined ||
containerSha256 === source.container_sha256
);
};
/** 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)
admitsRuntimeContainer(input, path, target.container_sha256)
)
return { resolvedPath: path, status: "resolved", limitations: [] };
if (target !== undefined && isNodeFilesystemLookup(input))
return {
resolvedPath: null,
status: "unavailable",
candidatePath: path,
limitations: [
"The inventoried module crosses an artifact container boundary; its runtime loader mapping has not been established.",
],
};
const unanalyzed = input.unanalyzedFiles?.get(path);
if (
unanalyzed !== undefined &&
(source === undefined ||
unanalyzed.container_sha256 === source.container_sha256)
admitsRuntimeContainer(input, path, unanalyzed.container_sha256)
)
return {
resolvedPath: null,
@@ -1542,9 +1774,10 @@ type PackageExportOutcome =
| ({ readonly status: "value" } & PackageFileTarget)
| { readonly status: "rejected"; readonly limitation: string }
| { readonly status: "blocked"; readonly cause: PackageTargetBlockCause }
| { readonly status: "pattern-unresolved"; readonly pattern: string }
| { readonly status: "unmatched"; readonly declared: readonly string[] };
/** Only exact non-directory keys participate; wildcard expansion is unsupported. */
/** Exact keys precede the best matching pattern; pattern expansion remains unknown. */
const selectPackageExport = (
value: unknown,
subpath: string,
@@ -1552,6 +1785,7 @@ const selectPackageExport = (
packagePath: string,
): PackageExportOutcome => {
let target = value;
let pattern: string | undefined;
let declared: string[] = [];
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const keys = Object.keys(value);
@@ -1564,13 +1798,14 @@ const selectPackageExport = (
};
declared = keys;
if (hasSubpaths) {
if (
subpath.endsWith("/") ||
subpath.includes("*") ||
!Object.hasOwn(value, subpath)
)
return { status: "unmatched", declared };
target = Reflect.get(value, subpath);
if (subpath.endsWith("/")) return { status: "unmatched", declared };
if (!subpath.includes("*") && Object.hasOwn(value, subpath))
target = Reflect.get(value, subpath);
else {
pattern = selectPackageSubpathPattern(keys, subpath);
if (pattern === undefined) return { status: "unmatched", declared };
target = Reflect.get(value, pattern);
}
} else if (subpath !== ".") return { status: "unmatched", declared };
} else if (subpath !== ".") return { status: "unmatched", declared };
const selected = selectExportTarget(
@@ -1592,6 +1827,7 @@ const selectPackageExport = (
status: "rejected",
limitation: `The package exports target ${JSON.stringify(selected.value)} must start with "./".`,
};
if (pattern !== undefined) return { status: "pattern-unresolved", pattern };
return {
status: "value",
value: selected.value,
@@ -2,6 +2,7 @@ import { primitiveByteExpansionSource } from "../../../tests/fixtures/javascript
import { graphForJavaScript as graphFor } from "../../../tests/fixtures/javascriptSemanticGraph.js";
import * as t from "@babel/types";
import { expect, it } from "vitest";
import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js";
import {
SEMANTIC_GRAPH_FILE_NODE_CEILING,
@@ -26,6 +27,36 @@ import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterE
const SHA256 = "a".repeat(64);
it("projects bounded alias selection as a resource-limit unknown", () => {
const source = aliasSelectionBudgetSource();
const ir = analyzeJavaScriptSemantics(source);
const graph = graphFor(source, ir);
expect(ir.coverage).toMatchObject({
status: "partial",
resourceLimits: ["alias-selections"],
});
const unknown = graph.unknowns.find(
({ reason, detail }) =>
reason === "resource-limit" && /Alias selection expansion/u.test(detail),
);
expect(unknown).toBeDefined();
expect(
graph.evidence_contexts.find(
({ context_id }) => context_id === unknown?.evidence.context_id,
),
).toMatchObject({
coverage: {
limits: expect.arrayContaining([
{
name: "javascript_semantic_alias_selections",
value: 512,
unit: "items",
},
]),
},
});
});
it.each([
["plain export", "const answer = 42; export { answer };", 0],
["aliased export", "const answer = 42; export { answer as result };", 0],
@@ -660,10 +660,10 @@ function* projectFileSteps(
const projectFileResourceLimits = (
context: Pick<FileContext, "file" | "ir" | "state" | "moduleNode">,
): void => {
// Getter limits describe query effects without tagged values; other limits
// Query limits describe effects without tagged values; other limits
// already project from binding or return-site values and must not be duplicated.
for (const limit of semanticCoverageResourceLimits(context.ir.coverage)) {
if (limit !== "getter-expansion") continue;
if (limit !== "getter-expansion" && limit !== "alias-selections") continue;
addSemanticGraphUnknown(context.state, {
node_id: context.moduleNode.node_id,
family: "data-flow",