Graph algorithms · how did I get here

Reaching conditions

Read first: Visiting a graph

Whether a statement runs is decided by every IF on every path into it: along a path all its guards must hold, where paths merge any one will do, and a branch that never reaches the statement must add nothing. Reachability only says yes or no, and reading guards off by eye across GO TOs misses one or counts a branch that goes elsewhere.

In this page's example, from cobol-rekt's README, an IF WS-NUM1 > 10 jumps to an IF WS-NUM1 = 201 that guards a DISPLAY. The technique slices away the two STOP RUN arms, walks the four statements left in topological order, and gives the DISPLAY the condition AND(C2, C1): WS-NUM1 = 201 and WS-NUM1 > 10. It works on acyclic slices only.

Topological order is explained in Visiting a graph.

The example: a DISPLAY behind two IFs

The page's model of the README program: start A, the IFs as B and C, the DISPLAY as D, a STOP RUN on each arm not taken. The conditions are written C1 and C2:

C1 = WS-NUM1 > 10C2 = WS-NUM1 = 201
flowchart TD
    A -->|always| B["B: IF C1"]
    B -->|C1| C["C: IF C2"]
    B -->|"not C1"| Stop1["STOP RUN"]
    C -->|C2| D["D: DISPLAY"]
    C -->|"not C2"| Stop2["STOP RUN"]

Neither STOP RUN in the graph above leads to D, so the first step throws both away. What is left is the slice from A to D: only the statements and edges on some path from the start to the target. Four statements, three edges:

flowchart TD
    A -->|always| B
    B -->|"then: C1"| C
    C -->|"then: C2"| D

The answer for the slice above, one condition per statement, is what the walk below builds. D's is the one the question was about:

statementcondition
AFALSE (an implementation quirk; see step 1)
BTRUE
CC1
DAND(C2, C1)

The lines of the README program that the graph models. GO TO SA2 is the then-edge from B to C; the model leaves out the paragraph labels:

SA1.
    IF WS-NUM1 > 10
     THEN
         GO TO SA2.
    STOP RUN.
SA2.
    IF WS-NUM1 = 201
     THEN
         DISPLAY "IT IS DONE".
 STOP RUN.

cobol-rekt's README gives this as the reaching condition it prints for the DISPLAY, reformatted here onto two lines. It is AND(C2, C1), with C2 first, as in the table:

and(eq(ref('WS-NUM1'), primitive(201.0)),
    gt(ref('WS-NUM1'), primitive(10.0)))
