From 25f0f9934d226caa62fb89d68e7bacad74f4fe73 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sun, 25 Oct 2026 05:32:29 -0700 Subject: [PATCH] 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 --- .../javascriptSemanticMemberMutations.ts | 65 ++++++++----- .../javascriptSemanticMutationCycles.test.ts | 94 ++++++++++++++++++- .../cli/javascriptSelfStoreCycles.test.ts | 61 ++++++++++++ 3 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 tests/boundary/cli/javascriptSelfStoreCycles.test.ts diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index ce4b4fd6e9..e1dccfe70e 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -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(); 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(); 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( diff --git a/src/domain/javascript/javascriptSemanticMutationCycles.test.ts b/src/domain/javascript/javascriptSemanticMutationCycles.test.ts index c79c6dd30e..18182c58bd 100644 --- a/src/domain/javascript/javascriptSemanticMutationCycles.test.ts +++ b/src/domain/javascript/javascriptSemanticMutationCycles.test.ts @@ -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, + }); + }); +}); diff --git a/tests/boundary/cli/javascriptSelfStoreCycles.test.ts b/tests/boundary/cli/javascriptSelfStoreCycles.test.ts new file mode 100644 index 0000000000..198a92d18e --- /dev/null +++ b/tests/boundary/cli/javascriptSelfStoreCycles.test.ts @@ -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, + }); + }, +);