El problema fundamental que JSIR aborda es la limitación de los Abstract Syntax Trees (ASTs) para realizar análisis de código complejos y transformaciones fuente-a-fuente en lenguajes dinámicos como JavaScript. Mientras que los ASTs son excelentes para representar la estructura sintáctica, carecen de capacidades inherentes para el análisis de flujo de control y flujo de datos, esenciales para optimizaciones, transpilación avanzada, ofuscación/desofuscación y decompilación.
La industria de compiladores ha reconocido esta brecha, con lenguajes como Rust y Swift adoptando IRs de alto nivel específicos del lenguaje antes de la bajada a LLVM. JSIR se alinea con esta tendencia, proporcionando una representación que no solo captura toda la información a nivel de fuente, sino que también integra las capacidades de análisis de flujo de datos de MLIR. Esto permite a las herramientas de JavaScript superar las limitaciones de los enfoques puramente basados en AST, que a menudo requieren la reimplementación de lógica de flujo de control y datos de manera ad-hoc.
La necesidad de JSIR se agudiza por la prevalencia de herramientas JavaScript que requieren emitir código JavaScript como salida (transpiladores como Babel, optimizadores como Closure Compiler, bundlers como Webpack). Estas herramientas operan tradicionalmente en ASTs, lo que dificulta la integración de análisis complejos. JSIR busca llenar este vacío, ofreciendo una base unificada para análisis y transformaciones que pueden ser levantadas de nuevo a código fuente de alta fidelidad.
Arquitectura del Sistema
JSIR se construye sobre el framework MLIR (Multi-Level IR), aprovechando sus capacidades para definir IRs extensibles y composables. La arquitectura central de JSIR se basa en una correspondencia casi uno a uno entre los nodos del AST de JavaScript (siguiendo el estándar ESTree) y las operaciones de JSIR. Esto asegura que toda la información a nivel de fuente se preserve, permitiendo un 'round-trip' sin pérdidas entre el código fuente, el AST y el JSIR.
Para el código lineal, JSIR representa las operaciones como una secuencia de operaciones que reflejan un recorrido post-orden del AST. Por ejemplo, expresiones como 1 + 2 + 3 se descomponen en operaciones jsir.numeric_literal y jsir.binary_expression que construyen el resultado paso a paso, utilizando valores SSA (Static Single Assignment) para representar los resultados intermedios. Las sentencias de expresión se encapsulan en jsir.expression_statement.
La representación del flujo de control es una característica clave. JSIR utiliza las 'regiones' de MLIR para encapsular bloques de código anidados dentro de estructuras de control como if_statement, while_statement y logical_expression. Por ejemplo, un jshir.if_statement toma una condición como valor SSA y dos regiones para los bloques then y else. Las condiciones de while_statement y las partes derechas de logical_expression se representan como regiones para modelar su evaluación condicional o repetitiva, lo que es crucial para el análisis de flujo de datos y la preservación de la semántica de cortocircuito. JSIR distingue explícitamente entre l-values (jsir.identifier_ref) y r-values (jsir.identifier) para representar la semántica de asignación y referencia de memoria.
El framework de análisis de flujo de datos de JSIR se construye sobre la API de análisis de flujo de datos de MLIR, con mejoras de usabilidad. Introduce clases como JsirStateRef para encapsular escrituras a estados de análisis, JsirDataFlowAnalysis para manejar estados dispersos y densos simultáneamente, y JsirGeneralCfgEdge para unificar ramas de bloques MLIR y ramas de región, incluyendo salidas tempranas (break/continue). Esto simplifica la definición de lattices y funciones de transferencia, haciendo el análisis de flujo de datos más accesible para los desarrolladores.
Flujo de Transformación Fuente-a-Fuente con JSIR
- 1 Código Fuente JS Entrada de JavaScript
- 2 Parser (Babel/SWC) Genera el Abstract Syntax Tree (AST) de ESTree
- 3 Conversión a JSIR Transforma el AST en la Representación Intermedia de JSIR (MLIR)
- 4 Análisis/Transformación JSIR Aplicación de análisis de flujo de datos o transformaciones en el IR
- 5 Conversión JSIR a AST Levanta el JSIR transformado de nuevo a un AST de ESTree
- 6 Printer (Babel) Genera código JavaScript a partir del AST
- 7 Código Fuente JS Transformado Salida de JavaScript modificada
| Capa | Tecnología | Justificación |
|---|---|---|
| data-processing | MLIR | Framework subyacente para la definición, optimización y análisis de la Representación Intermedia (IR) de JSIR. Proporciona la infraestructura para regiones, operaciones y el sistema de tipos. |
| data-processing | ESTree | Estándar para la estructura del Abstract Syntax Tree (AST) de JavaScript. JSIR se diseña para tener una correspondencia casi uno a uno con los nodos de ESTree para asegurar la fidelidad y reversibilidad. |
| data-processing | Babel | Parser de JavaScript utilizado para convertir código fuente en ASTs. También se usa como 'printer' para generar código JavaScript a partir de ASTs. vs SWC |
| data-processing | QuickJS | Motor de ejecución JavaScript ligero utilizado para la evaluación de constantes (constant folding) dentro de JSIR, evitando la reimplementación de la semántica de JavaScript. |
Trade-offs
Ganancias
- ▲ Capacidad de análisis de flujo de datos
- ▲ Fidelidad en la transformación fuente-a-fuente
- ▲ Reversibilidad AST <-> JSIR
- △ Usabilidad de la API de análisis de flujo de datos
Costes
- △ Complejidad de la infraestructura (dependencia de MLIR, QuickJS, Babel/SWC)
- △ Curva de aprendizaje para desarrolladores no familiarizados con IRs
%1 = jsir.numeric_literal {1}
%2 = jsir.numeric_literal {2}
%1_plus_2 = jsir.binary_expression {'+'} (%1, %2)
%3 = jsir.numeric_literal {3}
%1_plus_2_plus_3 = jsir.binary_expression {'+'} (%1_plus_2, %3)
jsir.expression_statement (%1_plus_2_plus_3)%cond = jsir.identifier {"cond"}
jshir.if_statement (%cond) ({
%a = jsir.identifier {"a"}
jsir.expression_statement (%a)
}, {
%b = jsir.identifier {"b"}
jsir.expression_statement (%b)
})Fundamentos Teóricos
La evolución de JSIR y la tendencia hacia IRs de alto nivel específicos del lenguaje se conecta con décadas de investigación en compiladores y análisis de programas. El concepto de Representación Intermedia (IR) es fundamental en la teoría de compiladores, remontándose a trabajos pioneros como los de Aho, Sethi y Ullman en 'Compilers: Principles, Techniques, and Tools' (conocido como el 'Dragon Book'). Estos textos establecieron la necesidad de IRs para desacoplar el frontend (parsing y análisis léxico/sintáctico) del backend (generación de código y optimizaciones).
La distinción entre l-values y r-values, explícitamente modelada en JSIR, es un concepto clásico de la semántica de lenguajes de programación, formalizado en trabajos como los de Christopher Strachey sobre 'L-values and R-values' en la década de 1960. La adopción de Static Single Assignment (SSA) para los valores intermedios en JSIR, aunque no se menciona explícitamente como tal, es un patrón de diseño bien establecido en IRs modernas (como LLVM IR), popularizado por papers como 'The Static Single Assignment Form and its Applications' de Cytron et al. (1991), que facilita enormemente el análisis de flujo de datos.
El uso de regiones para representar el flujo de control en JSIR se alinea con la investigación más reciente en IRs estructuradas y composables, como la que subyace a MLIR. Este enfoque permite modelar la estructura sintáctica y semántica de alto nivel del lenguaje de manera más fiel que un Control Flow Graph (CFG) plano, lo que es crucial para la reversibilidad del IR a código fuente. La idea de un IR que puede 'levantarse' de nuevo a código fuente sin pérdidas se relaciona con la investigación en decompilación y verificación de programas, donde la preservación de la información semántica es primordial.