Full listing (the README's STOPRUN program)
       IDENTIFICATION DIVISION.
       PROGRAM-ID.    STOPRUN.
       AUTHOR.        MOJO
       DATE-WRITTEN.  SEP 2024.
       ENVIRONMENT DIVISION.
       DATA DIVISION.
       WORKING-STORAGE SECTION.
            01 WS-NUM1 PIC 9(9) VALUE 5.
       PROCEDURE DIVISION.
       S SECTION.
       SA1.
           IF WS-NUM1 > 10
            THEN
                GO TO SA2.
           STOP RUN.
       SA2.
           IF WS-NUM1 = 201
            THEN
                DISPLAY "IT IS DONE".
        STOP RUN.

Step by step

The walk visits the slice in topological order, so every statement comes after everything that leads into it, and its predecessors' conditions are already known when it needs them. Each step applies this rule once:

condition(v) = OR, over every incoming edge (u → v):
  AND(that edge's own condition, condition(u))

Diagrams below mark the statement whose condition a step is computing in amber.

Computing the condition at each of A, B, C, D (4 steps)
  1. Nothing computed yet.

    flowchart TD
        A -->|always| B
        B -->|"then: C1"| C
        C -->|"then: C2"| D
    
  2. A has no incoming edge in the slice at all — it is the start. With nothing to combine, its condition is the empty case.

    flowchart TD
        A -->|always| B
        B -->|"then: C1"| C
        C -->|"then: C2"| D
        class A changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    

    condition(A)

    FALSE

    Flag: FALSE is what the real implementation gives here — OR over no incoming edges. The correct value is TRUE: the start is always reached. See What goes wrong.

  3. B's only way in: the always-taken edge from A. Combine that edge's own condition (always true) with A's condition.

    flowchart TD
        A -->|always| B
        B -->|"then: C1"| C
        C -->|"then: C2"| D
        class B changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    

    condition(B)

    TRUE

    Flag: literally this is AND(TRUE, FALSE), which in correct boolean logic is FALSE. The real implementation returns TRUE because of a second quirk in its AND. TRUE is the right answer for B — it is what AND(TRUE, TRUE) gives with A's correct condition — but only because the two mistakes cancel. See What goes wrong.

  4. C's only way in: the then-edge from B, carrying C1. Combined with B's (true) condition, C1 survives unchanged.

    flowchart TD
        A -->|always| B
        B -->|"then: C1"| C
        C -->|"then: C2"| D
        class C changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    

    condition(C)

    C1
  5. D's only way in: the then-edge from C, carrying C2. Combined with C's condition, C1: both now have to hold.

    flowchart TD
        A -->|always| B
        B -->|"then: C1"| C
        C -->|"then: C2"| D
        class D changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    

    condition(D)

    AND(C2, C1)

Step 4 is the rule box in full: one incoming edge, C → D, whose own condition is C2, ANDed with condition(C), which step 3 left as C1. Each statement in this slice has one way in, so the OR line never has more than one operand here. Step 1 is the OR over no edges at all. AND(C2, C1) means D is reached exactly when both guards hold, and it matches the README output above.

Why each edge carries a condition

Reachability on the first graph says only that D can be reached. To get a condition, every edge is tagged with its own. Leaving an ordinary statement, as at A, control goes the one way it can: always taken, condition TRUE. Leaving an IF, the then-edge carries the IF's condition and the else-edge carries its negation. For the five edges of the first graph:

edgekindits own conditionin the slice?
A → BalwaysTRUEyes
B → CthenC1yes
B → STOP RUNelseNOT C1no
C → DthenC2yes
C → STOP RUNelseNOT C2no

The rule box then reads these off the three rows marked "yes": steps 2, 3 and 4 each took one row's condition and ANDed it with the predecessor's. The two "no" rows would only matter if they led to D; the next example changes the shape so that an else-edge does.

A branch that merges back

One statement can have more than one way in. Change the shape so both of B's arms lead to D instead of one of them being a dead end:

flowchart TD
    A -->|always| B["B: IF C1"]
    B -->|C1| C
    B -->|"not C1"| X
    C -->|always| D
    X -->|always| D
Computing the condition with two ways into D (4 steps)
  1. Nothing computed yet.

    flowchart TD
        A -->|always| B
        B -->|C1| C
        B -->|"not C1"| X
        C -->|always| D
        X -->|always| D
    
  2. A: no incoming edge. Same empty case as before.

    flowchart TD
        A -->|always| B
        B -->|C1| C
        B -->|"not C1"| X
        C -->|always| D
        X -->|always| D
        class A changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    

    condition(A)

    FALSE

    Flag: as before, the real implementation's value; the correct one is TRUE.

  3. B: always-taken from A. Same as before.

    flowchart TD
        A -->|always| B
        B -->|C1| C
        B -->|"not C1"| X
        C -->|always| D
        X -->|always| D
        class B changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    

    condition(B)

    TRUE

    Flag: as before, the implementation's AND(TRUE, FALSE) returns TRUE — wrong boolean logic, right answer, because it cancels the wrong FALSE at A.

  4. C: then-edge from B, carrying C1. X: else-edge from B, carrying NOT C1.

    flowchart TD
        A -->|always| B
        B -->|C1| C
        B -->|"not C1"| X
        C -->|always| D
        X -->|always| D
        class C,X changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    

    condition(C)

    C1

    condition(X)

    NOT C1
  5. D: two ways in, both always-taken, from C and from X. Combine each edge with its source's condition, then take either being enough.

    flowchart TD
        A -->|always| B
        B -->|C1| C
        B -->|"not C1"| X
        C -->|always| D
        X -->|always| D
        class D changed
        classDef changed fill:#f5b041,stroke:#f5b041,color:#000
        linkStyle 3 stroke:#f5b041,stroke-width:3px
        linkStyle 4 stroke:#f5b041,stroke-width:3px
    

    condition(D)

    OR(C1, NOT C1)

Step 4's OR(C1, NOT C1) says reach D when C1 holds, or when it does not: every possible value. The graph above shows the same by inspection, since both of B's arms lead to D. The walk's answer, though, is the literal expression, not the word "always"; the third bullet below shows why.

What goes wrong

Each bullet comes with the small case that shows it. The first three are taken from the two walks above.

Where this shows up in reverse engineering

Where this is used

The slice does most of the narrowing: in the example both STOP RUNs were gone before any condition was built.

The same idea, under the same name, drives control-flow structuring in decompilers: Yakdan et al. compute reaching conditions over acyclic regions to turn a recovered flowgraph back into if/else without pattern matching.[1]

References

  1. K. Yakdan, S. Eschweiler, E. Gerhards-Padilla, and M. Smith. "No More Gotos: Decompilation Using Pattern-Independent Control-Flow Structuring and Semantics-Preserving Transformations." Network and Distributed System Security Symposium (NDSS 2015), 2015. Reaching conditions over acyclic regions as the basis for pattern-independent structuring.