fix(semantics): recognize Node builtin default namespaces (#769)

* fix(semantics): recognize Node builtin default namespaces

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

* style(semantics): keep Babel imports before local helpers

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

---------

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>
This commit is contained in:
Rudy Mizrahi Celekli 2026-10-06 21:25:59 -05:00 committed by GitHub
parent 630bdafcac
commit 24a0bf9a61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 118 additions and 5 deletions

View File

@ -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([]);
});

View File

@ -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 =>

View File

@ -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 =>

View File

@ -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)

View File

@ -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;

View File

@ -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");

View File

@ -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 =>

View File

@ -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,
);