depyo 1.0.1 → 1.0.3

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
  }
@@ -140,6 +153,10 @@ class PycDecompiler {
140
153
  this.dedupeExceptHandlers(functonBody);
141
154
  this.removeDuplicateReturns(functonBody);
142
155
 
156
+ if (this.object.Name == "<lambda>") {
157
+ this.foldLambdaConditional(functonBody);
158
+ }
159
+
143
160
  if (this.object.Name != "<lambda>" && functonBody.last instanceof AST.ASTReturn && functonBody.last.value instanceof AST.ASTNone) {
144
161
  functonBody.list.pop();
145
162
  }
@@ -152,6 +169,81 @@ class PycDecompiler {
152
169
  return functonBody;
153
170
  }
154
171
 
172
+ foldLambdaConditional(body) {
173
+ // Lambda bodies must be single expressions. CPython often compiles a
174
+ // conditional expression `A if cond else B` as:
175
+ // POP_JUMP_IF_FALSE X; LOAD A; RETURN_VALUE; X: LOAD B; RETURN_VALUE
176
+ // The decompiler's default path leaves this as two statements which is
177
+ // invalid Python inside a lambda. Detect the patterns and fold into a
178
+ // single ASTReturn(ASTTernary).
179
+ const list = body?.list;
180
+ if (!Array.isArray(list) || list.length !== 2) {
181
+ return;
182
+ }
183
+ const first = list[0];
184
+ const second = list[1];
185
+ if (!(second instanceof AST.ASTReturn)) {
186
+ return;
187
+ }
188
+ const elseVal = second.value;
189
+
190
+ const makeCondBlock = (condExpr, negative) => {
191
+ const cb = new AST.ASTCondBlock(AST.ASTBlock.BlockType.If, 0, 0, condExpr, !!negative);
192
+ return cb;
193
+ };
194
+
195
+ let tern = null;
196
+
197
+ // Case 1: first is `ASTReturn(ASTBinary(cond AND/OR val))` — the line-900
198
+ // collapse already ran. Recover cond/val/direction from the binary op.
199
+ if (first instanceof AST.ASTReturn && first.value instanceof AST.ASTBinary) {
200
+ const bin = first.value;
201
+ const isAnd = bin.op === AST.ASTBinary.BinOp.LogicalAnd;
202
+ const isOr = bin.op === AST.ASTBinary.BinOp.LogicalOr;
203
+ if (isAnd || isOr) {
204
+ const cond = bin.left;
205
+ const thenVal = bin.right;
206
+ const cb = makeCondBlock(cond, isOr);
207
+ tern = new AST.ASTTernary(cb, thenVal, elseVal);
208
+ }
209
+ }
210
+
211
+ // Case 2: first is an ASTCondBlock (if-return not collapsed by line 900).
212
+ if (!tern && first instanceof AST.ASTCondBlock &&
213
+ first.blockType === AST.ASTBlock.BlockType.If &&
214
+ Array.isArray(first.nodes) && first.nodes.length === 1 &&
215
+ first.nodes[0] instanceof AST.ASTReturn) {
216
+ const thenVal = first.nodes[0].value;
217
+ const cb = makeCondBlock(first.condition, first.negative);
218
+ tern = new AST.ASTTernary(cb, thenVal, elseVal);
219
+ }
220
+
221
+ if (!tern) {
222
+ // Two plain-return statements with no folded condition = optimizer
223
+ // eliminated the `if X else Y` when X was a constant, or the first
224
+ // return short-circuits the whole lambda (e.g. chained compare).
225
+ // The second return is unreachable; drop it so the lambda renders
226
+ // as a valid expression. Also covers generator lambdas where the
227
+ // yield expression is followed by the implicit `return None`.
228
+ const secondIsImplicitNone =
229
+ second instanceof AST.ASTReturn &&
230
+ second.rettype === AST.ASTReturn.RetType.Return &&
231
+ (!second.value || second.value instanceof AST.ASTNone);
232
+ if (first instanceof AST.ASTReturn &&
233
+ (first.rettype === AST.ASTReturn.RetType.Return || secondIsImplicitNone)) {
234
+ body.list.length = 1;
235
+ first.inLambda = true;
236
+ }
237
+ return;
238
+ }
239
+
240
+ const folded = new AST.ASTReturn(tern);
241
+ folded.inLambda = true;
242
+ folded.line = second.line ?? first.line;
243
+ body.list.length = 0;
244
+ body.list.push(folded);
245
+ }
246
+
155
247
  append_to_chain_store(chainStore, item)
156
248
  {
157
249
  if (this.dataStack.top() == item) {
@@ -243,6 +335,23 @@ class PycDecompiler {
243
335
  if (!(rit instanceof AST.ASTBlock) ||
244
336
  rit.blockType != AST.ASTBlock.BlockType.If)
245
337
  return;
338
+ // Peek before mutating: a real ternary needs both branches as expressions
339
+ // on the data stack. Otherwise the if/else are real statements (the bytecode
340
+ // path covers a control-flow if, not an if-expr) and rewriting them produces
341
+ // a ##ERROR## ternary that masks the real source.
342
+ const else_candidate = this.dataStack.top();
343
+ if (else_candidate == null) {
344
+ return;
345
+ }
346
+ const ifBlockCandidate = this.curBlock.nodes[this.curBlock.nodes.length - 2];
347
+ const stackDepth = this.dataStack.length;
348
+ const ifExprOnStack = stackDepth >= 2 ? this.dataStack[stackDepth - 2] : null;
349
+ const ifExprFromBody = ifExprOnStack == null && ifBlockCandidate.nodes.length == 1
350
+ ? ifBlockCandidate.nodes[0]
351
+ : ifExprOnStack;
352
+ if (ifExprFromBody == null) {
353
+ return;
354
+ }
246
355
  let else_expr = this.dataStack.pop();
247
356
  this.curBlock.removeLast();
248
357
  let if_block = this.curBlock.nodes.top();
@@ -520,7 +629,22 @@ class PycDecompiler {
520
629
  if (!matches.length) {
521
630
  return this.maxExceptionHandlerEnd || null;
522
631
  }
523
- return Math.max(...matches.map(e => e.end));
632
+ let end = Math.max(...matches.map(e => e.end));
633
+ // CPython 3.11+ splits a single source-level except handler into
634
+ // contiguous depth>0 sub-regions (alias-binding, body, cleanup).
635
+ // Walk forward through adjacent depth>0 entries so the Except block
636
+ // covers the full handler body, not just the alias-binding region.
637
+ let guard = 0;
638
+ while (guard++ < 32) {
639
+ const next = entries.find(e =>
640
+ e.depth > 0 &&
641
+ e.start >= end &&
642
+ e.start <= end + 2 &&
643
+ e.end > end);
644
+ if (!next) break;
645
+ end = next.end;
646
+ }
647
+ return end;
524
648
  }
525
649
 
526
650
  statements () {
@@ -538,6 +662,83 @@ class PycDecompiler {
538
662
  // Open blocks based on 3.11+ exception table (no SETUP_EXCEPT opcodes)
539
663
  this.ensureExceptionTableBlocks();
540
664
 
665
+ // Early-return/raise in try body: CPython omits POP_BLOCK+JUMP_FORWARD,
666
+ // so the Try block is still open when we reach the except/finally handler.
667
+ // Close it and open Except/Finally here so handler prologue is consumed
668
+ // in the right block and __exception__ doesn't leak into Try.
669
+ if (this.code.Current.Offset > 0 && this.blocks.length > 1) {
670
+ let tryIdx = -1, containerIdx = -1, isFinally = false;
671
+ for (let i = this.blocks.length - 1; i >= 0; i--) {
672
+ const blk = this.blocks[i];
673
+ if (blk.blockType === AST.ASTBlock.BlockType.Container) {
674
+ if (blk.hasExcept && blk.except === this.code.Current.Offset) {
675
+ containerIdx = i;
676
+ isFinally = false;
677
+ break;
678
+ }
679
+ if (blk.hasFinally && !blk.hasExcept
680
+ && blk.finally === this.code.Current.Offset) {
681
+ containerIdx = i;
682
+ isFinally = true;
683
+ break;
684
+ }
685
+ }
686
+ if (blk.blockType === AST.ASTBlock.BlockType.Try && tryIdx === -1) {
687
+ tryIdx = i;
688
+ }
689
+ }
690
+ if (containerIdx >= 0 && tryIdx > containerIdx
691
+ && this.curBlock.blockType !== AST.ASTBlock.BlockType.Except
692
+ && this.curBlock.blockType !== AST.ASTBlock.BlockType.Finally) {
693
+ while (this.blocks.length - 1 > containerIdx) {
694
+ const top = this.blocks[this.blocks.length - 1];
695
+ this.blocks.pop();
696
+ this.curBlock = this.blocks.top();
697
+ this.curBlock.append(top);
698
+ }
699
+ const cont = this.curBlock;
700
+ const handlerStart = isFinally ? cont.finally : cont.except;
701
+ let handlerEnd = handlerStart;
702
+ let cursor = handlerStart;
703
+ for (let k = 0; k < 500; k++) {
704
+ const instr = this.code.PeekInstructionAtOffset(cursor);
705
+ if (!instr) break;
706
+ const op = instr.OpCodeID;
707
+ if (op === this.OpCodes.END_FINALLY
708
+ || op === this.OpCodes.POP_EXCEPT
709
+ || op === this.OpCodes.RERAISE_A
710
+ || op === this.OpCodes.RERAISE) {
711
+ handlerEnd = instr.Offset;
712
+ break;
713
+ }
714
+ if (op === this.OpCodes.RETURN_VALUE
715
+ || op === this.OpCodes.RETURN_VALUE_A
716
+ || op === this.OpCodes.RETURN_CONST_A
717
+ || op === this.OpCodes.RAISE_VARARGS_A) {
718
+ handlerEnd = instr.Offset + (instr.Size || 2);
719
+ break;
720
+ }
721
+ cursor = instr.Offset + (instr.Size || 2);
722
+ }
723
+ if (isFinally) {
724
+ let finallyBlk = new AST.ASTBlock(AST.ASTBlock.BlockType.Finally, this.code.Current.Offset, handlerEnd, true);
725
+ this.blocks.push(finallyBlk);
726
+ this.curBlock = this.blocks.top();
727
+ if (global.g_cliArgs?.debug) {
728
+ console.log(`[EarlyReturnFinally] Opened Finally at ${this.code.Current.Offset}, handlerEnd=${handlerEnd}`);
729
+ }
730
+ } else {
731
+ let except = new AST.ASTCondBlock(AST.ASTBlock.BlockType.Except, this.code.Current.Offset, handlerEnd, null, false);
732
+ except.init();
733
+ this.blocks.push(except);
734
+ this.curBlock = this.blocks.top();
735
+ if (global.g_cliArgs?.debug) {
736
+ console.log(`[EarlyReturnExcept] Opened Except at ${this.code.Current.Offset}, handlerEnd=${handlerEnd}`);
737
+ }
738
+ }
739
+ }
740
+ }
741
+
541
742
  // Python 3.8: When entering exception handler, push exception instance to stack
542
743
  if (this.exceptionHandlerOffsets.has(this.code.Current.Offset)) {
543
744
  // Create synthetic exception instance placeholder
@@ -708,10 +909,33 @@ class PycDecompiler {
708
909
  }
709
910
  }
710
911
 
912
+ // Resolve any open boolean-expression chain frames whose target
913
+ // offset matches the current instruction. This folds pending
914
+ // JUMP_IF_FALSE/TRUE lhs+rhs pairs into ASTBinary AND/OR nodes
915
+ // and pushes the result onto dataStack before the consumer
916
+ // (STORE_*, RETURN_*, CALL_*, or a nested JUMP_IF_*) fires.
917
+ if (this.boolChainStack && this.boolChainStack.length > 0) {
918
+ resolveBoolChainFrames(this, this.code.Current.Offset);
919
+ }
920
+
711
921
  if (this.code.Current.OpCodeID in PycDecompiler.opCodeHandlers)
712
922
  {
713
923
  PycDecompiler.opCodeHandlers[this.code.Current.OpCodeID].call(this);
714
924
  } else {
925
+ // Unsupported opcode: bail out of the loop with what we have.
926
+ // Record so `--strict` and the dirty-files report can surface it.
927
+ this.errors.push({
928
+ opcode: this.code.Current.InstructionName,
929
+ argument: this.code.Current.Argument,
930
+ offset: this.code.Current.Offset,
931
+ fileOffset: this.object.codeOffset + this.code.Current.Offset,
932
+ codeObject: this.object.Name,
933
+ message: `Unsupported opcode ${this.code.Current.InstructionName}`,
934
+ unsupported: true
935
+ });
936
+ if (global.g_cliArgs?.strict) {
937
+ throw new Error(`Unsupported opcode ${this.code.Current.InstructionName} at offset ${this.code.Current.Offset}`);
938
+ }
715
939
  if (!g_cliArgs?.silent) {
716
940
  console.error(`Unsupported opcode ${this.code.Current.InstructionName} at pos ${this.code.Current.Offset}\n`);
717
941
  }
@@ -727,6 +951,21 @@ class PycDecompiler {
727
951
  && (this.curBlock.end == this.code.Next?.Offset);
728
952
 
729
953
  } catch (ex) {
954
+ // Record the failure so callers can detect partial output. Without this
955
+ // accumulator the main loop swallows handler exceptions silently and
956
+ // cleanBuild gets reset to true at end-of-loop, masking corruption.
957
+ this.errors.push({
958
+ opcode: this.code.Current.InstructionName,
959
+ argument: this.code.Current.Argument,
960
+ offset: this.code.Current.Offset,
961
+ fileOffset: this.object.codeOffset + this.code.Current.Offset,
962
+ codeObject: this.object.Name,
963
+ message: ex.message,
964
+ stack: ex.stack
965
+ });
966
+ if (global.g_cliArgs?.strict) {
967
+ throw ex;
968
+ }
730
969
  if (!g_cliArgs?.silent) {
731
970
  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
971
  if (global.g_cliArgs?.debug) {
@@ -764,7 +1003,7 @@ class PycDecompiler {
764
1003
  }
765
1004
  }
766
1005
 
767
- this.cleanBuild = true;
1006
+ this.cleanBuild = this.errors.length === 0;
768
1007
  let mainNode = new AST.ASTNodeList(this.defBlock.nodes);
769
1008
  return mainNode;
770
1009
  }
@@ -1123,10 +1362,19 @@ class PycDecompiler {
1123
1362
  return true;
1124
1363
  });
1125
1364
  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;
1365
+ const existing = blk.condition;
1366
+ if (existing instanceof AST.ASTStore) {
1367
+ // Condition already encodes `Type as alias` (set by misc_other.js
1368
+ // POP_TOP→STORE_FAST→POP_TOP path). Don't double-wrap.
1369
+ if (matchType) {
1370
+ blk.isExceptStar = true;
1371
+ }
1372
+ } else {
1373
+ const cond = matchType || existing || new AST.ASTName("Exception");
1374
+ blk.condition = new AST.ASTStore(cond, aliasName);
1375
+ if (matchType) {
1376
+ blk.isExceptStar = true;
1377
+ }
1130
1378
  }
1131
1379
  }
1132
1380
  if (blk.m_nodes.length > 3) {
@@ -1256,14 +1504,15 @@ class PycDecompiler {
1256
1504
  const alias = node.condition instanceof AST.ASTStore ? node.condition.dest : null;
1257
1505
  const filtered = (node.nodes || []).filter(n => !isCleanupNode(n, alias));
1258
1506
 
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) {
1507
+ // Only synthetic except* handlers without a condition AND no real body
1508
+ // should be dropped; user-written `except:` and `except E:` blocks must
1509
+ // be preserved (with `pass` if the body was nothing but cleanup).
1510
+ if (filtered.length === 0) {
1262
1511
  node.m_nodes = [new AST.ASTKeyword(AST.ASTKeyword.Word.Pass)];
1263
1512
  } else if (filtered.length !== (node.nodes || []).length) {
1264
1513
  node.m_nodes = filtered;
1265
1514
  }
1266
- if (Array.isArray(parentArr) && (!node.nodes || node.nodes.length === 0)) {
1515
+ if (Array.isArray(parentArr) && node.isExceptStar && !node.condition && filtered.length === 0) {
1267
1516
  parentArr.splice(idx, 1);
1268
1517
  continue;
1269
1518
  }
@@ -1361,28 +1610,70 @@ class PycDecompiler {
1361
1610
  }
1362
1611
 
1363
1612
  rewriteClassDefinitions(root) {
1364
- if (!(root instanceof AST.ASTNodeList)) {
1365
- return;
1366
- }
1613
+ const hasDataclass = (root instanceof AST.ASTNodeList) && this.astHasDataclassImport(root);
1614
+ this._rewriteClassDefsInNodes(this._collectStatementNodes(root), hasDataclass, new Set());
1615
+ }
1616
+
1617
+ _collectStatementNodes(container) {
1618
+ if (!container) return [];
1619
+ if (container instanceof AST.ASTNodeList) return container.list;
1620
+ if (container instanceof AST.ASTBlock) return container.nodes;
1621
+ return [];
1622
+ }
1367
1623
 
1368
- const hasDataclass = this.astHasDataclassImport(root);
1624
+ _rewriteClassDefsInNodes(nodes, hasDataclass, visited) {
1625
+ if (!Array.isArray(nodes) || visited.has(nodes)) return;
1626
+ visited.add(nodes);
1369
1627
 
1370
- for (const node of root.list) {
1371
- if (!(node instanceof AST.ASTStore)) {
1372
- continue;
1628
+ for (const node of nodes) {
1629
+ if (node instanceof AST.ASTStore) {
1630
+ this._maybeRewriteClassStore(node, hasDataclass);
1373
1631
  }
1632
+ for (const child of this._childContainers(node)) {
1633
+ this._rewriteClassDefsInNodes(child, hasDataclass, visited);
1634
+ }
1635
+ }
1636
+ }
1374
1637
 
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);
1638
+ _maybeRewriteClassStore(node, hasDataclass) {
1639
+ if (node.src instanceof AST.ASTCall && node.src.func instanceof AST.ASTClass &&
1640
+ this.isPlainClassCall(node.src)) {
1641
+ node.src = node.src.func;
1642
+ this.cleanupClassBody(node.src);
1643
+ if (hasDataclass) {
1644
+ node.addDecorator(new AST.ASTName('dataclass'));
1645
+ }
1646
+ } else if (node.src instanceof AST.ASTCall && node.src.func instanceof AST.ASTClass &&
1647
+ this.isClassCallWithOnlyKwargs(node.src)) {
1648
+ const callNode = node.src;
1649
+ const classNode = callNode.func;
1650
+ classNode.kwargs = callNode.kwparams || [];
1651
+ node.src = classNode;
1652
+ this.cleanupClassBody(classNode);
1653
+ if (hasDataclass) {
1654
+ node.addDecorator(new AST.ASTName('dataclass'));
1655
+ }
1656
+ } else if (node.src instanceof AST.ASTClass) {
1657
+ this.cleanupClassBody(node.src);
1658
+ }
1659
+ }
1660
+
1661
+ _childContainers(node) {
1662
+ const containers = [];
1663
+ if (node instanceof AST.ASTBlock) {
1664
+ containers.push(node.nodes);
1665
+ }
1666
+ if (node?.body && (node.body instanceof AST.ASTBlock || node.body instanceof AST.ASTNodeList)) {
1667
+ containers.push(this._collectStatementNodes(node.body));
1668
+ }
1669
+ // Generic walk for nested blocks/lists in object properties (e.g. ifBlock.elseBlock).
1670
+ for (const key of ['elseBlock', 'finallyBlock', 'tryBlock', 'thenBlock']) {
1671
+ const val = node?.[key];
1672
+ if (val instanceof AST.ASTBlock || val instanceof AST.ASTNodeList) {
1673
+ containers.push(this._collectStatementNodes(val));
1384
1674
  }
1385
1675
  }
1676
+ return containers;
1386
1677
  }
1387
1678
 
1388
1679
  isPlainClassCall(call) {
@@ -1395,6 +1686,15 @@ class PycDecompiler {
1395
1686
  return !hasParams;
1396
1687
  }
1397
1688
 
1689
+ isClassCallWithOnlyKwargs(call) {
1690
+ if (!(call.func instanceof AST.ASTClass)) {
1691
+ return false;
1692
+ }
1693
+ const hasPparams = call.pparams && call.pparams.length > 0;
1694
+ const hasKwparams = call.kwparams && call.kwparams.length > 0;
1695
+ return !hasPparams && hasKwparams && !call.hasVar && !call.hasKw;
1696
+ }
1697
+
1398
1698
  astHasDataclassImport(root) {
1399
1699
  if (!(root instanceof AST.ASTNodeList)) {
1400
1700
  return false;
@@ -1520,12 +1820,23 @@ class PycDecompiler {
1520
1820
  }
1521
1821
  }
1522
1822
 
1523
- // Drop trivial generic except handlers that only contain pass/cleanup.
1823
+ // Drop trivial generic except* handlers (exception-group artifacts) that only contain pass/cleanup.
1824
+ // User-written `except:` and `except Exception:` must be preserved.
1825
+ // Python forbids mixing `except` and `except*` inside the same try, so a
1826
+ // non-star trivial `except Exception [as X]: pass` in a try that has any
1827
+ // `except*` sibling is always a decompiler artifact.
1828
+ const hasExceptStarSibling = nodes.some(n =>
1829
+ n instanceof AST.ASTCondBlock &&
1830
+ n.blockType === AST.ASTBlock.BlockType.Except &&
1831
+ n.isExceptStar);
1524
1832
  for (let i = nodes.length - 1; i >= 0; i--) {
1525
1833
  const blk = nodes[i];
1526
1834
  if (!(blk instanceof AST.ASTCondBlock) || blk.blockType !== AST.ASTBlock.BlockType.Except) {
1527
1835
  continue;
1528
1836
  }
1837
+ if (!blk.isExceptStar && !hasExceptStarSibling) {
1838
+ continue;
1839
+ }
1529
1840
  const condNode = blk.condition instanceof AST.ASTStore ? blk.condition.src : blk.condition;
1530
1841
  const condText = (typeof condNode?.codeFragment === 'function' ? condNode.codeFragment()?.toString?.() : null) || condNode?.name;
1531
1842
  const body = blk.nodes || [];
@@ -1603,6 +1914,11 @@ class PycDecompiler {
1603
1914
  if (!(node instanceof AST.ASTCondBlock)) {
1604
1915
  return false;
1605
1916
  }
1917
+ // `while True: pass` / `while 1:` has no explicit condition but is a legitimate
1918
+ // user construct; only If blocks are null-sentinel artifacts from with-cleanup.
1919
+ if (node.blockType !== AST.ASTBlock.BlockType.If) {
1920
+ return false;
1921
+ }
1606
1922
  const condition = node.condition;
1607
1923
 
1608
1924
  // Drop degenerate IF blocks with no condition and empty body (often leftover from with cleanup tests)
@@ -1843,25 +2159,72 @@ class PycDecompiler {
1843
2159
  }
1844
2160
 
1845
2161
  const targetName = node.dest?.name || node.dest?.codeFragment?.();
2162
+ const isClassLike = targetName && targetName[0] === targetName[0]?.toUpperCase?.();
2163
+
2164
+ // Preferred path (3.13+): the wrapper's own SourceCode ends with
2165
+ // `return <ASTFunction>` where the function already has correct
2166
+ // annotations (via SET_FUNCTION_ATTRIBUTE) and typeParams with
2167
+ // PEP 696 defaults (via CALL_INTRINSIC_2 intrinsics 5 + 4).
2168
+ const wrapperBody = func.code?.object?.SourceCode?.list || [];
2169
+ let returnedNode = null;
2170
+ for (let j = wrapperBody.length - 1; j >= 0; j--) {
2171
+ const stmt = wrapperBody[j];
2172
+ if (stmt instanceof AST.ASTReturn && stmt.value) {
2173
+ returnedNode = stmt.value;
2174
+ break;
2175
+ }
2176
+ }
2177
+ if (!isClassLike && returnedNode instanceof AST.ASTFunction) {
2178
+ node.m_src = returnedNode;
2179
+ continue;
2180
+ }
2181
+ // Classes: the wrapper's direct return is an ASTClass whose base is
2182
+ // an internal CPython temp (".generic_base" for SUBSCRIPT_GENERIC).
2183
+ // Fall through to the Consts-based rebuild which infers clean
2184
+ // typeParams from string names instead.
2185
+
2186
+ // Fallback (pre-3.13 or irregular wrappers): rebuild from Consts.
1846
2187
  const codeConsts = func.code?.object?.Consts?.Value || [];
1847
- const innerCodeObj = codeConsts.find(c => c?.ClassName === 'Py_CodeObject');
2188
+ const allCodeObjects = codeConsts.filter(c => c?.ClassName === 'Py_CodeObject');
2189
+ let innerCodeObj = allCodeObjects.find(c => c.Name?.toString?.() === targetName);
2190
+ if (!innerCodeObj) {
2191
+ innerCodeObj = allCodeObjects[0];
2192
+ }
1848
2193
  if (!innerCodeObj) {
1849
2194
  continue;
1850
2195
  }
1851
- // Extract type parameters: pick uppercase-like strings excluding target name
1852
- const typeParams = codeConsts
2196
+ const typeParamNames = codeConsts
1853
2197
  .filter(c => c?.ClassName === 'Py_String')
1854
2198
  .map(c => c.Value)
1855
2199
  .filter(v => /^[A-Z][A-Za-z0-9_]*$/.test(v || '') && v !== targetName);
1856
2200
 
1857
- // Decompile inner code object to get body
2201
+ const typeParams = typeParamNames.map(name => {
2202
+ const defaultCode = allCodeObjects.find(c =>
2203
+ c !== innerCodeObj &&
2204
+ (c.ArgCount || 0) === 0 &&
2205
+ c.Name?.toString?.() === name
2206
+ );
2207
+ if (!defaultCode) return name;
2208
+ try {
2209
+ const dd = new PycDecompiler(defaultCode);
2210
+ const body = dd.decompile();
2211
+ defaultCode.SourceCode = body;
2212
+ if (dd.errors.length) this.errors.push(...dd.errors);
2213
+ const list = body?.list || [];
2214
+ const top = list.top?.() || list[list.length - 1] || list[0];
2215
+ if (top instanceof AST.ASTReturn && top.value) {
2216
+ return { name, default: top.value };
2217
+ }
2218
+ } catch (_) { /* fall through */ }
2219
+ return name;
2220
+ });
2221
+
1858
2222
  const innerDecompiler = new PycDecompiler(innerCodeObj);
1859
2223
  const innerBody = innerDecompiler.decompile();
1860
2224
  innerCodeObj.SourceCode = innerBody;
2225
+ if (innerDecompiler.errors.length) this.errors.push(...innerDecompiler.errors);
1861
2226
  const astObj = new AST.ASTObject(innerCodeObj);
1862
2227
 
1863
- const isClassLike = targetName && targetName[0] === targetName[0]?.toUpperCase?.();
1864
-
1865
2228
  if (!isClassLike) {
1866
2229
  const fn = new AST.ASTFunction(astObj);
1867
2230
  fn.annotations = innerDecompiler.funcAnnotations || fn.annotations;
@@ -17,7 +17,7 @@ class PycDisassembler
17
17
  if (opCode.HasConstant) {
18
18
  argValue = opCode.Constant;
19
19
  } else if (opCode.HasName || opCode.HasLocal || opCode.HasFree) {
20
- argValue = opCode.Name;
20
+ argValue = opCode.Name || opCode.LocalName || opCode.FreeName;
21
21
  } else if (opCode.HasJumpRelative) {
22
22
  argValue = opCode.Argument;
23
23
  } else if (opCode.HasCompare) {