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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-14 21:39:26 -07:00
committed by GitHub
parent 089ef732e8
commit 0323ce0a28
2 changed files with 73 additions and 8 deletions
@@ -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 })]);
});
@@ -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;