pyjsclear 0.1.0__tar.gz → 0.1.2__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 (69) hide show
  1. pyjsclear-0.1.2/PKG-INFO +126 -0
  2. pyjsclear-0.1.2/README.md +100 -0
  3. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/__init__.py +2 -2
  4. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/deobfuscator.py +53 -17
  5. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/generator.py +6 -1
  6. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/scope.py +107 -81
  7. pyjsclear-0.1.2/pyjsclear/transforms/class_static_resolver.py +206 -0
  8. pyjsclear-0.1.2/pyjsclear/transforms/class_string_decoder.py +332 -0
  9. pyjsclear-0.1.2/pyjsclear/transforms/cleanup.py +272 -0
  10. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/dead_branch.py +18 -0
  11. pyjsclear-0.1.2/pyjsclear/transforms/dead_class_props.py +251 -0
  12. pyjsclear-0.1.2/pyjsclear/transforms/dead_expressions.py +29 -0
  13. pyjsclear-0.1.2/pyjsclear/transforms/dead_object_props.py +150 -0
  14. pyjsclear-0.1.2/pyjsclear/transforms/else_if_flatten.py +32 -0
  15. pyjsclear-0.1.2/pyjsclear/transforms/enum_resolver.py +175 -0
  16. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/eval_unpack.py +3 -52
  17. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/expression_simplifier.py +106 -0
  18. pyjsclear-0.1.2/pyjsclear/transforms/global_alias.py +117 -0
  19. pyjsclear-0.1.2/pyjsclear/transforms/hex_numerics.py +30 -0
  20. pyjsclear-0.1.2/pyjsclear/transforms/member_chain_resolver.py +171 -0
  21. pyjsclear-0.1.2/pyjsclear/transforms/noop_calls.py +77 -0
  22. pyjsclear-0.1.2/pyjsclear/transforms/nullish_coalescing.py +98 -0
  23. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/object_simplifier.py +7 -5
  24. pyjsclear-0.1.2/pyjsclear/transforms/optional_chaining.py +152 -0
  25. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/property_simplifier.py +29 -4
  26. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/proxy_functions.py +44 -2
  27. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/reassignment.py +5 -0
  28. pyjsclear-0.1.2/pyjsclear/transforms/require_inliner.py +81 -0
  29. pyjsclear-0.1.2/pyjsclear/transforms/single_use_vars.py +155 -0
  30. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/string_revealer.py +2 -0
  31. pyjsclear-0.1.2/pyjsclear/transforms/unreachable_code.py +37 -0
  32. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/unused_vars.py +3 -2
  33. pyjsclear-0.1.2/pyjsclear/transforms/variable_renamer.py +511 -0
  34. pyjsclear-0.1.2/pyjsclear/transforms/xor_string_decode.py +228 -0
  35. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/utils/ast_helpers.py +72 -2
  36. pyjsclear-0.1.2/pyjsclear.egg-info/PKG-INFO +126 -0
  37. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear.egg-info/SOURCES.txt +19 -3
  38. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/tests/test_regression.py +288 -13
  39. pyjsclear-0.1.0/PKG-INFO +0 -168
  40. pyjsclear-0.1.0/README.md +0 -142
  41. pyjsclear-0.1.0/pyjsclear/transforms/jj_decode.py +0 -91
  42. pyjsclear-0.1.0/pyjsclear/transforms/jsfuck_decode.py +0 -93
  43. pyjsclear-0.1.0/pyjsclear.egg-info/PKG-INFO +0 -168
  44. pyjsclear-0.1.0/setup.py +0 -18
  45. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/LICENSE +0 -0
  46. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/MANIFEST.in +0 -0
  47. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/NOTICE +0 -0
  48. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/THIRD_PARTY_LICENSES.md +0 -0
  49. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/__main__.py +0 -0
  50. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/parser.py +0 -0
  51. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/__init__.py +0 -0
  52. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/aa_decode.py +0 -0
  53. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/anti_tamper.py +0 -0
  54. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/base.py +0 -0
  55. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/constant_prop.py +0 -0
  56. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/control_flow.py +0 -0
  57. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/hex_escapes.py +0 -0
  58. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/logical_to_if.py +0 -0
  59. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/object_packer.py +0 -0
  60. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/transforms/sequence_splitter.py +0 -0
  61. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/traverser.py +0 -0
  62. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/utils/__init__.py +0 -0
  63. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear/utils/string_decoders.py +0 -0
  64. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear.egg-info/dependency_links.txt +0 -0
  65. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear.egg-info/entry_points.txt +0 -0
  66. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear.egg-info/requires.txt +0 -0
  67. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyjsclear.egg-info/top_level.txt +0 -0
  68. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/pyproject.toml +0 -0
  69. {pyjsclear-0.1.0 → pyjsclear-0.1.2}/setup.cfg +0 -0
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyjsclear
3
+ Version: 0.1.2
4
+ Summary: Pure Python JavaScript deobfuscator
5
+ Author-email: Intezer Labs <info@intezer.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/intezer/PyJSClear
8
+ Project-URL: Repository, https://github.com/intezer/PyJSClear
9
+ Project-URL: Issues, https://github.com/intezer/PyJSClear/issues
10
+ Keywords: javascript,deobfuscator,deobfuscation,security,malware-analysis,ast
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Information Technology
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ License-File: NOTICE
24
+ Requires-Dist: esprima2>=5.0.1
25
+ Dynamic: license-file
26
+
27
+ <p align="center">
28
+ <img src="https://raw.githubusercontent.com/intezer/PyJSClear/main/PyJSClear.png" alt="PyJSClear" width="200">
29
+ </p>
30
+
31
+ # PyJSClear
32
+
33
+ Pure Python JavaScript deobfuscator.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install pyjsclear
39
+ ```
40
+
41
+ For development:
42
+
43
+ ```bash
44
+ git clone https://github.com/intezer/PyJSClear.git
45
+ cd PyJSClear
46
+ pip install -e .
47
+ pip install pytest
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ### Python API
53
+
54
+ ```python
55
+ from pyjsclear import deobfuscate, deobfuscate_file
56
+
57
+ # From a string
58
+ cleaned = deobfuscate(obfuscated_code)
59
+
60
+ # From a file
61
+ deobfuscate_file("input.js", "output.js")
62
+
63
+ # Or get the result as a string
64
+ cleaned = deobfuscate_file("input.js")
65
+ ```
66
+
67
+ ### Command line
68
+
69
+ ```bash
70
+ # File to stdout
71
+ pyjsclear input.js
72
+
73
+ # File to file
74
+ pyjsclear input.js -o output.js
75
+
76
+ # Stdin to stdout
77
+ cat input.js | pyjsclear -
78
+
79
+ # With custom iteration limit
80
+ pyjsclear input.js --max-iterations 20
81
+ ```
82
+
83
+ ## What it does
84
+
85
+ PyJSClear applies ~40 transforms in a multi-pass loop until the code
86
+ stabilises (default limit: 50 iterations). A final one-shot pass renames
87
+ variables and converts var/let to const.
88
+
89
+ **Capabilities:**
90
+ - String array decoding (obfuscator.io basic/base64/RC4, XOR, class-based)
91
+ - Constant propagation & reassignment elimination
92
+ - Dead code / dead branch / unreachable code removal
93
+ - Control-flow unflattening (switch-dispatch recovery)
94
+ - Proxy function & proxy object inlining
95
+ - Expression simplification & modern syntax recovery (?., ??)
96
+ - Anti-tamper / anti-debug removal
97
+ - Variable renaming (_0x… → readable names)
98
+
99
+ Large files (>500 KB / >50 K AST nodes) automatically use a lite mode
100
+ that skips expensive transforms.
101
+
102
+ ## Testing
103
+
104
+ ```bash
105
+ pytest tests/ # all tests
106
+ pytest tests/test_regression.py # regression suite (62 tests across 25 samples)
107
+ pytest tests/ -n auto # parallel execution (requires pytest-xdist)
108
+ ```
109
+
110
+ ## Limitations
111
+
112
+ - **Optimised for obfuscator.io output.** Other obfuscation tools may only partially deobfuscate.
113
+ - **Large files get reduced treatment.** Files >500 KB or ASTs >50 K nodes skip expensive transforms; files >2 MB use a minimal lite mode.
114
+ - **No minification reversal.** Minified-but-not-obfuscated code won't be reformatted or beautified.
115
+ - **Recursive AST traversal** may hit Python's default recursion limit (~1 000 frames) on extremely deep nesting.
116
+
117
+ ## License
118
+
119
+ Apache License 2.0 — see [LICENSE](LICENSE).
120
+
121
+ This project is a derivative work based on
122
+ [obfuscator-io-deobfuscator](https://github.com/ben-sb/obfuscator-io-deobfuscator)
123
+ (Apache 2.0) and
124
+ [javascript-deobfuscator](https://github.com/ben-sb/javascript-deobfuscator)
125
+ (Apache 2.0). See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
126
+ [NOTICE](NOTICE) for full attribution.
@@ -0,0 +1,100 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/intezer/PyJSClear/main/PyJSClear.png" alt="PyJSClear" width="200">
3
+ </p>
4
+
5
+ # PyJSClear
6
+
7
+ Pure Python JavaScript deobfuscator.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install pyjsclear
13
+ ```
14
+
15
+ For development:
16
+
17
+ ```bash
18
+ git clone https://github.com/intezer/PyJSClear.git
19
+ cd PyJSClear
20
+ pip install -e .
21
+ pip install pytest
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### Python API
27
+
28
+ ```python
29
+ from pyjsclear import deobfuscate, deobfuscate_file
30
+
31
+ # From a string
32
+ cleaned = deobfuscate(obfuscated_code)
33
+
34
+ # From a file
35
+ deobfuscate_file("input.js", "output.js")
36
+
37
+ # Or get the result as a string
38
+ cleaned = deobfuscate_file("input.js")
39
+ ```
40
+
41
+ ### Command line
42
+
43
+ ```bash
44
+ # File to stdout
45
+ pyjsclear input.js
46
+
47
+ # File to file
48
+ pyjsclear input.js -o output.js
49
+
50
+ # Stdin to stdout
51
+ cat input.js | pyjsclear -
52
+
53
+ # With custom iteration limit
54
+ pyjsclear input.js --max-iterations 20
55
+ ```
56
+
57
+ ## What it does
58
+
59
+ PyJSClear applies ~40 transforms in a multi-pass loop until the code
60
+ stabilises (default limit: 50 iterations). A final one-shot pass renames
61
+ variables and converts var/let to const.
62
+
63
+ **Capabilities:**
64
+ - String array decoding (obfuscator.io basic/base64/RC4, XOR, class-based)
65
+ - Constant propagation & reassignment elimination
66
+ - Dead code / dead branch / unreachable code removal
67
+ - Control-flow unflattening (switch-dispatch recovery)
68
+ - Proxy function & proxy object inlining
69
+ - Expression simplification & modern syntax recovery (?., ??)
70
+ - Anti-tamper / anti-debug removal
71
+ - Variable renaming (_0x… → readable names)
72
+
73
+ Large files (>500 KB / >50 K AST nodes) automatically use a lite mode
74
+ that skips expensive transforms.
75
+
76
+ ## Testing
77
+
78
+ ```bash
79
+ pytest tests/ # all tests
80
+ pytest tests/test_regression.py # regression suite (62 tests across 25 samples)
81
+ pytest tests/ -n auto # parallel execution (requires pytest-xdist)
82
+ ```
83
+
84
+ ## Limitations
85
+
86
+ - **Optimised for obfuscator.io output.** Other obfuscation tools may only partially deobfuscate.
87
+ - **Large files get reduced treatment.** Files >500 KB or ASTs >50 K nodes skip expensive transforms; files >2 MB use a minimal lite mode.
88
+ - **No minification reversal.** Minified-but-not-obfuscated code won't be reformatted or beautified.
89
+ - **Recursive AST traversal** may hit Python's default recursion limit (~1 000 frames) on extremely deep nesting.
90
+
91
+ ## License
92
+
93
+ Apache License 2.0 — see [LICENSE](LICENSE).
94
+
95
+ This project is a derivative work based on
96
+ [obfuscator-io-deobfuscator](https://github.com/ben-sb/obfuscator-io-deobfuscator)
97
+ (Apache 2.0) and
98
+ [javascript-deobfuscator](https://github.com/ben-sb/javascript-deobfuscator)
99
+ (Apache 2.0). See [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) and
100
+ [NOTICE](NOTICE) for full attribution.
@@ -2,13 +2,13 @@
2
2
 
3
3
  Combines functionality from obfuscator-io-deobfuscator (13 AST transforms)
4
4
  and javascript-deobfuscator (3 surface-cleanup modules) into a single
5
- Python package with no Node.js dependency.
5
+ Python package.
6
6
  """
7
7
 
8
8
  from .deobfuscator import Deobfuscator
9
9
 
10
10
 
11
- __version__ = '0.1.0'
11
+ __version__ = '0.1.2'
12
12
 
13
13
 
14
14
  def deobfuscate(code, max_iterations=50):
@@ -5,28 +5,47 @@ from .parser import parse
5
5
  from .transforms.aa_decode import aa_decode
6
6
  from .transforms.aa_decode import is_aa_encoded
7
7
  from .transforms.anti_tamper import AntiTamperRemover
8
+ from .transforms.class_static_resolver import ClassStaticResolver
9
+ from .transforms.class_string_decoder import ClassStringDecoder
10
+ from .transforms.cleanup import EmptyIfRemover
11
+ from .transforms.cleanup import LetToConst
12
+ from .transforms.cleanup import OptionalCatchBinding
13
+ from .transforms.cleanup import ReturnUndefinedCleanup
14
+ from .transforms.cleanup import TrailingReturnRemover
15
+ from .transforms.cleanup import VarToConst
8
16
  from .transforms.constant_prop import ConstantProp
9
17
  from .transforms.control_flow import ControlFlowRecoverer
10
18
  from .transforms.dead_branch import DeadBranchRemover
19
+ from .transforms.dead_class_props import DeadClassPropRemover
20
+ from .transforms.dead_expressions import DeadExpressionRemover
21
+ from .transforms.dead_object_props import DeadObjectPropRemover
22
+ from .transforms.else_if_flatten import ElseIfFlattener
23
+ from .transforms.enum_resolver import EnumResolver
11
24
  from .transforms.eval_unpack import eval_unpack
12
25
  from .transforms.eval_unpack import is_eval_packed
13
26
  from .transforms.expression_simplifier import ExpressionSimplifier
27
+ from .transforms.global_alias import GlobalAliasInliner
14
28
  from .transforms.hex_escapes import HexEscapes
15
29
  from .transforms.hex_escapes import decode_hex_escapes_source
16
- from .transforms.jj_decode import is_jj_encoded
17
- from .transforms.jj_decode import jj_decode
18
- from .transforms.jj_decode import jj_decode_via_eval
19
- from .transforms.jsfuck_decode import is_jsfuck
20
- from .transforms.jsfuck_decode import jsfuck_decode
30
+ from .transforms.hex_numerics import HexNumerics
21
31
  from .transforms.logical_to_if import LogicalToIf
32
+ from .transforms.member_chain_resolver import MemberChainResolver
33
+ from .transforms.noop_calls import NoopCallRemover
34
+ from .transforms.nullish_coalescing import NullishCoalescing
22
35
  from .transforms.object_packer import ObjectPacker
23
36
  from .transforms.object_simplifier import ObjectSimplifier
37
+ from .transforms.optional_chaining import OptionalChaining
24
38
  from .transforms.property_simplifier import PropertySimplifier
25
39
  from .transforms.proxy_functions import ProxyFunctionInliner
26
40
  from .transforms.reassignment import ReassignmentRemover
41
+ from .transforms.require_inliner import RequireInliner
27
42
  from .transforms.sequence_splitter import SequenceSplitter
43
+ from .transforms.single_use_vars import SingleUseVarInliner
28
44
  from .transforms.string_revealer import StringRevealer
45
+ from .transforms.unreachable_code import UnreachableCodeRemover
29
46
  from .transforms.unused_vars import UnusedVariableRemover
47
+ from .transforms.variable_renamer import VariableRenamer
48
+ from .transforms.xor_string_decode import XorStringDecoder
30
49
  from .traverser import simple_traverse
31
50
 
32
51
 
@@ -36,15 +55,36 @@ from .traverser import simple_traverse
36
55
  TRANSFORM_CLASSES = [
37
56
  StringRevealer,
38
57
  HexEscapes,
58
+ HexNumerics,
59
+ ClassStringDecoder,
60
+ XorStringDecoder,
61
+ MemberChainResolver,
62
+ DeadClassPropRemover,
63
+ ClassStaticResolver,
64
+ EnumResolver,
65
+ RequireInliner,
66
+ GlobalAliasInliner,
39
67
  UnusedVariableRemover,
40
68
  ConstantProp,
41
69
  ReassignmentRemover,
70
+ SingleUseVarInliner,
42
71
  DeadBranchRemover,
72
+ UnreachableCodeRemover,
73
+ NoopCallRemover,
74
+ EmptyIfRemover,
75
+ DeadObjectPropRemover,
43
76
  ObjectPacker,
44
77
  ProxyFunctionInliner,
45
78
  SequenceSplitter,
79
+ DeadExpressionRemover,
46
80
  ExpressionSimplifier,
81
+ NullishCoalescing,
82
+ OptionalChaining,
47
83
  LogicalToIf,
84
+ ElseIfFlattener,
85
+ OptionalCatchBinding,
86
+ ReturnUndefinedCleanup,
87
+ TrailingReturnRemover,
48
88
  ControlFlowRecoverer,
49
89
  PropertySimplifier,
50
90
  AntiTamperRemover,
@@ -87,24 +127,12 @@ class Deobfuscator:
87
127
  Returns decoded code if an encoding/packing was detected and decoded,
88
128
  or None to continue with the normal AST pipeline.
89
129
  """
90
- # JSFUCK check (must be first — these are whole-file encodings)
91
- if is_jsfuck(code):
92
- decoded = jsfuck_decode(code)
93
- if decoded:
94
- return decoded
95
-
96
130
  # AAEncode check
97
131
  if is_aa_encoded(code):
98
132
  decoded = aa_decode(code)
99
133
  if decoded:
100
134
  return decoded
101
135
 
102
- # JJEncode check
103
- if is_jj_encoded(code):
104
- decoded = jj_decode(code) or jj_decode_via_eval(code)
105
- if decoded:
106
- return decoded
107
-
108
136
  # Eval packer check
109
137
  if is_eval_packed(code):
110
138
  decoded = eval_unpack(code)
@@ -182,6 +210,14 @@ class Deobfuscator:
182
210
  if not modified:
183
211
  break
184
212
 
213
+ # Post-passes: cosmetic transforms that run once after convergence
214
+ for post_transform in [VariableRenamer, VarToConst, LetToConst]:
215
+ try:
216
+ if post_transform(ast).execute():
217
+ any_transform_changed = True
218
+ except Exception:
219
+ pass
220
+
185
221
  if not any_transform_changed:
186
222
  return self.original_code
187
223
 
@@ -363,9 +363,12 @@ def _gen_member(node, indent):
363
363
  object_code = f'({object_code})'
364
364
 
365
365
  property_code = generate(node['property'], indent)
366
+ dot = '?.' if node.get('optional') else '.'
366
367
  if computed:
368
+ if node.get('optional'):
369
+ return f'{object_code}?.[{property_code}]'
367
370
  return f'{object_code}[{property_code}]'
368
- return f'{object_code}.{property_code}'
371
+ return f'{object_code}{dot}{property_code}'
369
372
 
370
373
 
371
374
  def _gen_call(node, indent):
@@ -374,6 +377,8 @@ def _gen_call(node, indent):
374
377
  if callee_type in ('FunctionExpression', 'ArrowFunctionExpression', 'SequenceExpression'):
375
378
  callee = f'({callee})'
376
379
  args = ', '.join(generate(a, indent) for a in node.get('arguments', []))
380
+ if node.get('optional'):
381
+ return f'{callee}?.({args})'
377
382
  return f'{callee}({args})'
378
383
 
379
384
 
@@ -70,19 +70,17 @@ def _is_non_reference_identifier(parent, parent_key):
70
70
  """Return True if this Identifier usage is not a variable reference."""
71
71
  if not parent:
72
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
73
+ match parent.get('type'):
74
+ case 'MemberExpression' if parent_key == 'property' and not parent.get('computed'):
75
+ return True
76
+ case 'Property' if parent_key == 'key' and not parent.get('computed'):
77
+ return True
78
+ case 'FunctionDeclaration' | 'FunctionExpression' | 'ClassDeclaration' | 'ClassExpression' if (
79
+ parent_key == 'id'
80
+ ):
81
+ return True
82
+ case 'VariableDeclarator' if parent_key == 'id':
83
+ return True
86
84
  return False
87
85
 
88
86
 
@@ -132,76 +130,104 @@ def build_scope_tree(ast):
132
130
  if node_type is None:
133
131
  return
134
132
 
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', []):
133
+ match node_type:
134
+ case 'FunctionDeclaration' | 'FunctionExpression' | 'ArrowFunctionExpression':
135
+ new_scope = Scope(scope, node, is_function=True)
136
+ node_scope[id(node)] = new_scope
137
+ all_scopes.append(new_scope)
138
+
139
+ # Function name goes in outer scope (for declarations) or inner (for expressions)
140
+ if node_type == 'FunctionDeclaration' and node.get('id'):
141
+ scope.add_binding(node['id']['name'], node, 'function')
142
+ elif node_type == 'FunctionExpression' and node.get('id'):
143
+ new_scope.add_binding(node['id']['name'], node, 'function')
144
+
145
+ # Params go in function scope
146
+ for param in node.get('params', []):
147
+ match param.get('type'):
148
+ case 'Identifier':
149
+ new_scope.add_binding(param['name'], param, 'param')
150
+ case 'AssignmentPattern' if param.get('left', {}).get('type') == 'Identifier':
151
+ new_scope.add_binding(param['left']['name'], param, 'param')
152
+ case 'RestElement':
153
+ arg = param.get('argument')
154
+ if arg and arg.get('type') == 'Identifier':
155
+ new_scope.add_binding(arg['name'], param, 'param')
156
+
157
+ # Body - use the new scope
158
+ body = node.get('body')
159
+ if not body:
160
+ return
161
+ if isinstance(body, dict) and body.get('type') == 'BlockStatement':
162
+ node_scope[id(body)] = new_scope
163
+ for statement in body.get('body', []):
164
+ _collect_declarations(statement, new_scope)
165
+ else:
166
+ _collect_declarations(body, new_scope)
167
+
168
+ case 'ClassExpression' | 'ClassDeclaration':
169
+ class_id = node.get('id')
170
+ inner_scope = scope
171
+ if class_id and class_id.get('type') == 'Identifier':
172
+ name = class_id['name']
173
+ if node_type == 'ClassDeclaration':
174
+ scope.add_binding(name, node, 'function')
175
+ else:
176
+ inner_scope = Scope(scope, node)
177
+ node_scope[id(node)] = inner_scope
178
+ all_scopes.append(inner_scope)
179
+ inner_scope.add_binding(name, node, 'function')
180
+ body = node.get('body')
181
+ if body:
182
+ _collect_declarations(body, inner_scope)
183
+ superclass = node.get('superClass')
184
+ if superclass:
185
+ _collect_declarations(superclass, scope)
186
+
187
+ case 'VariableDeclaration':
188
+ kind = node.get('kind', 'var')
189
+ target_scope = (_nearest_function_scope(scope) or scope) if kind == 'var' else scope
190
+ for declaration in node.get('declarations', []):
191
+ declaration_id = declaration.get('id')
192
+ if declaration_id and declaration_id.get('type') == 'Identifier':
193
+ target_scope.add_binding(declaration_id['name'], declaration, kind)
194
+ _collect_pattern_names(declaration_id, target_scope, kind, declaration)
195
+ init = declaration.get('init')
196
+ if init:
197
+ _collect_declarations(init, scope)
198
+
199
+ case 'BlockStatement' if id(node) not in node_scope:
200
+ new_scope = Scope(scope, node)
201
+ node_scope[id(node)] = new_scope
202
+ all_scopes.append(new_scope)
203
+ for statement in node.get('body', []):
165
204
  _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
205
 
203
- # Recurse into children
204
- _recurse_into_children(node, _child_keys_map, lambda child_node: _collect_declarations(child_node, scope))
206
+ case 'ForStatement':
207
+ new_scope = Scope(scope, node)
208
+ node_scope[id(node)] = new_scope
209
+ all_scopes.append(new_scope)
210
+ if node.get('init'):
211
+ _collect_declarations(node['init'], new_scope)
212
+ if node.get('body'):
213
+ _collect_declarations(node['body'], new_scope)
214
+
215
+ case 'CatchClause':
216
+ catch_body = node.get('body')
217
+ if catch_body and catch_body.get('type') == 'BlockStatement':
218
+ catch_scope = Scope(scope, catch_body)
219
+ node_scope[id(catch_body)] = catch_scope
220
+ all_scopes.append(catch_scope)
221
+ param = node.get('param')
222
+ if param and param.get('type') == 'Identifier':
223
+ catch_scope.add_binding(param['name'], param, 'param')
224
+ for statement in catch_body.get('body', []):
225
+ _collect_declarations(statement, catch_scope)
226
+
227
+ case _:
228
+ _recurse_into_children(
229
+ node, _child_keys_map, lambda child_node: _collect_declarations(child_node, scope)
230
+ )
205
231
 
206
232
  def _collect_pattern_names(pattern, scope, kind, declaration):
207
233
  """Collect binding names from destructuring patterns."""