[GH-ISSUE #7559] [Bug] A later scalar property assignment makes an earlier destructuring dependency ambiguous #4499

Open
opened 2026-10-31 00:50:18 +00:00 by bsakaguchi · 0 comments
Owner

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

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

Running node scripts/rea.mjs analyze-javascript-application <directory> --json on this source produces a candidate destructuring dependency for the second line:

const config = { mode: "strict" };
const { mode } = config;
config.mode = "loose";

A subsequent node scripts/rea.mjs trace-javascript-semantics <query.json> --json query with a semantic-node seed for the mode binding and direction: "backward-provenance" returns status: "ambiguous", 3 nodes and 2 defines relations. The property-specific destructures dependency is absent from this default trace.

Removing only the third line changes the earlier dependency to resolved, and the same query returns found with 7 nodes and 6 relations, including destructures. Replacing the third line with config.other = 1 also preserves that resolved dependency. All analysis and trace commands exit 0.

The captured primitive value is correct even in the failing case: the default trace still reaches the literal "strict". The defect is the loss of its known property dependency from default traversal. Opting into include_ambiguous_dynamic_edges: true exposes the dependency but leaves it classified as candidate and the trace as ambiguous.

Reproduction

  1. From the assigned source baseline with its dependencies and runtime already built, put Node.js 24.22.1 on PATH. Run this shell block from the repository root. It creates three inline fixtures, uses private configuration/cache directories, and runs both public CLI commands.
#!/usr/bin/env bash
set -euo pipefail
checkout=$PWD
probe_root=${1:-$(mktemp -d)}
mkdir -p "$probe_root"/{home,config,cache,data,state,changed,unchanged,other}
rea_probe() {
  env HOME="$probe_root/home" XDG_CONFIG_HOME="$probe_root/config" \
    XDG_CACHE_HOME="$probe_root/cache" XDG_DATA_HOME="$probe_root/data" \
    XDG_STATE_HOME="$probe_root/state" \
    node "$checkout/scripts/rea.mjs" "$@"
}
for name in changed unchanged other; do
  printf '%s\n' 'const config = { mode: "strict" };' 'const { mode } = config;' \
    > "$probe_root/$name/main.js"
done
printf '%s\n' 'config.mode = "loose";' >> "$probe_root/changed/main.js"
printf '%s\n' 'config.other = 1;' >> "$probe_root/other/main.js"
for name in changed unchanged other; do
  rea_probe analyze-javascript-application "$probe_root/$name" --json \
    > "$probe_root/$name.json" 2> "$probe_root/$name.stderr"
done
python3 - "$probe_root" <<'PY'
import json, pathlib, sys
root = pathlib.Path(sys.argv[1])
for name in ('changed', 'unchanged', 'other'):
    app = json.loads((root / (name + '.json')).read_text())
    graph = app['normalized_result']['semantic_graph']
    binding = next(n for n in graph['nodes'] if n['kind'] == 'binding' and n['label'] == 'mode')
    for candidates in ((False, True) if name == 'changed' else (False,)):
        query = {'application': app, 'query': {
            'seed': {'kind': 'semantic-node', 'node_id': binding['node_id']},
            'direction': 'backward-provenance', 'include_ambiguous_dynamic_edges': candidates}}
        (root / f'{name}-{str(candidates).lower()}-query.json').write_text(json.dumps(query))
PY
for name in changed-false changed-true unchanged-false other-false; do
  rea_probe trace-javascript-semantics "$probe_root/$name-query.json" --json \
    > "$probe_root/$name-trace.json" 2> "$probe_root/$name-trace.stderr"
done
python3 - "$probe_root" <<'PY'
import json, pathlib, sys
root = pathlib.Path(sys.argv[1])
for name in ('changed', 'unchanged', 'other'):
    graph = json.loads((root / (name + '.json')).read_text())['normalized_result']['semantic_graph']
    edge = next(e for e in graph['relations'] if e['relation'] == 'destructures')
    print(name, 'destructures', edge['resolution'])
for name in ('changed-false', 'changed-true', 'unchanged-false', 'other-false'):
    trace = json.loads((root / (name + '-trace.json')).read_text())['normalized_result']
    destructures = [e['resolution'] for e in trace['relations'] if e['relation'] == 'destructures']
    print(name, trace['status'], 'nodes', len(trace['nodes']), 'relations', len(trace['relations']),
          'destructures', ','.join(destructures) or 'absent')
PY
  1. Inspect the printed comparison below. The changed fixture differs from unchanged only by a scalar assignment after the destructuring read; other writes an unrelated key after the same read.

The failure and both controls reproduced in two independent analysis-and-trace attempts each. Across these attempts, all six analyses and eight trace commands completed with exit status 0. No process-table workaround, patched runtime, temporary checkout test, or native provider was used.

Expected behavior

The earlier, straight-line destructuring read should retain its resolved dependency on the statically known config.mode slot. Its target identity and capture order are already established before the later assignment. Default backward provenance should include that dependency, as it does for the unchanged and unrelated-key controls.

The workflow guide documents static property slots and destructuring, evaluates captured values at their read position, and preserves straight-line capture order. It defines resolved by a unique admitted static target identity. Candidate edges remain appropriate when target identity or ordering is uncertain; this fixture has no branch, deferred function, dynamic key, accessor or opaque call. This expectation does not require interpreting the later replacement value or proving arbitrary runtime execution.

Impact and scope

Observed impact is bounded loss of a known property-specific dependency from the default semantic trace. The caller receives ambiguity and fewer reachable nodes even though the earlier captured literal remains available. The complete parent graph retains the relation as a candidate; no graph truncation or omitted-node/relation counts caused the difference. This is not a claim of a wrong captured value or falsely complete coverage.

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A simple supported local-object fixture loses a known provenance relation from default traversal, while analysis succeeds and the captured primitive value remains correct.
  • Workaround status: available
  • Workaround basis: Explicitly enabling candidate edges exposes the retained dependency for inspection on the unchanged input; it does not restore resolved status or remove ambiguity.

The demonstrated scope is this ordinary object destructuring and its two controls on macOS. Production prevalence, plain member reads, arrays, rest/spread, interprocedural cases, MCP execution and other platforms were not verified.

REA version

Exact node scripts/rea.mjs --version output:

6.8.0

Source baseline: 65f1c447344a423fa2785847dbcea1e9a9e2454a. The npm latest-version query also returned 6.8.0; the published package was not separately executed.

Runtime and platform

Node.js v24.22.1; macOS 27.0.1, Darwin arm64. Static JavaScript analysis through the built source CLI; no external analysis provider.

Target and artifact context

Three authored directory applications, each containing one inert JavaScript file. REA analyzed the fixture source statically. A separate Node control evaluated only the authored three-line fixture and confirmed that the captured mode is "strict" while the object's final property is "loose".

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Output printed by the reproduction, identical on the independent replay:

changed destructures candidate
unchanged destructures resolved
other destructures resolved
changed-false ambiguous nodes 3 relations 2 destructures absent
changed-true ambiguous nodes 7 relations 6 destructures candidate
unchanged-false found nodes 7 relations 6 destructures resolved
other-false found nodes 7 relations 6 destructures resolved

The changed application's parent graph contains this object-flow unknown at the earlier destructuring slot:

Static destructure property /mode has unknown-coverage presence; its receiver is a retained container; object identity is complete.

The graph also keeps resolved defines flow from literal "strict" to binding mode. Its initializer binding-to-slot writes-property relation becomes candidate, while the explicit later write:/mode occurrence has its own resolved writes-property relation. These are different edges.

Expected-source checks at the baseline:

  • docs/javascript-application-workflows.md:181-194 describes backward provenance through property slots and destructuring; :218-223 preserves earlier reads/captured aliases; :264-267 defines unique static target resolution and default candidate filtering; :437-443 and :460-464 describe read-position values and straight-line capture order.
  • src/application/javascript/JavaScriptSemanticGraphDestructuring.test.ts:3-44 requires resolved source-property relations for known local destructuring patterns.

An additional executed diagnostic imported analyzeJavaScriptSemantics from the built runtime for all three fixture texts. It returned a complete destructuring operation at line 2, with a known object binding and property path ["mode"], in every case. The captured binding's value was literal "strict" throughout. Only the same-property-write case gave the merged config.mode slot presence: "unknown-coverage", with reason "This property may have been mutated.".

Typed outcome or recovery expected

Keep the earlier destructures dependency resolved and traversable with default query options, while preserving uncertainty for reads whose relationship to a write is genuinely unresolved. The two controls should retain their current resolved behavior. No particular replacement-value inference or graph topology beyond restoring the known dependency is required.

Analysis or suspected cause

Source-derived explanation, supported by the separate producer diagnostic: src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts:25-30 builds one value map from merged binding facts. At :41-49, a destructuring relation becomes resolved only when the selected slot has present coverage. The slot lookup at :80-96 uses that merged value rather than the value at the operation's read position. Thus the later same-property mutation's uncertainty changes the earlier dependency's resolution despite the operation retaining complete identity and the captured binding retaining the correct literal.

The initializer-edge downgrade is separately explained by src/application/javascript/JavaScriptSemanticGraphValueProjection.ts:80-85, which selects its resolution from the same slot-presence state. No repair was attempted, and no last-known-good revision was established.

Additional context

Searched open/closed issues, all-state PRs including merged work, issue comments and the relevant inline review discussions before preparing this report.

  • Open #7418 concerns a stale literal after a write inside with; this case has no dynamic scope and retains the correct literal, but loses an earlier property dependency from default traversal.
  • Open #7500 concerns influence through binary/template operands, and #7318 concerns repeated native resolver calls; neither construct appears here.
  • Open #6192 and PR #6972 model containers assigned to properties after declaration. Their read-order discussions concern future folded values and later reads of assigned containers. This scalar-write fixture requires preserving an existing earlier dependency without any assigned-container folding.
  • Closed #4604 and #4609 concern missing destructuring operations for other syntax. Here the plain declaration's operation already exists and is complete. Closed #5585 and #5648 concern value connections across return/argument boundaries, absent here.
  • Merged PR #4196, fixing #3881, preserves captured values before later binding assignments. That value contract already works in this fixture; the remaining failure is the property-specific relation's resolution.
Originally created by @coygeek on GitHub (Oct 27, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7559 ### 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 Running `node scripts/rea.mjs analyze-javascript-application <directory> --json` on this source produces a `candidate` destructuring dependency for the second line: ```js const config = { mode: "strict" }; const { mode } = config; config.mode = "loose"; ``` A subsequent `node scripts/rea.mjs trace-javascript-semantics <query.json> --json` query with a semantic-node seed for the `mode` binding and `direction: "backward-provenance"` returns `status: "ambiguous"`, 3 nodes and 2 `defines` relations. The property-specific `destructures` dependency is absent from this default trace. Removing only the third line changes the earlier dependency to `resolved`, and the same query returns `found` with 7 nodes and 6 relations, including `destructures`. Replacing the third line with `config.other = 1` also preserves that resolved dependency. All analysis and trace commands exit 0. The captured primitive value is correct even in the failing case: the default trace still reaches the literal `"strict"`. The defect is the loss of its known property dependency from default traversal. Opting into `include_ambiguous_dynamic_edges: true` exposes the dependency but leaves it classified as `candidate` and the trace as `ambiguous`. ### Reproduction 1. From the assigned source baseline with its dependencies and runtime already built, put Node.js 24.22.1 on PATH. Run this shell block from the repository root. It creates three inline fixtures, uses private configuration/cache directories, and runs both public CLI commands. ```sh #!/usr/bin/env bash set -euo pipefail checkout=$PWD probe_root=${1:-$(mktemp -d)} mkdir -p "$probe_root"/{home,config,cache,data,state,changed,unchanged,other} rea_probe() { env HOME="$probe_root/home" XDG_CONFIG_HOME="$probe_root/config" \ XDG_CACHE_HOME="$probe_root/cache" XDG_DATA_HOME="$probe_root/data" \ XDG_STATE_HOME="$probe_root/state" \ node "$checkout/scripts/rea.mjs" "$@" } for name in changed unchanged other; do printf '%s\n' 'const config = { mode: "strict" };' 'const { mode } = config;' \ > "$probe_root/$name/main.js" done printf '%s\n' 'config.mode = "loose";' >> "$probe_root/changed/main.js" printf '%s\n' 'config.other = 1;' >> "$probe_root/other/main.js" for name in changed unchanged other; do rea_probe analyze-javascript-application "$probe_root/$name" --json \ > "$probe_root/$name.json" 2> "$probe_root/$name.stderr" done python3 - "$probe_root" <<'PY' import json, pathlib, sys root = pathlib.Path(sys.argv[1]) for name in ('changed', 'unchanged', 'other'): app = json.loads((root / (name + '.json')).read_text()) graph = app['normalized_result']['semantic_graph'] binding = next(n for n in graph['nodes'] if n['kind'] == 'binding' and n['label'] == 'mode') for candidates in ((False, True) if name == 'changed' else (False,)): query = {'application': app, 'query': { 'seed': {'kind': 'semantic-node', 'node_id': binding['node_id']}, 'direction': 'backward-provenance', 'include_ambiguous_dynamic_edges': candidates}} (root / f'{name}-{str(candidates).lower()}-query.json').write_text(json.dumps(query)) PY for name in changed-false changed-true unchanged-false other-false; do rea_probe trace-javascript-semantics "$probe_root/$name-query.json" --json \ > "$probe_root/$name-trace.json" 2> "$probe_root/$name-trace.stderr" done python3 - "$probe_root" <<'PY' import json, pathlib, sys root = pathlib.Path(sys.argv[1]) for name in ('changed', 'unchanged', 'other'): graph = json.loads((root / (name + '.json')).read_text())['normalized_result']['semantic_graph'] edge = next(e for e in graph['relations'] if e['relation'] == 'destructures') print(name, 'destructures', edge['resolution']) for name in ('changed-false', 'changed-true', 'unchanged-false', 'other-false'): trace = json.loads((root / (name + '-trace.json')).read_text())['normalized_result'] destructures = [e['resolution'] for e in trace['relations'] if e['relation'] == 'destructures'] print(name, trace['status'], 'nodes', len(trace['nodes']), 'relations', len(trace['relations']), 'destructures', ','.join(destructures) or 'absent') PY ``` 2. Inspect the printed comparison below. The `changed` fixture differs from `unchanged` only by a scalar assignment after the destructuring read; `other` writes an unrelated key after the same read. The failure and both controls reproduced in two independent analysis-and-trace attempts each. Across these attempts, all six analyses and eight trace commands completed with exit status 0. No process-table workaround, patched runtime, temporary checkout test, or native provider was used. ### Expected behavior The earlier, straight-line destructuring read should retain its resolved dependency on the statically known `config.mode` slot. Its target identity and capture order are already established before the later assignment. Default backward provenance should include that dependency, as it does for the unchanged and unrelated-key controls. The workflow guide documents static property slots and destructuring, evaluates captured values at their read position, and preserves straight-line capture order. It defines `resolved` by a unique admitted static target identity. Candidate edges remain appropriate when target identity or ordering is uncertain; this fixture has no branch, deferred function, dynamic key, accessor or opaque call. This expectation does not require interpreting the later replacement value or proving arbitrary runtime execution. ### Impact and scope Observed impact is bounded loss of a known property-specific dependency from the default semantic trace. The caller receives ambiguity and fewer reachable nodes even though the earlier captured literal remains available. The complete parent graph retains the relation as a candidate; no graph truncation or omitted-node/relation counts caused the difference. This is not a claim of a wrong captured value or falsely complete coverage. - Impact level: P2 - Assessment status: supported - Impact basis: A simple supported local-object fixture loses a known provenance relation from default traversal, while analysis succeeds and the captured primitive value remains correct. - Workaround status: available - Workaround basis: Explicitly enabling candidate edges exposes the retained dependency for inspection on the unchanged input; it does not restore resolved status or remove ambiguity. The demonstrated scope is this ordinary object destructuring and its two controls on macOS. Production prevalence, plain member reads, arrays, rest/spread, interprocedural cases, MCP execution and other platforms were not verified. ### REA version Exact `node scripts/rea.mjs --version` output: ```text 6.8.0 ``` Source baseline: `65f1c447344a423fa2785847dbcea1e9a9e2454a`. The npm latest-version query also returned `6.8.0`; the published package was not separately executed. ### Runtime and platform Node.js `v24.22.1`; macOS `27.0.1`, Darwin `arm64`. Static JavaScript analysis through the built source CLI; no external analysis provider. ### Target and artifact context Three authored directory applications, each containing one inert JavaScript file. REA analyzed the fixture source statically. A separate Node control evaluated only the authored three-line fixture and confirmed that the captured `mode` is `"strict"` while the object's final property is `"loose"`. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Output printed by the reproduction, identical on the independent replay: ```text changed destructures candidate unchanged destructures resolved other destructures resolved changed-false ambiguous nodes 3 relations 2 destructures absent changed-true ambiguous nodes 7 relations 6 destructures candidate unchanged-false found nodes 7 relations 6 destructures resolved other-false found nodes 7 relations 6 destructures resolved ``` The changed application's parent graph contains this object-flow unknown at the earlier destructuring slot: ```text Static destructure property /mode has unknown-coverage presence; its receiver is a retained container; object identity is complete. ``` The graph also keeps resolved `defines` flow from literal `"strict"` to binding `mode`. Its initializer binding-to-slot `writes-property` relation becomes candidate, while the explicit later `write:/mode` occurrence has its own resolved `writes-property` relation. These are different edges. Expected-source checks at the baseline: - `docs/javascript-application-workflows.md:181-194` describes backward provenance through property slots and destructuring; `:218-223` preserves earlier reads/captured aliases; `:264-267` defines unique static target resolution and default candidate filtering; `:437-443` and `:460-464` describe read-position values and straight-line capture order. - `src/application/javascript/JavaScriptSemanticGraphDestructuring.test.ts:3-44` requires resolved source-property relations for known local destructuring patterns. An additional executed diagnostic imported `analyzeJavaScriptSemantics` from the built runtime for all three fixture texts. It returned a `complete` destructuring operation at line 2, with a known object binding and property path `["mode"]`, in every case. The captured binding's value was literal `"strict"` throughout. Only the same-property-write case gave the merged `config.mode` slot `presence: "unknown-coverage"`, with reason `"This property may have been mutated."`. ### Typed outcome or recovery expected Keep the earlier `destructures` dependency resolved and traversable with default query options, while preserving uncertainty for reads whose relationship to a write is genuinely unresolved. The two controls should retain their current resolved behavior. No particular replacement-value inference or graph topology beyond restoring the known dependency is required. ### Analysis or suspected cause Source-derived explanation, supported by the separate producer diagnostic: `src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts:25-30` builds one value map from merged binding facts. At `:41-49`, a destructuring relation becomes resolved only when the selected slot has present coverage. The slot lookup at `:80-96` uses that merged value rather than the value at the operation's read position. Thus the later same-property mutation's uncertainty changes the earlier dependency's resolution despite the operation retaining complete identity and the captured binding retaining the correct literal. The initializer-edge downgrade is separately explained by `src/application/javascript/JavaScriptSemanticGraphValueProjection.ts:80-85`, which selects its resolution from the same slot-presence state. No repair was attempted, and no last-known-good revision was established. ### Additional context Searched open/closed issues, all-state PRs including merged work, issue comments and the relevant inline review discussions before preparing this report. - Open [#7418](https://github.com/morluto/rea/issues/7418) concerns a stale literal after a write inside `with`; this case has no dynamic scope and retains the correct literal, but loses an earlier property dependency from default traversal. - Open [#7500](https://github.com/morluto/rea/issues/7500) concerns influence through binary/template operands, and [#7318](https://github.com/morluto/rea/issues/7318) concerns repeated native resolver calls; neither construct appears here. - Open [#6192](https://github.com/morluto/rea/issues/6192) and [PR #6972](https://github.com/morluto/rea/pull/6972) model containers assigned to properties after declaration. Their read-order discussions concern future folded values and later reads of assigned containers. This scalar-write fixture requires preserving an existing earlier dependency without any assigned-container folding. - Closed [#4604](https://github.com/morluto/rea/issues/4604) and [#4609](https://github.com/morluto/rea/issues/4609) concern missing destructuring operations for other syntax. Here the plain declaration's operation already exists and is complete. Closed [#5585](https://github.com/morluto/rea/issues/5585) and [#5648](https://github.com/morluto/rea/issues/5648) concern value connections across return/argument boundaries, absent here. - Merged [PR #4196](https://github.com/morluto/rea/pull/4196), fixing [#3881](https://github.com/morluto/rea/issues/3881), preserves captured values before later binding assignments. That value contract already works in this fixture; the remaining failure is the property-specific relation's resolution.
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#4499
No description provided.