mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
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:
@@ -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",
|
||||
|
||||
+67
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user