From 649517181268a0b729970b7cbbda15e04b199e32 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:50:23 +0800 Subject: [PATCH] fix(javascript): classify direct defaults and argv elements --- .../javascriptSemanticDataEffectHelpers.ts | 13 --- ...vascriptSemanticDataEffects.lookup.test.ts | 23 ---- .../javascriptSemanticDataEffects.ts | 73 ++++-------- .../javascriptBuiltinDefaultsCli.test.ts | 104 ++++++++++++++++++ 4 files changed, 128 insertions(+), 85 deletions(-) delete mode 100644 src/domain/javascript/javascriptSemanticDataEffects.lookup.test.ts diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 3fa7776100..f98b791dca 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -287,16 +287,3 @@ export const dataEffectMemberObject = ( (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) ? node.object : null; - -/** Compare Babel source offsets for exact containment. */ -export const containsSemanticNode = (outer: t.Node, inner: t.Node): boolean => - outer.start !== null && - outer.start !== undefined && - outer.end !== null && - outer.end !== undefined && - inner.start !== null && - inner.start !== undefined && - inner.end !== null && - inner.end !== undefined && - outer.start <= inner.start && - outer.end >= inner.end; diff --git a/src/domain/javascript/javascriptSemanticDataEffects.lookup.test.ts b/src/domain/javascript/javascriptSemanticDataEffects.lookup.test.ts deleted file mode 100644 index e9536443f6..0000000000 --- a/src/domain/javascript/javascriptSemanticDataEffects.lookup.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, it } from "vitest"; - -import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; - -it("keeps defaults linked to their own config reads across a large source", () => { - const count = 400; - const source = Array.from( - { length: count }, - (_, index) => - `const value${index} = process.env.KEY_${index} ?? "fallback-${index}";`, - ).join("\n"); - - const operations = analyzeJavaScriptSemantics(source).configurationOperations; - const defaults = operations.filter(({ kind }) => kind === "default"); - - expect(defaults).toHaveLength(count); - for (let index = 0; index < count; index += 1) { - expect(defaults.find(({ key }) => key === `KEY_${index}`)).toMatchObject({ - key: `KEY_${index}`, - value: `fallback-${index}`, - }); - } -}); diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 18abeb532a..2819ea4bd3 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -20,7 +20,6 @@ import { } from "./javascriptSemanticState.js"; import { builtinDataEffectMethod as builtinMethod, - containsSemanticNode as containsNode, dataEffectArgumentBindingId as argumentBindingId, dataEffectLiteralString as literalString, dataEffectMemberCallee as memberCallee, @@ -150,6 +149,7 @@ const processConfigurationMember = ( if (family !== "env" && family !== "argv") return null; // `process.env[""]` names an exact (empty) key; only a dynamic key is unknown. const key = semanticStaticPropertyKey(node.property, node.computed); + if (family === "argv" && key !== null && !isArrayIndexKey(key)) return null; return { kind: family === "env" ? "environment" : "argv", key, @@ -157,39 +157,47 @@ const processConfigurationMember = ( }; }; +const isArrayIndexKey = (key: string): boolean => { + if (!/^(?:0|[1-9][0-9]*)$/u.test(key)) return false; + const index = Number(key); + return Number.isSafeInteger(index) && index < 2 ** 32 - 1; +}; + const collectDefaults = ( program: t.Program, context: TraversalContext, configurations: readonly ConfigurationCandidate[], ): JavaScriptSemanticConfigurationOperation[] => { const output: JavaScriptSemanticConfigurationOperation[] = []; - const configurationsByStart = configurations - .filter( - ({ node }) => - node.start !== null && - node.start !== undefined && - node.end !== null && - node.end !== undefined, - ) - .toSorted( - (left, right) => (left.node.start ?? 0) - (right.node.start ?? 0), - ); + const configurationsByNode = new Map( + configurations.map((candidate) => [candidate.node, candidate]), + ); traverseWithContext(program, context, (node) => { if ( !t.isLogicalExpression(node) || (node.operator !== "??" && node.operator !== "||") ) return; - const sources = configurationsIn(node.left, configurationsByStart); - if (sources.length !== 1 || sources[0] === undefined) return; + let source = node.left; + while ( + t.isParenthesizedExpression(source) || + t.isTSAsExpression(source) || + t.isTSTypeAssertion(source) || + t.isTypeCastExpression(source) || + t.isTSSatisfiesExpression(source) || + t.isTSNonNullExpression(source) + ) + source = source.expression; + const candidate = configurationsByNode.get(source); + if (candidate === undefined) return; output.push({ configId: `config:default:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind: "default", location: range(node.right), ownerCallableId: context.callableStack.at(-1) ?? null, resultBindingId: outerAssignedBinding(node, context), - key: sources[0].operation.key, - sourceConfigId: sources[0].operation.configId, + key: candidate.operation.key, + sourceConfigId: candidate.operation.configId, value: primitiveValue(node.right), resolution: primitiveValue(node.right) === null ? "partial" : "complete", }); @@ -197,39 +205,6 @@ const collectDefaults = ( return output; }; -/** Find only configuration candidates whose source offsets can fit this left operand. */ -const configurationsIn = ( - expression: t.Node, - candidates: readonly ConfigurationCandidate[], -): readonly ConfigurationCandidate[] => { - if ( - expression.start === null || - expression.start === undefined || - expression.end === null || - expression.end === undefined - ) - return []; - let low = 0; - let high = candidates.length; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - const start = candidates[middle]?.node.start; - if (start !== null && start !== undefined && start < expression.start) - low = middle + 1; - else high = middle; - } - const sources: ConfigurationCandidate[] = []; - for (let index = low; index < candidates.length; index += 1) { - const candidate = candidates[index]; - if (candidate === undefined) continue; - const start = candidate.node.start; - if (start === null || start === undefined || start > expression.end) break; - if (containsNode(expression, candidate.node)) sources.push(candidate); - if (sources.length > 1) break; - } - return sources; -}; - const collectRequests = ( program: t.Program, context: TraversalContext, diff --git a/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts b/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts index 729a504fc0..6dbf855fc4 100644 --- a/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts +++ b/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts @@ -53,3 +53,107 @@ it.each([ }, 20_000, ); + +it("keeps only direct logical defaults and actual argv elements in the public CLI graph", async () => { + const root = await createTestTempDirectory("rea-builtin-default-cli-"); + const manyDefaults = Array.from( + { length: 400 }, + (_, index) => + `const value${index} = process.env.KEY_${index} ?? "fallback-${index}";`, + ).join("\n"); + await writeFile( + join(root, "app.js"), + [ + `const chained = process.env.CHAINED ?? "fallback" ?? "unreachable"; +const wrapped = ((process.env.WRAPPED)) ?? "wrapped"; +const argument = process.argv[2]; +const argumentLength = process.argv.length; +const namedArgument = process.argv["02"]; +const negativeArgument = process.argv["-1"]; +const method = process.argv.map; +const lastArrayIndex = process.argv[4294967294]; +const outOfRangeArgument = process.argv[4294967295]; +const dynamicArgument = process.argv[process.env.ARGV_INDEX]; +function shadowed(process) { return process.argv[3]; }`, + manyDefaults, + ].join("\n"), + ); + 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, + ); + const configSources = result.semantic_graph.nodes.filter( + ({ kind }) => kind === "config-source", + ); + const defaults = configSources.filter( + ({ properties }) => properties.source_kind === "default", + ); + expect(defaults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + key: "CHAINED", + value: "fallback", + }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ + key: "WRAPPED", + value: "wrapped", + }), + }), + ]), + ); + expect( + defaults.filter(({ properties }) => properties.key === "CHAINED"), + ).toHaveLength(1); + expect(defaults).toHaveLength(402); + const defaultsByKey = new Map( + defaults.map(({ properties }) => [properties.key, properties.value]), + ); + for (let index = 0; index < 400; index += 1) + expect(defaultsByKey.get(`KEY_${index}`)).toBe(`fallback-${index}`); + const argvSources = configSources.filter( + ({ properties }) => properties.source_kind === "argv", + ); + expect(argvSources).toHaveLength(3); + expect(argvSources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ key: "2" }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ key: "4294967294" }), + }), + expect.objectContaining({ + properties: expect.objectContaining({ key: null }), + }), + ]), + ); + expect( + result.semantic_graph.relations + .filter(({ relation }) => relation === "reads-argv") + .map(({ resolution }) => resolution), + ).toEqual(expect.arrayContaining(["resolved", "candidate"])); + expect( + result.semantic_graph.relations.filter( + ({ relation, source_node_id, target_node_id }) => { + if (relation !== "overrides") return false; + const source = result.semantic_graph.nodes.find( + ({ node_id }) => node_id === source_node_id, + ); + const target = result.semantic_graph.nodes.find( + ({ node_id }) => node_id === target_node_id, + ); + return ( + source?.properties.key === "CHAINED" && + target?.properties.key === "CHAINED" + ); + }, + ), + ).toHaveLength(1); +}, 20_000);