Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/codegen-expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
33 changes: 28 additions & 5 deletions packages/python/src/codegen-body-python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -3904,6 +3918,15 @@ function needsWalrusOperandParens(child: ValueIR): boolean {
return child.kind === 'conditional' || child.kind === 'lambda';
}

/** Low-precedence operand positions (`a <op> b`, `await x`, `<callee>(...)`)
* 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
Expand Down
86 changes: 86 additions & 0 deletions packages/python/tests/ternary-precedence-codegen.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});