fix(javascript): preserve capture-time guards and ordering
This commit is contained in:
parent
39dabd0608
commit
001851fde6
|
|
@ -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;",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue