diff --git a/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts index 425653e6f5..016666db1c 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts @@ -57,3 +57,38 @@ it("leaves a response replaced by a non-request value without an endpoint", () = `), ).toEqual([expect.objectContaining({ method: "json", endpoint: null })]); }); + +it.each([ + [ + "ordered", + `async function load() { const res = await fetch("https://example.invalid/api"); return res.json(); }`, + "https://example.invalid/api", + ], + [ + "parameter-replaced-before", + `async function load(res) { res = await fetch("https://example.invalid/api"); return res.json(); }`, + "https://example.invalid/api", + ], + [ + "parameter-conditional", + `async function load(res, maybe) { if (maybe) res = await fetch("https://example.invalid/api"); return res.json(); }`, + null, + ], + [ + "parameter-later", + `async function load(res) { const value = await res.json(); res = await fetch("https://example.invalid/api"); return value; }`, + null, + ], + [ + "multiple-requests", + `async function load(maybe) { let res = await fetch("https://example.invalid/a"); if (maybe) res = await fetch("https://example.invalid/b"); return res.json(); }`, + null, + ], +] as const)( + "keeps a response endpoint only when its request reaches the read: %s", + (_name, source, endpoint) => { + expect(responses(source)).toEqual([ + expect.objectContaining({ method: "json", endpoint }), + ]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index e3d670cf56..a98ed5d729 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -17,8 +17,11 @@ import { recordSemanticValueLimit, resolveSemanticBindingState, semanticResolutionBlocked, + semanticStableInitializer, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { builtinDataEffectMethod as builtinMethod, dataEffectArgumentBindingId as argumentBindingId, @@ -242,6 +245,21 @@ const collectRequests = ( return output; }; +/** + * Any other write (assignment, update, loop or destructuring target) can + * replace the response before this read. A sole write that can be skipped or + * runs after the read leaves an incoming value possible. + */ +const soleWriteReachesRead = ( + binding: JavaScriptSemanticBindingState | undefined, + read: t.Node, + context: TraversalContext, +): boolean => + binding?.initializers.length === 1 && + (semanticStableInitializer(binding, context.state) !== undefined || + semanticBindingAtRead(binding, read, context.state.parentsByNode) + .hasDefiniteInitializer); + const collectResponseConsumers = ( program: t.Program, context: TraversalContext, @@ -290,9 +308,7 @@ const collectResponseConsumers = ( ) ? linked[0] : undefined; - // Any other write (assignment, update, loop or destructuring target) can - // replace the response before this read, so its endpoint stays unknown. - const soleWrite = binding?.initializers.length === 1; + const soleWrite = soleWriteReachesRead(binding, member.object, context); output.push({ requestId: `response:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind: "response-consumer",