diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 210bbff2c1..129e5b1091 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -427,6 +427,8 @@ member-mutation owners must establish the receiver before static API facts becom roles or storage observations. Path `join`, `resolve`, and `fileURLToPath` use proven Node imports; source-relative URL conversion additionally requires an unmodified global or proven Node `url.URL` constructor and `import.meta.url`. +Native constructor aliases retain that identity; their local spelling does not +determine URL semantics, and replaced exports or local constructors remain unknown. Relative results of native path operations retain filesystem-expression context; their spelling does not turn them into module specifiers. Other calls preserve their expressions. A property named `preload` outside a proven BrowserWindow diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index deccdde911..34bdfc2f25 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -267,12 +267,6 @@ export interface JavaScriptStaticSourceUrlPath { readonly directory: boolean; } -/** Read a literal `new URL(relative, import.meta.url)` shape without evaluation. */ -export const staticSourceRelativeUrlPath = ( - node: t.Node, -): JavaScriptStaticSourceUrlPath | undefined => - isSourceFileUrl(node) ? sourceRelativeFileUrlPath(node) : undefined; - /** Convert an exact static file URL expression to a POSIX path. */ export const staticFileUrlPath = ( node: t.CallExpression | t.NewExpression, @@ -282,8 +276,8 @@ export const staticFileUrlPath = ( const rawArgument = argumentNode(node.arguments[0]); if (rawArgument === undefined) return undefined; const argument = unwrapJavaScriptExpression(rawArgument).node; - if (isSourceFileUrl(argument) && pathIntrinsics?.(argument, "source-url")) - return sourceRelativeFileUrlPath(argument)?.path; + if (t.isNewExpression(argument) && pathIntrinsics?.(argument, "source-url")) + return staticSourceRelativeUrlPath(argument)?.path; const value = staticPath(argument, pathIntrinsics); const rawPath = value?.split(/[?#]/u, 1)[0] ?? ""; if ( @@ -308,9 +302,9 @@ export const staticFileUrlPath = ( } }; -/** Project a known source-relative URL through the actual file URL decoder. */ -const sourceRelativeFileUrlPath = ( - node: t.CallExpression | t.NewExpression, +/** Decode a source-relative URL after the caller proves constructor and base identity. */ +export const staticSourceRelativeUrlPath = ( + node: t.NewExpression, ): JavaScriptStaticSourceUrlPath | undefined => { const value = stringValue(node.arguments[0]); if ( @@ -350,14 +344,6 @@ const sourceRelativeFileUrlPath = ( } }; -const isSourceFileUrl = ( - node: t.Node, -): node is t.CallExpression | t.NewExpression => - (t.isCallExpression(node) || t.isNewExpression(node)) && - (calleeName(node.callee) === "URL" || - calleeName(node.callee).endsWith(".URL")) && - isFileIdentity(argumentNode(node.arguments[1])); - const staticResolvedPath = ( node: t.CallExpression | t.NewExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, diff --git a/src/domain/javascript/javascriptStaticApiIdentity.test.ts b/src/domain/javascript/javascriptStaticApiIdentity.test.ts index dd815799fe..9f13dc990d 100644 --- a/src/domain/javascript/javascriptStaticApiIdentity.test.ts +++ b/src/domain/javascript/javascriptStaticApiIdentity.test.ts @@ -36,6 +36,42 @@ it.each([ }, ); +it.each([ + [ + "named import", + "import {URL as Constructor} from 'node:url';", + "./native.js", + ], + [ + "captured export", + "import url from 'node:url'; const Constructor = url.URL;", + "./native.js", + ], + [ + "replaced export", + "import url from 'node:url'; url.URL = class {}; const Constructor = url.URL;", + null, + ], + [ + "local constructor", + "class Constructor {constructor(input, base) {return new globalThis.URL('./actual.js', base);}}", + null, + ], +] as const)( + "uses the proven URL constructor identity for a %s alias", + (_kind, declaration, expected) => { + const analysis = + analyzeJavaScriptStaticSource(`${windowPrefix}${declaration} + new BrowserWindow({webPreferences:{preload:fileURLToPath(new Constructor('./native.js', import.meta.url))}});`); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: expected }, + ]); + expect(analysis.role_paths.map(({ path }) => path)).toEqual( + expected === null ? [] : [expected], + ); + }, +); + it.each([ '["a", "b"].join("/")', 'settings.join("/app", "preload.js")', diff --git a/tests/acceptance/applications/javascriptStaticApiIdentity.test.ts b/tests/acceptance/applications/javascriptStaticApiIdentity.test.ts index f20018fcb1..946cdbb3eb 100644 --- a/tests/acceptance/applications/javascriptStaticApiIdentity.test.ts +++ b/tests/acceptance/applications/javascriptStaticApiIdentity.test.ts @@ -13,7 +13,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( "package.json": JSON.stringify({ main: "main.mjs", type: "module" }), "main.mjs": ` import {BrowserWindow as Win} from 'electron'; - import {fileURLToPath} from 'node:url'; + import {fileURLToPath, URL as NativeConstructor} from 'node:url'; class URL {constructor(input, base) {return new globalThis.URL('./actual.js', base);}} const cache = {loadFile(value) {return value;}}; cache.loadFile('data.json'); @@ -22,11 +22,13 @@ cliTest.for(["CLI", "stdio MCP"] as const)( globalThis.localStorage.setItem('real', 'value'); const unrelated = {preload: './decoy.js'}; new Win({webPreferences:{preload:fileURLToPath(new URL('./decoy.js', import.meta.url))}}); + new Win({webPreferences:{preload:fileURLToPath(new NativeConstructor('./native.js', import.meta.url))}}); new Win({webPreferences:{preload:'./preload.js'}}).loadFile('index.html'); `, "actual.js": "export {};", "decoy.js": "export {};", "preload.js": "export {};", + "native.js": "export {};", "data.json": "{}", "index.html": "Fixture", }); @@ -39,7 +41,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)( roles.flatMap(({ observations }) => observations.map(({ properties }) => properties.resolved_path), ), - ).toEqual(expect.arrayContaining(["preload.js", "index.html"])); + ).toEqual( + expect.arrayContaining(["preload.js", "native.js", "index.html"]), + ); expect( roles.flatMap(({ observations }) => observations.map(({ properties }) => properties.resolved_path),