fix(javascript): close member-path cycles through self stores (#7131)

A write below a slot that stores a value read from the same slot, such
as `s.c = s.c.p; s.c.i = 1` on a parameter, revisits the binding with
one more segment after the stored key on every turn. The cycle closure
only recognized segments added at the front of the path (`node =
node[key]`), so the work-list never terminated and exhausted the owned
worker heap (CodeMirror 5 `mode/stylus/stylus.js`).

Recognize a contiguous segment inserted after a stable base, check that
repeating it after that base can never be pruned, and escape through it
once, as the self-alias rule already does. Front insertions keep their
existing order and result.

Fixes #7130

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-25 05:32:29 -07:00
committed by GitHub
parent 70b96b65d9
commit 25f0f9934d
3 changed files with 196 additions and 24 deletions
@@ -998,25 +998,37 @@ export function* collectSemanticMemberMutationsSteps(
// An active normalized prefix owns its closure before nested traversals.
if (!covering.retain(path, captureContexts)) continue;
// A self alias such as `node = node[key]` revisits its capture with the
// same prefix added on every cycle, so exact paths never repeat. When
// that prefix can never be pruned, escaping it once covers every longer
// effect, including writes below shared children.
// Only a capture already on this alias chain can repeat a prefix.
// same prefix added on every cycle, so exact paths never repeat. A self
// store such as `s.c = s.c.p` adds its segment after the stored key
// instead. When that segment can never be pruned, escaping through it
// once covers every longer effect, including writes below shared
// children. Only a capture already on this alias chain can repeat.
const revisited = current.bindings.has(captureKey);
let repeatedPrefix: PropertyPath | undefined;
for (
let start = 1;
revisited && start <= current.path.length;
start++
) {
const suffix = current.path.slice(start);
if (!current.bindings.has(lifetimeKey(captureKey, suffix))) continue;
repeatedPrefix = current.path.slice(0, start);
break;
}
let repeatedBase = 0;
repeat: for (
let base = 0;
revisited && base < current.path.length;
base++
)
for (let end = base + 1; end <= current.path.length; end++) {
const shorter =
base === 0
? current.path.slice(end)
: [...current.path.slice(0, base), ...current.path.slice(end)];
if (!current.bindings.has(lifetimeKey(captureKey, shorter)))
continue;
repeatedPrefix = current.path.slice(0, end);
repeatedBase = base;
break repeat;
}
if (
repeatedPrefix !== undefined &&
observablePaths.repeatsUnpruned(binding.bindingId, repeatedPrefix)
observablePaths.repeatsUnpruned(
binding.bindingId,
repeatedPrefix.slice(0, repeatedBase),
repeatedPrefix.slice(repeatedBase),
)
) {
markValue(
current.node,
@@ -1974,7 +1986,8 @@ const observableMutationPaths = (
) => PropertyPath | null;
readonly repeatsUnpruned: (
bindingId: string,
prefix: PropertyPath,
base: PropertyPath,
segment: PropertyPath,
) => boolean;
} => {
const ambientPaths = createAmbientPathResolver(state, false);
@@ -2265,22 +2278,28 @@ const observableMutationPaths = (
return path;
};
// A projection's origins depend only on its source origins and key. Once
// repeating a prefix revisits an origin set, every longer repetition stays
// observable and normalization can never prune it. The answer depends only
// on the binding and prefix, so every revisit of a long chain shares it.
// repeating a segment after its base revisits an origin set, every longer
// repetition stays observable and normalization can never prune it. The
// answer depends only on the binding, base and segment, so every revisit of
// a long chain shares it.
const repeated = new Map<string, boolean>();
const repeatsUnpruned = (
bindingId: string,
prefix: PropertyPath,
base: PropertyPath,
segment: PropertyPath,
): boolean => {
const key = JSON.stringify([bindingId, prefix]);
const key = JSON.stringify([bindingId, base, segment]);
const cached = repeated.get(key);
if (cached !== undefined) return cached;
let observer = bindingNodes.get(bindingId);
if (observer !== undefined && base.length > 0) {
observer = project(observer, base);
settle();
}
const signatures = new Set<string>();
let result = false;
while (observer !== undefined && prefix.length > 0) {
observer = project(observer, prefix);
while (observer !== undefined && segment.length > 0) {
observer = project(observer, segment);
settle();
if ((observer.origins?.size ?? 0) === 0) break;
const signature = JSON.stringify(
@@ -1,7 +1,14 @@
import * as t from "@babel/types";
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import {
analyzeJavaScriptSemantics,
analyzeParsedJavaScriptSemantics,
} from "./javascriptSemanticAnalysis.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
import type { JavaScriptSemanticIr } from "./javascriptSemanticIr.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
const returnedValue = (body: string, expression: string) =>
onlyCallable(
@@ -109,3 +116,88 @@ describe("member-path cycles and captured aliases", () => {
).toBe("unknown");
});
});
describe("member-path cycles through self stores", () => {
/**
* Analyze a source while counting member-object reads. An unclosed cycle
* grows its path on every revisit; failing at a fixed count keeps that
* regression fast instead of exhausting the test worker's heap.
*/
const analyzeWithBoundedMemberReads = (
source: string,
limit: number,
): { readonly ir: JavaScriptSemanticIr; readonly reads: number } => {
const file = parseJavaScriptSource(source);
if (file === null) throw new Error("fixture must parse");
let reads = 0;
traverseJavaScriptAst(file, {
enter: (node) => {
if (!t.isMemberExpression(node)) return;
const object = node.object;
Object.defineProperty(node, "object", {
enumerable: true,
configurable: true,
get: () => {
if (++reads > limit)
throw new Error(`member-object reads exceeded ${String(limit)}`);
return object;
},
});
},
});
reads = 0;
const ir = analyzeParsedJavaScriptSemantics(file);
return { ir, reads };
};
it.each([
["one key", "s.c = s.c.p; s.c.i = 1;"],
["a nested key", "s.a.b = s.a.b.next; s.a.b.x = 1;"],
["a computed key", "s[k] = s[k].p; s[k].i = 1;"],
["a two-step segment", "s.c = s.c.p.q; s.c.i = 1;"],
[
"two alternating slots",
"s.c = s.d.p; s.d = s.c.q; s.c.i = 1; s.d.j = 2;",
],
[
"a CodeMirror-style context stack",
`s.context = new Context(s.context);
s.context = s.context.prev;
s.context.indent = 0;
s.context.line.firstWord = "x";`,
],
])(
"closes a parameter slot that stores a value read from itself through %s",
(_shape, body) => {
// Each call can see the previous call's store, so the written receiver
// may be any number of segments below the slot's original value.
const { ir, reads } = analyzeWithBoundedMemberReads(
`function Context(prev) { this.prev = prev; this.line = {}; }
function update(s, k) { ${body} }
module.exports = update;`,
5_000,
);
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
expect(reads).toBeGreaterThan(0);
},
);
it("keeps unrelated slots exact when a local stack pops in a loop", () => {
const body = `
const seed = {
context: { prev: { prev: { value: 1 }, value: 1 }, value: 1 },
keep: 3,
};
const state = seed;
while (state.flag) state.context = state.context.prev;
state.context.value = 2;
`;
expect(returnedValue(body, "seed.context.prev.prev.value")?.status).toBe(
"unknown",
);
expect(returnedValue(body, "seed.keep")).toEqual({
status: "literal",
value: 3,
});
});
});
@@ -0,0 +1,61 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
// CodeMirror 5 modes keep a parser context stack on a state parameter. Popping
// it stores a value read from the same slot, so each call can select one more
// `prev` below the slot's original value. An unclosed member-path cycle there
// exhausted the owned worker heap on `mode/stylus/stylus.js`.
const contextStack = `
function Context(type, prev) {
this.type = type;
this.prev = prev;
this.line = { firstWord: "", indent: 0 };
}
function pushContext(state, type) {
state.context = new Context(type, state.context);
return type;
}
function popContext(state, currentIndent) {
var contextIndent = state.context.indent - 2;
state.context = state.context.prev;
if (currentIndent) state.context.indent = contextIndent;
return state.context.type;
}
function tokenBase(stream, state) {
state.context.line.firstWord = stream.current();
state.context.line.indent = stream.indentation();
}
module.exports = { pushContext, popContext, tokenBase };
`;
cliTest(
"analyzes a parser context stack that pops through its own slot",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-js-self-store-");
await writeFile(join(root, "mode.js"), contextStack);
const output = await cli.run({
arguments: [
"analyze-javascript-application",
root,
"--max-heap-mb",
"256",
"--json",
],
});
expect(output.exitCode).toBe(0);
const result = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(result.statistics).toMatchObject({
parsed_javascript_files: 1,
skipped_javascript_files: 0,
parse_failures: 0,
});
},
);