Files
rea/src/domain/javascript/javascriptSemanticResources.ts
T
Kaoru0822 fc712b3750 fix(javascript): keep releases through a replaced handle as candidates (#3897)
A release call was linked to its acquisition through the receiver's lexical
binding alone, so `stream = { destroy() {} }; stream.destroy()` still
produced a resolved `releases` edge to the original stream and entered the
default ownership trace.

A release is now complete only when every value written to the handle
binding is the linked acquisition itself (through parentheses, TypeScript
wrappers or `await`) or a nullish placeholder such as `let h = null`, which
cannot be released. Any other write, conditional replacement, or
destructuring keeps the linked release as a candidate (partial).

Fixes #3313

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:40:14 -07:00

192 lines
6.5 KiB
TypeScript

import * as t from "@babel/types";
import {
BUILTIN_RESOURCE_SPECIFIERS,
BUILTIN_RESOURCE_METHODS,
} from "./javascriptBuiltinResourceMethods.js";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticResourceOperation,
} from "./javascriptSemanticIr.js";
import {
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import {
builtinDataEffectMethod,
dataEffectMemberCallee,
outerDataEffectBinding,
traverseDataEffects,
type DataEffectTraversalContext,
} from "./javascriptSemanticDataEffectHelpers.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
const RELEASE_METHODS = ["close", "destroy", "end"] as const;
interface ResourceCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly method: JavaScriptSemanticResourceOperation["method"];
readonly ownerCallableId: string | null;
readonly resultBindingId: string | null;
}
/** Recover built-in filesystem/network acquisition and local release handles. */
export const collectJavaScriptSemanticResources = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): JavaScriptSemanticResourceOperation[] => {
const context: DataEffectTraversalContext = {
state,
callableIds: new Set(callables.map(({ callableId }) => callableId)),
callableStack: [],
ancestors: [],
};
const acquisitions = collectAcquisitions(program, context);
return [
...acquisitions.map(immutableAcquisition),
...collectReleases(program, context, acquisitions),
];
};
const collectAcquisitions = (
program: t.Program,
context: DataEffectTraversalContext,
): ResourceCandidate[] => {
const output: ResourceCandidate[] = [];
traverseDataEffects(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const method = acquisitionMethod(node.callee, context.state);
if (method === null) return;
output.push({
node,
method,
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: outerDataEffectBinding(node, context),
});
});
return output;
};
const collectReleases = (
program: t.Program,
context: DataEffectTraversalContext,
acquisitions: readonly ResourceCandidate[],
): JavaScriptSemanticResourceOperation[] => {
const byBinding = new Map<string, ResourceCandidate[]>();
for (const acquisition of acquisitions) {
if (acquisition.resultBindingId === null) continue;
const existing = byBinding.get(acquisition.resultBindingId) ?? [];
existing.push(acquisition);
byBinding.set(acquisition.resultBindingId, existing);
}
const output: JavaScriptSemanticResourceOperation[] = [];
traverseDataEffects(program, context, (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const member = dataEffectMemberCallee(node);
if (member === null || !t.isIdentifier(member.object)) return;
const method = RELEASE_METHODS.find(
(candidate) =>
candidate ===
semanticStaticPropertyName(member.property, member.computed),
);
if (method === undefined) return;
const binding = resolveSemanticBindingState(
context.state,
member.object,
member.object.name,
);
const linked =
binding === undefined ? [] : (byBinding.get(binding.bindingId) ?? []);
if (binding === undefined || linked.length === 0) return;
output.push({
resourceId: `resource:release:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind: "release",
method,
location: range(node),
ownerCallableId: context.callableStack.at(-1) ?? null,
resultBindingId: binding.bindingId,
linkedResourceIds: linked.map(resourceCandidateId).sort(),
resolution:
linked.length === 1 &&
holdsOnlyAcquisitions(binding, linked, context.state)
? "complete"
: "partial",
});
});
return output;
};
/**
* A lexical handle identifies its acquisition only while every value written
* to the binding is that acquisition itself. Any replacement, even a
* conditional or later one, leaves the released resource a candidate. A
* nullish placeholder, as in `let h = null; try { h = open() } finally {
* h?.close() }`, releases nothing else: the call is skipped or throws.
*/
const holdsOnlyAcquisitions = (
binding: JavaScriptSemanticBindingState,
linked: readonly ResourceCandidate[],
state: JavaScriptSemanticAnalysisState,
): boolean =>
[...binding.initializers, ...binding.referenceInitializers].every(
({ node, projection }) => {
if (projection.length > 0) return false;
let value = unwrapJavaScriptExpression(node).node;
if (t.isAwaitExpression(value))
value = unwrapJavaScriptExpression(value.argument).node;
return (
isNullishPlaceholder(value, state) ||
linked.some((acquisition) => acquisition.node === value)
);
},
);
const isNullishPlaceholder = (
value: t.Node,
state: JavaScriptSemanticAnalysisState,
): boolean =>
t.isNullLiteral(value) ||
t.isUnaryExpression(value, { operator: "void" }) ||
isUnmodifiedGlobalReference(value, state, "undefined");
const immutableAcquisition = (
candidate: ResourceCandidate,
): JavaScriptSemanticResourceOperation => ({
resourceId: resourceCandidateId(candidate),
kind: "acquire",
method: candidate.method,
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
resultBindingId: candidate.resultBindingId,
linkedResourceIds: [],
resolution: "complete",
});
const acquisitionMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticResourceOperation["method"] | null => {
for (const specifier of BUILTIN_RESOURCE_SPECIFIERS) {
const method = builtinDataEffectMethod(callee, state, [specifier]);
if (
method !== null &&
(
BUILTIN_RESOURCE_METHODS[
specifier as keyof typeof BUILTIN_RESOURCE_METHODS
] as readonly string[]
).includes(method)
)
return method as JavaScriptSemanticResourceOperation["method"];
}
return null;
};
const resourceCandidateId = (candidate: ResourceCandidate): string =>
`resource:acquire:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;