mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
* 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>
430 lines
15 KiB
TypeScript
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]);
|