@kernlang/python 4.0.1-canary.224.1.1a92ac0a → 4.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.
@@ -171,6 +171,16 @@ interface BodyEmitContext {
171
171
  /** Depth of nested `finally` blocks. Propagation from finally would
172
172
  * override pending control flow, so it gets a finally-specific error. */
173
173
  finallyDepth: number;
174
+ /** Error-substrate Slice 1 — names currently bound by an enclosing
175
+ * `except Exception as <name>:` (a body-statement `catch name=<name>`). A
176
+ * caught binding's `.message` read lowers to `str(<name>)` (Python exceptions
177
+ * have no `.message` attribute; `str(Exception("x"))` === TS
178
+ * `Error("x").message`). Populated on catch-body entry, restored on exit
179
+ * (a `Set`, so nested catches with distinct names stack; a re-bound same name
180
+ * is idempotent). Only `.message` is rewritten — `.name`/`.stack`/etc. are NOT
181
+ * in the parity domain and emit verbatim (the runner abstains on them, so they
182
+ * never reach a certified differential row). */
183
+ caughtBindings: Set<string>;
174
184
  standaloneExpression: boolean;
175
185
  /** When true, helper-dependent JS value→string coercion is emitted
176
186
  * (`__kern_add`, `_kern_fmt`-wrapped templates, the `_KERN_UNDEFINED`
@@ -40,7 +40,7 @@
40
40
  * threads a `indent` string. The propagation hoist embeds its own 4-space
41
41
  * relative indent on the `return __k_tN` line; the wrapper prepends the
42
42
  * surrounding indent so the post-emit result nests correctly. */
43
- import { applyTemplate, classifyRegexLiteralIndexReadFailClose, classifyRegexLiteralMemberReadFailClose, emitStringKeyArray, expandRegexIFold, instanceofRhsPythonType, instanceofRhsRejectReasonForName, isHostNamespaceRoot, isPostfixMutationOperator, isSupportedAssignOperator, isZeroWidthCapableRegex, KERN_STDLIB_MODULES, lookupStdlibCall, lookupStdlibProperty, lowerRegexAnchorsPython, lowerRegexNamedGroupsPython, needsArgParens, needsBinaryParens, normalizeRegexClasses, parseExpression, parseKeys, REGEX_EXEC_FAILCLOSE, REGEX_HOST_REGEXP_FAILCLOSE, REGEX_MATCHALL_NO_G_FAILCLOSE, REGEX_NONLITERAL_FAILCLOSE, REGEX_REPLACE_NONLITERAL_REPL_FAILCLOSE, REGEX_REPLACEALL_NO_G_FAILCLOSE, REGEX_SPLIT_LIMIT_FAILCLOSE, REGEX_SPLIT_ZEROWIDTH_FAILCLOSE, REGEX_TEST_G_FAILCLOSE, regexAstralFailMessage, regexCaptureMeta, regexIFoldFailMessage, regexLiteralReceiverIR, regexMethodRegexArgIdent, scanRegexAstral, suggestStdlibMethod, translateReplStringToPython, unmappedHostNamespaceMessage, unwrapTransparentReceiverIR, validateRegexNamedGroupsPortable, } from '@kernlang/core';
43
+ import { applyTemplate, assertNoDecimalOperator, classifyRegexLiteralIndexReadFailClose, classifyRegexLiteralMemberReadFailClose, decimalBareConstructionFailMessage, emitStringKeyArray, expandRegexIFold, instanceofRhsPythonType, instanceofRhsRejectReasonForName, isHostNamespaceRoot, isPostfixMutationOperator, isSupportedAssignOperator, isZeroWidthCapableRegex, KERN_DECIMAL_OPS_HELPER_PY, KERN_STDLIB_MODULES, lookupStdlibCall, lookupStdlibProperty, lowerRegexAnchorsPython, lowerRegexNamedGroupsPython, needsArgParens, needsBinaryParens, normalizeRegexClasses, parseExpression, parseKeys, REGEX_EXEC_FAILCLOSE, REGEX_HOST_REGEXP_FAILCLOSE, REGEX_MATCHALL_NO_G_FAILCLOSE, REGEX_NONLITERAL_FAILCLOSE, REGEX_REPLACE_NONLITERAL_REPL_FAILCLOSE, REGEX_REPLACEALL_NO_G_FAILCLOSE, REGEX_SPLIT_LIMIT_FAILCLOSE, REGEX_SPLIT_ZEROWIDTH_FAILCLOSE, REGEX_TEST_G_FAILCLOSE, regexAstralFailMessage, regexCaptureMeta, regexIFoldFailMessage, regexLiteralReceiverIR, regexMethodRegexArgIdent, scanRegexAstral, suggestStdlibMethod, translateReplStringToPython, unmappedHostNamespaceMessage, unwrapTransparentReceiverIR, validateDecimalConstructionArg, validateDecimalDivModArgs, validateDecimalOperands, validateDecimalPowArgs, validateRegexNamedGroupsPortable, } from '@kernlang/core';
44
44
  // Slice 0.9 — the TypeScript-AST closure helpers + classifier live on the Node
45
45
  // subpath (the barrel is browser-safe). Python codegen is Node-side and parses
46
46
  // block-bodied arrows, so it injects `typescriptClosureClassifier`.
@@ -75,6 +75,7 @@ function freshCtx(options) {
75
75
  usedPropagation: false,
76
76
  tryDepth: 0,
77
77
  finallyDepth: 0,
78
+ caughtBindings: new Set(),
78
79
  standaloneExpression: false,
79
80
  coerceJsValues: options?.coerceJsValues ?? true,
80
81
  traceHooks: options?.traceHooks,
@@ -310,6 +311,13 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
310
311
  lines.push(`${indent}${line}`);
311
312
  }
312
313
  else if (child.type === 'let') {
314
+ // Error-substrate Slice 1 (codex review: shadowing) — a `let` that rebinds
315
+ // a caught-error name SHADOWS the catch binding, so from here on its
316
+ // `.message` is an ordinary property access (matching TS), NOT `str(e)`.
317
+ // Drop it from the caught set so the rewrite no longer fires.
318
+ const letName = child.props?.name;
319
+ if (typeof letName === 'string')
320
+ ctx.caughtBindings.delete(letName);
313
321
  for (const line of emitLetPy(child, ctx))
314
322
  lines.push(`${indent}${line}`);
315
323
  }
@@ -322,6 +330,11 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
322
330
  lines.push(line);
323
331
  }
