Value sets and dynamic calls
Resolving CALL WS-PGM without running the program: track every value a field can hold, and say so when the list may be short.
Where this shows up in reverse engineering
Any call graph for a program with dynamic dispatch has this hole in it until something fills it in — the question is always the same: what values can actually reach the thing deciding where to go next.
- A menu, a router, a transaction dispatcher — any program whose next call is chosen by a field's runtime value rather than written as a literal — is invisible to a call graph built by reading
CALLstatements alone. The value-set technique on this page is what turns that into a graph with real edges instead of a gap at every dispatch point. - The same question, on the Java side, over a different kind of value: a backward data-dependency walk that crosses method boundaries can resolve a literal that reaches a dynamically-dispatched call's argument several call frames up — the identical "what value could this hold" question, answered by walking data flow backward instead of forward.
- Virtual dispatch resolution is a value-set problem one level up: "every concrete class an interface call's receiver could actually be at runtime" is the same kind of set, just over types instead of string literals — a call graph builder resolving polymorphic calls is running this page's technique on a different domain.
The problem
CALL 'PROGB' names its target in the source; a call graph can draw that edge by reading it. CALL WS-PGM calls whatever program name is in WS-PGM when the statement runs. Leave those edges out and the call graph has holes exactly where a program dispatches on data — menus, routers, transaction handlers. Guess and it draws edges that do not exist.
The intuition
For every field, carry the set of byte values it may hold at each statement. A MOVE of a literal sets it to exactly that value. A MOVE from another field copies that field's set. Where paths meet, take the union. At a CALL through a field, the field's set is the list of programs that call could reach.
The set can be incomplete: the value may come from a file, from a caller, or from nowhere the analysis can see. So each set also carries reasons it may be missing values. An empty set of reasons means "these and no others"; any reason means "at least these".
The example
Two branches put different program names into WS-PGM before calling through it. A second call goes through WS-OTHER, which nothing assigns.
IDENTIFICATION DIVISION.
PROGRAM-ID. VALSETS.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-FLAG PIC X.
01 WS-PGM PIC X(8).
01 WS-OTHER PIC X(8).
PROCEDURE DIVISION.
MAIN-PARA.
IF WS-FLAG = 'Y'
MOVE 'PROGB' TO WS-PGM
ELSE
MOVE 'PROGC' TO WS-PGM
END-IF.
CALL WS-PGM.
CALL WS-OTHER.
STOP RUN.
Step by step
Values are shown as stored: PIC X(8) pads 'PROGB' with three spaces, written ␣. A call trims the trailing spaces off a value to get a program name.
The value sets, one statement at a time (6 steps)
-
IF WS-FLAG = 'Y'. On entry, nothing has been written: every field is empty and uninitialised.
Value sets after this statement (amber: reasons the set may be short)
field values WS-FLAG {} + uninitialised WS-PGM {} + uninitialised WS-OTHER {} + uninitialised -
MOVE 'PROGB' TO WS-PGM replaces WS-PGM's set with exactly one value, and no reasons.
Value sets after this statement (amber: reasons the set may be short)
field values WS-FLAG {} + uninitialised WS-PGM {'PROGB␣␣␣'} WS-OTHER {} + uninitialised -
MOVE 'PROGC' TO WS-PGM replaces WS-PGM's set with exactly one value, and no reasons.
Value sets after this statement (amber: reasons the set may be short)
field values WS-FLAG {} + uninitialised WS-PGM {'PROGC␣␣␣'} WS-OTHER {} + uninitialised -
Two paths meet: each field's values and reasons are the union of both arms'. CALL WS-PGM: candidates: 2 possible edges, to PROGB and PROGC.
Value sets after this statement (amber: reasons the set may be short)
field values WS-FLAG {} + uninitialised WS-PGM {'PROGB␣␣␣', 'PROGC␣␣␣'} WS-OTHER {} + uninitialised Row written for this call
candidates PROGB, PROGC reasons none in the graph candidates: 2 possible edges, to PROGB and PROGC -
CALL WS-OTHER: unresolved: a dynamic-target node for WS-OTHER, because uninitialised.
Value sets after this statement (amber: reasons the set may be short)
field values WS-FLAG {} + uninitialised WS-PGM {'PROGB␣␣␣', 'PROGC␣␣␣'} WS-OTHER {} + uninitialised Row written for this call
candidates none reasons uninitialised in the graph unresolved: a dynamic-target node for WS-OTHER, because uninitialised -
STOP RUN.
Value sets after this statement (amber: reasons the set may be short)
field values WS-FLAG {} + uninitialised WS-PGM {'PROGB␣␣␣', 'PROGC␣␣␣'} WS-OTHER {} + uninitialised
The rule
MOVE literal TO f : f := ({literal padded to f}, {})
MOVE g TO f : f := g's set
merge : (values₁ ∪ values₂, reasons₁ ∪ reasons₂), at most 32 values
never written : ({}, {uninitialised})
call through f : candidates = trimmed values of f, reasons = f's reasons
A call site then becomes one of three things in the graph:
- Proven — exactly one candidate and no reasons. An ordinary call edge, as if the name were written in the source.
- Candidates — any other site with at least one candidate. One edge per candidate, marked as possible rather than certain.
- Unresolved — no candidates. A single dynamic-target node carrying the expression and the reasons, so the hole is visible rather than silent.
What goes wrong
- Under-approximation is the normal case, and the reasons say why.
uninitialised: nothing in the program wrote the field.over-budget: more than 32 values reached it; the first 32 in sorted order are kept.incomplete-walk: the fixpoint stopped before settling, so later values may not have arrived. Others name where a value came from that the analysis cannot follow: a file, the caller's parameters, a called program, a non-literal expression. - One candidate is not proof. A walk that stopped early can leave a single value in the set while a second was still on its way. That is why a proven edge needs an empty set of reasons, not just one candidate.
- A value is not always a program name. A field can hold spaces or a sentinel like
'NONE'on some path. Values that cannot be a program name are dropped from the candidates and recorded separately.
In cobble
The fixpoint stage (cobble-dataflow-fixpoint) builds the value sets, applies the budget and records the reasons. It writes each dynamic call site as a targets.jsonl row. The graph build (cobble-build) reads those rows and applies the three-way rule.