[GH-ISSUE #3880] [Bug] A conditionally replaced callback parameter becomes a resolved local call #1499

Closed
opened 2026-10-16 21:06:13 +00:00 by bsakaguchi · 0 comments
Owner

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

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

When a callback parameter is replaced only conditionally (if (flag) cb = fallback; return cb();), rea analyze-javascript-application records the cb() call as a resolved calls edge to fallback, exactly as for an unconditional replacement. rea trace-javascript-semantics then returns that edge with status: "found" and no unknowns, although the caller-supplied callback is still a possible target.

Both calls are reported as resolved edges to fallback. The default trace from the line-4 call returns status: "found", one resolved calls relation, and an empty unknown inventory. Executing the fixture shows choose(() => "incoming", false) returns "incoming", so fallback is not the unique target.

Reproduction

  1. Run the script below. It writes a one-file fixture with a conditional replacement (choose, line 4 call) and an unconditional control (definite, line 8 call), analyzes it, traces forward from the line-4 call site over calls relations, and executes the authored fixture as an independent runtime control.
python3 - <<'PY'
import json, os, pathlib, shutil, subprocess, tempfile
work = pathlib.Path(tempfile.mkdtemp(prefix="rea-repro."))
(work / "app").mkdir()
(work / "home").mkdir()
(work / "app" / "main.mjs").write_text('''function fallback() { return "local"; }
export function choose(cb, flag) {
  if (flag) cb = fallback;
  return cb();
}
export function definite(cb) {
  cb = fallback;
  return cb();
}
''')
env = dict(os.environ, HOME=str(work / "home"))
def cli(*args):
    result = subprocess.run(["rea", *args, "--format", "json"], env=env, capture_output=True, text=True, timeout=60, check=True)
    return json.loads(result.stdout)
application = cli("analyze-javascript-application", str(work / "app"))
graph = application["normalized_result"]["semantic_graph"]
nodes = {n["node_id"]: n for n in graph["nodes"]}
for relation in graph["relations"]:
    if relation["relation"] == "calls":
        call, target = nodes[relation["source_node_id"]], nodes[relation["target_node_id"]]
        print("calls:", call["identity"]["source_range"]["start"]["line"], "=>", target["label"], relation["resolution"])
call = next(n for n in graph["nodes"] if n["kind"] == "call-site" and n["identity"]["source_range"]["start"]["line"] == 4)
payload = {"application": application, "query": {"seed": {"kind": "semantic-node", "node_id": call["node_id"]}, "direction": "forward-influence", "allowed_relations": ["calls"]}}
(work / "trace-input.json").write_text(json.dumps(payload))
trace = cli("trace-javascript-semantics", str(work / "trace-input.json"))["normalized_result"]
print("trace:", json.dumps({"status": trace["status"], "relations": [{"relation": r["relation"], "resolution": r["resolution"]} for r in trace["relations"]], "unknowns": trace["unknowns"]}))
probe = subprocess.run(["node", "--input-type=module", "-e",
    'import {pathToFileURL} from "node:url"; const m = await import(pathToFileURL(process.argv[1])); '
    'console.log(JSON.stringify([m.choose(() => "incoming", false), m.choose(() => "incoming", true), m.definite(() => "incoming")]));',
    str(work / "app" / "main.mjs")], env=env, capture_output=True, text=True, check=True, timeout=10)
print("fixture runtime:", probe.stdout.strip())
shutil.rmtree(work)
PY

Expected behavior

The line-4 cb() call in choose does not get a uniquely resolved edge to fallback; it keeps the incoming callback as an alternative (a candidate edge or an explicit dynamic-call unknown), and the default trace does not report it as found with no unknowns. The line-8 call in definite can stay resolved to fallback. docs/javascript-application-workflows.md describes resolved as a unique static target and requires dynamic calls to stay explicit unknowns.

Impact and scope

Conditional replacement of an incoming callback parameter produces a false resolved call edge that the default semantic trace admits, which can misdirect caller and influence analysis; the effect is bounded static-analysis incorrectness.

Workaround: unverified. No public option that keeps this call unresolved or surfaces the incoming-callback alternative was established; a reader can only inspect the source manually.

Reporter's impact estimate: P2 (supported).

REA version

6.5.0, built from source at main commit 034342ba28 (latest npm release is 6.5.0)

Runtime and platform

Node.js v26.10.0; macOS 27.0.1 arm64

Target and artifact context

Local JavaScript ES module directory

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

  • Expected source: docs/javascript-application-workflows.md:70-72 "Semantic relation tracing" (uniquely resolved local calls), docs/javascript-application-workflows.md:103-106 (dynamic calls stay explicit unknowns), and docs/javascript-application-workflows.md:108-111 (resolved means a unique static target; candidate edges are excluded by default)
  • Failure source: observed analyze-javascript-application semantic graph and trace-javascript-semantics output for the fixture above, with the fixture's own runtime result as an independent control
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete
calls: 8 => fallback resolved
calls: 4 => fallback resolved
trace: {"status": "found", "relations": [{"relation": "calls", "resolution": "resolved"}], "unknowns": []}
fixture runtime: ["incoming","local","local"]

Source-derived reasoning: src/domain/javascript/javascriptSemanticAnalysis.ts:1518 appends the assignment initializer and records its conditional execution in conditionallySkippedExpressions. src/domain/javascript/javascriptSemanticCallResolution.ts:132 resolves only the retained initializer nodes and ignores the skipped assignment and the parameter's incoming value, so the single local candidate becomes complete: true at line 146. src/domain/javascript/javascriptSemanticCalls.ts:141 promotes it to exact, and line 152 omits the dynamic-call frontier. A direct domain probe also showed the conditionally assigned cb binding value as unknown while the call was exact.

Typed outcome or recovery expected

The call should remain a candidate or explicit dynamic-call unknown that keeps the incoming callback alternative, and the default semantic trace should not report it as found with no unknowns.

Restoration check:

For the fixture above, the line-4 call currently prints calls: 4 => fallback resolved and the trace prints "status": "found" with "unknowns": []; after a fix the line-4 call is not a uniquely resolved edge and the default trace reports the remaining callback alternative as an unknown or ambiguity. Control: the line-8 call in definite continues to print calls: 8 => fallback resolved.

Analysis or suspected cause

Source-derived: src/domain/javascript/javascriptSemanticCallResolution.ts:132-146 resolves only retained initializer nodes and ignores conditionallySkippedExpressions recorded at src/domain/javascript/javascriptSemanticAnalysis.ts:1518; src/domain/javascript/javascriptSemanticCalls.ts:141 then promotes the call to exact and line 152 omits the dynamic-call frontier.

Additional context

Related closed #2562 concerned definite binding and return values after skipped initialization. This report concerns the separate callable-target resolver admitting a conditionally skipped plain assignment as an exact call.

This report came from AI-assisted source review with local reproduction where stated above; the evidence labels distinguish observed runs from source-only reasoning.

Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3880 ### 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 When a callback parameter is replaced only conditionally (`if (flag) cb = fallback; return cb();`), `rea analyze-javascript-application` records the `cb()` call as a `resolved` `calls` edge to `fallback`, exactly as for an unconditional replacement. `rea trace-javascript-semantics` then returns that edge with `status: "found"` and no unknowns, although the caller-supplied callback is still a possible target. Both calls are reported as `resolved` edges to `fallback`. The default trace from the line-4 call returns `status: "found"`, one resolved `calls` relation, and an empty unknown inventory. Executing the fixture shows `choose(() => "incoming", false)` returns `"incoming"`, so `fallback` is not the unique target. ### Reproduction 1. Run the script below. It writes a one-file fixture with a conditional replacement (`choose`, line 4 call) and an unconditional control (`definite`, line 8 call), analyzes it, traces forward from the line-4 call site over `calls` relations, and executes the authored fixture as an independent runtime control. ```sh python3 - <<'PY' import json, os, pathlib, shutil, subprocess, tempfile work = pathlib.Path(tempfile.mkdtemp(prefix="rea-repro.")) (work / "app").mkdir() (work / "home").mkdir() (work / "app" / "main.mjs").write_text('''function fallback() { return "local"; } export function choose(cb, flag) { if (flag) cb = fallback; return cb(); } export function definite(cb) { cb = fallback; return cb(); } ''') env = dict(os.environ, HOME=str(work / "home")) def cli(*args): result = subprocess.run(["rea", *args, "--format", "json"], env=env, capture_output=True, text=True, timeout=60, check=True) return json.loads(result.stdout) application = cli("analyze-javascript-application", str(work / "app")) graph = application["normalized_result"]["semantic_graph"] nodes = {n["node_id"]: n for n in graph["nodes"]} for relation in graph["relations"]: if relation["relation"] == "calls": call, target = nodes[relation["source_node_id"]], nodes[relation["target_node_id"]] print("calls:", call["identity"]["source_range"]["start"]["line"], "=>", target["label"], relation["resolution"]) call = next(n for n in graph["nodes"] if n["kind"] == "call-site" and n["identity"]["source_range"]["start"]["line"] == 4) payload = {"application": application, "query": {"seed": {"kind": "semantic-node", "node_id": call["node_id"]}, "direction": "forward-influence", "allowed_relations": ["calls"]}} (work / "trace-input.json").write_text(json.dumps(payload)) trace = cli("trace-javascript-semantics", str(work / "trace-input.json"))["normalized_result"] print("trace:", json.dumps({"status": trace["status"], "relations": [{"relation": r["relation"], "resolution": r["resolution"]} for r in trace["relations"]], "unknowns": trace["unknowns"]})) probe = subprocess.run(["node", "--input-type=module", "-e", 'import {pathToFileURL} from "node:url"; const m = await import(pathToFileURL(process.argv[1])); ' 'console.log(JSON.stringify([m.choose(() => "incoming", false), m.choose(() => "incoming", true), m.definite(() => "incoming")]));', str(work / "app" / "main.mjs")], env=env, capture_output=True, text=True, check=True, timeout=10) print("fixture runtime:", probe.stdout.strip()) shutil.rmtree(work) PY ``` ### Expected behavior The line-4 `cb()` call in `choose` does not get a uniquely `resolved` edge to `fallback`; it keeps the incoming callback as an alternative (a candidate edge or an explicit dynamic-call unknown), and the default trace does not report it as found with no unknowns. The line-8 call in `definite` can stay resolved to `fallback`. `docs/javascript-application-workflows.md` describes `resolved` as a unique static target and requires dynamic calls to stay explicit unknowns. ### Impact and scope Conditional replacement of an incoming callback parameter produces a false resolved call edge that the default semantic trace admits, which can misdirect caller and influence analysis; the effect is bounded static-analysis incorrectness. Workaround: unverified. No public option that keeps this call unresolved or surfaces the incoming-callback alternative was established; a reader can only inspect the source manually. Reporter's impact estimate: P2 (supported). ### REA version 6.5.0, built from source at main commit 034342ba28c8e56c3746be92b78aa7299afa02bd (latest npm release is 6.5.0) ### Runtime and platform Node.js v26.10.0; macOS 27.0.1 arm64 ### Target and artifact context Local JavaScript ES module directory ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence - Expected source: docs/javascript-application-workflows.md:70-72 "Semantic relation tracing" (uniquely resolved local calls), docs/javascript-application-workflows.md:103-106 (dynamic calls stay explicit unknowns), and docs/javascript-application-workflows.md:108-111 (`resolved` means a unique static target; candidate edges are excluded by default) - Failure source: observed `analyze-javascript-application` semantic graph and `trace-javascript-semantics` output for the fixture above, with the fixture's own runtime result as an independent control - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete ```text calls: 8 => fallback resolved calls: 4 => fallback resolved trace: {"status": "found", "relations": [{"relation": "calls", "resolution": "resolved"}], "unknowns": []} fixture runtime: ["incoming","local","local"] ``` Source-derived reasoning: `src/domain/javascript/javascriptSemanticAnalysis.ts:1518` appends the assignment initializer and records its conditional execution in `conditionallySkippedExpressions`. `src/domain/javascript/javascriptSemanticCallResolution.ts:132` resolves only the retained initializer nodes and ignores the skipped assignment and the parameter's incoming value, so the single local candidate becomes `complete: true` at line 146. `src/domain/javascript/javascriptSemanticCalls.ts:141` promotes it to `exact`, and line 152 omits the dynamic-call frontier. A direct domain probe also showed the conditionally assigned `cb` binding value as unknown while the call was exact. ### Typed outcome or recovery expected The call should remain a candidate or explicit dynamic-call unknown that keeps the incoming callback alternative, and the default semantic trace should not report it as found with no unknowns. Restoration check: For the fixture above, the line-4 call currently prints `calls: 4 => fallback resolved` and the trace prints `"status": "found"` with `"unknowns": []`; after a fix the line-4 call is not a uniquely resolved edge and the default trace reports the remaining callback alternative as an unknown or ambiguity. Control: the line-8 call in `definite` continues to print `calls: 8 => fallback resolved`. ### Analysis or suspected cause Source-derived: src/domain/javascript/javascriptSemanticCallResolution.ts:132-146 resolves only retained initializer nodes and ignores conditionallySkippedExpressions recorded at src/domain/javascript/javascriptSemanticAnalysis.ts:1518; src/domain/javascript/javascriptSemanticCalls.ts:141 then promotes the call to exact and line 152 omits the dynamic-call frontier. ### Additional context Related closed #2562 concerned definite binding and return values after skipped initialization. This report concerns the separate callable-target resolver admitting a conditionally skipped plain assignment as an exact call. This report came from AI-assisted source review with local reproduction where stated above; the evidence labels distinguish observed runs from source-only reasoning.
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#1499
No description provided.