Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, it, vi } from "vitest";

import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyBinding } from "./javascriptSemanticAnalysis.fixture.js";

it("bounds ancestry reads for repeated deep lexical references", () => {
const ancestryReads = (depth: number): number => {
const original = WeakMap.prototype.get;
let reads = 0;
const get = vi.spyOn(WeakMap.prototype, "get").mockImplementation(function (
this: WeakMap<object, unknown>,
key,
) {
const value: unknown = original.call(this, key);
if (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
)
reads++;
return value;
});
try {
const ir = analyzeJavaScriptSemantics(
`const a=1; module.exports.value=${"a+(".repeat(depth)}a${")".repeat(depth)};`,
);
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
expect(onlyBinding(ir, "a").value).toEqual({
status: "literal",
value: 1,
});
return reads;
} finally {
get.mockRestore();
}
};

const smaller = ancestryReads(80);
const larger = ancestryReads(160);
expect(smaller).toBeGreaterThan(0);
expect(larger).toBeLessThan(3 * smaller);
});
6 changes: 5 additions & 1 deletion src/domain/javascript/javascriptSemanticAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ function* collectDefinitionsSteps(
}[] = [];
yield* traverseJavaScriptAstSteps(program, {
enter: (node, parent, readAncestors) => {
if (parent !== null) state.parentsByNode.set(node, parent);
let parentScope = currentSemanticScope(stack);
if (
parent !== null &&
Expand Down Expand Up @@ -493,7 +494,10 @@ function* collectDefinitionsSteps(
});
}
const scope = currentSemanticScope(stack);
state.scopeByNode.set(node, scope);
// Other nodes inherit the nearest boundary through their AST parents.
// Avoid a second full-tree index for the same lexical relationship.
if (parent === null || openedScopes.has(node))
state.scopeByNode.set(node, scope);
collectSemanticCallable({
node,
parent,
Expand Down
8 changes: 7 additions & 1 deletion src/domain/javascript/javascriptSemanticCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,4 +428,10 @@ const enclosingFunction = (
const semanticCallSiteId = (
node: t.CallExpression | t.OptionalCallExpression | t.NewExpression,
): string =>
`call:${t.isNewExpression(node) ? "construct" : "call"}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`;
// Join once so retained IDs do not keep a chain of intermediate strings.
[
"call",
t.isNewExpression(node) ? "construct" : "call",
String(node.start ?? -1),
String(node.end ?? -1),
].join(":");
68 changes: 30 additions & 38 deletions src/domain/javascript/javascriptSemanticMemberMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ export function* collectSemanticMemberMutationsSteps(
state: JavaScriptSemanticAnalysisState,
): Generator<void, void> {
const parents = state.parentsByNode;
yield* traverseJavaScriptAstSteps(program, {
enter: (node, parent) => {
if (parent !== null) parents.set(node, parent);
},
});
const normalizePath = observableMutationPaths(state);
const returnedReferences = createSemanticReturnedReferences(state);
const recordedEffects = new Set<string>();
Expand Down Expand Up @@ -670,10 +665,10 @@ type MutationObservableOrigin =
| "opaque";

interface MutationObserverNode {
readonly origins: Set<MutationObservableOrigin>;
readonly sources: Set<MutationObserverNode>;
readonly dependents: Set<MutationObserverNode>;
readonly projections: Map<string, MutationObserverNode>;
origins?: Set<MutationObservableOrigin>;
sources?: Set<MutationObserverNode>;
dependents?: Set<MutationObserverNode>;
projections?: Map<string, MutationObserverNode>;
readonly read?: {
readonly source: MutationObserverNode;
readonly key: PropertyPath[number];
Expand All @@ -700,22 +695,17 @@ const observableMutationPaths = (
queued.add(node);
pending.push(node);
};
const create = (
read?: MutationObserverNode["read"],
): MutationObserverNode => ({
origins: new Set(),
sources: new Set(),
dependents: new Set(),
projections: new Map(),
...(read === undefined ? {} : { read }),
});
const create = (read?: MutationObserverNode["read"]): MutationObserverNode =>
// Most bindings have no object origin or property projection. Materialize
// each collection only when it contains a fact or relationship.
read === undefined ? {} : { read };
const link = (
source: MutationObserverNode,
target: MutationObserverNode,
): void => {
if (target.sources.has(source)) return;
target.sources.add(source);
source.dependents.add(target);
if (target.sources?.has(source) === true) return;
(target.sources ??= new Set()).add(source);
(source.dependents ??= new Set()).add(target);
schedule(target);
};
const expressionNode = (node: t.Node): MutationObserverNode => {
Expand All @@ -736,11 +726,11 @@ const observableMutationPaths = (
const identity = JSON.stringify(
key === null || typeof key === "object" ? key : String(key),
);
let selected = observer.projections.get(identity);
let selected = observer.projections?.get(identity);
if (selected === undefined) {
selected = create({ source: observer, key });
observer.projections.set(identity, selected);
observer.dependents.add(selected);
(observer.projections ??= new Map()).set(identity, selected);
(observer.dependents ??= new Set()).add(selected);
schedule(selected);
}
observer = selected;
Expand All @@ -760,7 +750,7 @@ const observableMutationPaths = (
// Preserve exact paths where copies, defaults or iteration need their
// reference-specific projections; an opaque origin prevents pruning.
if (binding.referenceInitializers.length > 0)
observer.origins.add("opaque");
(observer.origins ??= new Set()).add("opaque");
for (const initializer of binding.referenceInitializers) {
for (const source of [
initializer,
Expand All @@ -777,7 +767,7 @@ const observableMutationPaths = (
if (selected === undefined) break;
const { expression, observer } = selected;
if (t.isObjectExpression(expression) || t.isArrayExpression(expression))
observer.origins.add(expression);
(observer.origins ??= new Set()).add(expression);
else if (t.isIdentifier(expression)) {
const binding = resolveSemanticBindingState(
state,
Expand All @@ -787,11 +777,12 @@ const observableMutationPaths = (
if (binding !== undefined) {
const origin = bindingNodes.get(binding.bindingId);
if (origin !== undefined) link(origin, observer);
} else if (expression.name === "Array") observer.origins.add("array");
} else if (expression.name === "Array")
(observer.origins ??= new Set()).add("array");
else if (
["globalThis", "global", "window", "self"].includes(expression.name)
)
observer.origins.add("ambient");
(observer.origins ??= new Set()).add("ambient");
} else if (
t.isMemberExpression(expression) ||
t.isOptionalMemberExpression(expression)
Expand All @@ -818,21 +809,21 @@ const observableMutationPaths = (
queued.delete(observer);
let changed = false;
const add = (origin: MutationObservableOrigin): void => {
if (observer.origins.has(origin)) return;
observer.origins.add(origin);
if (observer.origins?.has(origin) === true) return;
(observer.origins ??= new Set()).add(origin);
changed = true;
};
for (const source of observer.sources)
for (const origin of source.origins) add(origin);
for (const source of observer.sources ?? [])
for (const origin of source.origins ?? []) add(origin);
if (observer.read !== undefined) {
const { source, key } = observer.read;
if (key === null || typeof key === "object") {
// Dynamic keys and copied-slot masks retain their original paths.
// Enumerating their exclusion combinations would recreate the growth
// that the mutation collector's selection union already prevents.
if (source.origins.size > 0) add("opaque");
if ((source.origins?.size ?? 0) > 0) add("opaque");
} else
for (const origin of source.origins) {
for (const origin of source.origins ?? []) {
if (typeof origin === "string") {
if (origin === "array-prototype" || origin === "opaque")
add(origin);
Expand Down Expand Up @@ -869,7 +860,7 @@ const observableMutationPaths = (
}
}
if (changed)
for (const dependent of observer.dependents) schedule(dependent);
for (const dependent of observer.dependents ?? []) schedule(dependent);
}
};
settle();
Expand All @@ -881,15 +872,16 @@ const observableMutationPaths = (
if (effect === "escape") {
observer = project(observer, path);
settle();
return observer.origins.size === 0 ? null : path;
return (observer.origins?.size ?? 0) === 0 ? null : path;
}
if (observer.origins.size === 0) return path.length === 0 ? path : [null];
if ((observer.origins?.size ?? 0) === 0)
return path.length === 0 ? path : [null];
for (let offset = 0; offset < path.length; offset++) {
const key = path[offset];
if (key === undefined) break;
observer = project(observer, [key]);
settle();
if (observer.origins.size === 0 && offset + 1 < path.length)
if ((observer.origins?.size ?? 0) === 0 && offset + 1 < path.length)
return [...path.slice(0, offset + 1), null];
}
return path;
Expand Down
8 changes: 7 additions & 1 deletion src/domain/javascript/javascriptSemanticObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,13 @@ const addObjectOperation = (
output: JavaScriptSemanticObjectOperation[],
): void => {
output.push({
objectOperationId: `object:${input.kind}:${String(input.node.start ?? -1)}:${String(input.node.end ?? -1)}`,
// These high-volume IDs must not retain intermediate concatenations.
objectOperationId: [
"object",
input.kind,
String(input.node.start ?? -1),
String(input.node.end ?? -1),
].join(":"),
kind: input.kind,
location: range(input.node),
ownerCallableId: input.ownerCallableId,
Expand Down
9 changes: 8 additions & 1 deletion src/domain/javascript/javascriptSemanticReturns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ const semanticReturnSiteId = (
callableId: string,
location: JavaScriptSemanticReturnSite["location"],
): string =>
`${callableId}:return:${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`;
[
callableId,
"return",
String(location.start.line),
String(location.start.column),
String(location.end.line),
String(location.end.column),
].join(":");

/** Link exports to callables only when lexical resolution is unique and exact. */
export const resolveSemanticModuleCallables = (
Expand Down
27 changes: 25 additions & 2 deletions src/domain/javascript/javascriptSemanticState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface JavaScriptSemanticAnalysisState {
readonly parentsByNode: WeakMap<t.Node, t.Node>;
readonly scopes: JavaScriptSemanticScopeState[];
readonly scopesById: Map<string, JavaScriptSemanticScopeState>;
/** Scope boundaries and descendant scopes resolved on demand. */
readonly scopeByNode: WeakMap<t.Node, JavaScriptSemanticScopeState>;
readonly bindingsById: Map<string, JavaScriptSemanticBindingState>;
readonly callables: JavaScriptSemanticCallable[];
Expand Down Expand Up @@ -94,7 +95,7 @@ export const resolveSemanticBindingState = (
node: t.Node,
name: string,
): JavaScriptSemanticBindingState | undefined => {
let scope = state.scopeByNode.get(node);
let scope = semanticScopeForNode(state, node);
while (scope !== undefined) {
const binding = scope.bindings.get(name);
if (binding !== undefined) return binding;
Expand All @@ -113,7 +114,7 @@ export const semanticResolutionBlocked = (
node: t.Node,
name: string,
): boolean => {
let scope = state.scopeByNode.get(node);
let scope = semanticScopeForNode(state, node);
while (scope !== undefined) {
if (scope.bindings.has(name)) return false;
if (!scope.bindingsComplete) return true;
Expand All @@ -125,6 +126,28 @@ export const semanticResolutionBlocked = (
return false;
};

const semanticScopeForNode = (
state: JavaScriptSemanticAnalysisState,
node: t.Node,
): JavaScriptSemanticScopeState | undefined => {
let candidate: t.Node | undefined = node;
while (candidate !== undefined) {
const scope = state.scopeByNode.get(candidate);
if (scope !== undefined) {
// Repeated references in a deep expression share this ancestry. Cache
// the traversed path without eagerly indexing nodes never resolved.
let descendant: t.Node | undefined = node;
while (descendant !== undefined && descendant !== candidate) {
state.scopeByNode.set(descendant, scope);
descendant = state.parentsByNode.get(descendant);
}
return scope;
}
candidate = state.parentsByNode.get(candidate);
}
return undefined;
};

/** Resolve one name starting from an explicitly selected lexical scope. */
export const resolveSemanticBindingFromScope = (
scope: JavaScriptSemanticScopeState,
Expand Down
Loading