diff --git a/src/domain/javascript/javascriptSemanticAnalysis.resource_handles.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.resource_handles.test.ts new file mode 100644 index 0000000000..2b0d5ea098 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.resource_handles.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const releases = (source: string) => + analyzeJavaScriptSemantics(source) + .resourceOperations.filter(({ kind }) => kind === "release") + .map(({ method, linkedResourceIds, resolution }) => ({ + method, + linked: linkedResourceIds.length, + resolution, + })); + +it.each([ + [ + "a declaration", + `import { createReadStream } from "node:fs"; + const stream = createReadStream("input.txt");`, + ], + [ + "a later assignment", + `import { createReadStream } from "node:fs"; + let stream; + stream = createReadStream("input.txt");`, + ], + [ + "an awaited handle", + `import { open } from "node:fs/promises"; + const stream = await open("input.txt");`, + ], + [ + "a nullish placeholder before the acquisition", + `import { open } from "node:fs/promises"; + let stream = null; + try { stream = await open("input.txt"); } finally {}`, + ], +])( + "keeps a release complete when the handle holds only %s", + (_name, acquire) => { + expect(releases(`${acquire}\nstream.close();`)).toEqual([ + { method: "close", linked: 1, resolution: "complete" }, + ]); + }, +); + +it.each([ + ["an unconditional replacement", "stream = { destroy() {} };"], + ["a conditional replacement", "if (flag) stream = { destroy() {} };"], + ["a destructuring replacement", "({ stream } = other);"], +])("keeps a release partial after %s", (_name, replacement) => { + expect( + releases(` + import { createReadStream } from "node:fs"; + let stream = createReadStream("input.txt"); + ${replacement} + stream.destroy(); + `), + ).toEqual([{ method: "destroy", linked: 1, resolution: "partial" }]); +}); diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index 4cfd066e94..7ca9420076 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -8,7 +8,11 @@ import type { JavaScriptSemanticCallable, JavaScriptSemanticResourceOperation, } from "./javascriptSemanticIr.js"; -import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, @@ -100,21 +104,58 @@ const collectReleases = ( ); const linked = binding === undefined ? [] : (byBinding.get(binding.bindingId) ?? []); - if (linked.length === 0) return; + 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 ?? null, + resultBindingId: binding.bindingId, linkedResourceIds: linked.map(resourceCandidateId).sort(), - resolution: linked.length === 1 ? "complete" : "partial", + 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 => ({