fix(javascript): preserve capture-time guards and ordering

This commit is contained in:
morluto 2026-10-10 07:13:48 +08:00
parent 39dabd0608
commit 001851fde6
5 changed files with 203 additions and 37 deletions

View File

@ -104,6 +104,98 @@ describe("reference lifetimes across calls and shallow copies", () => {
);
});
describe("capture ordering within declarations and expressions", () => {
it.each([
"const ignored = mutate(source);",
"void mutate(source);",
"consume(mutate(source));",
])("preserves a primitive before a later nested call: %s", (escape) => {
expect(
resultValue(
`const source={value:1}; const snapshot=source.value; ${escape} return snapshot;`,
),
).toEqual({ status: "literal", value: 1 });
expect(
resultValue(
`const source={value:1}; ${escape} const snapshot=source.value; return snapshot;`,
)?.status,
).toBe("unknown");
});
it("preserves a primitive before an awaited escape", () => {
const value = onlyCallable(
analyzeJavaScriptSemantics(`export async function result() {
const source={value:1}; const snapshot=source.value;
await mutate(source); return snapshot;
}`),
"result",
).returnSites[0]?.value;
expect(value).toEqual({ status: "literal", value: 1 });
});
it.each([
"({value:1} as {value:number})",
"({value:1} satisfies {value:number})",
"({value:1})!",
])("classifies transparent initializer wrappers: %s", (source) => {
expect(
resultValue(
`const source=${source}; const snapshot=source.value; mutate(source); return snapshot;`,
),
).toEqual({ status: "literal", value: 1 });
expect(
resultValue(
`const source=${source}; mutate(source); const snapshot=source.value; return snapshot;`,
)?.status,
).toBe("unknown");
});
it.each(["(source as {value:number})", "(source satisfies {value:number})"])(
"captures through a wrapped alias: %s",
(alias) => {
expect(
resultValue(
`const source={value:1}; const alias=${alias}; const snapshot=alias.value; mutate(alias); return snapshot;`,
),
).toEqual({ status: "literal", value: 1 });
},
);
it.each([
["{child:{}}", "{child:copy=fallback}"],
["[{}]", "[copy=fallback]"],
])("guards fallback selection before escape: %s", (source, pattern) => {
expect(
resultValue(
`const fallback={value:1}; const source=${source}; const ${pattern}=source; mutate(copy); return fallback.value;`,
),
).toEqual({ status: "literal", value: 1 });
expect(
resultValue(
`const fallback={value:1}; const source=${source}; mutate(source); const ${pattern}=source; mutate(copy); return fallback.value;`,
)?.status,
).toBe("unknown");
});
it.each([
"const source={value:1}, snapshot=source.value; mutate(source);",
"const source={value:1}, snapshot=source.value, ignored=mutate(source);",
])("preserves sequential declarator captures: %s", (body) => {
expect(resultValue(`${body} return snapshot;`)).toEqual({
status: "literal",
value: 1,
});
});
it("retains an escape in an earlier declarator", () => {
expect(
resultValue(
"const source={value:1}, ignored=mutate(source), snapshot=source.value; return snapshot;",
)?.status,
).toBe("unknown");
});
});
describe("primitive snapshots across calls", () => {
it.each([
"const [copy] = source;",

View File

@ -439,10 +439,13 @@ const referenceSourcesInactive = (
state: JavaScriptSemanticAnalysisState,
): boolean =>
reference.requiredSources?.some(
(source) => selectedReferenceSlot(source, state)?.presence === "absent",
(source) =>
selectedReferenceSlot(source, state, source.node)?.presence === "absent",
) === true ||
reference.fallbackSources?.some((source) => {
const slot = selectedReferenceSlot(source, state);
// Selection precedes effects through the chosen reference. A later escape
// cannot make a fallback that was excluded at destructuring reachable.
const slot = selectedReferenceSlot(source, state, source.node);
return (
slot?.presence === "present" &&
(slot.value.status === "literal" ||
@ -558,7 +561,7 @@ const objectReferencedValues = (
path: [
typeof selected === "object"
? {
...(selected ?? {}),
...selected,
excludedKeys: [
...new Set([
...(selected?.excludedKeys ?? []),
@ -693,6 +696,7 @@ const copiedReferencePath = (
const selectedReferenceSlot = (
source: { readonly node: t.Node; readonly projection: PropertyPath },
state: JavaScriptSemanticAnalysisState,
capturePoint?: t.Node,
) => {
const path: string[] = [];
for (const key of source.projection) {
@ -700,7 +704,7 @@ const selectedReferenceSlot = (
path.push(String(key));
}
return semanticSlotAtPath(
evaluateSemanticExpression(source.node, state),
evaluateSemanticExpression(source.node, state, capturePoint),
path,
);
};

View File

@ -1,23 +1,47 @@
import * as t from "@babel/types";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import type { JavaScriptSemanticBindingState } from "./javascriptSemanticState.js";
interface StatementPosition {
readonly body: readonly t.Statement[];
readonly index: number;
readonly declaratorIndex: number;
}
const statementPosition = (
statement: t.Statement,
parents: WeakMap<t.Node, t.Node>,
declarator?: t.VariableDeclarator,
): StatementPosition | null => {
const parent = parents.get(statement);
if (!t.isProgram(parent) && !t.isBlockStatement(parent)) return null;
const index = parent.body.indexOf(statement);
return index < 0 ? null : { body: parent.body, index };
return index < 0
? null
: {
body: parent.body,
index,
declaratorIndex:
t.isVariableDeclaration(statement) && declarator !== undefined
? statement.declarations.indexOf(declarator)
: -1,
};
};
const positionPrecedes = (
left: StatementPosition,
right: StatementPosition,
): boolean =>
left.body === right.body &&
(left.index < right.index ||
(left.index === right.index &&
left.declaratorIndex >= 0 &&
left.declaratorIndex < right.declaratorIndex));
const directInitializerPosition = (
node: t.Node,
parents: WeakMap<t.Node, t.Node>,
@ -26,7 +50,7 @@ const directInitializerPosition = (
if (t.isVariableDeclarator(parent) && parent.init === node) {
const declaration = parents.get(parent);
return t.isVariableDeclaration(declaration)
? statementPosition(declaration, parents)
? statementPosition(declaration, parents, parent)
: null;
}
if (
@ -51,7 +75,7 @@ const directMutationPosition = (
if (t.isVariableDeclarator(node)) {
const declaration = parents.get(node);
return t.isVariableDeclaration(declaration)
? statementPosition(declaration, parents)
? statementPosition(declaration, parents, node)
: null;
}
if (t.isSpreadElement(node)) {
@ -70,10 +94,19 @@ const directMutationPosition = (
!t.isTaggedTemplateExpression(node)
)
return null;
const statement = parents.get(node);
return t.isExpressionStatement(statement) && statement.expression === node
? statementPosition(statement, parents)
: null;
let current = parents.get(node);
while (current !== undefined) {
if (t.isFunction(current)) return null;
if (t.isStatement(current)) return statementPosition(current, parents);
if (t.isVariableDeclarator(current)) {
const declaration = parents.get(current);
return t.isVariableDeclaration(declaration)
? statementPosition(declaration, parents, current)
: null;
}
current = parents.get(current);
}
return null;
};
const referenceInitializerPosition = (
@ -91,7 +124,7 @@ const referenceInitializerPosition = (
if (t.isVariableDeclarator(parent)) {
const declaration = parents.get(parent);
return t.isVariableDeclaration(declaration)
? statementPosition(declaration, parents)
? statementPosition(declaration, parents, parent)
: null;
}
current = parent;
@ -117,7 +150,11 @@ export const semanticMutationInitializers = (
position:
initializer.entryBody === undefined
? directInitializerPosition(initializer.node, parents)
: { body: initializer.entryBody.body, index: -1 },
: {
body: initializer.entryBody.body,
index: -1,
declaratorIndex: -1,
},
})),
...binding.referenceInitializers.map((initializer) => ({
initializer,
@ -127,19 +164,25 @@ export const semanticMutationInitializers = (
if (
origins.some(
({ position }) =>
position === null ||
position.body !== mutationPosition.body ||
position.index >= mutationPosition.index,
position === null || !positionPrecedes(position, mutationPosition),
)
)
return binding;
const latestIndex = origins.reduce(
(latest, { position }) => Math.max(latest, position?.index ?? -1),
-1,
const latest = origins.reduce<StatementPosition | null>(
(latest, { position }) =>
position !== null &&
(latest === null || positionPrecedes(latest, position))
? position
: latest,
null,
);
const retained = new Set(
origins
.filter(({ position }) => position?.index === latestIndex)
.filter(
({ position }) =>
position?.index === latest?.index &&
position?.declaratorIndex === latest?.declaratorIndex,
)
.map(({ initializer }) => initializer),
);
return {
@ -188,8 +231,7 @@ export const semanticBindingPrecedesCapture = (
return (
sourcePosition !== null &&
capturePosition !== null &&
sourcePosition.body === capturePosition.body &&
sourcePosition.index < capturePosition.index
positionPrecedes(sourcePosition, capturePosition)
);
};
@ -209,12 +251,13 @@ export const semanticEscapeFollowsCapture = (
return false;
// A literal allocates here. Aliases are evaluated at the same capture point,
// so an outer/persistent origin still receives all of its own escape effects.
const origin = unwrapJavaScriptExpression(initializer.node).node;
if (
!t.isObjectExpression(initializer.node) &&
!t.isArrayExpression(initializer.node) &&
!t.isIdentifier(initializer.node) &&
!t.isMemberExpression(initializer.node) &&
!t.isOptionalMemberExpression(initializer.node)
!t.isObjectExpression(origin) &&
!t.isArrayExpression(origin) &&
!t.isIdentifier(origin) &&
!t.isMemberExpression(origin) &&
!t.isOptionalMemberExpression(origin)
)
return false;
const capturePosition = semanticCapturePosition(capture, parents);
@ -222,7 +265,6 @@ export const semanticEscapeFollowsCapture = (
return (
capturePosition !== null &&
escapePosition !== null &&
capturePosition.body === escapePosition.body &&
capturePosition.index < escapePosition.index
positionPrecedes(capturePosition, escapePosition)
);
};

View File

@ -114,17 +114,25 @@ export const evaluateSemanticBinding = (
primitiveBindingValues: primitiveBindingValuesFor(state),
});
/** Evaluate one arbitrary inert expression in the established lexical state. */
/** Evaluate an inert expression, optionally at an earlier reference capture. */
export const evaluateSemanticExpression = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticValue =>
evaluateExpression(node, {
capturePoint?: t.Node,
): JavaScriptSemanticValue => {
const context: EvaluationContext = {
state,
bindings: new Set(),
expressionDepth: 0,
primitiveBindingValues: primitiveBindingValuesFor(state),
});
};
return evaluateExpression(
node,
capturePoint === undefined
? context
: (captureContextAt(capturePoint, context) ?? context),
);
};
/** Follow module provenance through destructuring, members, and aliases. */
export const evaluateSemanticProvenance = (
@ -733,11 +741,17 @@ const isPrimitive = (value: JavaScriptSemanticValue): boolean =>
const primitiveCaptureContext = (
node: t.Node,
context: EvaluationContext,
): EvaluationContext | undefined => {
if (t.isObjectExpression(node) || t.isArrayExpression(node)) return undefined;
return captureContextAt(node, context);
};
const captureContextAt = (
node: t.Node,
context: EvaluationContext,
): EvaluationContext | undefined => {
if (
context.capturePoint !== undefined ||
t.isObjectExpression(node) ||
t.isArrayExpression(node) ||
semanticCapturePosition(node, context.state.parentsByNode) === null
)
return undefined;

View File

@ -541,6 +541,9 @@ describe("application workflow CLI and MCP capture lifetimes", () => {
"destructured",
"objectSnapshot",
"arraySnapshot",
"unusedFallback",
"nestedSnapshot",
"declaratorSnapshot",
];
const sources = [
`export default function make() {
@ -559,11 +562,22 @@ describe("application workflow CLI and MCP capture lifetimes", () => {
const objectSnapshot = {value: source.value};
const arraySnapshot = [source.value];
mutate(source);
const fallback = {value: "TOKEN"};
const container = {child: {}};
const {child: selected = fallback} = container;
mutate(selected);
const nestedSource = {value: "TOKEN"};
const nestedSnapshot = nestedSource.value;
const ignored = mutate(nestedSource);
const declaratorSource = {value: "TOKEN"},
declaratorSnapshot = declaratorSource.value,
declaratorIgnored = mutate(declaratorSource);
return {
kind: "capture-boundary",
rebound: original.value, rest: child.value,
snapshot, destructured,
objectSnapshot: objectSnapshot.value, arraySnapshot: arraySnapshot[0],
unusedFallback: fallback.value, nestedSnapshot, declaratorSnapshot,
};
}`,
`export default function make() { return ${JSON.stringify({
@ -597,7 +611,7 @@ describe("application workflow CLI and MCP capture lifetimes", () => {
await writeFile(inputPath, JSON.stringify(input));
const expected = {
normalized_result: {
summary: { added: 0, removed: 0, changed: 6, unknown: 0 },
summary: { added: 0, removed: 0, changed: fields.length, unknown: 0 },
coverage: { status: "complete-within-inputs" },
changes: expect.arrayContaining(
fields.map((field) =>