[GH-ISSUE #3312] [Bug] Setter writes leave stale sibling return literals #1185

Closed
opened 2026-10-15 18:31:49 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3312

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

A write through a local object's setter leaves the setter receiver's earlier sibling value marked as a known return literal. For a setter that assigns this.value, analyze-javascript-application reports the original value: 1 after invoking the setter with 2. Comparing this function with an otherwise identical no-op setter using compare-javascript-export-shapes reports no changes and no unknowns. This loses the conservative uncertainty promised for accessor effects.

All three REA commands succeed. Both return projections retain /value as the literal 1. The comparison reports coverage.status: "complete-within-inputs", with both graph statuses complete, and returns zero added, removed, changed, and unknown fields with an empty changes array.

The stale value also reproduced with the setter fixture alone. A separate getter variant, get update() { this.value = 2; return 0; } followed by const observed = state.update, also returned a literal /value: 1 in analysis while its independently executed function returned {"value":2}. That variant shares the implicit accessor-call boundary; the setter fixture below is the primary reproduction.

Reproduction

  1. Start with a clean source checkout and prepare the runtime. Run the remaining commands from the repository root.
git clone https://github.com/morluto/rea.git rea
cd rea
git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063
npm ci
npm run build:cached
  1. Create both complete input modules, analyze them, construct the comparison input from the saved analysis results, compare their exports, and independently execute only the supplied benign fixture functions.
work="$(mktemp -d)"
mkdir -p "$work/left" "$work/right" "$work/home"
cat > "$work/left/sample.mjs" <<'JS'
export function result() {
  const state = { value: 1, set update(next) {} };
  state.update = 2;
  return { value: state.value };
}
JS
cat > "$work/right/sample.mjs" <<'JS'
export function result() {
  const state = { value: 1, set update(next) { this.value = next; } };
  state.update = 2;
  return { value: state.value };
}
JS
env HOME="$work/home" node scripts/rea.mjs analyze-javascript-application "$work/left" --json > "$work/left.json"
env HOME="$work/home" node scripts/rea.mjs analyze-javascript-application "$work/right" --json > "$work/right.json"
node --input-type=module - "$work" <<'JS'
import { readFileSync, writeFileSync } from 'node:fs';
const root = process.argv[2];
const left = JSON.parse(readFileSync(`${root}/left.json`, 'utf8'));
const right = JSON.parse(readFileSync(`${root}/right.json`, 'utf8'));
for (const [side, evidence] of [['left', left], ['right', right]]) {
  for (const node of evidence.normalized_result.graph.nodes) {
    for (const observation of node.observations) {
      const p = observation.properties;
      if (p.semantic_role === 'export-return-shapes' && p.exported_name === 'result') {
        console.log(side, JSON.stringify(p.static_return_shapes));
      }
    }
  }
}
writeFileSync(`${root}/comparison-input.json`, JSON.stringify({
  left, right,
  left_module_path: 'sample.mjs', left_export_name: 'result',
  right_module_path: 'sample.mjs', right_export_name: 'result'
}));
JS
env HOME="$work/home" node scripts/rea.mjs compare-javascript-export-shapes "$work/comparison-input.json" --json > "$work/comparison.json"
node --input-type=module - "$work/comparison.json" <<'JS'
import { readFileSync } from 'node:fs';
const result = JSON.parse(readFileSync(process.argv[2], 'utf8')).normalized_result;
console.log(JSON.stringify({summary: result.summary, changes: result.changes}));
JS
node --input-type=module - "$work" <<'JS'
import { pathToFileURL } from 'node:url';
const root = process.argv[2];
const left = await import(pathToFileURL(`${root}/left/sample.mjs`).href);
const right = await import(pathToFileURL(`${root}/right/sample.mjs`).href);
console.log(JSON.stringify({left: left.result(), right: right.result()}));
JS

The final command independently executes the two supplied benign fixtures with Node.js. It returned {"left":{"value":1},"right":{"value":2}}. REA's analysis remains static. The analysis and comparison failure was reproduced through the public commands above.

  1. To exercise the supplementary getter shape, replace the right-hand module with this complete input, then repeat the right-hand analysis and inspect its return projection. This variant was observed separately from the primary comparison.
cat > "$work/right/sample.mjs" <<'JS'
export function result() {
  const state = { value: 1, get update() { this.value = 2; return 0; } };
  const observed = state.update;
  return { value: state.value };
}
JS
env HOME="$work/home" node scripts/rea.mjs analyze-javascript-application "$work/right" --json > "$work/right.json"
node --input-type=module - "$work/right.json" <<'JS'
import { readFileSync } from 'node:fs';
const evidence = JSON.parse(readFileSync(process.argv[2], 'utf8'));
for (const node of evidence.normalized_result.graph.nodes) {
  for (const observation of node.observations) {
    const p = observation.properties;
    if (p.semantic_role === 'export-return-shapes' && p.exported_name === 'result') {
      console.log(JSON.stringify(p.static_return_shapes));
    }
  }
}
JS
node --input-type=module - "$work" <<'JS'
import { pathToFileURL } from 'node:url';
const root = process.argv[2];
const right = await import(pathToFileURL(`${root}/right/sample.mjs`).href);
console.log(JSON.stringify(right.result()));
JS

Expected behavior

The right-hand return projection must not retain /value as the exact literal 1 after the setter call. Conservative uncertainty is sufficient; the analyzer does not need to execute the setter or establish the exact new value. Export comparison must preserve that uncertainty rather than using the stale literal as an exact match. Matching unknown projections may still omit a change under the normal comparison rules; their uncertainty must remain explicit. A pure getter read that performs no receiver write should retain the documented precision.

docs/javascript-application-workflows.md:200 describes the static return-value lattice. docs/javascript-application-workflows.md:204 through line 217 requires conservative uncertainty for mutable receivers and accessors. The expected result is uncertainty rather than execution of the setter body to invent a new exact value.

Impact and scope

Impact level P2, supported by the executed accessor fixtures retaining an incorrect known field value and the export-comparison workflow silently omitting a real return-value difference. The demonstrated scope is local object accessors with receiver writes; arbitrary getter or setter behavior was not assessed.

Workaround status is unverified. No existing static REA workflow was verified to recover or conservatively invalidate this accessor effect. Independently executing the benign sample establishes its oracle, but executing an analyzed application is not a general replacement for the static workflow.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0; macOS 27.0.1. Architecture was not recorded in the reproduction evidence.

Target and artifact context

Extracted directories containing the supplied JavaScript ES modules and saved JSON analysis/comparison inputs. No native provider is required.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Both analyzed setter return projections contain:
{"path":"/value","presence":"present","state":"literal","value":1,"reason":null}
Comparison coverage.status: "complete-within-inputs"
Both graph statuses: "complete"
{"summary":{"added":0,"removed":0,"changed":0,"unknown":0},"changes":[]}
Independent Node.js fixture execution:
{"left":{"value":1},"right":{"value":2}}
Supplementary getter: analysis retains literal /value: 1; independent execution returns {"value":2}.

Typed outcome or recovery expected

Analyze the mutating-setter fixture again and verify that /value cannot remain the known old literal 1. Compare it with the no-op-setter fixture and preserve uncertainty in the result when the new value is not statically known, following the normal variant-matching rules. Keep an immutable direct-return control precise, and keep the documented pure getter read without a receiver write from causing unrelated invalidation.

Analysis or suspected cause

Source-derived reasoning: src/domain/javascript/javascriptSemanticValues.ts:587 marks only the accessor's own value unknown. src/domain/javascript/javascriptSemanticMemberMutations.ts:578 records the /update write, while src/domain/javascript/javascriptSemanticMemberMutations.ts:601 through line 629 handles explicit call receiver escapes without the implicit setter invocation. This explains a possible missing invalidation boundary; the observed public analysis and comparison outputs establish the stale literal independently of that source interpretation.

Additional context

  • #1494, completed, concerns external writes through objects returned by getters; this failure concerns writes performed inside an accessor on its receiver.
  • #1665, completed, concerns receiver/private/super references returned by getters and then written; this own setter writes a sibling through this without returning a reference.
  • #2653, completed, concerns getter-local receiver aliases that return shared references; the primary setter fixture has no such alias or returned reference.
  • #2654, completed, concerns container and assigned receiver forms for writes through getter results; the primary receiver is a directly declared local object.
  • #2655, completed, concerns shared objects returned by inherited getters; this fixture uses an own accessor with an internal receiver write.
  • #3261, open, concerns unavailable return shapes for identifier default exports; both named exports here produce return shapes whose known values are incorrect.
  • #2659, completed, concerns missing change locations when an export has no return variants; both inputs here contain return projections and the stale literal originates in analysis.
Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3312 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior A write through a local object's setter leaves the setter receiver's earlier sibling value marked as a known return literal. For a setter that assigns `this.value`, `analyze-javascript-application` reports the original `value: 1` after invoking the setter with `2`. Comparing this function with an otherwise identical no-op setter using `compare-javascript-export-shapes` reports no changes and no unknowns. This loses the conservative uncertainty promised for accessor effects. All three REA commands succeed. Both return projections retain `/value` as the literal `1`. The comparison reports `coverage.status: "complete-within-inputs"`, with both graph statuses `complete`, and returns zero added, removed, changed, and unknown fields with an empty `changes` array. The stale value also reproduced with the setter fixture alone. A separate getter variant, `get update() { this.value = 2; return 0; }` followed by `const observed = state.update`, also returned a literal `/value: 1` in analysis while its independently executed function returned `{"value":2}`. That variant shares the implicit accessor-call boundary; the setter fixture below is the primary reproduction. ### Reproduction 1. Start with a clean source checkout and prepare the runtime. Run the remaining commands from the repository root. ```bash git clone https://github.com/morluto/rea.git rea cd rea git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063 npm ci npm run build:cached ``` 2. Create both complete input modules, analyze them, construct the comparison input from the saved analysis results, compare their exports, and independently execute only the supplied benign fixture functions. ```bash work="$(mktemp -d)" mkdir -p "$work/left" "$work/right" "$work/home" cat > "$work/left/sample.mjs" <<'JS' export function result() { const state = { value: 1, set update(next) {} }; state.update = 2; return { value: state.value }; } JS cat > "$work/right/sample.mjs" <<'JS' export function result() { const state = { value: 1, set update(next) { this.value = next; } }; state.update = 2; return { value: state.value }; } JS env HOME="$work/home" node scripts/rea.mjs analyze-javascript-application "$work/left" --json > "$work/left.json" env HOME="$work/home" node scripts/rea.mjs analyze-javascript-application "$work/right" --json > "$work/right.json" node --input-type=module - "$work" <<'JS' import { readFileSync, writeFileSync } from 'node:fs'; const root = process.argv[2]; const left = JSON.parse(readFileSync(`${root}/left.json`, 'utf8')); const right = JSON.parse(readFileSync(`${root}/right.json`, 'utf8')); for (const [side, evidence] of [['left', left], ['right', right]]) { for (const node of evidence.normalized_result.graph.nodes) { for (const observation of node.observations) { const p = observation.properties; if (p.semantic_role === 'export-return-shapes' && p.exported_name === 'result') { console.log(side, JSON.stringify(p.static_return_shapes)); } } } } writeFileSync(`${root}/comparison-input.json`, JSON.stringify({ left, right, left_module_path: 'sample.mjs', left_export_name: 'result', right_module_path: 'sample.mjs', right_export_name: 'result' })); JS env HOME="$work/home" node scripts/rea.mjs compare-javascript-export-shapes "$work/comparison-input.json" --json > "$work/comparison.json" node --input-type=module - "$work/comparison.json" <<'JS' import { readFileSync } from 'node:fs'; const result = JSON.parse(readFileSync(process.argv[2], 'utf8')).normalized_result; console.log(JSON.stringify({summary: result.summary, changes: result.changes})); JS node --input-type=module - "$work" <<'JS' import { pathToFileURL } from 'node:url'; const root = process.argv[2]; const left = await import(pathToFileURL(`${root}/left/sample.mjs`).href); const right = await import(pathToFileURL(`${root}/right/sample.mjs`).href); console.log(JSON.stringify({left: left.result(), right: right.result()})); JS ``` The final command independently executes the two supplied benign fixtures with Node.js. It returned `{"left":{"value":1},"right":{"value":2}}`. REA's analysis remains static. The analysis and comparison failure was reproduced through the public commands above. 3. To exercise the supplementary getter shape, replace the right-hand module with this complete input, then repeat the right-hand analysis and inspect its return projection. This variant was observed separately from the primary comparison. ```bash cat > "$work/right/sample.mjs" <<'JS' export function result() { const state = { value: 1, get update() { this.value = 2; return 0; } }; const observed = state.update; return { value: state.value }; } JS env HOME="$work/home" node scripts/rea.mjs analyze-javascript-application "$work/right" --json > "$work/right.json" node --input-type=module - "$work/right.json" <<'JS' import { readFileSync } from 'node:fs'; const evidence = JSON.parse(readFileSync(process.argv[2], 'utf8')); for (const node of evidence.normalized_result.graph.nodes) { for (const observation of node.observations) { const p = observation.properties; if (p.semantic_role === 'export-return-shapes' && p.exported_name === 'result') { console.log(JSON.stringify(p.static_return_shapes)); } } } JS node --input-type=module - "$work" <<'JS' import { pathToFileURL } from 'node:url'; const root = process.argv[2]; const right = await import(pathToFileURL(`${root}/right/sample.mjs`).href); console.log(JSON.stringify(right.result())); JS ``` ### Expected behavior The right-hand return projection must not retain `/value` as the exact literal `1` after the setter call. Conservative uncertainty is sufficient; the analyzer does not need to execute the setter or establish the exact new value. Export comparison must preserve that uncertainty rather than using the stale literal as an exact match. Matching unknown projections may still omit a change under the normal comparison rules; their uncertainty must remain explicit. A pure getter read that performs no receiver write should retain the documented precision. `docs/javascript-application-workflows.md:200` describes the static return-value lattice. `docs/javascript-application-workflows.md:204` through line 217 requires conservative uncertainty for mutable receivers and accessors. The expected result is uncertainty rather than execution of the setter body to invent a new exact value. ### Impact and scope Impact level P2, supported by the executed accessor fixtures retaining an incorrect known field value and the export-comparison workflow silently omitting a real return-value difference. The demonstrated scope is local object accessors with receiver writes; arbitrary getter or setter behavior was not assessed. Workaround status is unverified. No existing static REA workflow was verified to recover or conservatively invalidate this accessor effect. Independently executing the benign sample establishes its oracle, but executing an analyzed application is not a general replacement for the static workflow. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0; macOS 27.0.1. Architecture was not recorded in the reproduction evidence. ### Target and artifact context Extracted directories containing the supplied JavaScript ES modules and saved JSON analysis/comparison inputs. No native provider is required. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence ```text Both analyzed setter return projections contain: {"path":"/value","presence":"present","state":"literal","value":1,"reason":null} Comparison coverage.status: "complete-within-inputs" Both graph statuses: "complete" {"summary":{"added":0,"removed":0,"changed":0,"unknown":0},"changes":[]} Independent Node.js fixture execution: {"left":{"value":1},"right":{"value":2}} Supplementary getter: analysis retains literal /value: 1; independent execution returns {"value":2}. ``` ### Typed outcome or recovery expected Analyze the mutating-setter fixture again and verify that `/value` cannot remain the known old literal `1`. Compare it with the no-op-setter fixture and preserve uncertainty in the result when the new value is not statically known, following the normal variant-matching rules. Keep an immutable direct-return control precise, and keep the documented pure getter read without a receiver write from causing unrelated invalidation. ### Analysis or suspected cause Source-derived reasoning: `src/domain/javascript/javascriptSemanticValues.ts:587` marks only the accessor's own value unknown. `src/domain/javascript/javascriptSemanticMemberMutations.ts:578` records the `/update` write, while `src/domain/javascript/javascriptSemanticMemberMutations.ts:601` through line 629 handles explicit call receiver escapes without the implicit setter invocation. This explains a possible missing invalidation boundary; the observed public analysis and comparison outputs establish the stale literal independently of that source interpretation. ### Additional context - [#1494](https://github.com/morluto/rea/issues/1494), completed, concerns external writes through objects returned by getters; this failure concerns writes performed inside an accessor on its receiver. - [#1665](https://github.com/morluto/rea/issues/1665), completed, concerns receiver/private/super references returned by getters and then written; this own setter writes a sibling through `this` without returning a reference. - [#2653](https://github.com/morluto/rea/issues/2653), completed, concerns getter-local receiver aliases that return shared references; the primary setter fixture has no such alias or returned reference. - [#2654](https://github.com/morluto/rea/issues/2654), completed, concerns container and assigned receiver forms for writes through getter results; the primary receiver is a directly declared local object. - [#2655](https://github.com/morluto/rea/issues/2655), completed, concerns shared objects returned by inherited getters; this fixture uses an own accessor with an internal receiver write. - [#3261](https://github.com/morluto/rea/issues/3261), open, concerns unavailable return shapes for identifier default exports; both named exports here produce return shapes whose known values are incorrect. - [#2659](https://github.com/morluto/rea/issues/2659), completed, concerns missing change locations when an export has no return variants; both inputs here contain return projections and the stale literal originates in analysis.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1185
No description provided.