depyo 1.0.3 → 1.2.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.
@@ -150,6 +150,8 @@ class PycDecompiler {
150
150
  this.enrichGenericAnnotations(functonBody);
151
151
  this.rewriteClassDefinitions(functonBody);
152
152
  this.removeNullSentinelComparisons(functonBody);
153
+ this.cleanupExcMatchArtifacts(functonBody);
154
+ this.hoistNestedExceptBlocks(functonBody);
153
155
  this.dedupeExceptHandlers(functonBody);
154
156
  this.removeDuplicateReturns(functonBody);
155
157
 
@@ -428,7 +430,19 @@ class PycDecompiler {
428
430
  if (offset === undefined) {
429
431
  return;
430
432
  }
431
- 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
+ );
432
446
 
433
447
  // Build set of WITH_EXCEPT_START handler ranges (lazily, once per code object)
434
448
  if (!this._withExceptRanges) {
@@ -579,6 +593,16 @@ class PycDecompiler {
579
593
  }
580
594
  }
581
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
+ }
582
606
  const handlerEnd = entry.depth === 0
583
607
  ? (maxHandlerEnd || entry.end || this.code.LastOffset || this.object.CodeSize || 0)
584
608
  : (entry.end || maxHandlerEnd || this.code.LastOffset || this.object.CodeSize || 0);
@@ -644,6 +668,54 @@ class PycDecompiler {
644
668
  if (!next) break;
645
669
  end = next.end;
646
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
+ }
647
719
  return end;
648
720
  }
649
721
 
@@ -861,6 +933,23 @@ class PycDecompiler {
861
933
  break; // This WITH block hasn't reached its end yet
862
934
  }
863
935
 
936
+ // Close any blocks ABOVE this WITH whose end is also reached.
937
+ // 3.14 with-bodies that contain an if/else commonly have the
938
+ // else's end coincide with the with's end (both target the
939
+ // post-with cleanup offset). If we close the WITH while an
940
+ // Else is still open above it, the else body gets reparented
941
+ // to the WITH's parent — producing a stray `else:` dedented
942
+ // out of the with-block in the rendered source.
943
+ while (this.blocks.length - 1 > withIdx) {
944
+ const above = this.blocks.top();
945
+ if (above.end <= 0 || this.code.Current.Offset < above.end) {
946
+ break;
947
+ }
948
+ this.blocks.pop();
949
+ const newTop = this.blocks.top();
950
+ if (newTop) newTop.append(above);
951
+ }
952
+
864
953
  // Close this WITH block
865
954
  this.blocks.splice(withIdx, 1);
866
955
  const parentBlock = this.blocks[withIdx - 1] || this.blocks.top();
@@ -1653,11 +1742,45 @@ class PycDecompiler {
1653
1742
  if (hasDataclass) {
1654
1743
  node.addDecorator(new AST.ASTName('dataclass'));
1655
1744
  }
1745
+ } else if (node.src instanceof AST.ASTCall && this.isDecoratedClassCall(node.src)) {
1746
+ // Pattern: `Name = decorator(ClassLiteral[, ...])` produced by Py3.12+
1747
+ // dataclass/decorator-of-class flows. Promote to a real class with
1748
+ // the call-of-decorator attached as an @decorator.
1749
+ const call = node.src;
1750
+ const classNode = call.pparams[0];
1751
+ const decoratorExpr = this._buildDecoratorExpr(call);
1752
+ node.src = classNode;
1753
+ this.cleanupClassBody(classNode);
1754
+ if (decoratorExpr) {
1755
+ node.addDecorator(decoratorExpr);
1756
+ }
1656
1757
  } else if (node.src instanceof AST.ASTClass) {
1657
1758
  this.cleanupClassBody(node.src);
1658
1759
  }
1659
1760
  }
1660
1761
 
