fix(javascript): retain configuration reads before member access (#2756)

Squash merge of https://github.com/morluto/rea/pull/2756.

Original pull request submitted by @xu91102.

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
This commit is contained in:
hhang 2026-10-13 23:59:49 +08:00 committed by GitHub
parent d4f0c5e664
commit 72b9ab4af1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 81 additions and 17 deletions

View File

@ -275,12 +275,3 @@ export const dataEffectMemberCallee = (
t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)
? node.callee
: null;
/** Read a parent member's object when present. */
export const dataEffectMemberObject = (
node: t.Node | null,
): t.Expression | t.Super | null =>
node !== null &&
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
? node.object
: null;

View File

@ -24,7 +24,6 @@ import {
dataEffectArgumentBindingId as argumentBindingId,
dataEffectLiteralString as literalString,
dataEffectMemberCallee as memberCallee,
dataEffectMemberObject as memberObject,
dataEffectPrimitive as primitiveValue,
isSemanticProcessObject as isProcessObject,
outerDataEffectBinding as outerAssignedBinding,
@ -83,8 +82,8 @@ const collectConfigurations = (
context: TraversalContext,
): ConfigurationCandidate[] => {
const output: ConfigurationCandidate[] = [];
traverseWithContext(program, context, (node, parent) => {
const details = configurationDetails(node, parent, context);
traverseWithContext(program, context, (node) => {
const details = configurationDetails(node, context);
if (details === null) return;
const operation: JavaScriptSemanticConfigurationOperation = {
configId: `config:${details.kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
@ -104,17 +103,13 @@ const collectConfigurations = (
const configurationDetails = (
node: t.Node,
parent: t.Node | null,
context: TraversalContext,
): {
readonly kind: "environment" | "argv" | "file";
readonly key: string | null;
readonly resolution: "complete" | "partial";
} | null => {
if (
(t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) &&
!(memberObject(parent) === node)
) {
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
const processMember = processConfigurationMember(node, context.state);
if (processMember !== null) return processMember;
}

View File

@ -0,0 +1,78 @@
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"retains configuration sources consumed by member access through %s",
async (surface, { cli, processes }) => {
const root = await createTestTempDirectory("rea-config-reads-");
await writeFixtureFiles(root, {
"entry.mjs": [
"const length = process.env.REA_ENDPOINT.length;",
"const endpoint = process.env.REA_ENDPOINT;",
"const argumentLength = process.argv[2].length;",
"const mode = process.argv[3].trim();",
"const optional = process.env.REA_MISSING?.length;",
"function shadow(process) { return process.env.LOCAL.length + process.argv[2].length; }",
'const local = { env: { FAKE: "fake" }, argv: [0, 1, "abc"] };',
'const control = shadow({ env: { LOCAL: "local" }, argv: [0, 1, "abc"] });',
"console.log(JSON.stringify([length, endpoint, argumentLength, mode, optional, control, local.env.FAKE.length]));",
].join("\n"),
});
const control = await processes.run(
process.execPath,
[join(root, "entry.mjs"), "abc", " mode "],
{ env: { REA_ENDPOINT: "endpoint" }, timeoutMs: 15_000 },
);
expect(control.exitCode, control.stderr).toBe(0);
expect(JSON.parse(control.stdout)).toEqual([
8,
"endpoint",
3,
"mode",
null,
8,
4,
]);
const graph = (await analyzeJavaScriptDirectory(surface, cli, root))
.semantic_graph;
const sources = graph.nodes.filter((node) => node.kind === "config-source");
expect(
sources
.map((node) => [node.properties.source_kind, node.properties.key])
.sort(),
).toEqual([
["argv", "2"],
["argv", "3"],
["environment", "REA_ENDPOINT"],
["environment", "REA_ENDPOINT"],
["environment", "REA_MISSING"],
]);
expect(sources.every((node) => node.properties.value === null)).toBe(true);
const endpoint = graph.nodes.find(
(node) => node.kind === "binding" && node.label === "endpoint",
);
expect(endpoint).toBeDefined();
for (const source of sources) {
const bindingTargets = graph.relations
.filter((relation) => relation.source_node_id === source.node_id)
.flatMap((relation) =>
graph.nodes.filter(
(node) =>
node.node_id === relation.target_node_id &&
node.kind === "binding",
),
);
expect(bindingTargets.map((node) => node.node_id)).toEqual(
source.identity.source_range?.start.line === 2
? [endpoint?.node_id]
: [],
);
}
},
);