Improved LeaveSSA pass
## Problem The previous version of LeaveSSA used a very simple approach in which identifiers stored their pre-ssa id, and LeaveSSA restored this id back. The upside of this approach is that it's very simple and trivially correct (assuming no reordering of code). The downside is that after running LeaveSSA we lose all information about which versions of variable declarations are distinct, and which might merge together in a phi. That information is really useful for scope analysis! Consider this input (variables are numbered as they would be in SSA form): ```javascript function foo(a, b, c) { let x$1 = null; if (a) { x$2 = b; } else { x$3 = c; } x$4 = phi(x$2, x$3); return x$4; ``` The current LeaveSSA assigns all 4 variables back to `x$1`, with a single let declaration at `let x$1 = null`. However, from a reactive scopes perspective, there are really just 2 versions of x: the initial x$1 (defined and never used) and then x$2, x$3, and x$4, which have to be merged into a single scope because they are part of a phi. In other words, we can't independently compute x$2, x$3, or x$4 - if any of their inputs changes, we have to redo all the computation. However, the existing structure makes it difficult to figure out the correct starting point for this scope — there is no initial `let` declaration that we can refer to. Instead, we can represent the program as follows after LeaveSSA, and then use this form for scope analysis: ```javascript function foo(a, b, c) { const x$1 = null; // NOTE: rewritten to const let x$2; // synthesized declaration to allow later reassignment if (a) { x$2 = b; } else { x$2 = c; } return x$2; ``` Note that there are only 2 versions of x, and we have synthesized a variable declaration for x$2 at the appropriate scope. Our scope analysis can then determine that the range of x$2 is from the declaration to the end of the if. ## Approach This pass does two main rewrites: * For variables that do *not* appear as a phi id or operand, it rewrites the declaration to be `const`. You can see this above for x$1. * For variables that *do* appear as a phi or operand, it synthesizes a new `let` binding at the appropriate scope (ie, in the appropriate block), and updates all other operands from the phi to use the same id for the variable. You can see this above for x$2, x$3, and x$4. Note that the let binding is generated at the narrowest scope possible. In this example, we generate distinct let bindings for the other if and else branches: ```javascript function foo(a, b, c) { let x = null; if (a) { // we generate a `let x$2` here if (b) { x = 0; // becomes x$2 } else { x = 1; // becomes x$2 } x // becomes x$2 } else { // we generate a `let x$3` here if (c) { x = 2; // becomes x$3 } else { x = 3; // becomes x$3 } x; // becomes x$3 } } ``` Because the different x values from the outer if/else can never join in a phi, we can treat them as independent variables and (re)compute them independently. The algorithm works by iterating in reverse-postorder, and looking ahead at fallthrough blocks to find phi nodes that may need a let declaration (see above example of where these are generated). It also tracks variables which _don't_ participate in a phi so that it can rewrite their declarations to `const`. ## TODO This PR does *not* yet work for cases where there is unconditional assignment within a `while` test condition. That would technically create a distinct version of the variable that shadows the value for the loop, and you can't have variable declarations in a while test condition. That case already doesn't work, though, so i'm punting on it for now until we figure out a bit more around "value" blocks. We have some good options, like desugaring to a `for(;;)` and manually implementing the while semantics in that case.