1762
+ isDecoratedClassCall(call) {
1763
+ if (!(call instanceof AST.ASTCall)) return false;
1764
+ if (call.func instanceof AST.ASTClass) return false;
1765
+ if (!call.pparams || call.pparams.length < 1) return false;
1766
+ if (!(call.pparams[0] instanceof AST.ASTClass)) return false;
1767
+ // Any further pparams would become positional args to the decorator,
1768
+ // which cannot be expressed as `@decorator(class)` sugar. Reject.
1769
+ return call.pparams.length === 1;
1770
+ }
1771
+
1772
+ _buildDecoratorExpr(call) {
1773
+ // `decorator(class)` → `@decorator`
1774
+ // `decorator(class, kw=val, ...)` → `@decorator(kw=val, ...)`
1775
+ const kwargs = call.kwparams || [];
1776
+ if (kwargs.length === 0 && !call.hasVar && !call.hasKw) {
1777
+ return call.func;
1778
+ }
1779
+ const wrapper = new AST.ASTCall(call.func, [], kwargs);
1780
+ wrapper.line = call.line;
1781
+ return wrapper;
1782
+ }
1783
+
1661
1784
  _childContainers(node) {
1662
1785
  const containers = [];
1663
1786
  if (node instanceof AST.ASTBlock) {
@@ -1801,6 +1924,635 @@ class PycDecompiler {
1801
1924
  }
1802
1925
  }
1803
1926
 
1927
+ // Rewrites 3.11+ CHECK_EXC_MATCH artifacts that leaked past block
1928
+ // reconstruction: unwraps `except <exc> EXC_MATCH <type>` conditions
1929
+ // down to `<type>`, rewrites sibling `while __exception__ EXC_MATCH T:`
1930
+ // blocks into `except T:` handlers on the preceding Try, and strips
1931
+ // trailing compiler-generated reraise/cleanup nodes (bare `raise`,
1932
+ // `e = None; del e`, empty `try: pass`). Runs once on the whole tree.
1933
+ cleanupExcMatchArtifacts(root) {
1934
+ // Pre-3.11 except-as handlers still need two cleanups: strip the
1935
+ // `X = None; del X` pair CPython emits after every `except ... as X:`
1936
+ // body (PEP 3134), and unwrap the synthetic `try: body / finally: pass`
1937
+ // that wraps the handler body (CPython lowers `except ... as X:` to
1938
+ // `try: body; finally: X = None; del X`, and the finally body ends up
1939
+ // emptied as the X=None/del X move out to the handler tail).
1940
+ if (this.object.Reader.versionCompare(3, 11) < 0) {
1941
+ this.cleanupPre311ExceptAsArtifacts(root);
1942
+ return;
1943
+ }
1944
+
1945
+ const EXC_MATCH = AST.ASTCompare.CompareOp.Exception;
1946
+
1947
+ const unwrapExcMatch = (cond) => {
1948
+ if (!cond) return cond;
1949
+ // The condition may have been wrapped in ASTStore (alias binding) -
1950
+ // unwrap first, then strip the EXC_MATCH compare.
1951
+ let inner = cond;
1952
+ if (inner instanceof AST.ASTStore && inner.src) {
1953
+ inner = inner.src;
1954
+ }
1955
+ if (inner instanceof AST.ASTCompare && inner.op === EXC_MATCH) {
1956
+ return inner.right;
1957
+ }
1958
+ return cond;
1959
+ };
1960
+
1961
+ const isExcMatchCompare = (node) =>
1962
+ node instanceof AST.ASTCompare && node.op === EXC_MATCH;
1963
+
1964
+ const isExceptionName = (node) =>
1965
+ node instanceof AST.ASTName && node.name === '__exception__';
1966
+
1967
+ const containsExceptionRef = (node, depth = 0) => {
1968
+ if (!node || depth > 4) return false;
1969
+ if (isExceptionName(node)) return true;
1970
+ if (node instanceof AST.ASTCompare || node instanceof AST.ASTBinary) {
1971
+ return containsExceptionRef(node.left, depth + 1) ||
1972
+ containsExceptionRef(node.right, depth + 1);
1973
+ }
1974
+ if (node instanceof AST.ASTCall) {
1975
+ return containsExceptionRef(node.func, depth + 1) ||
1976
+ (node.pparams || []).some(p => containsExceptionRef(p, depth + 1));
1977
+ }
1978
+ if (node instanceof AST.ASTStore) {
1979
+ return containsExceptionRef(node.src, depth + 1);
1980
+ }
1981
+ return false;
1982
+ };
1983
+
1984
+ const isEmptyTryBlock = (node) =>
1985
+ node instanceof AST.ASTBlock &&
1986
+ node.blockType === AST.ASTBlock.BlockType.Try &&
1987
+ (node.nodes.length === 0 ||
1988
+ (node.nodes.length === 1 &&
1989
+ node.nodes[0] instanceof AST.ASTKeyword &&
1990
+ node.nodes[0].key === AST.ASTKeyword.Word.Pass));
1991
+
1992
+ const isOrphanExceptionStmt = (node) => {
1993
+ // Bare `raise` with no params → fall-through RERAISE artifact
1994
+ if (node instanceof AST.ASTRaise && (!node.params || node.params.length === 0)) {
1995
+ return true;
1996
+ }
1997
+ // `__exception__(None, None, None)` or `__exception__` alone
1998
+ if (node instanceof AST.ASTCall && isExceptionName(node.func)) {
1999
+ return true;
2000
+ }
2001
+ if (isExceptionName(node)) {
2002
+ return true;
2003
+ }
2004
+ // `x = __exception__` - CPython alias-clear artifact
2005
+ if (node instanceof AST.ASTStore && isExceptionName(node.src)) {
2006
+ return true;
2007
+ }
2008
+ // `return __exception__` - misrendered reraise epilogue
2009
+ if (node instanceof AST.ASTReturn && isExceptionName(node.value)) {
2010
+ return true;
2011
+ }
2012
+ return false;
2013
+ };
2014
+
2015
+ const rewriteContainer = (nodes, parentBlockType) => {
2016
+ if (!Array.isArray(nodes)) return;
2017
+
2018
+ // Pass 1: unwrap EXC_MATCH in Except block conditions.
2019
+ for (const node of nodes) {
2020
+ if (node instanceof AST.ASTCondBlock &&
2021
+ node.blockType === AST.ASTBlock.BlockType.Except) {
2022
+ node.condition = unwrapExcMatch(node.condition);
2023
+ }
2024
+ }
2025
+
2026
+ // Pass 2: convert ASTWhile/ASTIf with EXC_MATCH condition to Except
2027
+ // handlers and attach them to the preceding Try block. If no preceding
2028
+ // Try exists (deeply misparsed region), drop them to avoid invalid output.
2029
+ for (let i = nodes.length - 1; i >= 0; i--) {
2030
+ const node = nodes[i];
2031
+ if (!(node instanceof AST.ASTCondBlock) ||
2032
+ !(node.blockType === AST.ASTBlock.BlockType.While ||
2033
+ node.blockType === AST.ASTBlock.BlockType.If)) {
2034
+ continue;
2035
+ }
2036
+ if (!isExcMatchCompare(node.condition) ||
2037
+ !containsExceptionRef(node.condition.left)) {
2038
+ continue;
2039
+ }
2040
+ const matchType = node.condition.right;
2041
+ // Find nearest preceding Try block in this container.
2042
+ let tryIdx = -1;
2043
+ for (let j = i - 1; j >= 0; j--) {
2044
+ const cand = nodes[j];
2045
+ if (cand instanceof AST.ASTBlock &&
2046
+ cand.blockType === AST.ASTBlock.BlockType.Try) {
2047
+ tryIdx = j;
2048
+ break;
2049
+ }
2050
+ // Allow existing Except handlers between; skip past them.
2051
+ if (cand instanceof AST.ASTCondBlock &&
2052
+ cand.blockType === AST.ASTBlock.BlockType.Except) {
2053
+ continue;
2054
+ }
2055
+ break;
2056
+ }
2057
+ // Strip any trailing orphan reraise/alias-clear statements from body.
2058
+ const cleanBody = node.nodes.filter(n => !isOrphanExceptionStmt(n));
2059
+ if (cleanBody.length === 0) {
2060
+ cleanBody.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2061
+ }
2062
+
2063
+ if (tryIdx >= 0) {
2064
+ const exceptBlk = new AST.ASTCondBlock(
2065
+ AST.ASTBlock.BlockType.Except,
2066
+ node.start, node.end, matchType, false);
2067
+ exceptBlk.line = node.line;
2068
+ exceptBlk.m_nodes = cleanBody;
2069
+ // Insert right after last existing Except handler for that Try.
2070
+ let insertAt = tryIdx + 1;
2071
+ while (insertAt < nodes.length &&
2072
+ nodes[insertAt] instanceof AST.ASTCondBlock &&
2073
+ nodes[insertAt].blockType === AST.ASTBlock.BlockType.Except) {
2074
+ insertAt++;
2075
+ }
2076
+ nodes.splice(i, 1);
2077
+ if (insertAt > i) insertAt--;
2078
+ nodes.splice(insertAt, 0, exceptBlk);
2079
+ } else {
2080
+ // No Try to attach to — dropping keeps output parseable.
2081
+ nodes.splice(i, 1);
2082
+ }
2083
+ }
2084
+
2085
+ // Pass 3: strip orphan exception-related statements at container
2086
+ // level — but NOT inside Except/Finally bodies, where bare `raise`
2087
+ // is a legitimate re-raise statement.
2088
+ const inHandlerBody = parentBlockType === AST.ASTBlock.BlockType.Except ||
2089
+ parentBlockType === AST.ASTBlock.BlockType.Finally;
2090
+ if (!inHandlerBody) {
2091
+ for (let i = nodes.length - 1; i >= 0; i--) {
2092
+ if (isOrphanExceptionStmt(nodes[i])) {
2093
+ nodes.splice(i, 1);
2094
+ }
2095
+ }
2096
+ }
2097
+
2098
+ // Pass 3.5: strip `X = None; del X` pairs — CPython 3.11+ emits this
2099
+ // at the tail/fall-through of every `except Foo as X:` body to clear
2100
+ // the binding. The pattern is synthetic and user code effectively
2101
+ // never writes it literally, so removing it is safe and prevents
2102
+ // the artifact from breaking up adjacent except clauses.
2103
+ for (let i = nodes.length - 2; i >= 0; i--) {
2104
+ const a = nodes[i], b = nodes[i + 1];
2105
+ if (a instanceof AST.ASTStore &&
2106
+ a.src instanceof AST.ASTNone &&
2107
+ a.dest instanceof AST.ASTName &&
2108
+ b instanceof AST.ASTDelete &&
2109
+ b.value instanceof AST.ASTName &&
2110
+ a.dest.name === b.value.name) {
2111
+ nodes.splice(i, 2);
2112
+ }
2113
+ }
2114
+
2115
+ // Pass 4: drop empty `try:` blocks. With no following handler the
2116
+ // try is a bare leftover. With a trivial `except: pass` handler the
2117
+ // whole pair is a reconstruction artifact — Python forbids
2118
+ // `try:<empty>`, so either outcome requires the try to go. When the
2119
+ // handler has real body drop only the try and splice a `pass`
2120
+ // placeholder into the handler's matching — but the current
2121
+ // sightings all have pass-only handlers, so mirror that case.
2122
+ const isTrivialHandlerBlock = (n) =>
2123
+ n instanceof AST.ASTCondBlock &&
2124
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2125
+ n.blockType === AST.ASTBlock.BlockType.Finally) &&
2126
+ (n.nodes.length === 0 ||
2127
+ (n.nodes.length === 1 &&
2128
+ n.nodes[0] instanceof AST.ASTKeyword &&
2129
+ n.nodes[0].key === AST.ASTKeyword.Word.Pass));
2130
+ for (let i = nodes.length - 1; i >= 0; i--) {
2131
+ if (!isEmptyTryBlock(nodes[i])) continue;
2132
+ const next = nodes[i + 1];
2133
+ const hasHandler = next instanceof AST.ASTCondBlock &&
2134
+ (next.blockType === AST.ASTBlock.BlockType.Except ||
2135
+ next.blockType === AST.ASTBlock.BlockType.Finally);
2136
+ if (!hasHandler) {
2137
+ nodes.splice(i, 1);
2138
+ continue;
2139
+ }
2140
+ // Walk all trailing handlers; drop the whole cluster iff every
2141
+ // one is a trivial pass-body — keeping any would still leave
2142
+ // the empty try as a syntax error anyway.
2143
+ let tailEnd = i + 1;
2144
+ while (tailEnd < nodes.length && isTrivialHandlerBlock(nodes[tailEnd])) {
2145
+ tailEnd++;
2146
+ }
2147
+ const allTrivial = tailEnd > i + 1 &&
2148
+ nodes.slice(i + 1, tailEnd).every(isTrivialHandlerBlock) &&
2149
+ (tailEnd === nodes.length ||
2150
+ !(nodes[tailEnd] instanceof AST.ASTCondBlock &&
2151
+ (nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Except ||
2152
+ nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Finally)));
2153
+ if (allTrivial) {
2154
+ nodes.splice(i, tailEnd - i);
2155
+ }
2156
+ }
2157
+
2158
+ // Pass 4.5: move a sibling Except/Finally that immediately follows
2159
+ // a Try into the Try's body. When CFG reconstruction hoists the
2160
+ // handler one level up (e.g. into the parent Else), the block
2161
+ // renderer dedents `except:` to the parent's indent — producing
2162
+ // `else: / try: / except:` where `except:` lands at the `else:`
2163
+ // column instead of the `try:` column, which is a SyntaxError.
2164
+ // Absorbing the handler into the Try body lets the renderer's
2165
+ // single-step dedent land it at the correct `try:` column.
2166
+ for (let i = 0; i < nodes.length - 1; i++) {
2167
+ const cur = nodes[i];
2168
+ if (!(cur instanceof AST.ASTBlock) ||
2169
+ cur.blockType !== AST.ASTBlock.BlockType.Try) continue;
2170
+ const next = nodes[i + 1];
2171
+ if (!(next instanceof AST.ASTCondBlock)) continue;
2172
+ if (next.blockType !== AST.ASTBlock.BlockType.Except &&
2173
+ next.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2174
+ // Skip if the Try already carries its own trailing handler —
2175
+ // moving another one in would stack two handlers, which is
2176
+ // only valid for multi-except chains (same Try). Those cases
2177
+ // already render fine.
2178
+ const last = cur.nodes[cur.nodes.length - 1];
2179
+ const hasOwnHandler = last instanceof AST.ASTCondBlock &&
2180
+ (last.blockType === AST.ASTBlock.BlockType.Except ||
2181
+ last.blockType === AST.ASTBlock.BlockType.Finally);
2182
+ if (hasOwnHandler) continue;
2183
+ // Empty Try bodies + non-trivial handler would collapse to an
2184
+ // invalid `try: / except:` with nothing between. Inject a
2185
+ // `pass` so the rendered block is syntactically valid.
2186
+ if (cur.nodes.length === 0) {
2187
+ cur.nodes.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2188
+ }
2189
+ cur.nodes.push(next);
2190
+ nodes.splice(i + 1, 1);
2191
+ }
2192
+
2193
+ // Pass 5: a Try block with a non-empty body but no following
2194
+ // Except/Finally handler is a SyntaxError. Append a synthetic
2195
+ // `except: pass` so the output parses — real structural recovery
2196
+ // needs dedicated work, but the fallback keeps the file usable.
2197
+ // Skip when the Try already has an Except/Finally nested in its
2198
+ // body (except* groups build nested handler structures).
2199
+ for (let i = 0; i < nodes.length; i++) {
2200
+ const cur = nodes[i];
2201
+ if (!(cur instanceof AST.ASTBlock) ||
2202
+ cur.blockType !== AST.ASTBlock.BlockType.Try) {
2203
+ continue;
2204
+ }
2205
+ if (cur.nodes.length === 0) continue;
2206
+ const next = nodes[i + 1];
2207
+ const hasHandler = next instanceof AST.ASTCondBlock &&
2208
+ (next.blockType === AST.ASTBlock.BlockType.Except ||
2209
+ next.blockType === AST.ASTBlock.BlockType.Finally);
2210
+ if (hasHandler) continue;
2211
+ const hasNestedExcept = cur.nodes.some(n =>
2212
+ n instanceof AST.ASTCondBlock &&
2213
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2214
+ n.blockType === AST.ASTBlock.BlockType.Finally));
2215
+ if (hasNestedExcept) continue;
2216
+ const synthetic = new AST.ASTCondBlock(
2217
+ AST.ASTBlock.BlockType.Except,
2218
+ cur.end, cur.end, null, false);
2219
+ synthetic.line = cur.line;
2220
+ synthetic.m_nodes.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2221
+ nodes.splice(i + 1, 0, synthetic);
2222
+ i++;
2223
+ }
2224
+
2225
+ // Pass 5.5: adopt orphan Except into a preceding branch whose
2226
+ // body ends with an un-paired Try. CPython can emit a try/except
2227
+ // inside the tail of an if/else branch so that, after CFG
2228
+ // reconstruction, the Try ends up as the last child of the branch
2229
+ // while its handler gets hoisted one level up as a stray sibling.
2230
+ // Moving the handler back in keeps the pair valid. Absorbed
2231
+ // trailing statements stay in the handler body — not semantically
2232
+ // correct, but renders to parseable Python instead of a stray
2233
+ // `except:` at the outer indent.
2234
+ const branchTypes = [
2235
+ AST.ASTBlock.BlockType.If,
2236
+ AST.ASTBlock.BlockType.Elif,
2237
+ AST.ASTBlock.BlockType.Else,
2238
+ ];
2239
+ const endsWithOrphanTry = (block) => {
2240
+ if (!(block instanceof AST.ASTBlock)) return false;
2241
+ if (!branchTypes.includes(block.blockType)) return false;
2242
+ const body = block.nodes;
2243
+ if (!body || body.length === 0) return false;
2244
+ const last = body[body.length - 1];
2245
+ if (!(last instanceof AST.ASTBlock) ||
2246
+ last.blockType !== AST.ASTBlock.BlockType.Try) {
2247
+ return false;
2248
+ }
2249
+ const hasHandlerInside = last.nodes.some(n =>
2250
+ n instanceof AST.ASTCondBlock &&
2251
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2252
+ n.blockType === AST.ASTBlock.BlockType.Finally));
2253
+ return !hasHandlerInside;
2254
+ };
2255
+ for (let i = nodes.length - 1; i > 0; i--) {
2256
+ const cur = nodes[i];
2257
+ if (!(cur instanceof AST.ASTCondBlock)) continue;
2258
+ if (cur.blockType !== AST.ASTBlock.BlockType.Except &&
2259
+ cur.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2260
+ const prev = nodes[i - 1];
2261
+ if (!endsWithOrphanTry(prev)) continue;
2262
+ // Confirm there's no matching Try at this container level,
2263
+ // otherwise leave the pair alone.
2264
+ let hasLocalTry = false;
2265
+ for (let j = i - 1; j >= 0; j--) {
2266
+ const p = nodes[j];
2267
+ if (p instanceof AST.ASTBlock &&
2268
+ p.blockType === AST.ASTBlock.BlockType.Try) {
2269
+ hasLocalTry = true;
2270
+ break;
2271
+ }
2272
+ if (p instanceof AST.ASTCondBlock &&
2273
+ (p.blockType === AST.ASTBlock.BlockType.Except ||
2274
+ p.blockType === AST.ASTBlock.BlockType.Finally)) {
2275
+ continue;
2276
+ }
2277
+ break;
2278
+ }
2279
+ if (hasLocalTry) continue;
2280
+ prev.nodes.push(cur);
2281
+ nodes.splice(i, 1);
2282
+ }
2283
+
2284
+ // Pass 6: drop orphan Except/Finally blocks that have no matching
2285
+ // Try (or preceding Except) anywhere earlier in this container.
2286
+ // These are leftovers from misreconstructed CFGs — keeping them
2287
+ // produces SyntaxError at parse time. Inside a Try body Except
2288
+ // can legitimately nest (Py3.11+ handler layout, except* groups),
2289
+ // but only at the tail. A mid-body Except with non-handler
2290
+ // statements after it is a stray handler — drop it.
2291
+ const inTryBody = parentBlockType === AST.ASTBlock.BlockType.Try;
2292
+ const isHandlerBlock = (x) =>
2293
+ x instanceof AST.ASTCondBlock &&
2294
+ (x.blockType === AST.ASTBlock.BlockType.Except ||
2295
+ x.blockType === AST.ASTBlock.BlockType.Finally);
2296
+ for (let i = nodes.length - 1; i >= 0; i--) {
2297
+ const cur = nodes[i];
2298
+ if (!(cur instanceof AST.ASTCondBlock)) continue;
2299
+ if (cur.blockType !== AST.ASTBlock.BlockType.Except &&
2300
+ cur.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2301
+ if (inTryBody) {
2302
+ // except* groups have complex internal structure — never
2303
+ // touch them.
2304
+ if (cur.isExceptStar) continue;
2305
+ // For regular Except nested inside Try body (Py3.11+
2306
+ // handler layout): legitimate only when at the tail.
2307
+ let allTrailingHandlers = true;
2308
+ for (let k = i + 1; k < nodes.length; k++) {
2309
+ if (!isHandlerBlock(nodes[k])) {
2310
+ allTrailingHandlers = false;
2311
+ break;
2312
+ }
2313
+ }
2314
+ if (allTrailingHandlers) continue;
2315
+ }
2316
+ let anchorFound = false;
2317
+ for (let j = i - 1; j >= 0; j--) {
2318
+ const prev = nodes[j];
2319
+ if (prev instanceof AST.ASTBlock &&
2320
+ prev.blockType === AST.ASTBlock.BlockType.Try) {
2321
+ anchorFound = true;
2322
+ break;
2323
+ }
2324
+ if (prev instanceof AST.ASTCondBlock &&
2325
+ (prev.blockType === AST.ASTBlock.BlockType.Except ||
2326
+ prev.blockType === AST.ASTBlock.BlockType.Finally)) {
2327
+ continue;
2328
+ }
2329
+ break;
2330
+ }
2331
+ if (!anchorFound) nodes.splice(i, 1);
2332
+ }
2333
+
2334
+ // Pass 7: after earlier passes may have emptied a Try body, drop
2335
+ // Try+trivial-Except pairs where the body is now empty. Python
2336
+ // forbids `try:` with no body; a pass-only handler carries no
2337
+ // recoverable intent, so splice the cluster out entirely.
2338
+ for (let i = nodes.length - 1; i >= 0; i--) {
2339
+ if (!isEmptyTryBlock(nodes[i])) continue;
2340
+ let tailEnd = i + 1;
2341
+ while (tailEnd < nodes.length && isTrivialHandlerBlock(nodes[tailEnd])) {
2342
+ tailEnd++;
2343
+ }
2344
+ if (tailEnd === i + 1) continue;
2345
+ const trailingHandler = nodes[tailEnd] instanceof AST.ASTCondBlock &&
2346
+ (nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Except ||
2347
+ nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Finally);
2348
+ if (trailingHandler) continue;
2349
+ nodes.splice(i, tailEnd - i);
2350
+ }
2351
+ };
2352
+
2353
+ // Post-order traversal: process children before parent so nested
2354
+ // emptyings bubble up — an outer Try can only be recognised as empty
2355
+ // after the inner Try/Except artifacts inside its body were dropped.
2356
+ const visited = new WeakSet();
2357
+ const visit = (n) => {
2358
+ if (!n || visited.has(n)) return;
2359
+ visited.add(n);
2360
+ let nodes = null;
2361
+ let parentType = null;
2362
+ if (n instanceof AST.ASTNodeList) {
2363
+ nodes = n.list;
2364
+ parentType = null;
2365
+ } else if (n instanceof AST.ASTBlock) {
2366
+ nodes = n.nodes;
2367
+ parentType = n.blockType;
2368
+ } else if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2369
+ const body = n.src.code?.object?.SourceCode;
2370
+ if (body) visit(body);
2371
+ return;
2372
+ }
2373
+ if (nodes) {
2374
+ for (const c of nodes) visit(c);
2375
+ rewriteContainer(nodes, parentType);
2376
+ }
2377
+ };
2378
+ visit(root);
2379
+ }
2380
+
2381
+ // Pre-3.11 `except Foo as X:` expands to `try: body / finally: X = None;
2382
+ // del X`. The decompiler hoists the cleanup out to the handler tail,
2383
+ // leaving `try: body / finally: pass / X = None / del X`. Strip the pair
2384
+ // and unwrap the synthetic try/finally so the handler renders as `body`.
2385
+ cleanupPre311ExceptAsArtifacts(root) {
2386
+ const isNoneDelPair = (a, b) =>
2387
+ a instanceof AST.ASTStore &&
2388
+ a.src instanceof AST.ASTNone &&
2389
+ a.dest instanceof AST.ASTName &&
2390
+ b instanceof AST.ASTDelete &&
2391
+ b.value instanceof AST.ASTName &&
2392
+ a.dest.name === b.value.name;
2393
+
2394
+ const isFinallyTrivial = (n) =>
2395
+ n instanceof AST.ASTBlock &&
2396
+ n.blockType === AST.ASTBlock.BlockType.Finally &&
2397
+ (n.nodes.length === 0 ||
2398
+ (n.nodes.length === 1 &&
2399
+ n.nodes[0] instanceof AST.ASTKeyword &&
2400
+ n.nodes[0].key === AST.ASTKeyword.Word.Pass));
2401
+
2402
+ // A CPython `except Foo as X:` body wraps user code in a synthetic
2403
+ // `try: body / finally: X=None; del X`. After the Pass 3.5 strip the
2404
+ // finally becomes empty / pass-only, leaving an ASTContainerBlock
2405
+ // whose children are [Try(body), Finally(pass)]. Collapse that back
2406
+ // to just the try-body so the handler renders as `body`.
2407
+ const unwrapTryFinallyPassContainer = (nodes) => {
2408
+ for (let i = nodes.length - 1; i >= 0; i--) {
2409
+ const cont = nodes[i];
2410
+ if (!(cont instanceof AST.ASTContainerBlock)) continue;
2411
+ const children = cont.nodes;
2412
+ if (!children || children.length !== 2) continue;
2413
+ const tryBlk = children[0];
2414
+ const finBlk = children[1];
2415
+ if (!(tryBlk instanceof AST.ASTBlock) ||
2416
+ tryBlk.blockType !== AST.ASTBlock.BlockType.Try) continue;
2417
+ if (!isFinallyTrivial(finBlk)) continue;
2418
+ if (tryBlk.nodes.length === 0) continue;
2419
+ // Keep real user try/except nested inside — unwrapping
2420
+ // would change semantics when the try body itself carries
2421
+ // handlers that could fire.
2422
+ const hasNestedHandler = tryBlk.nodes.some(n =>
2423
+ n instanceof AST.ASTCondBlock &&
2424
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2425
+ n.blockType === AST.ASTBlock.BlockType.Finally));
2426
+ if (hasNestedHandler) continue;
2427
+ nodes.splice(i, 1, ...tryBlk.nodes);
2428
+ }
2429
+ };
2430
+
2431
+ const stripNoneDelPairs = (nodes) => {
2432
+ for (let i = nodes.length - 2; i >= 0; i--) {
2433
+ if (isNoneDelPair(nodes[i], nodes[i + 1])) {
2434
+ nodes.splice(i, 2);
2435
+ }
2436
+ }
2437
+ };
2438
+
2439
+ const visited = new WeakSet();
2440
+ const visit = (n) => {
2441
+ if (!n || visited.has(n)) return;
2442
+ visited.add(n);
2443
+ let nodes = null;
2444
+ let parentType = null;
2445
+ if (n instanceof AST.ASTNodeList) {
2446
+ nodes = n.list;
2447
+ } else if (n instanceof AST.ASTBlock) {
2448
+ nodes = n.nodes;
2449
+ parentType = n.blockType;
2450
+ } else if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2451
+ const body = n.src.code?.object?.SourceCode;
2452
+ if (body) visit(body);
2453
+ return;
2454
+ }
2455
+ if (nodes) {
2456
+ for (const c of nodes) visit(c);
2457
+ stripNoneDelPairs(nodes);
2458
+ // The try/finally:pass wrapper is CPython's synthetic scaffold
2459
+ // for `except Foo as X:` — only unwrap inside Except bodies,
2460
+ // otherwise real user-written `try: body / finally: pass`
2461
+ // constructs at top-level/function body get collapsed too.
2462
+ if (parentType === AST.ASTBlock.BlockType.Except) {
2463
+ unwrapTryFinallyPassContainer(nodes);
2464
+ }
2465
+ }
2466
+ };
2467
+ visit(root);
2468
+ }
2469
+
2470
+ // An Except block can get reconstructed inside a non-Try block's body
2471
+ // (most often a For/While/If inside the protected try-body) when the
2472
+ // handler's offset range overlaps a nested loop. Python requires the
2473
+ // except clause to sit next to its `try:`, so walk the tree and pull
2474
+ // such Except blocks out of any non-Try ancestor until they become a
2475
+ // sibling of their enclosing Try.
2476
+ hoistNestedExceptBlocks(root) {
2477
+ const isHoistableBlock = (node) =>
2478
+ node instanceof AST.ASTBlock &&
2479
+ node.blockType !== AST.ASTBlock.BlockType.Try;
2480
+
2481
+ const isExcept = (node) =>
2482
+ node instanceof AST.ASTCondBlock &&
2483
+ node.blockType === AST.ASTBlock.BlockType.Except;
2484
+
2485
+ // Fixed-point loop: one pass hoists Except up one level. Bounded by
2486
+ // tree depth; excessive iterations just exit.
2487
+ for (let iter = 0; iter < 64; iter++) {
2488
+ let moved = false;
2489
+ const stack = [{container: null, nodes: null, parent: null}];
2490
+ const parentMap = new WeakMap(); // child container → {arr, index, parent}
2491
+
2492
+ const visit = (arr, parentInfo) => {
2493
+ for (let i = 0; i < arr.length; i++) {
2494
+ const n = arr[i];
2495
+ if (!n) continue;
2496
+ let children = null;
2497
+ if (n instanceof AST.ASTBlock || n instanceof AST.ASTCondBlock) {
2498
+ children = n.nodes;
2499
+ } else if (n instanceof AST.ASTNodeList) {
2500
+ children = n.list;
2501
+ } else if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2502
+ const src = n.src.code?.object?.SourceCode;
2503
+ if (src instanceof AST.ASTNodeList) {
2504
+ visit(src.list, null);
2505
+ }
2506
+ continue;
2507
+ }
2508
+ if (Array.isArray(children) && children.length > 0) {
2509
+ // If this block is non-Try and its first child is an
2510
+ // Except, pull that Except out into the parent arr
2511
+ // right after `n`.
2512
+ if (isHoistableBlock(n)) {
2513
+ // Scan forward for leading Except children only;
2514
+ // an Except appearing later in the body is usually
2515
+ // a real construct we should leave alone.
2516
+ while (children.length > 0 && isExcept(children[0])) {
2517
+ const ex = children.shift();
2518
+ arr.splice(i + 1, 0, ex);
2519
+ moved = true;
2520
+ }
2521
+ }
2522
+ visit(children, {arr, index: i});
2523
+ }
2524
+ }
2525
+ };
2526
+
2527
+ const roots = [];
2528
+ if (root instanceof AST.ASTNodeList) roots.push(root.list);
2529
+ else if (root instanceof AST.ASTBlock) roots.push(root.nodes);
2530
+
2531
+ for (const r of roots) visit(r, null);
2532
+
2533
+ // Also dive into nested function bodies.
2534
+ const walk = (n, seen) => {
2535
+ if (!n || seen.has(n)) return;
2536
+ seen.add(n);
2537
+ if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2538
+ const src = n.src.code?.object?.SourceCode;
2539
+ if (src instanceof AST.ASTNodeList) {
2540
+ visit(src.list, null);
2541
+ }
2542
+ }
2543
+ let children = null;
2544
+ if (n instanceof AST.ASTNodeList) children = n.list;
2545
+ else if (n instanceof AST.ASTBlock || n instanceof AST.ASTCondBlock) children = n.nodes;
2546
+ if (Array.isArray(children)) {
2547
+ for (const c of children) walk(c, seen);
2548
+ }
2549
+ };
2550
+ walk(root, new WeakSet());
2551
+
2552
+ if (!moved) break;
2553
+ }
2554
+ }
2555
+
1804
2556
  dedupeExceptHandlers(root) {
1805
2557
  const queue = [root];
1806
2558
  const visited = new WeakSet();
@@ -2085,6 +2837,14 @@ class PycDecompiler {
2085
2837
  const frag = ch?.codeFragment?.()?.toString?.() || "";
2086
2838
  return frag === "pass" || frag.includes("__exception__") || frag.includes("##ERROR##");
2087
2839
  }))) {
2840
+ // Drop only synthetic except* handlers without a user condition.
2841
+ // User-written `except:` / `except E:` must be preserved; replace
2842
+ // cleanup-only body with `pass` so the try statement stays valid.
2843
+ if (node.isExceptStar && !node.condition) {
2844
+ continue;
2845
+ }
2846
+ node.m_nodes = [new AST.ASTKeyword(AST.ASTKeyword.Word.Pass)];
2847
+ rewritten.push(node);
2088
2848
  continue;
2089
2849
  }
2090
2850
  if (typeof node?.codeFragment === 'function') {