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.
@@ -23,6 +23,179 @@ function findBackwardJump(code, startIndex, currentOffset, backJumpOpcodes) {
23
23
  return null;
24
24
  }
25
25
 
26
+ // Detect a Py2.x (or early-Py3) boolean expression chain rooted at the current
27
+ // JUMP_IF_* instruction. An expression chain converges at a value consumer
28
+ // (STORE_*, RETURN_*, COMPARE_OP, BINARY_*, UNARY_*, CALL_*, POP_JUMP_IF_*,
29
+ // another JUMP_IF_* for nested chains) without crossing block-level opcodes.
30
+ // This distinguishes `a = b and c or d` (expression, consumer=STORE_NAME) from
31
+ // `if b: pass; if c: pass` (control flow, two separate If blocks).
32
+ function isBoolExprChain(ctx) {
33
+ const OpCodes = ctx.OpCodes;
34
+ const chainJumpOps = new Set([
35
+ OpCodes.JUMP_IF_FALSE_A,
36
+ OpCodes.JUMP_IF_TRUE_A,
37
+ OpCodes.JUMP_IF_FALSE_OR_POP_A,
38
+ OpCodes.JUMP_IF_TRUE_OR_POP_A
39
+ ].filter(v => v !== undefined));
40
+ const valueOps = new Set([
41
+ OpCodes.LOAD_NAME_A, OpCodes.LOAD_FAST_A, OpCodes.LOAD_CONST_A,
42
+ OpCodes.LOAD_GLOBAL_A, OpCodes.LOAD_ATTR_A, OpCodes.LOAD_DEREF_A,
43
+ OpCodes.LOAD_CLASSDEREF_A, OpCodes.LOAD_CLOSURE_A,
44
+ OpCodes.LOAD_FAST_CHECK_A, OpCodes.LOAD_FAST_AND_CLEAR_A,
45
+ OpCodes.LOAD_FAST_LOAD_FAST_A,
46
+ OpCodes.LOAD_LOCALS, OpCodes.LOAD_BUILD_CLASS,
47
+ OpCodes.POP_TOP, OpCodes.POP_TOP_A,
48
+ OpCodes.DUP_TOP, OpCodes.DUP_TOP_A, OpCodes.ROT_TWO, OpCodes.ROT_THREE, OpCodes.ROT_FOUR,
49
+ OpCodes.COPY_A, OpCodes.SWAP_A,
50
+ OpCodes.COMPARE_OP, OpCodes.COMPARE_OP_A,
51
+ OpCodes.UNARY_POSITIVE, OpCodes.UNARY_NEGATIVE, OpCodes.UNARY_NOT, OpCodes.UNARY_INVERT, OpCodes.UNARY_CONVERT,
52
+ OpCodes.BINARY_ADD, OpCodes.BINARY_SUBTRACT, OpCodes.BINARY_MULTIPLY, OpCodes.BINARY_DIVIDE,
53
+ OpCodes.BINARY_TRUE_DIVIDE, OpCodes.BINARY_FLOOR_DIVIDE, OpCodes.BINARY_MODULO, OpCodes.BINARY_POWER,
54
+ OpCodes.BINARY_LSHIFT, OpCodes.BINARY_RSHIFT, OpCodes.BINARY_AND, OpCodes.BINARY_OR, OpCodes.BINARY_XOR,
55
+ OpCodes.BINARY_SUBSCR, OpCodes.BINARY_SUBSCR_A,
56
+ OpCodes.BINARY_OP_A, OpCodes.BINARY_OP,
57
+ OpCodes.BINARY_MATRIX_MULTIPLY,
58
+ OpCodes.IS_OP_A, OpCodes.CONTAINS_OP_A
59
+ ].filter(v => v !== undefined));
60
+ const consumerOps = new Set([
61
+ OpCodes.STORE_NAME_A, OpCodes.STORE_FAST_A, OpCodes.STORE_GLOBAL_A,
62
+ OpCodes.STORE_DEREF_A, OpCodes.STORE_ATTR_A,
63
+ OpCodes.STORE_SUBSCR, OpCodes.STORE_SUBSCR_A,
64
+ OpCodes.RETURN_VALUE, OpCodes.RETURN_VALUE_A, OpCodes.RETURN_CONST_A,
65
+ OpCodes.POP_JUMP_IF_FALSE_A, OpCodes.POP_JUMP_IF_TRUE_A,
66
+ OpCodes.POP_JUMP_FORWARD_IF_FALSE_A, OpCodes.POP_JUMP_FORWARD_IF_TRUE_A,
67
+ OpCodes.POP_JUMP_FORWARD_IF_NONE_A, OpCodes.POP_JUMP_FORWARD_IF_NOT_NONE_A,
68
+ OpCodes.POP_JUMP_BACKWARD_IF_NONE_A, OpCodes.POP_JUMP_BACKWARD_IF_NOT_NONE_A,
69
+ OpCodes.POP_JUMP_IF_NONE_A, OpCodes.POP_JUMP_IF_NOT_NONE_A,
70
+ OpCodes.CALL_FUNCTION_A, OpCodes.CALL_FUNCTION_KW_A, OpCodes.CALL_FUNCTION_EX_A,
71
+ OpCodes.CALL_A, OpCodes.CALL_KW_A, OpCodes.CALL_METHOD_A,
72
+ OpCodes.PRINT_ITEM, OpCodes.PRINT_ITEM_TO, OpCodes.PRINT_EXPR
73
+ ].filter(v => v !== undefined));
74
+
75
+ const startIdx = ctx.code.CurrentInstructionIndex;
76
+ const instructions = ctx.code.Instructions || [];
77
+ if (!instructions[startIdx] || !chainJumpOps.has(instructions[startIdx].OpCodeID)) return false;
78
+
79
+ let maxTarget = instructions[startIdx].JumpTarget;
80
+ if (!maxTarget || maxTarget <= instructions[startIdx].Offset) return false;
81
+
82
+ const maxLookahead = 64;
83
+ for (let i = startIdx + 1; i < instructions.length && i - startIdx <= maxLookahead; i++) {
84
+ const instr = instructions[i];
85
+ if (!instr) return false;
86
+ // Reached convergence offset — inspect what's there
87
+ if (instr.Offset >= maxTarget) {
88
+ // Another JUMP_IF_* at the convergence → nested chain continuation
89
+ if (chainJumpOps.has(instr.OpCodeID)) return true;
90
+ if (consumerOps.has(instr.OpCodeID)) return true;
91
+ // POP_TOP at convergence = control flow cleanup (if-body), not expression.
92
+ // Excluding this keeps `if X and Y: pass` — which always ends at POP_TOP —
93
+ // on the existing if-merge path and lets frame synthesis fire only when
94
+ // the chain value is actually consumed (STORE_*, RETURN_*, CALL_*, etc.).
95
+ if (instr.OpCodeID === OpCodes.POP_TOP || instr.OpCodeID === OpCodes.POP_TOP_A) return false;
96
+ // Any value op at the convergence means expression continues
97
+ // (e.g. chain is sub-expression of a larger one)
98
+ if (valueOps.has(instr.OpCodeID)) return true;
99
+ return false;
100
+ }
101
+ // Extend the chain by further JUMP_IF_* targets
102
+ if (chainJumpOps.has(instr.OpCodeID)) {
103
+ if (instr.JumpTarget > maxTarget) {
104
+ maxTarget = instr.JumpTarget;
105
+ }
106
+ continue;
107
+ }
108
+ // Only value-level opcodes allowed inside the chain window
109
+ if (!valueOps.has(instr.OpCodeID)) return false;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ // Frame-based reconstruction of Py2.x (and early-Py3) boolean expression chains.
115
+ // A chain of `LOAD + JUMP_IF_FALSE/TRUE + POP_TOP` pairs converging at a value
116
+ // consumer (STORE/RETURN/CALL/...) is the bytecode shape of `a = b and c or d`
117
+ // style expressions. The `isBoolExprChain` detector gates this; here we grow a
118
+ // per-decompiler stack of frames {target, op, lhs} and fold them into a
119
+ // compound AST.ASTBinary when execution reaches each frame's target offset.
120
+ //
121
+ // JUMP_IF_FALSE + POP_TOP => AND (lhs short-circuits when falsy).
122
+ // JUMP_IF_TRUE + POP_TOP => OR (lhs short-circuits when truthy).
123
+ function resolveBoolChainFrames(ctx, atOffset) {
124
+ if (!ctx.boolChainStack || ctx.boolChainStack.length === 0) return;
125
+ while (ctx.boolChainStack.length > 0) {
126
+ const frame = ctx.boolChainStack[ctx.boolChainStack.length - 1];
127
+ if (frame.target > atOffset) break;
128
+ ctx.boolChainStack.pop();
129
+ if (ctx.dataStack.length === 0) {
130
+ // Stack underflow means we misread the chain; drop remaining frames
131
+ // so we don't synthesize garbage and let normal handling resume.
132
+ ctx.boolChainStack = [];
133
+ return;
134
+ }
135
+ const rhs = ctx.dataStack.pop();
136
+ const op = frame.op === 'AND'
137
+ ? AST.ASTBinary.BinOp.LogicalAnd
138
+ : AST.ASTBinary.BinOp.LogicalOr;
139
+ const combined = new AST.ASTBinary(frame.lhs, rhs, op);
140
+ combined.line = ctx.code.Current?.LineNo ?? 0;
141
+ ctx.dataStack.push(combined);
142
+ }
143
+ }
144
+
145
+ function tryStartBoolChain(ctx) {
146
+ const OpCodes = ctx.OpCodes;
147
+ // Only Py2.x peek-and-branch (JUMP_IF_FALSE_A / JUMP_IF_TRUE_A + POP_TOP).
148
+ // The _OR_POP variants are already reconstructed correctly by the
149
+ // existing 3.x handler; intercepting them here would double-wrap.
150
+ const peekJumps = [OpCodes.JUMP_IF_FALSE_A, OpCodes.JUMP_IF_TRUE_A]
151
+ .filter(v => v !== undefined);
152
+ if (!peekJumps.includes(ctx.code.Current.OpCodeID)) return false;
153
+ if (ctx.inMatchPattern) return false;
154
+ if (ctx.dataStack.length === 0) return false;
155
+ if (!isBoolExprChain(ctx)) return false;
156
+
157
+ const rawTarget = ctx.code.Current.JumpTarget;
158
+ if (!rawTarget || rawTarget <= ctx.code.Current.Offset) return false;
159
+
160
+ const isFalseJump = ctx.code.Current.OpCodeID === OpCodes.JUMP_IF_FALSE_A;
161
+
162
+ // Py2.4+ optimization: the outer JUMP_IF_* may target a shared POP_TOP
163
+ // that also serves as the inner chain's fall-through sink (merged
164
+ // false-branch entry). In that case raw target points past an inner
165
+ // JUMP_IF_*, which would nest the inner chain under the outer frame —
166
+ // wrong parse. Re-aim the frame at the inner JUMP_IF_* offset so the
167
+ // outer resolves BEFORE the inner pushes its own frame.
168
+ // Shape: <outer JUMP_IF> ... POP_TOP <LOADs>* <inner JUMP_IF> POP_TOP@rawTarget <LOADs>* <consumer>.
169
+ let target = rawTarget;
170
+ const instructions = ctx.code.Instructions || [];
171
+ const curIdx = ctx.code.CurrentInstructionIndex;
172
+ const chainJumpOps = new Set([
173
+ OpCodes.JUMP_IF_FALSE_A, OpCodes.JUMP_IF_TRUE_A
174
+ ].filter(v => v !== undefined));
175
+ const popOps = new Set([OpCodes.POP_TOP, OpCodes.POP_TOP_A].filter(v => v !== undefined));
176
+ const rawTargetIdx = instructions.findIndex(i => i && i.Offset === rawTarget);
177
+ if (rawTargetIdx > curIdx + 1 &&
178
+ popOps.has(instructions[rawTargetIdx]?.OpCodeID)) {
179
+ for (let i = rawTargetIdx - 1; i > curIdx; i--) {
180
+ const instr = instructions[i];
181
+ if (!instr) break;
182
+ if (chainJumpOps.has(instr.OpCodeID) && instr.JumpTarget >= rawTarget) {
183
+ target = instr.Offset;
184
+ break;
185
+ }
186
+ }
187
+ }
188
+
189
+ const lhs = ctx.dataStack.pop();
190
+ ctx.boolChainStack = ctx.boolChainStack || [];
191
+ ctx.boolChainStack.push({ target, op: isFalseJump ? 'AND' : 'OR', lhs });
192
+
193
+ if (ctx.code.Next?.OpCodeID === OpCodes.POP_TOP) {
194
+ ctx.code.GoNext();
195
+ }
196
+ return true;
197
+ }
198
+
26
199
  function tryStartConditionalExpression(ctx, cond, falseOffset) {
27
200
  if (!cond || falseOffset <= ctx.code.Current.Offset) {
28
201
  return false;
@@ -32,13 +205,53 @@ function tryStartConditionalExpression(ctx, cond, falseOffset) {
32
205
  return false;
33
206
  }
34
207
 
208
+ // Statement terminators inside the true-branch: if the branch ends with a
209
+ // side-effecting instruction (STORE_*, POP_TOP, RETURN, RAISE) before the
210
+ // candidate JUMP_FORWARD, this is an if/else, not a ternary, and we must
211
+ // not push a pending — that would suppress the real If block.
212
+ const storeOps = new Set([
213
+ ctx.OpCodes.STORE_FAST_A,
214
+ ctx.OpCodes.STORE_NAME_A,
215
+ ctx.OpCodes.STORE_GLOBAL_A,
216
+ ctx.OpCodes.STORE_DEREF_A,
217
+ ctx.OpCodes.STORE_ATTR_A,
218
+ ctx.OpCodes.STORE_SUBSCR,
219
+ ctx.OpCodes.STORE_SUBSCR_A,
220
+ ctx.OpCodes.POP_TOP,
221
+ ctx.OpCodes.POP_TOP_A,
222
+ ctx.OpCodes.RETURN_VALUE,
223
+ ctx.OpCodes.RETURN_VALUE_A,
224
+ ctx.OpCodes.RETURN_CONST_A,
225
+ ctx.OpCodes.RAISE_VARARGS_A,
226
+ ].filter(v => v !== undefined));
227
+ const blockOpeners = new Set([
228
+ ctx.OpCodes.SETUP_EXCEPT_A,
229
+ ctx.OpCodes.SETUP_FINALLY_A,
230
+ ctx.OpCodes.SETUP_LOOP_A,
231
+ ctx.OpCodes.SETUP_WITH_A,
232
+ ctx.OpCodes.SETUP_ASYNC_WITH_A,
233
+ ctx.OpCodes.FOR_ITER_A,
234
+ ].filter(v => v !== undefined));
235
+
35
236
  let joinInstr = null;
36
237
  for (let i = ctx.code.CurrentInstructionIndex + 1; i < falseIdx; i++) {
37
238
  const instr = ctx.code.Instructions[i];
38
239
  if (!instr) {
39
240
  continue;
40
241
  }
242
+ // Crossing a block opener disqualifies: the JUMP_FORWARD inside the
243
+ // nested block is not the true-branch's join.
244
+ if (blockOpeners.has(instr.OpCodeID)) {
245
+ return false;
246
+ }
41
247
  if ([ctx.OpCodes.JUMP_FORWARD_A, ctx.OpCodes.JUMP_ABSOLUTE_A].includes(instr.OpCodeID)) {
248
+ // Look at the instruction immediately before the jump. For a ternary,
249
+ // it should leave a value on the stack (LOAD_*, BUILD_*, arithmetic),
250
+ // not be a statement terminator.
251
+ const prev = ctx.code.Instructions[i - 1];
252
+ if (prev && storeOps.has(prev.OpCodeID)) {
253
+ return false;
254
+ }
42
255
  const target = instr.JumpTarget ?? -1;
43
256
  if (target > falseOffset && target <= falseOffset + 50) {
44
257
  joinInstr = instr;
@@ -71,6 +284,33 @@ function captureTrueBranchForConditional(ctx) {
71
284
  if (!pending) {
72
285
  return false;
73
286
  }
287
+ // A real ternary leaves the true-branch expression on the data stack; if
288
+ // the stack is empty, this JUMP_FORWARD belongs to some other structure
289
+ // (e.g. a nested try/except whose exit jump happens to land on the same
290
+ // offset) and we must not consume it.
291
+ if (!ctx.dataStack.length) {
292
+ return false;
293
+ }
294
+ // Block openers between pending.startOffset and here mean the JUMP_FORWARD
295
+ // is inside a nested scope and cannot be the ternary's true branch.
296
+ const blockOpeners = new Set([
297
+ ctx.OpCodes.SETUP_EXCEPT_A,
298
+ ctx.OpCodes.SETUP_FINALLY_A,
299
+ ctx.OpCodes.SETUP_LOOP_A,
300
+ ctx.OpCodes.SETUP_WITH_A,
301
+ ctx.OpCodes.SETUP_ASYNC_WITH_A,
302
+ ctx.OpCodes.FOR_ITER_A,
303
+ ].filter(v => v !== undefined));
304
+ const startIdx = ctx.code.GetIndexByOffset(pending.startOffset);
305
+ const curIdx = ctx.code.CurrentInstructionIndex;
306
+ if (startIdx >= 0) {
307
+ for (let i = startIdx + 1; i < curIdx; i++) {
308
+ const instr = ctx.code.Instructions[i];
309
+ if (instr && blockOpeners.has(instr.OpCodeID)) {
310
+ return false;
311
+ }
312
+ }
313
+ }
74
314
  pending.trueValue = ctx.dataStack.pop();
75
315
  return true;
76
316
  }
@@ -156,6 +396,15 @@ function processJumpOps() {
156
396
  return;
157
397
  }
158
398
 
399
+ // Intercept boolean expression chains (a = b and c or d style) before the
400
+ // If-block machinery. If this JUMP_IF_* is part of a chain converging at a
401
+ // value consumer, push a frame and let resolveBoolChainFrames fold the
402
+ // combined expression back onto dataStack when the chain's target offset
403
+ // is reached. See comments on tryStartBoolChain / resolveBoolChainFrames.
404
+ if (tryStartBoolChain(this)) {
405
+ return;
406
+ }
407
+
159
408
  const wrapNoneComparison = (expectNone) => {
160
409
  let value = this.dataStack.pop();
161
410
  let cmp = new AST.ASTCompare(
@@ -213,8 +462,29 @@ function processJumpOps() {
213
462
  }
214
463
 
215
464
  // CRITICAL: Close blocks that have ended before creating new conditional block
216
- // This ensures proper sibling relationships between if/elif blocks
217
- while (this.curBlock.end > 0 &&
465
+ // This ensures proper sibling relationships between if/elif blocks.
466
+ //
467
+ // Exception: a Py2.x boolean expression chain (`a = b and c or d`) compiles
468
+ // to a sequence of JUMP_IF_FALSE/JUMP_IF_TRUE + POP_TOP pairs converging at
469
+ // a consumer (STORE/RETURN/CALL/etc). The first JUMP_IF_* opens an empty
470
+ // If(end=T) block; when we reach offset T we find another JUMP_IF_* that
471
+ // should continue the chain via the AND/OR merge below (line ~540). If we
472
+ // close here, the merge opportunity is lost and the expression renders as
473
+ // stray `if X: pass` control flow.
474
+ const chainJumpOps = [
475
+ this.OpCodes.JUMP_IF_FALSE_A,
476
+ this.OpCodes.JUMP_IF_TRUE_A,
477
+ this.OpCodes.JUMP_IF_FALSE_OR_POP_A,
478
+ this.OpCodes.JUMP_IF_TRUE_OR_POP_A
479
+ ].filter(v => v !== undefined);
480
+ const keepOpenForChain = chainJumpOps.includes(this.code.Current.OpCodeID) &&
481
+ this.curBlock.size === 0 &&
482
+ this.curBlock.end === this.code.Current.Offset &&
483
+ [AST.ASTBlock.BlockType.If, AST.ASTBlock.BlockType.Elif, AST.ASTBlock.BlockType.While].includes(this.curBlock.blockType) &&
484
+ isBoolExprChain(this);
485
+
486
+ while (!keepOpenForChain &&
487
+ this.curBlock.end > 0 &&
218
488
  this.curBlock.end <= this.code.Current.Offset &&
219
489
  this.curBlock.blockType != AST.ASTBlock.BlockType.Main &&
220
490
  this.blocks.length > 1) {
@@ -339,9 +609,19 @@ function processJumpOps() {
339
609
  }
340
610
  }
341
611
 
612
+ const isEgMatchCall = cond instanceof AST.ASTCall &&
613
+ cond.func instanceof AST.ASTName &&
614
+ cond.func.name === "__check_eg_match__";
615
+ const isExceptCompare = cond instanceof AST.ASTCompare &&
616
+ cond.op == AST.ASTCompare.CompareOp.Exception;
617
+
342
618
  // Conditional expression (a if cond else b) heuristic:
343
619
  // POP_JUMP_IF_* to falseOffset, then JUMP_FORWARD to joinOffset > falseOffset.
620
+ // Skip for exception matches: except handlers emit a similar bytecode shape
621
+ // (POP_JUMP_IF_FALSE past handler body, JUMP_FORWARD over END_FINALLY) but
622
+ // are not ternary expressions and must not be rewritten.
344
623
  if (!this.inMatchPattern &&
624
+ !isExceptCompare && !isEgMatchCall &&
345
625
  condJumpOpcodes.has(this.code.Current.OpCodeID) &&
346
626
  tryStartConditionalExpression(this, cond, rawJumpTarget)) {
347
627
  return;
@@ -362,12 +642,6 @@ function processJumpOps() {
362
642
  }
363
643
  }
364
644
 
365
- const isEgMatchCall = cond instanceof AST.ASTCall &&
366
- cond.func instanceof AST.ASTName &&
367
- cond.func.name === "__check_eg_match__";
368
- const isExceptCompare = cond instanceof AST.ASTCompare &&
369
- cond.op == AST.ASTCompare.CompareOp.Exception;
370
-
371
645
  if (isExceptCompare || isEgMatchCall) {
372
646
  if (global.g_cliArgs?.debug) {
373
647
  const matchType = isExceptCompare ? cond.right : cond.pparams?.[1];
@@ -377,13 +651,15 @@ function processJumpOps() {
377
651
  const handlerEnd = this.findExceptionHandlerEnd ? this.findExceptionHandlerEnd(this.code.Current.Offset) : null;
378
652
  if (this.curBlock.blockType == AST.ASTBlock.BlockType.Except
379
653
  && this.curBlock.condition == null) {
380
- // Reuse current except block (from exception table) instead of creating a nested one
654
+ // Reuse current except block (from exception table or pushed by JUMP_FORWARD)
655
+ // instead of creating a nested one. Return immediately to avoid the
656
+ // fall-through that would otherwise create a stray If block on top.
381
657
  this.curBlock.condition = isExceptCompare ? cond.right : cond.pparams?.[1];
382
658
  if (handlerEnd) {
383
659
  this.curBlock.end = handlerEnd;
384
660
  }
385
661
  this.curBlock.init();
386
- ifblk = null; // no extra push
662
+ return;
387
663
  } else {
388
664
  const end = handlerEnd || offs;
389
665
  const matchType = isExceptCompare ? cond.right : cond.pparams?.[1];
@@ -398,10 +674,26 @@ function processJumpOps() {
398
674
  }
399
675
  }
400
676
 
401
- if (this.curBlock.size == 0 ||
402
- (this.curBlock.size == 1 &&
403
- this.curBlock.nodes[0] instanceof AST.ASTCondBlock &&
404
- this.curBlock.nodes[0].blockType == AST.ASTBlock.BlockType.If)) {
677
+ // Only collapse else→elif when the Else attaches to an If/Elif.
678
+ // `while/for ... else: if X: ...` must stay as a nested if, since
679
+ // rendering `elif` after `while/for` is invalid Python. The Else's
680
+ // prior sibling (last node in the enclosing block) reveals what
681
+ // the Else was created for.
682
+ let elseHolder = this.blocks.length >= 2
683
+ ? this.blocks[this.blocks.length - 2]
684
+ : null;
685
+ let priorSibling = elseHolder && elseHolder.size > 0
686
+ ? elseHolder.nodes[elseHolder.size - 1]
687
+ : null;
688
+ let elseAttachesToIf = priorSibling instanceof AST.ASTCondBlock &&
689
+ (priorSibling.blockType == AST.ASTBlock.BlockType.If ||
690
+ priorSibling.blockType == AST.ASTBlock.BlockType.Elif);
691
+
692
+ if (elseAttachesToIf &&
693
+ (this.curBlock.size == 0 ||
694
+ (this.curBlock.size == 1 &&
695
+ this.curBlock.nodes[0] instanceof AST.ASTCondBlock &&
696
+ this.curBlock.nodes[0].blockType == AST.ASTBlock.BlockType.If))) {
405
697
  /* Collapse into elif statement */
406
698
  if (global.g_cliArgs?.debug) {
407
699
  console.log(`ELIF DETECTED: else block size=${this.curBlock.size}, converting to elif at offset ${this.code.Current.Offset}`);
@@ -462,7 +754,12 @@ function processJumpOps() {
462
754
  && this.curBlock.comprehension
463
755
  && this.object.Reader.versionCompare(2, 7) >= 0) {
464
756
  /* Comprehension condition */
465
- this.curBlock.condition = cond;
757
+ let actualCond = cond;
758
+ if (neg) {
759
+ actualCond = new AST.ASTUnary(cond, AST.ASTUnary.UnaryOp.Not);
760
+ actualCond.line = this.code.Current.LineNo;
761
+ }
762
+ this.curBlock.condition = actualCond;
466
763
  return;
467
764
  }
468
765
 
@@ -493,10 +790,21 @@ function processJumpOps() {
493
790
  lastNode.blockType == AST.ASTBlock.BlockType.Elif) &&
494
791
  !lastNodeInStack) { // CLOSED, not in stack = sibling!
495
792
 
496
- shouldBeElif = true;
793
+ // Assert-style guards (`if not X: raise`) don't chain as elif.
794
+ // `assert X; assert Y` and `if not X: raise; if not Y: raise`
795
+ // share bytecode shape with elif, but rendering the second as
796
+ // elif creates an orphan `elif` after the first becomes `assert`.
797
+ let priorIsAssertGuard =
798
+ lastNode.negative &&
799
+ lastNode.nodes.length == 1 &&
800
+ lastNode.nodes[0] instanceof AST.ASTRaise;
497
801
 
498
- if (global.g_cliArgs?.debug) {
499
- console.log(`ELIF DETECTED: Creating elif at offset ${this.code.Current.Offset} (follows ${lastNode.type_str} at ${lastNode.start})`);
802
+ if (!priorIsAssertGuard) {
803
+ shouldBeElif = true;
804
+
805
+ if (global.g_cliArgs?.debug) {
806
+ console.log(`ELIF DETECTED: Creating elif at offset ${this.code.Current.Offset} (follows ${lastNode.type_str} at ${lastNode.start})`);
807
+ }
500
808
  }
501
809
  }
502
810
  }
@@ -552,6 +860,37 @@ function handleJumpAbsoluteA() {
552
860
 
553
861
  // [offs] = this.code.FindEndOfBlock(offs);
554
862
 
863
+ // Inside a try-inside-loop (e.g. `while x: try: ... except: ...`),
864
+ // the successful-path jump back to the loop is emitted as JUMP_ABSOLUTE
865
+ // instead of JUMP_FORWARD. If we don't open the Except block here, the
866
+ // handler body ends up appended to the container as raw statements and
867
+ // the `__exception__` placeholder leaks into the output.
868
+ if (this.curBlock.blockType == AST.ASTBlock.BlockType.Container) {
869
+ let cont = this.curBlock;
870
+ if (cont.hasExcept && this.code.Next?.Offset <= cont.except) {
871
+ // Find the END_FINALLY that closes this except so the handler body
872
+ // stays attached to the Except block rather than leaking back into
873
+ // the Container.
874
+ let handlerEnd = cont.except;
875
+ let cursor = cont.except;
876
+ for (let i = 0; i < 200; i++) {
877
+ const instr = this.code.PeekInstructionAtOffset(cursor);
878
+ if (!instr) break;
879
+ if (instr.OpCodeID === this.OpCodes.END_FINALLY) {
880
+ handlerEnd = instr.Offset;
881
+ break;
882
+ }
883
+ cursor = instr.Offset + (instr.Size || 3);
884
+ }
885
+ cont.end = handlerEnd;
886
+ let except = new AST.ASTCondBlock(AST.ASTBlock.BlockType.Except, this.code.Current.Offset, handlerEnd, null, false);
887
+ except.init();
888
+ this.blocks.push(except);
889
+ this.curBlock = this.blocks.top();
890
+ return;
891
+ }
892
+ }
893
+
555
894
  if (offs <= this.code.Next?.Offset) {
556
895
  if (this.curBlock.blockType == AST.ASTBlock.BlockType.For) {
557
896
  let is_jump_to_start = offs == this.curBlock.start;
@@ -564,11 +903,63 @@ function handleJumpAbsoluteA() {
564
903
  let top = this.dataStack.top();
565
904
 
566
905
  if (top instanceof AST.ASTComprehension) {
567
- let comp = this.dataStack.pop();
568
- comp.addGenerator(this.curBlock);
906
+ top.addGenerator(this.curBlock);
569
907
  this.blocks.pop();
570
908
  this.curBlock = this.blocks.top();
571
- this.curBlock.append(comp);
909
+ // For multi-clause comprehensions/genexprs, keep the
910
+ // ASTComprehension on the data stack so the enclosing
911
+ // For+comprehension block can attach its own generator.
912
+ // Only materialize it as a statement once we're clear of
913
+ // comprehension-tagged for-blocks.
914
+ if (!(this.curBlock.blockType == AST.ASTBlock.BlockType.For && this.curBlock.comprehension)) {
915
+ // Detect bare listcomp statement vs assignment/return.
916
+ // Bare form ends with POP_TOP (possibly after DELETE_FAST/
917
+ // DELETE_NAME that drops the cached `_[N]`). If POP_TOP
918
+ // is the terminator, materialize the comprehension as a
919
+ // statement; otherwise leave it on the stack so the
920
+ // downstream consumer (STORE_NAME, RETURN_VALUE, CALL,
921
+ // BINARY_*, …) can use it as an expression.
922
+ // Generator expressions always materialize as the sole
923
+ // body statement so the enclosing CALL handler can
924
+ // extract the comp from the <genexpr> code object.
925
+ let isBareStatement = top.kind === AST.ASTComprehension.GENERATOR;
926
+ if (!isBareStatement && this.code.PeekInstructionAtOffset) {
927
+ let scanOff = this.code.Next?.Offset;
928
+ const SKIP_OPS = [
929
+ this.OpCodes.DELETE_FAST_A,
930
+ this.OpCodes.DELETE_NAME_A,
931
+ this.OpCodes.DELETE_GLOBAL_A,
932
+ this.OpCodes.DELETE_DEREF_A,
933
+ this.OpCodes.JUMP_ABSOLUTE_A,
934
+ this.OpCodes.JUMP_FORWARD_A
935
+ ];
936
+ for (let i = 0; i < 12 && scanOff != null; i++) {
937
+ const ins = this.code.PeekInstructionAtOffset(scanOff);
938
+ if (!ins) break;
939
+ if (SKIP_OPS.includes(ins.OpCodeID)) {
940
+ scanOff = ins.Offset + (ins.Size || 3);
941
+ continue;
942
+ }
943
+ if (ins.OpCodeID == this.OpCodes.POP_TOP) {
944
+ // Distinguish real bare-listcomp marker
945
+ // from intra-loop skip-path POP_TOP in
946
+ // 2.x (which is always followed by a
947
+ // JUMP_ABSOLUTE back to FOR_ITER).
948
+ const nextIns = this.code.PeekInstructionAtOffset(ins.Offset + (ins.Size || 1));
949
+ if (nextIns && SKIP_OPS.includes(nextIns.OpCodeID)) {
950
+ scanOff = ins.Offset + (ins.Size || 1);
951
+ continue;
952
+ }
953
+ isBareStatement = true;
954
+ }
955
+ break;
956
+ }
957
+ }
958
+ if (isBareStatement) {
959
+ let comp = this.dataStack.pop();
960
+ this.curBlock.append(comp);
961
+ }
962
+ }
572
963
  } else {
573
964
  let tmp = this.curBlock;
574
965
  this.blocks.pop();
@@ -590,6 +981,74 @@ function handleJumpAbsoluteA() {
590
981
  this.blocks.top().append(this.curBlock);
591
982
  this.curBlock = this.blocks.top();
592
983
  }
984
+ } else if (this.curBlock.blockType == AST.ASTBlock.BlockType.Except
985
+ && this.code.PeekInstructionAtOffset
986
+ && ![this.OpCodes.JUMP_ABSOLUTE_A, this.OpCodes.JUMP_FORWARD_A].includes(this.code.Prev?.OpCodeID)
987
+ && this.blocks.length >= 2
988
+ && this.blocks[this.blocks.length - 2].blockType == AST.ASTBlock.BlockType.Container) {
989
+ // Python 2.x try/except/else inside a loop: the except handler
990
+ // body ends with JUMP_ABSOLUTE back to the loop start (skipping
991
+ // the else body). Bytecode then has POP_TOP + END_FINALLY + else
992
+ // body, and the else body itself ends with another JUMP_ABSOLUTE
993
+ // to the same loop start. Without this branch, the else body
994
+ // gets attached to the surrounding loop instead of the try.
995
+ let endFinallyAhead = false;
996
+ let scan = this.code.Next?.Offset;
997
+ for (let i = 0; i < 4 && scan != null; i++) {
998
+ const instr = this.code.PeekInstructionAtOffset(scan);
999
+ if (!instr) break;
1000
+ if (instr.OpCodeID === this.OpCodes.END_FINALLY) {
1001
+ endFinallyAhead = true;
1002
+ scan = instr.Offset + (instr.Size || 1);
1003
+ break;
1004
+ }
1005
+ if (instr.OpCodeID !== this.OpCodes.POP_TOP) break;
1006
+ scan = instr.Offset + (instr.Size || 1);
1007
+ }
1008
+
1009
+ if (endFinallyAhead) {
1010
+ // Distinguish try/except/else from try/except (no else) inside
1011
+ // a loop. With else: bytecode after END_FINALLY is the else
1012
+ // body, then a JUMP_ABSOLUTE to loop start. Without else: the
1013
+ // very next instruction is the loop's exit JUMP_ABSOLUTE.
1014
+ let elseEnd = scan;
1015
+ let cursor = scan;
1016
+ let hasBody = false;
1017
+ for (let i = 0; i < 200; i++) {
1018
+ const instr = this.code.PeekInstructionAtOffset(cursor);
1019
+ if (!instr) break;
1020
+ if (instr.OpCodeID === this.OpCodes.JUMP_ABSOLUTE_A
1021
+ && instr.JumpTarget === this.code.Current.JumpTarget) {
1022
+ elseEnd = instr.Offset + (instr.Size || 3);
1023
+ break;
1024
+ }
1025
+ if (instr.OpCodeID === this.OpCodes.POP_BLOCK) {
1026
+ elseEnd = instr.Offset;
1027
+ break;
1028
+ }
1029
+ hasBody = true;
1030
+ cursor = instr.Offset + (instr.Size || 3);
1031
+ }
1032
+
1033
+ if (!hasBody) {
1034
+ // No else body — fall through to default jump handling.
1035
+ } else {
1036
+ let except = this.blocks.pop();
1037
+ this.curBlock = this.blocks.top();
1038
+ if (!except.empty()) {
1039
+ this.curBlock.append(except);
1040
+ }
1041
+ if (this.curBlock.blockType == AST.ASTBlock.BlockType.Container) {
1042
+ this.curBlock.end = elseEnd;
1043
+ }
1044
+
1045
+ let elseblk = new AST.ASTBlock(AST.ASTBlock.BlockType.Else, this.code.Current.Offset, elseEnd);
1046
+ elseblk.init();
1047
+ this.blocks.push(elseblk);
1048
+ this.curBlock = this.blocks.top();
1049
+ return;
1050
+ }
1051
+ }
593
1052
  } else {
594
1053
  // First of all we have to figure out if there is any While or For blocks wer are in
595
1054
  let loopBlock = null;
@@ -601,6 +1060,19 @@ function handleJumpAbsoluteA() {
601
1060
  }
602
1061
 
603
1062
  if (!loopBlock) {
1063
+ // Self-referential JUMP_ABSOLUTE with no enclosing loop is a
1064
+ // `while True: pass` that 3.8+ emits without SETUP_LOOP. Emit a
1065
+ // synthesized while block so the infinite loop is preserved.
1066
+ // Pre-3.8 still has SETUP_LOOP and different jump patterns; leave
1067
+ // those branches to the existing loop machinery.
1068
+ if (offs === this.code.Current.Offset
1069
+ && this.object.Reader.versionCompare(3, 8) >= 0) {
1070
+ const whileBlk = new AST.ASTCondBlock(AST.ASTBlock.BlockType.While, offs, offs, null, false);
1071
+ whileBlk.init();
1072
+ whileBlk.line = this.code.Current.LineNo;
1073
+ whileBlk.append(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
1074
+ this.curBlock.append(whileBlk);
1075
+ }
604
1076
  return;
605
1077
  }
606
1078
 
@@ -825,7 +1297,27 @@ function processJumpForward() {
825
1297
  }
826
1298
  let next = null;
827
1299
 
828
- if (this.code.Next?.OpCodeID == this.OpCodes.END_FINALLY) {
1300
+ // try/except/else: JUMP_FORWARD at end of except handler skips
1301
+ // past END_FINALLY and the else body. Detect by scanning the
1302
+ // few instructions between code.Next and the JUMP target for
1303
+ // an END_FINALLY (Python 2.x emits POP_TOP before END_FINALLY).
1304
+ let jumpTarget = this.code.Next?.Offset + offs;
1305
+ let nextIsElse = this.code.Next?.OpCodeID == this.OpCodes.END_FINALLY;
1306
+ if (!nextIsElse && this.code.PeekInstructionAtOffset) {
1307
+ let scan = this.code.Next?.Offset;
1308
+ for (let i = 0; i < 4 && scan != null && scan < jumpTarget; i++) {
1309
+ const instr = this.code.PeekInstructionAtOffset(scan);
1310
+ if (!instr) break;
1311
+ if (instr.OpCodeID === this.OpCodes.END_FINALLY) {
1312
+ nextIsElse = true;
1313
+ break;
1314
+ }
1315
+ if (instr.OpCodeID !== this.OpCodes.POP_TOP) break;
1316
+ scan = instr.Offset + instr.Size;
1317
+ }
1318
+ }
1319
+
1320
+ if (nextIsElse) {
829
1321
  next = new AST.ASTBlock(AST.ASTBlock.BlockType.Else, this.code.Current.Offset, this.code.Current.JumpTarget);
830
1322
  next.init();
831
1323
  } else {
@@ -950,5 +1442,6 @@ module.exports = {
950
1442
  handleJumpBackwardNoInterruptA,
951
1443
  handleJumpIfNotExcMatchA,
952
1444
  handleNotTaken,
953
- handleInstrumentedNotTakenA
1445
+ handleInstrumentedNotTakenA,
1446
+ resolveBoolChainFrames
954
1447
  };