pyjsclear 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. pyjsclear/__init__.py +47 -0
  2. pyjsclear/__main__.py +37 -0
  3. pyjsclear/deobfuscator.py +191 -0
  4. pyjsclear/generator.py +772 -0
  5. pyjsclear/parser.py +46 -0
  6. pyjsclear/scope.py +283 -0
  7. pyjsclear/transforms/__init__.py +0 -0
  8. pyjsclear/transforms/aa_decode.py +83 -0
  9. pyjsclear/transforms/anti_tamper.py +106 -0
  10. pyjsclear/transforms/base.py +24 -0
  11. pyjsclear/transforms/constant_prop.py +103 -0
  12. pyjsclear/transforms/control_flow.py +330 -0
  13. pyjsclear/transforms/dead_branch.py +71 -0
  14. pyjsclear/transforms/eval_unpack.py +133 -0
  15. pyjsclear/transforms/expression_simplifier.py +403 -0
  16. pyjsclear/transforms/hex_escapes.py +68 -0
  17. pyjsclear/transforms/jj_decode.py +91 -0
  18. pyjsclear/transforms/jsfuck_decode.py +93 -0
  19. pyjsclear/transforms/logical_to_if.py +173 -0
  20. pyjsclear/transforms/object_packer.py +133 -0
  21. pyjsclear/transforms/object_simplifier.py +192 -0
  22. pyjsclear/transforms/property_simplifier.py +43 -0
  23. pyjsclear/transforms/proxy_functions.py +193 -0
  24. pyjsclear/transforms/reassignment.py +183 -0
  25. pyjsclear/transforms/sequence_splitter.py +215 -0
  26. pyjsclear/transforms/string_revealer.py +1259 -0
  27. pyjsclear/transforms/unused_vars.py +111 -0
  28. pyjsclear/traverser.py +195 -0
  29. pyjsclear/utils/__init__.py +0 -0
  30. pyjsclear/utils/ast_helpers.py +257 -0
  31. pyjsclear/utils/string_decoders.py +141 -0
  32. pyjsclear-0.1.0.dist-info/METADATA +168 -0
  33. pyjsclear-0.1.0.dist-info/RECORD +38 -0
  34. pyjsclear-0.1.0.dist-info/WHEEL +5 -0
  35. pyjsclear-0.1.0.dist-info/entry_points.txt +2 -0
  36. pyjsclear-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. pyjsclear-0.1.0.dist-info/licenses/NOTICE +19 -0
  38. pyjsclear-0.1.0.dist-info/top_level.txt +1 -0
