diff --git a/src/domain/javascript/javascriptSemanticAnalysis.response_body_readers.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.response_body_readers.test.ts new file mode 100644 index 0000000000..26ef076033 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.response_body_readers.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * Only `fetch` resolves to a `Response`, and only once its promise settles. + * `http.request`/`http.get` return a `ClientRequest` and `new WebSocket` a + * socket; neither exposes `json`/`text`/`arrayBuffer`, and an un-awaited + * `fetch` returns a `Promise`. In every one of those the call throws at the call + * site instead of reading a body, so it is not a response consumer. + */ +const consumersFor = (source: string) => + analyzeJavaScriptSemantics(source) + .requestOperations.filter(({ kind }) => kind === "response-consumer") + .map(({ method, endpoint, resolution }) => ({ + method, + endpoint, + resolution, + })); + +it.each([ + [ + "an un-awaited fetch", + `async function f(){ const res = fetch("https://api.test/x"); return res.json(); }`, + ], + [ + "an http.get request", + `import http from "node:http"; + function f(){ const req = http.get("https://api.test/x"); return req.json(); }`, + ], + [ + "an http.request request", + `import http from "node:http"; + function f(){ const req = http.request("https://api.test/x"); return req.text(); }`, + ], + [ + "a WebSocket handshake", + `function f(){ const ws = new WebSocket("wss://api.test/x"); return ws.json(); }`, + ], +])("emits no response consumer for %s", (_name, source) => { + expect(consumersFor(source)).toEqual([]); +}); + +it.each([ + ["json", "json"], + ["text", "text"], + ["arrayBuffer", "arrayBuffer"], +] as const)("keeps the settled fetch body reader for %s", (_name, method) => { + expect( + consumersFor( + `async function f(){ const res = await fetch("https://api.test/x"); return res.${method}(); }`, + ), + ).toEqual([ + { + method, + endpoint: "https://api.test/x", + resolution: "complete", + }, + ]); +}); + +it("keeps a settled fetch reader through a wrapper expression", () => { + expect( + consumersFor( + `async function f(){ const res = await fetch("https://api.test/x"); return (res).json(); }`, + ), + ).toHaveLength(1); +}); + +it("keeps the fetch candidate when the binding also holds another request", () => { + // The mixed binding keeps its fetch link, so the consumer stays a candidate + // rather than losing the endpoint entirely. + const consumers = consumersFor(` + import http from "node:http"; + async function f(){ + let res = await fetch("https://api.test/x"); + res = await http.get("https://api.test/y"); + return res.json(); + } + `); + expect(consumers).toEqual([ + { method: "json", endpoint: null, resolution: "partial" }, + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 7e8e8163b3..4b8385f53a 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -8,6 +8,7 @@ import type { } from "./javascriptSemanticIr.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { + javaScriptExpressionWrapperTarget, readExactJavaScriptLiteral, semanticStaticPropertyKey, semanticStaticPropertyName, @@ -304,6 +305,28 @@ const soleWriteReachesRead = ( ); }; +/** + * Only `fetch` resolves to a `Response`, and only once its promise settles. + * `http.request`/`http.get` return a `ClientRequest` and `new WebSocket` a + * socket, neither of which has `json`/`text`/`arrayBuffer`; an un-awaited + * `fetch` returns a `Promise`. In all of those the call throws at the call site + * instead of reading a body, so it is not a response consumer at all. + */ +const settlesToResponse = ( + request: RequestCandidate, + context: TraversalContext, +): boolean => { + if (request.operation.method !== "fetch") return false; + let node: t.Node = request.node; + for (;;) { + const parent = context.state.parentsByNode.get(node); + if (parent === undefined) return false; + if (t.isAwaitExpression(parent)) return true; + if (javaScriptExpressionWrapperTarget(parent) !== node) return false; + node = parent; + } +}; + const collectResponseConsumers = ( program: t.Program, context: TraversalContext, @@ -338,11 +361,20 @@ const collectResponseConsumers = ( member.object, member.object.name, ); - const linked = + const candidates = binding === undefined ? [] : (requestsByBinding.get(binding.bindingId) ?? []); + // A body reader only exists on a settled fetch Response. Requests of any + // other kind throw at this call site instead, so they link nothing; if no + // candidate survives, the call is not a response consumer at all. + const linked = candidates.filter((request) => + settlesToResponse(request, context), + ); if (linked.length === 0) return; + // A binding that also receives a request kind without a body reader leaves + // this reader a candidate, not a proven consumer of the fetch response. + const otherKinds = candidates.length !== linked.length; // 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 = @@ -377,7 +409,8 @@ const collectResponseConsumers = ( .sort(compareUnicodeCodePoints), endpoint: soleWrite ? (uniqueRequest?.operation.endpoint ?? null) : null, fields: [], - resolution: uniqueRequest === undefined ? "partial" : "complete", + resolution: + uniqueRequest === undefined || otherKinds ? "partial" : "complete", }); }); return output;