fix(javascript): own reference roles and invocation result identities

This commit is contained in:
morluto committed 2026-10-24 02:50:51 +08:00
1 parent 990d1de4d0
commit 965aff805d
8 files changed
+212 -72

No files matched your search

+12 -1
View File
@@ -447,11 +447,22 @@ ordinary destructuring can invoke an inherited getter. Operations requiring
primitive conversion conservatively expose their conversion operands, including
computed keys, arithmetic and untagged template interpolation. `using` and
`await using` expose the retained resource at its lexical scope exit; earlier
scalar reads retain their capture timing. These are source-derived effects,
scalar reads retain their capture timing. Repeated loop iterations can expose
a previous disposal and therefore retain uncertainty. Private getter fields
retain their parsed initializer references; `super` reads use the selected
receiver. Custom `instanceof` hooks expose their receiver and argument.
These are source-derived effects,
not execution results or proofs of accessor purity. Reflectively installed
descriptors remain opaque; their setup calls retain ordinary argument/receiver
escape uncertainty. External prototype changes and unsupported runtime protocols
remain unknown rather than supplying exact post-invocation values.
Identifier references and closure captures share Babel syntax-role facts plus
explicit binding/write roles; noncomputed declaration keys and import/meta names
are not lexical reads. Direct generator and async calls produce iterators and
Promises; body returns do not become direct call-result flows. Their unresolved
consumption remains an explicit frontier. Replaced emitter bindings select their
initializer lifetime at each operation. A lexical `this` owner remains partial
because it does not prove one runtime instance.
Nested callable returns are not assigned to their parent callable. Projected
graph observations carry source ranges but never source text.
@@ -0,0 +1,98 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
it("uses parser roles for TS, import, and meta names while retaining computed reads", () => {
const ir = analyzeJavaScriptSemantics(`
const field=1, method=2, enumKey=3, url=4, type=5, computed="slot";
interface Shape { field: number; method(): void; [computed]: number; }
enum Choice { enumKey = field }
const meta = import.meta.url;
import data from "./data.json" with { type: "json" };
const object = { [computed]: field }; object[computed];
`);
expect(ir.coverage.status).not.toBe("failed");
expect(
ir.references
.filter(({ name }) =>
["field", "method", "enumKey", "url", "type"].includes(name),
)
.map(({ name }) => name),
).toEqual(["field", "field"]);
expect(ir.references.filter(({ name }) => name === "computed")).toHaveLength(
3,
);
});
it("keeps destructuring writes, defaults and closure captures under one role owner", () => {
const ir = analyzeJavaScriptSemantics(`
let value=0; const source={value:1}; const fallback=2;
function change() { ({ value = fallback } = source); return value; }
`);
expect(
ir.references.filter(
({ name, role }) => name === "value" && role === "write",
),
).toHaveLength(1);
expect(ir.closureCaptures).toHaveLength(4);
});
it.each([
"function* wrapped(){ yield 1; return 2; }",
"async function wrapped(){ return 2; }",
"async function* wrapped(){ yield 1; return 2; }",
])(
"does not project wrapped body results into direct calls: %s",
(declaration) => {
const ir = analyzeJavaScriptSemantics(
`${declaration} const result=wrapped(); function ordinary(){return 3;} const direct=ordinary();`,
);
const wrapped = onlyCallable(ir, "wrapped");
const ordinary = onlyCallable(ir, "ordinary");
expect(
ir.callReturnFlows.filter(
({ callableId }) => callableId === wrapped.callableId,
),
).toEqual([]);
expect(
ir.callReturnFlows.filter(
({ callableId }) => callableId === ordinary.callableId,
),
).toHaveLength(1);
expect(
ir.frontiers.some(({ reason }) =>
reason.includes("body results require separate consumption"),
),
).toBe(true);
},
);
it("separates emitter binding lifetimes and retains stable emitter identity", () => {
const ir = analyzeJavaScriptSemantics(`
let bus = createEmitter(); bus.on("ready", handler); bus = createEmitter(); bus.emit("ready");
const stable = createEmitter(); stable.on("ready", handler); stable.emit("ready");
`);
expect(ir.eventOperations).toHaveLength(4);
expect(ir.eventOperations[0]?.emitterKey).not.toBe(
ir.eventOperations[1]?.emitterKey,
);
expect(ir.eventOperations[2]?.emitterKey).toBe(
ir.eventOperations[3]?.emitterKey,
);
expect(ir.eventOperations[2]?.resolution).toBe("complete");
});
it("does not claim one receiver instance for unrelated this expressions", () => {
const ir = analyzeJavaScriptSemantics(`
class First { start(){this.on("ready", handler);} }
class Second { finish(){this.emit("ready");} }
`);
expect(ir.eventOperations).toHaveLength(2);
expect(ir.eventOperations[0]?.emitterKey).not.toBe(
ir.eventOperations[1]?.emitterKey,
);
expect(ir.eventOperations.map(({ resolution }) => resolution)).toEqual([
"partial",
"partial",
]);
});
@@ -1438,7 +1438,11 @@ function* indexPrototypeUncertaintySteps(
parent.arguments.includes(node as t.Expression)
)
return;
if (t.isIdentifier(node) && semanticReferenceRole(node, parent) === null)
if (
t.isIdentifier(node) &&
semanticReferenceRole(node, parent, state.parentsByNode.get(parent)) ===
null
)
return;
taint(owner);
},
@@ -190,20 +190,53 @@ const emitterIdentity = (
} => {
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding !== undefined)
if (binding !== undefined) {
const read = semanticBindingAtRead(binding, node, state.parentsByNode);
const origins = [
...read.binding.initializers,
...read.binding.referenceInitializers,
];
const replaced = [
...binding.initializers,
...binding.referenceInitializers,
].some(({ assignment }) => assignment);
const origin = origins.length === 1 ? origins[0] : undefined;
const complete =
!replaced ||
(read.status === "ordered" &&
read.hasDefiniteInitializer &&
origin !== undefined);
return {
key: `binding:${binding.bindingId}`,
key: !replaced
? `binding:${binding.bindingId}`
: complete
? `binding:${binding.bindingId}:origin:${origin?.node.start ?? -1}:${origin?.node.end ?? -1}`
: `expression:${node.start ?? -1}:${node.end ?? -1}`,
bindingId: binding.bindingId,
complete: true,
complete,
};
}
return {
key: `global:${node.name}`,
bindingId: null,
complete: !semanticResolutionBlocked(state, node, node.name),
};
}
if (t.isThisExpression(node))
return { key: "this", bindingId: null, complete: true };
if (t.isThisExpression(node)) {
let owner = state.parentsByNode.get(node);
while (
owner !== undefined &&
!t.isClass(owner) &&
!(t.isFunction(owner) && !t.isArrowFunctionExpression(owner))
)
owner = state.parentsByNode.get(owner);
// Lexical receiver ownership does not establish one runtime instance.
return {
key: `this:${owner?.start ?? -1}:${owner?.end ?? -1}`,
bindingId: null,
complete: false,
};
}
return {
key: `expression:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
bindingId: null,
@@ -51,3 +51,12 @@ export const semanticCallableResultExpressions = (
}
return output;
};
/** A callable's direct invocation result differs from its body result syntax. */
export const semanticCallableResultKind = (
callable: t.Node,
): "value" | "promise" | "iterator" | "async-iterator" => {
if (!t.isFunction(callable)) return "value";
if (callable.generator) return callable.async ? "async-iterator" : "iterator";
return callable.async ? "promise" : "value";
};
@@ -9,7 +9,7 @@ import type {
JavaScriptSemanticClosureCapture,
JavaScriptSemanticFrontier,
} from "./javascriptSemanticIr.js";
import { semanticReferenceRole } from "./javascriptSemanticProjection.js";
import { semanticIdentifierRole } from "./javascriptSemanticProjection.js";
import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js";
import {
semanticStaticPropertyKey,
@@ -28,6 +28,7 @@ import {
} from "./javascriptSemanticCallResolution.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { semanticCallableResultKind } from "./javascriptSemanticCallableResults.js";
import { assignedSemanticResultBindings } from "./javascriptSemanticDataEffectHelpers.js";
/** Local call, flow, capture, and unsupported-frontier facts. */
@@ -293,6 +294,22 @@ const collectReturnFlows = (
for (const callableId of site.calleeCallableIds) {
const callable = callableById.get(callableId);
if (callable === undefined) continue;
const source = state.callableNodesById.get(callableId);
const kind =
source === undefined ? "value" : semanticCallableResultKind(source);
if (kind !== "value") {
addFrontier(
{
kind: "dynamic-call",
callableId: site.callerCallableId,
location: site.location,
reason: `Direct invocation produces ${kind}; body results require separate consumption.`,
},
state,
output,
);
continue;
}
for (const returnSite of callable.returnSites) {
output.callReturnFlows.push({
callSiteId: site.callSiteId,
@@ -318,7 +335,7 @@ const collectCapture = (
owner.bodyScopeId === null
)
return;
if (semanticReferenceRole(node, parent) === null) return;
if (semanticIdentifierRole(node, parent, context.ancestors) === null) return;
const binding = resolveSemanticBindingState(state, node, node.name);
if (
binding === undefined ||
@@ -171,16 +171,12 @@ export function* collectSemanticReferencesSteps(
): Generator<void, JavaScriptSemanticReference[]> {
const output: JavaScriptSemanticReference[] = [];
const seen = new Set<string>();
const patterns: t.Node[] = [];
yield* traverseJavaScriptAstSteps(program, {
enter: (node, parent, readAncestors) => {
if (isPatternNode(node)) patterns.push(node);
if (
!t.isIdentifier(node) ||
parent === null ||
(name !== undefined && node.name !== name) ||
(isNonReferenceKey(node, parent) &&
!patterns.some((pattern) => bindsIdentifier(pattern, parent, node)))
(name !== undefined && node.name !== name)
)
return;
const ancestors = readAncestors();
@@ -218,9 +214,6 @@ export function* collectSemanticReferencesSteps(
});
}
},
exit: (node) => {
if (isPatternNode(node)) patterns.pop();
},
});
return output;
}
@@ -635,6 +628,13 @@ export const semanticIdentifierRole = (
parent: t.Node,
ancestors: readonly t.Node[],
): JavaScriptSemanticReference["role"] | null => {
if (
t.isObjectProperty(parent) &&
parent.key === node &&
!parent.computed &&
parent.value !== node
)
return null;
const patternAncestor = enclosingPatternAncestor(node, parent, ancestors);
if (patternAncestor !== null) {
if (isPatternOwnerDeclaration(patternAncestor)) return null;
@@ -642,7 +642,7 @@ export const semanticIdentifierRole = (
}
const site = semanticWrappedWriteSite(node, parent, ancestors);
if (site !== null && site !== parent && !isDeleteSite(site)) return "write";
return semanticReferenceRole(node, parent);
return semanticReferenceRole(node, parent, ancestors.at(-2));
};
/**
@@ -720,9 +720,8 @@ const isPatternOwnerDeclaration = (node: t.Node): boolean =>
export const semanticReferenceRole = (
node: t.Identifier,
parent: t.Node,
grandparent?: t.Node,
): JavaScriptSemanticReference["role"] | null => {
if (isDeclarationIdentifier(node, parent) || isNonReferenceKey(node, parent))
return null;
if (
(t.isAssignmentExpression(parent) && parent.left === node) ||
t.isUpdateExpression(parent) ||
@@ -730,59 +729,23 @@ export const semanticReferenceRole = (
parent.left === node)
)
return "write";
if (t.isExportSpecifier(parent) && parent.local === node) return "export";
return "read";
};
const isDeclarationIdentifier = (
node: t.Identifier,
parent: t.Node,
): boolean => {
if (t.isTSImportEqualsDeclaration(parent)) return parent.id === node;
return (
// Babel owns syntax roles. These TS declaration forms are not covered by
// the installed Babel 8 isReferenced predicate.
if (
(t.isTSImportEqualsDeclaration(parent) && parent.id === node) ||
(t.isTSParameterProperty(parent) &&
patternContains(parent.parameter, node)) ||
(t.isVariableDeclarator(parent) && patternContains(parent.id, node)) ||
((t.isFunctionDeclaration(parent) || t.isFunctionExpression(parent)) &&
parent.id === node) ||
(t.isFunction(parent) &&
parent.params.some((value) => patternContains(value, node))) ||
((t.isClassDeclaration(parent) || t.isClassExpression(parent)) &&
parent.id === node) ||
(t.isImportSpecifier(parent) &&
(parent.local === node || parent.imported === node)) ||
(t.isImportDefaultSpecifier(parent) && parent.local === node) ||
(t.isImportNamespaceSpecifier(parent) && parent.local === node) ||
(t.isCatchClause(parent) && parent.param === node)
);
((t.isTSDeclareMethod(parent) || t.isTSMethodSignature(parent)) &&
parent.key === node &&
!parent.computed)
)
return null;
if (!t.isReferenced(node, parent, grandparent)) return null;
return t.isExportSpecifier(parent) && parent.local === node
? "export"
: "read";
};
const isNonReferenceKey = (node: t.Identifier, parent: t.Node): boolean =>
((t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) &&
parent.property === node &&
!parent.computed) ||
((t.isObjectProperty(parent) || t.isObjectMethod(parent)) &&
parent.key === node &&
!parent.computed &&
!(t.isObjectProperty(parent) && parent.shorthand)) ||
isNoncomputedClassMemberKey(node, parent) ||
t.isPrivateName(parent) ||
(t.isExportSpecifier(parent) && parent.exported === node) ||
t.isLabeledStatement(parent) ||
t.isBreakStatement(parent) ||
t.isContinueStatement(parent);
/** A noncomputed class member name is a property key, never a lexical read. */
const isNoncomputedClassMemberKey = (
node: t.Identifier,
parent: t.Node,
): boolean =>
(t.isClassMethod(parent) ||
t.isClassProperty(parent) ||
t.isTSDeclareMethod(parent)) &&
parent.key === node &&
!parent.computed;
const patternNames = (pattern: t.Node): string[] => {
if (t.isTSParameterProperty(pattern)) return patternNames(pattern.parameter);
if (t.isIdentifier(pattern)) return [pattern.name];
@@ -4,7 +4,10 @@ import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
import {
semanticCallableResultExpressions,
semanticCallableResultKind,
} from "./javascriptSemanticCallableResults.js";
import {
javaScriptSourceInvocationContext,
javaScriptGenericInvocationMethod,
@@ -669,10 +672,12 @@ export const createSemanticReturnedReferences = (
// A direct generator/async call produces a fresh iterator/Promise, not
// the yielded/resolved contents. Escape analysis still retains those
// potential references for their eventual consumers.
const resultKind = semanticCallableResultKind(callable);
if (
consumption !== "potential-reference" &&
(callable.generator ||
(consumption === "direct-call" && callable.async))
(resultKind === "iterator" ||
resultKind === "async-iterator" ||
(consumption === "direct-call" && resultKind === "promise"))
)
return [];
return semanticCallableResultExpressions(callable).flatMap(