pyjsclear/parser.py ADDED
@@ -0,0 +1,46 @@
1
+ """JavaScript parser wrapper around esprima2."""
2
+
3
+ import re
4
+
5
+ import esprima
6
+
7
+
8
+ _ASYNC_MAP = {'isAsync': 'async', 'allowAwait': 'await'}
9
+
10
+
11
+ def _fast_to_dict(obj):
12
+ """Convert esprima AST objects to plain dicts, ~2x faster than toDict()."""
13
+ if isinstance(obj, (str, int, float, bool, type(None))):
14
+ return obj
15
+ if isinstance(obj, list):
16
+ return [_fast_to_dict(item) for item in obj]
17
+ if isinstance(obj, re.Pattern):
18
+ return {}
19
+ # Object with __dict__ (esprima node)
20
+ result_dict = obj if isinstance(obj, dict) else obj.__dict__
21
+ output = {}
22
+ for key, value in result_dict.items():
23
+ if key.startswith('_'):
24
+ continue
25
+ if key == 'optional' and value is False:
26
+ continue
27
+ key = _ASYNC_MAP.get(key, key)
28
+ output[key] = _fast_to_dict(value)
29
+ return output
30
+
31
+
32
+ def parse(code):
33
+ """Parse JavaScript code into an ESTree-compatible AST.
34
+
35
+ Returns a Program node (dict).
36
+ Raises SyntaxError on parse failure.
37
+ """
38
+ try:
39
+ return _fast_to_dict(esprima.parseScript(code))
40
+ except esprima.Error:
41
+ try:
42
+ return _fast_to_dict(esprima.parseModule(code))
43
+ except Exception as e:
44
+ raise SyntaxError(f'Failed to parse JavaScript: {e}') from e
45
+ except Exception as e:
46
+ raise SyntaxError(f'Failed to parse JavaScript: {e}') from e
pyjsclear/scope.py ADDED
@@ -0,0 +1,283 @@
1
+ """Variable scope and binding analysis for ESTree ASTs."""
2
+
3
+ from .utils.ast_helpers import _CHILD_KEYS
4
+ from .utils.ast_helpers import get_child_keys
5
+
6
+
7
+ class Binding:
8
+ """Represents a variable binding in a scope."""
9
+
10
+ __slots__ = ('name', 'node', 'kind', 'scope', 'references', 'assignments')
11
+
12
+ def __init__(self, name, node, kind, scope):
13
+ self.name = name
14
+ self.node = node # The declaration node
15
+ self.kind = kind # 'var', 'let', 'const', 'function', 'param'
16
+ self.scope = scope
17
+ self.references = [] # List of (node, parent, key, index) where name is referenced
18
+ self.assignments = [] # List of assignment nodes
19
+
20
+ @property
21
+ def is_constant(self):
22
+ """True if the binding is never reassigned after declaration."""
23
+ if self.kind == 'const':
24
+ return True
25
+ if self.kind == 'function':
26
+ return len(self.assignments) == 0
27
+ # var/let/param: constant if exactly one init and no reassignments
28
+ return len(self.assignments) == 0
29
+
30
+
31
+ class Scope:
32
+ """Represents a lexical scope."""
33
+
34
+ __slots__ = ('parent', 'node', 'bindings', 'children', 'is_function')
35
+
36
+ def __init__(self, parent, node, is_function=False):
37
+ self.parent = parent
38
+ self.node = node
39
+ self.bindings = {} # name -> Binding
40
+ self.children = []
41
+ self.is_function = is_function
42
+ if parent:
43
+ parent.children.append(self)
44
+
45
+ def add_binding(self, name, node, kind):
46
+ binding = Binding(name, node, kind, self)
47
+ self.bindings[name] = binding
48
+ return binding
49
+
50
+ def get_binding(self, name):
51
+ """Look up a binding, walking up the scope chain."""
52
+ if name in self.bindings:
53
+ return self.bindings[name]
54
+ if self.parent:
55
+ return self.parent.get_binding(name)
56
+ return None
57
+
58
+ def get_own_binding(self, name):
59
+ return self.bindings.get(name)
60
+
61
+
62
+ def _nearest_function_scope(scope):
63
+ """Walk up to the nearest function (or root) scope."""
64
+ while scope and not scope.is_function:
65
+ scope = scope.parent
66
+ return scope
67
+
68
+
69
+ def _is_non_reference_identifier(parent, parent_key):
70
+ """Return True if this Identifier usage is not a variable reference."""
71
+ if not parent:
72
+ return False
73
+ parent_type = parent.get('type')
74
+ # Property names in member expressions (non-computed)
75
+ if parent_type == 'MemberExpression' and parent_key == 'property' and not parent.get('computed'):
76
+ return True
77
+ # Property keys in object literals (non-computed)
78
+ if parent_type == 'Property' and parent_key == 'key' and not parent.get('computed'):
79
+ return True
80
+ # Function/class names at declaration site
81
+ if parent_type in ('FunctionDeclaration', 'FunctionExpression', 'ClassDeclaration') and parent_key == 'id':
82
+ return True
83
+ # VariableDeclarator id
84
+ if parent_type == 'VariableDeclarator' and parent_key == 'id':
85
+ return True
86
+ return False
87
+
88
+
89
+ def _recurse_into_children(node, child_keys_map, callback):
90
+ """Walk child nodes, calling callback(child_node) for each dict with 'type'."""
91
+ node_type = node.get('type')
92
+ child_keys = child_keys_map.get(node_type)
93
+ if child_keys is None:
94
+ child_keys = get_child_keys(node)
95
+ for key in child_keys:
96
+ child = node.get(key)
97
+ if child is None:
98
+ continue
99
+ if isinstance(child, list):
100
+ for item in child:
101
+ if isinstance(item, dict) and 'type' in item:
102
+ callback(item)
103
+ elif isinstance(child, dict) and 'type' in child:
104
+ callback(child)
105
+
106
+
107
+ def build_scope_tree(ast):
108
+ """Build a scope tree from an AST, collecting bindings and references.
109
+
110
+ Returns the root Scope and a dict mapping node id -> Scope.
111
+ """
112
+ root_scope = Scope(None, ast, is_function=True)
113
+ # Maps id(node) -> scope for function/block scope nodes
114
+ node_scope = {id(ast): root_scope}
115
+ # We need to collect all declarations first, then references
116
+ all_scopes = [root_scope]
117
+
118
+ def _get_scope_for(node, current_scope):
119
+ """Get or create the scope for a node."""
120
+ node_id = id(node)
121
+ if node_id in node_scope:
122
+ return node_scope[node_id]
123
+ return current_scope
124
+
125
+ _child_keys_map = _CHILD_KEYS
126
+
127
+ def _collect_declarations(node, scope):
128
+ """Walk the AST collecting variable declarations into scopes."""
129
+ if not isinstance(node, dict):
130
+ return
131
+ node_type = node.get('type')
132
+ if node_type is None:
133
+ return
134
+
135
+ # Create new scope for functions
136
+ if node_type in (
137
+ 'FunctionDeclaration',
138
+ 'FunctionExpression',
139
+ 'ArrowFunctionExpression',
140
+ ):
141
+ new_scope = Scope(scope, node, is_function=True)
142
+ node_scope[id(node)] = new_scope
143
+ all_scopes.append(new_scope)
144
+
145
+ # Function name goes in outer scope (for declarations) or inner (for expressions)
146
+ if node_type == 'FunctionDeclaration' and node.get('id'):
147
+ scope.add_binding(node['id']['name'], node, 'function')
148
+ elif node_type == 'FunctionExpression' and node.get('id'):
149
+ new_scope.add_binding(node['id']['name'], node, 'function')
150
+
151
+ # Params go in function scope
152
+ for param in node.get('params', []):
153
+ if param.get('type') == 'Identifier':
154
+ new_scope.add_binding(param['name'], param, 'param')
155
+ elif param.get('type') == 'AssignmentPattern' and param.get('left', {}).get('type') == 'Identifier':
156
+ new_scope.add_binding(param['left']['name'], param, 'param')
157
+
158
+ # Body - use the new scope
159
+ body = node.get('body')
160
+ if not body:
161
+ return
162
+ if isinstance(body, dict) and body.get('type') == 'BlockStatement':
163
+ node_scope[id(body)] = new_scope
164
+ for statement in body.get('body', []):
165
+ _collect_declarations(statement, new_scope)
166
+ else:
167
+ _collect_declarations(body, new_scope)
168
+ return
169
+
170
+ # Variable declarations
171
+ if node_type == 'VariableDeclaration':
172
+ kind = node.get('kind', 'var')
173
+ # var is function-scoped, let/const are block-scoped
174
+ target_scope = (_nearest_function_scope(scope) or scope) if kind == 'var' else scope
175
+ for declaration in node.get('declarations', []):
176
+ declaration_id = declaration.get('id')
177
+ if declaration_id and declaration_id.get('type') == 'Identifier':
178
+ target_scope.add_binding(declaration_id['name'], declaration, kind)
179
+ # Handle destructuring patterns
180
+ _collect_pattern_names(declaration_id, target_scope, kind, declaration)
181
+ return
182
+
183
+ # Block scopes (for, if, etc. with block statements)
184
+ if node_type == 'BlockStatement' and id(node) not in node_scope:
185
+ # Only create block scope if parent is not a function (handled above)
186
+ new_scope = Scope(scope, node)
187
+ node_scope[id(node)] = new_scope
188
+ all_scopes.append(new_scope)
189
+ for statement in node.get('body', []):
190
+ _collect_declarations(statement, new_scope)
191
+ return
192
+
193
+ if node_type == 'ForStatement':
194
+ new_scope = Scope(scope, node)
195
+ node_scope[id(node)] = new_scope
196
+ all_scopes.append(new_scope)
197
+ if node.get('init'):
198
+ _collect_declarations(node['init'], new_scope)
199
+ if node.get('body'):
200
+ _collect_declarations(node['body'], new_scope)
201
+ return
202
+
203
+ # Recurse into children
204
+ _recurse_into_children(node, _child_keys_map, lambda child_node: _collect_declarations(child_node, scope))
205
+
206
+ def _collect_pattern_names(pattern, scope, kind, declaration):
207
+ """Collect binding names from destructuring patterns."""
208
+ if not isinstance(pattern, dict):
209
+ return
210
+ match pattern.get('type', ''):
211
+ case 'ArrayPattern':
212
+ for element in pattern.get('elements', []):
213
+ if not element:
214
+ continue
215
+ if element.get('type') == 'Identifier':
216
+ scope.add_binding(element['name'], declaration, kind)
217
+ else:
218
+ _collect_pattern_names(element, scope, kind, declaration)
219
+ case 'ObjectPattern':
220
+ for property_node in pattern.get('properties', []):
221
+ value_node = property_node.get('value', property_node.get('argument'))
222
+ if not value_node:
223
+ continue
224
+ if value_node.get('type') == 'Identifier':
225
+ scope.add_binding(value_node['name'], declaration, kind)
226
+ else:
227
+ _collect_pattern_names(value_node, scope, kind, declaration)
228
+ case 'RestElement':
229
+ argument_node = pattern.get('argument')
230
+ if argument_node and argument_node.get('type') == 'Identifier':
231
+ scope.add_binding(argument_node['name'], declaration, kind)
232
+ case 'AssignmentPattern':
233
+ left = pattern.get('left')
234
+ if left and left.get('type') == 'Identifier':
235
+ scope.add_binding(left['name'], declaration, kind)
236
+
237
+ _collect_declarations(ast, root_scope)
238
+
239
+ # Second pass: collect references and assignments
240
+ def _collect_references(node, scope, parent=None, parent_key=None, parent_index=None):
241
+ if not isinstance(node, dict):
242
+ return
243
+ node_type = node.get('type')
244
+ if node_type is None:
245
+ return
246
+
247
+ # Look up scope for this node
248
+ scope = _get_scope_for(node, scope)
249
+
250
+ if node_type == 'Identifier':
251
+ name = node.get('name', '')
252
+ if _is_non_reference_identifier(parent, parent_key):
253
+ return
254
+
255
+ binding = scope.get_binding(name)
256
+ if not binding:
257
+ return
258
+ binding.references.append((node, parent, parent_key, parent_index))
259
+ if parent and parent.get('type') == 'AssignmentExpression' and parent_key == 'left':
260
+ binding.assignments.append(parent)
261
+ elif parent and parent.get('type') == 'UpdateExpression':
262
+ binding.assignments.append(parent)
263
+ return
264
+
265
+ # Recurse — can't use _recurse_into_children here because we need
266
+ # per-child (key, index) args for reference tracking
267
+ child_keys = _child_keys_map.get(node_type)
268
+ if child_keys is None:
269
+ child_keys = get_child_keys(node)
270
+ for key in child_keys:
271
+ child = node.get(key)
272
+ if child is None:
273
+ continue
274
+ if isinstance(child, list):
275
+ for i, item in enumerate(child):
276
+ if isinstance(item, dict) and 'type' in item:
277
+ _collect_references(item, scope, node, key, i)
278
+ elif isinstance(child, dict) and 'type' in child:
279
+ _collect_references(child, scope, node, key, None)
280
+
281
+ _collect_references(ast, root_scope)
282
+
283
+ return root_scope, node_scope
File without changes
@@ -0,0 +1,83 @@
1
+ """AAEncode decoder.
2
+
3
+ AAEncode encodes JavaScript using Japanese-style emoticons.
4
+ This decoder reverses the encoding by replacing emoticon patterns
5
+ with their numeric values, then converting octal/hex to characters.
6
+ """
7
+
8
+ import re
9
+
10
+
11
+ # The 16 AAEncode symbol table entries (indices 0-15)
12
+ _AA_SYMBOLS = [
13
+ '(c^_^o)',
14
+ '(\uff9f\u0398\uff9f)',
15
+ '((o^_^o) - (\uff9f\u0398\uff9f))',
16
+ '(o^_^o)',
17
+ '(\uff9f\uff70\uff9f)',
18
+ '((\uff9f\uff70\uff9f) + (\uff9f\u0398\uff9f))',
19
+ '((o^_^o) +(o^_^o))',
20
+ '((\uff9f\uff70\uff9f) + (o^_^o))',
21
+ '((\uff9f\uff70\uff9f) + (\uff9f\uff70\uff9f))',
22
+ '((\uff9f\uff70\uff9f) + (\uff9f\uff70\uff9f) + (\uff9f\u0398\uff9f))',
23
+ '(\uff9f\u0414\uff9f) .\uff9f\u03c9\uff9f\uff89',
24
+ '(\uff9f\u0414\uff9f) .\uff9f\u0398\uff9f\uff89',
25
+ "(\uff9f\u0414\uff9f) ['c']",
26
+ '(\uff9f\u0414\uff9f) .\uff9f\uff70\uff9f\uff89',
27
+ '(\uff9f\u0414\uff9f) .\uff9f\u0414\uff9f\uff89',
28
+ '(\uff9f\u0414\uff9f) [\uff9f\u0398\uff9f]',
29
+ ]
30
+
31
+ # Detection pattern: AAEncoded code contains these characteristic markers
32
+ _AA_DETECT_RE = re.compile(r'\(\uff9f\u0414\uff9f\)\s*\[\uff9f\u03b5\uff9f\]') # (゚Д゚)[゚ε゚]
33
+
34
+ # Unicode marker for hex characters (code points > 127)
35
+ _UNICODE_MARKER = '(o\uff9f\uff70\uff9fo)+ '
36
+
37
+
38
+ def is_aa_encoded(code):
39
+ """Check if code is AAEncoded."""
40
+ return bool(_AA_DETECT_RE.search(code))
41
+
42
+
43
+ def aa_decode(code):
44
+ """Decode AAEncoded JavaScript. Returns decoded string or None on failure."""
45
+ if not is_aa_encoded(code):
46
+ return None
47
+
48
+ try:
49
+ text = code
50
+ # Replace each symbol with its numeric value
51
+ for i, symbol in enumerate(_AA_SYMBOLS):
52
+ search = symbol + '+ '
53
+ replacement = str(i) if i <= 7 else format(i, 'x')
54
+ text = text.replace(search, replacement)
55
+
56
+ # Remove the trailing execution wrapper
57
+ text = text.replace("(\uff9f\u0414\uff9f)[\uff9fo\uff9f]) (\uff9f\u0398\uff9f)) ('_');", '')
58
+ text = text.replace(
59
+ "(\uff9f\u0414\uff9f)[\uff9fo\uff9f])(\uff9f\u0398\uff9f))((\uff9f\u0398\uff9f)+(\uff9f\u0414\uff9f)[\uff9f\u03b5\uff9f]+((\uff9f\uff70\uff9f)+(\uff9f\u0398\uff9f))+(\uff9f\u0398\uff9f)+(\uff9f\u0414\uff9f)[\uff9fo\uff9f]);",
60
+ '',
61
+ )
62
+
63
+ # Split on the escape marker
64
+ parts = text.split('(\uff9f\u0414\uff9f)[\uff9f\u03b5\uff9f]+')
65
+
66
+ result = ''
67
+ for part in parts[1:]: # Skip the preamble
68
+ part = part.strip()
69
+ if not part:
70
+ continue
71
+ if part.startswith(_UNICODE_MARKER):
72
+ # Unicode character: parse as hex
73
+ hex_str = part[len(_UNICODE_MARKER) :].strip().rstrip('+').strip()
74
+ result += chr(int(hex_str, 16))
75
+ else:
76
+ # ASCII character: parse as octal
77
+ octal_str = part.strip().rstrip('+').strip()
78
+ if octal_str:
79
+ result += chr(int(octal_str, 8))
80
+
81
+ return result if result else None
82
+ except (ValueError, IndexError):
83
+ return None
@@ -0,0 +1,106 @@
1
+ """Remove anti-tamper, self-defending, and debug protection patterns.
2
+
3
+ Detects common obfuscator.io patterns:
4
+ - Self-defending functions (check if code was modified)
5
+ - Debug protection (infinite debugger loops)
6
+ - Console output disabling
7
+ """
8
+
9
+ import re
10
+
11
+ from ..generator import generate
12
+ from ..traverser import REMOVE
13
+ from ..traverser import traverse
14
+ from .base import Transform
15
+
16
+
17
+ class AntiTamperRemover(Transform):
18
+ """Remove self-defending, debug protection, and console-disabling code."""
19
+
20
+ rebuild_scope = True
21
+
22
+ # Patterns to match in generated code for suspicious IIFEs
23
+ _SELF_DEFENDING_PATTERNS = [
24
+ re.compile(r'constructor\s*\(\s*\)\s*\.\s*constructor\s*\('),
25
+ re.compile(r'toString\s*\(\s*\)\s*\.\s*search'),
26
+ re.compile(r'prototype\s*\.\s*toString'),
27
+ re.compile(r'__proto__'),
28
+ ]
29
+
30
+ _DEBUG_PATTERNS = [
31
+ re.compile(r'\bdebugger\b'),
32
+ re.compile(r'setInterval\s*\('),
33
+ ]
34
+
35
+ _CONSOLE_PATTERNS = [
36
+ re.compile(r'console\s*\[\s*[\'"](?:log|warn|error|info|debug|trace|exception|table)'),
37
+ re.compile(r'console\s*\.\s*(?:log|warn|error|info|debug|trace|exception|table)\s*='),
38
+ ]
39
+
40
+ @staticmethod
41
+ def _extract_iife_call(expr):
42
+ """Extract a CallExpression from an IIFE pattern."""
43
+ if expr.get('type') == 'CallExpression':
44
+ return expr
45
+ if expr.get('type') == 'UnaryExpression' and expr.get('argument', {}).get('type') == 'CallExpression':
46
+ return expr.get('argument')
47
+ return None
48
+
49
+ def _matches_anti_tamper_pattern(self, src):
50
+ """Check if source matches any anti-tamper pattern."""
51
+ for pattern in self._SELF_DEFENDING_PATTERNS:
52
+ if pattern.search(src):
53
+ return True
54
+ if any(p.search(src) for p in self._DEBUG_PATTERNS):
55
+ if re.search(r'\bdebugger\b', src) and (re.search(r'\bwhile\b|\bfor\b|\bsetInterval\b', src)):
56
+ return True
57
+ for pattern in self._CONSOLE_PATTERNS:
58
+ if pattern.search(src):
59
+ return True
60
+ return False
61
+
62
+ def execute(self):
63
+ nodes_to_remove = []
64
+
65
+ def enter(node, parent, key, index):
66
+ if node.get('type') != 'ExpressionStatement':
67
+ return
68
+ expr = node.get('expression')
69
+ if not expr:
70
+ return
71
+
72
+ call = self._extract_iife_call(expr)
73
+ if not call:
74
+ return
75
+
76
+ callee = call.get('callee')
77
+ if not callee:
78
+ return
79
+ if callee.get('type') not in (
80
+ 'FunctionExpression',
81
+ 'ArrowFunctionExpression',
82
+ ):
83
+ return
84
+
85
+ try:
86
+ source_code = generate(callee)
87
+ except Exception:
88
+ return
89
+
90
+ if self._matches_anti_tamper_pattern(source_code):
91
+ nodes_to_remove.append(node)
92
+
93
+ traverse(self.ast, {'enter': enter})
94
+
95
+ # Remove flagged nodes
96
+ if nodes_to_remove:
97
+ remove_set = set(id(n) for n in nodes_to_remove)
98
+
99
+ def remover(node, parent, key, index):
100
+ if id(node) in remove_set:
101
+ self.set_changed()
102
+ return REMOVE
103
+
104
+ traverse(self.ast, {'enter': remover})
105
+
106
+ return self.has_changed()
@@ -0,0 +1,24 @@
1
+ """Base transform class."""
2
+
3
+
4
+ class Transform:
5
+ """Base class for all AST transforms."""
6
+
7
+ # Subclasses can set this to True to trigger scope rebuild after execution
8
+ rebuild_scope = False
9
+
10
+ def __init__(self, ast, scope_tree=None, node_scope=None):
11
+ self.ast = ast
12
+ self.scope_tree = scope_tree
13
+ self.node_scope = node_scope
14
+ self._changed = False
15
+
16
+ def execute(self):
17
+ """Execute the transform. Returns True if the AST was modified."""
18
+ raise NotImplementedError
19
+
20
+ def set_changed(self):
21
+ self._changed = True
22
+
23
+ def has_changed(self):
24
+ return self._changed
@@ -0,0 +1,103 @@
1
+ """Constant propagation — replace references to constant variables with their literal values."""
2
+
3
+ from ..scope import build_scope_tree
4
+ from ..traverser import REMOVE
5
+ from ..traverser import SKIP
6
+ from ..traverser import traverse
7
+ from ..utils.ast_helpers import deep_copy
8
+ from ..utils.ast_helpers import is_literal
9
+ from .base import Transform
10
+
11
+
12
+ def _should_skip_reference(ref_parent, ref_key):
13
+ """Return True if this reference should not be replaced with its literal value."""
14
+ if not ref_parent:
15
+ return True
16
+ match ref_parent.get('type'):
17
+ case 'AssignmentExpression' if ref_key == 'left':
18
+ return True
19
+ case 'UpdateExpression':
20
+ return True
21
+ case 'VariableDeclarator' if ref_key == 'id':
22
+ return True
23
+ return False
24
+
25
+
26
+ class ConstantProp(Transform):
27
+ """Find `const x = <literal>` and replace all references with the literal."""
28
+
29
+ rebuild_scope = True
30
+
31
+ def execute(self):
32
+ scope_tree, node_scope = build_scope_tree(self.ast)
33
+
34
+ replacements = dict(self._iter_constant_bindings(scope_tree))
35
+ if not replacements:
36
+ return False
37
+
38
+ bindings_replaced = self._replace_references(replacements)
39
+ self._remove_fully_propagated(replacements, bindings_replaced)
40
+ return self.has_changed()
41
+
42
+ def _iter_constant_bindings(self, scope):
43
+ """Yield (binding_id, (binding, literal)) for constant bindings with literal values."""
44
+ for name, binding in scope.bindings.items():
45
+ if not binding.is_constant:
46
+ continue
47
+ node = binding.node
48
+ if not isinstance(node, dict) or node.get('type') != 'VariableDeclarator':
49
+ continue
50
+ init_val = node.get('init')
51
+ if not init_val or not is_literal(init_val):
52
+ continue
53
+ yield id(binding), (binding, init_val)
54
+
55
+ for child in scope.children:
56
+ yield from self._iter_constant_bindings(child)
57
+
58
+ def _replace_references(self, replacements):
59
+ """Replace all qualifying references with their literal values."""
60
+ bindings_replaced = set()
61
+ for bind_id, (binding, literal) in replacements.items():
62
+ for ref_node, ref_parent, ref_key, ref_index in binding.references:
63
+ if _should_skip_reference(ref_parent, ref_key):
64
+ continue
65
+ new_node = deep_copy(literal)
66
+ if ref_index is not None:
67
+ ref_parent[ref_key][ref_index] = new_node
68
+ else:
69
+ ref_parent[ref_key] = new_node
70
+ self.set_changed()
71
+ bindings_replaced.add(bind_id)
72
+ return bindings_replaced
73
+
74
+ def _remove_fully_propagated(self, replacements, bindings_replaced):
75
+ """Remove declarations whose bindings were fully propagated."""
76
+ for bind_id in bindings_replaced:
77
+ binding = replacements[bind_id][0]
78
+ if binding.assignments:
79
+ continue
80
+ decl_node = binding.node
81
+ if not isinstance(decl_node, dict):
82
+ continue
83
+ if decl_node.get('type') != 'VariableDeclarator':
84
+ continue
85
+ self._remove_declarator(decl_node)
86
+
87
+ def _remove_declarator(self, declarator_node):
88
+ """Remove a VariableDeclarator from its parent VariableDeclaration."""
89
+
90
+ def enter(node, parent, key, index):
91
+ if node.get('type') != 'VariableDeclaration':
92
+ return
93
+ decls = node.get('declarations', [])
94
+ for i, declaration in enumerate(decls):
95
+ if declaration is not declarator_node:
96
+ continue
97
+ decls.pop(i)
98
+ self.set_changed()
99
+ if not decls:
100
+ return REMOVE
101
+ return SKIP
102
+
103
+ traverse(self.ast, {'enter': enter})