mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(javascript): retain static paths through TypeScript wrappers (#3998)
Normalize erased TypeScript wrappers through existing expression/string owners for static paths, call targets, collection/configuration receivers and global aliases. Preserve binding/mutation guards and explicit unknown targets, with CLI and application-boundary regressions. Original contribution by @ShuaixinHuang. Co-authored-by: ShuaixinHuang <1836656514@qq.com>
This commit is contained in:
@@ -300,25 +300,21 @@ function* classifyDefinitionReceiversSteps(
|
||||
if (client) roots.add(root);
|
||||
};
|
||||
const classifyOpenReceiver = (node: t.Node): void => {
|
||||
if (
|
||||
(!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) ||
|
||||
(!t.isMemberExpression(node.callee) &&
|
||||
!t.isOptionalMemberExpression(node.callee))
|
||||
)
|
||||
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
|
||||
const callee = unwrapJavaScriptExpression(node.callee).node;
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return;
|
||||
const method = semanticStaticPropertyKey(
|
||||
node.callee.property,
|
||||
node.callee.computed,
|
||||
);
|
||||
const object = unwrapJavaScriptExpression(callee.object).node;
|
||||
const method = semanticStaticPropertyKey(callee.property, callee.computed);
|
||||
if (method === "get") {
|
||||
if (isConfigurationReceiver(state, node.callee.object))
|
||||
if (isConfigurationReceiver(state, object))
|
||||
facts.set(node.start ?? -1, "configuration");
|
||||
else if (isExtensionStateMember(node.callee.object))
|
||||
else if (isExtensionStateMember(object))
|
||||
extensionStateReads.push(node.start ?? -1);
|
||||
return;
|
||||
}
|
||||
if (method !== "open" || !t.isIdentifier(node.callee.object)) return;
|
||||
const receiver = node.callee.object.name;
|
||||
if (method !== "open" || !t.isIdentifier(object)) return;
|
||||
const receiver = object.name;
|
||||
const binding = resolveSemanticBindingState(state, node, receiver);
|
||||
if (binding === undefined) {
|
||||
if (semanticResolutionBlocked(state, node, receiver)) return;
|
||||
@@ -338,7 +334,9 @@ function* classifyDefinitionReceiversSteps(
|
||||
return;
|
||||
}
|
||||
if (binding.mutable || binding.initializers.length !== 1) return;
|
||||
const initializer = binding.initializers[0]?.node;
|
||||
const initializerSource = binding.initializers[0]?.node;
|
||||
if (initializerSource === undefined) return;
|
||||
const initializer = unwrapJavaScriptExpression(initializerSource).node;
|
||||
if (!t.isIdentifier(initializer)) return;
|
||||
const initializerBinding = resolveSemanticBindingState(
|
||||
state,
|
||||
@@ -357,7 +355,9 @@ function* classifyDefinitionReceiversSteps(
|
||||
initializerBinding.initializers.length !== 1
|
||||
)
|
||||
return;
|
||||
const nested = initializerBinding.initializers[0]?.node;
|
||||
const nestedSource = initializerBinding.initializers[0]?.node;
|
||||
if (nestedSource === undefined) return;
|
||||
const nested = unwrapJavaScriptExpression(nestedSource).node;
|
||||
if (t.isIdentifier(nested)) {
|
||||
const nestedFact = resolveGlobalAliasFact(
|
||||
state,
|
||||
@@ -420,16 +420,17 @@ const isConfigurationAccessorCall = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
node: t.Node,
|
||||
): boolean => {
|
||||
node = unwrapJavaScriptExpression(node).node;
|
||||
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
|
||||
return false;
|
||||
const callee = node.callee;
|
||||
const callee = unwrapJavaScriptExpression(node.callee).node;
|
||||
if (
|
||||
(!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) ||
|
||||
semanticStaticPropertyKey(callee.property, callee.computed) !==
|
||||
"getConfiguration"
|
||||
)
|
||||
return false;
|
||||
const host = callee.object;
|
||||
const host = unwrapJavaScriptExpression(callee.object).node;
|
||||
const matchesSpelling = t.isIdentifier(host)
|
||||
? host.name === "workspace"
|
||||
: (t.isMemberExpression(host) || t.isOptionalMemberExpression(host)) &&
|
||||
@@ -438,7 +439,7 @@ const isConfigurationAccessorCall = (
|
||||
const root = t.isIdentifier(host)
|
||||
? host
|
||||
: t.isMemberExpression(host) || t.isOptionalMemberExpression(host)
|
||||
? host.object
|
||||
? unwrapJavaScriptExpression(host.object).node
|
||||
: undefined;
|
||||
if (!t.isIdentifier(root)) return false;
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
|
||||
@@ -2,6 +2,173 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
const expressionWrappers = [
|
||||
["plain", (value: string) => value],
|
||||
["asserted", (value: string) => `(${value} as any)`],
|
||||
["non-null", (value: string) => `(${value}!)`],
|
||||
["satisfies", (value: string) => `(${value} satisfies any)`],
|
||||
] as const;
|
||||
|
||||
it("shares erased call-target names with endpoint consumers without inventing dynamic keys", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
(client.get as typeof client.get)("/api/items");
|
||||
(navigator as typeof navigator).sendBeacon("/api/telemetry", payload);
|
||||
(client[method] as typeof client.get)("/api/unknown");
|
||||
((choose ? client.get : client.post) as typeof client.get)("/api/conditional");
|
||||
`);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(
|
||||
analysis.endpoints.map(({ value, mechanism }) => [value, mechanism]),
|
||||
).toEqual([
|
||||
["/api/items", "call:client.get"],
|
||||
["/api/telemetry", "call:navigator.sendBeacon"],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(expressionWrappers)(
|
||||
"excludes %s collection and configuration reads while retaining request candidates",
|
||||
(_label, wrap) => {
|
||||
const call = (receiver: string, method: string, value: string) =>
|
||||
`${wrap(`${wrap(receiver)}.${method}`)}(${JSON.stringify(value)});`;
|
||||
const workspace = `${wrap("vscode")}.workspace`;
|
||||
const configuration = `${wrap(`${wrap(workspace)}.getConfiguration`)}("editor")`;
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
import * as vscode from "vscode";
|
||||
const settings = ${wrap(configuration)};
|
||||
let changed = ${wrap(configuration)};
|
||||
changed = client;
|
||||
const uncertain = flag ? ${wrap(configuration)} : client;
|
||||
${call(`new ${wrap("Headers")}()`, "get", "content-type")}
|
||||
${call(`new ${wrap("URLSearchParams")}()`, "delete", "q")}
|
||||
${call(`new ${wrap("Map")}()`, "get", "setting")}
|
||||
${call(`${wrap("response")}.headers`, "get", "authorization")}
|
||||
${call(`${wrap("url")}.searchParams`, "delete", "token")}
|
||||
${call(configuration, "get", "tabSize")}
|
||||
${call("settings", "get", "wordWrap")}
|
||||
${call(`${wrap("context")}.globalState`, "get", "lastSession")}
|
||||
${call(`${wrap("context")}.workspaceState`, "get", "lastFile")}
|
||||
${call("client", "get", "setting")}
|
||||
${call(`new ${wrap("Headers")}()`, "get", "/api/header-shaped")}
|
||||
${call("settings", "get", "https://api.example.test/config-shaped")}
|
||||
${call("client", "get", "/api/items")}
|
||||
${call("changed", "get", "changed-setting")}
|
||||
${call("uncertain", "get", "conditional-setting")}
|
||||
`);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(analysis.parse_status).toBe("complete");
|
||||
expect(
|
||||
analysis.endpoints.map(({ value, interpretation }) => [
|
||||
value,
|
||||
interpretation ?? null,
|
||||
]),
|
||||
).toEqual([
|
||||
["setting", "candidate"],
|
||||
["/api/header-shaped", null],
|
||||
["https://api.example.test/config-shaped", null],
|
||||
["/api/items", null],
|
||||
["changed-setting", "candidate"],
|
||||
["conditional-setting", "candidate"],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains uncertainty and module evidence for wrapped get receivers", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const vscode = otherLibrary;
|
||||
const settings = vscode.workspace.getConfiguration("editor");
|
||||
(settings.get as any)("unproven-config");
|
||||
(context.globalState.get as any)("unproven-state");
|
||||
((choose ? new Headers() : client).get as any)("uncertain-receiver");
|
||||
(client[method] as any)("unknown-method");
|
||||
((choose ? client.get : client.post) as any)("unknown-target");
|
||||
`);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(
|
||||
analysis.endpoints.map(({ value, interpretation }) => [
|
||||
value,
|
||||
interpretation,
|
||||
]),
|
||||
).toEqual([
|
||||
["unproven-config", "candidate"],
|
||||
["unproven-state", "candidate"],
|
||||
["uncertain-receiver", "candidate"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains lexical browser-open facts through erased call and receiver wrappers", () => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
((window as any).open as any)(method, "browser-context");
|
||||
function local(window) {
|
||||
((window as any).open as any)(method, "local-request");
|
||||
}
|
||||
`);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect(
|
||||
analysis.endpoints.map(({ value, interpretation }) => [
|
||||
value,
|
||||
interpretation,
|
||||
]),
|
||||
).toEqual([["local-request", "candidate"]]);
|
||||
});
|
||||
|
||||
it.each(expressionWrappers)(
|
||||
"retains global and local identity through %s alias initializers",
|
||||
(_label, wrap) => {
|
||||
const analysis = analyzeJavaScriptStaticSource(`
|
||||
const browser = ${wrap("window")};
|
||||
(browser.open as any)(method, "browser-context");
|
||||
const database = ${wrap("indexedDB")};
|
||||
(database.open as any)("fixture-db", "2");
|
||||
const cache = ${wrap("caches")};
|
||||
(cache.open as any)("fixture-cache");
|
||||
{
|
||||
const indexedDB = ${wrap("caches")};
|
||||
const nested = ${wrap("indexedDB")};
|
||||
(nested.open as any)("nested-cache");
|
||||
}
|
||||
{
|
||||
const caches = ${wrap("indexedDB")};
|
||||
const nested = ${wrap("caches")};
|
||||
(nested.open as any)("nested-db", "2");
|
||||
}
|
||||
function local(window, indexedDB, caches) {
|
||||
const browser = ${wrap("window")};
|
||||
(browser.open as any)(method, "local-window");
|
||||
const database = ${wrap("indexedDB")};
|
||||
(database.open as any)("GET", "/local-db");
|
||||
const cache = ${wrap("caches")};
|
||||
(cache.open as any)("GET", "/local-cache");
|
||||
}
|
||||
let replaced = ${wrap("window")};
|
||||
replaced = client;
|
||||
(replaced.open as any)(method, "changed-window");
|
||||
const uncertain = ${wrap("flag ? window : client")};
|
||||
(uncertain.open as any)(method, "uncertain-window");
|
||||
`);
|
||||
expect(analysis.parse_error_count).toBe(0);
|
||||
expect
|
||||
.soft(
|
||||
analysis.endpoints.map(({ value, interpretation }) => [
|
||||
value,
|
||||
interpretation ?? null,
|
||||
]),
|
||||
)
|
||||
.toEqual([
|
||||
["local-window", "candidate"],
|
||||
["/local-db", null],
|
||||
["/local-cache", null],
|
||||
["changed-window", "candidate"],
|
||||
["uncertain-window", "candidate"],
|
||||
]);
|
||||
expect(analysis.storage.map(({ kind, name }) => [kind, name])).toEqual([
|
||||
["indexed-db", "fixture-db"],
|
||||
["cache-storage", "fixture-cache"],
|
||||
["cache-storage", "nested-cache"],
|
||||
["indexed-db", "nested-db"],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains endpoint observations for exact template and TypeScript member keys", () => {
|
||||
for (const [get, beacon] of [
|
||||
['"get"', '"sendBeacon"'],
|
||||
|
||||
@@ -180,9 +180,9 @@ export const staticPathResolutionContext = (
|
||||
|
||||
/** Resolve a path composed only from inert literal syntax. */
|
||||
export const staticPath = (node: t.Node): string | undefined => {
|
||||
if (t.isStringLiteral(node)) return node.value;
|
||||
if (t.isTemplateLiteral(node) && node.expressions.length === 0)
|
||||
return stringValue(node);
|
||||
node = unwrapJavaScriptExpression(node).node;
|
||||
const value = stringValue(node);
|
||||
if (value !== undefined) return value;
|
||||
if (t.isBinaryExpression(node, { operator: "+" }))
|
||||
return staticConcatenation(node);
|
||||
if (t.isCallExpression(node) || t.isNewExpression(node))
|
||||
@@ -200,7 +200,7 @@ const staticConcatenation = (node: t.BinaryExpression): string | undefined => {
|
||||
let current: t.Node = node;
|
||||
while (t.isBinaryExpression(current, { operator: "+" })) {
|
||||
operands.push(current.right);
|
||||
current = current.left;
|
||||
current = unwrapJavaScriptExpression(current.left).node;
|
||||
}
|
||||
const anchored = isDirectoryPath(current);
|
||||
if (!anchored) operands.push(current);
|
||||
@@ -249,8 +249,9 @@ const staticFileUrlPath = (
|
||||
node: t.CallExpression | t.NewExpression,
|
||||
): string | undefined => {
|
||||
if (node.arguments.length !== 1) return undefined;
|
||||
const argument = argumentNode(node.arguments[0]);
|
||||
if (argument === undefined) return undefined;
|
||||
const rawArgument = argumentNode(node.arguments[0]);
|
||||
if (rawArgument === undefined) return undefined;
|
||||
const argument = unwrapJavaScriptExpression(rawArgument).node;
|
||||
if (isSourceFileUrl(argument)) return sourceRelativeFileUrlPath(argument);
|
||||
const value = staticPath(argument);
|
||||
const rawPath = value?.split(/[?#]/u, 1)[0] ?? "";
|
||||
@@ -277,8 +278,9 @@ const staticFileUrlPath = (
|
||||
};
|
||||
|
||||
/** Project a known source-relative URL through the actual file URL decoder. */
|
||||
const sourceRelativeFileUrlPath = (node: t.Node): string | undefined => {
|
||||
if (!isSourceFileUrl(node)) return undefined;
|
||||
const sourceRelativeFileUrlPath = (
|
||||
node: t.CallExpression | t.NewExpression,
|
||||
): string | undefined => {
|
||||
const value = stringValue(node.arguments[0]);
|
||||
if (
|
||||
value === undefined ||
|
||||
@@ -347,7 +349,9 @@ const staticResolvedPath = (
|
||||
};
|
||||
|
||||
const isFilesystemPathExpression = (node: t.Node): boolean => {
|
||||
if (t.isStringLiteral(node)) return node.value.startsWith("/");
|
||||
node = unwrapJavaScriptExpression(node).node;
|
||||
const value = stringValue(node);
|
||||
if (value !== undefined) return value.startsWith("/");
|
||||
if (isFileIdentity(node) || isDirectoryIdentity(node)) return true;
|
||||
if (t.isBinaryExpression(node, { operator: "+" }))
|
||||
return (
|
||||
@@ -377,6 +381,7 @@ const isFilesystemPathExpression = (node: t.Node): boolean => {
|
||||
};
|
||||
|
||||
const isDirectoryIdentity = (node: t.Node | undefined): boolean => {
|
||||
if (node !== undefined) node = unwrapJavaScriptExpression(node).node;
|
||||
if (isDirectoryPath(node)) return true;
|
||||
if (!t.isCallExpression(node) && !t.isNewExpression(node)) return false;
|
||||
const name = calleeName(node.callee);
|
||||
@@ -388,6 +393,7 @@ const isDirectoryIdentity = (node: t.Node | undefined): boolean => {
|
||||
|
||||
/** `__dirname` or the dirname of the source file, as a string. */
|
||||
const isDirectoryPath = (node: t.Node | undefined): boolean => {
|
||||
if (node !== undefined) node = unwrapJavaScriptExpression(node).node;
|
||||
if (t.isIdentifier(node, { name: "__dirname" })) return true;
|
||||
if (!t.isCallExpression(node)) return false;
|
||||
const name = calleeName(node.callee);
|
||||
@@ -413,6 +419,7 @@ export const isImportMetaUrl = (node: t.Node | undefined): boolean => {
|
||||
};
|
||||
|
||||
const isFileIdentity = (node: t.Node | undefined): boolean => {
|
||||
if (node !== undefined) node = unwrapJavaScriptExpression(node).node;
|
||||
if (t.isIdentifier(node, { name: "__filename" }) || isImportMetaUrl(node))
|
||||
return true;
|
||||
if (!t.isCallExpression(node)) return false;
|
||||
@@ -560,14 +567,17 @@ const KEYED_COLLECTION_CONSTRUCTORS = new Set([
|
||||
* binding, so callers keep URL and path literals as requests.
|
||||
*/
|
||||
const isKeyedCollectionReceiver = (callee: t.Node): boolean => {
|
||||
callee = unwrapJavaScriptExpression(callee).node;
|
||||
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
|
||||
return false;
|
||||
const receiver = callee.object;
|
||||
if (t.isNewExpression(receiver))
|
||||
const receiver = unwrapJavaScriptExpression(callee.object).node;
|
||||
if (t.isNewExpression(receiver)) {
|
||||
const constructor = unwrapJavaScriptExpression(receiver.callee).node;
|
||||
return (
|
||||
t.isIdentifier(receiver.callee) &&
|
||||
KEYED_COLLECTION_CONSTRUCTORS.has(receiver.callee.name)
|
||||
t.isIdentifier(constructor) &&
|
||||
KEYED_COLLECTION_CONSTRUCTORS.has(constructor.name)
|
||||
);
|
||||
}
|
||||
if (
|
||||
!t.isMemberExpression(receiver) &&
|
||||
!t.isOptionalMemberExpression(receiver)
|
||||
@@ -669,14 +679,15 @@ export const storageKind = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Produce a dotted callee name from member syntax. */
|
||||
/** Produce a dotted callee name through type-erased expression wrappers. */
|
||||
export const calleeName = (node: t.Node): string => {
|
||||
node = unwrapJavaScriptExpression(node).node;
|
||||
const segments: string[] = [];
|
||||
while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
||||
// Dynamic keys retain their syntax identity instead of inventing a name.
|
||||
segments.push(memberPropertyName(node));
|
||||
if (!t.isNode(node.object)) break;
|
||||
node = node.object;
|
||||
node = unwrapJavaScriptExpression(node.object).node;
|
||||
}
|
||||
const base = t.isIdentifier(node)
|
||||
? node.name
|
||||
|
||||
@@ -5,6 +5,7 @@ import { parseExpression } from "@babel/parser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
import { parserPluginsForPath } from "./javascriptSourceParser.js";
|
||||
import {
|
||||
staticPath,
|
||||
staticPathResolutionContext,
|
||||
@@ -46,8 +47,13 @@ const anchoredExpressions = [
|
||||
] as const;
|
||||
|
||||
const unknownExpressions = [
|
||||
"runtimePath as string",
|
||||
"path.join(__dirname as string, runtimeFile as string) as string",
|
||||
'path[method](__dirname, "preload.js")',
|
||||
'path[`join${suffix}`](__dirname, "preload.js")',
|
||||
'(path[method] as typeof path.join)(__dirname, "preload.js")',
|
||||
'((useJoin ? path.join : path.resolve) as typeof path.join)(__dirname, "preload.js")',
|
||||
'fileURLToPath(new (runtimeURL as typeof URL)("./preload.js", import.meta.url))',
|
||||
"path.resolve()",
|
||||
"path.posix.resolve()",
|
||||
'path.resolve("sub", "../preload.js")',
|
||||
@@ -69,7 +75,101 @@ const unknownExpressions = [
|
||||
'path.dirname(__filename) + "preload.js"',
|
||||
];
|
||||
|
||||
const pathValueExpressions = [
|
||||
['"./preload.js" as const', "./preload.js", "module-specifier"],
|
||||
[
|
||||
'path.join(__dirname, "preload.js") as string',
|
||||
"preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'path.join(__dirname as string, "preload.js" as const)',
|
||||
"preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'path.resolve(__dirname as string, "preload.js" as const)',
|
||||
"preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'fileURLToPath(new URL("./preload.js", import.meta.url as string) as URL) as string',
|
||||
"./preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'path.join(path.dirname(__filename as string), "preload.js")',
|
||||
"preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'path.join(path.dirname(__filename) as string, "preload.js")',
|
||||
"preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'((__dirname + "/lib") as string) + "/preload.js"',
|
||||
"lib/preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
['"/app/preload.js"', "/app/preload.js", "filesystem-expression"],
|
||||
["`/app/preload.js`", "/app/preload.js", "filesystem-expression"],
|
||||
[
|
||||
'path.join(`/app`, "preload.js")',
|
||||
"/app/preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
["`./preload.js`", "./preload.js", "module-specifier"],
|
||||
] as const;
|
||||
|
||||
const pathCallTargetExpressions = [
|
||||
['(path.join as typeof path.join)(__dirname, "preload.js")', "preload.js"],
|
||||
['path.join!(__dirname, "preload.js")', "preload.js"],
|
||||
[
|
||||
'(path.resolve satisfies typeof path.resolve)(__dirname, "preload.js")',
|
||||
"preload.js",
|
||||
],
|
||||
[
|
||||
'((path as typeof path).posix as typeof path.posix).resolve("/app", "preload.js")',
|
||||
"/app/preload.js",
|
||||
],
|
||||
[
|
||||
'(fileURLToPath as typeof fileURLToPath)(new URL("./preload.js", import.meta.url))',
|
||||
"./preload.js",
|
||||
],
|
||||
[
|
||||
'fileURLToPath(new (URL as typeof URL)("./preload.js", import.meta.url))',
|
||||
"./preload.js",
|
||||
],
|
||||
[
|
||||
'fileURLToPath(new (url.URL as typeof URL)("./preload.js", import.meta.url))',
|
||||
"./preload.js",
|
||||
],
|
||||
[
|
||||
'path.join((path.dirname as typeof path.dirname)(__filename), "preload.js")',
|
||||
"preload.js",
|
||||
],
|
||||
[
|
||||
'path.join(dirname((fileURLToPath as typeof fileURLToPath)(import.meta.url)), "preload.js")',
|
||||
"preload.js",
|
||||
],
|
||||
] as const;
|
||||
|
||||
describe("inert JavaScript path resolution", () => {
|
||||
it.each(pathCallTargetExpressions)(
|
||||
"preserves exact path values and context through call target %s",
|
||||
(expression, path) => {
|
||||
assertPathProjection(expression, path, "filesystem-expression");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(pathValueExpressions)(
|
||||
"preserves exact path values and context through %s",
|
||||
(expression, path, context) => {
|
||||
assertPathProjection(expression, path, context);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(absoluteParts)(
|
||||
"matches the POSIX resolve oracle for %j",
|
||||
(...parts) => {
|
||||
@@ -141,9 +241,7 @@ describe("inert JavaScript path resolution", () => {
|
||||
});
|
||||
|
||||
it.each(unknownExpressions)("keeps unsupported %s unknown", (expression) => {
|
||||
expect(
|
||||
staticPath(parseExpression(expression, { sourceType: "module" })),
|
||||
).toBeUndefined();
|
||||
expect(staticPath(parsePathExpression(expression))).toBeUndefined();
|
||||
const { analysis, windowExpression, utilityExpression } =
|
||||
analyzePathConsumers(expression);
|
||||
expect(analysis.parse_status).toBe("partial");
|
||||
@@ -210,9 +308,7 @@ describe("inert JavaScript path resolution", () => {
|
||||
'path.join("/app", runtimeFile)',
|
||||
"path.join(...parts)",
|
||||
])("preserves unresolved join syntax in %s", (expression) =>
|
||||
expect(
|
||||
staticPath(parseExpression(expression, { sourceType: "module" })),
|
||||
).toBeUndefined(),
|
||||
expect(staticPath(parsePathExpression(expression))).toBeUndefined(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -237,11 +333,15 @@ it.each([
|
||||
'fileURLToPath("file:///app/preload.js", options)',
|
||||
"fileURLToPath(selectedUrl)",
|
||||
])("leaves platform-dependent or invalid %s unknown", (expression) =>
|
||||
expect(
|
||||
staticPath(parseExpression(expression, { sourceType: "module" })),
|
||||
).toBeUndefined(),
|
||||
expect(staticPath(parsePathExpression(expression))).toBeUndefined(),
|
||||
);
|
||||
|
||||
const parsePathExpression = (expression: string) =>
|
||||
parseExpression(expression, {
|
||||
sourceType: "module",
|
||||
plugins: parserPluginsForPath("preload.ts"),
|
||||
});
|
||||
|
||||
const analyzePathConsumers = (expression: string) => {
|
||||
const property = `preload: ${expression}`;
|
||||
const genericPrefix = "const options = { ";
|
||||
@@ -265,7 +365,7 @@ const assertPathProjection = (
|
||||
path: string,
|
||||
context: JavaScriptStaticPathContext,
|
||||
) => {
|
||||
const node = parseExpression(expression, { sourceType: "module" });
|
||||
const node = parsePathExpression(expression);
|
||||
expect(staticPath(node)).toBe(path);
|
||||
expect(staticPathResolutionContext(node)).toBe(context);
|
||||
const {
|
||||
|
||||
@@ -13,8 +13,25 @@ const execute = promisify(execFile);
|
||||
|
||||
it("reports a candidate endpoint through the worker boundary in the public CLI", async () => {
|
||||
const root = await createTestTempDirectory("rea-endpoint-candidate-cli-");
|
||||
// A method-like call whose first argument is neither a URL nor a path.
|
||||
await writeFile(join(root, "main.js"), 'client.post("session");\n');
|
||||
// Retain ambiguous requests while excluding type-erased collection/config reads.
|
||||
await writeFile(
|
||||
join(root, "main.ts"),
|
||||
`
|
||||
import * as vscode from "vscode";
|
||||
client.post("session");
|
||||
(client.get as any)("/api/items");
|
||||
(new Headers().get as any)("content-type");
|
||||
(new URLSearchParams() as any).delete("q");
|
||||
((vscode.workspace.getConfiguration("editor") as any).get as any)("tabSize");
|
||||
(context.globalState.get as any)("lastSession");
|
||||
const browser = window as Window;
|
||||
(browser.open as any)(method, "browser-context");
|
||||
const database = indexedDB as IDBFactory;
|
||||
(database.open as any)("fixture-db", "2");
|
||||
const cache = caches as CacheStorage;
|
||||
(cache.open as any)("fixture-cache");
|
||||
`,
|
||||
);
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "analyze-javascript-application", root, "--json"],
|
||||
@@ -23,7 +40,32 @@ it("reports a candidate endpoint through the worker boundary in the public CLI",
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
parseEvidence(JSON.parse(stdout)).normalized_result,
|
||||
);
|
||||
const endpoint = result.graph.nodes.find(({ kind }) => kind === "endpoint");
|
||||
expect
|
||||
.soft(
|
||||
result.graph.nodes
|
||||
.filter(({ kind }) => kind === "endpoint")
|
||||
.flatMap(({ observations }) =>
|
||||
observations.map(({ properties }) => properties.value),
|
||||
)
|
||||
.sort(),
|
||||
)
|
||||
.toEqual(["/api/items", "session"]);
|
||||
expect(
|
||||
result.graph.nodes
|
||||
.filter(({ kind }) => kind === "storage")
|
||||
.flatMap(({ observations }) =>
|
||||
observations.map(
|
||||
({ properties }) =>
|
||||
`${String(properties.storage_kind)}:${String(properties.name)}`,
|
||||
),
|
||||
)
|
||||
.sort(),
|
||||
).toEqual(["cache-storage:fixture-cache", "indexed-db:fixture-db"]);
|
||||
const endpoint = result.graph.nodes.find(
|
||||
({ kind, observations }) =>
|
||||
kind === "endpoint" &&
|
||||
observations.some(({ properties }) => properties.value === "session"),
|
||||
);
|
||||
expect(endpoint?.observations[0]).toMatchObject({
|
||||
properties: {
|
||||
endpoint_kind: "network",
|
||||
|
||||
+180
@@ -125,6 +125,186 @@ it("resolves erased wrappers through the composed application graph", async () =
|
||||
expect(parseJavaScriptSource(source, "app.mts")?.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "path-value wrappers",
|
||||
plain: ['"./preload.js"', '"./worker.js"', '"kept"'],
|
||||
wrapped: [
|
||||
'("./preload.js" as const)',
|
||||
'("./worker.js" as const)',
|
||||
'("kept" as const)',
|
||||
],
|
||||
preloadPath: "./preload.js",
|
||||
workerPath: "./worker.js",
|
||||
context: "module-specifier",
|
||||
},
|
||||
{
|
||||
name: "path-call target wrappers",
|
||||
plain: [
|
||||
'path.join(__dirname, "preload.js")',
|
||||
'fileURLToPath(new URL("./worker.js", import.meta.url))',
|
||||
'"kept"',
|
||||
],
|
||||
wrapped: [
|
||||
'(path.join as typeof path.join)(__dirname, "preload.js")',
|
||||
'(fileURLToPath as typeof fileURLToPath)(new (URL as typeof URL)("./worker.js", import.meta.url))',
|
||||
'("kept" as const)',
|
||||
],
|
||||
preloadPath: "preload.js",
|
||||
workerPath: "./worker.js",
|
||||
context: "filesystem-expression",
|
||||
},
|
||||
])(
|
||||
"retains Electron entrypoint facts through erased TypeScript $name",
|
||||
async ({
|
||||
plain: plainExpressions,
|
||||
wrapped: wrappedExpressions,
|
||||
preloadPath,
|
||||
workerPath,
|
||||
context,
|
||||
}) => {
|
||||
const applications: ApplicationAnalysis[] = [];
|
||||
for (const [preload, worker, serviceName] of [
|
||||
plainExpressions,
|
||||
wrappedExpressions,
|
||||
]) {
|
||||
applications.push(
|
||||
await analyzeFiles(
|
||||
new Map([
|
||||
["package.json", '{"main":"input.ts","type":"commonjs"}'],
|
||||
[
|
||||
"input.ts",
|
||||
`const { BrowserWindow, utilityProcess } = require("electron");
|
||||
const path = require("node:path");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
new BrowserWindow({ webPreferences: { preload: ${preload} } });
|
||||
utilityProcess.fork(${worker}, [], { serviceName: ${serviceName} });`,
|
||||
],
|
||||
["preload.js", "void 'preload';\n"],
|
||||
["worker.js", "void 'worker';\n"],
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const entrypointFacts = (analysis: ApplicationAnalysis) => {
|
||||
const nodes = new Map(
|
||||
analysis.graph.nodes.map((node) => [node.node_id, node]),
|
||||
);
|
||||
const windows = analysis.graph.nodes.filter(
|
||||
({ kind }) => kind === "browser-window",
|
||||
);
|
||||
const utilities = analysis.graph.nodes.filter(
|
||||
({ kind }) => kind === "electron-utility",
|
||||
);
|
||||
const windowIds = new Set(windows.map(({ node_id }) => node_id));
|
||||
const preloadLoads = analysis.graph.edges.filter(
|
||||
({ source_node_id, relation }) =>
|
||||
windowIds.has(source_node_id) && relation === "loads",
|
||||
);
|
||||
const entrypointIds = new Set([
|
||||
...preloadLoads.map(({ target_node_id }) => target_node_id),
|
||||
...utilities.map(({ node_id }) => node_id),
|
||||
]);
|
||||
// Several provenance edges can identify the same entrypoint asset.
|
||||
const assetMappings = new Map(
|
||||
analysis.graph.edges
|
||||
.filter(
|
||||
({ source_node_id, relation }) =>
|
||||
entrypointIds.has(source_node_id) && relation === "maps_to",
|
||||
)
|
||||
.map(({ source_node_id, target_node_id, properties }) => {
|
||||
const identity = [
|
||||
nodes.get(source_node_id)?.kind,
|
||||
properties.resolved_path,
|
||||
properties.resolution_status,
|
||||
nodes
|
||||
.get(target_node_id)
|
||||
?.observations.find(
|
||||
({ properties: target }) => typeof target.path === "string",
|
||||
)?.properties.path,
|
||||
];
|
||||
return [
|
||||
canonicalJson(identity, "Electron entrypoint asset identity"),
|
||||
identity,
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
summary: analysis.summary,
|
||||
windows: windows.flatMap(({ observations }) =>
|
||||
observations.map(({ properties }) => properties),
|
||||
),
|
||||
utilities: utilities.flatMap(({ observations }) =>
|
||||
observations
|
||||
.filter(
|
||||
({ evidence }) =>
|
||||
evidence.extractor.operation === "observe-utility-process-fork",
|
||||
)
|
||||
.map(({ properties }) => properties),
|
||||
),
|
||||
preloadLoads: preloadLoads.map(({ properties }) => properties),
|
||||
assetMappings: [...assetMappings]
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, identity]) => identity),
|
||||
};
|
||||
};
|
||||
|
||||
const [plain, wrapped] = applications.map(entrypointFacts);
|
||||
expect(plain).toMatchObject({
|
||||
summary: {
|
||||
browser_windows: 1,
|
||||
explicit_web_preferences: 1,
|
||||
preload_entrypoints: 1,
|
||||
utility_processes: 1,
|
||||
resolved_utility_entrypoints: 1,
|
||||
},
|
||||
windows: [
|
||||
{
|
||||
source_path: "input.ts",
|
||||
options_status: "object-literal",
|
||||
web_preferences_status: "object-literal",
|
||||
web_preferences: [
|
||||
{
|
||||
name: "preload",
|
||||
value: {
|
||||
status: "literal",
|
||||
value: preloadPath,
|
||||
expression: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
preload_path: preloadPath,
|
||||
preload_resolution_context: context,
|
||||
absence_means_default: false,
|
||||
},
|
||||
],
|
||||
utilities: [
|
||||
{
|
||||
module_path: workerPath,
|
||||
module_resolution_context: context,
|
||||
module_expression: null,
|
||||
service_name: "kept",
|
||||
},
|
||||
],
|
||||
preloadLoads: [
|
||||
{
|
||||
declared_path: preloadPath,
|
||||
resolution_context: context,
|
||||
resolved_path: "preload.js",
|
||||
resolution_status: "resolved",
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
assetMappings: [
|
||||
["electron-preload", "preload.js", "resolved", "preload.js"],
|
||||
["electron-utility", "worker.js", "resolved", "worker.js"],
|
||||
],
|
||||
});
|
||||
expect(wrapped).toEqual(plain);
|
||||
},
|
||||
);
|
||||
|
||||
it("applies TypeScript .mts ambiguity rules while retaining unambiguous generics", () => {
|
||||
expect(
|
||||
parseJavaScriptSource(
|
||||
|
||||
Reference in New Issue
Block a user