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.
package/lib/PycReader.js CHANGED
@@ -38,13 +38,14 @@ const TypeSmallTuple = ')';
38
38
  const TypeShortAscii = 'z';
39
39
  const TypeShortAsciiInterned = 'Z';
40
40
  const TypeObjectReference = 'r';
41
+ const TypeSlice = ':'; // CPython 3.14+: marshal'd slice constant (start, stop, step)
41
42
  const KnownTypes = new Set([
42
43
  TypeNull, TypeNone, TypeFalse, TypeTrue, TypeStopiter, TypeEllipsis,
43
44
  TypeInt, TypeInt64, TypeFloat, TypeBinaryFloat, TypeComplex, TypeBinaryComplex,
44
45
  TypeLong, TypeString, TypeInterned, TypeStringRef, TypeTuple, TypeList,
45
46
  TypeDict, TypeCode, TypeCode2, TypeUnicode, TypeUnknown, TypeSet, TypeFrozenset,
46
47
  TypeAscii, TypeAsciiInterned, TypeSmallTuple, TypeShortAscii, TypeShortAsciiInterned,
47
- TypeObjectReference
48
+ TypeObjectReference, TypeSlice
48
49
  ]);
49
50
 
50
51
  const MagicToVersion = {
@@ -590,6 +591,14 @@ class PycReader
590
591
  obj.ClassName = "Py_FrozenSet";
591
592
  obj.Value = frozenSet;
592
593
  break;
594
+ case TypeSlice: {
595
+ const sliceStart = this.ReadObject();
596
+ const sliceStop = this.ReadObject();
597
+ const sliceStep = this.ReadObject();
598
+ obj.ClassName = "Py_Slice";
599
+ obj.Value = { start: sliceStart, stop: sliceStop, step: sliceStep };
600
+ break;
601
+ }
593
602
  case TypeCode:
594
603
  case TypeCode2:
595
604
  let codeObj = this.ReadCodeObject();
@@ -57,7 +57,9 @@ class PythonObject {
57
57
  .replace(/\\/g, '\\\\')
58
58
  .replace(/\n/g, '\\n')
59
59
  .replace(/\r/g, '\\r')
60
- .replace(/\t/g, '\\t');
60
+ .replace(/\t/g, '\\t')
61
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, c =>
62
+ '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'));
61
63
  let quote = '"';
