[GH-ISSUE #6457] [Bug] compare-javascript-export-shapes loses known values copied through object spreads #3872

Closed
opened 2026-10-31 00:44:48 +00:00 by bsakaguchi · 0 comments
Owner

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

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

analyze-javascript-application loses the known primitive values copied by const box = { ...o } when o is a local object literal. For a return containing a: box.a and b: box.b, both fields become unknown. Comparing versions that change only o.a from 1 to 9 through compare-javascript-export-shapes then reports no changes and coverage.status: "complete-within-inputs".

The spread case reproduced in two independent fixture-directory rounds through the built CLI. A direct-alias control, const box = o, retained the literals and reported exactly one /a change. All analysis and comparison commands exited 0.

Reproduction

From a fresh source checkout at 9eb829aca34be36ff112826337996f6139f82eff, prepare the locked dependencies and built CLI:

npm ci && npm run build:cached

The observed runs used existing installed dependencies and built output; these setup commands were not rerun. Run this Python 3 script from the checkout root. It constructs both inert applications inline, supplies their complete Evidence to the comparison, and prints the returned fields and comparison summary:

python3 - <<'PY'
import json, os, subprocess, tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as directory:
    root = Path(directory)
    (root / 'home').mkdir()
    env = dict(os.environ, HOME=str(root / 'home'), REA_LOG_LEVEL='silent')

    def rea(*args):
        p = subprocess.run(['node', 'scripts/rea.mjs', *map(str, args), '--json'],
                           env=env, capture_output=True, text=True,
                           check=True, timeout=120)
        return json.loads(p.stdout)

    for mode, expression in [('spread', '{ ...o }'), ('control', 'o')]:
        evidence = []
        for side, value in [('left', 1), ('right', 9)]:
            app = root / (mode + '-' + side)
            app.mkdir()
            (app / 'package.json').write_text(json.dumps({
                'name': 'fixture', 'version': '1.0.0', 'main': 'index.js'}))
            (app / 'index.js').write_text(
                'export default () => { const o = { a: ' + str(value) +
                ', b: 2 }; const box = ' + expression +
                '; return { type: "x", a: box.a, b: box.b }; };\n')
            e = rea('analyze-javascript-application', app)
            evidence.append(e)
            for node in e['normalized_result']['graph']['nodes']:
                for observation in node['observations']:
                    p = observation['properties']
                    if p.get('semantic_role') == 'export-return-shapes':
                        print(mode, side, json.dumps(
                            p['static_return_shapes'][0]['fields']))
        request = root / (mode + '-request.json')
        request.write_text(json.dumps({
            'left': evidence[0], 'right': evidence[1],
            'left_module_path': 'index.js', 'left_export_name': 'default',
            'right_module_path': 'index.js', 'right_export_name': 'default'}))
        r = rea('compare-javascript-export-shapes', request)['normalized_result']
        print(mode, json.dumps({'summary': r['summary'],
              'coverage_status': r['coverage']['status'],
              'changes': [{k: c[k] for k in ('path', 'status', 'left', 'right')}
                          for c in r['changes']]}))
PY

This reproduces the exercised input and control. The spread case was also rerun separately with the same result.

Expected behavior

The spread should retain /a as literal 1 on the left and 9 on the right, and /b as literal 2 on both sides. Their comparison should report exactly one changed row at /a.

The return-shape contract represents literal object fields and explicitly says copied primitive slots remain known, while dynamic spreads, accessors, uncertain iteration, and shared-reference escape retain uncertainty. This fixture has only local own data properties and no calls or mutations.

Impact and scope

P2: a bounded supported syntax pattern loses useful static values and hides a literal change from the comparison's change list. Observed twice for the object-spread fixture; the direct-alias control worked once. The original source remains readable and the returned fields explicitly say unknown. No source mutation or security boundary crossing occurred.

complete-within-inputs should not be read as runtime equivalence: the comparison contract intentionally omits matching unknown projections and disclaims runtime semantics. The defect occurs earlier when known literal values become unknown. The control changes the artifact and is not a workaround for analyzing an unchanged original input. Real-application prevalence and an automated original-input workaround are NOT_ENOUGH_INFO; neither was established.

REA version

6.7.0 (source 9eb829aca3)

Runtime and platform

Node.js v26.12.1; macOS 27.0.1; arm64. The repository's .nvmrc specifies 24.18.0; this observation used the recorded Node 26 runtime. No native provider was involved.

Target and artifact context

Local extracted JavaScript application directories, each with package.json and one index.js. Default exports return object literals with a stable literal /type discriminator. REA statically analyzes the files without executing them.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Reduced observed spread result:

{
  "left_fields": [
    {"path":"/a","presence":"present","state":"unknown","value":null,"reason":"Own property a is unknown-coverage."},
    {"path":"/b","presence":"present","state":"unknown","value":null,"reason":"Own property b is unknown-coverage."},
    {"path":"/type","presence":"present","state":"literal","value":"x","reason":null}
  ],
  "summary":{"added":0,"removed":0,"changed":0,"unknown":0},
  "coverage_status":"complete-within-inputs",
  "changes":[]
}

The right fields matched these unknown projections. Both returned objects had complete root-property coverage. In the direct-alias control, /a was literal 1 versus 9 and /b was literal 2; the comparison returned changed: 1 with only /a changed.

Typed outcome or recovery expected

Preserve the known copied data-property literals in the analysis Evidence so the existing comparison can report /a changing from 1 to 9. Unresolved spread sources and accessor effects must continue to retain uncertainty; those are proposed repair controls, not cases exercised here.

Analysis or suspected cause

Source-derived explanation: evaluateObject treats every SpreadElement as unknown and continues without evaluating its argument. projectValue then yields the observed missing-slot reason. The comparison omits equal field projections, explaining why the two identical unknown fields generate no change row.

Additional context

No repair or last-known-good revision has been established. Array spreads, inline-spread variants, and MCP transport were not exercised. Related closed #5075 concerns a different producer for WebMCP declaration metadata; #3212 concerns nested contextBridge member enumeration after an unknown spread. Neither covers this return-value loss.

Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6457 ### 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 `analyze-javascript-application` loses the known primitive values copied by `const box = { ...o }` when `o` is a local object literal. For a return containing `a: box.a` and `b: box.b`, both fields become unknown. Comparing versions that change only `o.a` from 1 to 9 through `compare-javascript-export-shapes` then reports no changes and `coverage.status: "complete-within-inputs"`. The spread case reproduced in two independent fixture-directory rounds through the built CLI. A direct-alias control, `const box = o`, retained the literals and reported exactly one `/a` change. All analysis and comparison commands exited 0. ### Reproduction From a fresh source checkout at `9eb829aca34be36ff112826337996f6139f82eff`, prepare the locked dependencies and built CLI: ```sh npm ci && npm run build:cached ``` The observed runs used existing installed dependencies and built output; these setup commands were not rerun. Run this Python 3 script from the checkout root. It constructs both inert applications inline, supplies their complete Evidence to the comparison, and prints the returned fields and comparison summary: ```sh python3 - <<'PY' import json, os, subprocess, tempfile from pathlib import Path with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / 'home').mkdir() env = dict(os.environ, HOME=str(root / 'home'), REA_LOG_LEVEL='silent') def rea(*args): p = subprocess.run(['node', 'scripts/rea.mjs', *map(str, args), '--json'], env=env, capture_output=True, text=True, check=True, timeout=120) return json.loads(p.stdout) for mode, expression in [('spread', '{ ...o }'), ('control', 'o')]: evidence = [] for side, value in [('left', 1), ('right', 9)]: app = root / (mode + '-' + side) app.mkdir() (app / 'package.json').write_text(json.dumps({ 'name': 'fixture', 'version': '1.0.0', 'main': 'index.js'})) (app / 'index.js').write_text( 'export default () => { const o = { a: ' + str(value) + ', b: 2 }; const box = ' + expression + '; return { type: "x", a: box.a, b: box.b }; };\n') e = rea('analyze-javascript-application', app) evidence.append(e) for node in e['normalized_result']['graph']['nodes']: for observation in node['observations']: p = observation['properties'] if p.get('semantic_role') == 'export-return-shapes': print(mode, side, json.dumps( p['static_return_shapes'][0]['fields'])) request = root / (mode + '-request.json') request.write_text(json.dumps({ 'left': evidence[0], 'right': evidence[1], 'left_module_path': 'index.js', 'left_export_name': 'default', 'right_module_path': 'index.js', 'right_export_name': 'default'})) r = rea('compare-javascript-export-shapes', request)['normalized_result'] print(mode, json.dumps({'summary': r['summary'], 'coverage_status': r['coverage']['status'], 'changes': [{k: c[k] for k in ('path', 'status', 'left', 'right')} for c in r['changes']]})) PY ``` This reproduces the exercised input and control. The spread case was also rerun separately with the same result. ### Expected behavior The spread should retain `/a` as literal 1 on the left and 9 on the right, and `/b` as literal 2 on both sides. Their comparison should report exactly one `changed` row at `/a`. The [return-shape contract](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/docs/javascript-application-workflows.md#L367-L391) represents literal object fields and explicitly says copied primitive slots remain known, while dynamic spreads, accessors, uncertain iteration, and shared-reference escape retain uncertainty. This fixture has only local own data properties and no calls or mutations. ### Impact and scope P2: a bounded supported syntax pattern loses useful static values and hides a literal change from the comparison's change list. Observed twice for the object-spread fixture; the direct-alias control worked once. The original source remains readable and the returned fields explicitly say unknown. No source mutation or security boundary crossing occurred. `complete-within-inputs` should not be read as runtime equivalence: [the comparison contract](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/docs/javascript-application-workflows.md#L420-L442) intentionally omits matching unknown projections and disclaims runtime semantics. The defect occurs earlier when known literal values become unknown. The control changes the artifact and is not a workaround for analyzing an unchanged original input. Real-application prevalence and an automated original-input workaround are NOT_ENOUGH_INFO; neither was established. ### REA version 6.7.0 (source 9eb829aca34be36ff112826337996f6139f82eff) ### Runtime and platform Node.js v26.12.1; macOS 27.0.1; arm64. The repository's `.nvmrc` specifies 24.18.0; this observation used the recorded Node 26 runtime. No native provider was involved. ### Target and artifact context Local extracted JavaScript application directories, each with `package.json` and one `index.js`. Default exports return object literals with a stable literal `/type` discriminator. REA statically analyzes the files without executing them. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Reduced observed spread result: ```json { "left_fields": [ {"path":"/a","presence":"present","state":"unknown","value":null,"reason":"Own property a is unknown-coverage."}, {"path":"/b","presence":"present","state":"unknown","value":null,"reason":"Own property b is unknown-coverage."}, {"path":"/type","presence":"present","state":"literal","value":"x","reason":null} ], "summary":{"added":0,"removed":0,"changed":0,"unknown":0}, "coverage_status":"complete-within-inputs", "changes":[] } ``` The right fields matched these unknown projections. Both returned objects had complete root-property coverage. In the direct-alias control, `/a` was literal 1 versus 9 and `/b` was literal 2; the comparison returned `changed: 1` with only `/a` changed. ### Typed outcome or recovery expected Preserve the known copied data-property literals in the analysis Evidence so the existing comparison can report `/a` changing from 1 to 9. Unresolved spread sources and accessor effects must continue to retain uncertainty; those are proposed repair controls, not cases exercised here. ### Analysis or suspected cause Source-derived explanation: [evaluateObject](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/src/domain/javascript/javascriptSemanticValues.ts#L753-L828) treats every `SpreadElement` as unknown and continues without evaluating its argument. [projectValue](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/src/domain/javascript/javascriptSemanticValues.ts#L969-L984) then yields the observed missing-slot reason. The comparison [omits equal field projections](https://github.com/morluto/rea/blob/9eb829aca34be36ff112826337996f6139f82eff/src/domain/javascript/javascriptExportShapeVariants.ts#L375-L404), explaining why the two identical unknown fields generate no change row. ### Additional context No repair or last-known-good revision has been established. Array spreads, inline-spread variants, and MCP transport were not exercised. Related closed [#5075](https://github.com/morluto/rea/issues/5075) concerns a different producer for WebMCP declaration metadata; [#3212](https://github.com/morluto/rea/issues/3212) concerns nested contextBridge member enumeration after an unknown spread. Neither covers this return-value loss.
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#3872
No description provided.