diff --git a/examples/native-test/conformance-bad-cases.kern b/examples/native-test/conformance-bad-cases.kern index fa25261d..1364a024 100644 --- a/examples/native-test/conformance-bad-cases.kern +++ b/examples/native-test/conformance-bad-cases.kern @@ -88,6 +88,10 @@ class name=PlainSuper class name=ProtocolBase field name=id type=string + constructor + param name=id type=string + handler + assign target="this.id" value="id" method name=load returns=string param name=id type=string handler diff --git a/examples/native-test/conformance-bad-cases.test.kern b/examples/native-test/conformance-bad-cases.test.kern index ab8b8c51..4e1a67d2 100644 --- a/examples/native-test/conformance-bad-cases.test.kern +++ b/examples/native-test/conformance-bad-cases.test.kern @@ -22,7 +22,7 @@ test name="Bad KERN conformance" target="./conformance-bad-cases.kern" coverage= expect has=semanticViolations matches="declares more than one constructor" expect has=semanticViolations matches="conflicting instance member 'value'" expect has=semanticViolations matches="uses .*super.* does not extend a base class" - expect has=semanticViolations matches="constructor does not call .*super" + expect has=semanticViolations matches="omits .*super.* requires arguments" expect has=semanticViolations matches="member access before .*super" expect has=semanticViolations matches="calls .*super.* more than once" expect has=semanticViolations matches="must call .*super.* definitely on every path" diff --git a/packages/core/src/codegen/type-system.ts b/packages/core/src/codegen/type-system.ts index 318e4f64..20b4b5e1 100644 --- a/packages/core/src/codegen/type-system.ts +++ b/packages/core/src/codegen/type-system.ts @@ -5,6 +5,7 @@ */ import { emitExpression } from '../codegen-expression.js'; +import { hasDirectSuperCtorCall } from '../constructor-super.js'; import { propsOf } from '../node-props.js'; import { parseExpression } from '../parser-expression.js'; import { type IRNode, isExprObject } from '../types.js'; @@ -243,6 +244,17 @@ function emitSingletons(node: IRNode, lines: string[], className: string, exp: s } function emitClassBody(node: IRNode, lines: string[]): void { + // Abstract members — handler-less methods/getters/setters under an + // `abstract=true` class — emit a fail-fast `throw` body, identical to the + // Python `raise`, so an un-overridden abstract member fails the same way on + // both targets. The class-level `abstract` keyword stays (tsc still rejects + // `new X()`); only the member BODY is synthesized, since TS forbids a body on + // an `abstract` method. + const className = emitIdentifier(p(node).name as string | undefined, 'Unknown', node); + const isAbstractClass = p(node).abstract === 'true' || p(node).abstract === true; + const isHandlerless = (m: IRNode): boolean => firstChild(m, 'handler') === undefined; + const abstractThrow = (kind: string, memberName: string): string => + `throw new Error("abstract ${kind} ${className}.${memberName} not implemented");`; // Fields for (const field of kids(node, 'field')) { const fp = propsOf<'field'>(field); @@ -288,6 +300,19 @@ function emitClassBody(node: IRNode, lines: string[]): void { const ctorCode = classMemberBodyCode(ctorNode); lines.push(''); lines.push(` constructor${generics}(${ctorParams}) {`); + // KERN constructor semantic: a DERIVED constructor that omits a direct + // super(...) call gets an implicit no-arg super() injected FIRST, so + // `this`/field access is legal (JS forbids touching `this` before super in a + // derived ctor). Mirrors the Python side's injected base-init; class-field + // initializers then run after super per JS semantics. The author writes + // explicit `super(args)` only to pass args up. The inject decision uses the + // canonical structural predicate (shared with the validator, runtime, and + // Python target) rather than scanning emitted text — a `super(` inside a + // string literal or comment no longer suppresses injection. + const isDerived = typeof p(node).extends === 'string' && p(node).extends !== ''; + if (isDerived && !hasDirectSuperCtorCall(ctorNode)) { + lines.push(' super();'); + } if (ctorCode) { for (const line of ctorCode.split('\n')) { lines.push(` ${line}`); @@ -310,7 +335,7 @@ function emitClassBody(node: IRNode, lines: string[]): void { const staticKw = isStatic ? 'static ' : ''; const star = isStream || isGenerator ? '*' : ''; const asyncKw = isAsync || isStream ? 'async ' : ''; - const mcode = methodBodyCode(method); + const mcode = isAbstractClass && isHandlerless(method) ? abstractThrow('method', mname) : methodBodyCode(method); // stream=true → AsyncGenerator, generator=true → Generator/AsyncGenerator // If user already declared full Generator<...>/AsyncGenerator<...>, use as-is @@ -345,7 +370,8 @@ function emitClassBody(node: IRNode, lines: string[]): void { const gvis = gp.private === 'true' || gp.private === true ? 'private ' : ''; const gstatic = gp.static === 'true' || gp.static === true ? 'static ' : ''; const greturns = gp.returns ? `: ${emitTypeAnnotation(gp.returns, 'unknown', getter)}` : ''; - const gcode = classMemberBodyCode(getter); + const gcode = + isAbstractClass && isHandlerless(getter) ? abstractThrow('getter', gname) : classMemberBodyCode(getter); lines.push(''); lines.push(` ${gvis}${gstatic}get ${gname}()${greturns} {`); if (gcode) { @@ -363,7 +389,8 @@ function emitClassBody(node: IRNode, lines: string[]): void { const svis = sp.private === 'true' || sp.private === true ? 'private ' : ''; const sstatic = sp.static === 'true' || sp.static === true ? 'static ' : ''; const sparams = emitParamList(setter, { fallback: 'value: unknown' }); - const scode = classMemberBodyCode(setter); + const scode = + isAbstractClass && isHandlerless(setter) ? abstractThrow('setter', sname) : classMemberBodyCode(setter); lines.push(''); lines.push(` ${svis}${sstatic}set ${sname}(${sparams}) {`); if (scode) { diff --git a/packages/core/src/constructor-super.ts b/packages/core/src/constructor-super.ts new file mode 100644 index 00000000..d7705c17 --- /dev/null +++ b/packages/core/src/constructor-super.ts @@ -0,0 +1,120 @@ +/** + * Canonical constructor-super analysis — the SINGLE source of truth for the one + * question every KERN layer must answer the same way: does a constructor contain + * a direct `super(...)` constructor call? + * + * KERN's constructor semantic (Option C): a derived constructor MAY omit + * `super(...)`. When it does, KERN implicitly initializes the base first; when it + * writes an explicit `super(...)`, the author owns its placement and the strict + * discipline (no double/conditional super, no `this` before super) applies. The + * fork between those two modes is decided by exactly this predicate, and it MUST + * be decided identically by the semantic validator, the in-process core runtime, + * and BOTH codegen targets (TS + Python) — otherwise a program is legal in one + * layer and rejected/divergent in another (the precise bug this module exists to + * prevent). Previously each layer answered it differently: the validator walked + * the IR, while both codegens scanned EMITTED text (`/\bsuper\s*\(/` / + * `"super().__init__"`), which false-matched `super(` inside string literals and + * comments. One structural predicate, consumed everywhere, removes that drift. + * + * "Direct" mirrors the validator's long-standing rule precisely: + * - a `super(...)` call where the callee is the bare `super` identifier counts; + * - `super.method()` (a super MEMBER call) does NOT — it never initializes base; + * - a `super(...)` inside a lambda/arrow body does NOT — it never runs at + * construction time; + * - calls inside `if`/`else` branches DO count (the call is structurally present; + * whether it runs on every path is a separate discipline concern). + */ + +import { parseExpression } from './parser-expression.js'; +import type { IRNode } from './types.js'; + +// Props on a body statement whose value is an expression we must scan. Kept in +// sync with the validator's BODY_EXPRESSION_PROPS — a `super(...)` can appear in +// a `do value=...`, a `return value=...`, an `if cond=...`, etc. +const SUPER_SCAN_PROPS = [ + 'value', + 'expr', + 'target', + 'cond', + 'on', + 'in', + 'from', + 'to', + 'initial', + 'source', + 'sources', + 'cleanup', + 'min', + 'max', +] as const; + +/** True when `value` is the parser's wrapped-expression object `{__expr:true, code}`. */ +function expressionCode(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if ( + typeof value === 'object' && + value !== null && + (value as { readonly __expr?: unknown }).__expr === true && + typeof (value as { readonly code?: unknown }).code === 'string' + ) { + return (value as { readonly code: string }).code; + } + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +} + +/** + * Structural recursion over a parsed expression looking for a direct `super(...)` + * constructor call. Equivalent to the validator's `valueIRCallsSuperConstructor` + * (super-ident callee => yes; lambda => stop, never descend; else recurse), but + * self-contained so this module depends only on the parser + node types. + */ +function valueContainsSuperCtorCall(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const node = value as { kind?: string; callee?: { kind?: string; name?: string } }; + // A lambda body that calls super never runs during construction — do not descend. + if (node.kind === 'lambda') return false; + if (node.kind === 'call' && node.callee?.kind === 'ident' && node.callee.name === 'super') { + return true; + } + for (const child of Object.values(value as Record)) { + if (Array.isArray(child)) { + if (child.some(valueContainsSuperCtorCall)) return true; + } else if (child && typeof child === 'object') { + if (valueContainsSuperCtorCall(child)) return true; + } + } + return false; +} + +/** The constructor's executable statements (handler body, minus params/decorators). */ +function constructorBodyStatements(ctor: IRNode): readonly IRNode[] { + const handler = ctor.children?.find((child) => child.type === 'handler'); + const body = handler ? (handler.children ?? []) : (ctor.children ?? []); + return body.filter((child) => child.type !== 'param' && child.type !== 'decorator'); +} + +/** Walk a statement subtree, stopping at a nested `class` (its super belongs to it). */ +function statementContainsSuperCtorCall(node: IRNode, isRoot: boolean): boolean { + if (!isRoot && node.type === 'class') return false; + for (const prop of SUPER_SCAN_PROPS) { + const code = expressionCode(node.props?.[prop]); + if (code === undefined) continue; + try { + if (valueContainsSuperCtorCall(parseExpression(code))) return true; + } catch { + // Unparseable expression text can't be a structural super call — ignore. + } + } + return (node.children ?? []).some((child) => statementContainsSuperCtorCall(child, false)); +} + +/** + * Does this constructor contain a direct `super(...)` constructor call anywhere + * in its body (including inside `if`/`else` branches, but not inside lambdas or + * nested classes)? `true` => explicit-super mode (author owns placement, strict + * discipline applies). `false` => implicit-super mode (KERN injects base init). + */ +export function hasDirectSuperCtorCall(ctor: IRNode): boolean { + return constructorBodyStatements(ctor).some((stmt) => statementContainsSuperCtorCall(stmt, true)); +} diff --git a/packages/core/src/core-runtime/index.ts b/packages/core/src/core-runtime/index.ts index 8344936c..6f5a0347 100644 --- a/packages/core/src/core-runtime/index.ts +++ b/packages/core/src/core-runtime/index.ts @@ -1,3 +1,4 @@ +import { hasDirectSuperCtorCall } from '../constructor-super.js'; import { CORE_TYPE_CONTRACTS, CoreContractEvaluationError, @@ -1187,8 +1188,25 @@ function initializeClassLayer( instance.initializedClasses.add(klass.name); return; } + if (base && !hasDirectSuperCtorCall(ctor)) { + // Implicit-super mode (KERN Option C): a derived constructor that omits a + // direct super(...) gets base init injected FIRST, then its own field + // defaults, then its body — identical to what both codegen targets emit, so + // the interpreter and generated TS/Python agree. The frame starts with + // superCalled=true so this/super access inside the body is unguarded; an + // unexpected late super(...) would still trip the double-init guard. The same + // `hasDirectSuperCtorCall` predicate decides this mode in the validator and + // both codegens, so all four layers classify the constructor identically. + initializeClassLayer(instance, base, [], false); + initializeClassFields(instance, klass); + withConstructionFrame(instance, klass, true, () => { + callClassMemberBody(ctor, klass, instance, receivesConstructorArgs ? args : []).value; + }); + instance.initializedClasses.add(klass.name); + return; + } if (base) { - withConstructionFrame(instance, klass, () => { + withConstructionFrame(instance, klass, false, () => { callClassMemberBody(ctor, klass, instance, receivesConstructorArgs ? args : []).value; }); } else { @@ -1432,9 +1450,14 @@ function callSuperConstructor(value: KernSuperValue, args: readonly KernValue[]) return value.receiver; } -function withConstructionFrame(instance: KernInstanceValue, ownerClass: KernClassValue, run: () => void): void { +function withConstructionFrame( + instance: KernInstanceValue, + ownerClass: KernClassValue, + initialSuperCalled: boolean, + run: () => void, +): void { const stack = ACTIVE_CONSTRUCTORS.get(instance) ?? []; - const frame: RuntimeConstructionFrame = { ownerClass, superCalled: false }; + const frame: RuntimeConstructionFrame = { ownerClass, superCalled: initialSuperCalled }; stack.push(frame); ACTIVE_CONSTRUCTORS.set(instance, stack); try { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a83c3795..e76e7847 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -140,6 +140,9 @@ export { VALID_STRUCTURES, VALID_TARGETS, } from './config.js'; +// Canonical constructor-super predicate — single source of truth shared by the +// validator, runtime, and both codegen targets (TS here + Python via this export). +export { hasDirectSuperCtorCall } from './constructor-super.js'; export type { CoreFixture, CoreFixtureError, diff --git a/packages/core/src/semantic-validator.ts b/packages/core/src/semantic-validator.ts index 53c8669f..ea40ee5b 100644 --- a/packages/core/src/semantic-validator.ts +++ b/packages/core/src/semantic-validator.ts @@ -15,6 +15,7 @@ * symbols that the resolver proved exist. */ +import { hasDirectSuperCtorCall } from './constructor-super.js'; import { type CoreShapeDiagnostic, type CoreShapeInterfaceFact, @@ -2820,12 +2821,14 @@ function validateClassGraphRoots(roots: readonly IRNode[], violations: SemanticV ); validateClassConstructors(info, violations); validateClassMemberConflicts(info, violations); - validateClassSuperUsage(info, violations); + validateClassSuperUsage(info, classByName, violations); + validateClassAbstractMembers(info, classByName, violations); } validateClassInheritanceCycles(classes, classByName, violations); validateClassOverrides(classes, classByName, violations); validateClassShapeUsage(classes, classByName, violations); + validateAbstractInstantiations(roots, classByName, visibleNamesByRoot, violations); } function collectClassInfos(root: IRNode, rootIndex = 0): ClassInfo[] { @@ -3354,6 +3357,215 @@ function classInfoParticipatesInCycle(info: ClassInfo, classByName: ReadonlyMap< return false; } +// ── Abstract-class contract enforcement ────────────────────────────────────── +// KERN owns its abstract contract at the VALIDATOR layer (codegen/runtime stay +// the loud backstop): a concrete class must implement every abstract member it +// inherits, and an abstract class may never be instantiated. The validator runs +// before codegen, so enforcement is parity-free — TS and Python reject the same +// programs by construction. +// +// PR3 convention: an "abstract member" is a handler-less method/getter/setter +// declared under an `abstract=true` class. Fields always carry a value, so they +// are never abstract. + +function isAbstractClassNode(node: IRNode): boolean { + const raw = node.props?.abstract; + return raw === true || raw === 'true'; +} + +function memberHasHandler(node: IRNode): boolean { + return (node.children ?? []).some((child) => child.type === 'handler'); +} + +interface AbstractObligation { + readonly name: string; + readonly kind: ClassMemberKind; + readonly static: boolean; + // The nearest abstract ancestor that left this member unimplemented. + readonly declaredIn: string; +} + +function abstractObligationKey(member: { + readonly static: boolean; + readonly name: string; + readonly kind: ClassMemberKind; +}): string { + return `${member.static ? 'static' : 'instance'}:${member.name}:${member.kind}`; +} + +// Walk the lineage base→derived and return the abstract members still owed by +// `info`. Keyed by (static, name, kind) so a getter override never clears the +// sibling setter obligation, and a same-name different-kind member never erases +// an inherited abstract member (the exact soundness hole that drove this off +// `effectiveClassMemberFacts`, which collapses members by name+static only). +function collectAbstractObligations( + info: ClassInfo, + classByName: ReadonlyMap, + seen: ReadonlySet = new Set(), +): AbstractObligation[] { + // Inheritance cycles carry their own primary diagnostic; do not also walk a + // cyclic chain here (it would never terminate cleanly nor add signal). + if (seen.has(info.name) || classInfoParticipatesInCycle(info, classByName)) return []; + const nextSeen = new Set(seen); + nextSeen.add(info.name); + const obligations = new Map(); + const base = info.baseName ? classByName.get(info.baseName) : undefined; + if (base) { + for (const obligation of collectAbstractObligations(base, classByName, nextSeen)) { + obligations.set(abstractObligationKey(obligation), obligation); + } + } + const ownIsAbstract = isAbstractClassNode(info.node); + for (const member of info.members) { + if (member.kind === 'field') continue; // fields are never abstract + const key = abstractObligationKey(member); + if (memberHasHandler(member.node)) { + // A concrete definition for this exact (static,name,kind) satisfies the + // obligation — same-kind only. + obligations.delete(key); + } else if (ownIsAbstract) { + // Handler-less member under an abstract owner declares an obligation. + obligations.set(key, { + name: member.name, + kind: member.kind, + static: member.static, + declaredIn: info.name, + }); + } + // A handler-less member under a CONCRETE owner neither satisfies nor + // declares: any inherited obligation stands and is flagged below. + } + return [...obligations.values()].sort((a, b) => abstractObligationKey(a).localeCompare(abstractObligationKey(b))); +} + +function validateClassAbstractMembers( + info: ClassInfo, + classByName: ReadonlyMap, + violations: SemanticViolation[], +): void { + // Abstract classes are allowed to carry (and inherit) abstract members. + if (isAbstractClassNode(info.node)) return; + for (const obligation of collectAbstractObligations(info, classByName)) { + violations.push({ + rule: 'class-abstract-member-unimplemented', + nodeType: info.node.type, + message: `Concrete class '${info.name}' must implement abstract ${obligation.kind} '${obligation.name}' inherited from '${obligation.declaredIn}'.`, + line: info.node.loc?.line, + col: info.node.loc?.col, + }); + } +} + +// Resolve the class a `new` expression constructs. KERN parses `new` greedily +// (the argument is a full postfix chain), and codegen prefixes `new ` to the +// emitted chain, so KERN follows JS `new` precedence: +// new Shape -> Shape +// new Shape() -> Shape +// new Shape().area() -> Shape (new binds to Shape(); `.area()` is after) +// new pkg.Shape() -> pkg.Shape (qualified) -> not a bare local class, skip +// new makeShape()() -> makeShape (head ident; not a class -> skipped on lookup) +// We descend the spine to the head ident and skip qualified constructors (a head +// reached as a member's object, e.g. `pkg.Shape`). +function newExpressionClassName(argument: ValueIR): string | undefined { + let node: ValueIR = argument; + let edge: 'root' | 'callee' | 'object' = 'root'; + while (true) { + switch (node.kind) { + case 'ident': + // A member-object head (`pkg.Shape`) is a qualified constructor; every + // other head (root, or a called ident) is a bare construction target. + return edge === 'object' ? undefined : node.name; + case 'call': + node = node.callee; + edge = 'callee'; + continue; + case 'member': + node = node.object; + edge = 'object'; + continue; + case 'index': + node = node.object; + edge = 'object'; + continue; + case 'nonNull': + node = node.expression; + continue; + default: + return undefined; // dynamic / non-resolvable constructor + } + } +} + +// `default` is an executable initializer site (field `default=` and +// `param default=`) that is NOT in BODY_EXPRESSION_PROPS — field initializers +// treat `value` and `default` equivalently and both lower to runtime code, so a +// `new Abstract()` in a default must be checked too. Scanned local to this pass +// so the shared super-detection / shape-usage walks are unaffected. A non-`new` +// default just parses to a harmless expression that matches nothing. +const INSTANTIATION_EXPRESSION_PROPS: readonly string[] = [...BODY_EXPRESSION_PROPS, 'default']; + +// Module-wide pass: reject `new (...)` anywhere — including inside +// the abstract class's own static factory (KERN matches TS: abstract is not +// self-instantiable). Conservative by design — non-ident callees, names not +// resolving to a visible local class, and (consistent with every other +// class-name resolution in this validator) names rebound by a local binding are +// not pursued; the validator does not track lexical shadowing for any class +// reference, so abstract instantiation follows the same name+visibility rule. +// Multi-root note: visibleNamesByRoot unions every root's declared class names +// (as extends/implements resolution already does), so this resolves classes +// across roots; all production callers validate a single root, so the +// cross-root union is not a false-positive surface in practice. +function validateAbstractInstantiations( + roots: readonly IRNode[], + classByName: ReadonlyMap, + visibleNamesByRoot: readonly ReadonlySet[], + violations: SemanticViolation[], +): void { + roots.forEach((root, rootIndex) => { + const visible = visibleNamesByRoot[rootIndex]; + walkSemanticTree(root, (node) => { + for (const prop of INSTANTIATION_EXPRESSION_PROPS) { + const text = expressionPropText(node.props?.[prop]); + if (!text) continue; + let value: ValueIR; + try { + value = parseExpression(text); + } catch { + continue; + } + collectAbstractInstantiations(value, node, visible, classByName, violations); + } + }); + }); +} + +function collectAbstractInstantiations( + value: ValueIR, + node: IRNode, + visible: ReadonlySet | undefined, + classByName: ReadonlyMap, + violations: SemanticViolation[], +): void { + if (value.kind === 'new') { + const name = newExpressionClassName(value.argument); + if (name && (!visible || visible.has(name))) { + const target = classByName.get(name); + if (target && isAbstractClassNode(target.node)) { + violations.push({ + rule: 'class-abstract-instantiation', + nodeType: node.type, + message: `Cannot instantiate abstract class '${name}'.`, + line: node.loc?.line, + col: node.loc?.col, + }); + } + } + } + for (const child of valueIRChildren(value)) { + collectAbstractInstantiations(child, node, visible, classByName, violations); + } +} + function collectClassOverrideFacts( classes: readonly ClassInfo[], classByName: ReadonlyMap, @@ -3817,11 +4029,16 @@ function validateClassMemberConflicts(info: ClassInfo, violations: SemanticViola } } -function validateClassSuperUsage(info: ClassInfo, violations: SemanticViolation[]): void { +function validateClassSuperUsage( + info: ClassInfo, + classByName: ReadonlyMap, + violations: SemanticViolation[], +): void { const hasBase = Boolean(info.baseName); + const argRequiringBaseName = hasBase ? argRequiringEffectiveBaseName(info, classByName) : undefined; for (const ctor of info.constructors) { if (hasBase) { - validateDerivedConstructorDiscipline(info, ctor, violations); + validateDerivedConstructorSuper(info, ctor, argRequiringBaseName, violations); } if (!hasBase && nodeBodyUsesSuper(ctor)) { violations.push({ @@ -3862,6 +4079,12 @@ interface ConstructorAnalysis { sawSuper: boolean; } +// DESCRIPTIVE analyzer — feeds the `superStatus` substrate fact (via +// `constructorSuperDiagnostics`), NOT user-facing violations. It still classifies +// an omitted super as `missing` and a pre-super `this` access as `this-before-super` +// so the FACT keeps describing the constructor's structure faithfully. The +// user-facing legality judgment lives in `validateDerivedConstructorSuper`, which +// applies KERN's Option-C semantics on top of this description. function validateDerivedConstructorDiscipline(info: ClassInfo, ctor: IRNode, violations: SemanticViolation[]): void { const ctx: ConstructorDisciplineContext = { info, @@ -3885,6 +4108,97 @@ function validateDerivedConstructorDiscipline(info: ClassInfo, ctor: IRNode, vio } } +/** + * User-facing derived-constructor validation under KERN's Option-C super + * semantics. The mode is decided by the canonical `hasDirectSuperCtorCall` + * predicate — shared verbatim with the runtime and both codegen targets so all + * four layers agree on whether a constructor opted into explicit-super mode: + * + * - No direct `super(...)` call (implicit mode): KERN injects base init at + * constructor entry, so omitting super is LEGAL and `this`/super-member access + * is always safe. The only error is when the base constructor REQUIRES + * arguments — an arg-less implicit super cannot satisfy it. + * - A direct `super(...)` call exists (explicit mode): the author owns its + * placement, so the full discipline applies — reject double-super, + * conditional-super (not on every path), and `this`/super before super. + * + * `class-constructor-missing-super` is intentionally unreachable here: an omitted + * super is no longer an error, and an explicit super means a direct call exists. + */ +function validateDerivedConstructorSuper( + info: ClassInfo, + ctor: IRNode, + argRequiringBaseName: string | undefined, + violations: SemanticViolation[], +): void { + if (!hasDirectSuperCtorCall(ctor)) { + if (argRequiringBaseName) { + // Name the class whose constructor actually requires args — which may be a + // transitive ancestor reached through constructor-less bases, not the + // immediate base — so the diagnostic points the author at the real source. + violations.push({ + rule: 'class-constructor-implicit-super-needs-args', + nodeType: 'constructor', + message: `Class '${info.name}' omits \`super(...)\` but base class '${argRequiringBaseName}' has a constructor that requires arguments. Call \`super(...)\` explicitly to pass them.`, + line: ctor.loc?.line, + col: ctor.loc?.col, + }); + } + return; + } + // Explicit-super mode: replay the discipline analysis. Its walk emits + // double-super / this-before-super as side effects; the tail covers "super + // present but not on every path" (conditional-super). + const ctx: ConstructorDisciplineContext = { + info, + violations, + sawSuper: false, + emittedConditionalSuper: false, + }; + const analysis = analyzeConstructorStatements(constructorBodyStatements(ctor), 'uninit', ctx); + if (analysis.state !== 'init') emitConstructorConditionalSuper(ctx, ctor); +} + +/** + * The name of the EFFECTIVE base class whose constructor an implicit no-arg + * `super()` would reach and fail to satisfy — i.e. the first ancestor that + * declares a constructor with a required (no-default) parameter — or `undefined` + * when implicit init succeeds. The effective base ctor is found by walking up the + * inheritance chain through constructor-less bases, exactly as the runtime does: + * `initializeClassLayer` forwards `[]` through a base that has no constructor + * (`base && !ctor`) to ITS base, so the first ancestor that actually declares a + * constructor is the one invoked with no args. Checking only the immediate base + * would let `C extends B extends A` (B ctor-less, A arg-requiring) pass validation + * yet throw at runtime — re-creating the validator/runtime split this + * reconciliation closes. Returning the name (not a bool) lets the diagnostic point + * at the real source rather than the immediate base. Mirrors the runtime's + * required-arg rule (a param is required unless it carries a `value`/`default`); a + * chain with no constructor anywhere (or an unresolved base) needs no args. + */ +function argRequiringEffectiveBaseName( + info: ClassInfo, + classByName: ReadonlyMap, +): string | undefined { + const seen = new Set(); + let current = info.baseName ? classByName.get(info.baseName) : undefined; + while (current && !seen.has(current.name)) { + seen.add(current.name); + const ctor = current.constructors[0]; + if (ctor) { + const requiresArgs = (ctor.children ?? []).some( + (child) => + child.type === 'param' && + !Object.hasOwn(child.props ?? {}, 'value') && + !Object.hasOwn(child.props ?? {}, 'default'), + ); + return requiresArgs ? current.name : undefined; + } + // Constructor-less base: the runtime forwards [] to its base — keep walking. + current = current.baseName ? classByName.get(current.baseName) : undefined; + } + return undefined; +} + function analyzeConstructorStatements( statements: readonly IRNode[], initialState: ConstructorSuperState, diff --git a/packages/core/tests/__snapshots__/golden-codegen.test.ts.snap b/packages/core/tests/__snapshots__/golden-codegen.test.ts.snap index 0ed7790c..65361ac5 100644 --- a/packages/core/tests/__snapshots__/golden-codegen.test.ts.snap +++ b/packages/core/tests/__snapshots__/golden-codegen.test.ts.snap @@ -57,6 +57,7 @@ exports[`golden: class abstract class 1`] = ` private area: number; render(): void { + throw new Error("abstract method Shape.render not implemented"); } }" `; diff --git a/packages/core/tests/class-semantics.test.ts b/packages/core/tests/class-semantics.test.ts index 81e27cce..8fa6fd13 100644 --- a/packages/core/tests/class-semantics.test.ts +++ b/packages/core/tests/class-semantics.test.ts @@ -543,8 +543,11 @@ describe('semantic-validator — class object model', () => { expect(violations.map((violation) => violation.rule)).toContain('class-member-conflict'); }); - test('reports derived constructors that omit super', () => { - const violations = violationsFor( + test('accepts a derived constructor that omits super (implicit base init)', () => { + // KERN Option C: a derived constructor may omit super(); KERN injects an + // implicit no-arg base init at entry, so omitting it is legal when the base + // constructor needs no arguments. No missing-super / needs-args violation. + const rules = rulesFor( [ 'class name=Entity', 'class name=User extends=Entity', @@ -554,11 +557,15 @@ describe('semantic-validator — class object model', () => { ].join('\n'), ); - expect(violations.map((violation) => violation.rule)).toContain('class-constructor-missing-super'); + expect(rules).not.toContain('class-constructor-missing-super'); + expect(rules).not.toContain('class-constructor-implicit-super-needs-args'); }); - test('does not accept delayed super calls inside constructor lambdas', () => { - const violations = violationsFor( + test('treats a lambda-only super as no effective super (implicit base init)', () => { + // A super() that only appears inside a lambda never runs at construction, so + // it is not an effective super call. Under Option C the constructor falls + // into implicit mode and is legal (base needs no args) — no missing-super. + const rules = rulesFor( [ 'class name=Entity', 'class name=User extends=Entity', @@ -568,10 +575,110 @@ describe('semantic-validator — class object model', () => { ].join('\n'), ); - expect(violations.map((violation) => violation.rule)).toContain('class-constructor-missing-super'); + expect(rules).not.toContain('class-constructor-missing-super'); }); - test('reports this and super member access before constructor super', () => { + test('flags an omitted super when the base constructor requires arguments', () => { + // Implicit no-arg super() cannot satisfy a base whose constructor needs `id`, + // so KERN raises the arity-specific diagnostic (NOT the retired missing-super). + const rules = rulesFor( + [ + 'class name=Entity', + ' field name=id type=string', + ' constructor', + ' param name=id type=string', + ' handler lang=kern', + ' assign target="this.id" value="id"', + 'class name=User extends=Entity', + ' field name=label type=string', + ' constructor', + ' param name=label type=string', + ' handler lang=kern', + ' assign target="this.label" value="label"', + ].join('\n'), + ); + + expect(rules).toContain('class-constructor-implicit-super-needs-args'); + expect(rules).not.toContain('class-constructor-missing-super'); + }); + + test('accepts an explicit super(args) when the base constructor requires arguments', () => { + const rules = rulesFor( + [ + 'class name=Entity', + ' field name=id type=string', + ' constructor', + ' param name=id type=string', + ' handler lang=kern', + ' assign target="this.id" value="id"', + 'class name=User extends=Entity', + ' constructor', + ' param name=id type=string', + ' handler lang=kern', + ' do value="super(id)"', + ].join('\n'), + ); + + expect(rules).not.toContain('class-constructor-implicit-super-needs-args'); + expect(rules).not.toContain('class-constructor-missing-super'); + }); + + test('flags an omitted super when an arg-requiring base is reached transitively through a ctor-less base', () => { + // C extends B extends A: B has no constructor, so an implicit super() in C + // forwards [] through B to A — which requires `id`. The validator must walk + // through the constructor-less B to A (matching the runtime), or it would pass + // here while the runtime throws, re-opening the split this reconciliation closes. + const violations = violationsFor( + [ + 'class name=A', + ' field name=id type=string', + ' constructor', + ' param name=id type=string', + ' handler lang=kern', + ' assign target="this.id" value="id"', + 'class name=B extends=A', + 'class name=C extends=B', + ' field name=label type=string', + ' constructor', + ' param name=label type=string', + ' handler lang=kern', + ' assign target="this.label" value="label"', + ].join('\n'), + ); + + const needsArgs = violations.find((v) => v.rule === 'class-constructor-implicit-super-needs-args'); + expect(needsArgs).toBeDefined(); + // The message must name the class that actually has the arg-requiring ctor (A), + // not the immediate constructor-less base (B). + expect(needsArgs?.message).toContain("base class 'A'"); + expect(needsArgs?.message).not.toContain("base class 'B'"); + }); + + test('accepts an omitted super when the transitively-reached base needs no args', () => { + // Same ctor-less intermediate, but the effective base A takes no required args, + // so an implicit no-arg super() succeeds end-to-end — no diagnostic. + const rules = rulesFor( + [ + 'class name=A', + ' field name=tag type=string value="base"', + 'class name=B extends=A', + 'class name=C extends=B', + ' field name=label type=string', + ' constructor', + ' param name=label type=string', + ' handler lang=kern', + ' assign target="this.label" value="label"', + ].join('\n'), + ); + + expect(rules).not.toContain('class-constructor-implicit-super-needs-args'); + }); + + test('reports this access before an explicit super, but allows super.member in implicit mode', () => { + // User writes an explicit super() AFTER touching `this` -> this-before-super. + // Admin only reads super.kind() (a super MEMBER call, not a super constructor + // call), so it has no explicit super and runs in implicit mode, where base + // init happens at entry and super.kind() is legal. Only User is flagged. const rules = rulesFor( [ 'class name=Entity', @@ -590,7 +697,7 @@ describe('semantic-validator — class object model', () => { ].join('\n'), ); - expect(rules.filter((rule) => rule === 'class-constructor-this-before-super')).toHaveLength(2); + expect(rules.filter((rule) => rule === 'class-constructor-this-before-super')).toHaveLength(1); }); test('reports double constructor super calls', () => { @@ -851,3 +958,194 @@ describe('semantic-validator — class object model', () => { expect(rules).toContain('class-inheritance-cycle'); }); }); + +describe('semantic-validator — abstract-class contract', () => { + // ── class-abstract-instantiation: `new ()` is rejected ────── + test('rejects instantiating an abstract class directly', () => { + const violations = violationsFor( + [ + 'class name=Shape abstract=true', + ' method name=area returns=number', + 'fn name=probe returns=number', + ' handler lang=kern', + ' return value="new Shape().area()"', + ].join('\n'), + ); + const violation = violations.find((candidate) => candidate.rule === 'class-abstract-instantiation'); + expect(violation?.message).toContain("Cannot instantiate abstract class 'Shape'"); + }); + + test('accepts instantiating a concrete subclass that overrides the abstract member', () => { + const rules = rulesFor( + [ + 'class name=Shape abstract=true', + ' method name=area returns=number', + 'class name=Square extends=Shape', + ' method name=area returns=number', + ' handler lang=kern', + ' return value=9', + 'fn name=probe returns=number', + ' handler lang=kern', + ' return value="new Square().area()"', + ].join('\n'), + ); + expect(rules).not.toContain('class-abstract-instantiation'); + expect(rules).not.toContain('class-abstract-member-unimplemented'); + }); + + test('rejects abstract instantiation even inside the abstract class own static factory', () => { + // KERN matches TS: an abstract class is not self-instantiable, anywhere. + const rules = rulesFor( + [ + 'class name=Shape abstract=true', + ' method name=area returns=number', + ' method name=make returns=Shape static=true', + ' handler lang=kern', + ' return value="new Shape()"', + ].join('\n'), + ); + expect(rules).toContain('class-abstract-instantiation'); + }); + + test('rejects abstract instantiation in a field default= initializer (not just value={{}})', () => { + // Review (codex/kimi/agy): `default=` is an executable initializer site like + // `value`, but is not in BODY_EXPRESSION_PROPS, so it must be scanned too. + const fieldDefault = rulesFor( + [ + 'class name=Shape abstract=true', + ' method name=area returns=number', + 'class name=Holder', + ' field name=s type=Shape default="new Shape()"', + ].join('\n'), + ); + expect(fieldDefault).toContain('class-abstract-instantiation'); + }); + + test('does not flag new of a concrete class or an unresolved identifier', () => { + const rules = rulesFor( + [ + 'class name=Widget', + ' method name=run returns=number', + ' handler lang=kern', + ' return value=1', + 'fn name=probe returns=number', + ' handler lang=kern', + ' return value="new Widget().run() + new Unknown().x"', + ].join('\n'), + ); + expect(rules).not.toContain('class-abstract-instantiation'); + }); + + // ── class-abstract-member-unimplemented: concrete must override ──────────── + test('rejects a concrete subclass that leaves an inherited abstract member unimplemented', () => { + const violations = violationsFor( + [ + 'class name=Shape abstract=true', + ' method name=area returns=number', + 'class name=Square extends=Shape', + ' field name=side type=number value={{ 3 }}', + ].join('\n'), + ); + const violation = violations.find((candidate) => candidate.rule === 'class-abstract-member-unimplemented'); + expect(violation?.message).toContain("Concrete class 'Square' must implement abstract method 'area'"); + expect(violation?.message).toContain("inherited from 'Shape'"); + }); + + test('accepts a concrete subclass that overrides every abstract member', () => { + const rules = rulesFor( + [ + 'class name=Shape abstract=true', + ' method name=area returns=number', + 'class name=Square extends=Shape', + ' method name=area returns=number', + ' handler lang=kern', + ' return value=9', + ].join('\n'), + ); + expect(rules).not.toContain('class-abstract-member-unimplemented'); + }); + + test('allows an abstract subclass to leave an inherited abstract member unimplemented', () => { + const rules = rulesFor( + [ + 'class name=Shape abstract=true', + ' method name=area returns=number', + 'class name=Polygon extends=Shape abstract=true', + ' method name=sides returns=number', + ].join('\n'), + ); + expect(rules).not.toContain('class-abstract-member-unimplemented'); + }); + + test('requires the override only at the concrete leaf of a multi-level abstract chain', () => { + // A(abstract area) -> B(abstract, no override) -> C(concrete, no override). + const violations = violationsFor( + [ + 'class name=A abstract=true', + ' method name=area returns=number', + 'class name=B extends=A abstract=true', + 'class name=C extends=B', + ].join('\n'), + ); + const matches = violations.filter((candidate) => candidate.rule === 'class-abstract-member-unimplemented'); + expect(matches).toHaveLength(1); + expect(matches[0]?.message).toContain("Concrete class 'C'"); + expect(matches[0]?.message).toContain("inherited from 'A'"); + }); + + test('requires overriding BOTH an abstract getter and setter pair (no sibling erasure)', () => { + // The soundness case: overriding only the getter must NOT silently satisfy + // the sibling abstract setter (effectiveClassMemberFacts would collapse by + // name+static and drop it — collectAbstractObligations keys by kind). + const violations = violationsFor( + [ + 'class name=Cell abstract=true', + ' getter name=value returns=number', + ' setter name=value', + ' param name=next type=number', + 'class name=IntCell extends=Cell', + ' getter name=value returns=number', + ' handler lang=kern', + ' return value="this._value"', + ].join('\n'), + ); + const matches = violations.filter((candidate) => candidate.rule === 'class-abstract-member-unimplemented'); + expect(matches).toHaveLength(1); + expect(matches[0]?.message).toContain("abstract setter 'value'"); + }); + + test('accepts overriding both members of an abstract getter/setter pair', () => { + const rules = rulesFor( + [ + 'class name=Cell abstract=true', + ' getter name=value returns=number', + ' setter name=value', + ' param name=next type=number', + 'class name=IntCell extends=Cell', + ' getter name=value returns=number', + ' handler lang=kern', + ' return value="this._value"', + ' setter name=value', + ' param name=next type=number', + ' handler lang=kern', + ' assign target="this._value" value="next"', + ].join('\n'), + ); + expect(rules).not.toContain('class-abstract-member-unimplemented'); + }); + + test('a same-name different-kind member does not satisfy an abstract method obligation', () => { + // Base abstract METHOD `area`; subclass declares a FIELD `area`. The method + // obligation stands (kind-specific); the kind collision is owned separately + // by class-member-conflict. + const violations = violationsFor( + [ + 'class name=Shape abstract=true', + ' method name=area returns=number', + 'class name=Square extends=Shape', + ' field name=area type=number value={{ 9 }}', + ].join('\n'), + ); + expect(violations.map((candidate) => candidate.rule)).toContain('class-abstract-member-unimplemented'); + }); +}); diff --git a/packages/core/tests/constructor-super.test.ts b/packages/core/tests/constructor-super.test.ts new file mode 100644 index 00000000..627cf72b --- /dev/null +++ b/packages/core/tests/constructor-super.test.ts @@ -0,0 +1,96 @@ +import { hasDirectSuperCtorCall } from '../src/constructor-super.js'; +import { parse } from '../src/parser.js'; +import type { IRNode } from '../src/types.js'; + +// Extract the (single) class's constructor node from a parsed KERN module. The +// canonical predicate operates on a constructor IR node, so these tests pin the +// ONE classification every layer (validator, runtime, TS + Python codegen) relies +// on — if this drifts, all four drift together, which is exactly what the shared +// predicate exists to prevent. +function ctorOf(source: string): IRNode { + const root = parse(source); + const cls = root.type === 'class' ? root : (root.children ?? []).find((c) => c.type === 'class'); + if (!cls) throw new Error('test fixture parsed no class'); + const ctor = (cls.children ?? []).find((c) => c.type === 'constructor'); + if (!ctor) throw new Error('test fixture parsed no constructor'); + return ctor; +} + +describe('hasDirectSuperCtorCall — canonical constructor-super predicate', () => { + test('false when the constructor omits super entirely (implicit mode)', () => { + const ctor = ctorOf( + [ + 'class name=Box extends=Base', + ' constructor', + ' param name=v type=number', + ' handler lang=kern', + ' assign target="this.x" value="v"', + ].join('\n'), + ); + expect(hasDirectSuperCtorCall(ctor)).toBe(false); + }); + + test('true for a straight-line direct super(...) call (explicit mode)', () => { + const ctor = ctorOf( + [ + 'class name=Dog extends=Animal', + ' constructor', + ' param name=name type=string', + ' handler lang=kern', + ' do value="super(name)"', + ].join('\n'), + ); + expect(hasDirectSuperCtorCall(ctor)).toBe(true); + }); + + test('true for a super() inside an if branch — presence, not satisfaction', () => { + const ctor = ctorOf( + [ + 'class name=User extends=Entity', + ' constructor', + ' param name=ready type=boolean', + ' handler lang=kern', + ' if cond=ready', + ' do value="super()"', + ].join('\n'), + ); + expect(hasDirectSuperCtorCall(ctor)).toBe(true); + }); + + test('false for a super() that only appears inside a lambda (never runs at construction)', () => { + const ctor = ctorOf( + [ + 'class name=User extends=Entity', + ' constructor', + ' handler lang=kern', + ' do value="(() => super())"', + ].join('\n'), + ); + expect(hasDirectSuperCtorCall(ctor)).toBe(false); + }); + + test('false for a super MEMBER call (super.method), which never initializes the base', () => { + const ctor = ctorOf( + [ + 'class name=Admin extends=Entity', + ' constructor', + ' handler lang=kern', + ' return value="super.kind()"', + ].join('\n'), + ); + expect(hasDirectSuperCtorCall(ctor)).toBe(false); + }); + + test('true for a double super (both calls are direct, structurally present)', () => { + const ctor = ctorOf( + [ + 'class name=User extends=Entity', + ' constructor', + ' handler lang=kern', + ' do value="super()"', + ' do value="super()"', + ].join('\n'), + ); + expect(hasDirectSuperCtorCall(ctor)).toBe(true); + }); +}); diff --git a/packages/core/tests/core-runtime.test.ts b/packages/core/tests/core-runtime.test.ts index 918e6e87..e7dc2453 100644 --- a/packages/core/tests/core-runtime.test.ts +++ b/packages/core/tests/core-runtime.test.ts @@ -1529,7 +1529,12 @@ describe('KERN core runtime statements', () => { expect(toHostValue(evalCoreExpression('setChain()', env))).toBe(10); }); - test('does not count delayed lambda super calls as constructor initialization', () => { + test('a lambda-only super is not effective: implicit base init runs and fails an arg-requiring base', () => { + // The only super(id) sits inside a lambda, so it never runs at construction. + // Under Option C the derived constructor is in implicit mode: KERN attempts a + // no-arg base init FIRST, which fails because Entity's constructor requires + // `id`. The 'missing required argument: id' error (not a lambda error) proves + // the lambda super was NOT counted AND implicit base init was attempted. const root = parse( [ 'class name=Entity', @@ -1547,7 +1552,34 @@ describe('KERN core runtime statements', () => { const env = createCoreRuntimeEnv(); runCoreRuntime(root, env); - expect(() => evalCoreExpression('new User("u1")', env)).toThrow('lambda expressions are not supported'); + expect(() => evalCoreExpression('new User("u1")', env)).toThrow('missing required argument: id'); + }); + + test('derived constructor that omits super gets implicit base init (Option C, parity with codegen)', () => { + // Mirrors the class-conformance Box/Base fixture inside the interpreter: Box's + // constructor touches this.x but never calls super(). KERN injects base init + // FIRST (so Base.tag=1 default is present), then derived field defaults, then + // the body — get() = x(7) + tag(1) = 8. Proves the runtime now agrees with + // generated TS/Python instead of throwing "must call super(...)". + const root = parse( + [ + 'class name=Base', + ' field name=tag type=number value={{ 1 }}', + 'class name=Box extends=Base', + ' field name=x type=number value={{ 0 }}', + ' constructor', + ' param name=v type=number', + ' handler', + ' assign target="this.x" value="v"', + ' method name=get returns=number', + ' handler', + ' return value="this.x + this.tag"', + ].join('\n'), + ); + const env = createCoreRuntimeEnv(); + runCoreRuntime(root, env); + + expect(toHostValue(evalCoreExpression('new Box(7).get()', env))).toBe(8); }); }); diff --git a/packages/python/src/codegen-body-python.ts b/packages/python/src/codegen-body-python.ts index 4266a8ac..dbfc3489 100644 --- a/packages/python/src/codegen-body-python.ts +++ b/packages/python/src/codegen-body-python.ts @@ -62,6 +62,7 @@ import { KERN_PAIR_HELPERS_PY, KERN_TMOD_HELPER_PY, } from './core/expr/index.js'; +import { isSharedPortableArrayMethod, lowerPortableArrayMethodPy } from './core/expr/list-ops.js'; import { mapTsTypeToPython } from './type-map.js'; /** Slice 3e — caller-provided options for the Python body emitter. @@ -103,6 +104,16 @@ export interface BodyEmitOptions { * packages/core/src/ir/semantics/python-leg.ts for the runtime contract. */ traceHooks?: { eachIterNext?: boolean; forIterNext?: boolean; letAssign?: boolean }; + /** Coercion-slice opt-out for the helper-less Ground/React declarative + * layer. Defaults to `true` (native KERN bodies + expression unit tests + * get full JS value→string coercion, injecting helpers function-locally). + * The Ground generators (`coalesce`/`firstDefined`/`firstTruthy`/`objectMerge` + * /…) emit module-level statements via `emitPyExpression` and have no + * channel to define `_kern_fmt`/`__kern_add`/`_KERN_UNDEFINED`, so they pass + * `false` to keep the pre-slice output (zero regression). Extending coercion + * to the Ground layer needs module-level (single-definition) helper + * injection — a separate follow-up. */ + coerceJsValues?: boolean; /** Outer-scope names the body INHERITS — typically function parameters and * module-level globals the wrapper has bound. Pre-populated as the * outermost `localScopes` map so an inner-block `let` that shadows ANY of @@ -172,6 +183,16 @@ interface BodyEmitContext { * override pending control flow, so it gets a finally-specific error. */ finallyDepth: number; standaloneExpression: boolean; + /** When true, helper-dependent JS value→string coercion is emitted + * (`__kern_add`, `_kern_fmt`-wrapped templates, the `_KERN_UNDEFINED` + * sentinel + sentinel-aware `??`/`typeof`). Native KERN bodies inject the + * required helpers function-locally, so the default is true. The Ground/ + * React declarative layer (`coalesce`/`firstDefined`/etc.) emits module- + * level statements through `emitPyExpression` with NO per-statement helper + * channel, so it opts out and keeps the pre-coercion-slice forms (raw `+`, + * raw f-string interpolation, `None` for undefined, None-only `??`). + * See BodyEmitOptions.coerceJsValues. */ + coerceJsValues: boolean; } const INDENT_STEP = ' '; @@ -193,6 +214,7 @@ function freshCtx(options?: BodyEmitOptions): BodyEmitContext { tryDepth: 0, finallyDepth: 0, standaloneExpression: false, + coerceJsValues: options?.coerceJsValues ?? true, traceHooks: options?.traceHooks, }; } @@ -1691,7 +1713,12 @@ function emitPyExprCtx(node: ValueIR, ctx: BodyEmitContext): string { case 'nullLit': return 'None'; case 'undefLit': - return 'None'; + // Ground/React layer (no helper channel) keeps the pre-slice collapse to + // None; native bodies materialize the sentinel so `${undefined}` renders + // "undefined" (vs null's "null") and `?? `/`typeof` can distinguish it. + if (!ctx.coerceJsValues) return 'None'; + ctx.helpers.add(KERN_FMT_HELPER_PY); + return '_KERN_UNDEFINED'; case 'regexLit': ctx.imports.add('re'); return `__k_re.compile(${pyRegexPattern(node)}, ${pyRegexFlags(node.flags, { allowGlobal: true })})`; @@ -1740,7 +1767,13 @@ function emitPyExprCtx(node: ValueIR, ctx: BodyEmitContext): string { case 'nonNull': return emitPyExprCtx(node.expression, ctx); case 'tmplLit': { - // Lower TS template literals to Python f-strings. + // Lower TS template literals to Python f-strings. In native bodies, wrap + // each interpolation in _kern_fmt so JS value→string coercion semantics + // (true→"true", null→"null", undefined→"undefined", 1.0→"1", arrays→ + // comma-joined, objects→"[object Object]") are preserved. The helper-less + // Ground/React layer keeps the pre-slice raw f-string interpolation. + const coerce = ctx.coerceJsValues; + if (coerce) ctx.helpers.add(KERN_FMT_HELPER_PY); let out = 'f"'; for (let i = 0; i < node.quasis.length; i++) { out += node.quasis[i] @@ -1749,7 +1782,10 @@ function emitPyExprCtx(node: ValueIR, ctx: BodyEmitContext): string { .replace(/\n/g, '\\n') .replace(/\{/g, '{{') .replace(/\}/g, '}}'); - if (i < node.expressions.length) out += `{${emitPyExprCtx(node.expressions[i], ctx)}}`; + if (i < node.expressions.length) { + const inner = emitPyExprCtx(node.expressions[i], ctx); + out += coerce ? `{_kern_fmt(${inner})}` : `{${inner}}`; + } } out += '"'; return out; @@ -1790,6 +1826,25 @@ function emitPyExprCtx(node: ValueIR, ctx: BodyEmitContext): string { return `isinstance(${left}, ${right})`; } + if (node.op === '+' && ctx.coerceJsValues) { + // JS `+` is overloaded: string concat if either operand is string-ish, + // numeric addition otherwise. Python has no implicit coercion, so we + // lower based on syntactic hints: + // - If either operand is syntactically string-producing (strLit/tmplLit), + // emit _kern_fmt(left) + _kern_fmt(right) for JS string concat. + // - Otherwise (idents/calls/members/numbers — type unknown at emit time), + // emit __kern_add(left, right) so numeric + stays additive and dynamic + // string concat is coerced at runtime. + // The helper-less Ground/React layer skips this and falls through to the + // generic raw `+` path below (pre-slice behavior, zero regression). + ctx.helpers.add(KERN_FMT_HELPER_PY); + const isStr = (n: ValueIR) => n.kind === 'strLit' || n.kind === 'tmplLit'; + if (isStr(node.left) || isStr(node.right)) { + return `_kern_fmt(${left}) + _kern_fmt(${right})`; + } + return `__kern_add(${left}, ${right})`; + } + if (node.op === '??') { // Slice 4c — nullish coalesce lowering. Two shapes: // @@ -1812,11 +1867,22 @@ function emitPyExprCtx(node: ValueIR, ctx: BodyEmitContext): string { // Slice 4c (post-buddy-review) was the easy-win expansion after the // 22.7% empirical-gate scan; this lifts the slice-2 `??` throw and // adds an estimated +7% to native eligibility on Agon-AI bodies. + // Ground/React layer keeps the pre-slice None-only nullish test (no + // sentinel, no helper). Native bodies also exclude the undefined + // sentinel so `undefined ?? x` coalesces. + if (!ctx.coerceJsValues) { + if (isReceiverChainPure(node.left)) { + return `(${left} if ${left} is not None else ${right})`; + } + const tmp = `__k_nc${++ctx.gensymCounter}`; + return `(${tmp} if (${tmp} := ${left}) is not None else ${right})`; + } + ctx.helpers.add(KERN_FMT_HELPER_PY); if (isReceiverChainPure(node.left)) { - return `(${left} if ${left} is not None else ${right})`; + return `(${left} if (${left} is not None and ${left} is not _KERN_UNDEFINED) else ${right})`; } const tmp = `__k_nc${++ctx.gensymCounter}`; - return `(${tmp} if (${tmp} := ${left}) is not None else ${right})`; + return `(${tmp} if ((${tmp} := ${left}) is not None and ${tmp} is not _KERN_UNDEFINED) else ${right})`; } const forceLeft = needsComparisonChainParens(node.left, node.op); @@ -1921,6 +1987,23 @@ function emitPyTypeof(argument: ValueIR, ctx: BodyEmitContext): string { const value = emitPyExprCtx(argument, ctx); const wrapped = needsArgParens(argument) ? `(${value})` : value; const tmp = `__k_typeof${++ctx.gensymCounter}`; + // Native bodies: a runtime value holding the undefined sentinel reports + // "undefined" (JS `typeof undefined`), not "object". The walrus binds in the + // first test so the sentinel branch is checked before the None branch. The + // helper-less Ground layer never materializes the sentinel, so it keeps the + // pre-slice None-first form. + if (ctx.coerceJsValues) { + ctx.helpers.add(KERN_FMT_HELPER_PY); + return ( + `("undefined" if (${tmp} := ${wrapped}) is _KERN_UNDEFINED ` + + `else "object" if ${tmp} is None ` + + `else "boolean" if isinstance(${tmp}, bool) ` + + `else "number" if isinstance(${tmp}, (int, float)) ` + + `else "string" if isinstance(${tmp}, str) ` + + `else "function" if callable(${tmp}) ` + + `else "object")` + ); + } return ( `("object" if (${tmp} := ${wrapped}) is None ` + `else "boolean" if isinstance(${tmp}, bool) ` + @@ -2115,6 +2198,11 @@ function lowerChain(node: ChainNode, ctx: BodyEmitContext): GuardedExpr { if (regex !== null) return { guard: null, expr: regex }; const stdlib = applyStdlibLoweringPython(node, ctx); if (stdlib !== null) return { guard: null, expr: stdlib }; + // Portable array methods (e.g. `arr.push(x)`) lower through the SAME shared + // helper the route emitter uses, so a class method's `this.items.push(x)` + // matches a route handler's `arr.push(x)` by construction (no per-path drift). + const portableArray = lowerPortableArrayCallPython(node, ctx); + if (portableArray !== null) return { guard: null, expr: portableArray }; if (ctx.inConstructor && node.callee.kind === 'ident' && node.callee.name === 'super') { const superArgs = node.args.map((arg) => emitPyExprCtx(arg, ctx)).join(', '); return { guard: null, expr: `super().__init__(${superArgs})` }; @@ -2137,6 +2225,40 @@ function lowerChain(node: ChainNode, ctx: BodyEmitContext): GuardedExpr { return { guard: inner.guard, expr: `${inner.expr}(${args})` }; } +/** + * Lower a portable Array *method call* (e.g. `arr.push(x)`) through the shared + * `list-ops` module, so a class-method body and a route handler lower the same + * portable subset to identical Python. Returns `null` — and the caller falls + * through to the generic call emission — for anything that is not a bare, + * non-optional member call of a shared portable method on a guard-free + * receiver. Mirrors the peek-then-emit shape of `lowerRegexCallPython`. + */ +function lowerPortableArrayCallPython(call: Extract, ctx: BodyEmitContext): string | null { + const callee = call.callee; + if (callee.kind !== 'member' || callee.optional) return null; + // Gate on method name + arity BEFORE emitting receiver/args, so a non-shared + // or malformed call falls through without any duplicated emission. The only + // shared method (push) is single-arg; a 0-/2-arg push is left to the generic + // path (an unsupported case, pre-existing on the route emitter too). + if (!isSharedPortableArrayMethod(callee.property)) return null; + if (call.args.length !== 1) return null; + const recvNode = callee.object; + // The shim names the receiver twice (`(recv.append(x) or len(recv))`), so a + // side-effectful receiver — `makeBag().items.push(x)`, `bags[idx()].push(x)` — + // would run those effects twice on Python and break JS parity. Lower only a + // provably-pure receiver; let impure ones fall through unchanged. + if (!isReceiverChainPure(recvNode)) return null; + const recv: GuardedExpr = + recvNode.kind === 'member' || recvNode.kind === 'call' || recvNode.kind === 'index' + ? lowerChain(recvNode, ctx) + : { guard: null, expr: emitPyExprCtx(recvNode, ctx) }; + // A pure receiver can still be an optional chain (`a?.b`), which carries a + // None-guard the flat shim can't honor — fall through for those too. + if (recv.guard !== null) return null; + const args = call.args.map((a) => emitPyExprCtx(a, ctx)); + return lowerPortableArrayMethodPy(recv.expr, callee.property, args); +} + function lowerRegexCallPython(call: Extract, ctx: BodyEmitContext): string | null { const callee = call.callee; if (callee.kind !== 'member') return null; diff --git a/packages/python/src/core/expr/helpers.ts b/packages/python/src/core/expr/helpers.ts index 4f5b4534..6e3008a2 100644 --- a/packages/python/src/core/expr/helpers.ts +++ b/packages/python/src/core/expr/helpers.ts @@ -12,14 +12,67 @@ export const KERN_PAIR_HELPERS_PY = [ ].join('\n'); export const KERN_FMT_HELPER_PY = [ + 'class _KernUndefined:', + // JS `undefined` is falsy: `!undefined`, `undefined ? a : b`, `if (undefined)`, + // and `undefined || x` must behave as falsy. A bare object is truthy in Python, + // so override __bool__ — without this the sentinel diverges from JS in every + // truthiness position. Identity (`is`) is unaffected, so the `??` checks hold. + ' def __bool__(self): return False', + " def __repr__(self): return 'undefined'", + " def __str__(self): return 'undefined'", + '_KERN_UNDEFINED = _KernUndefined()', + '', 'def _kern_fmt(__k_v):', - ' if isinstance(__k_v, bool):', - " return 'true' if __k_v else 'false'", + ' if __k_v is _KERN_UNDEFINED:', + " return 'undefined'", ' if __k_v is None:', " return 'null'", + ' if isinstance(__k_v, bool):', + " return 'true' if __k_v else 'false'", + ' if isinstance(__k_v, str):', + ' return __k_v', + ' if isinstance(__k_v, float) and __k_v != __k_v:', + " return 'NaN'", + // JS String(Infinity) is "Infinity"/"-Infinity"; Python str(inf) is "inf". + // Check before is_integer() — inf.is_integer() is False and int(inf) raises. + " if isinstance(__k_v, float) and __k_v == float('inf'):", + " return 'Infinity'", + " if isinstance(__k_v, float) and __k_v == float('-inf'):", + " return '-Infinity'", ' if isinstance(__k_v, float) and __k_v.is_integer():', ' return str(int(__k_v))', + ' if isinstance(__k_v, (int, float)):', + ' return str(__k_v)', + ' if isinstance(__k_v, (list, tuple)):', + " return ','.join(", + " '' if x is None or x is _KERN_UNDEFINED else _kern_fmt(x)", + ' for x in __k_v', + ' )', + ' if isinstance(__k_v, dict):', + " return '[object Object]'", ' return str(__k_v)', + '', + 'def __kern_add(left, right):', + ' # JS `+`: string concat when either operand is string-ish (ToPrimitive →', + ' # string for str/array/object/tuple); otherwise numeric addition with ToNumber', + ' # coercion (null→0, undefined→NaN, bool→0/1) so `5 + null` is 5, not "5null".', + ' if isinstance(left, (str, list, tuple, dict)) or isinstance(right, (str, list, tuple, dict)):', + ' return _kern_fmt(left) + _kern_fmt(right)', + ' def _num(v):', + ' if v is _KERN_UNDEFINED:', + " return float('nan')", + ' if v is None:', + ' return 0', + ' if isinstance(v, bool):', + ' return 1 if v else 0', + ' return v', + ' # ToNumber path for the KERN value domain (numbers/bool/null/undefined).', + ' # Any exotic host type (set, custom object) that escapes the string-ish', + ' # check falls back to JS object→string concat rather than raising.', + ' try:', + ' return _num(left) + _num(right)', + ' except TypeError:', + ' return _kern_fmt(left) + _kern_fmt(right)', ].join('\n'); export const KERN_I32_HELPER_PY = [ diff --git a/packages/python/src/core/expr/index.ts b/packages/python/src/core/expr/index.ts index 0866f63e..e7f46d9f 100644 --- a/packages/python/src/core/expr/index.ts +++ b/packages/python/src/core/expr/index.ts @@ -11,6 +11,7 @@ import { KERN_JS_STRING_HELPERS_PY, KERN_TMOD_HELPER_PY, } from './helpers.js'; +import { lowerPortableArrayMethodPy } from './list-ops.js'; export { KERN_FMT_HELPER_PY, @@ -344,10 +345,11 @@ function lowerJsArrayMethods(expr: string, ctx: ExprRewriteContext): string { lowered = `(next((__i for __i, __v in enumerate(${receiver}) if __v == ${needle}), -1))`; } } else if (method === 'push') { - // JS Array.push mutates AND returns the new length. Python list.append - // returns None, so emit `(recv.append(x) or len(recv))` for exact parity - // (mutate + length). Single-arg only; varargs push left unsupported. - if (args.length === 1) lowered = `(${receiver}.append(${args[0]}) or len(${receiver}))`; + // Delegate to the single shared push lowering (also used by the + // class-method body emitter) so routes and class methods can't drift. + // Single-arg only; varargs push left unsupported. + const portable = lowerPortableArrayMethodPy(receiver, 'push', args); + if (portable !== null) lowered = portable; } else if (method === 'reverse') { // JS Array.reverse mutates AND returns the (same, reversed) array; Python // list.reverse returns None -> `(recv.reverse() or recv)` mutates + returns it. diff --git a/packages/python/src/core/expr/list-ops.ts b/packages/python/src/core/expr/list-ops.ts new file mode 100644 index 00000000..dcef700e --- /dev/null +++ b/packages/python/src/core/expr/list-ops.ts @@ -0,0 +1,61 @@ +/** + * Portable Array → Python lowering — the SINGLE source shared by both Python + * emission paths: + * - the route/expression emitter (`core/expr/index.ts`), and + * - the class-method body emitter (`codegen-body-python.ts`). + * + * A route handler's `arr.push(x)` and a class method's `arr.push(x)` MUST lower + * to the same Python. Before this module the two emitters open-coded their own + * dispatch tables and drifted — the route path lowered `.push`, the class path + * did not, so `this.items.push(x)` inside a class method emitted invalid + * `self.items.push(x)`. Routing both paths through one function makes the + * portable lowering identical by construction (the parity invariant KERN + * exists to enforce), so the two can never drift again. + * + * The TypeScript target keeps the native host syntax (`arr.push(x)` already + * returns the new length); only Python needs a shim, so this module is + * Python-only. + * + * Scope note: the lambda-taking methods (`.map`/`.filter`) are NOT shared here. + * They operate on representation-specific inputs (the route path rewrites arrow + * *strings*; the class path lowers `ValueIR` *lambdas*), so a single + * string-based helper cannot express them cleanly — each path keeps its own. + * The remaining scalar methods (`.length`, `.slice`, `.concat`, …) are a + * tracked follow-up; today they are route-only (no class-path counterpart, so + * no drift). This module owns the methods that are actually shared. + */ + +/** + * Method names this module lowers. Kept module-private (reached only through the + * `isSharedPortableArrayMethod` predicate) so the gate cannot be mutated by a + * consumer — exporting the `Set` itself would be a runtime footgun, since a + * `ReadonlySet` type does not freeze the underlying `Set`. + */ +const SHARED_PORTABLE_ARRAY_METHODS: ReadonlySet = new Set(['push']); + +/** + * True when `method` is a portable Array method this module lowers. A peek-style + * caller (the class-method body emitter) gates on this BEFORE emitting receiver + * and argument strings, avoiding duplicated emission when the method isn't ours. + */ +export function isSharedPortableArrayMethod(method: string): boolean { + return SHARED_PORTABLE_ARRAY_METHODS.has(method); +} + +/** + * Lower a portable Array *method call* to its Python form, operating purely on + * already-emitted receiver/argument strings so both call sites (which hold + * different input representations) can delegate to it. Returns `null` when the + * method is not a shared portable method, so callers fall through to their + * existing handling. + */ +export function lowerPortableArrayMethodPy(receiver: string, method: string, args: string[]): string | null { + if (method === 'push' && args.length === 1) { + // JS `Array.push` mutates AND returns the new length; Python `list.append` + // mutates but returns `None`. `(recv.append(x) or len(recv))` reproduces + // both effects: append runs, then the falsy `None` yields to `len(recv)`, + // which is always >= 1 after an append — exact parity with the JS return. + return `(${receiver}.append(${args[0]}) or len(${receiver}))`; + } + return null; +} diff --git a/packages/python/src/generators/data.ts b/packages/python/src/generators/data.ts index 242da074..ed97d260 100644 --- a/packages/python/src/generators/data.ts +++ b/packages/python/src/generators/data.ts @@ -4,7 +4,15 @@ */ import type { IRNode } from '@kernlang/core'; -import { emitIdentifier, getFirstChild, getProps, handlerCode, mapSemanticType, propsOf } from '@kernlang/core'; +import { + emitIdentifier, + getFirstChild, + getProps, + handlerCode, + hasDirectSuperCtorCall, + mapSemanticType, + propsOf, +} from '@kernlang/core'; import { emitNativeKernBodyPythonWithImports } from '../codegen-body-python.js'; import { buildPythonParamList, firstChild, kids, p, parseLegacyParamParts } from '../codegen-helpers.js'; import { mapTsTypeToPython, toSnakeCase } from '../type-map.js'; @@ -31,10 +39,23 @@ function methodBodyCodePython( return { code: handlerCode(method), imports: new Set(), helpers: new Set() }; } const symbolMap: Record = {}; - const claimedSnake = new Set(['self']); + // The implicit receiver occupies the first parameter slot: `self` for an + // instance member, `cls` for a static accessor (metaclass property). A user + // parameter that snake-cases to the receiver name would emit invalid Python + // (e.g. `def label(cls, cls):`), so reserve it and fail codegen early with a + // clear message rather than generate a SyntaxError. + const receiver = opts?.staticReceiver ? 'cls' : 'self'; + const claimedSnake = new Set([receiver]); const recordParam = (rawName: string): void => { if (!rawName) return; const snake = toSnakeCase(rawName); + if (snake === receiver) { + throw new Error( + `KERN-Python codegen: parameter '${rawName}' snake-cases to '${snake}', the implicit ` + + `${opts?.staticReceiver ? 'static-accessor receiver (cls)' : 'method receiver (self)'}. ` + + 'Rename the parameter to avoid shadowing the receiver.', + ); + } if (claimedSnake.has(snake)) { throw new Error( `KERN-Python codegen: method param '${rawName}' snake-cases to '${snake}', which collides with another param on this method. ` + @@ -507,6 +528,19 @@ export function generatePythonClass(node: IRNode): string[] { return np.static === 'true' || np.static === true; }; + // `abstract` is ERASED at codegen on both targets (a plain, instantiable + // class — matching TS, where `abstract` is compile-time-only and gone from + // emitted JS). An abstract member is a handler-less method/getter/setter under + // an abstract class; it lowers to a fail-fast `raise`, so an un-overridden + // abstract member fails identically on TS (throw) and Python (raise) — parity + // by construction. `implements` is likewise erased (the semantic validator + // owns conformance); only a human-readable marker comment is emitted. + const isAbstractClass = props.abstract === 'true' || props.abstract === true; + const implementsRaw = typeof props.implements === 'string' ? (props.implements as string) : ''; + const isAbstractMember = (m: IRNode): boolean => isAbstractClass && firstChild(m, 'handler') === undefined; + const abstractRaise = (kind: string, memberName: string): string => + ` raise NotImplementedError("abstract ${kind} ${name}.${memberName} not implemented")`; + const fields = kids(node, 'field'); const staticFields = fields.filter(isStatic); const methods = kids(node, 'method'); @@ -536,7 +570,14 @@ export function generatePythonClass(node: IRNode): string[] { metaGetterNames.add(gname); metaLines.push(' @property'); metaLines.push(` def ${gname}(cls)${returns}:`); - metaLines.push(...methodBodyLinesPython(g, { classBody: true, staticReceiver: true })); + // Abstract static accessors fail-fast like instance ones, so an + // un-overridden abstract static getter raises on Python the same way it + // throws on TS (was silently `pass` -> None before). + if (isAbstractMember(g)) { + metaLines.push(abstractRaise('getter', gname)); + } else { + metaLines.push(...methodBodyLinesPython(g, { classBody: true, staticReceiver: true })); + } metaLines.push(''); } for (const s of staticSetters) { @@ -550,7 +591,11 @@ export function generatePythonClass(node: IRNode): string[] { } metaLines.push(` @${sname}.setter`); metaLines.push(` def ${sname}(cls, ${buildPythonParamList(s, { selfPrefix: false })}):`); - metaLines.push(...methodBodyLinesPython(s, { classBody: true, staticReceiver: true })); + if (isAbstractMember(s)) { + metaLines.push(abstractRaise('setter', sname)); + } else { + metaLines.push(...methodBodyLinesPython(s, { classBody: true, staticReceiver: true })); + } metaLines.push(''); } } @@ -581,12 +626,27 @@ export function generatePythonClass(node: IRNode): string[] { if (ctor) { body.push(` def __init__(${buildPythonParamList(ctor, { selfPrefix: true })}):`); const ctorLines = methodBodyLinesPython(ctor, { classBody: true, isConstructor: true }); - // Field initializers run AFTER super().__init__() (TS field-init-after-super - // order), so inject defaults right after the super call when present, else at - // the top of the constructor body. - const superIdx = ctorLines.findIndex((line) => line.includes('super().__init__')); - if (superIdx >= 0) { - body.push(...ctorLines.slice(0, superIdx + 1), ...defaultLines, ...ctorLines.slice(superIdx + 1)); + // Whether the constructor already calls super(...) is decided by the canonical + // structural predicate (shared with the validator, runtime, and TS target) — + // NEVER by scanning emitted text, so a `super().__init__` substring inside a + // string literal or comment can't change codegen. Field initializers run AFTER + // super (TS field-init-after-super order). + if (hasDirectSuperCtorCall(ctor)) { + // Explicit-super mode: position the field defaults right after the emitted + // super line. The predicate already proved a direct super exists, so this + // locates the real call (a native super(...) lowers to `super().__init__(...)`); + // the index is used only for placement, not the inject decision. + const superIdx = ctorLines.findIndex((line) => line.includes('super().__init__')); + const splice = superIdx >= 0 ? superIdx + 1 : 0; + body.push(...ctorLines.slice(0, splice), ...defaultLines, ...ctorLines.slice(splice)); + } else if (base) { + // Implicit-super mode (KERN Option C): a DERIVED constructor that omits + // super() gets an implicit no-arg base-init injected FIRST, then field + // defaults, then the body — so `this`/field access is always legal (TS + // requires super-before-this; Python is lax, but we emit identically for + // parity). The author writes explicit `super(args)` only to pass args up; + // when the base constructor REQUIRES args, the validator flags it. + body.push(' super().__init__()', ...defaultLines, ...ctorLines); } else { body.push(...defaultLines, ...ctorLines); } @@ -617,7 +677,11 @@ export function generatePythonClass(node: IRNode): string[] { } else { body.push(` ${asyncKw}def ${mname}(${buildPythonParamList(m, { selfPrefix: true })})${returns}:`); } - body.push(...methodBodyLinesPython(m, { classBody: !isStatic(m) })); + if (isAbstractMember(m)) { + body.push(abstractRaise('method', mname)); + } else { + body.push(...methodBodyLinesPython(m, { classBody: !isStatic(m) })); + } body.push(''); } @@ -631,7 +695,11 @@ export function generatePythonClass(node: IRNode): string[] { const returns = gp.returns ? ` -> ${mapTsTypeToPython(gp.returns as string)}` : ''; body.push(' @property'); body.push(` def ${gname}(self)${returns}:`); - body.push(...methodBodyLinesPython(g, { classBody: true })); + if (isAbstractMember(g)) { + body.push(abstractRaise('getter', gname)); + } else { + body.push(...methodBodyLinesPython(g, { classBody: true })); + } body.push(''); } // Setters -> @.setter. Python requires a property to exist before its @@ -650,14 +718,21 @@ export function generatePythonClass(node: IRNode): string[] { } body.push(` @${sname}.setter`); body.push(` def ${sname}(${buildPythonParamList(s, { selfPrefix: true })}):`); - body.push(...methodBodyLinesPython(s, { classBody: true })); + if (isAbstractMember(s)) { + body.push(abstractRaise('setter', sname)); + } else { + body.push(...methodBodyLinesPython(s, { classBody: true })); + } body.push(''); } if (body.length === 0) body.push(' pass'); + // `implements` is erased at codegen (the validator owns conformance); emit a + // human-readable marker so the relationship survives in the generated source. + const headerLines = implementsRaw ? [`# implements: ${implementsRaw}`, header] : [header]; // Metaclass (if any) must be defined before the class that references it. - return metaLines.length > 0 ? [...metaLines, header, ...body] : [header, ...body]; + return metaLines.length > 0 ? [...metaLines, ...headerLines, ...body] : [...headerLines, ...body]; } // ── Union (Pydantic Discriminated Union) ──────────────────────────────── diff --git a/packages/python/src/generators/ground.ts b/packages/python/src/generators/ground.ts index 0a2cbb1c..6b752f02 100644 --- a/packages/python/src/generators/ground.ts +++ b/packages/python/src/generators/ground.ts @@ -16,6 +16,16 @@ import { } from '../codegen-helpers.js'; import { mapTsTypeToPython, toPythonBindingName, toSnakeCase } from '../type-map.js'; +/** Ground/React Layer generators emit module-level statements and have NO + * per-statement channel to define the runtime helpers (`_kern_fmt`, + * `__kern_add`, the `_KERN_UNDEFINED` sentinel) that JS value→string coercion + * needs. So every `emitPyExpression` here opts out of coercion and keeps the + * pre-slice forms (raw `+`, raw f-string interpolation, `None` for undefined, + * None-only `??`). Coercion remains scoped to native KERN bodies, where + * helpers inject function-locally. Extending it to this layer is a follow-up + * that needs module-level (single-definition) helper injection. */ +const GROUND_EMIT = { coerceJsValues: false } as const; + /** * Common preamble extracted from all ground layer generators. * Returns { annotations, todo, props, name } ready for use. @@ -145,7 +155,7 @@ export function generateFirstTruthy(node: IRNode): string[] { } function emitFirstTruthyOperandPy(valueIR: ValueIR): string { - const emitted = emitPyExpression(valueIR); + const emitted = emitPyExpression(valueIR, GROUND_EMIT); return valueIR.kind === 'conditional' ? `(${emitted})` : emitted; } @@ -172,7 +182,7 @@ export function generateCoalesce(node: IRNode): string[] { const constType = props.type as string | undefined; const typeAnnotation = constType ? `: ${mapTsTypeToPython(constType)}` : ''; - const chain = emitPyExpression(buildNullishCoalesceIR(valueIRs)); + const chain = emitPyExpression(buildNullishCoalesceIR(valueIRs), GROUND_EMIT); return [...todo, ...annotations, `${name}${typeAnnotation} = ${chain}`]; } @@ -193,7 +203,7 @@ export function generateFirstDefined(node: IRNode): string[] { const constType = props.type as string | undefined; const typeAnnotation = constType ? `: ${mapTsTypeToPython(constType)}` : ''; - const chain = emitPyExpression(buildNullishCoalesceIR(valueIRs)); + const chain = emitPyExpression(buildNullishCoalesceIR(valueIRs), GROUND_EMIT); return [...todo, ...annotations, `${name}${typeAnnotation} = ${chain}`]; } @@ -213,7 +223,7 @@ export function generateObjectMerge(node: IRNode): string[] { if (sourceIR.kind === 'propagate') { throw new Error("Propagation '?' is not allowed in `objectMerge sources=` — bind the value first."); } - emitted.push(`**(${emitPyExpression(sourceIR)})`); + emitted.push(`**(${emitPyExpression(sourceIR, GROUND_EMIT)})`); } const constType = props.type as string | undefined; @@ -235,7 +245,7 @@ export function generateObjectPick(node: IRNode): string[] { if (inIR.kind === 'propagate') { throw new Error("Propagation '?' is not allowed in objectPick in="); } - const inExpr = emitPyExpression(inIR); + const inExpr = emitPyExpression(inIR, GROUND_EMIT); const keysList = parseKeys(rawKeys, node, 'objectPick keys='); const formattedKeys = emitStringKeyArray(keysList); @@ -264,7 +274,7 @@ export function generateObjectOmit(node: IRNode): string[] { if (inIR.kind === 'propagate') { throw new Error("Propagation '?' is not allowed in objectOmit in="); } - const inExpr = emitPyExpression(inIR); + const inExpr = emitPyExpression(inIR, GROUND_EMIT); const keysList = parseKeys(rawKeys, node, 'objectOmit keys='); const formattedKeys = emitStringKeyArray(keysList); diff --git a/packages/python/tests/class-python.test.ts b/packages/python/tests/class-python.test.ts index 9b54e0e3..f0a22bab 100644 --- a/packages/python/tests/class-python.test.ts +++ b/packages/python/tests/class-python.test.ts @@ -131,6 +131,24 @@ describe('Python class codegen (single-source class slice)', () => { expect(code).not.toContain('def label(self)'); }); + test('static setter param shadowing the cls receiver fails codegen (no `def label(cls, cls):`)', () => { + const reg: IRNode = { + type: 'class', + props: { name: 'Reg' }, + children: [ + { + type: 'setter', + props: { name: 'label', static: 'true' }, + children: [ + param('cls', 'string'), // snake-cases to the implicit metaclass receiver + handler([{ type: 'assign', props: { target: 'this.store', value: 'cls' }, children: [] }]), + ], + }, + ], + }; + expect(() => generatePythonClass(reg)).toThrow(/receiver/); + }); + test('instance-field defaults emit in __init__, never as a shared class attr', () => { const bag: IRNode = { type: 'class', @@ -213,4 +231,138 @@ describe('Python class codegen (single-source class slice)', () => { expect(code).toContain('super().__init__(name)'); expect(code.indexOf('super().__init__(name)')).toBeLessThan(code.indexOf('self.tricks = []')); }); + + test('abstract instance method (handler-less, under abstract class) emits a fail-fast raise', () => { + const shape: IRNode = { + type: 'class', + props: { name: 'Shape', abstract: 'true' }, + children: [{ type: 'method', props: { name: 'area', returns: 'number' }, children: [] }], // no handler -> abstract + }; + const code = generatePythonClass(shape).join('\n'); + expect(code).toContain('class Shape:'); // abstract erased -> plain instantiable class (no ABC/metaclass) + expect(code).toContain('raise NotImplementedError("abstract method Shape.area not implemented")'); + }); + + test('abstract STATIC accessor emits a fail-fast raise (not a silent metaclass pass)', () => { + const base: IRNode = { + type: 'class', + props: { name: 'Base', abstract: 'true' }, + children: [{ type: 'getter', props: { name: 'tag', static: 'true', returns: 'string' }, children: [] }], + }; + const code = generatePythonClass(base).join('\n'); + // The metaclass static getter must raise, matching the TS throw — not `pass`/None. + expect(code).toContain('raise NotImplementedError("abstract getter Base.tag not implemented")'); + expect(code).not.toMatch(/def tag\(cls\)[^\n]*:\n\s*pass\b/); + }); + + test('implements is erased on the Python target, left as a marker comment', () => { + const user: IRNode = { + type: 'class', + props: { name: 'User', implements: 'Serializable' }, + children: [ + { type: 'field', props: { name: 'id', type: 'string', value: { __expr: true, code: '"x"' } }, children: [] }, + ], + }; + const code = generatePythonClass(user).join('\n'); + expect(code).toContain('# implements: Serializable'); + expect(code).toContain('class User:'); // no Protocol/ABC base injected + expect(code).not.toContain('Serializable)'); // implements is NOT a runtime base + }); + + test('list push on a pure receiver lowers to the shared append+len shim', () => { + const bag: IRNode = { + type: 'class', + props: { name: 'Bag' }, + children: [ + { + type: 'field', + props: { name: 'items', type: 'number[]', value: { __expr: true, code: '[]' } }, + children: [], + }, + { + type: 'method', + props: { name: 'add', returns: 'number' }, + children: [ + param('x', 'number'), + handler([{ type: 'return', props: { value: 'this.items.push(x)' }, children: [] }]), + ], + }, + ], + }; + const code = generatePythonClass(bag).join('\n'); + // Same lowering the route emitter uses — JS push's new-length return parity. + expect(code).toContain('(self.items.append(x) or len(self.items))'); + expect(code).not.toContain('self.items.push'); // no JS-ism leaks + }); + + test('list push on an IMPURE receiver does NOT lower (no double-evaluation)', () => { + // The shim names the receiver twice; a side-effectful receiver would run its + // effects twice on Python and break parity. It must fall through unchanged. + const box: IRNode = { + type: 'class', + props: { name: 'Box' }, + children: [ + { + type: 'field', + props: { name: 'items', type: 'number[]', value: { __expr: true, code: '[]' } }, + children: [], + }, + { + type: 'method', + props: { name: 'fresh', returns: 'number[]' }, + children: [handler([{ type: 'return', props: { value: 'this.items' }, children: [] }])], + }, + { + type: 'method', + props: { name: 'danger', returns: 'number' }, + children: [handler([{ type: 'return', props: { value: 'this.fresh().push(9)' }, children: [] }])], + }, + ], + }; + const code = generatePythonClass(box).join('\n'); + expect(code).not.toContain('.append(9)'); // shim NOT applied to the impure receiver + expect(code).toContain('self.fresh().push(9)'); // receiver named exactly once + }); + + test('derived constructor omitting super() gets an implicit super().__init__() first', () => { + const box: IRNode = { + type: 'class', + props: { name: 'Box', extends: 'Base' }, + children: [ + { type: 'field', props: { name: 'x', type: 'number', value: { __expr: true, code: '0' } }, children: [] }, + { + type: 'constructor', + props: {}, + children: [ + param('v', 'number'), + handler([{ type: 'assign', props: { target: 'this.x', value: 'v' }, children: [] }]), + ], + }, + ], + }; + const code = generatePythonClass(box).join('\n'); + expect(code).toContain('super().__init__()'); + // Order must be: implicit super -> field default -> constructor body. + expect(code.indexOf('super().__init__()')).toBeLessThan(code.indexOf('self.x = 0')); + expect(code.indexOf('self.x = 0')).toBeLessThan(code.lastIndexOf('self.x = v')); + }); + + test('non-derived constructor gets NO implicit super (only derived classes base-init)', () => { + const box: IRNode = { + type: 'class', + props: { name: 'Box' }, + children: [ + { + type: 'constructor', + props: {}, + children: [ + param('v', 'number'), + handler([{ type: 'assign', props: { target: 'this.x', value: 'v' }, children: [] }]), + ], + }, + ], + }; + const code = generatePythonClass(box).join('\n'); + expect(code).not.toContain('super().__init__'); + }); }); diff --git a/packages/python/tests/native-handlers-cell-python.test.ts b/packages/python/tests/native-handlers-cell-python.test.ts index 42fda4c3..7c964155 100644 --- a/packages/python/tests/native-handlers-cell-python.test.ts +++ b/packages/python/tests/native-handlers-cell-python.test.ts @@ -9,6 +9,7 @@ import type { IRNode } from '@kernlang/core'; import { emitNativeKernBodyPython } from '../src/codegen-body-python.js'; +import { KERN_FMT_HELPER_PY } from '../src/core/expr/helpers.js'; function makeHandler(children: Array<{ type: string; props?: Record }>): IRNode { return { @@ -18,6 +19,10 @@ function makeHandler(children: Array<{ type: string; props?: Record { test('lowers to plain assignment', () => { const handler = makeHandler([{ type: 'cell', props: { name: 'count', initial: '0' } }]); @@ -39,7 +44,9 @@ describe('cell body-statement — Python codegen', () => { { type: 'cell', props: { name: 'count', initial: '0' } }, { type: 'set', props: { name: 'count', to: 'count + 1' } }, ]); - expect(emitNativeKernBodyPython(handler)).toBe(['count = 0', 'count = count + 1'].join('\n')); + expect(emitNativeKernBodyPython(handler)).toBe( + PY_PRELUDE + ['count = 0', 'count = __kern_add(count, 1)'].join('\n'), + ); }); test('throws on missing name', () => { diff --git a/packages/python/tests/native-handlers-python.test.ts b/packages/python/tests/native-handlers-python.test.ts index 21f42353..563ae1d4 100644 --- a/packages/python/tests/native-handlers-python.test.ts +++ b/packages/python/tests/native-handlers-python.test.ts @@ -12,6 +12,7 @@ import { emitNativeKernBodyPythonWithImports, emitPyExpression, } from '../src/codegen-body-python.js'; +import { KERN_FMT_HELPER_PY } from '../src/core/expr/helpers.js'; import { generateFunction } from '../src/generators/core.js'; function makeHandler(stmts: Array<{ type: string; props: Record; children?: IRNode[] }>): IRNode { @@ -22,6 +23,11 @@ function makeHandler(stmts: Array<{ type: string; props: Record }; } +// JS value→string coercion runtime prelude. Body-emit prepends this whole block +// (the _KERN_UNDEFINED sentinel + _kern_fmt + __kern_add helpers) whenever a body +// is lowered, ending with a blank-line separator before the body statements. +const PY_PRELUDE = `${KERN_FMT_HELPER_PY}\n\n`; + describe('emitPyExpression — slice 1 lowering rules', () => { test('booleans lower to Python True/False', () => { expect(emitPyExpression(parseExpression('true'))).toBe('True'); @@ -33,8 +39,8 @@ describe('emitPyExpression — slice 1 lowering rules', () => { expect(emitPyExpression(parseExpression('none'))).toBe('None'); }); - test('undefined lowers to None (slice 1 simplification)', () => { - expect(emitPyExpression(parseExpression('undefined'))).toBe('None'); + test('undefined lowers to the _KERN_UNDEFINED sentinel', () => { + expect(emitPyExpression(parseExpression('undefined'))).toBe('_KERN_UNDEFINED'); }); test('await lowers to Python `await ${expr}`', () => { @@ -113,7 +119,7 @@ describe('emitPyExpression — slice 1 lowering rules', () => { left: { kind: 'numLit', value: 1, raw: '1' }, right: { kind: 'numLit', value: 2, raw: '2' }, }), - ).toBe('1 + 2'); + ).toBe('__kern_add(1, 2)'); }); }); @@ -124,19 +130,7 @@ describe('emitNativeKernBodyPython — expression-v1 and nested fn statements', { type: 'return', props: { value: 'label' } }, ]); expect(emitNativeKernBodyPython(handler)).toBe( - [ - 'def _kern_fmt(__k_v):', - ' if isinstance(__k_v, bool):', - " return 'true' if __k_v else 'false'", - ' if __k_v is None:', - " return 'null'", - ' if isinstance(__k_v, float) and __k_v.is_integer():', - ' return str(int(__k_v))', - ' return str(__k_v)', - '', - 'label = _kern_fmt(value)', - 'return label', - ].join('\n'), + PY_PRELUDE + ['label = _kern_fmt(value)', 'return label'].join('\n'), ); }); @@ -152,7 +146,8 @@ describe('emitNativeKernBodyPython — expression-v1 and nested fn statements', { type: 'return', props: { value: 'add(2, 3)' } }, ]); expect(emitNativeKernBodyPython(handler)).toBe( - ['def add(a: float, b: float) -> float:', ' return a + b', 'return add(2, 3)'].join('\n'), + PY_PRELUDE + + ['def add(a: float, b: float) -> float:', ' return __kern_add(a, b)', 'return add(2, 3)'].join('\n'), ); }); @@ -190,9 +185,12 @@ describe('emitNativeKernBodyPython — expression-v1 and nested fn statements', }, ]); expect(emitNativeKernBodyPython(handler)).toBe( - ['async def loadTotal(amount: float) -> float:', ' loaded = await load(amount)', ' return loaded + 5'].join( - '\n', - ), + PY_PRELUDE + + [ + 'async def loadTotal(amount: float) -> float:', + ' loaded = await load(amount)', + ' return __kern_add(loaded, 5)', + ].join('\n'), ); }); @@ -212,7 +210,9 @@ describe('emitNativeKernBodyPython — expression-v1 and nested fn statements', { type: 'expression-v1', props: { name: 'total', expr: { __expr: true, code: 'amount + 1' } } }, { type: 'return', props: { value: 'total' } }, ]); - expect(emitNativeKernBodyPython(handler)).toBe(['total = amount + 1', 'return total'].join('\n')); + expect(emitNativeKernBodyPython(handler)).toBe( + PY_PRELUDE + ['total = __kern_add(amount, 1)', 'return total'].join('\n'), + ); }); test('nested fn rejects mixed legacy and structured params', () => { @@ -591,10 +591,11 @@ describe('emitNativeKernBodyPython — coalesce / firstDefined body statement', { type: 'return', props: { value: 'winner' } }, ]); expect(emitNativeKernBodyPython(handler)).toBe( - [ - 'winner = (count if count is not None else (flag if flag is not None else (label if label is not None else "fallback")))', - 'return winner', - ].join('\n'), + PY_PRELUDE + + [ + 'winner = (count if (count is not None and count is not _KERN_UNDEFINED) else (flag if (flag is not None and flag is not _KERN_UNDEFINED) else (label if (label is not None and label is not _KERN_UNDEFINED) else "fallback")))', + 'return winner', + ].join('\n'), ); }); @@ -604,7 +605,11 @@ describe('emitNativeKernBodyPython — coalesce / firstDefined body statement', { type: 'return', props: { value: 'winner' } }, ]); expect(emitNativeKernBodyPython(handler)).toBe( - ['winner = (primary if primary is not None else secondary)', 'return winner'].join('\n'), + PY_PRELUDE + + [ + 'winner = (primary if (primary is not None and primary is not _KERN_UNDEFINED) else secondary)', + 'return winner', + ].join('\n'), ); }); diff --git a/packages/python/tests/native-handlers-slice-alpha2-ternary-python.test.ts b/packages/python/tests/native-handlers-slice-alpha2-ternary-python.test.ts index a58a0b27..0daa71bb 100644 --- a/packages/python/tests/native-handlers-slice-alpha2-ternary-python.test.ts +++ b/packages/python/tests/native-handlers-slice-alpha2-ternary-python.test.ts @@ -12,8 +12,8 @@ describe('emitPyExpression — ternary lowering', () => { }); test('binary test gets parens around the test in Python form', () => { - // Python: `b if (a + 1) else c` - expect(emitPyExpression(parseExpression('a + 1 ? b : c'))).toBe('b if (a + 1) else c'); + // Python: `b if (__kern_add(a, 1)) else c` — the `+` test lowers to __kern_add. + expect(emitPyExpression(parseExpression('a + 1 ? b : c'))).toBe('b if (__kern_add(a, 1)) else c'); }); test('nested ternary in alternate gets parens', () => { diff --git a/packages/python/tests/native-handlers-slice2-python.test.ts b/packages/python/tests/native-handlers-slice2-python.test.ts index e69cb204..0708dfe6 100644 --- a/packages/python/tests/native-handlers-slice2-python.test.ts +++ b/packages/python/tests/native-handlers-slice2-python.test.ts @@ -13,14 +13,21 @@ import type { IRNode } from '@kernlang/core'; import { parseDocument, parseExpression } from '@kernlang/core'; import { emitNativeKernBodyPython, emitPyExpression } from '../src/codegen-body-python.js'; +import { KERN_FMT_HELPER_PY } from '../src/core/expr/helpers.js'; import { generateFunction } from '../src/generators/core.js'; function makeHandler(children: IRNode[]): IRNode { return { type: 'handler', props: { lang: 'kern' }, children }; } +// A dynamic `typeof` references the `_KERN_UNDEFINED` sentinel, so any BODY that +// lowers one carries the coercion helper prelude (derived from the source const +// so it can never drift). Expression-only `emitPyExpression` returns just the +// expression — no prelude — since it discards the collected helper set. +const PY_PRELUDE = `${KERN_FMT_HELPER_PY}\n\n`; + const TYPEOF_VALUE_PY = - '("object" if (__k_typeof1 := value) is None else "boolean" if isinstance(__k_typeof1, bool) else "number" if isinstance(__k_typeof1, (int, float)) else "string" if isinstance(__k_typeof1, str) else "function" if callable(__k_typeof1) else "object")'; + '("undefined" if (__k_typeof1 := value) is _KERN_UNDEFINED else "object" if __k_typeof1 is None else "boolean" if isinstance(__k_typeof1, bool) else "number" if isinstance(__k_typeof1, (int, float)) else "string" if isinstance(__k_typeof1, str) else "function" if callable(__k_typeof1) else "object")'; // ── 2b: stdlib expansion (Python) ──────────────────────────────────────── @@ -72,12 +79,12 @@ describe('KERN-stdlib expansion — Python target', () => { // ── 2c: arithmetic + comparison (Python) ───────────────────────────────── describe('emitPyExpression — arithmetic + comparison + unary', () => { - test('addition emits verbatim', () => { - expect(emitPyExpression(parseExpression('a + b'))).toBe('a + b'); + test('addition lowers to __kern_add (JS + string-coercion guard)', () => { + expect(emitPyExpression(parseExpression('a + b'))).toBe('__kern_add(a, b)'); }); test('multiplication binds tighter (precedence)', () => { - expect(emitPyExpression(parseExpression('a + b * c'))).toBe('a + b * c'); + expect(emitPyExpression(parseExpression('a + b * c'))).toBe('__kern_add(a, b * c)'); }); test('strict equality === lowers to Python ==', () => { @@ -134,7 +141,7 @@ describe('emitPyExpression — arithmetic + comparison + unary', () => { test('typeof in return body codegen does not throw on Python target', () => { const handler = makeHandler([{ type: 'return', props: { value: 'typeof value === "string"' }, children: [] }]); - expect(emitNativeKernBodyPython(handler)).toBe(`return ${TYPEOF_VALUE_PY} == "string"`); + expect(emitNativeKernBodyPython(handler)).toBe(`${PY_PRELUDE}return ${TYPEOF_VALUE_PY} == "string"`); }); test('typeof composes in Python if conditions', () => { @@ -145,15 +152,15 @@ describe('emitPyExpression — arithmetic + comparison + unary', () => { children: [{ type: 'return', props: { value: 'value' }, children: [] }], }, ]); - expect(emitNativeKernBodyPython(handler)).toBe(`if ${TYPEOF_VALUE_PY} == "string":\n return value`); + expect(emitNativeKernBodyPython(handler)).toBe(`${PY_PRELUDE}if ${TYPEOF_VALUE_PY} == "string":\n return value`); }); test('nested typeof and await keep stable temp numbering', () => { expect(emitPyExpression(parseExpression('typeof typeof value'))).toBe( - '("object" if (__k_typeof2 := (("object" if (__k_typeof1 := value) is None else "boolean" if isinstance(__k_typeof1, bool) else "number" if isinstance(__k_typeof1, (int, float)) else "string" if isinstance(__k_typeof1, str) else "function" if callable(__k_typeof1) else "object"))) is None else "boolean" if isinstance(__k_typeof2, bool) else "number" if isinstance(__k_typeof2, (int, float)) else "string" if isinstance(__k_typeof2, str) else "function" if callable(__k_typeof2) else "object")', + '("undefined" if (__k_typeof2 := (("undefined" if (__k_typeof1 := value) is _KERN_UNDEFINED else "object" if __k_typeof1 is None else "boolean" if isinstance(__k_typeof1, bool) else "number" if isinstance(__k_typeof1, (int, float)) else "string" if isinstance(__k_typeof1, str) else "function" if callable(__k_typeof1) else "object"))) is _KERN_UNDEFINED else "object" if __k_typeof2 is None else "boolean" if isinstance(__k_typeof2, bool) else "number" if isinstance(__k_typeof2, (int, float)) else "string" if isinstance(__k_typeof2, str) else "function" if callable(__k_typeof2) else "object")', ); expect(emitPyExpression(parseExpression('typeof await readValue()'))).toBe( - '("object" if (__k_typeof1 := (await readValue())) is None else "boolean" if isinstance(__k_typeof1, bool) else "number" if isinstance(__k_typeof1, (int, float)) else "string" if isinstance(__k_typeof1, str) else "function" if callable(__k_typeof1) else "object")', + '("undefined" if (__k_typeof1 := (await readValue())) is _KERN_UNDEFINED else "object" if __k_typeof1 is None else "boolean" if isinstance(__k_typeof1, bool) else "number" if isinstance(__k_typeof1, (int, float)) else "string" if isinstance(__k_typeof1, str) else "function" if callable(__k_typeof1) else "object")', ); }); @@ -442,16 +449,20 @@ describe('Cross-target parity — slice 2 stdlib hard cases', () => { describe('Review fixes — Python', () => { test('`??` nullish coalesce lowers to Python ternary with None check', () => { - expect(emitPyExpression(parseExpression('user ?? guest'))).toBe('(user if user is not None else guest)'); + expect(emitPyExpression(parseExpression('user ?? guest'))).toBe( + '(user if (user is not None and user is not _KERN_UNDEFINED) else guest)', + ); }); test('`??` on member chain also works', () => { - expect(emitPyExpression(parseExpression('user.id ?? 0'))).toBe('(user.id if user.id is not None else 0)'); + expect(emitPyExpression(parseExpression('user.id ?? 0'))).toBe( + '(user.id if (user.id is not None and user.id is not _KERN_UNDEFINED) else 0)', + ); }); test('`??` with side-effecting left side uses walrus for single-eval (slice 4c)', () => { expect(emitPyExpression(parseExpression('call() ?? b'))).toBe( - '(__k_nc1 if (__k_nc1 := call()) is not None else b)', + '(__k_nc1 if ((__k_nc1 := call()) is not None and __k_nc1 is not _KERN_UNDEFINED) else b)', ); }); @@ -466,8 +477,9 @@ describe('Review fixes — Python', () => { test('non-comparison binary ops do NOT trigger force-paren', () => { // `a + b - c` should NOT get extra parens (force-paren only applies to - // comparison-comparison nesting). - expect(emitPyExpression(parseExpression('a + b - c'))).toBe('a + b - c'); + // comparison-comparison nesting). The `+` lowers to __kern_add; the `-` + // is a non-`+` op and stays verbatim. + expect(emitPyExpression(parseExpression('a + b - c'))).toBe('__kern_add(a, b) - c'); }); test('stdlib arity mismatch — Python target also throws', () => { diff --git a/packages/python/tests/native-handlers-slice3-python.test.ts b/packages/python/tests/native-handlers-slice3-python.test.ts index c237f74c..0c806940 100644 --- a/packages/python/tests/native-handlers-slice3-python.test.ts +++ b/packages/python/tests/native-handlers-slice3-python.test.ts @@ -32,6 +32,7 @@ import { emitNativeKernBodyPythonWithImports, emitPyExpression, } from '../src/codegen-body-python.js'; +import { KERN_FMT_HELPER_PY } from '../src/core/expr/helpers.js'; import { generateFunction } from '../src/generators/core.js'; function makeHandler(children: IRNode[]): IRNode { @@ -46,6 +47,10 @@ function makeFn(props: Record, handlerChildren: IRNode[], param }; } +// JS value→string coercion runtime prelude (sentinel + _kern_fmt + __kern_add), +// prepended whenever a body lowers a `+` to __kern_add (string-coercion guard). +const PY_PRELUDE = `${KERN_FMT_HELPER_PY}\n\n`; + // ── 3a: symbol map (snake_case rename) ──────────────────────────────────── describe('slice 3a — Python symbol-map for snake_case params', () => { @@ -67,7 +72,7 @@ describe('slice 3a — Python symbol-map for snake_case params', () => { test('identifiers absent from symbolMap pass through unchanged', () => { const handler = makeHandler([{ type: 'return', props: { value: 'localVar + helperFn(x)' } }]); const out = emitNativeKernBodyPython(handler, { symbolMap: { onlyThisOne: 'only_this_one' } }); - expect(out).toBe('return localVar + helperFn(x)'); + expect(out).toBe(`${PY_PRELUDE}return __kern_add(localVar, helperFn(x))`); }); test('without symbolMap (legacy slice 1/2 callers) bodies emit unchanged', () => { diff --git a/packages/python/tests/native-handlers-slice4c-nullish.test.ts b/packages/python/tests/native-handlers-slice4c-nullish.test.ts index ac497dca..eb3a958b 100644 --- a/packages/python/tests/native-handlers-slice4c-nullish.test.ts +++ b/packages/python/tests/native-handlers-slice4c-nullish.test.ts @@ -21,30 +21,32 @@ import { emitPyExpression } from '../src/codegen-body-python.js'; describe('slice 4c — ?? nullish coalesce on Python target', () => { test('ident left lowers to readable double-name form', () => { - expect(emitPyExpression(parseExpression('user ?? guest'))).toBe('(user if user is not None else guest)'); + expect(emitPyExpression(parseExpression('user ?? guest'))).toBe( + '(user if (user is not None and user is not _KERN_UNDEFINED) else guest)', + ); }); test('member chain left also uses double-name form (pure receiver)', () => { expect(emitPyExpression(parseExpression('user.name ?? "anon"'))).toBe( - '(user.name if user.name is not None else "anon")', + '(user.name if (user.name is not None and user.name is not _KERN_UNDEFINED) else "anon")', ); }); test('deep member chain stays in pure form', () => { expect(emitPyExpression(parseExpression('user.profile.email ?? "no-email"'))).toBe( - '(user.profile.email if user.profile.email is not None else "no-email")', + '(user.profile.email if (user.profile.email is not None and user.profile.email is not _KERN_UNDEFINED) else "no-email")', ); }); test('call() left switches to walrus for single-eval', () => { expect(emitPyExpression(parseExpression('fetchName() ?? "default"'))).toBe( - '(__k_nc1 if (__k_nc1 := fetchName()) is not None else "default")', + '(__k_nc1 if ((__k_nc1 := fetchName()) is not None and __k_nc1 is not _KERN_UNDEFINED) else "default")', ); }); test('await left switches to walrus', () => { expect(emitPyExpression(parseExpression('(await loadName()) ?? "default"'))).toBe( - '(__k_nc1 if (__k_nc1 := await loadName()) is not None else "default")', + '(__k_nc1 if ((__k_nc1 := await loadName()) is not None and __k_nc1 is not _KERN_UNDEFINED) else "default")', ); }); @@ -53,7 +55,7 @@ describe('slice 4c — ?? nullish coalesce on Python target', () => { // double-name form (re-evaluating a + b is technically fine for pure // arithmetic, but the purity heuristic conservatively walrus-binds). expect(emitPyExpression(parseExpression('(a + b) ?? 0'))).toBe( - '(__k_nc1 if (__k_nc1 := a + b) is not None else 0)', + '(__k_nc1 if ((__k_nc1 := __kern_add(a, b)) is not None and __k_nc1 is not _KERN_UNDEFINED) else 0)', ); }); @@ -61,7 +63,7 @@ describe('slice 4c — ?? nullish coalesce on Python target', () => { // a ?? (call() ?? b) — outer pure (a is ident), inner non-pure (call). // Inner gets walrus __k_nc1; outer stays in double-name form. expect(emitPyExpression(parseExpression('a ?? (call() ?? b)'))).toBe( - '(a if a is not None else (__k_nc1 if (__k_nc1 := call()) is not None else b))', + '(a if (a is not None and a is not _KERN_UNDEFINED) else (__k_nc1 if ((__k_nc1 := call()) is not None and __k_nc1 is not _KERN_UNDEFINED) else b))', ); }); @@ -71,7 +73,7 @@ describe('slice 4c — ?? nullish coalesce on Python target', () => { // side which doesn't itself trigger walrus (since walrus only fires on // the LEFT of a ??). expect(emitPyExpression(parseExpression('call1() ?? call2()'))).toBe( - '(__k_nc1 if (__k_nc1 := call1()) is not None else call2())', + '(__k_nc1 if ((__k_nc1 := call1()) is not None and __k_nc1 is not _KERN_UNDEFINED) else call2())', ); }); @@ -79,7 +81,7 @@ describe('slice 4c — ?? nullish coalesce on Python target', () => { // Number.floor(x) lowers to __k_math.floor(x) — a call expression, // hence non-pure for the purity check, hence walrus. expect(emitPyExpression(parseExpression('Number.floor(x) ?? 0'))).toBe( - '(__k_nc1 if (__k_nc1 := __k_math.floor(x)) is not None else 0)', + '(__k_nc1 if ((__k_nc1 := __k_math.floor(x)) is not None and __k_nc1 is not _KERN_UNDEFINED) else 0)', ); }); }); diff --git a/packages/python/tests/native-handlers-stdlib-python.test.ts b/packages/python/tests/native-handlers-stdlib-python.test.ts index b0a34504..7d1731e0 100644 --- a/packages/python/tests/native-handlers-stdlib-python.test.ts +++ b/packages/python/tests/native-handlers-stdlib-python.test.ts @@ -45,7 +45,7 @@ describe('emitPyExpression — KERN-stdlib dispatch (Text module)', () => { test('lambda callbacks lower to Python lambda expressions', () => { expect(emitPyExpression(parseExpression('visit(() => value)'))).toBe('visit(lambda: value)'); - expect(emitPyExpression(parseExpression('visit((a, b) => a + b)'))).toBe('visit(lambda a, b: a + b)'); + expect(emitPyExpression(parseExpression('visit((a, b) => a + b)'))).toBe('visit(lambda a, b: __kern_add(a, b))'); expect(emitPyExpression(parseExpression('visit(user => user.name)'))).toBe('visit(lambda user: user.name)'); expect(emitPyExpression(parseExpression('visit((user: User) => user.name)'))).toBe('visit(lambda user: user.name)'); }); diff --git a/scripts/class-conformance.mjs b/scripts/class-conformance.mjs index 36e93643..c79cbb34 100644 --- a/scripts/class-conformance.mjs +++ b/scripts/class-conformance.mjs @@ -9,9 +9,11 @@ * targets BY CONSTRUCTION (both derive from one definition), not by hand-diffing * two emitters. * - * Scope: portable probes only (number/string ops). List mutation needs a - * portable list-append lowering and is exercised separately (unit tests prove - * the instance-field-default isolation directly). + * Scope: portable probes (number/string ops) plus portable list mutation — + * `arr.push(x)` lowers identically in class methods and route handlers via the + * shared `core/expr/list-ops` module, so per-instance list isolation is proven + * differentially here (not only in unit tests). Other list ops (`.length`, + * `.slice`, …) are a tracked follow-up; `.map`/`.filter` stay per-path. * * Run: node scripts/class-conformance.mjs (or via `pnpm check:class-conformance`) */ @@ -147,6 +149,27 @@ fn name=probe returns=number return value="Counter.count"`, expected: 10, }, + { + name: 'portable list mutation: per-instance isolation + push return parity', + kern: `class name=Bag export=true + field name=items type=number[] value={{ [] }} + method name=add returns=number + param name=x type=number + handler + return value="this.items.push(x)" +fn name=probe returns=number + handler + let name=a value="new Bag()" + let name=b value="new Bag()" + do value="a.add(10)" + do value="a.add(20)" + return value="b.add(99)"`, + // Discriminating: b is a SEPARATE instance, so b.add returns 1 — not 3. + // Kills (a) shared-mutable-default (items aliased -> b.add returns 3), + // (b) push not lowered (Python `list.push` -> AttributeError, ts != py), + // (c) push without JS return parity (`append` returns None -> b.add != 1). + expected: 1, + }, { name: 'inherited + overridden static accessor (metaclass chaining)', kern: `class name=Base export=true @@ -172,6 +195,93 @@ fn name=probe returns=number return value="Derived.val"`, expected: 12, }, + { + name: 'abstract class: erased at codegen, polymorphic dispatch to override', + kern: `class name=Shape abstract=true export=true + method name=area returns=number +class name=Square extends=Shape export=true + field name=side type=number value={{ 3 }} + method name=area returns=number + handler + return value="this.side * this.side" +fn name=measure returns=number + param name=shape type=Shape + handler + return value="shape.area()" +fn name=probe returns=number + handler + return value="measure(new Square())"`, + // `abstract` is erased on both targets (plain instantiable class), so a + // Shape-typed reference dispatches to Square.area on TS AND Python. + // Kills: Python dropping the override (AttributeError), the abstract base + // stub running instead of the override (NotImplementedError != 9), and any + // ABC/metaclass lowering that would make `new Square()` diverge. + expected: 9, + }, + { + name: 'abstract method: template method calls override + inherited field default', + kern: `class name=Formatter abstract=true export=true + field name=prefix type=string value={{ "[" }} + method name=suffix returns=string + method name=format returns=string + param name=input type=string + handler + return value="\`\${this.prefix}\${input}\${this.suffix()}\`" +class name=BracketFormatter extends=Formatter export=true + method name=suffix returns=string + handler + return value="\\"]\\"" +fn name=probe returns=string + handler + return value="new BracketFormatter().format(\\"test\\")"`, + // The concrete `format` (inherited) reads the inherited field default + // `prefix` and calls the abstract `suffix`, which dispatches to the override. + // Kills: dropped inherited field default, dropped inherited concrete method, + // and the abstract stub running instead of the BracketFormatter override. + expected: '[test]', + }, + { + name: 'abstract static accessor: override dispatches through chained metaclass', + kern: `class name=Base abstract=true export=true + getter name=tag static=true returns=string +class name=Impl extends=Base export=true + getter name=tag static=true returns=string + handler + return value="\\"impl\\"" +fn name=probe returns=string + handler + return value="Impl.tag"`, + // Abstract static getter on Base (fail-fast raise stub) + override on Impl, + // dispatched through the chained metaclass _ImplMeta(type(Base)). Reading + // Impl.tag resolves to the override on BOTH targets. Kills a Python lowering + // where the abstract static stub is `pass` (returns None) instead of a raise, + // or where the chained metaclass drops the override. + expected: 'impl', + }, + { + name: 'derived constructor without super(): implicit base-init injected', + kern: `class name=Base export=true + field name=tag type=number value={{ 1 }} +class name=Box extends=Base export=true + field name=x type=number value={{ 0 }} + constructor + param name=v type=number + handler + assign target="this.x" value="v" + method name=get returns=number + handler + return value="this.x + this.tag" +fn name=probe returns=number + handler + return value="new Box(7).get()"`, + // Box's constructor touches `this.x` but never calls super(). KERN injects an + // implicit super() FIRST on both targets, so (a) TS doesn't crash with "must + // call super before this", and (b) the base's `tag=1` default runs via that + // super, giving get() = 7 + 1. Kills: no super injected (TS crash); super + // injected AFTER this.x (TS crash); base init skipped (this.tag undefined -> + // NaN on TS / AttributeError on Python); field defaults before super. + expected: 8, + }, ]; const canon = (v) => JSON.stringify(v); diff --git a/scripts/coercion-conformance.mjs b/scripts/coercion-conformance.mjs new file mode 100644 index 00000000..efd50f94 --- /dev/null +++ b/scripts/coercion-conformance.mjs @@ -0,0 +1,130 @@ +/** + * Coercion differential conformance — KERN single-source value→string parity. + * + * KERN is one language emitted to BOTH TypeScript and Python; the contract is + * parity by construction. JS coerces values to strings with well-known rules + * (`true`→"true", `null`→"null", `undefined`→"undefined", `1.0`→"1", + * `[1,2,3]`→"1,2,3", `"a"+true`→"atrue") that Python's `str()`/`+` do NOT match + * (`True`/`None`/`1.0`/`[1, 2, 3]`/TypeError). Implicit coercion sites — + * template interpolation `${x}` and string `+` concatenation — must therefore + * be lowered to JS semantics on the Python target (TS already IS JS). + * + * Each fixture is a zero-arg `fn probe` whose return value exercises one + * coercion. The module is compiled through BOTH codegen paths (core → TS, + * python → pure Python), each driver calls `probe()` and prints its + * JSON-normalized return, and we assert ts == python == expected. Expected + * values are JS/TS truth, so the oracle is correct by construction. + * + * Discrimination: most fixtures are RED at base (Python diverges) and force the + * implementation. Four are GREEN guards that must STAY green — `2 + 3 == 5` + * catches an additive `+` that over-coerces to string concat, and the `??` + * fixtures (notably `undefined ?? 9 == 9`) catch an `undefined` representation + * that stops being nullish. A half-built fix turns a guard RED. + * + * Run: node scripts/coercion-conformance.mjs (or via `pnpm check:coercion-conformance`) + */ + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO = dirname(dirname(fileURLToPath(import.meta.url))); +const { parse, generateCoreNode } = await import(join(REPO, 'packages/core/dist/index.js')); +const { generatePythonCoreNode } = await import(join(REPO, 'packages/python/dist/codegen-python.js')); +const tsCompiler = await import('typescript'); + +// A big decimal float literal (KERN has no `e` exponent syntax) whose square +// overflows IEEE-754 double → inf on Python / Infinity on JS. Used to exercise +// the non-finite-float coercion branch at runtime. +const HUGE = `1${'0'.repeat(200)}.0`; + +// Each fixture: a probe() returning the value under test. `expected` is JS/TS truth. +const FIXTURES = [ + // ── Template interpolation: scalar coercion ─────────────────────────────── + { name: 'bool in template', ret: 'string', expr: '`${true} ${false}`', expected: 'true false' }, + { name: 'null in template', ret: 'string', expr: '`${null}`', expected: 'null' }, + { name: 'undefined in template', ret: 'string', expr: '`${undefined}`', expected: 'undefined' }, + { name: 'integer-valued float in template', ret: 'string', expr: '`${1.0} ${2.5}`', expected: '1 2.5' }, + // ── Template interpolation: array / object toString ─────────────────────── + { name: 'flat array in template', ret: 'string', expr: '`${[1, 2, 3]}`', expected: '1,2,3' }, + { name: 'nested array in template (recursive)', ret: 'string', expr: '`${[1, [2, 3]]}`', expected: '1,2,3' }, + { name: 'array with nullish elements → empty', ret: 'string', expr: '`${[null, undefined, 3]}`', expected: ',,3' }, + // ── String `+` concatenation coercion ───────────────────────────────────── + { name: 'concat string + number', ret: 'string', expr: '"n=" + 5', expected: 'n=5' }, + { name: 'concat string + bool', ret: 'string', expr: '"a" + true', expected: 'atrue' }, + // ── Mixed ───────────────────────────────────────────────────────────────── + { name: 'mixed template (arith + bool)', ret: 'string', expr: '`count: ${1 + 2}, ok: ${true}`', expected: 'count: 3, ok: true' }, + // ── `+` with nullish: JS numeric ToNumber coercion (null→0, undef→NaN) ───── + { name: 'number + null → numeric (null→0)', ret: 'number', expr: '5 + null', expected: 5 }, + { name: 'null + number → numeric (null→0)', ret: 'number', expr: 'null + 5', expected: 5 }, + { name: 'number + bool → numeric (true→1)', ret: 'number', expr: '5 + true', expected: 6 }, + { name: 'number + undefined → NaN renders "NaN"', ret: 'string', expr: '`${5 + undefined}`', expected: 'NaN' }, + // ── Non-finite floats: JS String() → "Infinity"/"-Infinity" (Python str → "inf") ── + { name: 'Infinity renders "Infinity"', ret: 'string', expr: `\`\${${HUGE} * ${HUGE}}\``, expected: 'Infinity' }, + { name: 'negative Infinity renders "-Infinity"', ret: 'string', expr: `\`\${-${HUGE} * ${HUGE}}\``, expected: '-Infinity' }, + // ── GUARD fixtures — currently GREEN, must STAY green (catch over-fixes) ─── + { name: 'GUARD numeric + stays additive', ret: 'number', expr: '2 + 3', expected: 5 }, + { name: 'GUARD nullish keeps present value', ret: 'number', expr: '5 ?? 9', expected: 5 }, + { name: 'GUARD null is nullish', ret: 'number', expr: 'null ?? 9', expected: 9 }, + { name: 'GUARD undefined stays nullish', ret: 'number', expr: 'undefined ?? 9', expected: 9 }, +]; + +function canon(value) { + return JSON.stringify(value); +} + +const dir = mkdtempSync(join(tmpdir(), 'kern-coercion-conformance-')); +process.on('exit', () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort tmp cleanup — never fail the run on it + } +}); + +let pass = 0; +const failures = []; + +for (let i = 0; i < FIXTURES.length; i++) { + const fx = FIXTURES[i]; + try { + const kern = `fn name=probe returns=${fx.ret}\n handler\n return value=${JSON.stringify(fx.expr)}`; + const root = parse(kern); + const topNodes = root.type === 'class' || root.type === 'fn' ? [root] : (root.children ?? []); + + const tsSource = `${topNodes.map((n) => generateCoreNode(n).join('\n')).join('\n\n')}\nconsole.log(JSON.stringify(probe()));`; + const tsFile = join(dir, `mod-${i}.mjs`); + writeFileSync( + tsFile, + tsCompiler.transpileModule(tsSource, { + compilerOptions: { module: tsCompiler.ModuleKind.ESNext, target: tsCompiler.ScriptTarget.ES2022 }, + }).outputText, + ); + + const pySource = `import json\n${topNodes.map((n) => generatePythonCoreNode(n).join('\n')).join('\n\n')}\nprint(json.dumps(probe()))`; + const pyFile = join(dir, `mod-${i}.py`); + writeFileSync(pyFile, pySource); + + const opts = { encoding: 'utf8', timeout: 10_000 }; + const tsOut = JSON.parse(execFileSync('node', [tsFile], opts).trim()); + const pyOut = JSON.parse(execFileSync('python3', [pyFile], opts).trim()); + + if (canon(tsOut) === canon(fx.expected) && canon(pyOut) === canon(fx.expected)) { + pass++; + } else { + failures.push({ name: fx.name, expected: fx.expected, ts: tsOut, py: pyOut }); + } + } catch (err) { + failures.push({ name: fx.name, error: err?.stderr?.toString?.() || err?.message || String(err) }); + } +} + +console.log(`Coercion conformance: ${pass}/${FIXTURES.length} fixtures passed (ts == python == expected)`); +for (const f of failures) { + if (f.error) console.error(` FAIL ${f.name}: ${f.error}`); + else console.error(` FAIL ${f.name}: expected ${canon(f.expected)} | ts ${canon(f.ts)} | py ${canon(f.py)}`); +} +if (failures.length > 0) process.exit(1); +console.log('All passed.');