diff --git a/packages/core/src/codegen-expression.ts b/packages/core/src/codegen-expression.ts index 186ea5ca..02e6aa7f 100644 --- a/packages/core/src/codegen-expression.ts +++ b/packages/core/src/codegen-expression.ts @@ -639,7 +639,7 @@ export function needsBinaryParens(child: ValueIR, parentOp: string, side: 'left' } function needsParens(child: ValueIR, parentOp: string, side: 'left' | 'right'): boolean { - if (child.kind === 'typeAssert') return true; + if (child.kind === 'typeAssert' || child.kind === 'conditional') return true; return needsBinaryParens(child, parentOp, side); } diff --git a/packages/python/src/codegen-body-python.ts b/packages/python/src/codegen-body-python.ts index ffa4a0d4..5d5b4249 100644 --- a/packages/python/src/codegen-body-python.ts +++ b/packages/python/src/codegen-body-python.ts @@ -2112,8 +2112,10 @@ function emitPyExprCtx(node: ValueIR, ctx: BodyEmitContext): string { const lowered = lowerChain(node, ctx); return wrapGuardIfAny(lowered, ctx); } - case 'await': - return `await ${emitPyExprCtx(node.argument, ctx)}`; + case 'await': { + const arg = emitPyExprCtx(node.argument, ctx); + return `await ${needsLowPrecedenceOperandParens(node.argument) ? `(${arg})` : arg}`; + } case 'new': { // Host Error mapping (spec §1): `new Error(args)` → `Exception(args)` on // Python, since `raise Error(...)` / `isinstance(x, Error)` would @@ -2414,8 +2416,14 @@ function emitPyExprCtx(node: ValueIR, ctx: BodyEmitContext): string { const forceLeft = needsComparisonChainParens(node.left, node.op); const forceRight = needsComparisonChainParens(node.right, node.op); - const lp = forceLeft || needsBinaryParens(node.left, node.op, 'left') ? `(${left})` : left; - const rp = forceRight || needsBinaryParens(node.right, node.op, 'right') ? `(${right})` : right; + const lp = + forceLeft || needsLowPrecedenceOperandParens(node.left) || needsBinaryParens(node.left, node.op, 'left') + ? `(${left})` + : left; + const rp = + forceRight || needsLowPrecedenceOperandParens(node.right) || needsBinaryParens(node.right, node.op, 'right') + ? `(${right})` + : right; const op = mapBinaryOpToPython(node.op); return `${lp} ${op} ${rp}`; } @@ -3225,7 +3233,13 @@ function lowerChain(node: ChainNode, ctx: BodyEmitContext): GuardedExpr { const inner: GuardedExpr = callee.kind === 'member' || callee.kind === 'call' || callee.kind === 'index' ? lowerChain(callee, ctx) - : { guard: null, expr: emitPyExprCtx(callee, ctx) }; + : { + guard: null, + expr: (() => { + const emitted = emitPyExprCtx(callee, ctx); + return needsLowPrecedenceOperandParens(callee) ? `(${emitted})` : emitted; + })(), + }; const args = node.args.map((a) => emitPyExprCtx(a, ctx)).join(', '); return { guard: inner.guard, expr: `${inner.expr}(${args})`, lambdaBind: inner.lambdaBind }; } @@ -3904,6 +3918,15 @@ function needsWalrusOperandParens(child: ValueIR): boolean { return child.kind === 'conditional' || child.kind === 'lambda'; } +/** Low-precedence operand positions (`a b`, `await x`, `(...)`) + * must wrap a conditional child so the surrounding operator/call binds to the + * whole operand instead of one ternary arm. */ +function needsLowPrecedenceOperandParens(child: ValueIR): boolean { + let node = child; + while (node.kind === 'typeAssert' || node.kind === 'nonNull') node = node.expression; + return node.kind === 'conditional'; +} + /** S5 review fix — run `fn` with `ctx.banWalrus` set (save/restore), for * emitting an operand that will be interpolated into a comprehension/ * generator ITERABLE position, where CPython rejects `:=` outright (see diff --git a/packages/python/tests/ternary-precedence-codegen.test.ts b/packages/python/tests/ternary-precedence-codegen.test.ts new file mode 100644 index 00000000..0132fbf5 --- /dev/null +++ b/packages/python/tests/ternary-precedence-codegen.test.ts @@ -0,0 +1,86 @@ +/** TERNARY-PRECEDENCE codegen fix — the discriminating RED-at-base oracle. + * + * BUG (confirmed systemic, agon codex audit 2026-06-16 + verified empirically on both + * legs): KERN's expression emitter drops the parentheses a `conditional` operand needs + * when it sits under a higher-precedence operator. `?:` (TS) / `... if ... else ...` + * (Python) bind LOOSER than `*`/`await`/call, so an unwrapped conditional operand + * silently re-associates — `(true ? 2 : 3) * 5` compiles to `true ? 2 : 3 * 5` (= 2, + * not 10). It also breaks TS↔Python byte-parity (the Python call-callee + await cases + * emit unwrapped while TS wraps). + * + * ROOT CAUSE: ad-hoc parenthesization instead of one precedence-aware "subexpression + * operand" policy. TS emitter = `packages/core/src/codegen-expression.ts`; Python = + * `packages/python/src/codegen-body-python.ts`. The fix must wrap a `conditional` + * child in EVERY low-precedence operand position on BOTH legs — and must NOT + * over-wrap (the guards below pin already-correct output so a "wrap everything" cheat + * fails). + * + * Python note: `+` lowers to `__kern_add(...)` (call-delimited, inherently safe), so + * these fixtures use `*` (raw infix on BOTH legs) where the bug actually bites. */ + +import { emitExpression, parseExpression } from '@kernlang/core'; +import { emitPyExpression } from '../src/codegen-body-python.js'; + +const ts = (src: string): string => emitExpression(parseExpression(src)); +const py = (src: string): string => emitPyExpression(parseExpression(src)); + +// ── RED at base — a `conditional` operand under a tighter operator must be wrapped ── +describe('ternary-precedence — conditional operand is parenthesized (currently RED)', () => { + // #1 generic infix (binary `*`) — WRONG on BOTH legs at base. + test('(ternary) * x — left operand, both legs', () => { + expect(ts('(true ? 2 : 3) * 5')).toBe('(true ? 2 : 3) * 5'); + expect(py('(true ? 2 : 3) * 5')).toBe('(2 if _kern_truthy(True) else 3) * 5'); + }); + test('x * (ternary) — right operand, both legs', () => { + expect(ts('5 * (false ? 2 : 3)')).toBe('5 * (false ? 2 : 3)'); + expect(py('5 * (false ? 2 : 3)')).toBe('5 * (2 if _kern_truthy(False) else 3)'); + }); + // #2 call callee — WRONG on Python (TS already wraps → also a parity divergence). + test('(ternary)(x) — call callee, Python', () => { + expect(py('(ok ? f : g)(x)')).toBe('(f if _kern_truthy(ok) else g)(x)'); + }); + // #3 await operand — WRONG on Python. + test('await (ternary) — Python', () => { + expect(py('await (ok ? a() : b())')).toBe('await (a() if _kern_truthy(ok) else b())'); + }); +}); + +// ── GREEN guards — pin already-correct output so the fix can't OVER-wrap / regress ── +describe('ternary-precedence — guards (must stay byte-identical, no spurious parens)', () => { + test('TS call callee + await already wrap correctly — must not break', () => { + expect(ts('(ok ? f : g)(x)')).toBe('(ok ? f : g)(x)'); + expect(ts('await (ok ? a() : b())')).toBe('await (ok ? a() : b())'); + }); + test('member receiver of a ternary is wrapped on both legs — must stay', () => { + expect(ts('(a ? b : c).d')).toBe('(a ? b : c).d'); + expect(py('(a ? b : c).d')).toBe('(b if _kern_truthy(a) else c).d'); + }); + test('plain binary chains gain NO spurious parentheses', () => { + expect(ts('2 * 3 + 4')).toBe('2 * 3 + 4'); + expect(py('2 * 3 + 4')).toBe('__kern_add(2 * 3, 4)'); + expect(ts('2 * 5')).toBe('2 * 5'); + expect(py('a + b')).toBe('__kern_add(a, b)'); + }); +}); + +// ── TRANSPARENT-WRAPPER blindness (D1) — Python ERASES `as T` / `!`, so a wrapped +// conditional becomes a BARE conditional in a tight position. The operand predicate must +// peel transparent wrappers (typeAssert/nonNull) and test the INNER kind. TS keeps the +// wrapper (`(x as T)` self-parenthesizes) so TS is already correct — this is a Python-only +// fix AND a TS↔Python byte-divergence until closed. (The recurring wrapper-bypass class.) +describe('ternary-precedence — transparent-wrapped conditional operand (currently RED, Python)', () => { + test('(ternary as T) under a binary op — Python wraps the erased conditional', () => { + expect(py('((true ? 2 : 3) as any) * 5')).toBe('(2 if _kern_truthy(True) else 3) * 5'); + // TS guard: already correct (keeps the cast, wraps the whole operand) — must not regress. + expect(ts('((true ? 2 : 3) as any) * 5')).toBe('((true ? 2 : 3) as any) * 5'); + }); + test('(ternary as T) as a call callee — Python', () => { + expect(py('((ok ? f : g) as any)(x)')).toBe('(f if _kern_truthy(ok) else g)(x)'); + }); + test('await (ternary as T) — Python', () => { + expect(py('await ((ok ? a() : b()) as any)')).toBe('await (a() if _kern_truthy(ok) else b())'); + }); + test('member object of a wrapped ternary is already wrapped — must stay (guard)', () => { + expect(py('((a ? b : c) as any).d')).toBe('(b if _kern_truthy(a) else c).d'); + }); +});