pyjsclear 0.1.4__tar.gz → 0.1.5__tar.gz

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 (95) hide show
  1. {pyjsclear-0.1.4/pyjsclear.egg-info → pyjsclear-0.1.5}/PKG-INFO +3 -2
  2. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/README.md +2 -1
  3. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/__init__.py +17 -8
  4. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/__main__.py +1 -0
  5. pyjsclear-0.1.5/pyjsclear/deobfuscator.py +391 -0
  6. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/generator.py +32 -31
  7. pyjsclear-0.1.5/pyjsclear/parser.py +87 -0
  8. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/scope.py +236 -184
  9. pyjsclear-0.1.5/pyjsclear/transforms/aa_decode.py +148 -0
  10. pyjsclear-0.1.5/pyjsclear/transforms/anti_tamper.py +121 -0
  11. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/base.py +11 -8
  12. pyjsclear-0.1.5/pyjsclear/transforms/class_static_resolver.py +229 -0
  13. pyjsclear-0.1.5/pyjsclear/transforms/class_string_decoder.py +387 -0
  14. pyjsclear-0.1.5/pyjsclear/transforms/cleanup.py +342 -0
  15. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/constant_prop.py +54 -33
  16. pyjsclear-0.1.5/pyjsclear/transforms/control_flow.py +374 -0
  17. pyjsclear-0.1.5/pyjsclear/transforms/dead_branch.py +150 -0
  18. pyjsclear-0.1.5/pyjsclear/transforms/dead_class_props.py +344 -0
  19. pyjsclear-0.1.5/pyjsclear/transforms/dead_expressions.py +43 -0
  20. pyjsclear-0.1.5/pyjsclear/transforms/dead_object_props.py +239 -0
  21. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/else_if_flatten.py +20 -7
  22. pyjsclear-0.1.5/pyjsclear/transforms/enum_resolver.py +267 -0
  23. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/eval_unpack.py +21 -21
  24. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/expression_simplifier.py +91 -96
  25. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/global_alias.py +57 -31
  26. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/hex_escapes.py +31 -23
  27. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/hex_numerics.py +11 -8
  28. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/jj_decode.py +180 -170
  29. pyjsclear-0.1.5/pyjsclear/transforms/jsfuck_decode.py +633 -0
  30. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/logical_to_if.py +27 -14
  31. pyjsclear-0.1.5/pyjsclear/transforms/member_chain_resolver.py +171 -0
  32. pyjsclear-0.1.5/pyjsclear/transforms/noop_calls.py +118 -0
  33. pyjsclear-0.1.5/pyjsclear/transforms/nullish_coalescing.py +120 -0
  34. pyjsclear-0.1.5/pyjsclear/transforms/object_packer.py +156 -0
  35. pyjsclear-0.1.5/pyjsclear/transforms/object_simplifier.py +224 -0
  36. pyjsclear-0.1.5/pyjsclear/transforms/optional_chaining.py +217 -0
  37. pyjsclear-0.1.5/pyjsclear/transforms/property_simplifier.py +111 -0
  38. pyjsclear-0.1.5/pyjsclear/transforms/proxy_functions.py +281 -0
  39. pyjsclear-0.1.5/pyjsclear/transforms/reassignment.py +230 -0
  40. pyjsclear-0.1.5/pyjsclear/transforms/require_inliner.py +103 -0
  41. pyjsclear-0.1.5/pyjsclear/transforms/sequence_splitter.py +261 -0
  42. pyjsclear-0.1.5/pyjsclear/transforms/single_use_vars.py +221 -0
  43. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/string_revealer.py +297 -261
  44. pyjsclear-0.1.5/pyjsclear/transforms/unreachable_code.py +68 -0
  45. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/unused_vars.py +56 -20
  46. pyjsclear-0.1.5/pyjsclear/transforms/variable_renamer.py +544 -0
  47. pyjsclear-0.1.5/pyjsclear/transforms/xor_string_decode.py +295 -0
  48. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/traverser.py +75 -84
  49. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/utils/ast_helpers.py +118 -86
  50. pyjsclear-0.1.5/pyjsclear/utils/string_decoders.py +162 -0
  51. {pyjsclear-0.1.4 → pyjsclear-0.1.5/pyjsclear.egg-info}/PKG-INFO +3 -2
  52. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyproject.toml +2 -1
  53. pyjsclear-0.1.4/pyjsclear/deobfuscator.py +0 -317
  54. pyjsclear-0.1.4/pyjsclear/parser.py +0 -46
  55. pyjsclear-0.1.4/pyjsclear/transforms/aa_decode.py +0 -150
  56. pyjsclear-0.1.4/pyjsclear/transforms/anti_tamper.py +0 -103
  57. pyjsclear-0.1.4/pyjsclear/transforms/class_static_resolver.py +0 -212
  58. pyjsclear-0.1.4/pyjsclear/transforms/class_string_decoder.py +0 -333
  59. pyjsclear-0.1.4/pyjsclear/transforms/cleanup.py +0 -283
  60. pyjsclear-0.1.4/pyjsclear/transforms/control_flow.py +0 -327
  61. pyjsclear-0.1.4/pyjsclear/transforms/dead_branch.py +0 -89
  62. pyjsclear-0.1.4/pyjsclear/transforms/dead_class_props.py +0 -254
  63. pyjsclear-0.1.4/pyjsclear/transforms/dead_expressions.py +0 -29
  64. pyjsclear-0.1.4/pyjsclear/transforms/dead_object_props.py +0 -150
  65. pyjsclear-0.1.4/pyjsclear/transforms/enum_resolver.py +0 -175
  66. pyjsclear-0.1.4/pyjsclear/transforms/jsfuck_decode.py +0 -610
  67. pyjsclear-0.1.4/pyjsclear/transforms/member_chain_resolver.py +0 -149
  68. pyjsclear-0.1.4/pyjsclear/transforms/noop_calls.py +0 -79
  69. pyjsclear-0.1.4/pyjsclear/transforms/nullish_coalescing.py +0 -109
  70. pyjsclear-0.1.4/pyjsclear/transforms/object_packer.py +0 -131
  71. pyjsclear-0.1.4/pyjsclear/transforms/object_simplifier.py +0 -197
  72. pyjsclear-0.1.4/pyjsclear/transforms/optional_chaining.py +0 -153
  73. pyjsclear-0.1.4/pyjsclear/transforms/property_simplifier.py +0 -68
  74. pyjsclear-0.1.4/pyjsclear/transforms/proxy_functions.py +0 -238
  75. pyjsclear-0.1.4/pyjsclear/transforms/reassignment.py +0 -197
  76. pyjsclear-0.1.4/pyjsclear/transforms/require_inliner.py +0 -77
  77. pyjsclear-0.1.4/pyjsclear/transforms/sequence_splitter.py +0 -213
  78. pyjsclear-0.1.4/pyjsclear/transforms/single_use_vars.py +0 -164
  79. pyjsclear-0.1.4/pyjsclear/transforms/unreachable_code.py +0 -38
  80. pyjsclear-0.1.4/pyjsclear/transforms/variable_renamer.py +0 -511
  81. pyjsclear-0.1.4/pyjsclear/transforms/xor_string_decode.py +0 -228
  82. pyjsclear-0.1.4/pyjsclear/utils/string_decoders.py +0 -140
  83. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/LICENSE +0 -0
  84. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/MANIFEST.in +0 -0
  85. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/NOTICE +0 -0
  86. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/THIRD_PARTY_LICENSES.md +0 -0
  87. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/transforms/__init__.py +0 -0
  88. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear/utils/__init__.py +0 -0
  89. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/SOURCES.txt +0 -0
  90. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/dependency_links.txt +0 -0
  91. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/entry_points.txt +0 -0
  92. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/requires.txt +0 -0
  93. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/pyjsclear.egg-info/top_level.txt +0 -0
  94. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/setup.cfg +0 -0
  95. {pyjsclear-0.1.4 → pyjsclear-0.1.5}/tests/test_regression.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyjsclear
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Pure Python JavaScript deobfuscator
5
5
  Author-email: Intezer Labs <info@intezer.com>
