fix(javascript): own reference roles and invocation result identities
This commit is contained in:
1 parent
990d1de4d0
commit
965aff805d
8 files changed
+212
-72
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user