diff --git a/src/domain/javascriptSemanticAnalysis.builtin_defaults.test.ts b/src/domain/javascriptSemanticAnalysis.builtin_defaults.test.ts new file mode 100644 index 000000000..0e4b2c22a --- /dev/null +++ b/src/domain/javascriptSemanticAnalysis.builtin_defaults.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +it.each(["namespace", "default", "named-default"])( + "retains Node built-in effects from %s imports", + (mode) => { + const imports = ["fs", "child_process", "timers", "http"] + .map((name, index) => { + const declaration = + mode === "namespace" + ? `* as m${String(index)}` + : mode === "default" + ? `m${String(index)}` + : `{default as m${String(index)}}`; + return `import ${declaration} from 'node:${name}';`; + }) + .join("\n"); + const ir = analyzeJavaScriptSemantics(`${imports} + const text = m0.readFileSync('config.json','utf8'); + const stream = m0.createReadStream('config.json'); stream.destroy(); + const child = m1.spawn('/usr/bin/tool', ['--version']); + const timer = m2.setTimeout(work,1000); m2.clearTimeout(timer); + const request = m3.get('http://localhost/'); + `); + expect(ir.configurationOperations).toHaveLength(1); + expect(ir.resourceOperations).toHaveLength(2); + expect(ir.childProcessSpawns).toHaveLength(1); + expect(ir.timerOperations).toHaveLength(2); + expect(ir.requestOperations).toHaveLength(1); + }, +); + +it.each([ + "import fs from './fs.js'; fs.createReadStream('config.json');", + "import {constants as fs} from 'node:fs'; fs.createReadStream('config.json');", + "function run(fs) { fs.createReadStream('config.json'); }", +])("does not invent a built-in namespace for %s", (source) => { + expect(analyzeJavaScriptSemantics(source).resourceOperations).toEqual([]); +}); diff --git a/src/domain/javascriptSemanticAsyncEffects.ts b/src/domain/javascriptSemanticAsyncEffects.ts index 6dade9485..07d489890 100644 --- a/src/domain/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascriptSemanticAsyncEffects.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; + import type { JavaScriptSemanticCallable, JavaScriptSemanticEventOperation, @@ -338,7 +340,7 @@ const timerNamespaceBinding = ( ): boolean => binding?.directOrigins.some( ({ specifier, importedPath }) => - timerModule(specifier) && importedPath.length === 0, + timerModule(specifier) && semanticBuiltinNamespacePath(importedPath), ) ?? false; const timerModule = (specifier: string): boolean => diff --git a/src/domain/javascriptSemanticChildProcesses.ts b/src/domain/javascriptSemanticChildProcesses.ts index d1666bbc5..cdc127b1a 100644 --- a/src/domain/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascriptSemanticChildProcesses.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; + import type { JavaScriptSemanticCallable, JavaScriptSemanticChildProcessInteraction, @@ -305,7 +307,8 @@ const childProcessNamespace = ( ): boolean => binding?.directOrigins.some( ({ specifier, importedPath }) => - childProcessModule(specifier) && importedPath.length === 0, + childProcessModule(specifier) && + semanticBuiltinNamespacePath(importedPath), ) ?? false; const childProcessModule = (specifier: string): boolean => diff --git a/src/domain/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascriptSemanticDataEffectHelpers.ts index dc0254ddd..3a7975535 100644 --- a/src/domain/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascriptSemanticDataEffectHelpers.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; + import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; import { resolveSemanticBindingState, @@ -157,7 +159,8 @@ export const builtinDataEffectMethod = ( ); const namespace = binding?.directOrigins.some( ({ specifier, importedPath }) => - specifiers.includes(specifier) && importedPath.length === 0, + specifiers.includes(specifier) && + semanticBuiltinNamespacePath(importedPath), ); return namespace ? semanticStaticPropertyName(callee.property, callee.computed) diff --git a/src/domain/javascriptSemanticDataEffects.ts b/src/domain/javascriptSemanticDataEffects.ts index 839c50fd9..6cebc959e 100644 --- a/src/domain/javascriptSemanticDataEffects.ts +++ b/src/domain/javascriptSemanticDataEffects.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; + import type { JavaScriptSemanticBoundaryOperation, JavaScriptSemanticCallable, @@ -377,7 +379,7 @@ const requestMethod = ( return binding?.directOrigins.some( ({ specifier, importedPath }) => ["http", "https", "node:http", "node:https"].includes(specifier) && - importedPath.length === 0, + semanticBuiltinNamespacePath(importedPath), ) ? method : null; diff --git a/src/domain/javascriptSemanticProvenance.ts b/src/domain/javascriptSemanticProvenance.ts index 255e457e0..824c76414 100644 --- a/src/domain/javascriptSemanticProvenance.ts +++ b/src/domain/javascriptSemanticProvenance.ts @@ -55,3 +55,10 @@ export const semanticPrimitiveKey = ( value: JavaScriptSemanticPrimitive, ): string => `${value === null ? "null" : typeof value}:${JSON.stringify(value)}`; + +/** Match namespace paths only after the caller has identified a Node built-in. */ +export const semanticBuiltinNamespacePath = ( + importedPath: readonly string[], +): boolean => + importedPath.length === 0 || + (importedPath.length === 1 && importedPath[0] === "default"); diff --git a/src/domain/javascriptSemanticResources.ts b/src/domain/javascriptSemanticResources.ts index c7c9bc87e..b204aeb73 100644 --- a/src/domain/javascriptSemanticResources.ts +++ b/src/domain/javascriptSemanticResources.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; + import type { JavaScriptSemanticCallable, JavaScriptSemanticResourceOperation, @@ -173,7 +175,7 @@ const resourceNamespace = ( ): boolean => binding?.directOrigins.some( ({ specifier, importedPath }) => - resourceModule(specifier) && importedPath.length === 0, + resourceModule(specifier) && semanticBuiltinNamespacePath(importedPath), ) ?? false; const resourceModule = (specifier: string): boolean => diff --git a/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts b/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts new file mode 100644 index 000000000..ad8890f53 --- /dev/null +++ b/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts @@ -0,0 +1,55 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); + +it.each([ + "import fs from 'node:fs';", + "import {default as fs} from 'node:fs';", +])( + "retains filesystem effects in the public CLI with %s", + async (declaration) => { + const root = await createTestTempDirectory("rea-builtin-default-cli-"); + await writeFile( + join(root, "app.js"), + `${declaration} const text=fs.readFileSync('config.json','utf8'); const stream=fs.createReadStream('config.json'); stream.destroy();`, + ); + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], + { cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 }, + ); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(JSON.parse(stdout)).normalized_result, + ); + expect(result.semantic_graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "config-source", + properties: expect.objectContaining({ key: "config.json" }), + }), + expect.objectContaining({ + kind: "resource", + properties: expect.objectContaining({ method: "createReadStream" }), + }), + ]), + ); + expect(result.semantic_graph.relations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "releases", + resolution: "resolved", + }), + ]), + ); + }, + 20_000, +);