[GH-ISSUE #4168] [Bug] Export shape comparison reports "changed" for a branch that a known constant never takes #1631

Open
opened 2026-10-16 21:08:12 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @jayhemnani9910 on GitHub (Oct 15, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/4168

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 returned conditional with a known test, or a logical operator with a known left operand, keeps both sides in the static return shape. With const isDev = false; const port = 8080;, isDev ? "development" : "production" is kept as the union ["development", "production"], port || 3000 as [3000, 8080], and isDev && "yes" as [false, "yes"].

rea compare-javascript-export-shapes against a function that returns the literal values then reports all three paths as changed, although Node returns the same object from both. 3000 and "yes" can never be returned here.

Reproduction

Same result on the 6.5.0 npm package and on main cb18d4c9.

python3 - <<'PY'
import json, pathlib, shutil, subprocess, tempfile
work = pathlib.Path(tempfile.mkdtemp(prefix="rea-repro."))
sources = {
    "left": """const isDev = false;
const port = 8080;
export default function config() {
  return { type: "config", mode: isDev ? "development" : "production", port: port || 3000, verbose: isDev && "yes" };
}
""",
    "right": """export default function config() {
  return { type: "config", mode: "production", port: 8080, verbose: false };
}
""",
}
def cli(*args):
    out = subprocess.run(["rea", *args, "--format", "json"], capture_output=True, text=True, timeout=300, check=True)
    return json.loads(out.stdout)
evidence = {}
for side, source in sources.items():
    (work / side).mkdir()
    (work / side / "main.mjs").write_text(source)
    evidence[side] = cli("analyze-javascript-application", str(work / side))
payload = dict(evidence, left_module_path="main.mjs", left_export_name="default", right_module_path="main.mjs", right_export_name="default")
(work / "input.json").write_text(json.dumps(payload))
result = cli("compare-javascript-export-shapes", str(work / "input.json"))["normalized_result"]
print("summary:", json.dumps(result["summary"]))
for change in result["changes"]:
    print(change["status"], change["path"], "left:", json.dumps(change["left"]), "right:", json.dumps(change["right"]))
probe = subprocess.run(["node", "--input-type=module", "-e",
    'import {pathToFileURL} from "node:url"; for (const p of process.argv.slice(1)) console.log(JSON.stringify((await import(pathToFileURL(p))).default()));',
    str(work / "left" / "main.mjs"), str(work / "right" / "main.mjs")], capture_output=True, text=True, check=True)
print("node:", probe.stdout.strip().replace("\n", " | "))
shutil.rmtree(work)
PY

Expected behavior

The retained value holds only what the expression can return: "production", 8080 and false here, so the comparison reports no change. When the test or left operand is unknown, keeping both sides (today's behavior) is right.

src/domain/javascript/javascriptSymbolicBindings.ts already does this for the same expressions through symbolicLogicalRight in javascriptSymbolicControlFlow.ts.

Impact and scope

Comparing a build that reads a constant flag or default with a build that inlines the value reports fields as changed that did not change, and the result is a definite changed, not unknown. Minified bundles usually fold these constants already, so they are hit less. I have not checked how other users of the same semantic values (feature or semantic tracing) treat the extra candidates.

REA version

6.5.0 (npm), and main cb18d4c9

Runtime and platform

Node.js 24.18.0, Linux x86_64

Target and artifact context

Two-file ESM directory fixture, shown above.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

summary: {"added": 0, "removed": 0, "changed": 3, "unknown": 0}
changed /mode left: {"availability": "union", "values": ["development", "production"]} right: {"availability": "literal", "value": "production"}
changed /verbose left: {"availability": "union", "values": [false, "yes"]} right: {"availability": "literal", "value": false}
changed /port left: {"availability": "union", "values": [3000, 8080]} right: {"availability": "literal", "value": 8080}
node: {"type":"config","mode":"production","port":8080,"verbose":false} | {"type":"config","mode":"production","port":8080,"verbose":false}

Analysis or suspected cause

From the source: evaluateExpression in src/domain/javascript/javascriptSemanticValues.ts returns mergeValues([...]) of both sides for every ConditionalExpression and LogicalExpression, without looking at the test or the left operand.

src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts pins this: "does not introduce short-circuit evaluation" expects true ? "one" : missing and "one" || missing to stay ambiguous, and "retains conservative primitive unions across logical expressions" expects (choice ? "" : "one") || "two" as ["", "one", "two"]. Those tests came with #731, whose scope note says it added no short-circuit analysis. I could not find anything saying the union should include branches a known value never reaches, so I'm asking before sending a change: should this evaluator fold known tests and short-circuits?

If yes, I have a small change ready. It folds a conditional when every candidate of its test has the same truthiness, and for ||, && and ?? keeps only the left candidates the operator returns, adding the right operand only when some left candidate reaches it. Unknown or non-primitive tests keep today's merge. It updates the expectations in those two pinned tests and adds a comparison test with the fixture above.

Originally created by @jayhemnani9910 on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4168 ### 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 returned conditional with a known test, or a logical operator with a known left operand, keeps both sides in the static return shape. With `const isDev = false; const port = 8080;`, `isDev ? "development" : "production"` is kept as the union `["development", "production"]`, `port || 3000` as `[3000, 8080]`, and `isDev && "yes"` as `[false, "yes"]`. `rea compare-javascript-export-shapes` against a function that returns the literal values then reports all three paths as `changed`, although Node returns the same object from both. `3000` and `"yes"` can never be returned here. ### Reproduction Same result on the 6.5.0 npm package and on main `cb18d4c9`. ```sh python3 - <<'PY' import json, pathlib, shutil, subprocess, tempfile work = pathlib.Path(tempfile.mkdtemp(prefix="rea-repro.")) sources = { "left": """const isDev = false; const port = 8080; export default function config() { return { type: "config", mode: isDev ? "development" : "production", port: port || 3000, verbose: isDev && "yes" }; } """, "right": """export default function config() { return { type: "config", mode: "production", port: 8080, verbose: false }; } """, } def cli(*args): out = subprocess.run(["rea", *args, "--format", "json"], capture_output=True, text=True, timeout=300, check=True) return json.loads(out.stdout) evidence = {} for side, source in sources.items(): (work / side).mkdir() (work / side / "main.mjs").write_text(source) evidence[side] = cli("analyze-javascript-application", str(work / side)) payload = dict(evidence, left_module_path="main.mjs", left_export_name="default", right_module_path="main.mjs", right_export_name="default") (work / "input.json").write_text(json.dumps(payload)) result = cli("compare-javascript-export-shapes", str(work / "input.json"))["normalized_result"] print("summary:", json.dumps(result["summary"])) for change in result["changes"]: print(change["status"], change["path"], "left:", json.dumps(change["left"]), "right:", json.dumps(change["right"])) probe = subprocess.run(["node", "--input-type=module", "-e", 'import {pathToFileURL} from "node:url"; for (const p of process.argv.slice(1)) console.log(JSON.stringify((await import(pathToFileURL(p))).default()));', str(work / "left" / "main.mjs"), str(work / "right" / "main.mjs")], capture_output=True, text=True, check=True) print("node:", probe.stdout.strip().replace("\n", " | ")) shutil.rmtree(work) PY ``` ### Expected behavior The retained value holds only what the expression can return: `"production"`, `8080` and `false` here, so the comparison reports no change. When the test or left operand is unknown, keeping both sides (today's behavior) is right. `src/domain/javascript/javascriptSymbolicBindings.ts` already does this for the same expressions through `symbolicLogicalRight` in `javascriptSymbolicControlFlow.ts`. ### Impact and scope Comparing a build that reads a constant flag or default with a build that inlines the value reports fields as changed that did not change, and the result is a definite `changed`, not `unknown`. Minified bundles usually fold these constants already, so they are hit less. I have not checked how other users of the same semantic values (feature or semantic tracing) treat the extra candidates. ### REA version 6.5.0 (npm), and main `cb18d4c9` ### Runtime and platform Node.js 24.18.0, Linux x86_64 ### Target and artifact context Two-file ESM directory fixture, shown above. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence ```text summary: {"added": 0, "removed": 0, "changed": 3, "unknown": 0} changed /mode left: {"availability": "union", "values": ["development", "production"]} right: {"availability": "literal", "value": "production"} changed /verbose left: {"availability": "union", "values": [false, "yes"]} right: {"availability": "literal", "value": false} changed /port left: {"availability": "union", "values": [3000, 8080]} right: {"availability": "literal", "value": 8080} node: {"type":"config","mode":"production","port":8080,"verbose":false} | {"type":"config","mode":"production","port":8080,"verbose":false} ``` ### Analysis or suspected cause From the source: `evaluateExpression` in `src/domain/javascript/javascriptSemanticValues.ts` returns `mergeValues([...])` of both sides for every `ConditionalExpression` and `LogicalExpression`, without looking at the test or the left operand. `src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts` pins this: `"does not introduce short-circuit evaluation"` expects `true ? "one" : missing` and `"one" || missing` to stay ambiguous, and `"retains conservative primitive unions across logical expressions"` expects `(choice ? "" : "one") || "two"` as `["", "one", "two"]`. Those tests came with #731, whose scope note says it added no short-circuit analysis. I could not find anything saying the union should include branches a known value never reaches, so I'm asking before sending a change: should this evaluator fold known tests and short-circuits? If yes, I have a small change ready. It folds a conditional when every candidate of its test has the same truthiness, and for `||`, `&&` and `??` keeps only the left candidates the operator returns, adding the right operand only when some left candidate reaches it. Unknown or non-primitive tests keep today's merge. It updates the expectations in those two pinned tests and adds a comparison test with the fixture above.
Author
Owner

@JNTMTMTM commented on GitHub (Oct 15, 2026):

I'm trying to fix

<!-- gh-comment-id:6196761329 --> @JNTMTMTM commented on GitHub (Oct 15, 2026): I'm trying to fix
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#1631
No description provided.