324
332
  else if (child.type === 'assign') {
333
+ // Same shadowing guard for a bare-identifier assignment target: after
334
+ // `assign target="e" …`, `e` is no longer the caught error.
335
+ const tgt = child.props?.target;
336
+ if (typeof tgt === 'string' && /^[A-Za-z_$][\w$]*$/.test(tgt))
337
+ ctx.caughtBindings.delete(tgt);
325
338
  for (const line of emitAssignPy(child, ctx))
326
339
  lines.push(`${indent}${line}`);
327
340
  }
@@ -492,7 +505,16 @@ function emitChildrenPy(children, ctx, indent, initialBindings = [], isLoopBody
492
505
  if (catchNode !== null) {
493
506
  const errName = String(catchNode.props?.name ?? 'e');
494
507
  lines.push(`${indent}except Exception as ${errName}:`);
508
+ // Error-substrate Slice 1 — mark `errName` as a caught binding for the
509
+ // duration of the catch body so a `<errName>.message` read lowers to
510
+ // `str(<errName>)` (see `lowerChain`'s member case). Restore the prior
511
+ // membership on exit so a SHADOWED outer catch binding of the same name
512
+ // is unaffected and the flag never leaks past the catch block.
513
+ const hadCaught = ctx.caughtBindings.has(errName);
514
+ ctx.caughtBindings.add(errName);
495
515
  const catchInner = emitChildrenPy(catchNode.children ?? [], ctx, indent + INDENT_STEP);
516
+ if (!hadCaught)
517
+ ctx.caughtBindings.delete(errName);
496
518
  if (catchInner.length === 0)
497
519
  lines.push(`${indent}${INDENT_STEP}pass`);
498
520
  for (const cl of catchInner)
@@ -1862,8 +1884,10 @@ function emitPyExprCtx(node, ctx) {
1862
1884
  const lowered = lowerChain(node, ctx);
1863
1885
  return wrapGuardIfAny(lowered, ctx);
1864
1886
  }
1865
- case 'await':
1866
- return `await ${emitPyExprCtx(node.argument, ctx)}`;
1887
+ case 'await': {
1888
+ const arg = emitPyExprCtx(node.argument, ctx);
1889
+ return `await ${needsLowPrecedenceOperandParens(node.argument) ? `(${arg})` : arg}`;
1890
+ }
1867
1891
  case 'new': {
1868
1892
  // Host Error mapping (spec §1): `new Error(args)` → `Exception(args)` on
1869
1893
  // Python, since `raise Error(...)` / `isinstance(x, Error)` would
@@ -1930,6 +1954,20 @@ function emitPyExprCtx(node, ctx) {
1930
1954
  return out;
1931
1955
  }
1932
1956
  case 'binary': {
1957
+ // DECIMAL Slice 2 (item 3) — fail closed on `+`/`-`/`*` over a syntactically-
1958
+ // proven Decimal operand (`Decimal.of(...)`/`Decimal.<m>(...)`), the SAME
1959
+ // decision the TS leg makes (shared `assertNoDecimalOperator` + message), so
1960
+ // the refusal is byte-identical across targets. Conservative: a no-op for plain
1961
+ // numeric arithmetic and for every non-`+`/`-`/`*` operator below.
1962
+ //
1963
+ // Slice-2 remediation (Finding 2): the assert is now performed AFTER the
1964
+ // operands are lowered (see below, after `emitPyExprCtx(node.left/right)`),
1965
+ // mirroring the TS leg's lower-then-assert order. A bad Decimal operand — an
1966
+ // unknown member (`Decimal.nope(...)`) or a non-canonical literal
1967
+ // (`Decimal.of("1.10")`) — then throws its OWN specific diagnostic during
1968
+ // lowering instead of being masked by the generic operator error. The blocked
1969
+ // operators are only `+`/`-`/`*`, so deferring the assert past the bitwise /
1970
+ // shift / modulo branches below is safe (they never trip the Decimal check).
1933
1971
  // Slice 6 — bitwise / shift on the slice-0.75 ToInt32 substrate. Emitted
1934
1972
  // DIRECTLY as helper-wrapped strings (operands recurse through
1935
1973
  // `emitPyExprCtx`), so nested bitwise ops compose without the double-
@@ -1958,6 +1996,11 @@ function emitPyExprCtx(node, ctx) {
1958
1996
  // expected `(a == b) < c` evaluation order.
1959
1997
  const left = emitPyExprCtx(node.left, ctx);
1960
1998
  const right = emitPyExprCtx(node.right, ctx);
1999
+ // DECIMAL Slice 2 (item 3) — operator fail-close, now AFTER operand lowering
2000
+ // (Finding-2 remediation) so a bad Decimal operand surfaces its own diagnostic
2001
+ // first. No-op for every operator except `+`/`-`/`*`, so it never affects the
2002
+ // bitwise/shift/modulo paths handled above or the comparison/logical paths below.
2003
+ assertNoDecimalOperator(node);
1961
2004
  if (node.op === 'instanceof') {
1962
2005
  // JS `a instanceof B` → Python `isinstance(a, B)`. Emitting `instanceof`
1963
2006
  // verbatim would be a Python *syntax* error, so this lowering is
@@ -2137,8 +2180,12 @@ function emitPyExprCtx(node, ctx) {
2137
2180
  }
2138
2181
  const forceLeft = needsComparisonChainParens(node.left, node.op);
2139
2182
  const forceRight = needsComparisonChainParens(node.right, node.op);
2140
- const lp = forceLeft || needsBinaryParens(node.left, node.op, 'left') ? `(${left})` : left;
2141
- const rp = forceRight || needsBinaryParens(node.right, node.op, 'right') ? `(${right})` : right;
2183
+ const lp = forceLeft || needsLowPrecedenceOperandParens(node.left) || needsBinaryParens(node.left, node.op, 'left')
2184
+ ? `(${left})`
2185
+ : left;
2186
+ const rp = forceRight || needsLowPrecedenceOperandParens(node.right) || needsBinaryParens(node.right, node.op, 'right')
2187
+ ? `(${right})`
2188
+ : right;
2142
2189
  const op = mapBinaryOpToPython(node.op);
2143
2190
  return `${lp} ${op} ${rp}`;
2144
2191
  }
@@ -2698,6 +2745,17 @@ function lowerOptionalLink(inner, objectNode, ctx) {
2698
2745
  function lowerChain(node, ctx) {
2699
2746
  if (node.kind === 'member') {
2700
2747
  const obj = node.object;
2748
+ // Error-substrate Slice 1 — a `<caughtBinding>.message` read lowers to
2749
+ // `str(<caughtBinding>)`. Python exceptions have NO `.message` attribute
2750
+ // (a bare `e.message` raises AttributeError), but `str(Exception("x"))`
2751
+ // === V8 `Error("x").message`, so this restores cross-target parity for the
2752
+ // ONE admitted error-binding read. Scoped exactly: a NON-optional `.message`
2753
+ // on a bare ident that is currently a caught binding. `e?.message`, a member
2754
+ // chain root (`e.cause.message`), or any non-caught ident is NOT rewritten
2755
+ // and emits verbatim — out of the parity domain (the runner abstains there).
2756
+ if (!node.optional && node.property === 'message' && obj.kind === 'ident' && ctx.caughtBindings.has(obj.name)) {
2757
+ return { guard: null, expr: `str(${emitPyExprCtx(obj, ctx)})` };
2758
+ }
2701
2759
  // Slice 2 — a bare property READ on a DIRECT regex LITERAL (`/x/.source`,
2702
2760
  // `/x/.flags`) launders the pattern/flags back into a string. Routed through
2703
2761
  // the SHARED classifier (via the ValueIR adapter) so this site agrees with
@@ -2864,6 +2922,13 @@ function lowerChain(node, ctx) {
2864
2922
  if (node.callee.kind === 'ident') {
2865
2923
  rejectHostRegExpValuePython(node.callee.name, ctx);
2866
2924
  }
2925
+ // DECIMAL Slice 1 — bare `Decimal(...)` (ident callee) fail-closes
2926
+ // SYMMETRICALLY with the TS leg. Only `Decimal.of`/`Decimal.add` (member
2927
+ // callees, handled by `applyStdlibLoweringPython` above) are portable. A
2928
+ // proven user binding named `Decimal` is left alone.
2929
+ if (node.callee.kind === 'ident' && node.callee.name === 'Decimal' && !isProvenUserBinding(ctx, 'Decimal')) {
2930
+ throw new Error(decimalBareConstructionFailMessage());
2931
+ }
2867
2932
  // Slice H — fail-closed on an UNMAPPED host-namespace member CALL. This runs
2868
2933
  // AFTER every explicit lowering hook above (stdlib, regex, lambda/array,
2869
2934
  // portable-array, super/String/Error) and BEFORE generic call emission, so a
@@ -2881,7 +2946,13 @@ function lowerChain(node, ctx) {
2881
2946
  const callee = node.callee;
2882
2947
  const inner = callee.kind === 'member' || callee.kind === 'call' || callee.kind === 'index'
2883
2948
  ? lowerChain(callee, ctx)
2884
- : { guard: null, expr: emitPyExprCtx(callee, ctx) };
2949
+ : {
2950
+ guard: null,
2951
+ expr: (() => {
2952
+ const emitted = emitPyExprCtx(callee, ctx);
2953
+ return needsLowPrecedenceOperandParens(callee) ? `(${emitted})` : emitted;
2954
+ })(),
2955
+ };
2885
2956
  const args = node.args.map((a) => emitPyExprCtx(a, ctx)).join(', ');
2886
2957
  return { guard: inner.guard, expr: `${inner.expr}(${args})`, lambdaBind: inner.lambdaBind };
2887
2958
  }
@@ -3549,6 +3620,15 @@ function wrapGuardIfAny(g, ctx) {
3549
3620
  function needsWalrusOperandParens(child) {
3550
3621
  return child.kind === 'conditional' || child.kind === 'lambda';
3551
3622
  }
3623
+ /** Low-precedence operand positions (`a <op> b`, `await x`, `<callee>(...)`)
3624
+ * must wrap a conditional child so the surrounding operator/call binds to the
3625
+ * whole operand instead of one ternary arm. */
3626
+ function needsLowPrecedenceOperandParens(child) {
3627
+ let node = child;
3628
+ while (node.kind === 'typeAssert' || node.kind === 'nonNull')
3629
+ node = node.expression;
3630
+ return node.kind === 'conditional';
3631
+ }
3552
3632
  /** S5 review fix — run `fn` with `ctx.banWalrus` set (save/restore), for
3553
3633
  * emitting an operand that will be interpolated into a comprehension/
3554
3634
  * generator ITERABLE position, where CPython rejects `:=` outright (see
@@ -3846,6 +3926,22 @@ function applyStdlibLoweringPython(call, ctx) {
3846
3926
  if (moduleName === 'Array' && methodName === 'from' && call.args.some((arg) => arg.kind === 'spread')) {
3847
3927
  throw new Error('Array.from portable lowering does not accept spread arguments; pass source and mapper directly.');
3848
3928
  }
3929
+ // DECIMAL Slice 1 — `Decimal.of(arg)` string-literal + canonical-scale check,
3930
+ // running the SAME shared-core validator the TS leg runs, so the fail-close is
3931
+ // byte-identical across targets.
3932
+ validateDecimalConstructionArg(moduleName, methodName, call);
3933
+ // DECIMAL Slice 3 — same shared compile-time pow fail-close the TS leg runs, so a
3934
+ // non-integer / non-literal exponent or a negative base is refused byte-identically.
3935
+ validateDecimalPowArgs(moduleName, methodName, call);
3936
+ // DECIMAL Slice 3 (robustness) — same shared non-Decimal-operand fail-close the TS
3937
+ // leg runs: a provably-non-Decimal LITERAL operand (a host number/string/…) passed
3938
+ // to any Decimal op but `of` is refused byte-identically, closing the silent
3939
+ // cross-target divergence a raw `0.1` operand would otherwise emit.
3940
+ validateDecimalOperands(moduleName, methodName, call);
3941
+ // DECIMAL Slice 3 — same shared compile-time zero-divisor fail-close: a literal
3942
+ // `Decimal.of("0")` divisor to `Decimal.div`/`Decimal.mod` is refused byte-
3943
+ // identically with the runtime guard's message (a dynamic zero stays runtime-caught).
3944
+ validateDecimalDivModArgs(moduleName, methodName, call);
3849
3945
  const listLambda = lowerListLambdaPython(moduleName, methodName, call, ctx);
3850
3946
  if (listLambda !== null)
3851
3947
  return listLambda;
@@ -3904,6 +4000,19 @@ function registerStdlibRequirementPython(requirement, ctx) {
3904
4000
  ctx.helpers.add(KERN_JS_NUMBER_HELPERS_PY);
3905
4001
  return;
3906
4002
  }
4003
+ // DECIMAL Slice 3 — `Decimal.div/mod/pow` register the guarded-ops helper block
4004
+ // (single-sourced in `decimal-contract.ts`) AND the `decimal` import (the helper
4005
+ // body references `_KernDecimal` for the `0**0 → 1` special-case). The block's
4006
+ // own `from decimal import Decimal as _KernDecimal` line supplies that binding;
4007
+ // we still register the `decimal` module import so the Decimal VALUES the helper
4008
+ // operates on (`__k_decimal.Decimal(...)` from their `Decimal.of` producers) keep
4009
+ // their existing import path — and so a div/mod/pow used WITHOUT a co-located
4010
+ // `Decimal.of` literal still imports decimal.
4011
+ if (requirement === 'decimal-ops') {
4012
+ ctx.helpers.add(KERN_DECIMAL_OPS_HELPER_PY);
4013
+ ctx.imports.add('decimal');
4014
+ return;
4015
+ }
3907
4016
  ctx.imports.add(requirement);
3908
4017
  }
3909
4018
  function lowerListLambdaPython(moduleName, methodName, call, ctx) {