62
64
  if (escaped.includes('"')) {
63
65
  if (!escaped.includes("'")) {
@@ -192,7 +194,13 @@ class PythonObject {
192
194
 
193
195
  case "Py_Complex":
194
196
  return `${this.Value[0]}+${this.Value[1]}j`;
195
-
197
+
198
+ case "Py_Slice": {
199
+ const part = (v) => v instanceof PythonObject ? v.toReprString() : String(v);
200
+ const { start, stop, step } = this.Value || {};
201
+ return `slice(${part(start)}, ${part(stop)}, ${part(step)})`;
202
+ }
203
+
196
204
  default:
197
205
  return ["Py_String"].includes(this.ClassName) ? this.Value.toString("ascii") : null;
198
206
  }
@@ -212,9 +212,14 @@ class ASTNodeList extends ASTNode {
212
212
  if (this.emptyBlock()) {
213
213
  result.add("pass");
214
214
  } else {
215
+ // Defensive: a handler may have appended an undefined slot (e.g.
216
+ // a stack-pop that returned undefined on a malformed pyc).
217
+ // Without this, the sibling-link loop crashes on `undefined.prevSibling`
218
+ // and aborts rendering of the whole code object.
219
+ const list = this.list.filter(n => n);
215
220
  let prevNode = null;
216
221
 
217
- for (let node of this.list) {
222
+ for (let node of list) {
218
223
  if (prevNode) {
219
224
  prevNode.nextSibling = node;
220
225
  }
@@ -223,7 +228,7 @@ class ASTNodeList extends ASTNode {
223
228
  }
224
229
  prevNode = null;
225
230
 
226
- for (let node of this.list) {
231
+ for (let node of list) {
227
232
  if (node.skip) {
228
233
  continue;
229
234
  }
@@ -258,7 +263,19 @@ class ASTNodeList extends ASTNode {
258
263
  }
259
264
  }
260
265
 
261
- if (prevNode && spacing == 0 && sourceFragment.length == 1) {
266
+ // Only join with `; ` between two simple (non-compound) statements
267
+ // that also happen to be on the same source line. A compound block
268
+ // (if/while/for/try/function/class) must never be semicolon-joined
269
+ // to a following sibling — doing so swallows the sibling into the
270
+ // block's body and breaks structure.
271
+ const prevIsCompound = prevNode instanceof ASTBlock ||
272
+ prevNode instanceof ASTFunction ||
273
+ prevNode instanceof ASTClass;
274
+ const nodeIsCompound = node instanceof ASTBlock ||
275
+ node instanceof ASTFunction ||
276
+ node instanceof ASTClass;
277
+ if (prevNode && spacing == 0 && sourceFragment.length == 1 &&
278
+ !prevIsCompound && !nodeIsCompound) {
262
279
  result.lastLineAppend((prevNode ? "; " : "") + sourceFragment.toString(), false);
263
280
  } else {
264
281
  const blankCount = rawSpacing ? Math.max(0, spacing - 1) : (spacing > 1 ? 1 : 0);
@@ -465,7 +482,11 @@ class ASTObject extends ASTNode {
465
482
  .replace(/\\/g, '\\\\')
466
483
  .replace(/\n/g, '\\n')
467
484
  .replace(/\r/g, '\\r')
468
- .replace(/\t/g, '\\t');
485
+ .replace(/\t/g, '\\t')
486
+ // Any remaining C0/DEL byte must be hex-escaped; leaving a raw
487
+ // NUL or BEL in the source makes the output unparseable.
488
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, c =>
489
+ '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'));
469
490
  let quote = '"';
470
491
  if (escaped.includes('"')) {
471
492
  if (!escaped.includes("'")) {
@@ -1103,7 +1124,13 @@ class ASTStore extends ASTNode {
1103
1124
  if (inLambda) {
1104
1125
  result.lastLineAppend(methodBody);
1105
1126
  } else {
1106
- result.add(methodBody);
1127
+ const bodyHasContent = methodBody && methodBody.toString().trim().length;
1128
+ const hasGlobals = codeObject.Globals && codeObject.Globals.size > 0;
1129
+ if (bodyHasContent) {
1130
+ result.add(methodBody);
1131
+ } else if (!hasGlobals) {
1132
+ result.add("pass");
1133
+ }
1107
1134
  }
1108
1135
  result.decreaseIndent();
1109
1136
 
@@ -2539,19 +2566,33 @@ class ASTBlock extends ASTNode {
2539
2566
  result.add("pass");
2540
2567
  }
2541
2568
  } else {
2569
+ // Emit body nodes first, then trailing dedented handlers. If
2570
+ // every body node was `skip`-ed (e.g. Try body whose sole
2571
+ // child was consumed by ternary collapse), the block would
2572
+ // print `try:` with no body — inject a `pass` placeholder so
2573
+ // the resulting source stays parseable.
2574
+ let emittedBodyNode = false;
2575
+ const handlerTail = [];
2542
2576
  renderNodes.map(node => {
2543
2577
  if (!node || node.skip) {
2544
2578
  return;
2545
2579
  }
2546
2580
  const isHandler = node.blockType == ASTBlock.BlockType.Except || node.blockType == ASTBlock.BlockType.Finally;
2547
2581
  if (isHandler) {
2548
- result.decreaseIndent();
2549
- result.add(node.codeFragment());
2550
- result.increaseIndent();
2582
+ handlerTail.push(node);
2551
2583
  } else {
2552
2584
  result.add(node.codeFragment());
2585
+ emittedBodyNode = true;
2553
2586
  }
2554
2587
  });
2588
+ if (!emittedBodyNode) {
2589
+ result.add("pass");
2590
+ }
2591
+ for (const h of handlerTail) {
2592
+ result.decreaseIndent();
2593
+ result.add(h.codeFragment());
2594
+ result.increaseIndent();
2595
+ }
2555
2596
  }
2556
2597
  } else {
2557
2598
  result.add("pass");
@@ -2566,6 +2607,17 @@ class ASTBlock extends ASTNode {
2566
2607
  }
2567
2608
  }
2568
2609
 
2610
+ // Predicate: can this node legally appear as one branch of a ternary `x if c else y`?
2611
+ // Block-shaped nodes render multi-line and an ASTKeyword (pass/break/continue) is a
2612
+ // statement, not an expression — appending " if c else y" to either is invalid Python.
2613
+ function isTernaryExprNode(node) {
2614
+ if (node == null) return false;
2615
+ if (node instanceof ASTBlock) return false;
2616
+ if (node instanceof ASTKeyword) return false;
2617
+ if (node instanceof ASTReturn) return false;
2618
+ return true;
2619
+ }
2620
+
2569
2621
  class ASTCondBlock extends ASTBlock {
2570
2622
  static InitCondition = {
2571
2623
  Uninited: 0,
@@ -2600,7 +2652,23 @@ class ASTCondBlock extends ASTBlock {
2600
2652
 
2601
2653
  codeFragment() {
2602
2654
  let result = new PycResult("", true);
2655
+ // Cycle guard: if a control-flow handler accidentally nests a block
2656
+ // inside its own descendants, the recursive render would blow the JS
2657
+ // stack and abort the enclosing code object. Detect re-entry on the
2658
+ // same instance, emit a sentinel, and let the rest of the file render.
2659
+ if (this.__rendering) {
2660
+ result.add("###CYCLE###");
2661
+ return result;
2662
+ }
2663
+ this.__rendering = true;
2664
+ try {
2665
+ return this._codeFragmentImpl(result);
2666
+ } finally {
2667
+ this.__rendering = false;
2668
+ }
2669
+ }
2603
2670
 
2671
+ _codeFragmentImpl(result) {
2604
2672
  // This is the assert case
2605
2673
  if (this.blockType == ASTBlock.BlockType.If && this.negative && this.condition && this.nodes.length == 1 && this.nodes[0] instanceof ASTRaise) {
2606
2674
  result.lastLineAppend("assert ", false);
@@ -2615,9 +2683,11 @@ class ASTCondBlock extends ASTBlock {
2615
2683
  this.prevSibling instanceof ASTStore &&
2616
2684
  this.blockType == ASTBlock.BlockType.If &&
2617
2685
  this.nodes.length == 1 &&
2686
+ isTernaryExprNode(this.nodes[0]) &&
2618
2687
  this.nextSibling instanceof ASTCondBlock &&
2619
2688
  this.nextSibling?.type == ASTBlock.BlockType.Else &&
2620
2689
  this.nextSibling?.nodes.length == 1 &&
2690
+ isTernaryExprNode(this.nextSibling.nodes[0]) &&
2621
2691
  this.condition.line >= 0 &&
2622
2692
  this.condition.line == this.nodes[0].line &&
2623
2693
  this.condition.line == this.nextSibling?.nodes[0].line
@@ -2634,7 +2704,10 @@ class ASTCondBlock extends ASTBlock {
2634
2704
  this.prevSibling == null &&
2635
2705
  this.blockType == ASTBlock.BlockType.If &&
2636
2706
  this.nodes.length == 1 &&
2707
+ isTernaryExprNode(this.nodes[0]) &&
2637
2708
  this.nextSibling instanceof ASTReturn &&
2709
+ this.nextSibling.value &&
2710
+ isTernaryExprNode(this.nextSibling.value) &&
2638
2711
  this.condition.line == this.nodes[0].line &&
2639
2712
  this.condition.line == this.nextSibling?.value.line
2640
2713
  ) {
@@ -3137,12 +3210,27 @@ class ASTFormattedValue extends ASTNode {
3137
3210
  return this.m_format_spec;
3138
3211
  }
3139
3212
 
3140
- codeFragment() {
3213
+ codeFragment(outerQuote = null) {
3141
3214
  // Format: {value} or {value!r} or {value:.2f}
3215
+ // `outerQuote` is set only when this FormattedValue is being rendered
3216
+ // inside an enclosing f-string; nested strings must then use the
3217
+ // opposite delimiter so the outer string does not close prematurely.
3218
+ const innerQuote = outerQuote === '"' ? "'" :
3219
+ outerQuote === "'" ? '"' : '"';
3142
3220
  let result = "{";
3143
3221
 
3144
3222
  if (this.m_val) {
3145
- result += this.m_val.codeFragment();
3223
+ if (this.m_val instanceof ASTJoinedStr) {
3224
+ // Only force a flipped quote when we actually have an outer
3225
+ // f-string; otherwise let the nested string keep its default.
3226
+ if (outerQuote) {
3227
+ result += this.m_val.codeFragment(innerQuote);
3228
+ } else {
3229
+ result += this.m_val.codeFragment();
3230
+ }
3231
+ } else {
3232
+ result += this.m_val.codeFragment();
3233
+ }
3146
3234
  }
3147
3235
 
3148
3236
  // Add conversion flag (!s, !r, !a)
@@ -3163,7 +3251,10 @@ class ASTFormattedValue extends ASTNode {
3163
3251
  result += ":";
3164
3252
  // Format spec can be ASTJoinedStr (nested f-string) or string constant
3165
3253
  if (this.m_format_spec instanceof ASTJoinedStr) {
3166
- result += this.m_format_spec.codeFragment();
3254
+ // Format-spec content is already inside the outer f-string's
3255
+ // braces, so its literal parts must not use the outer quote.
3256
+ const specQuote = outerQuote ? innerQuote : '"';
3257
+ result += this.m_format_spec.codeFragment(specQuote, true);
3167
3258
  } else if (this.m_format_spec instanceof ASTObject) {
3168
3259
  // String constant like ".2f"
3169
3260
  result += this.m_format_spec.object.Value;
@@ -3196,9 +3287,14 @@ class ASTJoinedStr extends ASTNode {
3196
3287
  get lastLine() {
3197
3288
  return this.values[this.values.length - 1]?.lastLine;
3198
3289
  }
3199
- codeFragment() {
3290
+ codeFragment(quoteChar = '"', bareInnerForFormatSpec = false) {
3200
3291
  // PEP 750 t-strings use t"..." prefix; otherwise f-string.
3201
- let result = (this.isTemplateString ? 't"' : 'f"');
3292
+ // `quoteChar` lets nested f-strings pick the opposite delimiter so
3293
+ // they don't collide with the enclosing string. `bareInnerForFormatSpec`
3294
+ // skips the f"..." wrapper entirely (used when this ASTJoinedStr is a
3295
+ // format spec nested inside {...}).
3296
+ const prefix = this.isTemplateString ? 't' : 'f';
3297
+ let result = bareInnerForFormatSpec ? "" : `${prefix}${quoteChar}`;
3202
3298
 
3203
3299
  // Values are in reverse order (BUILD_STRING pops from stack)
3204
3300
  // So we need to reverse them
@@ -3232,8 +3328,10 @@ class ASTJoinedStr extends ASTNode {
3232
3328
  let prefix = prevStr.substring(0, prevStr.length - match[0].length);
3233
3329
 
3234
3330
  // Remove the previously added literal and replace with prefix + {var=}
3331
+ let quoteEsc = quoteChar === '"' ? /"/g : /'/g;
3332
+ let quoteReplace = quoteChar === '"' ? '\\"' : "\\'";
3235
3333
  let beforeLiteral = result.lastIndexOf(prevStr.replace(/\\/g, '\\\\')
3236
- .replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t'));
3334
+ .replace(quoteEsc, quoteReplace).replace(/\n/g, '\\n').replace(/\t/g, '\\t'));
3237
3335
 
3238
3336
  if (beforeLiteral !== -1) {
3239
3337
  result = result.substring(0, beforeLiteral);
@@ -3242,7 +3340,7 @@ class ASTJoinedStr extends ASTNode {
3242
3340
  // Add prefix (escaped) if present
3243
3341
  if (prefix) {
3244
3342
  let escapedPrefix = prefix.replace(/\\/g, '\\\\');
3245
- escapedPrefix = escapedPrefix.replace(/"/g, '\\"');
3343
+ escapedPrefix = escapedPrefix.replace(quoteEsc, quoteReplace);
3246
3344
  escapedPrefix = escapedPrefix.replace(/\n/g, '\\n');
3247
3345
  escapedPrefix = escapedPrefix.replace(/\t/g, '\\t');
3248
3346
  result += escapedPrefix;
@@ -3254,16 +3352,25 @@ class ASTJoinedStr extends ASTNode {
3254
3352
  }
3255
3353
  }
3256
3354
 
3257
- // {expression} part
3258
- result += value.codeFragment();
3259
- } else if (value instanceof ASTObject && value.object?.ClassName === 'Py_String') {
3355
+ // {expression} part - pass our own quote char so nested
3356
+ // f-strings / strings inside pick a compatible delimiter.
3357
+ result += value.codeFragment(quoteChar);
3358
+ // result above calls ASTFormattedValue.codeFragment(outerQuote).
3359
+ } else if (value instanceof ASTObject && ['Py_String', 'Py_Unicode'].includes(value.object?.ClassName)) {
3260
3360
  // Literal string part - need to escape special chars
3261
3361
  let str = value.object.Value;
3262
- // Escape backslashes and quotes
3362
+ // Escape backslashes and the current quote char
3263
3363
  str = str.replace(/\\/g, '\\\\');
3264
- str = str.replace(/"/g, '\\"');
3364
+ if (quoteChar === '"') {
3365
+ str = str.replace(/"/g, '\\"');
3366
+ } else {
3367
+ str = str.replace(/'/g, "\\'");
3368
+ }
3265
3369
  str = str.replace(/\n/g, '\\n');
3370
+ str = str.replace(/\r/g, '\\r');
3266
3371
  str = str.replace(/\t/g, '\\t');
3372
+ str = str.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, c =>
3373
+ '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'));
3267
3374
  result += str;
3268
3375
  } else {
3269
3376
  // Fallback for unexpected types
@@ -3271,7 +3378,9 @@ class ASTJoinedStr extends ASTNode {
3271
3378
  }
3272
3379
  }
3273
3380
 
3274
- result += '"';
3381
+ if (!bareInnerForFormatSpec) {
3382
+ result += quoteChar;
3383
+ }
3275
3384
  return result;
3276
3385
  }
3277
3386
 
@@ -7,7 +7,7 @@ const opcodes = [
7
7
  [0, new OpCode(OpCodes.CACHE, "CACHE")],
8
8
  [1, new OpCode(OpCodes.BINARY_SLICE, "BINARY_SLICE")],
9
9
  [2, new OpCode(OpCodes.BUILD_TEMPLATE, "BUILD_TEMPLATE")],
10
- [4, new OpCode(OpCodes.CALL_FUNCTION_EX, "CALL_FUNCTION_EX")],
10
+ [4, new OpCode(OpCodes.CALL_FUNCTION_EX_A, "CALL_FUNCTION_EX", {HasArgument: true})],
11
11
  [5, new OpCode(OpCodes.CHECK_EG_MATCH, "CHECK_EG_MATCH")],
12
12
  [6, new OpCode(OpCodes.CHECK_EXC_MATCH, "CHECK_EXC_MATCH")],
13
13
  [7, new OpCode(OpCodes.CLEANUP_THROW, "CLEANUP_THROW")],
@@ -2,6 +2,15 @@ const AST = require('../ast/ast_node');
2
2
 
3
3
  function handleBinaryOpA()
4
4
  {
5
+ // Python 3.14 fused BINARY_SUBSCR into BINARY_OP arg=26 (NB_SUBSCR).
6
+ if (this.code.Current.Argument === 26) {
7
+ let subscr = this.dataStack.pop();
8
+ let src = this.dataStack.pop();
9
+ let node = new AST.ASTSubscr(src, subscr);
10
+ node.line = this.code.Current.LineNo;
11
+ this.dataStack.push(node);
12
+ return;
13
+ }
5
14
  let rVal = this.dataStack.pop();
6
15
  let lVal = this.dataStack.pop();
7
16
  let op = AST.ASTBinary.from_binary_op(this.code.Current.Argument);
@@ -33,17 +33,10 @@ function handleCompareOpA() {
33
33
  let left = this.dataStack.pop();
34
34
  let arg = this.code.Current.Argument;
35
35
  if (this.object.Reader.versionCompare(3, 13) >= 0) {
36
- // Python 3.13 encodes the comparison in packed form; map observed
37
- // specializations back to standard compare ops.
38
- const mapped = {
39
- 18: AST.ASTCompare.CompareOp.Less, // guards: n < 0
40
- 72: AST.ASTCompare.CompareOp.Equal, // len == N
41
- 88: AST.ASTCompare.CompareOp.Equal, // literal == value
42
- 148: AST.ASTCompare.CompareOp.Greater // guards: n > 0
43
- };
44
- arg = mapped[arg] ?? (arg & 0x0F);
36
+ // Py3.13: oparg = (op << 5) | (cast_bool<<4) | cache_flags. op ∈ 0..5.
37
+ arg >>= 5;
45
38
  } else if (this.object.Reader.versionCompare(3, 12) >= 0) {
46
- // 3.12 quickened form stores compare op in high nibble
39
+ // Py3.12: oparg = (op << 4) | cache_flags. op 0..5.
47
40
  arg >>= 4;
48
41
  }
49
42
 
@@ -477,7 +477,8 @@ function handlePopExcept() {
477
477
  this.curBlock = this.defBlock || exceptBlock;
478
478
  }
479
479
 
480
- let bodyNode = exceptBlock.nodes;
480
+ // exceptBlock.nodes returns the block's m_nodes array directly (getter with no setter).
481
+ let bodyNodes = exceptBlock.nodes;
481
482
 
482
483
  if (this.curBlock.blockType == AST.ASTBlock.BlockType.Try) {
483
484
  const pendingEgType = this.egMatchTypeStack?.shift?.() || this.pendingEgMatchType;
@@ -502,7 +503,11 @@ function handlePopExcept() {
502
503
  }
503
504
  let catchBlock = new AST.ASTCondBlock(AST.ASTBlock.BlockType.Except, exceptBlock.start, exceptBlock.end, matchType, false);
504
505
  catchBlock.line = exceptBlock.line;
505
- catchBlock.nodes = bodyNode.nodes;
506
+ // Copy the handler body from the popped Except. `nodes` is getter-only,
507
+ // so mutate m_nodes directly — plain assignment here silently no-ops.
508
+ if (Array.isArray(bodyNodes) && bodyNodes.length) {
509
+ catchBlock.m_nodes.push(...bodyNodes);
510
+ }
506
511
  catchBlock.isExceptStar = isExceptStar;
507
512
  if (catchBlock.condition && typeof catchBlock.condition === 'object' && 'src' in catchBlock.condition) {
508
513
  const storeSrc = catchBlock.condition.src;
@@ -297,7 +297,10 @@ function handleInstrumentedCallA() {
297
297
  const topType = sourceTop?.constructor?.name || 'null';
298
298
  console.log(`[CALL] func=${codeObj?.Name || codeObj?.QualName?.Value || '<?>'} sourceTop=${topType} compNode=${compNode?.constructor?.name || 'null'} hasSource=${!!codeObj?.SourceCode} pparams=${pparamList.length}`);
299
299
  }
300
- const looksLikeComp = compNames.has(func.code?.object?.Name) ||
300
+ // Name is a PythonObject (Py_String/Py_Unicode), not a raw string — compare
301
+ // via toString() so the Set lookup actually matches.
302
+ const codeName = func.code?.object?.Name?.toString?.() || '';
303
+ const looksLikeComp = compNames.has(codeName) ||
301
304
  sourceTop instanceof AST.ASTComprehension ||
302
305
  compNode instanceof AST.ASTComprehension ||
303
306
  (sourceTop instanceof AST.ASTReturn && sourceTop.value instanceof AST.ASTComprehension);
@@ -313,6 +316,15 @@ function handleInstrumentedCallA() {
313
316
  if (!(resultNode instanceof AST.ASTComprehension) && codeObj?.SourceCode) {
314
317
  let yieldExpr = null;
315
318
  let loopVar = null;
319
+ // Seed async-ness from the code flags: async comprehensions and
320
+ // async generator expressions carry CO_ASYNC_GENERATOR /
321
+ // CO_COROUTINE on the inner code object, so use that as the
322
+ // default in case the inner tree lacks an explicit AsyncFor
323
+ // block (e.g. pre-3.8 async comprehensions use GET_ANEXT +
324
+ // STORE instead of a FOR_ITER-driven iter block).
325
+ const flags = codeObj?.Flags | 0;
326
+ let isAsyncLoop = !!(flags & AST.ASTFunction.CodeFlags.CO_ASYNC_GENERATOR) ||
327
+ !!(flags & AST.ASTFunction.CodeFlags.CO_COROUTINE);
316
328
  const searchNodes = (nodes) => {
317
329
  if (!nodes) return;
318
330
  for (const node of nodes) {
@@ -322,8 +334,13 @@ function handleInstrumentedCallA() {
322
334
  if (node instanceof AST.ASTStore && !loopVar) {
323
335
  loopVar = node.dest;
324
336
  }
325
- if (node instanceof AST.ASTIterBlock && node.blockType === AST.ASTBlock.BlockType.AsyncFor) {
326
- if (node.index) loopVar = node.index;
337
+ if (node instanceof AST.ASTIterBlock &&
338
+ (node.blockType === AST.ASTBlock.BlockType.AsyncFor ||
339
+ node.blockType === AST.ASTBlock.BlockType.For)) {
340
+ if (node.index) {
341
+ loopVar = node.index;
342
+ isAsyncLoop = (node.blockType === AST.ASTBlock.BlockType.AsyncFor);
343
+ }
327
344
  if (node.nodes) searchNodes(node.nodes);
328
345
  }
329
346
  if (node?.nodes) searchNodes(node.nodes);
@@ -356,13 +373,14 @@ function handleInstrumentedCallA() {
356
373
  const varName = op.Name?.toString?.() || '';
357
374
  if (varName) {
358
375
  loopVar = new AST.ASTName(varName);
376
+ isAsyncLoop = true;
359
377
  break;
360
378
  }
361
379
  }
362
380
  }
363
381
  }
364
382
  if (global.g_cliArgs?.debug) {
365
- console.log(`[CALL-RECONSTRUCT] ${codeObj.Name?.toString?.()} yield=${yieldExpr?.constructor?.name} loopVar=${loopVar?.name}`);
383
+ console.log(`[CALL-RECONSTRUCT] ${codeObj.Name?.toString?.()} yield=${yieldExpr?.constructor?.name} loopVar=${loopVar?.name} isAsyncLoop=${isAsyncLoop}`);
366
384
  }
367
385
  if (yieldExpr && loopVar) {
368
386
  let kind = AST.ASTComprehension.GENERATOR;
@@ -377,7 +395,8 @@ function handleInstrumentedCallA() {
377
395
  comp = new AST.ASTComprehension(yieldExpr);
378
396
  }
379
397
  comp.kind = kind;
380
- let gen = new AST.ASTIterBlock(AST.ASTBlock.BlockType.AsyncFor, 0, 0, new AST.ASTName('.0'));
398
+ const genBlockType = isAsyncLoop ? AST.ASTBlock.BlockType.AsyncFor : AST.ASTBlock.BlockType.For;
399
+ let gen = new AST.ASTIterBlock(genBlockType, 0, 0, new AST.ASTName('.0'));
381
400
  gen.index = loopVar;
382
401
  gen.comprehension = true;
383
402
  comp.addGenerator(gen);
@@ -379,7 +379,17 @@ function handleBuildConstKeyMapA() {
379
379
  function handleBuildStringA() {
380
380
  let values = [];
381
381
  for (let idx = 0; idx < this.code.Current.Argument; idx++) {
382
- values.push(this.dataStack.pop());
382
+ let v = this.dataStack.pop();
383
+ // Unwrap premature single-FormattedValue JoinedStr wrappers. These arise
384
+ // when FORMAT_SIMPLE's lookahead (hasUpcomingStringBuild) bailed early
385
+ // on a CALL_A that actually belongs to a later interpolation in this
386
+ // same BUILD_STRING — rendering the wrapper would otherwise produce
387
+ // `f"f"{expr}""` nested inside the outer f-string.
388
+ if (v instanceof AST.ASTJoinedStr && v.values && v.values.length === 1 &&
389
+ v.values[0] instanceof AST.ASTFormattedValue) {
390
+ v = v.values[0];
391
+ }
392
+ values.push(v);
383
393
  }
384
394
 
385
395
  let stringNode = new AST.ASTJoinedStr(values);
@@ -182,6 +182,22 @@ function handleLoadGlobalA() {
182
182
  this.dataStack.push(node);
183
183
  }
184
184
 
185
+ function handleLoadFromDictOrGlobalsA() {
186
+ // Python 3.12+: pops the class-body locals dict pushed by LOAD_LOCALS,
187
+ // looks up co_names[A] there, falling back to globals. For source
188
+ // reconstruction we render it as a bare name reference.
189
+ if (this.dataStack.top() instanceof AST.ASTLocals) {
190
+ this.dataStack.pop();
191
+ }
192
+ const varName = this.code.Current.Name || "";
193
+ if (!varName.length) {
194
+ return;
195
+ }
196
+ let node = new AST.ASTName(varName);
197
+ node.line = this.code.Current.LineNo;
198
+ this.dataStack.push(node);
199
+ }
200
+
185
201
  function handleLoadFromDictOrDerefA() {
186
202
  // Python 3.12+: LOAD_FROM_DICT_OR_DEREF consumes the locals dict from TOS
187
203
  // (pushed by the preceding LOAD_LOCALS) and resolves the name against it,
@@ -253,9 +269,15 @@ function handleLoadSpecialA() {
253
269
  }
254
270
 
255
271
  // For __exit__ (oparg 1): save the context manager expression for with-block
256
- // This is called BEFORE __enter__ in 3.14 bytecode pattern
272
+ // This is called BEFORE __enter__ in 3.14 bytecode pattern.
273
+ // The 3.14 with-prologue uses LOAD_FAST(ctx); COPY 1; LOAD_SPECIAL 1, which
274
+ // looks like the LOAD+COPY 1 match-subject idiom — clear the candidate so
275
+ // a later COMPARE_OP inside the with-body doesn't get wrapped in a
276
+ // synthetic `match ctx:` rooted on the context manager.
257
277
  if (oparg === 1) {
258
278
  this._py314WithContextMgr = obj;
279
+ this.potentialMatchSubject = null;
280
+ this.matchCandidateStart = -1;
259
281
  }
260
282
 
261
283
  // For __enter__ (oparg 0): create with-block using saved context manager
@@ -478,6 +500,28 @@ function processStore(nameOverride) {
478
500
  }
479
501
  }
480
502
 
503
+ // `case _ as name:` final clause: no preceding pattern ops, no LOAD
504
+ // before the STORE, and we're inside a match. CPython emits a bare STORE
505
+ // to bind the subject still living on the stack.
506
+ if (this.currentMatch && !this.currentCase && !this.inMatchPattern &&
507
+ (this.patternOps?.length || 0) === 0) {
508
+ const prevId = this.code.Prev?.OpCodeID;
509
+ const isAfterReturn = prevId == this.OpCodes.RETURN_VALUE ||
510
+ prevId == this.OpCodes.RETURN_VALUE_A ||
511
+ prevId == this.OpCodes.RETURN_CONST_A;
512
+ if (isAfterReturn) {
513
+ const wildcard = new AST.ASTPattern(AST.ASTPattern.PatternType.Wildcard, '_');
514
+ const asPattern = new AST.ASTPattern(AST.ASTPattern.PatternType.As, {
515
+ pattern: wildcard,
516
+ name: currentName
517
+ });
518
+ this.currentCase = new AST.ASTCase(asPattern, null, null);
519
+ this.currentCase.line = this.code.Current.LineNo;
520
+ this.caseBodyStartIndex = this.curBlock?.nodes?.length || 0;
521
+ return;
522
+ }
523
+ }
524
+
481
525
  if (global.g_cliArgs?.debug && currentName && (currentName === 'b' || currentName === 'i')) {
482
526
  console.log(`[processStore] varName=${currentName}, curBlock=${this.curBlock.type_str}, inited=${this.curBlock.inited}, unpack=${this.unpack}`);
483
527
  console.log(` Block stack: ${this.blocks.map((b,i) => `[${i}]${b.type_str}(inited=${b.inited})`).join(' → ')}`);
@@ -832,6 +876,7 @@ module.exports = {
832
876
  handleLoadZeroSuperAttrA,
833
877
  handleLoadZeroSuperMethodA,
834
878
  handleLoadFromDictOrDerefA,
879
+ handleLoadFromDictOrGlobalsA,
835
880
  handleStoreAttrA,
836
881
  handleStoreDerefA,
837
882
  handleStoreFastA,