depyo 1.0.2 → 1.1.0

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.
@@ -1,6 +1,7 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
3
  const AST = require('./ast/ast_node');
4
+ const { resolveBoolChainFrames } = require('./handlers/control_flow_jumps');
4
5
 
5
6
  Array.prototype.top = function ArrayTop (pos = 0) {
6
7
  return this[this.length - pos - 1];
@@ -13,6 +14,7 @@ Array.prototype.empty = function ArrayIsEmpty () {
13
14
  class PycDecompiler {
14
15
  static opCodeHandlers = {};
15
16
  cleanBuild = false;
17
+ errors = []; // Per-opcode exceptions caught by main loop. Non-empty → cleanBuild=false.
16
18
  object = null;
17
19
  code = null;
18
20
 
@@ -97,21 +99,32 @@ class PycDecompiler {
97
99
  typeof fileExports[handlerName] === 'function' &&
98
100
  handlerName.startsWith("handle")) {
99
101
 
100
- // Convert handler name (e.g., "handleJumpForwardA") to opcode name ("JUMP_FORWARD_A")
101
- let opCodeName = handlerName.replace(/^handle/, '')
102
- .replaceAll(/([A-Z][a-z]+)/g, m => m.toUpperCase() + '_')
103
- .replace(/_$/, '');
102
+ // Convert handler name (e.g., "handleJumpForwardA") to opcode name ("JUMP_FORWARD_A").
103
+ // Primary rule: camelCase → SNAKE_CASE with explicit runs-of-caps handling
104
+ // (HandleABCDef → ABC_DEF). Some legacy handlers like handleCallIntrinsic1A
105
+ // still map via the looser regex — OpCodes.js provides both _1A and _1_A aliases.
106
+ const body = handlerName.replace(/^handle/, '');
107
+ let opCodeName = body
108
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
109
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
110
+ .toUpperCase();
111
+ if (!(opCodeName in OpCodesMap)) {
112
+ const legacy = body
113
+ .replaceAll(/([A-Z][a-z]+)/g, m => m.toUpperCase() + '_')
114
+ .replace(/_$/, '');
115
+ if (legacy in OpCodesMap) opCodeName = legacy;
116
+ }
104
117
 
105
118
  if (opCodeName in OpCodesMap) {
106
119
  const opCodeId = OpCodesMap[opCodeName];
107
120
  const handlerFunc = fileExports[handlerName];
108
121
 
109
122
  if (PycDecompiler.opCodeHandlers[opCodeId]) {
110
- console.warn(`Static Handler warning: OpCode ${opCodeName} (${opCodeId}) already has a handler. Overwriting.`);
123
+ throw new Error(`Static handler collision: OpCode ${opCodeName} (${opCodeId}) already bound; refusing silent overwrite from ${entry.name}:${handlerName}.`);
111
124
  }
112
125
  PycDecompiler.opCodeHandlers[opCodeId] = handlerFunc;
113
126
  } else {
114
- console.warn(`Static Handler mapping warning: OpCode name "${opCodeName}" from ${entry.name} not found in OpCodes map.`);
127
+ throw new Error(`Static handler mapping failed: "${opCodeName}" (from ${entry.name}:${handlerName}) is not in OpCodes. Rename the handler to match a known opcode, or add the opcode to lib/OpCodes.js.`);
115
128
  }
116
129
  }
117
130
  }
@@ -137,9 +150,15 @@ class PycDecompiler {
137
150
  this.enrichGenericAnnotations(functonBody);
138
151
  this.rewriteClassDefinitions(functonBody);
139
152
  this.removeNullSentinelComparisons(functonBody);
153
+ this.cleanupExcMatchArtifacts(functonBody);
154
+ this.hoistNestedExceptBlocks(functonBody);
140
155
  this.dedupeExceptHandlers(functonBody);
141
156
  this.removeDuplicateReturns(functonBody);
142
157
 
158
+ if (this.object.Name == "<lambda>") {
159
+ this.foldLambdaConditional(functonBody);
160
+ }
161
+
143
162
  if (this.object.Name != "<lambda>" && functonBody.last instanceof AST.ASTReturn && functonBody.last.value instanceof AST.ASTNone) {
144
163
  functonBody.list.pop();
145
164
  }
@@ -152,6 +171,81 @@ class PycDecompiler {
152
171
  return functonBody;
153
172
  }
154
173
 
174
+ foldLambdaConditional(body) {
175
+ // Lambda bodies must be single expressions. CPython often compiles a
176
+ // conditional expression `A if cond else B` as:
177
+ // POP_JUMP_IF_FALSE X; LOAD A; RETURN_VALUE; X: LOAD B; RETURN_VALUE
178
+ // The decompiler's default path leaves this as two statements which is
179
+ // invalid Python inside a lambda. Detect the patterns and fold into a
180
+ // single ASTReturn(ASTTernary).
181
+ const list = body?.list;
182
+ if (!Array.isArray(list) || list.length !== 2) {
183
+ return;
184
+ }
185
+ const first = list[0];
186
+ const second = list[1];
187
+ if (!(second instanceof AST.ASTReturn)) {
188
+ return;
189
+ }
190
+ const elseVal = second.value;
191
+
192
+ const makeCondBlock = (condExpr, negative) => {
193
+ const cb = new AST.ASTCondBlock(AST.ASTBlock.BlockType.If, 0, 0, condExpr, !!negative);
194
+ return cb;
195
+ };
196
+
197
+ let tern = null;
198
+
199
+ // Case 1: first is `ASTReturn(ASTBinary(cond AND/OR val))` — the line-900
200
+ // collapse already ran. Recover cond/val/direction from the binary op.
201
+ if (first instanceof AST.ASTReturn && first.value instanceof AST.ASTBinary) {
202
+ const bin = first.value;
203
+ const isAnd = bin.op === AST.ASTBinary.BinOp.LogicalAnd;
204
+ const isOr = bin.op === AST.ASTBinary.BinOp.LogicalOr;
205
+ if (isAnd || isOr) {
206
+ const cond = bin.left;
207
+ const thenVal = bin.right;
208
+ const cb = makeCondBlock(cond, isOr);
209
+ tern = new AST.ASTTernary(cb, thenVal, elseVal);
210
+ }
211
+ }
212
+
213
+ // Case 2: first is an ASTCondBlock (if-return not collapsed by line 900).
214
+ if (!tern && first instanceof AST.ASTCondBlock &&
215
+ first.blockType === AST.ASTBlock.BlockType.If &&
216
+ Array.isArray(first.nodes) && first.nodes.length === 1 &&
217
+ first.nodes[0] instanceof AST.ASTReturn) {
218
+ const thenVal = first.nodes[0].value;
219
+ const cb = makeCondBlock(first.condition, first.negative);
220
+ tern = new AST.ASTTernary(cb, thenVal, elseVal);
221
+ }
222
+
223
+ if (!tern) {
224
+ // Two plain-return statements with no folded condition = optimizer
225
+ // eliminated the `if X else Y` when X was a constant, or the first
226
+ // return short-circuits the whole lambda (e.g. chained compare).
227
+ // The second return is unreachable; drop it so the lambda renders
228
+ // as a valid expression. Also covers generator lambdas where the
229
+ // yield expression is followed by the implicit `return None`.
230
+ const secondIsImplicitNone =
231
+ second instanceof AST.ASTReturn &&
232
+ second.rettype === AST.ASTReturn.RetType.Return &&
233
+ (!second.value || second.value instanceof AST.ASTNone);
234
+ if (first instanceof AST.ASTReturn &&
235
+ (first.rettype === AST.ASTReturn.RetType.Return || secondIsImplicitNone)) {
236
+ body.list.length = 1;
237
+ first.inLambda = true;
238
+ }
239
+ return;
240
+ }
241
+
242
+ const folded = new AST.ASTReturn(tern);
243
+ folded.inLambda = true;
244
+ folded.line = second.line ?? first.line;
245
+ body.list.length = 0;
246
+ body.list.push(folded);
247
+ }
248
+
155
249
  append_to_chain_store(chainStore, item)
156
250
  {
157
251
  if (this.dataStack.top() == item) {
@@ -243,6 +337,23 @@ class PycDecompiler {
243
337
  if (!(rit instanceof AST.ASTBlock) ||
244
338
  rit.blockType != AST.ASTBlock.BlockType.If)
245
339
  return;
340
+ // Peek before mutating: a real ternary needs both branches as expressions
341
+ // on the data stack. Otherwise the if/else are real statements (the bytecode
342
+ // path covers a control-flow if, not an if-expr) and rewriting them produces
343
+ // a ##ERROR## ternary that masks the real source.
344
+ const else_candidate = this.dataStack.top();
345
+ if (else_candidate == null) {
346
+ return;
347
+ }
348
+ const ifBlockCandidate = this.curBlock.nodes[this.curBlock.nodes.length - 2];
349
+ const stackDepth = this.dataStack.length;
350
+ const ifExprOnStack = stackDepth >= 2 ? this.dataStack[stackDepth - 2] : null;
351
+ const ifExprFromBody = ifExprOnStack == null && ifBlockCandidate.nodes.length == 1
352
+ ? ifBlockCandidate.nodes[0]
353
+ : ifExprOnStack;
354
+ if (ifExprFromBody == null) {
355
+ return;
356
+ }
246
357
  let else_expr = this.dataStack.pop();
247
358
  this.curBlock.removeLast();
248
359
  let if_block = this.curBlock.nodes.top();
@@ -319,7 +430,19 @@ class PycDecompiler {
319
430
  if (offset === undefined) {
320
431
  return;
321
432
  }
322
- const maxHandlerEnd = this.maxExceptionHandlerEnd || Math.max(...entries.filter(e => e.depth > 0).map(e => e.end || 0), 0);
433
+ // Exclude cleanup-only handler entries (depth>0 whose start is a RERAISE site,
434
+ // not a PUSH_EXC_INFO) from the try-block span. Those entries protect CPython's
435
+ // implicit re-raise tail, not a user-written except, and must not be treated
436
+ // as handler bodies when sizing the enclosing try.
437
+ const isUserHandlerEntry = (e) => {
438
+ if (!e || e.depth <= 0) return false;
439
+ const startInstr = this.code.PeekInstructionAtOffset(e.start);
440
+ return startInstr && startInstr.OpCodeID === this.OpCodes.PUSH_EXC_INFO;
441
+ };
442
+ const maxHandlerEnd = this.maxExceptionHandlerEnd || Math.max(
443
+ ...entries.filter(e => e.depth > 0 && isUserHandlerEntry(e)).map(e => e.end || 0),
444
+ 0
445
+ );
323
446
 
324
447
  // Build set of WITH_EXCEPT_START handler ranges (lazily, once per code object)
325
448
  if (!this._withExceptRanges) {
@@ -470,6 +593,16 @@ class PycDecompiler {
470
593
  }
471
594
  }
472
595
  }
596
+ // Skip depth>0 entries whose start is a RERAISE cleanup site rather than
597
+ // a user-level PUSH_EXC_INFO handler. These appear in CPython 3.11+ as
598
+ // protected tails around `RERAISE 0` or `COPY n; POP_EXCEPT; RERAISE 1`
599
+ // and have no source-level except counterpart.
600
+ if (entry.depth > 0 && !isUserHandlerEntry(entry)) {
601
+ if (g_cliArgs?.debug) {
602
+ console.log(`[EnsureExcBlocks] Skipping cleanup-only handler entry at ${entry.start} (not PUSH_EXC_INFO)`);
603
+ }
604
+ continue;
605
+ }
473
606
  const handlerEnd = entry.depth === 0
474
607
  ? (maxHandlerEnd || entry.end || this.code.LastOffset || this.object.CodeSize || 0)
475
608
  : (entry.end || maxHandlerEnd || this.code.LastOffset || this.object.CodeSize || 0);
@@ -520,7 +653,70 @@ class PycDecompiler {
520
653
  if (!matches.length) {
521
654
  return this.maxExceptionHandlerEnd || null;
522
655
  }
523
- return Math.max(...matches.map(e => e.end));
656
+ let end = Math.max(...matches.map(e => e.end));
657
+ // CPython 3.11+ splits a single source-level except handler into
658
+ // contiguous depth>0 sub-regions (alias-binding, body, cleanup).
659
+ // Walk forward through adjacent depth>0 entries so the Except block
660
+ // covers the full handler body, not just the alias-binding region.
661
+ let guard = 0;
662
+ while (guard++ < 32) {
663
+ const next = entries.find(e =>
664
+ e.depth > 0 &&
665
+ e.start >= end &&
666
+ e.start <= end + 2 &&
667
+ e.end > end);
668
+ if (!next) break;
669
+ end = next.end;
670
+ }
671
+ // The exception-table entry only covers the prologue (the protected
672
+ // region around CHECK_EXC_MATCH). The actual handler body — user
673
+ // statements, POP_EXCEPT, and the terminating exit — lives past the
674
+ // entry end. Walk forward to include POP_EXCEPT and the following
675
+ // terminator (RETURN/JUMP_FORWARD/RERAISE) so the Except block
676
+ // captures the body that renders as user code.
677
+ if (this.code && this.OpCodes) {
678
+ let cursor = end;
679
+ let steps = 0;
680
+ let sawHandlerExit = false;
681
+ while (cursor >= 0 && steps++ < 500) {
682
+ const instr = this.code.PeekInstructionAtOffset(cursor);
683
+ if (!instr) break;
684
+ const op = instr.OpCodeID;
685
+ // Stop if we enter another handler's prologue.
686
+ if (op === this.OpCodes.PUSH_EXC_INFO) break;
687
+ if (op === this.OpCodes.POP_EXCEPT) {
688
+ sawHandlerExit = true;
689
+ end = instr.Offset + (instr.Size || 2);
690
+ cursor = end;
691
+ continue;
692
+ }
693
+ if (sawHandlerExit && (
694
+ op === this.OpCodes.RETURN_VALUE ||
695
+ op === this.OpCodes.RETURN_VALUE_A ||
696
+ op === this.OpCodes.RETURN_CONST_A ||
697
+ op === this.OpCodes.JUMP_FORWARD ||
698
+ op === this.OpCodes.JUMP_FORWARD_A ||
699
+ op === this.OpCodes.JUMP_BACKWARD ||
700
+ op === this.OpCodes.JUMP_BACKWARD_A ||
701
+ op === this.OpCodes.RERAISE ||
702
+ op === this.OpCodes.RERAISE_A)) {
703
+ end = instr.Offset + (instr.Size || 2);
704
+ break;
705
+ }
706
+ if (sawHandlerExit) {
707
+ // Skip trailing CACHE/POP_TOP cleanup that sometimes sits
708
+ // between POP_EXCEPT and the terminator on 3.11+.
709
+ if (op === this.OpCodes.CACHE ||
710
+ op === this.OpCodes.POP_TOP) {
711
+ cursor = instr.Offset + (instr.Size || 2);
712
+ continue;
713
+ }
714
+ break;
715
+ }
716
+ cursor = instr.Offset + (instr.Size || 2);
717
+ }
718
+ }
719
+ return end;
524
720
  }
525
721
 
526
722
  statements () {
@@ -538,6 +734,83 @@ class PycDecompiler {
538
734
  // Open blocks based on 3.11+ exception table (no SETUP_EXCEPT opcodes)
539
735
  this.ensureExceptionTableBlocks();
540
736
 
737
+ // Early-return/raise in try body: CPython omits POP_BLOCK+JUMP_FORWARD,
738
+ // so the Try block is still open when we reach the except/finally handler.
739
+ // Close it and open Except/Finally here so handler prologue is consumed
740
+ // in the right block and __exception__ doesn't leak into Try.
741
+ if (this.code.Current.Offset > 0 && this.blocks.length > 1) {
742
+ let tryIdx = -1, containerIdx = -1, isFinally = false;
743
+ for (let i = this.blocks.length - 1; i >= 0; i--) {
744
+ const blk = this.blocks[i];
745
+ if (blk.blockType === AST.ASTBlock.BlockType.Container) {
746
+ if (blk.hasExcept && blk.except === this.code.Current.Offset) {
747
+ containerIdx = i;
748
+ isFinally = false;
749
+ break;
750
+ }
751
+ if (blk.hasFinally && !blk.hasExcept
752
+ && blk.finally === this.code.Current.Offset) {
753
+ containerIdx = i;
754
+ isFinally = true;
755
+ break;
756
+ }
757
+ }
758
+ if (blk.blockType === AST.ASTBlock.BlockType.Try && tryIdx === -1) {
759
+ tryIdx = i;
760
+ }
761
+ }
762
+ if (containerIdx >= 0 && tryIdx > containerIdx
763
+ && this.curBlock.blockType !== AST.ASTBlock.BlockType.Except
764
+ && this.curBlock.blockType !== AST.ASTBlock.BlockType.Finally) {
765
+ while (this.blocks.length - 1 > containerIdx) {
766
+ const top = this.blocks[this.blocks.length - 1];
767
+ this.blocks.pop();
768
+ this.curBlock = this.blocks.top();
769
+ this.curBlock.append(top);
770
+ }
771
+ const cont = this.curBlock;
772
+ const handlerStart = isFinally ? cont.finally : cont.except;
773
+ let handlerEnd = handlerStart;
774
+ let cursor = handlerStart;
775
+ for (let k = 0; k < 500; k++) {
776
+ const instr = this.code.PeekInstructionAtOffset(cursor);
777
+ if (!instr) break;
778
+ const op = instr.OpCodeID;
779
+ if (op === this.OpCodes.END_FINALLY
780
+ || op === this.OpCodes.POP_EXCEPT
781
+ || op === this.OpCodes.RERAISE_A
782
+ || op === this.OpCodes.RERAISE) {
783
+ handlerEnd = instr.Offset;
784
+ break;
785
+ }
786
+ if (op === this.OpCodes.RETURN_VALUE
787
+ || op === this.OpCodes.RETURN_VALUE_A
788
+ || op === this.OpCodes.RETURN_CONST_A
789
+ || op === this.OpCodes.RAISE_VARARGS_A) {
790
+ handlerEnd = instr.Offset + (instr.Size || 2);
791
+ break;
792
+ }
793
+ cursor = instr.Offset + (instr.Size || 2);
794
+ }
795
+ if (isFinally) {
796
+ let finallyBlk = new AST.ASTBlock(AST.ASTBlock.BlockType.Finally, this.code.Current.Offset, handlerEnd, true);
797
+ this.blocks.push(finallyBlk);
798
+ this.curBlock = this.blocks.top();
799
+ if (global.g_cliArgs?.debug) {
800
+ console.log(`[EarlyReturnFinally] Opened Finally at ${this.code.Current.Offset}, handlerEnd=${handlerEnd}`);
801
+ }
802
+ } else {
803
+ let except = new AST.ASTCondBlock(AST.ASTBlock.BlockType.Except, this.code.Current.Offset, handlerEnd, null, false);
804
+ except.init();
805
+ this.blocks.push(except);
806
+ this.curBlock = this.blocks.top();
807
+ if (global.g_cliArgs?.debug) {
808
+ console.log(`[EarlyReturnExcept] Opened Except at ${this.code.Current.Offset}, handlerEnd=${handlerEnd}`);
809
+ }
810
+ }
811
+ }
812
+ }
813
+
541
814
  // Python 3.8: When entering exception handler, push exception instance to stack
542
815
  if (this.exceptionHandlerOffsets.has(this.code.Current.Offset)) {
543
816
  // Create synthetic exception instance placeholder
@@ -708,10 +981,33 @@ class PycDecompiler {
708
981
  }
709
982
  }
710
983
 
984
+ // Resolve any open boolean-expression chain frames whose target
985
+ // offset matches the current instruction. This folds pending
986
+ // JUMP_IF_FALSE/TRUE lhs+rhs pairs into ASTBinary AND/OR nodes
987
+ // and pushes the result onto dataStack before the consumer
988
+ // (STORE_*, RETURN_*, CALL_*, or a nested JUMP_IF_*) fires.
989
+ if (this.boolChainStack && this.boolChainStack.length > 0) {
990
+ resolveBoolChainFrames(this, this.code.Current.Offset);
991
+ }
992
+
711
993
  if (this.code.Current.OpCodeID in PycDecompiler.opCodeHandlers)
712
994
  {
713
995
  PycDecompiler.opCodeHandlers[this.code.Current.OpCodeID].call(this);
714
996
  } else {
997
+ // Unsupported opcode: bail out of the loop with what we have.
998
+ // Record so `--strict` and the dirty-files report can surface it.
999
+ this.errors.push({
1000
+ opcode: this.code.Current.InstructionName,
1001
+ argument: this.code.Current.Argument,
1002
+ offset: this.code.Current.Offset,
1003
+ fileOffset: this.object.codeOffset + this.code.Current.Offset,
1004
+ codeObject: this.object.Name,
1005
+ message: `Unsupported opcode ${this.code.Current.InstructionName}`,
1006
+ unsupported: true
1007
+ });
1008
+ if (global.g_cliArgs?.strict) {
1009
+ throw new Error(`Unsupported opcode ${this.code.Current.InstructionName} at offset ${this.code.Current.Offset}`);
1010
+ }
715
1011
  if (!g_cliArgs?.silent) {
716
1012
  console.error(`Unsupported opcode ${this.code.Current.InstructionName} at pos ${this.code.Current.Offset}\n`);
717
1013
  }
@@ -727,6 +1023,21 @@ class PycDecompiler {
727
1023
  && (this.curBlock.end == this.code.Next?.Offset);
728
1024
 
729
1025
  } catch (ex) {
1026
+ // Record the failure so callers can detect partial output. Without this
1027
+ // accumulator the main loop swallows handler exceptions silently and
1028
+ // cleanBuild gets reset to true at end-of-loop, masking corruption.
1029
+ this.errors.push({
1030
+ opcode: this.code.Current.InstructionName,
1031
+ argument: this.code.Current.Argument,
1032
+ offset: this.code.Current.Offset,
1033
+ fileOffset: this.object.codeOffset + this.code.Current.Offset,
1034
+ codeObject: this.object.Name,
1035
+ message: ex.message,
1036
+ stack: ex.stack
1037
+ });
1038
+ if (global.g_cliArgs?.strict) {
1039
+ throw ex;
1040
+ }
730
1041
  if (!g_cliArgs?.silent) {
731
1042
  console.error(`EXCEPTION for OpCode ${this.code.Current.InstructionName} (${this.code.Current.Argument}) at offset ${this.code.Current.Offset} in code object '${this.object.Name}', file offset ${this.object.codeOffset + this.code.Current.Offset} : ${ex.message}\n\n`);
732
1043
  if (global.g_cliArgs?.debug) {
@@ -764,7 +1075,7 @@ class PycDecompiler {
764
1075
  }
765
1076
  }
766
1077
 
767
- this.cleanBuild = true;
1078
+ this.cleanBuild = this.errors.length === 0;
768
1079
  let mainNode = new AST.ASTNodeList(this.defBlock.nodes);
769
1080
  return mainNode;
770
1081
  }
@@ -1123,10 +1434,19 @@ class PycDecompiler {
1123
1434
  return true;
1124
1435
  });
1125
1436
  if (aliasName) {
1126
- const cond = matchType || blk.condition || new AST.ASTName("Exception");
1127
- blk.condition = new AST.ASTStore(cond, aliasName);
1128
- if (matchType) {
1129
- blk.isExceptStar = true;
1437
+ const existing = blk.condition;
1438
+ if (existing instanceof AST.ASTStore) {
1439
+ // Condition already encodes `Type as alias` (set by misc_other.js
1440
+ // POP_TOP→STORE_FAST→POP_TOP path). Don't double-wrap.
1441
+ if (matchType) {
1442
+ blk.isExceptStar = true;
1443
+ }
1444
+ } else {
1445
+ const cond = matchType || existing || new AST.ASTName("Exception");
1446
+ blk.condition = new AST.ASTStore(cond, aliasName);
1447
+ if (matchType) {
1448
+ blk.isExceptStar = true;
1449
+ }
1130
1450
  }
1131
1451
  }
1132
1452
  if (blk.m_nodes.length > 3) {
@@ -1256,14 +1576,15 @@ class PycDecompiler {
1256
1576
  const alias = node.condition instanceof AST.ASTStore ? node.condition.dest : null;
1257
1577
  const filtered = (node.nodes || []).filter(n => !isCleanupNode(n, alias));
1258
1578
 
1259
- // If an except handler has been reduced to empty but still has a condition, keep it with 'pass'
1260
- // to preserve the handler rather than dropping it entirely.
1261
- if (node.condition && filtered.length === 0) {
1579
+ // Only synthetic except* handlers without a condition AND no real body
1580
+ // should be dropped; user-written `except:` and `except E:` blocks must
1581
+ // be preserved (with `pass` if the body was nothing but cleanup).
1582
+ if (filtered.length === 0) {
1262
1583
  node.m_nodes = [new AST.ASTKeyword(AST.ASTKeyword.Word.Pass)];
1263
1584
  } else if (filtered.length !== (node.nodes || []).length) {
1264
1585
  node.m_nodes = filtered;
1265
1586
  }
1266
- if (Array.isArray(parentArr) && (!node.nodes || node.nodes.length === 0)) {
1587
+ if (Array.isArray(parentArr) && node.isExceptStar && !node.condition && filtered.length === 0) {
1267
1588
  parentArr.splice(idx, 1);
1268
1589
  continue;
1269
1590
  }
@@ -1361,28 +1682,104 @@ class PycDecompiler {
1361
1682
  }
1362
1683
 
1363
1684
  rewriteClassDefinitions(root) {
1364
- if (!(root instanceof AST.ASTNodeList)) {
1365
- return;
1366
- }
1685
+ const hasDataclass = (root instanceof AST.ASTNodeList) && this.astHasDataclassImport(root);
1686
+ this._rewriteClassDefsInNodes(this._collectStatementNodes(root), hasDataclass, new Set());
1687
+ }
1367
1688
 
1368
- const hasDataclass = this.astHasDataclassImport(root);
1689
+ _collectStatementNodes(container) {
1690
+ if (!container) return [];
1691
+ if (container instanceof AST.ASTNodeList) return container.list;
1692
+ if (container instanceof AST.ASTBlock) return container.nodes;
1693
+ return [];
1694
+ }
1369
1695
 
1370
- for (const node of root.list) {
1371
- if (!(node instanceof AST.ASTStore)) {
1372
- continue;
1696
+ _rewriteClassDefsInNodes(nodes, hasDataclass, visited) {
1697
+ if (!Array.isArray(nodes) || visited.has(nodes)) return;
1698
+ visited.add(nodes);
1699
+
1700
+ for (const node of nodes) {
1701
+ if (node instanceof AST.ASTStore) {
1702
+ this._maybeRewriteClassStore(node, hasDataclass);
1703
+ }
1704
+ for (const child of this._childContainers(node)) {
1705
+ this._rewriteClassDefsInNodes(child, hasDataclass, visited);
1373
1706
  }
1707
+ }
1708
+ }
1374
1709
 
1375
- if (node.src instanceof AST.ASTCall && node.src.func instanceof AST.ASTClass &&
1376
- this.isPlainClassCall(node.src)) {
1377
- node.src = node.src.func;
1378
- this.cleanupClassBody(node.src);
1379
- if (hasDataclass) {
1380
- node.addDecorator(new AST.ASTName('dataclass'));
1381
- }
1382
- } else if (node.src instanceof AST.ASTClass) {
1383
- this.cleanupClassBody(node.src);
1710
+ _maybeRewriteClassStore(node, hasDataclass) {
1711
+ if (node.src instanceof AST.ASTCall && node.src.func instanceof AST.ASTClass &&
1712
+ this.isPlainClassCall(node.src)) {
1713
+ node.src = node.src.func;
1714
+ this.cleanupClassBody(node.src);
1715
+ if (hasDataclass) {
1716
+ node.addDecorator(new AST.ASTName('dataclass'));
1717
+ }
1718
+ } else if (node.src instanceof AST.ASTCall && node.src.func instanceof AST.ASTClass &&
1719
+ this.isClassCallWithOnlyKwargs(node.src)) {
1720
+ const callNode = node.src;
1721
+ const classNode = callNode.func;
1722
+ classNode.kwargs = callNode.kwparams || [];
1723
+ node.src = classNode;
1724
+ this.cleanupClassBody(classNode);
1725
+ if (hasDataclass) {
1726
+ node.addDecorator(new AST.ASTName('dataclass'));
1727
+ }
1728
+ } else if (node.src instanceof AST.ASTCall && this.isDecoratedClassCall(node.src)) {
1729
+ // Pattern: `Name = decorator(ClassLiteral[, ...])` produced by Py3.12+
1730
+ // dataclass/decorator-of-class flows. Promote to a real class with
1731
+ // the call-of-decorator attached as an @decorator.
1732
+ const call = node.src;
1733
+ const classNode = call.pparams[0];
1734
+ const decoratorExpr = this._buildDecoratorExpr(call);
1735
+ node.src = classNode;
1736
+ this.cleanupClassBody(classNode);
1737
+ if (decoratorExpr) {
1738
+ node.addDecorator(decoratorExpr);
1739
+ }
1740
+ } else if (node.src instanceof AST.ASTClass) {
1741
+ this.cleanupClassBody(node.src);
1742
+ }
1743
+ }
1744
+
1745
+ isDecoratedClassCall(call) {
1746
+ if (!(call instanceof AST.ASTCall)) return false;
1747
+ if (call.func instanceof AST.ASTClass) return false;
1748
+ if (!call.pparams || call.pparams.length < 1) return false;
1749
+ if (!(call.pparams[0] instanceof AST.ASTClass)) return false;
1750
+ // Any further pparams would become positional args to the decorator,
1751
+ // which cannot be expressed as `@decorator(class)` sugar. Reject.
1752
+ return call.pparams.length === 1;
1753
+ }
1754
+
1755
+ _buildDecoratorExpr(call) {
1756
+ // `decorator(class)` → `@decorator`
1757
+ // `decorator(class, kw=val, ...)` → `@decorator(kw=val, ...)`
1758
+ const kwargs = call.kwparams || [];
1759
+ if (kwargs.length === 0 && !call.hasVar && !call.hasKw) {
1760
+ return call.func;
1761
+ }
1762
+ const wrapper = new AST.ASTCall(call.func, [], kwargs);
1763
+ wrapper.line = call.line;
1764
+ return wrapper;
1765
+ }
1766
+
1767
+ _childContainers(node) {
1768
+ const containers = [];
1769
+ if (node instanceof AST.ASTBlock) {
1770
+ containers.push(node.nodes);
1771
+ }
1772
+ if (node?.body && (node.body instanceof AST.ASTBlock || node.body instanceof AST.ASTNodeList)) {
1773
+ containers.push(this._collectStatementNodes(node.body));
1774
+ }
1775
+ // Generic walk for nested blocks/lists in object properties (e.g. ifBlock.elseBlock).
1776
+ for (const key of ['elseBlock', 'finallyBlock', 'tryBlock', 'thenBlock']) {
1777
+ const val = node?.[key];
1778
+ if (val instanceof AST.ASTBlock || val instanceof AST.ASTNodeList) {
1779
+ containers.push(this._collectStatementNodes(val));
1384
1780
  }
1385
1781
  }
1782
+ return containers;
1386
1783
  }
1387
1784
 
1388
1785
  isPlainClassCall(call) {
@@ -1395,6 +1792,15 @@ class PycDecompiler {
1395
1792
  return !hasParams;
1396
1793
  }
1397
1794
 
1795
+ isClassCallWithOnlyKwargs(call) {
1796
+ if (!(call.func instanceof AST.ASTClass)) {
1797
+ return false;
1798
+ }
1799
+ const hasPparams = call.pparams && call.pparams.length > 0;
1800
+ const hasKwparams = call.kwparams && call.kwparams.length > 0;
1801
+ return !hasPparams && hasKwparams && !call.hasVar && !call.hasKw;
1802
+ }
1803
+
1398
1804
  astHasDataclassImport(root) {
1399
1805
  if (!(root instanceof AST.ASTNodeList)) {
1400
1806
  return false;
@@ -1501,6 +1907,539 @@ class PycDecompiler {
1501
1907
  }
1502
1908
  }
1503
1909
 
1910
+ // Rewrites 3.11+ CHECK_EXC_MATCH artifacts that leaked past block
1911
+ // reconstruction: unwraps `except <exc> EXC_MATCH <type>` conditions
1912
+ // down to `<type>`, rewrites sibling `while __exception__ EXC_MATCH T:`
1913
+ // blocks into `except T:` handlers on the preceding Try, and strips
1914
+ // trailing compiler-generated reraise/cleanup nodes (bare `raise`,
1915
+ // `e = None; del e`, empty `try: pass`). Runs once on the whole tree.
1916
+ cleanupExcMatchArtifacts(root) {
1917
+ // CHECK_EXC_MATCH only exists in Py3.11+; earlier versions never produce
1918
+ // the EXC_MATCH compare op or the __exception__ sentinel, so skip.
1919
+ if (this.object.Reader.versionCompare(3, 11) < 0) return;
1920
+
1921
+ const EXC_MATCH = AST.ASTCompare.CompareOp.Exception;
1922
+
1923
+ const unwrapExcMatch = (cond) => {
1924
+ if (!cond) return cond;
1925
+ // The condition may have been wrapped in ASTStore (alias binding) -
1926
+ // unwrap first, then strip the EXC_MATCH compare.
1927
+ let inner = cond;
1928
+ if (inner instanceof AST.ASTStore && inner.src) {
1929
+ inner = inner.src;
1930
+ }
1931
+ if (inner instanceof AST.ASTCompare && inner.op === EXC_MATCH) {
1932
+ return inner.right;
1933
+ }
1934
+ return cond;
1935
+ };
1936
+
1937
+ const isExcMatchCompare = (node) =>
1938
+ node instanceof AST.ASTCompare && node.op === EXC_MATCH;
1939
+
1940
+ const isExceptionName = (node) =>
1941
+ node instanceof AST.ASTName && node.name === '__exception__';
1942
+
1943
+ const containsExceptionRef = (node, depth = 0) => {
1944
+ if (!node || depth > 4) return false;
1945
+ if (isExceptionName(node)) return true;
1946
+ if (node instanceof AST.ASTCompare || node instanceof AST.ASTBinary) {
1947
+ return containsExceptionRef(node.left, depth + 1) ||
1948
+ containsExceptionRef(node.right, depth + 1);
1949
+ }
1950
+ if (node instanceof AST.ASTCall) {
1951
+ return containsExceptionRef(node.func, depth + 1) ||
1952
+ (node.pparams || []).some(p => containsExceptionRef(p, depth + 1));
1953
+ }
1954
+ if (node instanceof AST.ASTStore) {
1955
+ return containsExceptionRef(node.src, depth + 1);
1956
+ }
1957
+ return false;
1958
+ };
1959
+
1960
+ const isEmptyTryBlock = (node) =>
1961
+ node instanceof AST.ASTBlock &&
1962
+ node.blockType === AST.ASTBlock.BlockType.Try &&
1963
+ (node.nodes.length === 0 ||
1964
+ (node.nodes.length === 1 &&
1965
+ node.nodes[0] instanceof AST.ASTKeyword &&
1966
+ node.nodes[0].key === AST.ASTKeyword.Word.Pass));
1967
+
1968
+ const isOrphanExceptionStmt = (node) => {
1969
+ // Bare `raise` with no params → fall-through RERAISE artifact
1970
+ if (node instanceof AST.ASTRaise && (!node.params || node.params.length === 0)) {
1971
+ return true;
1972
+ }
1973
+ // `__exception__(None, None, None)` or `__exception__` alone
1974
+ if (node instanceof AST.ASTCall && isExceptionName(node.func)) {
1975
+ return true;
1976
+ }
1977
+ if (isExceptionName(node)) {
1978
+ return true;
1979
+ }
1980
+ // `x = __exception__` - CPython alias-clear artifact
1981
+ if (node instanceof AST.ASTStore && isExceptionName(node.src)) {
1982
+ return true;
1983
+ }
1984
+ // `return __exception__` - misrendered reraise epilogue
1985
+ if (node instanceof AST.ASTReturn && isExceptionName(node.value)) {
1986
+ return true;
1987
+ }
1988
+ return false;
1989
+ };
1990
+
1991
+ const rewriteContainer = (nodes, parentBlockType) => {
1992
+ if (!Array.isArray(nodes)) return;
1993
+
1994
+ // Pass 1: unwrap EXC_MATCH in Except block conditions.
1995
+ for (const node of nodes) {
1996
+ if (node instanceof AST.ASTCondBlock &&
1997
+ node.blockType === AST.ASTBlock.BlockType.Except) {
1998
+ node.condition = unwrapExcMatch(node.condition);
1999
+ }
2000
+ }
2001
+
2002
+ // Pass 2: convert ASTWhile/ASTIf with EXC_MATCH condition to Except
2003
+ // handlers and attach them to the preceding Try block. If no preceding
2004
+ // Try exists (deeply misparsed region), drop them to avoid invalid output.
2005
+ for (let i = nodes.length - 1; i >= 0; i--) {
2006
+ const node = nodes[i];
2007
+ if (!(node instanceof AST.ASTCondBlock) ||
2008
+ !(node.blockType === AST.ASTBlock.BlockType.While ||
2009
+ node.blockType === AST.ASTBlock.BlockType.If)) {
2010
+ continue;
2011
+ }
2012
+ if (!isExcMatchCompare(node.condition) ||
2013
+ !containsExceptionRef(node.condition.left)) {
2014
+ continue;
2015
+ }
2016
+ const matchType = node.condition.right;
2017
+ // Find nearest preceding Try block in this container.
2018
+ let tryIdx = -1;
2019
+ for (let j = i - 1; j >= 0; j--) {
2020
+ const cand = nodes[j];
2021
+ if (cand instanceof AST.ASTBlock &&
2022
+ cand.blockType === AST.ASTBlock.BlockType.Try) {
2023
+ tryIdx = j;
2024
+ break;
2025
+ }
2026
+ // Allow existing Except handlers between; skip past them.
2027
+ if (cand instanceof AST.ASTCondBlock &&
2028
+ cand.blockType === AST.ASTBlock.BlockType.Except) {
2029
+ continue;
2030
+ }
2031
+ break;
2032
+ }
2033
+ // Strip any trailing orphan reraise/alias-clear statements from body.
2034
+ const cleanBody = node.nodes.filter(n => !isOrphanExceptionStmt(n));
2035
+ if (cleanBody.length === 0) {
2036
+ cleanBody.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2037
+ }
2038
+
2039
+ if (tryIdx >= 0) {
2040
+ const exceptBlk = new AST.ASTCondBlock(
2041
+ AST.ASTBlock.BlockType.Except,
2042
+ node.start, node.end, matchType, false);
2043
+ exceptBlk.line = node.line;
2044
+ exceptBlk.m_nodes = cleanBody;
2045
+ // Insert right after last existing Except handler for that Try.
2046
+ let insertAt = tryIdx + 1;
2047
+ while (insertAt < nodes.length &&
2048
+ nodes[insertAt] instanceof AST.ASTCondBlock &&
2049
+ nodes[insertAt].blockType === AST.ASTBlock.BlockType.Except) {
2050
+ insertAt++;
2051
+ }
2052
+ nodes.splice(i, 1);
2053
+ if (insertAt > i) insertAt--;
2054
+ nodes.splice(insertAt, 0, exceptBlk);
2055
+ } else {
2056
+ // No Try to attach to — dropping keeps output parseable.
2057
+ nodes.splice(i, 1);
2058
+ }
2059
+ }
2060
+
2061
+ // Pass 3: strip orphan exception-related statements at container
2062
+ // level — but NOT inside Except/Finally bodies, where bare `raise`
2063
+ // is a legitimate re-raise statement.
2064
+ const inHandlerBody = parentBlockType === AST.ASTBlock.BlockType.Except ||
2065
+ parentBlockType === AST.ASTBlock.BlockType.Finally;
2066
+ if (!inHandlerBody) {
2067
+ for (let i = nodes.length - 1; i >= 0; i--) {
2068
+ if (isOrphanExceptionStmt(nodes[i])) {
2069
+ nodes.splice(i, 1);
2070
+ }
2071
+ }
2072
+ }
2073
+
2074
+ // Pass 3.5: strip `X = None; del X` pairs — CPython 3.11+ emits this
2075
+ // at the tail/fall-through of every `except Foo as X:` body to clear
2076
+ // the binding. The pattern is synthetic and user code effectively
2077
+ // never writes it literally, so removing it is safe and prevents
2078
+ // the artifact from breaking up adjacent except clauses.
2079
+ for (let i = nodes.length - 2; i >= 0; i--) {
2080
+ const a = nodes[i], b = nodes[i + 1];
2081
+ if (a instanceof AST.ASTStore &&
2082
+ a.src instanceof AST.ASTNone &&
2083
+ a.dest instanceof AST.ASTName &&
2084
+ b instanceof AST.ASTDelete &&
2085
+ b.value instanceof AST.ASTName &&
2086
+ a.dest.name === b.value.name) {
2087
+ nodes.splice(i, 2);
2088
+ }
2089
+ }
2090
+
2091
+ // Pass 4: drop empty `try:` blocks. With no following handler the
2092
+ // try is a bare leftover. With a trivial `except: pass` handler the
2093
+ // whole pair is a reconstruction artifact — Python forbids
2094
+ // `try:<empty>`, so either outcome requires the try to go. When the
2095
+ // handler has real body drop only the try and splice a `pass`
2096
+ // placeholder into the handler's matching — but the current
2097
+ // sightings all have pass-only handlers, so mirror that case.
2098
+ const isTrivialHandlerBlock = (n) =>
2099
+ n instanceof AST.ASTCondBlock &&
2100
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2101
+ n.blockType === AST.ASTBlock.BlockType.Finally) &&
2102
+ (n.nodes.length === 0 ||
2103
+ (n.nodes.length === 1 &&
2104
+ n.nodes[0] instanceof AST.ASTKeyword &&
2105
+ n.nodes[0].key === AST.ASTKeyword.Word.Pass));
2106
+ for (let i = nodes.length - 1; i >= 0; i--) {
2107
+ if (!isEmptyTryBlock(nodes[i])) continue;
2108
+ const next = nodes[i + 1];
2109
+ const hasHandler = next instanceof AST.ASTCondBlock &&
2110
+ (next.blockType === AST.ASTBlock.BlockType.Except ||
2111
+ next.blockType === AST.ASTBlock.BlockType.Finally);
2112
+ if (!hasHandler) {
2113
+ nodes.splice(i, 1);
2114
+ continue;
2115
+ }
2116
+ // Walk all trailing handlers; drop the whole cluster iff every
2117
+ // one is a trivial pass-body — keeping any would still leave
2118
+ // the empty try as a syntax error anyway.
2119
+ let tailEnd = i + 1;
2120
+ while (tailEnd < nodes.length && isTrivialHandlerBlock(nodes[tailEnd])) {
2121
+ tailEnd++;
2122
+ }
2123
+ const allTrivial = tailEnd > i + 1 &&
2124
+ nodes.slice(i + 1, tailEnd).every(isTrivialHandlerBlock) &&
2125
+ (tailEnd === nodes.length ||
2126
+ !(nodes[tailEnd] instanceof AST.ASTCondBlock &&
2127
+ (nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Except ||
2128
+ nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Finally)));
2129
+ if (allTrivial) {
2130
+ nodes.splice(i, tailEnd - i);
2131
+ }
2132
+ }
2133
+
2134
+ // Pass 4.5: move a sibling Except/Finally that immediately follows
2135
+ // a Try into the Try's body. When CFG reconstruction hoists the
2136
+ // handler one level up (e.g. into the parent Else), the block
2137
+ // renderer dedents `except:` to the parent's indent — producing
2138
+ // `else: / try: / except:` where `except:` lands at the `else:`
2139
+ // column instead of the `try:` column, which is a SyntaxError.
2140
+ // Absorbing the handler into the Try body lets the renderer's
2141
+ // single-step dedent land it at the correct `try:` column.
2142
+ for (let i = 0; i < nodes.length - 1; i++) {
2143
+ const cur = nodes[i];
2144
+ if (!(cur instanceof AST.ASTBlock) ||
2145
+ cur.blockType !== AST.ASTBlock.BlockType.Try) continue;
2146
+ const next = nodes[i + 1];
2147
+ if (!(next instanceof AST.ASTCondBlock)) continue;
2148
+ if (next.blockType !== AST.ASTBlock.BlockType.Except &&
2149
+ next.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2150
+ // Skip if the Try already carries its own trailing handler —
2151
+ // moving another one in would stack two handlers, which is
2152
+ // only valid for multi-except chains (same Try). Those cases
2153
+ // already render fine.
2154
+ const last = cur.nodes[cur.nodes.length - 1];
2155
+ const hasOwnHandler = last instanceof AST.ASTCondBlock &&
2156
+ (last.blockType === AST.ASTBlock.BlockType.Except ||
2157
+ last.blockType === AST.ASTBlock.BlockType.Finally);
2158
+ if (hasOwnHandler) continue;
2159
+ // Empty Try bodies + non-trivial handler would collapse to an
2160
+ // invalid `try: / except:` with nothing between. Inject a
2161
+ // `pass` so the rendered block is syntactically valid.
2162
+ if (cur.nodes.length === 0) {
2163
+ cur.nodes.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2164
+ }
2165
+ cur.nodes.push(next);
2166
+ nodes.splice(i + 1, 1);
2167
+ }
2168
+
2169
+ // Pass 5: a Try block with a non-empty body but no following
2170
+ // Except/Finally handler is a SyntaxError. Append a synthetic
2171
+ // `except: pass` so the output parses — real structural recovery
2172
+ // needs dedicated work, but the fallback keeps the file usable.
2173
+ // Skip when the Try already has an Except/Finally nested in its
2174
+ // body (except* groups build nested handler structures).
2175
+ for (let i = 0; i < nodes.length; i++) {
2176
+ const cur = nodes[i];
2177
+ if (!(cur instanceof AST.ASTBlock) ||
2178
+ cur.blockType !== AST.ASTBlock.BlockType.Try) {
2179
+ continue;
2180
+ }
2181
+ if (cur.nodes.length === 0) continue;
2182
+ const next = nodes[i + 1];
2183
+ const hasHandler = next instanceof AST.ASTCondBlock &&
2184
+ (next.blockType === AST.ASTBlock.BlockType.Except ||
2185
+ next.blockType === AST.ASTBlock.BlockType.Finally);
2186
+ if (hasHandler) continue;
2187
+ const hasNestedExcept = cur.nodes.some(n =>
2188
+ n instanceof AST.ASTCondBlock &&
2189
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2190
+ n.blockType === AST.ASTBlock.BlockType.Finally));
2191
+ if (hasNestedExcept) continue;
2192
+ const synthetic = new AST.ASTCondBlock(
2193
+ AST.ASTBlock.BlockType.Except,
2194
+ cur.end, cur.end, null, false);
2195
+ synthetic.line = cur.line;
2196
+ synthetic.m_nodes.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2197
+ nodes.splice(i + 1, 0, synthetic);
2198
+ i++;
2199
+ }
2200
+
2201
+ // Pass 5.5: adopt orphan Except into a preceding branch whose
2202
+ // body ends with an un-paired Try. CPython can emit a try/except
2203
+ // inside the tail of an if/else branch so that, after CFG
2204
+ // reconstruction, the Try ends up as the last child of the branch
2205
+ // while its handler gets hoisted one level up as a stray sibling.
2206
+ // Moving the handler back in keeps the pair valid. Absorbed
2207
+ // trailing statements stay in the handler body — not semantically
2208
+ // correct, but renders to parseable Python instead of a stray
2209
+ // `except:` at the outer indent.
2210
+ const branchTypes = [
2211
+ AST.ASTBlock.BlockType.If,
2212
+ AST.ASTBlock.BlockType.Elif,
2213
+ AST.ASTBlock.BlockType.Else,
2214
+ ];
2215
+ const endsWithOrphanTry = (block) => {
2216
+ if (!(block instanceof AST.ASTBlock)) return false;
2217
+ if (!branchTypes.includes(block.blockType)) return false;
2218
+ const body = block.nodes;
2219
+ if (!body || body.length === 0) return false;
2220
+ const last = body[body.length - 1];
2221
+ if (!(last instanceof AST.ASTBlock) ||
2222
+ last.blockType !== AST.ASTBlock.BlockType.Try) {
2223
+ return false;
2224
+ }
2225
+ const hasHandlerInside = last.nodes.some(n =>
2226
+ n instanceof AST.ASTCondBlock &&
2227
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2228
+ n.blockType === AST.ASTBlock.BlockType.Finally));
2229
+ return !hasHandlerInside;
2230
+ };
2231
+ for (let i = nodes.length - 1; i > 0; i--) {
2232
+ const cur = nodes[i];
2233
+ if (!(cur instanceof AST.ASTCondBlock)) continue;
2234
+ if (cur.blockType !== AST.ASTBlock.BlockType.Except &&
2235
+ cur.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2236
+ const prev = nodes[i - 1];
2237
+ if (!endsWithOrphanTry(prev)) continue;
2238
+ // Confirm there's no matching Try at this container level,
2239
+ // otherwise leave the pair alone.
2240
+ let hasLocalTry = false;
2241
+ for (let j = i - 1; j >= 0; j--) {
2242
+ const p = nodes[j];
2243
+ if (p instanceof AST.ASTBlock &&
2244
+ p.blockType === AST.ASTBlock.BlockType.Try) {
2245
+ hasLocalTry = true;
2246
+ break;
2247
+ }
2248
+ if (p instanceof AST.ASTCondBlock &&
2249
+ (p.blockType === AST.ASTBlock.BlockType.Except ||
2250
+ p.blockType === AST.ASTBlock.BlockType.Finally)) {
2251
+ continue;
2252
+ }
2253
+ break;
2254
+ }
2255
+ if (hasLocalTry) continue;
2256
+ prev.nodes.push(cur);
2257
+ nodes.splice(i, 1);
2258
+ }
2259
+
2260
+ // Pass 6: drop orphan Except/Finally blocks that have no matching
2261
+ // Try (or preceding Except) anywhere earlier in this container.
2262
+ // These are leftovers from misreconstructed CFGs — keeping them
2263
+ // produces SyntaxError at parse time. Inside a Try body Except
2264
+ // can legitimately nest (Py3.11+ handler layout, except* groups),
2265
+ // but only at the tail. A mid-body Except with non-handler
2266
+ // statements after it is a stray handler — drop it.
2267
+ const inTryBody = parentBlockType === AST.ASTBlock.BlockType.Try;
2268
+ const isHandlerBlock = (x) =>
2269
+ x instanceof AST.ASTCondBlock &&
2270
+ (x.blockType === AST.ASTBlock.BlockType.Except ||
2271
+ x.blockType === AST.ASTBlock.BlockType.Finally);
2272
+ for (let i = nodes.length - 1; i >= 0; i--) {
2273
+ const cur = nodes[i];
2274
+ if (!(cur instanceof AST.ASTCondBlock)) continue;
2275
+ if (cur.blockType !== AST.ASTBlock.BlockType.Except &&
2276
+ cur.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2277
+ if (inTryBody) {
2278
+ // except* groups have complex internal structure — never
2279
+ // touch them.
2280
+ if (cur.isExceptStar) continue;
2281
+ // For regular Except nested inside Try body (Py3.11+
2282
+ // handler layout): legitimate only when at the tail.
2283
+ let allTrailingHandlers = true;
2284
+ for (let k = i + 1; k < nodes.length; k++) {
2285
+ if (!isHandlerBlock(nodes[k])) {
2286
+ allTrailingHandlers = false;
2287
+ break;
2288
+ }
2289
+ }
2290
+ if (allTrailingHandlers) continue;
2291
+ }
2292
+ let anchorFound = false;
2293
+ for (let j = i - 1; j >= 0; j--) {
2294
+ const prev = nodes[j];
2295
+ if (prev instanceof AST.ASTBlock &&
2296
+ prev.blockType === AST.ASTBlock.BlockType.Try) {
2297
+ anchorFound = true;
2298
+ break;
2299
+ }
2300
+ if (prev instanceof AST.ASTCondBlock &&
2301
+ (prev.blockType === AST.ASTBlock.BlockType.Except ||
2302
+ prev.blockType === AST.ASTBlock.BlockType.Finally)) {
2303
+ continue;
2304
+ }
2305
+ break;
2306
+ }
2307
+ if (!anchorFound) nodes.splice(i, 1);
2308
+ }
2309
+
2310
+ // Pass 7: after earlier passes may have emptied a Try body, drop
2311
+ // Try+trivial-Except pairs where the body is now empty. Python
2312
+ // forbids `try:` with no body; a pass-only handler carries no
2313
+ // recoverable intent, so splice the cluster out entirely.
2314
+ for (let i = nodes.length - 1; i >= 0; i--) {
2315
+ if (!isEmptyTryBlock(nodes[i])) continue;
2316
+ let tailEnd = i + 1;
2317
+ while (tailEnd < nodes.length && isTrivialHandlerBlock(nodes[tailEnd])) {
2318
+ tailEnd++;
2319
+ }
2320
+ if (tailEnd === i + 1) continue;
2321
+ const trailingHandler = nodes[tailEnd] instanceof AST.ASTCondBlock &&
2322
+ (nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Except ||
2323
+ nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Finally);
2324
+ if (trailingHandler) continue;
2325
+ nodes.splice(i, tailEnd - i);
2326
+ }
2327
+ };
2328
+
2329
+ // Post-order traversal: process children before parent so nested
2330
+ // emptyings bubble up — an outer Try can only be recognised as empty
2331
+ // after the inner Try/Except artifacts inside its body were dropped.
2332
+ const visited = new WeakSet();
2333
+ const visit = (n) => {
2334
+ if (!n || visited.has(n)) return;
2335
+ visited.add(n);
2336
+ let nodes = null;
2337
+ let parentType = null;
2338
+ if (n instanceof AST.ASTNodeList) {
2339
+ nodes = n.list;
2340
+ parentType = null;
2341
+ } else if (n instanceof AST.ASTBlock) {
2342
+ nodes = n.nodes;
2343
+ parentType = n.blockType;
2344
+ } else if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2345
+ const body = n.src.code?.object?.SourceCode;
2346
+ if (body) visit(body);
2347
+ return;
2348
+ }
2349
+ if (nodes) {
2350
+ for (const c of nodes) visit(c);
2351
+ rewriteContainer(nodes, parentType);
2352
+ }
2353
+ };
2354
+ visit(root);
2355
+ }
2356
+
2357
+ // An Except block can get reconstructed inside a non-Try block's body
2358
+ // (most often a For/While/If inside the protected try-body) when the
2359
+ // handler's offset range overlaps a nested loop. Python requires the
2360
+ // except clause to sit next to its `try:`, so walk the tree and pull
2361
+ // such Except blocks out of any non-Try ancestor until they become a
2362
+ // sibling of their enclosing Try.
2363
+ hoistNestedExceptBlocks(root) {
2364
+ const isHoistableBlock = (node) =>
2365
+ node instanceof AST.ASTBlock &&
2366
+ node.blockType !== AST.ASTBlock.BlockType.Try;
2367
+
2368
+ const isExcept = (node) =>
2369
+ node instanceof AST.ASTCondBlock &&
2370
+ node.blockType === AST.ASTBlock.BlockType.Except;
2371
+
2372
+ // Fixed-point loop: one pass hoists Except up one level. Bounded by
2373
+ // tree depth; excessive iterations just exit.
2374
+ for (let iter = 0; iter < 64; iter++) {
2375
+ let moved = false;
2376
+ const stack = [{container: null, nodes: null, parent: null}];
2377
+ const parentMap = new WeakMap(); // child container → {arr, index, parent}
2378
+
2379
+ const visit = (arr, parentInfo) => {
2380
+ for (let i = 0; i < arr.length; i++) {
2381
+ const n = arr[i];
2382
+ if (!n) continue;
2383
+ let children = null;
2384
+ if (n instanceof AST.ASTBlock || n instanceof AST.ASTCondBlock) {
2385
+ children = n.nodes;
2386
+ } else if (n instanceof AST.ASTNodeList) {
2387
+ children = n.list;
2388
+ } else if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2389
+ const src = n.src.code?.object?.SourceCode;
2390
+ if (src instanceof AST.ASTNodeList) {
2391
+ visit(src.list, null);
2392
+ }
2393
+ continue;
2394
+ }
2395
+ if (Array.isArray(children) && children.length > 0) {
2396
+ // If this block is non-Try and its first child is an
2397
+ // Except, pull that Except out into the parent arr
2398
+ // right after `n`.
2399
+ if (isHoistableBlock(n)) {
2400
+ // Scan forward for leading Except children only;
2401
+ // an Except appearing later in the body is usually
2402
+ // a real construct we should leave alone.
2403
+ while (children.length > 0 && isExcept(children[0])) {
2404
+ const ex = children.shift();
2405
+ arr.splice(i + 1, 0, ex);
2406
+ moved = true;
2407
+ }
2408
+ }
2409
+ visit(children, {arr, index: i});
2410
+ }
2411
+ }
2412
+ };
2413
+
2414
+ const roots = [];
2415
+ if (root instanceof AST.ASTNodeList) roots.push(root.list);
2416
+ else if (root instanceof AST.ASTBlock) roots.push(root.nodes);
2417
+
2418
+ for (const r of roots) visit(r, null);
2419
+
2420
+ // Also dive into nested function bodies.
2421
+ const walk = (n, seen) => {
2422
+ if (!n || seen.has(n)) return;
2423
+ seen.add(n);
2424
+ if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2425
+ const src = n.src.code?.object?.SourceCode;
2426
+ if (src instanceof AST.ASTNodeList) {
2427
+ visit(src.list, null);
2428
+ }
2429
+ }
2430
+ let children = null;
2431
+ if (n instanceof AST.ASTNodeList) children = n.list;
2432
+ else if (n instanceof AST.ASTBlock || n instanceof AST.ASTCondBlock) children = n.nodes;
2433
+ if (Array.isArray(children)) {
2434
+ for (const c of children) walk(c, seen);
2435
+ }
2436
+ };
2437
+ walk(root, new WeakSet());
2438
+
2439
+ if (!moved) break;
2440
+ }
2441
+ }
2442
+
1504
2443
  dedupeExceptHandlers(root) {
1505
2444
  const queue = [root];
1506
2445
  const visited = new WeakSet();
@@ -1520,12 +2459,23 @@ class PycDecompiler {
1520
2459
  }
1521
2460
  }
1522
2461
 
1523
- // Drop trivial generic except handlers that only contain pass/cleanup.
2462
+ // Drop trivial generic except* handlers (exception-group artifacts) that only contain pass/cleanup.
2463
+ // User-written `except:` and `except Exception:` must be preserved.
2464
+ // Python forbids mixing `except` and `except*` inside the same try, so a
2465
+ // non-star trivial `except Exception [as X]: pass` in a try that has any
2466
+ // `except*` sibling is always a decompiler artifact.
2467
+ const hasExceptStarSibling = nodes.some(n =>
2468
+ n instanceof AST.ASTCondBlock &&
2469
+ n.blockType === AST.ASTBlock.BlockType.Except &&
2470
+ n.isExceptStar);
1524
2471
  for (let i = nodes.length - 1; i >= 0; i--) {
1525
2472
  const blk = nodes[i];
1526
2473
  if (!(blk instanceof AST.ASTCondBlock) || blk.blockType !== AST.ASTBlock.BlockType.Except) {
1527
2474
  continue;
1528
2475
  }
2476
+ if (!blk.isExceptStar && !hasExceptStarSibling) {
2477
+ continue;
2478
+ }
1529
2479
  const condNode = blk.condition instanceof AST.ASTStore ? blk.condition.src : blk.condition;
1530
2480
  const condText = (typeof condNode?.codeFragment === 'function' ? condNode.codeFragment()?.toString?.() : null) || condNode?.name;
1531
2481
  const body = blk.nodes || [];
@@ -1603,6 +2553,11 @@ class PycDecompiler {
1603
2553
  if (!(node instanceof AST.ASTCondBlock)) {
1604
2554
  return false;
1605
2555
  }
2556
+ // `while True: pass` / `while 1:` has no explicit condition but is a legitimate
2557
+ // user construct; only If blocks are null-sentinel artifacts from with-cleanup.
2558
+ if (node.blockType !== AST.ASTBlock.BlockType.If) {
2559
+ return false;
2560
+ }
1606
2561
  const condition = node.condition;
1607
2562
 
1608
2563
  // Drop degenerate IF blocks with no condition and empty body (often leftover from with cleanup tests)
@@ -1769,6 +2724,14 @@ class PycDecompiler {
1769
2724
  const frag = ch?.codeFragment?.()?.toString?.() || "";
1770
2725
  return frag === "pass" || frag.includes("__exception__") || frag.includes("##ERROR##");
1771
2726
  }))) {
2727
+ // Drop only synthetic except* handlers without a user condition.
2728
+ // User-written `except:` / `except E:` must be preserved; replace
2729
+ // cleanup-only body with `pass` so the try statement stays valid.
2730
+ if (node.isExceptStar && !node.condition) {
2731
+ continue;
2732
+ }
2733
+ node.m_nodes = [new AST.ASTKeyword(AST.ASTKeyword.Word.Pass)];
2734
+ rewritten.push(node);
1772
2735
  continue;
1773
2736
  }
1774
2737
  if (typeof node?.codeFragment === 'function') {
@@ -1843,25 +2806,72 @@ class PycDecompiler {
1843
2806
  }
1844
2807
 
1845
2808
  const targetName = node.dest?.name || node.dest?.codeFragment?.();
2809
+ const isClassLike = targetName && targetName[0] === targetName[0]?.toUpperCase?.();
2810
+
2811
+ // Preferred path (3.13+): the wrapper's own SourceCode ends with
2812
+ // `return <ASTFunction>` where the function already has correct
2813
+ // annotations (via SET_FUNCTION_ATTRIBUTE) and typeParams with
2814
+ // PEP 696 defaults (via CALL_INTRINSIC_2 intrinsics 5 + 4).
2815
+ const wrapperBody = func.code?.object?.SourceCode?.list || [];
2816
+ let returnedNode = null;
2817
+ for (let j = wrapperBody.length - 1; j >= 0; j--) {
2818
+ const stmt = wrapperBody[j];
2819
+ if (stmt instanceof AST.ASTReturn && stmt.value) {
2820
+ returnedNode = stmt.value;
2821
+ break;
2822
+ }
2823
+ }
2824
+ if (!isClassLike && returnedNode instanceof AST.ASTFunction) {
2825
+ node.m_src = returnedNode;
2826
+ continue;
2827
+ }
2828
+ // Classes: the wrapper's direct return is an ASTClass whose base is
2829
+ // an internal CPython temp (".generic_base" for SUBSCRIPT_GENERIC).
2830
+ // Fall through to the Consts-based rebuild which infers clean
2831
+ // typeParams from string names instead.
2832
+
2833
+ // Fallback (pre-3.13 or irregular wrappers): rebuild from Consts.
1846
2834
  const codeConsts = func.code?.object?.Consts?.Value || [];
1847
- const innerCodeObj = codeConsts.find(c => c?.ClassName === 'Py_CodeObject');
2835
+ const allCodeObjects = codeConsts.filter(c => c?.ClassName === 'Py_CodeObject');
2836
+ let innerCodeObj = allCodeObjects.find(c => c.Name?.toString?.() === targetName);
2837
+ if (!innerCodeObj) {
2838
+ innerCodeObj = allCodeObjects[0];
2839
+ }
1848
2840
  if (!innerCodeObj) {
1849
2841
  continue;
1850
2842
  }
1851
- // Extract type parameters: pick uppercase-like strings excluding target name
1852
- const typeParams = codeConsts
2843
+ const typeParamNames = codeConsts
1853
2844
  .filter(c => c?.ClassName === 'Py_String')
1854
2845
  .map(c => c.Value)
1855
2846
  .filter(v => /^[A-Z][A-Za-z0-9_]*$/.test(v || '') && v !== targetName);
1856
2847
 
1857
- // Decompile inner code object to get body
2848
+ const typeParams = typeParamNames.map(name => {
2849
+ const defaultCode = allCodeObjects.find(c =>
2850
+ c !== innerCodeObj &&
2851
+ (c.ArgCount || 0) === 0 &&
2852
+ c.Name?.toString?.() === name
2853
+ );
2854
+ if (!defaultCode) return name;
2855
+ try {
2856
+ const dd = new PycDecompiler(defaultCode);
2857
+ const body = dd.decompile();
2858
+ defaultCode.SourceCode = body;
2859
+ if (dd.errors.length) this.errors.push(...dd.errors);
2860
+ const list = body?.list || [];
2861
+ const top = list.top?.() || list[list.length - 1] || list[0];
2862
+ if (top instanceof AST.ASTReturn && top.value) {
2863
+ return { name, default: top.value };
2864
+ }
2865
+ } catch (_) { /* fall through */ }
2866
+ return name;
2867
+ });
2868
+
1858
2869
  const innerDecompiler = new PycDecompiler(innerCodeObj);
1859
2870
  const innerBody = innerDecompiler.decompile();
1860
2871
  innerCodeObj.SourceCode = innerBody;
2872
+ if (innerDecompiler.errors.length) this.errors.push(...innerDecompiler.errors);
1861
2873
  const astObj = new AST.ASTObject(innerCodeObj);
1862
2874
 
1863
- const isClassLike = targetName && targetName[0] === targetName[0]?.toUpperCase?.();
1864
-
1865
2875
  if (!isClassLike) {
1866
2876
  const fn = new AST.ASTFunction(astObj);
1867
2877
  fn.annotations = innerDecompiler.funcAnnotations || fn.annotations;