Files
reverse-engineering-anything/src/domain/javascript/javascriptSemanticDataEffects.ts
T
Kaoru0822andClaude Opus 5.5 0323ce0a28 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>
2026-10-14 21:39:26 -07:00

430 lines
15 KiB
TypeScript

import * as t from "@babel/types";
import type {
JavaScriptSemanticBoundaryOperation,
JavaScriptSemanticCallable,
JavaScriptSemanticConfigurationOperation,
JavaScriptSemanticRequestOperation,
} from "./javascriptSemanticIr.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import {
semanticStaticPropertyKey,
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import {
builtinDataEffectMethod as builtinMethod,
dataEffectArgumentBindingId as argumentBindingId,
dataEffectLiteralString as literalString,
dataEffectMemberCallee as memberCallee,
dataEffectPrimitive as primitiveValue,
isSemanticProcessObject as isProcessObject,
outerDataEffectBinding as outerAssignedBinding,
assignedSemanticResultBindings,
semanticProjectedBindingFacts,
traverseDataEffects as traverseWithContext,
type DataEffectTraversalContext as TraversalContext,
} from "./javascriptSemanticDataEffectHelpers.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
interface DataEffectAnalysis {
readonly configurationOperations: readonly JavaScriptSemanticConfigurationOperation[];
readonly requestOperations: readonly JavaScriptSemanticRequestOperation[];
readonly boundaryOperations: readonly JavaScriptSemanticBoundaryOperation[];
}
interface ConfigurationCandidate {
readonly node: t.Node;
readonly operation: JavaScriptSemanticConfigurationOperation;
}
interface RequestCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression | t.NewExpression;
readonly operation: JavaScriptSemanticRequestOperation;
}
/** Recover configuration, request, and boundary candidates from inert syntax. */
export const collectJavaScriptSemanticDataEffects = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): DataEffectAnalysis => {
const context: TraversalContext = {
state,
callableIds: new Set(callables.map(({ callableId }) => callableId)),
callableStack: [],
ancestors: [],
};
const configuration = collectConfigurations(program, context);
const requests = collectRequests(program, context);
return {
configurationOperations: [
...configuration.map(({ operation }) => operation),
...collectDefaults(program, context, configuration),
],
requestOperations: [
...requests.map(({ operation }) => operation),
...collectResponseConsumers(program, context, requests),
],
boundaryOperations: collectBoundaries(program, context),
};
};
const collectConfigurations = (
program: t.Program,
context: TraversalContext,
): ConfigurationCandidate[] => {
const output: ConfigurationCandidate[] = [];
traverseWithContext(program, context, (node) => {
const details = configurationDetails(node, context);
if (details === null) return;
const operation: JavaScriptSemanticConfigurationOperation = {
configId: `config:${details.kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: details.kind,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
key: details.key,
sourceConfigId: null,
value: null,
resolution: details.resolution,
};
output.push({ node, operation });
});
return output;
};
const configurationDetails = (
node: t.Node,
context: TraversalContext,
): {
readonly kind: "environment" | "argv" | "file";
readonly key: string | null;
readonly resolution: "complete" | "partial";
} | null => {
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
const processMember = processConfigurationMember(node, context.state);
if (processMember !== null) return processMember;
}
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return null;
const fileMethod = builtinMethod(node.callee, context.state, [
"fs",
"node:fs",
]);
return fileMethod === "readFile" || fileMethod === "readFileSync"
? {
kind: "file",
key: literalString(node.arguments[0]),
resolution:
literalString(node.arguments[0]) === null ? "partial" : "complete",
}
: null;
};
const processConfigurationMember = (
node: t.MemberExpression | t.OptionalMemberExpression,
state: JavaScriptSemanticAnalysisState,
): {
readonly kind: "environment" | "argv";
readonly key: string | null;
readonly resolution: "complete" | "partial";
} | null => {
const object = node.object;
if (!t.isMemberExpression(object) && !t.isOptionalMemberExpression(object))
return null;
const family = semanticStaticPropertyName(object.property, object.computed);
if (family !== "env" && family !== "argv") return null;
// `process.env[""]` names an exact (empty) key; only a dynamic key is unknown.
const key = semanticStaticPropertyKey(node.property, node.computed);
if (family === "argv" && key !== null && !isArrayIndexKey(key)) return null;
if (!isProcessObject(object.object, state, family, key)) return null;
return {
kind: family === "env" ? "environment" : "argv",
key,
resolution: key === null ? "partial" : "complete",
};
};
const isArrayIndexKey = (key: string): boolean => {
if (!/^(?:0|[1-9][0-9]*)$/u.test(key)) return false;
const index = Number(key);
return Number.isSafeInteger(index) && index < 2 ** 32 - 1;
};
const collectDefaults = (
program: t.Program,
context: TraversalContext,
configurations: readonly ConfigurationCandidate[],
): JavaScriptSemanticConfigurationOperation[] => {
const output: JavaScriptSemanticConfigurationOperation[] = [];
const configurationsByNode = new Map(
configurations.map((candidate) => [candidate.node, candidate]),
);
traverseWithContext(program, context, (node) => {
if (
!t.isLogicalExpression(node) ||
(node.operator !== "??" && node.operator !== "||")
)
return;
const source = unwrapJavaScriptExpression(node.left).node;
const candidate = configurationsByNode.get(source);
if (candidate === undefined) return;
output.push({
configId: `config:default:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "default",
location: range(node.right),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
key: candidate.operation.key,
sourceConfigId: candidate.operation.configId,
value: primitiveValue(node.right),
resolution: primitiveValue(node.right) === null ? "partial" : "complete",
});
});
return output;
};
const collectRequests = (
program: t.Program,
context: TraversalContext,
): RequestCandidate[] => {
const output: RequestCandidate[] = [];
traverseWithContext(program, context, (node) => {
if (
!t.isCallExpression(node) &&
!t.isOptionalCallExpression(node) &&
!t.isNewExpression(node)
)
return;
const method = requestMethod(node, context.state);
if (method === null) return;
const endpoint = requestEndpoint(node);
output.push({
node,
operation: {
requestId: `request:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "request",
method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
projectedResultBindings: semanticProjectedBindingFacts(
assignedSemanticResultBindings(
node,
context.ancestors,
context.state,
).filter(({ projectionPath }) => projectionPath.length > 0),
),
linkedRequestIds: [],
endpoint,
fields: requestFields(node, method, context.state),
resolution: endpoint === null ? "partial" : "complete",
},
});
});
return output;
};
const collectResponseConsumers = (
program: t.Program,
context: TraversalContext,
requests: readonly RequestCandidate[],
): JavaScriptSemanticRequestOperation[] => {
const requestsByBinding = new Map<string, RequestCandidate[]>();
for (const request of requests) {
const bindingIds = [
...(request.operation.resultBindingId === null
? []
: [request.operation.resultBindingId]),
...request.operation.projectedResultBindings.map(
({ bindingId }) => bindingId,
),
];
for (const bindingId of bindingIds) {
const existing = requestsByBinding.get(bindingId) ?? [];
existing.push(request);
requestsByBinding.set(bindingId, existing);
}
}
const output: JavaScriptSemanticRequestOperation[] = [];
traverseWithContext(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const member = memberCallee(node);
if (member === null || !t.isIdentifier(member.object)) return;
const method = semanticStaticPropertyName(member.property, member.computed);
if (method !== "json" && method !== "text" && method !== "arrayBuffer")
return;
const binding = resolveSemanticBindingState(
context.state,
member.object,
member.object.name,
);
const linked =
binding === undefined
? []
: (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",
method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerAssignedBinding(node, context),
projectedResultBindings: semanticProjectedBindingFacts(
assignedSemanticResultBindings(
node,
context.ancestors,
context.state,
).filter(({ projectionPath }) => projectionPath.length > 0),
),
linkedRequestIds: linked
.map(({ operation }) => operation.requestId)
.sort(compareUnicodeCodePoints),
endpoint: soleWrite ? (uniqueRequest?.operation.endpoint ?? null) : null,
fields: [],
resolution: uniqueRequest === undefined ? "partial" : "complete",
});
});
return output;
};
const collectBoundaries = (
program: t.Program,
context: TraversalContext,
): JavaScriptSemanticBoundaryOperation[] => {
const output: JavaScriptSemanticBoundaryOperation[] = [];
traverseWithContext(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const details = boundaryDetails(node, context.state);
if (details === null) return;
output.push({
boundaryId: `boundary:${details.kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: details.kind,
method: details.method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
sourceBindingId: argumentBindingId(node.arguments[0], context.state),
resultBindingId: outerAssignedBinding(node, context),
resolution: details.resolution,
});
});
return output;
};
const boundaryDetails = (
node: t.CallExpression | t.OptionalCallExpression,
state: JavaScriptSemanticAnalysisState,
): {
readonly kind: JavaScriptSemanticBoundaryOperation["kind"];
readonly method: string;
readonly resolution: "complete" | "partial";
} | null => {
if (t.isIdentifier(node.callee)) {
const name = node.callee.name;
const unbound =
resolveSemanticBindingState(state, node.callee, name) === undefined &&
!semanticResolutionBlocked(state, node.callee, name);
if (
unbound &&
["String", "Number", "Boolean", "parseInt", "parseFloat"].includes(name)
)
return { kind: "coerce", method: name, resolution: "complete" };
}
const member = memberCallee(node);
if (member === null) return null;
const method = semanticStaticPropertyName(member.property, member.computed);
if (
method === "parse" &&
t.isIdentifier(member.object, { name: "JSON" }) &&
resolveSemanticBindingState(state, member.object, "JSON") === undefined &&
!semanticResolutionBlocked(state, member.object, "JSON")
)
return { kind: "parse", method: "JSON.parse", resolution: "complete" };
if (method === "parse" || method === "safeParse")
return { kind: "parse", method, resolution: "partial" };
if (method === "validate" || method === "assert" || method === "is")
return { kind: "validate", method, resolution: "partial" };
return null;
};
const requestMethod = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticRequestOperation["method"] | null => {
if (t.isIdentifier(node.callee)) {
const name = node.callee.name;
const unbound =
resolveSemanticBindingState(state, node.callee, name) === undefined &&
!semanticResolutionBlocked(state, node.callee, name);
if (!t.isNewExpression(node) && unbound && name === "fetch") return "fetch";
if (t.isNewExpression(node) && unbound && name === "WebSocket")
return "WebSocket";
}
const method = builtinMethod(node.callee, state, [
"http",
"https",
"node:http",
"node:https",
]);
return method === "request" || method === "get" ? method : null;
};
const requestFields = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
method: JavaScriptSemanticRequestOperation["method"],
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticRequestOperation["fields"] => {
const candidate =
method === "fetch" || method === "WebSocket"
? node.arguments[1]
: t.isObjectExpression(node.arguments[0])
? node.arguments[0]
: node.arguments[1];
if (!t.isObjectExpression(candidate)) return [];
return candidate.properties.flatMap((property) => {
if (!t.isObjectProperty(property)) return [];
// `{[key]: 1}` sends no knowable field name; reporting `key` would invent
// a request field that may not exist.
const name = semanticStaticPropertyKey(property.key, property.computed);
if (name === null) return [];
return [
{
name,
sourceBindingId: t.isIdentifier(property.value)
? (resolveSemanticBindingState(
state,
property.value,
property.value.name,
)?.bindingId ?? null)
: null,
},
];
});
};
const requestEndpoint = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
): string | null =>
t.isObjectExpression(node.arguments[0])
? null
: literalString(node.arguments[0]);