6
6
  License-Expression: Apache-2.0
@@ -120,5 +120,6 @@ See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
120
120
  [NOTICE](NOTICE) for full attribution.
121
121
 
122
122
  Test samples include obfuscated JavaScript from the
123
- [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0),
123
+ [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0)
124
+ and the [Obfuscated JavaScript Dataset](https://www.kaggle.com/datasets/fanbyprinciple/obfuscated-javascript-dataset),
124
125
  used solely for evaluation purposes.
@@ -94,5 +94,6 @@ See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
94
94
  [NOTICE](NOTICE) for full attribution.
95
95
 
96
96
  Test samples include obfuscated JavaScript from the
97
- [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0),
97
+ [JSIMPLIFIER dataset](https://zenodo.org/records/17531662) (GPL-3.0)
98
+ and the [Obfuscated JavaScript Dataset](https://www.kaggle.com/datasets/fanbyprinciple/obfuscated-javascript-dataset),
98
99
  used solely for evaluation purposes.
@@ -5,14 +5,18 @@ and javascript-deobfuscator (3 surface-cleanup modules) into a single
5
5
  Python package.
6
6
  """
7
7
 
8
+ from pathlib import Path
9
+
8
10
  from .deobfuscator import Deobfuscator
9
11
 
10
12
 
11
- __version__ = '0.1.4'
13
+ __all__ = ['Deobfuscator', 'deobfuscate', 'deobfuscate_file']
14
+
15
+ __version__ = '0.1.5'
12
16
 
13
17
 
14
18
  def deobfuscate(code: str, max_iterations: int = 50) -> str:
15
- """Deobfuscate JavaScript code. Returns cleaned source.
19
+ """Deobfuscate JavaScript code and return cleaned source.
16
20
 
17
21
  Args:
18
22
  code: JavaScript source code string.
@@ -24,7 +28,11 @@ def deobfuscate(code: str, max_iterations: int = 50) -> str:
24
28
  return Deobfuscator(code, max_iterations=max_iterations).execute()
25
29
 
26
30
 
27
- def deobfuscate_file(input_path: str, output_path: str | None = None, max_iterations: int = 50) -> str | bool:
31
+ def deobfuscate_file(
32
+ input_path: str | Path,
33
+ output_path: str | Path | None = None,
34
+ max_iterations: int = 50,
35
+ ) -> str | bool:
28
36
  """Deobfuscate a JavaScript file.
29
37
 
30
38
  Args:
@@ -40,8 +48,9 @@ def deobfuscate_file(input_path: str, output_path: str | None = None, max_iterat
40
48
 
41
49
  result = deobfuscate(code, max_iterations=max_iterations)
42
50
 
43
- if output_path:
44
- with open(output_path, 'w') as output_file:
45
- output_file.write(result)
46
- return result != code
47
- return result
51
+ if not output_path:
52
+ return result
53
+
54
+ with open(output_path, 'w') as output_file:
55
+ output_file.write(result)
56
+ return result != code
@@ -7,6 +7,7 @@ from . import deobfuscate
7
7
 
8
8
 
9
9
  def main() -> None:
10
+ """Parse CLI arguments and run the deobfuscator."""
10
11
  parser = argparse.ArgumentParser(description='Deobfuscate JavaScript files.')
11
12
  parser.add_argument('input', help='Input JS file (use - for stdin)')
12
13
  parser.add_argument('-o', '--output', help='Output file (default: stdout)')
@@ -0,0 +1,391 @@
1
+ """Multi-pass deobfuscation orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .generator import generate
6
+ from .parser import parse
7
+ from .scope import build_scope_tree
8
+ from .transforms.aa_decode import aa_decode
9
+ from .transforms.aa_decode import is_aa_encoded
10
+ from .transforms.anti_tamper import AntiTamperRemover
11
+ from .transforms.class_static_resolver import ClassStaticResolver
12
+ from .transforms.class_string_decoder import ClassStringDecoder
13
+ from .transforms.cleanup import EmptyIfRemover
14
+ from .transforms.cleanup import LetToConst
15
+ from .transforms.cleanup import OptionalCatchBinding
16
+ from .transforms.cleanup import ReturnUndefinedCleanup
17
+ from .transforms.cleanup import TrailingReturnRemover
18
+ from .transforms.cleanup import VarToConst
19
+ from .transforms.constant_prop import ConstantProp
20
+ from .transforms.control_flow import ControlFlowRecoverer
21
+ from .transforms.dead_branch import DeadBranchRemover
22
+ from .transforms.dead_class_props import DeadClassPropRemover
23
+ from .transforms.dead_expressions import DeadExpressionRemover
24
+ from .transforms.dead_object_props import DeadObjectPropRemover
25
+ from .transforms.else_if_flatten import ElseIfFlattener
26
+ from .transforms.enum_resolver import EnumResolver
27
+ from .transforms.eval_unpack import eval_unpack
28
+ from .transforms.eval_unpack import is_eval_packed
29
+ from .transforms.expression_simplifier import ExpressionSimplifier
30
+ from .transforms.global_alias import GlobalAliasInliner
31
+ from .transforms.hex_escapes import HexEscapes
32
+ from .transforms.hex_escapes import decode_hex_escapes_source
33
+ from .transforms.hex_numerics import HexNumerics
34
+ from .transforms.jj_decode import is_jj_encoded
35
+ from .transforms.jj_decode import jj_decode
36
+ from .transforms.jsfuck_decode import is_jsfuck
37
+ from .transforms.jsfuck_decode import jsfuck_decode
38
+ from .transforms.logical_to_if import LogicalToIf
39
+ from .transforms.member_chain_resolver import MemberChainResolver
40
+ from .transforms.noop_calls import NoopCallRemover
41
+ from .transforms.nullish_coalescing import NullishCoalescing
42
+ from .transforms.object_packer import ObjectPacker
43
+ from .transforms.object_simplifier import ObjectSimplifier
44
+ from .transforms.optional_chaining import OptionalChaining
45
+ from .transforms.property_simplifier import PropertySimplifier
46
+ from .transforms.proxy_functions import ProxyFunctionInliner
47
+ from .transforms.reassignment import ReassignmentRemover
48
+ from .transforms.require_inliner import RequireInliner
49
+ from .transforms.sequence_splitter import SequenceSplitter
50
+ from .transforms.single_use_vars import SingleUseVarInliner
51
+ from .transforms.string_revealer import StringRevealer
52
+ from .transforms.unreachable_code import UnreachableCodeRemover
53
+ from .transforms.unused_vars import UnusedVariableRemover
54
+ from .transforms.variable_renamer import VariableRenamer
55
+ from .transforms.xor_string_decode import XorStringDecoder
56
+ from .utils.ast_helpers import _CHILD_KEYS
57
+ from .utils.ast_helpers import get_child_keys
58
+
59
+
60
+ _SCOPE_TRANSFORMS: frozenset[type] = frozenset(
61
+ {
62
+ ConstantProp,
63
+ SingleUseVarInliner,
64
+ ReassignmentRemover,
65
+ ProxyFunctionInliner,
66
+ UnusedVariableRemover,
67
+ ObjectSimplifier,
68
+ StringRevealer,
69
+ VariableRenamer,
70
+ VarToConst,
71
+ LetToConst,
72
+ }
73
+ )
74
+
75
+ # StringRevealer runs first to decode string arrays before other transforms
76
+ # modify wrapper function structure. Remaining order follows obfuscator-io-deobfuscator.
77
+ TRANSFORM_CLASSES: list[type] = [
78
+ StringRevealer,
79
+ HexEscapes,
80
+ HexNumerics,
81
+ ClassStringDecoder,
82
+ XorStringDecoder,
83
+ MemberChainResolver,
84
+ DeadClassPropRemover,
85
+ ClassStaticResolver,
86
+ EnumResolver,
87
+ RequireInliner,
88
+ GlobalAliasInliner,
89
+ UnusedVariableRemover,
90
+ ConstantProp,
91
+ ReassignmentRemover,
92
+ SingleUseVarInliner,
93
+ DeadBranchRemover,
94
+ UnreachableCodeRemover,
95
+ NoopCallRemover,
96
+ EmptyIfRemover,
97
+ DeadObjectPropRemover,
98
+ ObjectPacker,
99
+ ProxyFunctionInliner,
100
+ SequenceSplitter,
101
+ DeadExpressionRemover,
102
+ ExpressionSimplifier,
103
+ NullishCoalescing,
104
+ OptionalChaining,
105
+ LogicalToIf,
106
+ ElseIfFlattener,
107
+ OptionalCatchBinding,
108
+ ReturnUndefinedCleanup,
109
+ TrailingReturnRemover,
110
+ ControlFlowRecoverer,
111
+ PropertySimplifier,
112
+ AntiTamperRemover,
113
+ ObjectSimplifier,
114
+ StringRevealer,
115
+ ]
116
+
117
+ _EXPENSIVE_TRANSFORMS: frozenset[type] = frozenset({ControlFlowRecoverer, ProxyFunctionInliner, ObjectPacker})
118
+
119
+ _POST_PASS_TRANSFORMS: list[type] = [VariableRenamer, VarToConst, LetToConst]
120
+
121
+ # File-size thresholds
122
+ _LARGE_FILE_SIZE: int = 500_000 # 500 KB — reduce iterations
123
+ _MAX_CODE_SIZE: int = 2_000_000 # 2 MB — use lite mode
124
+ _LITE_MAX_ITERATIONS: int = 10
125
+ _NODE_COUNT_LIMIT: int = 50_000 # skip ControlFlowRecoverer above this
126
+ _VERY_LARGE_NODE_COUNT: int = 100_000 # cap iterations to 3
127
+
128
+
129
+ def _count_nodes(syntax_tree: dict) -> int:
130
+ """Return the total number of nodes in *syntax_tree*.
131
+
132
+ Uses a direct iterative loop instead of simple_traverse to avoid
133
+ per-node callback overhead.
134
+ """
135
+ child_keys_map = _CHILD_KEYS
136
+ _get_child_keys = get_child_keys
137
+ _dict = dict
138
+ _list = list
139
+ _type = type
140
+
141
+ count = 0
142
+ stack = [syntax_tree]
143
+ while stack:
144
+ node = stack.pop()
145
+ node_type = node.get('type')
146
+ if node_type is None:
147
+ continue
148
+ count += 1
149
+ child_keys = child_keys_map.get(node_type)
150
+ if child_keys is None:
151
+ child_keys = _get_child_keys(node)
152
+ for key in child_keys:
153
+ child = node.get(key)
154
+ if child is None:
155
+ continue
156
+ if _type(child) is _list:
157
+ for item in child:
158
+ if _type(item) is _dict and 'type' in item:
159
+ stack.append(item)
160
+ elif _type(child) is _dict and 'type' in child:
161
+ stack.append(child)
162
+ return count
163
+
164
+
165
+ class Deobfuscator:
166
+ """Multi-pass JavaScript deobfuscator.
167
+
168
+ Applies a configurable sequence of AST transforms in a loop until the code
169
+ stabilises or *max_iterations* is reached, then runs cosmetic post-passes.
170
+ """
171
+
172
+ _MAX_OUTER_CYCLES: int = 5
173
+
174
+ def __init__(self, code: str, max_iterations: int = 50) -> None:
175
+ self.original_code: str = code
176
+ self.max_iterations: int = max_iterations
177
+
178
+ def _run_pre_passes(self, code: str) -> str | None:
179
+ """Detect whole-file encodings (JSFuck, AAEncode, etc.) and decode them.
180
+
181
+ Returns the decoded source when a known encoding is found, or ``None``
182
+ to continue with the normal AST pipeline.
183
+ """
184
+ if is_jsfuck(code):
185
+ decoded = jsfuck_decode(code)
186
+ if decoded:
187
+ return decoded
188
+
189
+ if is_aa_encoded(code):
190
+ decoded = aa_decode(code)
191
+ if decoded:
192
+ return decoded
193
+
194
+ if is_jj_encoded(code):
195
+ decoded = jj_decode(code)
196
+ if decoded:
197
+ return decoded
198
+
199
+ if is_eval_packed(code):
200
+ decoded = eval_unpack(code)
201
+ if decoded:
202
+ return decoded
203
+
204
+ return None
205
+
206
+ def execute(self) -> str:
207
+ """Run all deobfuscation passes and return cleaned JavaScript source."""
208
+ code = self.original_code
209
+
210
+ decoded = self._run_pre_passes(code)
211
+ if decoded:
212
+ recursive_deobfuscator = Deobfuscator(decoded, max_iterations=self.max_iterations)
213
+ return recursive_deobfuscator.execute()
214
+
215
+ syntax_tree = self._try_parse_or_fallback(code)
216
+ if isinstance(syntax_tree, str):
217
+ return syntax_tree
218
+
219
+ return self._transform_loop(syntax_tree, code)
220
+
221
+ def _try_parse_or_fallback(self, code: str) -> dict | str:
222
+ """Parse *code* into an AST, falling back to hex-decode on failure.
223
+
224
+ Returns the parsed AST dict on success, or a decoded/original source
225
+ string when parsing fails.
226
+ """
227
+ try:
228
+ return parse(code)
229
+ except SyntaxError:
230
+ decoded = decode_hex_escapes_source(code)
231
+ if decoded != code:
232
+ return decoded
233
+ return self.original_code
234
+
235
+ def _transform_loop(self, syntax_tree: dict, code: str) -> str:
236
+ """Run the outer generate-reparse convergence loop and post-passes.
237
+
238
+ Returns the best deobfuscated source produced.
239
+ """
240
+ previous_code = code
241
+ last_changed_tree: dict | None = None
242
+
243
+ try:
244
+ for _cycle in range(self._MAX_OUTER_CYCLES):
245
+ changed = self._run_ast_transforms(
246
+ syntax_tree,
247
+ code_size=len(previous_code),
248
+ )
249
+
250
+ if not changed:
251
+ break
252
+
253
+ last_changed_tree = syntax_tree
254
+ generated = self._try_generate(syntax_tree)
255
+ if generated is None or generated == previous_code:
256
+ break
257
+
258
+ previous_code = generated
259
+ try:
260
+ syntax_tree = parse(generated)
261
+ except SyntaxError:
262
+ break
263
+
264
+ any_post_changed = self._run_post_passes(syntax_tree)
265
+
266
+ if last_changed_tree is None and not any_post_changed:
267
+ return self.original_code
268
+
269
+ return self._try_generate(syntax_tree) or previous_code
270
+ except RecursionError:
271
+ # Deeply nested JS can exceed Python's recursion limit during
272
+ # parsing or AST walking. Return best result so far.
273
+ return previous_code
274
+
275
+ @staticmethod
276
+ def _try_generate(syntax_tree: dict) -> str | None:
277
+ """Generate source from *syntax_tree*, returning ``None`` on failure."""
278
+ try:
279
+ return generate(syntax_tree)
280
+ except Exception:
281
+ return None
282
+
283
+ @staticmethod
284
+ def _run_post_passes(syntax_tree: dict) -> bool:
285
+ """Run cosmetic post-passes (renaming, var-to-const).
286
+
287
+ Returns ``True`` if any post-pass modified the AST.
288
+ """
289
+ any_changed = False
290
+ for post_transform_class in _POST_PASS_TRANSFORMS:
291
+ try:
292
+ if post_transform_class(syntax_tree).execute():
293
+ any_changed = True
294
+ except Exception:
295
+ pass
296
+ return any_changed
297
+
298
+ def _run_ast_transforms(self, syntax_tree: dict, code_size: int = 0) -> bool:
299
+ """Run all AST transform passes.
300
+
301
+ Returns ``True`` if any transform modified the AST.
302
+ """
303
+ node_count = _count_nodes(syntax_tree) if code_size > _LARGE_FILE_SIZE else 0
304
+ iteration_limit = self._compute_iteration_limit(code_size, node_count)
305
+ active_transforms = self._select_transforms(code_size, node_count)
306
+
307
+ skipped_transforms: set[type] = set()
308
+
309
+ scope_tree: dict | None = None
310
+ node_scope: dict | None = None
311
+ scope_dirty: bool = True
312
+
313
+ any_transform_changed = False
314
+ for iteration in range(iteration_limit):
315
+ modified = False
316
+ for transform_class in active_transforms:
317
+ if transform_class in skipped_transforms:
318
+ continue
319
+
320
+ result, scope_tree, node_scope, scope_dirty = self._execute_single_transform(
321
+ syntax_tree,
322
+ transform_class,
323
+ scope_tree,
324
+ node_scope,
325
+ scope_dirty,
326
+ )
327
+
328
+ if result is None:
329
+ continue
330
+ if result:
331
+ modified = True
332
+ any_transform_changed = True
333
+ elif iteration > 0:
334
+ skipped_transforms.add(transform_class)
335
+
336
+ if not modified:
337
+ break
338
+
339
+ return any_transform_changed
340
+
341
+ def _compute_iteration_limit(self, code_size: int, node_count: int) -> int:
342
+ """Determine the maximum iteration count based on file/AST size."""
343
+ limit = self.max_iterations
344
+ if code_size > _LARGE_FILE_SIZE:
345
+ limit = min(limit, _LITE_MAX_ITERATIONS)
346
+ if node_count > _VERY_LARGE_NODE_COUNT:
347
+ limit = min(limit, 3)
348
+ return limit
349
+
350
+ @staticmethod
351
+ def _select_transforms(code_size: int, node_count: int) -> list[type]:
352
+ """Return the transform list, excluding expensive ones for large inputs."""
353
+ if code_size > _MAX_CODE_SIZE or node_count > _NODE_COUNT_LIMIT:
354
+ return [transform for transform in TRANSFORM_CLASSES if transform not in _EXPENSIVE_TRANSFORMS]
355
+ return TRANSFORM_CLASSES
356
+
357
+ @staticmethod
358
+ def _execute_single_transform(
359
+ syntax_tree: dict,
360
+ transform_class: type,
361
+ scope_tree: dict | None,
362
+ node_scope: dict | None,
363
+ scope_dirty: bool,
364
+ ) -> tuple[bool | None, dict | None, dict | None, bool]:
365
+ """Run a single transform, rebuilding scope lazily as needed.
366
+
367
+ Returns ``(result, scope_tree, node_scope, scope_dirty)`` where
368
+ *result* is ``True``/``False`` for success/no-change, or ``None``
369
+ if the transform raised an exception.
370
+ """
371
+ try:
372
+ if transform_class in _SCOPE_TRANSFORMS and scope_dirty:
373
+ scope_tree, node_scope = build_scope_tree(syntax_tree)
374
+ scope_dirty = False
375
+
376
+ if transform_class in _SCOPE_TRANSFORMS:
377
+ transform = transform_class(
378
+ syntax_tree,
379
+ scope_tree=scope_tree,
380
+ node_scope=node_scope,
381
+ )
382
+ else:
383
+ transform = transform_class(syntax_tree)
384
+
385
+ result = transform.execute()
386
+ except Exception:
387
+ return None, scope_tree, node_scope, scope_dirty
388
+
389
+ if result:
390
+ scope_dirty = True
391
+ return result, scope_tree, node_scope, scope_dirty
@@ -1,6 +1,8 @@
1
1
  """ESTree AST to JavaScript code generator."""
2
+
2
3
  from __future__ import annotations
3
4
 
5
+
4
6
  # Operator precedence (higher = binds tighter)
5
7
  _PRECEDENCE = {
6
8
  '=': 3,
@@ -72,9 +74,9 @@ def generate(node: dict | None, indent: int = 0) -> str:
72
74
  return str(node)
73
75
 
74
76
  node_type = node.get('type', '')
75
- gen = _GENERATORS.get(node_type)
76
- if gen:
77
- return gen(node, indent)
77
+ generator_function = _GENERATORS.get(node_type)
78
+ if generator_function:
79
+ return generator_function(node, indent)
78
80
  return f'/* unknown: {node_type} */'
79
81
 
80
82
 
@@ -153,12 +155,12 @@ def _gen_function(node: dict, indent: int, is_expression: bool = False) -> str:
153
155
  name = generate(node['id'], indent) if node.get('id') else ''
154
156
  params = ', '.join(generate(param, indent) for param in node.get('params', []))
155
157
  async_prefix = 'async ' if node.get('async') else ''
156
- gen_prefix = '*' if node.get('generator') else ''
158
+ generator_prefix = '*' if node.get('generator') else ''
157
159
  body = generate(node['body'], indent)
158
160
  if name:
159
- return f'{async_prefix}function{gen_prefix} {name}({params}) {body}'
161
+ return f'{async_prefix}function{generator_prefix} {name}({params}) {body}'
160
162
  # Anonymous: always put space before parens (Babel style)
161
- return f'{async_prefix}function{gen_prefix} ({params}) {body}'
163
+ return f'{async_prefix}function{generator_prefix} ({params}) {body}'
162
164
 
163
165
 
164
166
  def _gen_function_decl(node: dict, indent: int) -> str:
@@ -172,13 +174,13 @@ def _gen_function_expr(node: dict, indent: int) -> str:
172
174
  def _gen_arrow(node: dict, indent: int) -> str:
173
175
  params = node.get('params', [])
174
176
  async_prefix = 'async ' if node.get('async') else ''
175
- param_str = '(' + ', '.join(generate(param, indent) for param in params) + ')'
177
+ parameter_string = '(' + ', '.join(generate(param, indent) for param in params) + ')'
176
178
  body = node.get('body', {})
177
- body_str = generate(body, indent)
179
+ body_string = generate(body, indent)
178
180
  # Wrap object literal in parens to avoid ambiguity with block
179
181
  if body.get('type') == 'ObjectExpression':
180
- body_str = '(' + body_str + ')'
181
- return f'{async_prefix}{param_str} => {body_str}'
182
+ body_string = '(' + body_string + ')'
183
+ return f'{async_prefix}{parameter_string} => {body_string}'
182
184
 
183
185
 
184
186
  def _gen_return(node: dict, indent: int) -> str:
@@ -344,13 +346,13 @@ def _gen_assignment(node: dict, indent: int) -> str:
344
346
 
345
347
  def _gen_member(node: dict, indent: int) -> str:
346
348
  object_code = generate(node['object'], indent)
347
- obj_type = node['object'].get('type', '')
349
+ object_type = node['object'].get('type', '')
348
350
  computed = node.get('computed')
349
351
 
350
352
  needs_parens = False
351
- if obj_type == 'Literal' and isinstance(node['object'].get('value'), (int, float)):
353
+ if object_type == 'Literal' and isinstance(node['object'].get('value'), (int, float)):
352
354
  needs_parens = not computed
353
- elif obj_type in (
355
+ elif object_type in (
354
356
  'BinaryExpression',
355
357
  'UnaryExpression',
356
358
  'ConditionalExpression',
@@ -364,12 +366,12 @@ def _gen_member(node: dict, indent: int) -> str:
364
366
  object_code = f'({object_code})'
365
367
 
366
368
  property_code = generate(node['property'], indent)
367
- dot = '?.' if node.get('optional') else '.'
369
+ accessor = '?.' if node.get('optional') else '.'
368
370
  if computed:
369
371
  if node.get('optional'):
370
372
  return f'{object_code}?.[{property_code}]'
371
373
  return f'{object_code}[{property_code}]'
372
- return f'{object_code}{dot}{property_code}'
374
+ return f'{object_code}{accessor}{property_code}'
373
375
 
374
376
 
375
377
  def _gen_call(node: dict, indent: int) -> str:
@@ -377,18 +379,18 @@ def _gen_call(node: dict, indent: int) -> str:
377
379
  callee_type = node['callee'].get('type', '')
378
380
  if callee_type in ('FunctionExpression', 'ArrowFunctionExpression', 'SequenceExpression'):
379
381
  callee = f'({callee})'
380
- args = ', '.join(generate(argument, indent) for argument in node.get('arguments', []))
382
+ argument_string = ', '.join(generate(argument, indent) for argument in node.get('arguments', []))
381
383
  if node.get('optional'):
382
- return f'{callee}?.({args})'
383
- return f'{callee}({args})'
384
+ return f'{callee}?.({argument_string})'
385
+ return f'{callee}({argument_string})'
384
386
 
385
387
 
386
388
  def _gen_new(node: dict, indent: int) -> str:
387
389
  callee = generate(node['callee'], indent)
388
- args = node.get('arguments', [])
389
- if args:
390
- arg_str = ', '.join(generate(argument, indent) for argument in args)
391
- return f'new {callee}({arg_str})'
390
+ arguments = node.get('arguments', [])
391
+ if arguments:
392
+ argument_string = ', '.join(generate(argument, indent) for argument in arguments)
393
+ return f'new {callee}({argument_string})'
392
394
  return f'new {callee}()'
393
395
 
394
396
 
@@ -407,14 +409,13 @@ def _gen_conditional(node: dict, indent: int) -> str:
407
409
 
408
410
 
409
411
  def _gen_sequence(node: dict, indent: int) -> str:
410
- exprs = ', '.join(generate(expression, indent) for expression in node.get('expressions', []))
411
- return exprs
412
+ return ', '.join(generate(expression, indent) for expression in node.get('expressions', []))
412
413
 
413
414
 
414
415
  def _gen_bracket_list(elements: list, indent: int) -> str:
415
416
  """Generate a bracketed list of elements, replacing None with empty slots."""
416
- elems = [generate(element, indent) if element is not None else '' for element in elements]
417
- return '[' + ', '.join(elems) + ']'
417
+ generated_elements = [generate(element, indent) if element is not None else '' for element in elements]
418
+ return '[' + ', '.join(generated_elements) + ']'
418
419
 
419
420
 
420
421
  def _gen_array(node: dict, indent: int) -> str:
@@ -568,8 +569,8 @@ def _gen_method_def(node: dict, indent: int) -> str:
568
569
  return f'{static_prefix}set {key}({params}) {body}'
569
570
  case _:
570
571
  async_prefix = 'async ' if value.get('async') else ''
571
- gen_prefix = '*' if value.get('generator') else ''
572
- return f'{static_prefix}{async_prefix}{gen_prefix}{key}({params}) {body}'
572
+ generator_prefix = '*' if value.get('generator') else ''
573
+ return f'{static_prefix}{async_prefix}{generator_prefix}{key}({params}) {body}'
573
574
 
574
575
 
575
576
  def _gen_yield(node: dict, indent: int) -> str:
@@ -639,9 +640,9 @@ def _gen_import_declaration(node: dict, indent: int) -> str:
639
640
  specifiers = node.get('specifiers', [])
640
641
  if not specifiers:
641
642
  return f'import {source}'
642
- default_specifiers = [s for s in specifiers if s.get('type') == 'ImportDefaultSpecifier']
643
- namespace_specifiers = [s for s in specifiers if s.get('type') == 'ImportNamespaceSpecifier']
644
- named_specifiers = [s for s in specifiers if s.get('type') == 'ImportSpecifier']
643
+ default_specifiers = [spec for spec in specifiers if spec.get('type') == 'ImportDefaultSpecifier']
644
+ namespace_specifiers = [spec for spec in specifiers if spec.get('type') == 'ImportNamespaceSpecifier']
645
+ named_specifiers = [spec for spec in specifiers if spec.get('type') == 'ImportSpecifier']
645
646
  parts = []
646
647
  if default_specifiers:
647
648
  parts.append(_gen_import_specifier(default_specifiers[0], indent))