From 25b69bb803ffe337cca705629ec88cd220ca09a7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:24:19 +0800 Subject: [PATCH] fix(javascript): qualify network receiver evidence --- .../javascript/javascriptNetworkRequests.ts | 115 ++++++++++++++++++ .../javascript/javascriptStaticAnalysis.ts | 2 + ...scriptStaticAnalysisCalleeBoundary.test.ts | 4 +- .../javascriptStaticAnalysisCalls.ts | 32 ++--- .../javascriptStaticAnalysisHelpers.ts | 25 +--- .../javascriptStaticAnalysisState.ts | 1 + src/domain/webBundleAnalyzer.test.ts | 98 ++++++++++++--- src/domain/webBundleAnalyzerInspection.ts | 37 +++++- .../cli/javascriptRendererEndpoints.test.ts | 69 +++++++++++ 9 files changed, 323 insertions(+), 60 deletions(-) create mode 100644 src/domain/javascript/javascriptNetworkRequests.ts diff --git a/src/domain/javascript/javascriptNetworkRequests.ts b/src/domain/javascript/javascriptNetworkRequests.ts new file mode 100644 index 0000000000..efc897f714 --- /dev/null +++ b/src/domain/javascript/javascriptNetworkRequests.ts @@ -0,0 +1,115 @@ +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { semanticOpaqueGlobalMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptBindingIndex, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import * as t from "@babel/types"; +import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; + +export const isFetchCall = ( + node: t.Node, + bindings: JavaScriptSemanticAnalysisState, +): boolean => + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + isUnmodifiedGlobalReference(node.callee, bindings, "fetch"); + +/** Browser request identity requires an intact intrinsic, not a method spelling. */ +export const isBrowserNetworkRequest = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + bindings: JavaScriptSemanticAnalysisState, +): boolean => + isFetchCall(node, bindings) || + (t.isNewExpression(node) && + (isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") || + isUnmodifiedGlobalReference(node.callee, bindings, "EventSource"))) || + ((t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + isUnmodifiedGlobalReference(node.callee, bindings, [ + "navigator", + "sendBeacon", + ])); + +const isXhrOpenRequest = ( + node: t.Node, + bindings: JavaScriptSemanticAnalysisState, + index?: Pick, +): boolean | undefined => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return false; + const callee = unwrapJavaScriptExpression(node.callee).node; + if ( + (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) || + semanticStaticPropertyName(callee.property, callee.computed) !== "open" + ) + return false; + if ( + ["XMLHttpRequest", "Object"].some((owner) => + ["open", "__proto__"].some( + (member) => + bindings.ambientWrites.covers([ + "global", + owner, + "prototype", + member, + ]) || + bindings.ambientEscapes.covers([ + "global", + owner, + "prototype", + member, + ]), + ), + ) + ) + return false; + let receiver = unwrapJavaScriptExpression(callee.object).node; + const seen = new Set(); + while (t.isIdentifier(receiver)) { + if ( + index !== undefined && + (index.referenceMayHaveChangedAt(node, receiver, ["open"]) || + index.referenceMayHaveChangedAt(node, receiver, ["__proto__"])) + ) + return false; + const binding = resolveSemanticBindingState( + bindings, + receiver, + receiver.name, + ); + if (binding === undefined || seen.has(binding.bindingId)) return false; + seen.add(binding.bindingId); + const initializer = semanticStableInitializer(binding, bindings); + if ( + initializer === undefined || + initializer.projection.length !== 0 || + !semanticBindingAtRead(binding, receiver, bindings.parentsByNode) + .hasDefiniteInitializer + ) + return false; + receiver = unwrapJavaScriptExpression(initializer.node).node; + } + if ( + !t.isNewExpression(receiver) || + !isUnmodifiedGlobalReference(receiver.callee, bindings, "XMLHttpRequest") || + semanticOpaqueGlobalMayHaveChanged(callee, bindings) + ) + return false; + return index === undefined && seen.size > 0 ? undefined : true; +}; + +/** Resolve once through the shared mutation index when lexical proof needs it. */ +export const createXhrOpenRequestQuery = + (index: JavaScriptBindingIndex): ((node: t.Node) => boolean) => + (node) => { + const proof = isXhrOpenRequest(node, index.bindings); + if (proof !== undefined) return proof; + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return isXhrOpenRequest(node, index.bindings, index) === true; + }; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 879caf5ea5..1e9598f814 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -1,3 +1,4 @@ +import { createXhrOpenRequestQuery } from "./javascriptNetworkRequests.js"; import { uniqueSortedBy } from "../canonicalOrdering.js"; import * as t from "@babel/types"; import { JavaScriptRouteScan } from "./javascriptRouteScan.js"; @@ -114,6 +115,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( bindings, ); const findings: JavaScriptFindingContext = { + xhrOpenRequest: createXhrOpenRequestQuery(index), pathIntrinsics: createStaticPathIntrinsicQuery(index), browserWindowReceiver: (node, site, suffix) => resolveElectronBrowserWindowReceiver(node, site, suffix, findings, index), diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index 282a7fa934..dee550ee66 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -357,8 +357,8 @@ it.each(expressionWrappers)( ) .toEqual([ ["local-window", "candidate"], - ["/local-db", null], - ["/local-cache", null], + ["/local-db", "candidate"], + ["/local-cache", "candidate"], ["changed-window", "candidate"], ["uncertain-window", "candidate"], ]); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index dbc90e9d58..b7dbccda5d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -1,3 +1,7 @@ +import { + isBrowserNetworkRequest, + isFetchCall, +} from "./javascriptNetworkRequests.js"; import * as t from "@babel/types"; import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; @@ -491,13 +495,6 @@ export const indexVitePreloadDependencyTable = ( return escapedOrMutated ? dependencies.map(() => undefined) : dependencies; }; -const isFetchCall = ( - node: t.Node, - bindings: JavaScriptSemanticAnalysisState, -): boolean => - (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && - isUnmodifiedGlobalReference(node.callee, bindings, "fetch"); - const requestConstructorUrl = ( node: t.Node, bindings: JavaScriptSemanticAnalysisState, @@ -591,7 +588,10 @@ const inspectLiteralEndpointCall = ( kind: "network", value: endpoint.value, mechanism: `call:${name}`, - ...(endpoint.kind === "candidate" ? { interpretation: "candidate" } : {}), + ...(endpoint.kind === "candidate" && + context.xhrOpenRequest?.(node) !== true + ? { interpretation: "candidate" } + : {}), }); }; @@ -611,7 +611,7 @@ const isKnownNetworkRequest = ( bindings: JavaScriptSemanticAnalysisState, context: FindingContext, ): boolean => { - if (isFetchCall(node, bindings)) return true; + if (isBrowserNetworkRequest(node, bindings)) return true; const callee = unwrapJavaScriptExpression(node.callee).node; if ( (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && @@ -620,19 +620,7 @@ const isKnownNetworkRequest = ( context.rendererLoad?.(node.callee, node) === true ) return true; - if ( - t.isNewExpression(node) && - (isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") || - isUnmodifiedGlobalReference(node.callee, bindings, "EventSource")) - ) - return true; - return ( - (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && - isUnmodifiedGlobalReference(node.callee, bindings, [ - "navigator", - "sendBeacon", - ]) - ); + return false; }; /** Finish queued targets after shared member-mutation facts are indexed. */ diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 0ee4859981..5322a7ebeb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -675,10 +675,10 @@ export const endpointArgument = ( callee: t.Node, receiverFact?: JavaScriptOpenReceiverFact, ): JavaScriptEndpointArgument | undefined => { - const literal = ( + const candidate = ( value: string | undefined, ): JavaScriptEndpointArgument | undefined => - value === undefined ? undefined : { kind: "endpoint", value }; + value === undefined ? undefined : { kind: "candidate", value }; if (receiverFact === "window") return undefined; if ( name === "fetch" || @@ -687,17 +687,11 @@ export const endpointArgument = ( name === "EventSource" || name.endsWith(".EventSource") ) - return literal(stringValue(args[0])); + return candidate(stringValue(args[0])); if (name.endsWith(".open")) { const value = xhrOpenUrl(name, args[0], args[1], receiverFact); if (value === undefined) return undefined; - return { - kind: - stringValue(args[0]) !== undefined || isRequestUrlReference(value) - ? "endpoint" - : "candidate", - value, - }; + return { kind: "candidate", value }; } const method = ["get", "post", "put", "patch", "delete", "request"].find( (candidate) => name === candidate || name.endsWith(`.${candidate}`), @@ -717,17 +711,10 @@ export const endpointArgument = ( receiverFact === "extension-state"))) ) return undefined; - return { - kind: - method !== "get" && method !== "delete" && isRequestUrlReference(value) - ? "endpoint" - : "candidate", - value, - }; + return { kind: "candidate", value }; } - // Beacon URLs are endpoint observations, retaining the upstream API support. if (name === "sendBeacon" || name.endsWith(".sendBeacon")) - return literal(stringValue(args[0])); + return candidate(stringValue(args[0])); if (name === "loadURL" || name.endsWith(".loadURL")) { const value = stringValue(args[0]); return value === undefined ? undefined : { kind: "candidate", value }; diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index e8b6b9bc93..6a149cb3fc 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -110,6 +110,7 @@ export interface JavaScriptFindingContext { ) => | { readonly owner: t.NewExpression; readonly path: readonly string[] } | undefined; + readonly xhrOpenRequest?: (node: t.Node) => boolean; readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean; readonly rpcScan: JavaScriptRpcScan; /** Shared bounded argv/nullish argument shapes for optional-slot signatures. */ diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index f08d7ae758..58fe478171 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -585,12 +585,17 @@ describe("web bundle source-map coverage", () => { }); describe("web bundle static-analysis parity", () => { - it("recognizes endpoints through nested dynamic receivers like static analysis", () => { + it("qualifies network spellings through nested dynamic receivers", () => { const result = analyzeWebBundleFixture( inspection('window[recv].fetch("/nested-fetch");'), ); - expect(result.observations.endpoints).toContainEqual( - expect.objectContaining({ value: "/nested-fetch" }), + expect(result.observations.endpoints).toEqual([]); + expect(result.inferences).toContainEqual( + expect.objectContaining({ + kind: "endpoint_candidate", + value: "/nested-fetch", + confidence: "low", + }), ); }); @@ -628,11 +633,17 @@ describe("web bundle static-analysis parity", () => { ); expect( result.observations.endpoints.map(({ value }) => value).sort(), + ).toEqual(["/shadowed-parent-xhr"]); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value) + .sort(), ).toEqual([ "/dav/", "/dynamic-method", - "/shadowed-parent-xhr", "/xhr-get", + "api/relative", "https://xhr.example.test/submit", ]); expect(result.inferences).toContainEqual( @@ -654,9 +665,9 @@ describe("web bundle API classification parity", () => { indexedDB.open("GET", "/storage-named-xhr"); `; expect( - analyzeWebBundleFixture(inspection(source)).observations.endpoints.map( - ({ value }) => value, - ), + analyzeWebBundleFixture(inspection(source)) + .inferences.filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value), ).toEqual(["/storage-named-xhr"]); const analysis = analyzeJavaScriptStaticSource(source); expect(analysis.endpoints.map(({ value }) => value)).toEqual([ @@ -686,7 +697,7 @@ describe("web bundle API classification parity", () => { ); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual(["/api", "/dav"]); + ).toEqual(["/api"]); }); it("classifies optional and one-hop aliased open calls", () => { @@ -733,7 +744,7 @@ describe("web bundle API classification parity", () => { ); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual(["/search", "/v1/headers"]); + ).toEqual([]); expect( result.inferences .filter(({ kind }) => kind === "endpoint_candidate") @@ -741,7 +752,9 @@ describe("web bundle API classification parity", () => { .sort(), ).toEqual([ "../search", + "/search", "/users", + "/v1/headers", "cache-key", "https://api.example.test/headers", "https://api.example.test/users", @@ -839,13 +852,12 @@ describe("web bundle explicit request reference forms", () => { caches.open(name, "#cache-name"); fs.open(path, "r"); `; - const expected = [ + const expected: string[] = []; + const expectedCandidates = [ "?view=1", "#view=1", "#literal=1", "https:api.example.test/xhr", - ].sort(); - const expectedCandidates = [ ...references, ...ambiguous, "POST", @@ -894,7 +906,7 @@ describe("web bundle endpoint candidate classification", () => { const result = analyzeWebBundleFixture(inspection(source)); expect( result.observations.endpoints.map(({ value }) => value).sort(), - ).toEqual(["login", "ping"]); + ).toEqual(["login"]); const candidates = result.inferences.filter( ({ kind }) => kind === "endpoint_candidate", ); @@ -905,6 +917,7 @@ describe("web bundle endpoint candidate classification", () => { "X", "login", "passwordControl", + "ping", "token", "users", ]); @@ -1857,7 +1870,6 @@ it("retains direct and literal computed call names", () => { expect(result.observations.endpoints.map(({ value }) => value)).toEqual([ "/direct", "/literal", - "/nested-receiver", ]); expect( result.observations.chunks.edges.map(({ specifier }) => specifier), @@ -2113,3 +2125,61 @@ it("retains captured Next script identity while deriving decoded route patterns" ]), ); }); + +it("qualifies local browser API spellings while preserving intrinsic requests", () => { + const result = analyzeWebBundleFixture( + inspection(` + const local = { fetch(x) { return x; }, sendBeacon(x) { return x; }, EventSource(x) { return x; } }; + local.fetch('/local-fetch'); local.sendBeacon('/local-beacon'); local.EventSource('/local-events'); + const other = { open(a,b) { return b; }, post(x) { return x; }, put(x) { return x; }, patch(x) { return x; }, request(x) { return x; } }; + other.open('GET', '/local-open'); other.post('/local-post'); other.put('/local-put'); other.patch('/local-patch'); other.request('/local-request'); + fetch('/intrinsic-fetch'); navigator.sendBeacon('/intrinsic-beacon'); new EventSource('/intrinsic-events'); + `), + ); + expect( + result.observations.endpoints.map(({ value }) => value).sort(), + ).toEqual(["/intrinsic-beacon", "/intrinsic-events", "/intrinsic-fetch"]); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value) + .sort(), + ).toEqual([ + "/local-beacon", + "/local-events", + "/local-fetch", + "/local-open", + "/local-patch", + "/local-post", + "/local-put", + "/local-request", + ]); +}); + +it.each([ + ["", true], + ["xhr.open = replacement;", false], + ["XMLHttpRequest.prototype.open = replacement;", false], + ["const alias = xhr; alias.open = replacement;", false], + ["mutate(xhr);", false], +])( + "preserves XHR receiver proof across visible mutation: %s", + (mutation, observed) => { + const source = `const xhr = new XMLHttpRequest(); ${mutation} xhr.open('GET', '/xhr');`; + const result = analyzeWebBundleFixture(inspection(source)); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual( + observed ? ["/xhr"] : [], + ); + expect( + result.inferences + .filter(({ kind }) => kind === "endpoint_candidate") + .map(({ value }) => value), + ).toEqual(observed ? [] : ["/xhr"]); + expect(analyzeJavaScriptStaticSource(source).endpoints).toContainEqual( + expect.objectContaining({ + value: "/xhr", + ...(observed ? {} : { interpretation: "candidate" }), + }), + ); + }, +); diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 7faeb18574..68ea3cb9af 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -1,3 +1,8 @@ +import { + isBrowserNetworkRequest, + createXhrOpenRequestQuery, +} from "./javascript/javascriptNetworkRequests.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascript/javascriptSemanticState.js"; import type { AnalysisAccumulator, IncludedScript, @@ -89,6 +94,7 @@ export const analyzeScript = ( } return isIntrinsicMapLookup(node, index.bindings, index) === true; }; + const isXhrRequest = createXhrOpenRequestQuery(index); const rpc = createJavaScriptRpcScan( script.source.artifact.text, receivers.rpcClientRoots, @@ -98,7 +104,15 @@ export const analyzeScript = ( enter: (node, parent) => { accumulator.visitedNodes += 1; routeScan.inspect(node, parent); - inspectNode(script, node, accumulator, openReceiverFacts, isMapLookup); + inspectNode( + script, + node, + accumulator, + openReceiverFacts, + isMapLookup, + index.bindings, + isXhrRequest, + ); const endpoint = rpc.inspect(node); if (endpoint !== undefined) addFinding({ @@ -134,6 +148,8 @@ const inspectNode = ( isMapLookup: ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, ) => boolean, + bindings: JavaScriptSemanticAnalysisState, + isXhrRequest: (node: t.Node) => boolean, ): void => { const moduleSource = semanticStaticModuleSource(node); if (moduleSource !== null) @@ -160,7 +176,15 @@ const inspectNode = ( t.isOptionalCallExpression(node) || t.isNewExpression(node) ) - inspectCall(script, node, accumulator, openReceiverFacts, isMapLookup); + inspectCall( + script, + node, + accumulator, + openReceiverFacts, + isMapLookup, + bindings, + isXhrRequest, + ); }; const inspectCall = ( @@ -171,6 +195,8 @@ const inspectCall = ( isMapLookup: ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, ) => boolean, + bindings: JavaScriptSemanticAnalysisState, + isXhrRequest: (node: t.Node) => boolean, ): void => { const name = calleeName(node.callee); const first = stringValue(node.arguments[0]); @@ -206,8 +232,13 @@ const inspectCall = ( node.callee, openReceiverFacts.get(node.start ?? -1), ); + const mapLookup = isMapLookup(node); + const knownRequest = + isBrowserNetworkRequest(node, bindings) || isXhrRequest(node); const networkEndpoint = - endpoint !== undefined && !isMapLookup(node) ? endpoint : undefined; + endpoint !== undefined && !mapLookup + ? { ...endpoint, kind: knownRequest ? "endpoint" : endpoint.kind } + : undefined; if (networkEndpoint?.kind === "candidate") addEndpointCandidate( script, diff --git a/tests/boundary/cli/javascriptRendererEndpoints.test.ts b/tests/boundary/cli/javascriptRendererEndpoints.test.ts index 02a50402e3..6441944e74 100644 --- a/tests/boundary/cli/javascriptRendererEndpoints.test.ts +++ b/tests/boundary/cli/javascriptRendererEndpoints.test.ts @@ -73,3 +73,72 @@ cliTest( }); }, ); + +cliTest( + "qualifies local browser request spellings through CLI Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-network-spellings-"); + await writeFixtureFiles(root, { + "index.js": ` + const local = { fetch(x) { return x; }, sendBeacon(x) { return x; }, EventSource(x) { return x; } }; + local.fetch('/local-fetch'); local.sendBeacon('/local-beacon'); local.EventSource('/local-events'); + const other = { open(a,b) { return b; }, post(x) { return x; }, put(x) { return x; }, patch(x) { return x; }, request(x) { return x; } }; + other.open('GET', '/local-open'); other.post('/local-post'); other.put('/local-put'); other.patch('/local-patch'); other.request('/local-request'); + fetch('/intrinsic-fetch'); navigator.sendBeacon('/intrinsic-beacon'); new EventSource('/intrinsic-events'); + `, + }); + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + expect(endpoints).toHaveLength(11); + for (const endpoint of endpoints) { + const observation = endpoint.observations[0]!; + const local = String(observation.properties.value).startsWith("/local-"); + expect(observation.evidence).toMatchObject( + local + ? { state: "inferred", confidence: "low" } + : { state: "observed", confidence: "exact" }, + ); + expect(observation.properties.interpretation).toBe( + local ? "candidate" : undefined, + ); + if (local) + expect( + result.graph.edges.find( + ({ target_node_id }) => target_node_id === endpoint.node_id, + )?.evidence, + ).toMatchObject({ state: "inferred", confidence: "low" }); + } + }, +); + +cliTest( + "preserves binding-backed XHR identity and qualifies replaced open methods", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-xhr-identity-"); + await writeFixtureFiles(root, { + "index.js": ` + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/intact-xhr'); + const replaced = new XMLHttpRequest(); + replaced.open = (method, value) => value; + replaced.open('GET', '/replaced-xhr'); + { const XMLHttpRequest = class {}; const local = new XMLHttpRequest(); local.open('GET', '/local-xhr'); } + `, + }); + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + const endpoints = result.graph.nodes.filter( + ({ kind }) => kind === "endpoint", + ); + expect(endpoints).toHaveLength(3); + for (const endpoint of endpoints) { + const observation = endpoint.observations[0]!; + expect(observation.evidence).toMatchObject( + observation.properties.value === "/intact-xhr" + ? { state: "observed", confidence: "exact" } + : { state: "inferred", confidence: "low" }, + ); + } + }, +);