From 0323ce0a287ceead80cb83afc1e96c3f125bb82b Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 21:39:26 -0700 Subject: [PATCH] fix(javascript): keep the request endpoint on a uniquely consumed response (#3822) * fix(javascript): keep the request endpoint on a uniquely consumed response collectResponseConsumers always produced response-consumer operations with endpoint null, so a json()/text() consumer that resolved to exactly one request lost the request's literal endpoint in the semantic graph. When the consumer resolves to one request through that request's own result binding, the same condition that already makes it complete, it now carries that request's endpoint. Ambiguous consumers stay null. Fixes #3328 Co-Authored-By: Claude Opus 5.5 * fix(javascript): drop the endpoint when another write can replace the response Copy the request's endpoint only when the request is the binding's sole write; any other assignment, update, loop or destructuring target can replace the response before the consumer reads it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...emanticAnalysis.response_endpoints.test.ts | 59 +++++++++++++++++++ .../javascriptSemanticDataEffects.ts | 22 ++++--- 2 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts new file mode 100644 index 0000000000..425653e6f5 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const responses = (source: string) => + analyzeJavaScriptSemantics(source) + .requestOperations.filter(({ kind }) => kind === "response-consumer") + .map(({ method, endpoint, resolution }) => ({ + method, + endpoint, + resolution, + })); + +it("keeps the endpoint of a uniquely consumed request response", () => { + expect( + responses(` + async function load() { + const a = await fetch("https://example.test/a"); + const b = await fetch("https://example.test/b"); + return [await a.json(), await b.text()]; + } + `), + ).toEqual([ + { + method: "json", + endpoint: "https://example.test/a", + resolution: "complete", + }, + { + method: "text", + endpoint: "https://example.test/b", + resolution: "complete", + }, + ]); +}); + +it("leaves an ambiguous response consumer without an endpoint", () => { + expect( + responses(` + async function load(flag) { + let response = await fetch("https://example.test/a"); + if (flag) response = await fetch("https://example.test/b"); + return response.json(); + } + `), + ).toEqual([{ method: "json", endpoint: null, resolution: "partial" }]); +}); + +it("leaves a response replaced by a non-request value without an endpoint", () => { + expect( + responses(` + async function load(cachedResponse) { + let response = await fetch("https://example.test/a"); + response = cachedResponse; + return response.json(); + } + `), + ).toEqual([expect.objectContaining({ method: "json", endpoint: null })]); +}); diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index c6d47dfd21..6d1de8325e 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -270,6 +270,18 @@ const collectResponseConsumers = ( ? [] : (requestsByBinding.get(binding.bindingId) ?? []); if (linked.length === 0) return; + // A consumer of exactly one request through its own result binding + // reads that request's response, so it keeps the request's endpoint. + const uniqueRequest = + linked.length === 1 && + !linked[0]?.operation.projectedResultBindings.some( + ({ bindingId }) => bindingId === binding?.bindingId, + ) + ? 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; output.push({ requestId: `response:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind: "response-consumer", @@ -287,15 +299,9 @@ const collectResponseConsumers = ( linkedRequestIds: linked .map(({ operation }) => operation.requestId) .sort(compareUnicodeCodePoints), - endpoint: null, + endpoint: soleWrite ? (uniqueRequest?.operation.endpoint ?? null) : null, fields: [], - resolution: - linked.length === 1 && - !linked[0]?.operation.projectedResultBindings.some( - ({ bindingId }) => bindingId === binding?.bindingId, - ) - ? "complete" - : "partial", + resolution: uniqueRequest === undefined ? "partial" : "complete", }); }); return output;