diff --git a/package.json b/package.json index f8f118e4..1952fa1a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "packageManager": "pnpm@10.32.1", - "description": "KERN — backend structure and portable route logic for TypeScript/Express and Python/FastAPI parity.", + "description": "KERN \u2014 backend structure and portable route logic for TypeScript/Express and Python/FastAPI parity.", "author": "cukas", "repository": { "type": "git", @@ -18,7 +18,7 @@ "test:non-semantics": "pnpm -r --filter '!kern-monorepo' --filter '!@kernlang/review-python' test --testPathIgnorePatterns=ir-semantics && pnpm test:prepush && pnpm check:rule-coverage", "check:rule-coverage": "node ./scripts/check-rule-coverage.mjs", "check:python-codegen": "pnpm --filter @kernlang/core --filter @kernlang/python build && node ./scripts/lift-rate-python.mjs --check", - "check:conformance": "pnpm --filter @kernlang/core --filter @kernlang/python --filter @kernlang/express build && node ./scripts/conformance.mjs", + "check:conformance": "pnpm --filter @kernlang/core --filter @kernlang/python --filter @kernlang/express build && node ./scripts/conformance.mjs && node ./scripts/class-conformance.mjs", "docs:contracts": "pnpm --filter @kernlang/core build && node ./scripts/generate-ir-semantics-docs.mjs --format=markdown --out=-", "docs:contracts:json": "pnpm --filter @kernlang/core build && node ./scripts/generate-ir-semantics-docs.mjs --format=json --out=generated/contracts/registry.json", "docs:contracts:check": "pnpm --filter @kernlang/core build && node ./scripts/check-contract-docs.mjs", @@ -32,7 +32,8 @@ "lint:fix": "biome check --fix", "format": "biome format --write", "prepush": "node ./scripts/pre-push.mjs", - "prepare": "node ./scripts/install-git-hooks.mjs" + "prepare": "node ./scripts/install-git-hooks.mjs", + "check:class-conformance": "pnpm --filter @kernlang/core --filter @kernlang/python build && node ./scripts/class-conformance.mjs" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/packages/python/src/codegen-body-python.ts b/packages/python/src/codegen-body-python.ts index 4fb213fa..4266a8ac 100644 --- a/packages/python/src/codegen-body-python.ts +++ b/packages/python/src/codegen-body-python.ts @@ -74,6 +74,15 @@ export interface BodyEmitOptions { * the KERN-form `userId` resolves to the snake_cased Python parameter. * Identifiers not in the map pass through unchanged. */ symbolMap?: Record; + /** When true, the handler is a class member body: identifier `super` + * lowers to Python `super()` (so `super.m()` -> `super().m()`) and a + * direct `super(...)` call lowers to `super().__init__(...)`. Paired with + * a `symbolMap` entry `this -> self` by the class generator. */ + inClassBody?: boolean; + /** When true, the handler is specifically a constructor body, so a direct + * `super(...)` call lowers to `super().__init__(...)`. Outside a constructor + * `super(...)` is not a parent-constructor call and is left untouched. */ + inConstructor?: boolean; /** Slice 4a review fix (Gemini #5) — how to lower the `?` propagation * hoist's err-branch return: * - 'value' (default for `fn`): `return __k_tN` so the caller sees @@ -137,6 +146,8 @@ interface BodyEmitContext { * `each` pair-mode). Consumer emits each entry at module scope. */ helpers: Set; symbolMap: Record; + inClassBody: boolean; + inConstructor: boolean; shadowedSymbols: Set; localScopes: Array>; regexScopes: Array | null>>; @@ -171,6 +182,8 @@ function freshCtx(options?: BodyEmitOptions): BodyEmitContext { imports: new Set(), helpers: new Set(), symbolMap: options?.symbolMap ?? {}, + inClassBody: options?.inClassBody ?? false, + inConstructor: options?.inConstructor ?? false, shadowedSymbols: new Set(), localScopes: [], regexScopes: [], @@ -1695,6 +1708,7 @@ function emitPyExprCtx(node: ValueIR, ctx: BodyEmitContext): string { // Python-form `user_id`. Identifiers not in the map (locals, globals, // module names) pass through unchanged. if (ctx.shadowedSymbols.has(node.name)) return node.name; + if (ctx.inClassBody && node.name === 'super') return 'super()'; return ctx.symbolMap[node.name] ?? node.name; } case 'member': @@ -2101,6 +2115,10 @@ 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 }; + 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})` }; + } if (node.callee.kind === 'ident' && node.callee.name === 'String') { if (node.args.length !== 1) { throw new Error('String() portable coercion expects exactly one argument on Python target.'); diff --git a/packages/python/src/codegen-python.ts b/packages/python/src/codegen-python.ts index a3950b84..0fd5a782 100644 --- a/packages/python/src/codegen-python.ts +++ b/packages/python/src/codegen-python.ts @@ -27,6 +27,7 @@ import { // Data layer generators (model, repository, cache, dependency, service, union) import { generatePythonCache, + generatePythonClass, generatePythonDependency, generatePythonModel, generatePythonRepository, @@ -180,6 +181,8 @@ export function generatePythonCoreNode(node: IRNode, options: PythonCodegenOptio return generatePythonDependency(node); case 'service': return generatePythonService(node); + case 'class': + return generatePythonClass(node); case 'union': return generatePythonUnion(node); // Backend infrastructure diff --git a/packages/python/src/generators/data.ts b/packages/python/src/generators/data.ts index cf88b89d..242da074 100644 --- a/packages/python/src/generators/data.ts +++ b/packages/python/src/generators/data.ts @@ -22,7 +22,10 @@ import { mapTsTypeToPython, toSnakeCase } from '../type-map.js'; * * When the handler is legacy raw, returns `{ code: handlerCode(method), * imports: empty }`. */ -function methodBodyCodePython(method: IRNode): { code: string; imports: Set; helpers: Set } { +function methodBodyCodePython( + method: IRNode, + opts?: { classBody?: boolean; isConstructor?: boolean; staticReceiver?: boolean }, +): { code: string; imports: Set; helpers: Set } { const handler = getFirstChild(method, 'handler'); if (!handler || getProps(handler).lang !== 'kern') { return { code: handlerCode(method), imports: new Set(), helpers: new Set() }; @@ -54,7 +57,15 @@ function methodBodyCodePython(method: IRNode): { code: string; imports: Set `cls`. + if (opts?.classBody) symbolMap.this = opts?.staticReceiver ? 'cls' : 'self'; + const { code, imports, helpers } = emitNativeKernBodyPythonWithImports(handler, { + symbolMap, + inClassBody: opts?.classBody ?? false, + inConstructor: opts?.isConstructor ?? false, + }); return { code, imports, helpers }; } @@ -64,8 +75,11 @@ function methodBodyCodePython(method: IRNode): { code: string; imports: Set }}` block parses to `{ __expr: true, code: '' }`; + * a bare `default=...` is a raw string. `new X(...)` -> `X(...)`; literals go + * through formatPythonDefault (true/false/null/number/string handling). */ +function fieldDefaultPython(field: IRNode): string | undefined { + const fp = p(field); + const v = fp.value as unknown; + let code: string | undefined; + if (v && typeof v === 'object' && (v as { __expr?: boolean }).__expr) { + code = (v as { code?: string }).code; + } else if (typeof v === 'string') { + code = v; + } else if (typeof fp.default === 'string') { + code = fp.default as string; + } + if (code === undefined) return undefined; + return formatPythonDefault(code.replace(/\bnew\s+/g, ''), (fp.type as string) || ''); +} + // SQLModel column override: pydantic validator types -> plain DB types for column declarations const SQLMODEL_COLUMN_OVERRIDE: Record = { Email: 'str', @@ -455,6 +488,178 @@ export function generatePythonService(node: IRNode): string[] { return lines; } +// ── Class (single-source class slice, Python target) ──────────────────── +// Phase 1: structural shell parity with the TS `emitClassBody`. Emits the +// class header + `extends` base, static fields as class attributes, the +// constructor as `__init__`, instance/static methods, and getters/setters via +// `@property`. Method/ctor bodies route through the shared +// `methodBodyLinesPython`; full class-body symbol translation +// (`this`->`self`, `super.m()`->`super().m()`, `new X()`->`X()`) is the next +// sub-problem the differential class fixtures will drive. +export function generatePythonClass(node: IRNode): string[] { + const props = p(node); + const name = emitIdentifier(props.name as string, 'UnknownClass', node); + const baseRaw = typeof props.extends === 'string' ? (props.extends as string) : ''; + const base = baseRaw ? emitIdentifier(baseRaw, 'object', node) : ''; + + const isStatic = (n: IRNode): boolean => { + const np = p(n); + return np.static === 'true' || np.static === true; + }; + + const fields = kids(node, 'field'); + const staticFields = fields.filter(isStatic); + const methods = kids(node, 'method'); + const getters = kids(node, 'getter'); + const setters = kids(node, 'setter'); + const ctor = firstChild(node, 'constructor'); + + // Static accessors (static get/set) lower to a per-class metaclass: both + // `Box.label` reads and `Box.label = x` writes dispatch through the metaclass + // @property/.setter (a plain descriptor would be shadowed on assignment). The + // static backing field stays a class attribute. The metaclass extends + // `type()` so that when the base ALSO has static accessors the derived + // metaclass subclasses the base metaclass (no `metaclass conflict`, and the + // base's static accessors are inherited); when the base has none, `type()` + // is just `type`. + const staticGetters = getters.filter(isStatic); + const staticSetters = setters.filter(isStatic); + const metaName = `_${name}Meta`; + const metaLines: string[] = []; + if (staticGetters.length + staticSetters.length > 0) { + metaLines.push(`class ${metaName}(${base ? `type(${base})` : 'type'}):`); + const metaGetterNames = new Set(); + for (const g of staticGetters) { + const gp = p(g); + const gname = toSnakeCase((gp.name as string) || 'prop'); + const returns = gp.returns ? ` -> ${mapTsTypeToPython(gp.returns as string)}` : ''; + metaGetterNames.add(gname); + metaLines.push(' @property'); + metaLines.push(` def ${gname}(cls)${returns}:`); + metaLines.push(...methodBodyLinesPython(g, { classBody: true, staticReceiver: true })); + metaLines.push(''); + } + for (const s of staticSetters) { + const sname = toSnakeCase((p(s).name as string) || 'prop'); + if (!metaGetterNames.has(sname)) { + metaLines.push(' @property'); + metaLines.push(` def ${sname}(cls): # write-only static property`); + metaLines.push(' return None'); + metaLines.push(''); + metaGetterNames.add(sname); + } + metaLines.push(` @${sname}.setter`); + metaLines.push(` def ${sname}(cls, ${buildPythonParamList(s, { selfPrefix: false })}):`); + metaLines.push(...methodBodyLinesPython(s, { classBody: true, staticReceiver: true })); + metaLines.push(''); + } + } + const baseParts = [base, metaLines.length > 0 ? `metaclass=${metaName}` : ''].filter(Boolean); + const header = baseParts.length > 0 ? `class ${name}(${baseParts.join(', ')}):` : `class ${name}:`; + + const body: string[] = []; + + // Static fields -> class-level attributes (shared across instances, like TS statics). + for (const f of staticFields) { + const fp = p(f); + const fname = toSnakeCase((fp.name as string) || 'field'); + const ftype = fp.type ? mapTsTypeToPython(fp.type as string) : 'Any'; + body.push(` ${fname}: ${ftype} = ${fieldDefaultPython(f) ?? 'None'}`); + } + if (staticFields.length > 0) body.push(''); + + // Constructor -> __init__. Instance-field defaults are emitted INSIDE __init__ + // (never as class-level attributes) so each instance gets a fresh value — + // matching TS per-instance field initialization and avoiding Python's + // shared-mutable-default trap (a class-level `items = []` would be shared by + // every instance). Defaults precede the constructor body, which may reassign + // them (TS field-init-then-constructor order). + const instanceDefaults = fields.filter((f) => !isStatic(f) && fieldDefaultPython(f) !== undefined); + const defaultLines = instanceDefaults.map( + (f) => ` self.${toSnakeCase((p(f).name as string) || 'field')} = ${fieldDefaultPython(f)}`, + ); + 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)); + } else { + body.push(...defaultLines, ...ctorLines); + } + body.push(''); + } else if (instanceDefaults.length > 0) { + // No explicit constructor. A derived class still forwards to its base + // initializer (TS subclasses without a constructor auto-forward args), then + // applies its own field defaults. + if (base) { + body.push(' def __init__(self, *args, **kwargs):'); + body.push(' super().__init__(*args, **kwargs)'); + } else { + body.push(' def __init__(self):'); + } + body.push(...defaultLines); + body.push(''); + } + + // Methods (instance + static). + for (const m of methods) { + const mp = p(m); + const mname = toSnakeCase((mp.name as string) || 'method'); + const asyncKw = mp.async === 'true' || mp.async === true ? 'async ' : ''; + const returns = mp.returns ? ` -> ${mapTsTypeToPython(mp.returns as string)}` : ''; + if (isStatic(m)) { + body.push(' @staticmethod'); + body.push(` ${asyncKw}def ${mname}(${buildPythonParamList(m, { selfPrefix: false })})${returns}:`); + } else { + body.push(` ${asyncKw}def ${mname}(${buildPythonParamList(m, { selfPrefix: true })})${returns}:`); + } + body.push(...methodBodyLinesPython(m, { classBody: !isStatic(m) })); + body.push(''); + } + + // Getters -> @property. Static getters were already emitted on the metaclass. + const instanceGetterNames = new Set(); + for (const g of getters) { + if (isStatic(g)) continue; + const gp = p(g); + const gname = toSnakeCase((gp.name as string) || 'prop'); + instanceGetterNames.add(gname); + const returns = gp.returns ? ` -> ${mapTsTypeToPython(gp.returns as string)}` : ''; + body.push(' @property'); + body.push(` def ${gname}(self)${returns}:`); + body.push(...methodBodyLinesPython(g, { classBody: true })); + body.push(''); + } + // Setters -> @.setter. Python requires a property to exist before its + // `.setter`; KERN allows setter-only properties, so synthesize a getter when + // none was declared (write-only -> returns None, matching a TS getter-less read). + for (const s of setters) { + if (isStatic(s)) continue; // static setters were already emitted on the metaclass + const sp = p(s); + const sname = toSnakeCase((sp.name as string) || 'prop'); + if (!instanceGetterNames.has(sname)) { + body.push(' @property'); + body.push(` def ${sname}(self): # write-only property (no getter declared in KERN)`); + body.push(' return None'); + body.push(''); + instanceGetterNames.add(sname); + } + body.push(` @${sname}.setter`); + body.push(` def ${sname}(${buildPythonParamList(s, { selfPrefix: true })}):`); + body.push(...methodBodyLinesPython(s, { classBody: true })); + body.push(''); + } + + if (body.length === 0) body.push(' pass'); + + // Metaclass (if any) must be defined before the class that references it. + return metaLines.length > 0 ? [...metaLines, header, ...body] : [header, ...body]; +} + // ── Union (Pydantic Discriminated Union) ──────────────────────────────── // union name=ContentSegment discriminant=type // variant name=prose diff --git a/packages/python/src/targets/python.ts b/packages/python/src/targets/python.ts index 35d1593a..ab921d9b 100644 --- a/packages/python/src/targets/python.ts +++ b/packages/python/src/targets/python.ts @@ -5,6 +5,7 @@ import { emitModels } from '../core/emit-models.js'; import { collectFenceDiagnostics } from '../core/fence-diagnostics.js'; import { emitPureHandlers } from '../core/handlers/index.js'; import { findServerNode } from '../fastapi-utils.js'; +import { generatePythonClass } from '../generators/data.js'; /** * The PyDotDict / _DotList shim, emitted at the top of every `--emit=backend` @@ -132,6 +133,11 @@ export function transpilePython(root: IRNode, config?: ResolvedKernConfig): Tran target: 'python', }); + // 3b. Class declarations -> pure Python classes. Additive: files without + // `class` nodes (e.g. the models-only byte-invariance corpus) are untouched. + const classNodes = root.type === 'class' ? [root] : (root.children ?? []).filter((child) => child.type === 'class'); + const classesCode = classNodes.map((node) => generatePythonClass(node).join('\n')).join('\n\n'); + const lines: string[] = []; // Sort and print imports @@ -166,6 +172,12 @@ export function transpilePython(root: IRNode, config?: ResolvedKernConfig): Tran lines.push(modelsCode); } + // Class definitions (pure Python — not FastAPI/Pydantic). + if (classesCode.trim().length > 0) { + lines.push(''); + lines.push(classesCode); + } + // Pure handlers (additive) if (handlersCode) { lines.push(''); diff --git a/packages/python/tests/class-python.test.ts b/packages/python/tests/class-python.test.ts new file mode 100644 index 00000000..9b54e0e3 --- /dev/null +++ b/packages/python/tests/class-python.test.ts @@ -0,0 +1,216 @@ +/** Single-source class slice — Python target. + * + * KERN `class` nodes lower to pure Python (NOT FastAPI/Pydantic) via + * `generatePythonClass`. Class member bodies translate through the shared + * Python body emitter with `inClassBody`/`inConstructor`: + * - `this` -> `self` (symbol map) + * - `super(args)` -> `super().__init__(args)` (constructor only) + * - `super.m()` / `super.x` -> `super().m()` / `super().x` (any member) + * + * Behaviour locked here was driven by an Agon review of the slice + * (setter-only synthesis + static-accessor skip closed two blocking findings). + */ + +import type { IRNode } from '@kernlang/core'; +import { generatePythonClass } from '../src/generators/data.js'; + +function handler(children: IRNode[]): IRNode { + return { type: 'handler', props: { lang: 'kern' }, children }; +} +function param(name: string, type?: string): IRNode { + return { type: 'param', props: type ? { name, type } : { name }, children: [] }; +} + +describe('Python class codegen (single-source class slice)', () => { + test('emits a pure-Python class: __init__, this->self, instance method, getter', () => { + const animal: IRNode = { + type: 'class', + props: { name: 'Animal' }, + children: [ + { + type: 'constructor', + props: {}, + children: [ + param('name', 'string'), + param('legs', 'number'), + handler([ + { type: 'assign', props: { target: 'this.name', value: 'name' }, children: [] }, + { type: 'assign', props: { target: 'this.legs', value: 'legs' }, children: [] }, + ]), + ], + }, + { + type: 'getter', + props: { name: 'legCount', returns: 'number' }, + children: [handler([{ type: 'return', props: { value: 'this.legs' }, children: [] }])], + }, + ], + }; + const code = generatePythonClass(animal).join('\n'); + expect(code).toContain('class Animal:'); + expect(code).toContain('def __init__(self, name: str, legs: float):'); + expect(code).toContain('self.name = name'); + expect(code).toContain('@property'); + expect(code).toContain('def leg_count(self) -> float:'); + expect(code).toContain('return self.legs'); + expect(code).not.toContain('this.'); // no JS-ism leaks + }); + + test('inheritance: super(...) -> super().__init__ in constructor, super.m() -> super().m()', () => { + const dog: IRNode = { + type: 'class', + props: { name: 'Dog', extends: 'Animal' }, + children: [ + { + type: 'constructor', + props: {}, + children: [ + param('name', 'string'), + handler([{ type: 'do', props: { value: 'super(name, 4)' }, children: [] }]), + ], + }, + { + type: 'method', + props: { name: 'summary', returns: 'string' }, + children: [handler([{ type: 'return', props: { value: '`${super.describe()}`' }, children: [] }])], + }, + ], + }; + const code = generatePythonClass(dog).join('\n'); + expect(code).toContain('class Dog(Animal):'); + expect(code).toContain('super().__init__(name, 4)'); + expect(code).toContain('super().describe()'); + }); + + test('setter-only property synthesizes a write-only getter (valid Python, no NameError)', () => { + const box: IRNode = { + type: 'class', + props: { name: 'Box' }, + children: [ + { + type: 'setter', + props: { name: 'items' }, + children: [ + param('next', 'object[]'), + handler([{ type: 'assign', props: { target: 'this.store', value: 'next' }, children: [] }]), + ], + }, + ], + }; + const code = generatePythonClass(box).join('\n'); + expect(code).toContain('def items(self):'); // synthesized getter precedes the setter + expect(code).toContain('@items.setter'); + }); + + test('static accessors lower to a per-class metaclass property (this -> cls)', () => { + const reg: IRNode = { + type: 'class', + props: { name: 'Reg' }, + children: [ + { + type: 'getter', + props: { name: 'label', static: 'true', returns: 'string' }, + children: [handler([{ type: 'return', props: { value: 'this.store' }, children: [] }])], + }, + { + type: 'setter', + props: { name: 'label', static: 'true' }, + children: [ + param('v', 'string'), + handler([{ type: 'assign', props: { target: 'this.store', value: 'v' }, children: [] }]), + ], + }, + ], + }; + const code = generatePythonClass(reg).join('\n'); + expect(code).toContain('class _RegMeta(type):'); + expect(code).toContain('class Reg(metaclass=_RegMeta):'); + expect(code).toContain('def label(cls) -> str:'); + expect(code).toContain('return cls.store'); // this -> cls inside a static accessor + expect(code).toContain('@label.setter'); + expect(code).not.toContain('def label(self)'); + }); + + test('instance-field defaults emit in __init__, never as a shared class attr', () => { + const bag: IRNode = { + type: 'class', + props: { name: 'Bag' }, + children: [ + { + type: 'field', + props: { name: 'items', type: 'object[]', value: { __expr: true, code: '[]' } }, + children: [], + }, + { + type: 'field', + props: { name: 'tag', type: 'string', value: { __expr: true, code: '"empty"' } }, + children: [], + }, + ], + }; + const code = generatePythonClass(bag).join('\n'); + expect(code).toContain('def __init__(self):'); + expect(code).toContain('self.items = []'); + expect(code).toContain('self.tag = "empty"'); + // Shared-mutable-default trap: instance fields must NOT become class-level attrs. + expect(code).not.toMatch(/^ {4}items\s*[:=]/m); + }); + + test('static field values are extracted from value={{...}} (not None)', () => { + const reg: IRNode = { + type: 'class', + props: { name: 'Reg' }, + children: [ + { + type: 'field', + props: { name: 'kind', type: 'string', static: 'true', value: { __expr: true, code: '"audited"' } }, + children: [], + }, + ], + }; + const code = generatePythonClass(reg).join('\n'); + expect(code).toContain('kind: str = "audited"'); + expect(code).not.toContain('kind: str = None'); + }); + + test('derived class without a constructor forwards to base init, then applies defaults', () => { + const dog: IRNode = { + type: 'class', + props: { name: 'Dog', extends: 'Animal' }, + children: [ + { + type: 'field', + props: { name: 'tricks', type: 'object[]', value: { __expr: true, code: '[]' } }, + children: [], + }, + ], + }; + const code = generatePythonClass(dog).join('\n'); + expect(code).toContain('def __init__(self, *args, **kwargs):'); + expect(code).toContain('super().__init__(*args, **kwargs)'); + expect(code).toContain('self.tricks = []'); + expect(code.indexOf('super().__init__')).toBeLessThan(code.indexOf('self.tricks = []')); + }); + + test('field defaults run AFTER super() inside an explicit derived constructor', () => { + const dog: IRNode = { + type: 'class', + props: { name: 'Dog', extends: 'Animal' }, + children: [ + { + type: 'field', + props: { name: 'tricks', type: 'object[]', value: { __expr: true, code: '[]' } }, + children: [], + }, + { + type: 'constructor', + props: {}, + children: [param('name', 'string'), handler([{ type: 'do', props: { value: 'super(name)' }, children: [] }])], + }, + ], + }; + const code = generatePythonClass(dog).join('\n'); + expect(code).toContain('super().__init__(name)'); + expect(code.indexOf('super().__init__(name)')).toBeLessThan(code.indexOf('self.tricks = []')); + }); +}); diff --git a/scripts/class-conformance.mjs b/scripts/class-conformance.mjs new file mode 100644 index 00000000..36e93643 --- /dev/null +++ b/scripts/class-conformance.mjs @@ -0,0 +1,233 @@ +/** + * Class differential conformance — KERN single-source class parity. + * + * Each fixture is a self-contained KERN module: a class (or class hierarchy) + * plus a zero-arg `fn probe` that exercises it. The module is compiled through + * BOTH codegen paths (core -> TypeScript, python -> pure Python), each driver + * calls `probe()` and prints its JSON-normalized return, and we assert + * ts == python == expected. This proves class behavior is identical across + * 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). + * + * Run: node scripts/class-conformance.mjs (or via `pnpm check:class-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'); + +const FIXTURES = [ + { + name: 'construction + fields + method', + kern: `class name=Point export=true + field name=x type=number + field name=y type=number + constructor + param name=x type=number + param name=y type=number + handler + assign target="this.x" value="x" + assign target="this.y" value="y" + method name=sum returns=number + handler + return value="this.x + this.y" +fn name=probe returns=number + handler + return value="new Point(3, 4).sum()"`, + expected: 7, + }, + { + name: 'single inheritance + super constructor + super method', + kern: `class name=Animal export=true + field name=name type=string + constructor + param name=name type=string + handler + assign target="this.name" value="name" + method name=describe returns=string + handler + return value="\`\${this.name} is an animal\`" +class name=Dog extends=Animal export=true + constructor + param name=name type=string + handler + do value="super(name)" + method name=describe returns=string + handler + return value="\`\${super.describe()} (a dog)\`" +fn name=probe returns=string + handler + return value="new Dog(\\"Rex\\").describe()"`, + expected: 'Rex is an animal (a dog)', + }, + { + name: 'instance getter', + kern: `class name=Person export=true + field name=first type=string + field name=last type=string + constructor + param name=first type=string + param name=last type=string + handler + assign target="this.first" value="first" + assign target="this.last" value="last" + getter name=full returns=string + handler + return value="\`\${this.first} \${this.last}\`" +fn name=probe returns=string + handler + return value="new Person(\\"Ada\\", \\"Lovelace\\").full"`, + expected: 'Ada Lovelace', + }, + { + name: 'static method', + kern: `class name=MathBox export=true + method name=double static=true returns=number + param name=n type=number + handler + return value="n * 2" +fn name=probe returns=number + handler + return value="MathBox.double(21)"`, + expected: 42, + }, + { + name: 'instance field default (read, no constructor)', + kern: `class name=Config export=true + field name=mode type=string value={{ "dev" }} +fn name=probe returns=string + handler + return value="new Config().mode"`, + expected: 'dev', + }, + { + name: 'getter + setter + field default round-trip', + kern: `class name=Cell export=true + field name=v type=number value={{ 0 }} + getter name=value returns=number + handler + return value="this.v" + setter name=value + param name=next type=number + handler + assign target="this.v" value="next" +fn name=probe returns=number + handler + let name=c value="new Cell()" + assign target="c.value" value="9" + return value="c.value"`, + expected: 9, + }, + { + name: 'static accessor read + write round-trip', + kern: `class name=Counter export=true + field name=_count type=number static=true value={{ 0 }} + getter name=count static=true returns=number + handler + return value="this._count" + setter name=count static=true + param name=v type=number + handler + assign target="this._count" value="v" +fn name=probe returns=number + handler + assign target="Counter.count" value="Counter.count + 5" + assign target="Counter.count" value="Counter.count + 5" + return value="Counter.count"`, + expected: 10, + }, + { + name: 'inherited + overridden static accessor (metaclass chaining)', + kern: `class name=Base export=true + field name=_val type=number static=true value={{ 0 }} + getter name=val static=true returns=number + handler + return value="this._val" + setter name=val static=true + param name=v type=number + handler + assign target="this._val" value="v" +class name=Derived extends=Base export=true + getter name=val static=true returns=number + handler + return value="this._val * 2" + setter name=val static=true + param name=v type=number + handler + assign target="this._val" value="v + 1" +fn name=probe returns=number + handler + assign target="Derived.val" value="5" + return value="Derived.val"`, + expected: 12, + }, +]; + +const canon = (v) => JSON.stringify(v); + +const dir = mkdtempSync(join(tmpdir(), 'kern-class-conf-')); +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 root = parse(fx.kern); + // A single top-level decl parses as the node itself; multiple decls wrap in a root. + const topNodes = root.type === 'class' || root.type === 'fn' ? [root] : (root.children ?? []); + + // TypeScript module + 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, + ); + + // Python module + 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(`Class 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.');